feat: 🎸 Refactor session recording filters (#2981)
This commit has a few notable changes: 1. A simple attempt at establishing a pattern for using SELECT statements. This will be useful in the future for joins/subquery support. 2. Expanded usage of searching to be able to pass instead of just a string, an object that lets a consumer choose which columns to search on. 3. Decouple our session recording filters from being loaded from the route model hook which is a departure from our convention. This has the benefit of actually letting the page first load and having the filters load async so the page doesn't wait for the filters to finish. This is also much faster to query for each individual filter option from SQLite rather than returning all data to filter in application code.
This commit is contained in:
1 parent
72f7c8a92d
commit
fa18bfff01
18 files changed
+558
-250
No files matched your search
@@ -7,12 +7,20 @@ import Controller from '@ember/controller';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import { assert } from '@ember/debug';
|
||||
import { restartableTask } from 'ember-concurrency';
|
||||
|
||||
class FilterOptions {
|
||||
@tracked search;
|
||||
@tracked options = [];
|
||||
}
|
||||
|
||||
export default class ScopesScopeSessionRecordingsIndexController extends Controller {
|
||||
// =services
|
||||
|
||||
@service store;
|
||||
@service intl;
|
||||
@service db;
|
||||
|
||||
// =attributes
|
||||
|
||||
@@ -30,6 +38,10 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control
|
||||
|
||||
now = new Date();
|
||||
|
||||
userFilters = new FilterOptions();
|
||||
scopeFilters = new FilterOptions();
|
||||
targetFilters = new FilterOptions();
|
||||
|
||||
@tracked search;
|
||||
@tracked time = null;
|
||||
@tracked users = [];
|
||||
@@ -48,9 +60,9 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control
|
||||
return {
|
||||
allFilters: {
|
||||
time: this.timeOptions,
|
||||
users: this.filterOptions('user'),
|
||||
scopes: this.projectScopes,
|
||||
targets: this.filterOptions('target'),
|
||||
users: this.userFilters.options,
|
||||
scopes: this.scopeFilters.options,
|
||||
targets: this.targetFilters.options,
|
||||
},
|
||||
selectedFilters: {
|
||||
time: [this.time],
|
||||
@@ -95,29 +107,87 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns unique project scopes from targets
|
||||
* linked to the session recordings
|
||||
* Configurations for different filter options
|
||||
* @returns {[object]}
|
||||
*/
|
||||
get projectScopes() {
|
||||
const uniqueMap = new Map();
|
||||
this.model.allSessionRecordings.forEach(
|
||||
({
|
||||
create_time_values: {
|
||||
target: {
|
||||
scope: { id, name, parent_scope_id },
|
||||
},
|
||||
},
|
||||
}) => {
|
||||
if (!uniqueMap.has(id)) {
|
||||
const projectName = name || id;
|
||||
uniqueMap.set(id, { id, name: projectName, parent_scope_id });
|
||||
}
|
||||
get filterConfigs() {
|
||||
return {
|
||||
userFilters: {
|
||||
select: [
|
||||
{ field: 'user_id', isDistinct: true },
|
||||
{ field: 'user_name', isDistinct: true },
|
||||
],
|
||||
searchFields: ['user_id', 'user_name'],
|
||||
mapper: ({ user_id, user_name }) => ({
|
||||
id: user_id,
|
||||
name: user_name,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return Array.from(uniqueMap.values());
|
||||
scopeFilters: {
|
||||
select: [
|
||||
{ field: 'target_scope_id', isDistinct: true },
|
||||
{ field: 'target_scope_name', isDistinct: true },
|
||||
{ field: 'target_scope_parent_scope_id', isDistinct: true },
|
||||
],
|
||||
searchFields: [
|
||||
'target_scope_id',
|
||||
'target_scope_name',
|
||||
'target_scope_parent_scope_id',
|
||||
],
|
||||
mapper: ({
|
||||
target_scope_id,
|
||||
target_scope_name,
|
||||
target_scope_parent_scope_id,
|
||||
}) => ({
|
||||
id: target_scope_id,
|
||||
name: target_scope_name ?? target_scope_id,
|
||||
parent_scope_id: target_scope_parent_scope_id,
|
||||
}),
|
||||
},
|
||||
targetFilters: {
|
||||
select: [
|
||||
{ field: 'target_id', isDistinct: true },
|
||||
{ field: 'target_name', isDistinct: true },
|
||||
],
|
||||
searchFields: ['target_id', 'target_name'],
|
||||
mapper: ({ target_id, target_name }) => ({
|
||||
id: target_id,
|
||||
name: target_name,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic retrieve function for session recording options
|
||||
* @param {string} type - The type of options to retrieve (user, scope, target)
|
||||
* @param {string} search - Search term
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async retrieveFilterOptions(type, search) {
|
||||
const config = this.filterConfigs[type];
|
||||
assert(`Unknown filter type: ${type}`, config);
|
||||
|
||||
const results = await this.db.query('session-recording', {
|
||||
select: config.select,
|
||||
query: {
|
||||
search: { text: search, fields: config.searchFields },
|
||||
},
|
||||
page: 1,
|
||||
pageSize: 250,
|
||||
});
|
||||
|
||||
return results.map(config.mapper);
|
||||
}
|
||||
|
||||
loadItems = restartableTask(async () => {
|
||||
this.userFilters.options = await this.retrieveFilterOptions('userFilters');
|
||||
this.scopeFilters.options =
|
||||
await this.retrieveFilterOptions('scopeFilters');
|
||||
this.targetFilters.options =
|
||||
await this.retrieveFilterOptions('targetFilters');
|
||||
});
|
||||
|
||||
// =actions
|
||||
|
||||
/**
|
||||
@@ -131,28 +201,6 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control
|
||||
return org.displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all filter options for key for session recordings
|
||||
* @param {string} key
|
||||
* @returns {[object]}
|
||||
*/
|
||||
@action
|
||||
filterOptions(key) {
|
||||
const uniqueMap = new Map();
|
||||
this.model.allSessionRecordings.forEach(
|
||||
({
|
||||
create_time_values: {
|
||||
[key]: { id, name },
|
||||
},
|
||||
}) => {
|
||||
if (!uniqueMap.has(id)) {
|
||||
uniqueMap.set(id, { id, name });
|
||||
}
|
||||
},
|
||||
);
|
||||
return Array.from(uniqueMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles input on each keystroke and the search queryParam
|
||||
* @param {object} event
|
||||
@@ -192,7 +240,7 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control
|
||||
*/
|
||||
@action
|
||||
refresh() {
|
||||
this.send('refreshAll');
|
||||
this.router.refresh('scopes.scope.session-recordings');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,4 +254,9 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control
|
||||
this.sortDirection = sortOrder;
|
||||
this.page = 1;
|
||||
}
|
||||
|
||||
onFilterSearch = restartableTask(async (filter, value) => {
|
||||
this[filter].search = value;
|
||||
this[filter].options = await this.retrieveFilterOptions(filter, value);
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { restartableTask, timeout } from 'ember-concurrency';
|
||||
import {
|
||||
STATE_SESSION_RECORDING_STARTED,
|
||||
@@ -59,8 +58,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route {
|
||||
},
|
||||
};
|
||||
|
||||
allSessionRecordings;
|
||||
|
||||
/**
|
||||
* Load all session recordings.
|
||||
* @return {Promise<{ totalItems: number, sessionRecordings: [SessionRecordingModel], doSessionRecordingsExist: boolean, doStorageBucketsExist: boolean }>}
|
||||
@@ -144,10 +141,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route {
|
||||
queryOptions,
|
||||
);
|
||||
totalItems = sessionRecordings.meta?.totalItems;
|
||||
// Query all session recordings for filtering values if entering route for the first time
|
||||
if (!this.allSessionRecordings) {
|
||||
await this.getAllSessionRecordings(scope_id);
|
||||
}
|
||||
doSessionRecordingsExist = await this.getDoSessionRecordingsExist(
|
||||
scope_id,
|
||||
totalItems,
|
||||
@@ -157,7 +150,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route {
|
||||
return {
|
||||
sessionRecordings,
|
||||
doSessionRecordingsExist: doSessionRecordingsExist,
|
||||
allSessionRecordings: this.allSessionRecordings,
|
||||
totalItems,
|
||||
doStorageBucketsExist: doStorageBucketsExist,
|
||||
};
|
||||
@@ -165,22 +157,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route {
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets allSessionRecordings to all session recordings for filters
|
||||
* @param {string} scope_id
|
||||
*/
|
||||
async getAllSessionRecordings(scope_id) {
|
||||
const options = { pushToStore: false, peekDb: true };
|
||||
this.allSessionRecordings = await this.store.query(
|
||||
'session-recording',
|
||||
{
|
||||
scope_id,
|
||||
recursive: true,
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets doSessionRecordingsExist to true if there are any session recordings.
|
||||
* @param {string} scope_id
|
||||
@@ -228,14 +204,11 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route {
|
||||
// =actions
|
||||
|
||||
/**
|
||||
* refreshes all session recording route data.
|
||||
* Loads initial filter options in controller so it happens outside of model hook
|
||||
* @param controller
|
||||
*/
|
||||
@action
|
||||
async refreshAll() {
|
||||
const scope = this.modelFor('scopes.scope');
|
||||
|
||||
await this.getAllSessionRecordings(scope.id);
|
||||
|
||||
return super.refresh(...arguments);
|
||||
setupController(controller) {
|
||||
super.setupController(...arguments);
|
||||
controller.loadItems.perform();
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,16 @@
|
||||
<Dropdown
|
||||
name='user'
|
||||
@toggleText={{t 'resources.user.title'}}
|
||||
@itemOptions={{this.filterOptions 'user'}}
|
||||
@itemOptions={{this.userFilters.options}}
|
||||
@checkedItems={{this.users}}
|
||||
@applyFilter={{fn this.applyFilter 'users'}}
|
||||
@isSearchable={{true}}
|
||||
@searchTerm={{this.userFilters.search}}
|
||||
@updateSearchTerm={{fn this.onFilterSearch.perform 'userFilters'}}
|
||||
@isLoading={{or
|
||||
this.onFilterSearch.isRunning
|
||||
this.loadItems.isRunning
|
||||
}}
|
||||
as |FD selectItem itemOptions|
|
||||
>
|
||||
{{#each itemOptions as |itemOption|}}
|
||||
@@ -70,10 +76,19 @@
|
||||
<Dropdown
|
||||
name='scope'
|
||||
@toggleText={{t 'resources.scope.title'}}
|
||||
@itemOptions={{this.projectScopes}}
|
||||
@itemOptions={{this.scopeFilters.options}}
|
||||
@checkedItems={{this.scopes}}
|
||||
@applyFilter={{fn this.applyFilter 'scopes'}}
|
||||
@isSearchable={{true}}
|
||||
@searchTerm={{this.scopeFilters.search}}
|
||||
@updateSearchTerm={{fn
|
||||
this.onFilterSearch.perform
|
||||
'scopeFilters'
|
||||
}}
|
||||
@isLoading={{or
|
||||
this.onFilterSearch.isRunning
|
||||
this.loadItems.isRunning
|
||||
}}
|
||||
as |FD selectItem itemOptions|
|
||||
>
|
||||
{{#each-in
|
||||
@@ -100,10 +115,19 @@
|
||||
<Dropdown
|
||||
name='target'
|
||||
@toggleText={{t 'resources.target.title'}}
|
||||
@itemOptions={{this.filterOptions 'target'}}
|
||||
@itemOptions={{this.targetFilters.options}}
|
||||
@checkedItems={{this.targets}}
|
||||
@applyFilter={{fn this.applyFilter 'targets'}}
|
||||
@isSearchable={{true}}
|
||||
@searchTerm={{this.targetFilters.search}}
|
||||
@updateSearchTerm={{fn
|
||||
this.onFilterSearch.perform
|
||||
'targetFilters'
|
||||
}}
|
||||
@isLoading={{or
|
||||
this.onFilterSearch.isRunning
|
||||
this.loadItems.isRunning
|
||||
}}
|
||||
as |FD selectItem itemOptions|
|
||||
>
|
||||
{{#each itemOptions as |itemOption|}}
|
||||
|
||||
@@ -232,35 +232,13 @@ module('Acceptance | session-recordings | list', function (hooks) {
|
||||
assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 2 });
|
||||
|
||||
await click(commonSelectors.FILTER_DROPDOWN('user'));
|
||||
await waitFor(commonSelectors.FILTER_DROPDOWN_ITEM(instances.user.id));
|
||||
await click(commonSelectors.FILTER_DROPDOWN_ITEM(instances.user.id));
|
||||
await click(commonSelectors.FILTER_DROPDOWN_ITEM_APPLY_BTN('user'));
|
||||
|
||||
assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 1 });
|
||||
});
|
||||
|
||||
test('user can filter session recordings by scope', async function (assert) {
|
||||
setRunOptions({
|
||||
rules: {
|
||||
'color-contrast': {
|
||||
// [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await visit(urls.globalScope);
|
||||
|
||||
await click(commonSelectors.HREF(urls.sessionRecordings));
|
||||
|
||||
assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 2 });
|
||||
|
||||
await click(commonSelectors.FILTER_DROPDOWN('target'));
|
||||
await click(commonSelectors.FILTER_DROPDOWN_ITEM(instances.target.id));
|
||||
await click(commonSelectors.FILTER_DROPDOWN_ITEM_APPLY_BTN('target'));
|
||||
|
||||
assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 1 });
|
||||
});
|
||||
|
||||
test('user can filter session recordings by target', async function (assert) {
|
||||
setRunOptions({
|
||||
rules: {
|
||||
@@ -277,7 +255,34 @@ module('Acceptance | session-recordings | list', function (hooks) {
|
||||
|
||||
assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 2 });
|
||||
|
||||
await click(commonSelectors.FILTER_DROPDOWN('target'));
|
||||
await waitFor(commonSelectors.FILTER_DROPDOWN_ITEM(instances.target.id));
|
||||
await click(commonSelectors.FILTER_DROPDOWN_ITEM(instances.target.id));
|
||||
await click(commonSelectors.FILTER_DROPDOWN_ITEM_APPLY_BTN('target'));
|
||||
|
||||
assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 1 });
|
||||
});
|
||||
|
||||
test('user can filter session recordings by scope', async function (assert) {
|
||||
setRunOptions({
|
||||
rules: {
|
||||
'color-contrast': {
|
||||
// [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await visit(urls.globalScope);
|
||||
|
||||
await click(commonSelectors.HREF(urls.sessionRecordings));
|
||||
|
||||
assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 2 });
|
||||
|
||||
await click(commonSelectors.FILTER_DROPDOWN('scope'));
|
||||
await waitFor(
|
||||
commonSelectors.FILTER_DROPDOWN_ITEM(instances.target.scope.id),
|
||||
);
|
||||
await click(
|
||||
commonSelectors.FILTER_DROPDOWN_ITEM(instances.target.scope.id),
|
||||
);
|
||||
|
||||
@@ -93,44 +93,6 @@ module(
|
||||
date.restore();
|
||||
});
|
||||
|
||||
test('it exists', function (assert) {
|
||||
assert.ok(controller);
|
||||
});
|
||||
|
||||
test('filters returns expected entries', function (assert) {
|
||||
assert.deepEqual(controller.filters.allFilters, {
|
||||
time: [
|
||||
{
|
||||
id: last24Hours.toISOString(),
|
||||
name: 'Last 24 hours',
|
||||
},
|
||||
{
|
||||
id: last3Days.toISOString(),
|
||||
name: 'Last 3 days',
|
||||
},
|
||||
{
|
||||
id: last7Days.toISOString(),
|
||||
name: 'Last 7 days',
|
||||
},
|
||||
],
|
||||
users: [{ id: instances.user.id, name: instances.user.name }],
|
||||
scopes: [
|
||||
{
|
||||
id: instances.scopes.project.id,
|
||||
name: instances.scopes.project.name,
|
||||
parent_scope_id: instances.scopes.project.parent_scope_id,
|
||||
},
|
||||
],
|
||||
targets: [{ id: instances.target.id, name: instances.target.name }],
|
||||
});
|
||||
assert.deepEqual(controller.filters.selectedFilters, {
|
||||
time: [null],
|
||||
users: [],
|
||||
scopes: [],
|
||||
targets: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('timeOptions returns expected filter options', function (assert) {
|
||||
assert.deepEqual(controller.timeOptions, [
|
||||
{
|
||||
@@ -148,16 +110,6 @@ module(
|
||||
]);
|
||||
});
|
||||
|
||||
test('projectScopes returns an array of unique projects', function (assert) {
|
||||
assert.deepEqual(controller.projectScopes, [
|
||||
{
|
||||
id: instances.scopes.project.id,
|
||||
name: instances.scopes.project.name,
|
||||
parent_scope_id: instances.scopes.project.parent_scope_id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('handleSearchInput action sets expected values correctly', async function (assert) {
|
||||
// Date mock in beforeEach is causing waitUntil to fail so I restore it
|
||||
// back before running this test
|
||||
@@ -186,21 +138,5 @@ module(
|
||||
assert.strictEqual(controller.page, 1);
|
||||
assert.deepEqual(controller.time, last24Hours.toISOString());
|
||||
});
|
||||
|
||||
test('refresh action calls refreshAll', async function (assert) {
|
||||
assert.expect(2);
|
||||
controller.set('target', {
|
||||
send(actionName, ...args) {
|
||||
assert.strictEqual(actionName, 'refreshAll');
|
||||
assert.deepEqual(
|
||||
args,
|
||||
[],
|
||||
'refreshAll was called with the correct arguments',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user