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
@@ -49,7 +49,13 @@ export default class SqliteHandler {
|
||||
const schema = store.modelFor(type);
|
||||
const serializer = store.serializerFor(type);
|
||||
|
||||
let { page, pageSize, query: queryObj, ...remainingQuery } = query;
|
||||
let {
|
||||
page,
|
||||
pageSize,
|
||||
select,
|
||||
query: queryObj,
|
||||
...remainingQuery
|
||||
} = query;
|
||||
let payload,
|
||||
listToken,
|
||||
writeToDbPromise,
|
||||
@@ -119,7 +125,7 @@ export default class SqliteHandler {
|
||||
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
|
||||
page,
|
||||
pageSize,
|
||||
select: ['data'],
|
||||
select: select ?? [{ field: 'data' }],
|
||||
});
|
||||
|
||||
const rows = await this.sqlite.fetchResource({
|
||||
@@ -129,7 +135,7 @@ export default class SqliteHandler {
|
||||
|
||||
const { sql: countSql, parameters: countParams } =
|
||||
generateSQLExpressions(type, queryObj, {
|
||||
select: ['count(*) as total'],
|
||||
select: [{ field: '*', isCount: true, alias: 'total' }],
|
||||
});
|
||||
const count = await this.sqlite.fetchResource({
|
||||
sql: countSql,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service, { service } from '@ember/service';
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { assert } from '@ember/debug';
|
||||
import { generateSQLExpressions } from '../utils/sqlite-query';
|
||||
|
||||
/**
|
||||
* Database service for querying Boundary resources from storage.
|
||||
* Provides a high-level interface for executing queries against the local database.
|
||||
*/
|
||||
export default class DbService extends Service {
|
||||
@service sqlite;
|
||||
|
||||
/**
|
||||
* Query resources from the database by type.
|
||||
*
|
||||
* @param {string} type - The resource type (e.g., 'scope', 'user', 'target').
|
||||
* Must be a supported model type defined in modelMapping.
|
||||
* @param {Object} query - Query configuration object
|
||||
* @returns {Promise<Object>} Promise resolving to query results from sqlite service
|
||||
* @throws {Error} Assertion error if resource type is not supported
|
||||
*/
|
||||
query(type, query) {
|
||||
const supportedModels = Object.keys(modelMapping);
|
||||
assert('Resource type is not supported.', supportedModels.includes(type));
|
||||
|
||||
let { page, pageSize, select, query: queryObj } = query;
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
|
||||
page,
|
||||
pageSize,
|
||||
select,
|
||||
});
|
||||
|
||||
return this.sqlite.fetchResource({
|
||||
sql,
|
||||
parameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,8 @@ export const modelMapping = {
|
||||
target_name: 'create_time_values.target.name',
|
||||
target_scope_id: 'create_time_values.target.scope.id',
|
||||
target_scope_name: 'create_time_values.target.scope.name',
|
||||
target_scope_parent_scope_id:
|
||||
'create_time_values.target.scope.parent_scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
session: {
|
||||
@@ -114,21 +116,6 @@ export const modelMapping = {
|
||||
},
|
||||
};
|
||||
|
||||
// A list of tables that we support searching using FTS5 in SQLite.
|
||||
export const searchTables = new Set([
|
||||
'target',
|
||||
'alias',
|
||||
'group',
|
||||
'role',
|
||||
'user',
|
||||
'credential-store',
|
||||
'scope',
|
||||
'auth-method',
|
||||
'host-catalog',
|
||||
'session-recording',
|
||||
'session',
|
||||
]);
|
||||
|
||||
export default class SqliteDbService extends Service {
|
||||
// =attributes
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { searchTables } from 'api/services/sqlite';
|
||||
import { typeOf } from '@ember/utils';
|
||||
import { underscore } from '@ember/string';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
/**
|
||||
* Takes a POJO representing a filter query and builds a SQL query.
|
||||
@@ -35,8 +35,8 @@ export function generateSQLExpressions(
|
||||
addFilterConditions({ filters, parameters, conditions });
|
||||
addSearchConditions({ search, resource, tableName, parameters, conditions });
|
||||
|
||||
const selectClause = constructSelectClause(select, tableName);
|
||||
const orderByClause = constructOrderByClause(resource, sort);
|
||||
|
||||
const whereClause = conditions.length ? `WHERE ${and(conditions)}` : '';
|
||||
|
||||
const paginationClause = page && pageSize ? `LIMIT ? OFFSET ?` : '';
|
||||
@@ -44,8 +44,6 @@ export function generateSQLExpressions(
|
||||
parameters.push(pageSize, (page - 1) * pageSize);
|
||||
}
|
||||
|
||||
const selectClause = `SELECT ${select ? select.join(', ') : '*'} FROM "${tableName}"`;
|
||||
|
||||
return {
|
||||
// Replace any empty newlines or leading whitespace on each line to be consistent with formatting
|
||||
// This is mainly to help us read and test the generated SQL as it has no effect on the actual SQL execution.
|
||||
@@ -142,32 +140,83 @@ function addSearchConditions({
|
||||
parameters,
|
||||
conditions,
|
||||
}) {
|
||||
if (!search) {
|
||||
if (!search || !modelMapping[resource]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchTables.has(resource)) {
|
||||
// Use the special prefix indicator "*" for full-text search
|
||||
parameters.push(`"${search}"*`);
|
||||
// Use a subquery to match against the FTS table with rowids as SQLite is
|
||||
// much more efficient with FTS queries when using rowids or MATCH (or both).
|
||||
// We could have also used a join here but a subquery is simpler.
|
||||
conditions.push(
|
||||
`rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
|
||||
// Use the special prefix indicator "*" for full-text search
|
||||
if (typeOf(search) === 'object') {
|
||||
if (!search?.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
parameters.push(
|
||||
or(search.fields.map((field) => `${field}:"${search.text}"*`)),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
parameters.push(`"${search}"*`);
|
||||
}
|
||||
|
||||
const fields = Object.keys(modelMapping[resource]);
|
||||
const searchConditions = parenthetical(
|
||||
or(
|
||||
fields.map((field) => {
|
||||
parameters.push(`%${search}%`);
|
||||
return `${field}${OPERATORS['contains']}`;
|
||||
}),
|
||||
),
|
||||
// Use a subquery to match against the FTS table with rowids as SQLite is
|
||||
// much more efficient with FTS queries when using rowids or MATCH (or both).
|
||||
// We could have also used a join here but a subquery is simpler.
|
||||
conditions.push(
|
||||
`rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
|
||||
);
|
||||
conditions.push(searchConditions);
|
||||
}
|
||||
|
||||
function constructSelectClause(select = [{ field: '*' }], tableName) {
|
||||
const distinctColumns = select.filter(({ isDistinct }) => isDistinct);
|
||||
const nonDistinctColumns = select.filter(({ isDistinct }) => !isDistinct);
|
||||
|
||||
const buildColumnExpression = ({ field, isCount, alias, isDistinct }) => {
|
||||
let column = field;
|
||||
|
||||
// If there's just distinct with nothing else, this will be handled separately
|
||||
// and we will just return the column back as is
|
||||
if (isCount && isDistinct) {
|
||||
column = `count(DISTINCT ${column})`;
|
||||
} else if (isCount) {
|
||||
column = `count(${column})`;
|
||||
}
|
||||
|
||||
if (alias) {
|
||||
column = `${column} as ${alias}`;
|
||||
}
|
||||
|
||||
return column;
|
||||
};
|
||||
|
||||
let selectColumns;
|
||||
|
||||
if (distinctColumns.length > 0) {
|
||||
// Check if any columns also have COUNT (or other aggregate function in the future)
|
||||
const hasAggregateDistinct = distinctColumns.some((col) => col.isCount);
|
||||
|
||||
if (hasAggregateDistinct) {
|
||||
selectColumns = distinctColumns.map(buildColumnExpression).join(', ');
|
||||
|
||||
// Add back in the non distinct columns
|
||||
if (nonDistinctColumns.length > 0) {
|
||||
const regularPart = nonDistinctColumns
|
||||
.map(buildColumnExpression)
|
||||
.join(', ');
|
||||
selectColumns = `${selectColumns}, ${regularPart}`;
|
||||
}
|
||||
} else {
|
||||
assert(
|
||||
'Can not combine non-distincts with multi column distincts',
|
||||
nonDistinctColumns.length === 0,
|
||||
);
|
||||
|
||||
// Only do multi column DISTINCT with no aggregate functions
|
||||
selectColumns = `DISTINCT ${distinctColumns.map(buildColumnExpression).join(', ')}`;
|
||||
}
|
||||
} else {
|
||||
selectColumns = select.map(buildColumnExpression).join(', ');
|
||||
}
|
||||
|
||||
return `SELECT ${selectColumns} FROM "${tableName}"`;
|
||||
}
|
||||
|
||||
function constructOrderByClause(resource, sort) {
|
||||
|
||||
@@ -352,6 +352,7 @@ CREATE TABLE IF NOT EXISTS session_recording (
|
||||
target_name TEXT,
|
||||
target_scope_id TEXT,
|
||||
target_scope_name TEXT,
|
||||
target_scope_parent_scope_id TEXT,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
@@ -371,21 +372,22 @@ CREATE VIRTUAL TABLE IF NOT EXISTS session_recording_fts USING fts5(
|
||||
target_name,
|
||||
target_scope_id,
|
||||
target_scope_name,
|
||||
target_scope_parent_scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_recording_ai AFTER INSERT ON session_recording BEGIN
|
||||
INSERT INTO session_recording_fts(
|
||||
rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, created_time
|
||||
rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, target_scope_parent_scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.state, new.start_time, new.end_time, new.duration, new.scope_id, new.user_id, new.user_name, new.target_id, new.target_name, new.target_scope_id, new.target_scope_name, new.created_time
|
||||
new.rowid, new.id, new.type, new.state, new.start_time, new.end_time, new.duration, new.scope_id, new.user_id, new.user_name, new.target_id, new.target_name, new.target_scope_id, new.target_scope_name, new.target_scope_parent_scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_recording_ad AFTER DELETE ON session_recording BEGIN
|
||||
INSERT INTO session_recording_fts(session_recording_fts, rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.state, old.start_time, old.end_time, old.duration, old.scope_id, old.user_id, old.user_name, old.target_id, old.target_name, old.target_scope_id, old.target_scope_name, old.created_time);
|
||||
INSERT INTO session_recording_fts(session_recording_fts, rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, target_scope_parent_scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.state, old.start_time, old.end_time, old.duration, old.scope_id, old.user_id, old.user_name, old.target_id, old.target_name, old.target_scope_id, old.target_scope_name, old.target_scope_parent_scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createSessionTables = `
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'api/services/db';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'dummy/tests/helpers';
|
||||
|
||||
module('Unit | Service | db', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('query throws assertion error for unsupported resource type', function (assert) {
|
||||
let service = this.owner.lookup('service:db');
|
||||
|
||||
assert.throws(() => {
|
||||
service.query('unsupported-type', { query: {} });
|
||||
}, /Resource type is not supported/);
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,10 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'dummy/tests/helpers';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { modelMapping, searchTables } from 'api/services/sqlite';
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { underscore } from '@ember/string';
|
||||
|
||||
const supportedModels = Object.keys(modelMapping);
|
||||
const supportedFtsTables = [...searchTables];
|
||||
|
||||
module('Unit | Service | sqlite', function (hooks) {
|
||||
setupTest(hooks);
|
||||
@@ -38,7 +37,7 @@ module('Unit | Service | sqlite', function (hooks) {
|
||||
|
||||
test.each(
|
||||
'Mapping matches fts table columns',
|
||||
supportedFtsTables,
|
||||
supportedModels,
|
||||
async function (assert, resource) {
|
||||
const service = this.owner.lookup('service:sqlite');
|
||||
|
||||
|
||||
@@ -20,21 +20,6 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
delete String.prototype.removeExtraWhiteSpace;
|
||||
});
|
||||
|
||||
// TODO: Add a normal LIKE search when we add a resource that uses it
|
||||
test('it generates search correctly with FTS5', function (assert) {
|
||||
const query = { search: 'favorite' };
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
WHERE rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, ['"favorite"*']);
|
||||
});
|
||||
|
||||
test('it grabs resources not in the model mapping', function (assert) {
|
||||
const query = {
|
||||
filters: { id: [{ equals: 'tokenKey' }] },
|
||||
@@ -52,7 +37,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
|
||||
test('it executes count queries correctly', function (assert) {
|
||||
const select = {
|
||||
select: ['count(*) as total'],
|
||||
select: [{ field: '*', isCount: true, alias: 'total' }],
|
||||
};
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', {}, select);
|
||||
@@ -65,6 +50,123 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
assert.deepEqual(parameters, []);
|
||||
});
|
||||
|
||||
test.each(
|
||||
'it generates select clause with various options',
|
||||
{
|
||||
'single field': {
|
||||
select: [{ field: 'name' }],
|
||||
expectedSelect: 'name',
|
||||
},
|
||||
'multiple fields': {
|
||||
select: [{ field: 'name' }, { field: 'type' }, { field: 'id' }],
|
||||
expectedSelect: 'name, type, id',
|
||||
},
|
||||
'field with alias': {
|
||||
select: [{ field: 'name', alias: 'resource_name' }],
|
||||
expectedSelect: 'name as resource_name',
|
||||
},
|
||||
'multiple fields with aliases': {
|
||||
select: [
|
||||
{ field: 'name', alias: 'resource_name' },
|
||||
{ field: 'type', alias: 'resource_type' },
|
||||
],
|
||||
expectedSelect: 'name as resource_name, type as resource_type',
|
||||
},
|
||||
'count with alias': {
|
||||
select: [{ field: 'id', isCount: true, alias: 'total_count' }],
|
||||
expectedSelect: 'count(id) as total_count',
|
||||
},
|
||||
'count without alias': {
|
||||
select: [{ field: '*', isCount: true }],
|
||||
expectedSelect: 'count(*)',
|
||||
},
|
||||
'count distinct': {
|
||||
select: [{ field: 'status', isCount: true, isDistinct: true }],
|
||||
expectedSelect: 'count(DISTINCT status)',
|
||||
},
|
||||
'count distinct with alias': {
|
||||
select: [
|
||||
{
|
||||
field: 'type',
|
||||
isCount: true,
|
||||
isDistinct: true,
|
||||
alias: 'unique_types',
|
||||
},
|
||||
],
|
||||
expectedSelect: 'count(DISTINCT type) as unique_types',
|
||||
},
|
||||
'mixed regular and count fields': {
|
||||
select: [
|
||||
{ field: 'name' },
|
||||
{ field: 'id', isCount: true, alias: 'count' },
|
||||
],
|
||||
expectedSelect: 'name, count(id) as count',
|
||||
},
|
||||
'field with distinct': {
|
||||
select: [{ field: 'type', isDistinct: true }],
|
||||
expectedSelect: 'DISTINCT type',
|
||||
},
|
||||
'multiple distinct fields': {
|
||||
select: [
|
||||
{ field: 'type', isDistinct: true },
|
||||
{ field: 'status', isDistinct: true },
|
||||
],
|
||||
expectedSelect: 'DISTINCT type, status',
|
||||
},
|
||||
'multiple distinct with count fields': {
|
||||
select: [
|
||||
{ field: 'type', isDistinct: true, isCount: true },
|
||||
{ field: 'status', isDistinct: true, isCount: true },
|
||||
],
|
||||
expectedSelect: 'count(DISTINCT type), count(DISTINCT status)',
|
||||
},
|
||||
'distinct with alias': {
|
||||
select: [{ field: 'type', isDistinct: true, alias: 'unique_type' }],
|
||||
expectedSelect: 'DISTINCT type as unique_type',
|
||||
},
|
||||
'multiple distinct fields with aliases': {
|
||||
select: [
|
||||
{ field: 'type', isDistinct: true, alias: 'unique_type' },
|
||||
{ field: 'status', isDistinct: true, alias: 'unique_status' },
|
||||
],
|
||||
expectedSelect: 'DISTINCT type as unique_type, status as unique_status',
|
||||
},
|
||||
'count distinct multiple fields': {
|
||||
select: [
|
||||
{
|
||||
field: 'type',
|
||||
isCount: true,
|
||||
isDistinct: true,
|
||||
alias: 'type_count',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
isCount: true,
|
||||
isDistinct: true,
|
||||
alias: 'status_count',
|
||||
},
|
||||
],
|
||||
expectedSelect:
|
||||
'count(DISTINCT type) as type_count, count(DISTINCT status) as status_count',
|
||||
},
|
||||
},
|
||||
function (assert, { select, expectedSelect }) {
|
||||
const { sql, parameters } = generateSQLExpressions(
|
||||
'target',
|
||||
{},
|
||||
{ select },
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT ${expectedSelect} FROM "target"
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, []);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'it generates filters correctly',
|
||||
{
|
||||
@@ -283,6 +385,31 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
]);
|
||||
});
|
||||
|
||||
test('it generates FTS5 search with object parameter', function (assert) {
|
||||
const query = {
|
||||
search: {
|
||||
text: 'favorite',
|
||||
fields: ['name', 'description'],
|
||||
},
|
||||
filters: {
|
||||
type: [{ equals: 'ssh' }],
|
||||
},
|
||||
};
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
WHERE (type = ?) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, [
|
||||
'ssh',
|
||||
'name:"favorite"* OR description:"favorite"*',
|
||||
]);
|
||||
});
|
||||
|
||||
test('it generates SQL with all clauses combined', function (assert) {
|
||||
const query = {
|
||||
search: 'favorite',
|
||||
@@ -300,7 +427,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
const { sql, parameters } = generateSQLExpressions('target', query, {
|
||||
page: 2,
|
||||
pageSize: 15,
|
||||
select: ['data'],
|
||||
select: [{ field: 'data' }],
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
@@ -323,6 +450,35 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
]);
|
||||
});
|
||||
|
||||
test.each(
|
||||
'it throws assertion error for invalid select combinations',
|
||||
{
|
||||
'mixing distinct with non-distinct columns': {
|
||||
select: [
|
||||
{ field: 'type', isDistinct: true },
|
||||
{ field: 'status', isDistinct: true },
|
||||
{ field: 'name' },
|
||||
],
|
||||
expectedError:
|
||||
/Can not combine non-distincts with multi column distincts/,
|
||||
},
|
||||
'mixing distinct with aggregate non-distinct columns': {
|
||||
select: [
|
||||
{ field: 'type', isDistinct: true },
|
||||
{ field: 'status', isDistinct: true },
|
||||
{ field: 'name', isCount: true },
|
||||
],
|
||||
expectedError:
|
||||
/Can not combine non-distincts with multi column distincts/,
|
||||
},
|
||||
},
|
||||
function (assert, { select, expectedError }) {
|
||||
assert.throws(() => {
|
||||
generateSQLExpressions('target', {}, { select });
|
||||
}, expectedError);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'it handles empty and invalid fields',
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<DD.Header @hasDivider={{true}}>
|
||||
<Hds::Form::TextInput::Base
|
||||
@type='search'
|
||||
@value={{this.searchTerm}}
|
||||
@value={{or this.searchTerm @searchTerm}}
|
||||
placeholder={{t 'actions.narrow-results'}}
|
||||
aria-label={{t 'actions.narrow-results'}}
|
||||
{{on 'input' this.filterItems}}
|
||||
@@ -28,11 +28,18 @@
|
||||
/>
|
||||
</DD.Header>
|
||||
{{/if}}
|
||||
{{#unless this.itemOptions}}
|
||||
|
||||
{{#unless (or this.itemOptions @isLoading)}}
|
||||
<DD.Description @text={{t 'titles.no-results'}} />
|
||||
{{/unless}}
|
||||
|
||||
{{#if (has-block)}}
|
||||
{{#if @isLoading}}
|
||||
<DD.Generic>
|
||||
<Hds::Layout::Flex @align='center' @justify='center'>
|
||||
<Hds::Icon @name='loading' @size='24' class='margin-y-xs' />
|
||||
</Hds::Layout::Flex>
|
||||
</DD.Generic>
|
||||
{{else if (has-block)}}
|
||||
{{yield DD this.selectItem this.itemOptions}}
|
||||
{{else}}
|
||||
{{#each this.itemOptions as |itemOption|}}
|
||||
@@ -47,13 +54,15 @@
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
<DD.Footer @hasDivider={{true}}>
|
||||
<Hds::Button
|
||||
@text='Apply'
|
||||
@isFullWidth={{true}}
|
||||
@size='small'
|
||||
{{on 'click' (fn this.applyFilter DD.close)}}
|
||||
data-test-dropdown-apply-button
|
||||
/>
|
||||
</DD.Footer>
|
||||
{{#unless @isLoading}}
|
||||
<DD.Footer @hasDivider={{true}}>
|
||||
<Hds::Button
|
||||
@text='Apply'
|
||||
@isFullWidth={{true}}
|
||||
@size='small'
|
||||
{{on 'click' (fn this.applyFilter DD.close)}}
|
||||
data-test-dropdown-apply-button
|
||||
/>
|
||||
</DD.Footer>
|
||||
{{/unless}}
|
||||
</Hds::Dropdown>
|
||||
@@ -21,7 +21,8 @@ export default class DropdownComponent extends Component {
|
||||
*/
|
||||
get itemOptions() {
|
||||
let items = this.args.itemOptions;
|
||||
if (this.searchTerm) {
|
||||
|
||||
if (this.searchTerm && !this.args.updateSearchTerm) {
|
||||
const searchTerm = this.searchTerm.toLowerCase();
|
||||
items = this.args.itemOptions.filter((item) => {
|
||||
const isNameMatch = item.name?.toLowerCase().includes(searchTerm);
|
||||
@@ -29,7 +30,7 @@ export default class DropdownComponent extends Component {
|
||||
return isNameMatch || isIdMatch;
|
||||
});
|
||||
}
|
||||
return items.slice(0, 500);
|
||||
return items.slice(0, 250);
|
||||
}
|
||||
|
||||
// =methods
|
||||
@@ -39,10 +40,15 @@ export default class DropdownComponent extends Component {
|
||||
* @param {object} event
|
||||
*/
|
||||
@action
|
||||
@debounce(150)
|
||||
filterItems(event) {
|
||||
@debounce(250)
|
||||
async filterItems(event) {
|
||||
const { value } = event.target;
|
||||
this.searchTerm = value;
|
||||
|
||||
if (this.args.updateSearchTerm) {
|
||||
this.args.updateSearchTerm(value);
|
||||
} else {
|
||||
this.searchTerm = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
@use 'rose/app/styles/rose/variables/sizing' as sizing;
|
||||
@use 'sass:meta';
|
||||
|
||||
.filters-applied {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -72,3 +75,34 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
$margin-directions: (
|
||||
'': 'margin',
|
||||
'-top': 'margin-top',
|
||||
'-right': 'margin-right',
|
||||
'-bottom': 'margin-bottom',
|
||||
'-left': 'margin-left',
|
||||
'-x': (
|
||||
'margin-left',
|
||||
'margin-right',
|
||||
),
|
||||
'-y': (
|
||||
'margin-top',
|
||||
'margin-bottom',
|
||||
),
|
||||
);
|
||||
|
||||
// Generate margin utility classes with sizes and directions
|
||||
@each $size-name, $size-coefficient in sizing.$size-coefficients {
|
||||
@each $direction-suffix, $properties in $margin-directions {
|
||||
.margin#{$direction-suffix}-#{$size-name} {
|
||||
@if meta.type-of($properties) == 'list' {
|
||||
@each $property in $properties {
|
||||
#{$property}: #{sizing.rems($size-name)};
|
||||
}
|
||||
} @else {
|
||||
#{$properties}: #{sizing.rems($size-name)};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ module('Integration | Component | dropdown/index', function (hooks) {
|
||||
assert.true(result);
|
||||
});
|
||||
|
||||
test('it only renders a max of 500 items but allows searching on all items', async function (assert) {
|
||||
test('it only renders a max of 250 items but allows searching on all items', async function (assert) {
|
||||
this.set('checkedItems', []);
|
||||
this.set(
|
||||
'itemOptions',
|
||||
@@ -145,7 +145,7 @@ module('Integration | Component | dropdown/index', function (hooks) {
|
||||
|
||||
await click(TOGGLE_DROPDOWN_SELECTOR);
|
||||
|
||||
assert.dom(ITEM_SELECTOR).exists({ count: 500 });
|
||||
assert.dom(ITEM_SELECTOR).exists({ count: 250 });
|
||||
|
||||
// Random number between 500 and 599 to search
|
||||
await fillIn(SEARCH_INPUT_SELECTOR, '591');
|
||||
|
||||
@@ -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