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:
Zhihe Li authored and GitHub committed 2025-10-31 16:58:05 -04:00
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();
});
},
);