From 7d4854d549681758621e3aed1fd161eb47b0018c Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:41:15 -0700 Subject: [PATCH] UI: Build dropdown filter toolbar (#31475) * build filter toolbar component * delete unused controllers * rename enum and move to client count utils * wire up filters to route query params * update test coverage * add support for appliedFilters from parent * update type of ns param * move lists to client-list page component --- ui/app/components/clients/activity.ts | 20 +- ui/app/components/clients/filter-toolbar.hbs | 76 +++++++ ui/app/components/clients/filter-toolbar.ts | 69 ++++++ .../components/clients/page/client-list.hbs | 34 +-- ui/app/components/clients/page/client-list.ts | 43 ++-- ui/app/components/clients/page/counts.ts | 8 +- .../vault/cluster/clients/counts.ts | 17 +- .../vault/cluster/clients/counts/acme.ts | 13 -- .../cluster/clients/counts/client-list.ts | 12 ++ .../vault/cluster/clients/counts/overview.ts | 4 +- .../vault/cluster/clients/counts/sync.ts | 14 -- .../vault/cluster/clients/counts/token.ts | 14 -- ui/app/routes/vault/cluster/clients/counts.ts | 5 +- .../cluster/clients/counts/client-list.hbs | 10 +- ui/lib/core/addon/utils/client-count-utils.ts | 14 +- .../clients/counts/client-list-test.js | 54 ++++- .../helpers/clients/client-count-helpers.js | 15 ++ .../helpers/clients/client-count-selectors.ts | 8 + .../components/clients/filter-toolbar-test.js | 203 ++++++++++++++++++ .../components/clients/page/counts-test.js | 2 +- .../utils/client-count-utils-test.js | 12 +- 21 files changed, 521 insertions(+), 126 deletions(-) create mode 100644 ui/app/components/clients/filter-toolbar.hbs create mode 100644 ui/app/components/clients/filter-toolbar.ts delete mode 100644 ui/app/controllers/vault/cluster/clients/counts/acme.ts create mode 100644 ui/app/controllers/vault/cluster/clients/counts/client-list.ts delete mode 100644 ui/app/controllers/vault/cluster/clients/counts/sync.ts delete mode 100644 ui/app/controllers/vault/cluster/clients/counts/token.ts create mode 100644 ui/tests/integration/components/clients/filter-toolbar-test.js diff --git a/ui/app/components/clients/activity.ts b/ui/app/components/clients/activity.ts index 47f12053b4..c2aa1c4951 100644 --- a/ui/app/components/clients/activity.ts +++ b/ui/app/components/clients/activity.ts @@ -9,7 +9,6 @@ import Component from '@glimmer/component'; import { isSameMonth } from 'date-fns'; import { parseAPITimestamp } from 'core/utils/date-formatters'; -import { calculateAverage } from 'vault/utils/chart-helpers'; import { filterByMonthDataForMount, filteredTotalForMount, @@ -20,13 +19,7 @@ import { sanitizePath } from 'core/utils/sanitize-path'; import type ClientsActivityModel from 'vault/models/clients/activity'; import type ClientsVersionHistoryModel from 'vault/models/clients/version-history'; -import type { - ByMonthNewClients, - MountNewClients, - NamespaceByKey, - NamespaceNewClients, - TotalClients, -} from 'core/utils/client-count-utils'; +import type { TotalClients } from 'core/utils/client-count-utils'; import type NamespaceService from 'vault/services/namespace'; interface Args { @@ -36,20 +29,13 @@ interface Args { endTimestamp: string; namespace: string; mountPath: string; + mountType: string; + onFilterChange: CallableFunction; } export default class ClientsActivityComponent extends Component { @service declare readonly namespace: NamespaceService; - average = ( - data: - | (ByMonthNewClients | NamespaceNewClients | MountNewClients | undefined)[] - | (NamespaceByKey | undefined)[], - key: string - ) => { - return calculateAverage(data, key); - }; - // path of the filtered namespace OR current one, for filtering relevant data get namespacePathForFilter() { const { namespace } = this.args; diff --git a/ui/app/components/clients/filter-toolbar.hbs b/ui/app/components/clients/filter-toolbar.hbs new file mode 100644 index 0000000000..8b0006d2fc --- /dev/null +++ b/ui/app/components/clients/filter-toolbar.hbs @@ -0,0 +1,76 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + {{#if @namespaces}} + + + {{#each @namespaces as |namespace|}} + {{namespace}} + {{/each}} + + {{/if}} + {{#if @mountPaths}} + + + {{#each @mountPaths as |mountPath|}} + {{mountPath}} + {{/each}} + + {{/if}} + {{#if @mountTypes}} + + + {{#each @mountTypes as |mountType|}} + {{mountType}} + {{/each}} + + {{/if}} + + + + + {{#if this.anyFilters}} + Filters applied: + {{! render tags based on applied @filters and not the internally tracked properties }} + {{#each-in @appliedFilters as |filter value|}} + {{#if (and (this.supportedFilter filter) value)}} +
+ +
+ {{/if}} + {{/each-in}} + + {{/if}} +
\ No newline at end of file diff --git a/ui/app/components/clients/filter-toolbar.ts b/ui/app/components/clients/filter-toolbar.ts new file mode 100644 index 0000000000..a495c65e6b --- /dev/null +++ b/ui/app/components/clients/filter-toolbar.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { ClientFilters, ClientFilterTypes } from 'core/utils/client-count-utils'; + +interface Args { + onFilter: CallableFunction; + appliedFilters: Record; +} + +export default class ClientsFilterToolbar extends Component { + filterTypes = ClientFilters; + + @tracked nsLabel = ''; + @tracked mountPath = ''; + @tracked mountType = ''; + + constructor(owner: unknown, args: Args) { + super(owner, args); + const { nsLabel, mountPath, mountType } = this.args.appliedFilters; + this.nsLabel = nsLabel; + this.mountPath = mountPath; + this.mountType = mountType; + } + + get anyFilters() { + return ( + Object.keys(this.args.appliedFilters).every((f) => this.supportedFilter(f)) && + Object.values(this.args.appliedFilters).some((v) => !!v) + ); + } + + @action + updateFilter(prop: ClientFilterTypes, value: string, close: CallableFunction) { + this[prop] = value; + close(); + } + + @action + clearFilters(filterKey: ClientFilterTypes | '') { + if (filterKey) { + this[filterKey] = ''; + } else { + this.nsLabel = ''; + this.mountPath = ''; + this.mountType = ''; + } + // Fire callback so URL query params update when filters are cleared + this.applyFilters(); + } + + @action + applyFilters() { + this.args.onFilter({ + nsLabel: this.nsLabel, + mountPath: this.mountPath, + mountType: this.mountType, + }); + } + + // Helper function + supportedFilter = (f: string): f is ClientFilterTypes => + Object.values(this.filterTypes).includes(f as ClientFilterTypes); +} diff --git a/ui/app/components/clients/page/client-list.hbs b/ui/app/components/clients/page/client-list.hbs index 9ff1c40113..c2baf47a48 100644 --- a/ui/app/components/clients/page/client-list.hbs +++ b/ui/app/components/clients/page/client-list.hbs @@ -5,33 +5,13 @@ <:subheader> - - - - {{#each this.namespaces as |namespace|}} - {{namespace}} - {{/each}} - - - - {{#each this.mountPaths as |mountPath|}} - {{mountPath}} - {{/each}} - - - + <:table> diff --git a/ui/app/components/clients/page/client-list.ts b/ui/app/components/clients/page/client-list.ts index 12f5fdf990..b76fe982d5 100644 --- a/ui/app/components/clients/page/client-list.ts +++ b/ui/app/components/clients/page/client-list.ts @@ -3,33 +3,38 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { tracked } from '@glimmer/tracking'; import ActivityComponent from '../activity'; import { action } from '@ember/object'; +import { cached } from '@glimmer/tracking'; + +import type { ClientFilterTypes, MountClients } from 'core/utils/client-count-utils'; export default class ClientsClientListPageComponent extends ActivityComponent { - @tracked selectedNamespace = ''; - @tracked selectedMountPath = ''; - - // TODO stubbing this action here now, but it might end up being a callback in the parent to set URL query params - @action - setFilter(prop: 'selectedNamespace' | 'selectedMountPath', value: string) { - this[prop] = value; + @cached + get namespaceLabels() { + // TODO namespace list will be updated to come from the export data, not by_namespace from sys/internal/counters/activity + return this.args.activity.byNamespace.map((n) => n.label); } - @action - resetFilters() { - this.selectedNamespace = ''; - this.selectedMountPath = ''; - } - - get namespaces() { - // TODO map over exported activity data for list of namespaces - return ['root']; + @cached + get mounts() { + // TODO same comment here + return this.args.activity.byNamespace.map((n) => n.mounts).flat(); } + @cached get mountPaths() { - // TODO map over exported activity data for list of mountPaths - return []; + return [...new Set(this.mounts.map((m: MountClients) => m.label))]; + } + + @cached + get mountTypes() { + return [...new Set(this.mounts.map((m: MountClients) => m.mount_type))]; + } + + @action + handleFilter(filters: Record) { + const { nsLabel, mountPath, mountType } = filters; + this.args.onFilterChange({ nsLabel, mountPath, mountType }); } } diff --git a/ui/app/components/clients/page/counts.ts b/ui/app/components/clients/page/counts.ts index aeb83058a8..8baffd3a4d 100644 --- a/ui/app/components/clients/page/counts.ts +++ b/ui/app/components/clients/page/counts.ts @@ -192,11 +192,11 @@ export default class ClientsCountsPageComponent extends Component { } @action - setFilterValue(type: 'ns' | 'mountPath', [value]: [string | undefined]) { + setFilterValue(type: 'ns' | 'mountPath', [value]: [string]) { const params = { [type]: value }; if (type === 'ns' && !value) { // unset mountPath value when namespace is cleared - params['mountPath'] = undefined; + params['mountPath'] = ''; } else if (type === 'mountPath' && !this.args.namespace) { // set namespace when mountPath set without namespace already set params['ns'] = this.namespacePathForFilter; @@ -209,8 +209,8 @@ export default class ClientsCountsPageComponent extends Component { this.args.onFilterChange({ start_time: undefined, end_time: undefined, - ns: undefined, - mountPath: undefined, + ns: '', + mountPath: '', }); } } diff --git a/ui/app/controllers/vault/cluster/clients/counts.ts b/ui/app/controllers/vault/cluster/clients/counts.ts index f9125de2f1..adf7014401 100644 --- a/ui/app/controllers/vault/cluster/clients/counts.ts +++ b/ui/app/controllers/vault/cluster/clients/counts.ts @@ -5,17 +5,24 @@ import Controller from '@ember/controller'; import { action, set } from '@ember/object'; +import { ClientFilters } from 'core/utils/client-count-utils'; import type { ClientsCountsRouteParams } from 'vault/routes/vault/cluster/clients/counts'; -const queryParamKeys = ['start_time', 'end_time', 'ns', 'mountPath']; +// these params refire the request to /sys/internal/counters/activity +const ACTIVITY_QUERY_PARAMS = ['start_time', 'end_time', 'ns']; +// these params client-side filter table data +const DROPDOWN_FILTERS = Object.values(ClientFilters); +const queryParamKeys = [...ACTIVITY_QUERY_PARAMS, ...DROPDOWN_FILTERS]; export default class ClientsCountsController extends Controller { queryParams = queryParamKeys; start_time: string | number | undefined = undefined; end_time: string | number | undefined = undefined; - ns: string | undefined = undefined; - mountPath: string | undefined = undefined; + ns: string | undefined = undefined; // TODO delete when filter toolbar is removed + nsLabel = ''; + mountPath = ''; + mountType = ''; // using router.transitionTo to update the query params results in the model hook firing each time // this happens when the queryParams object is not added to the route or refreshModel is explicitly set to false @@ -23,12 +30,12 @@ export default class ClientsCountsController extends Controller { @action updateQueryParams(updatedParams: ClientsCountsRouteParams) { if (!updatedParams) { - this.queryParams.forEach((key) => (this[key as keyof ClientsCountsRouteParams] = undefined)); + this.queryParams.forEach((key) => (this[key as keyof ClientsCountsRouteParams] = '')); } else { Object.keys(updatedParams).forEach((key) => { if (queryParamKeys.includes(key)) { const value = updatedParams[key as keyof ClientsCountsRouteParams]; - set(this, key as keyof ClientsCountsRouteParams, value as keyof ClientsCountsRouteParams); + set(this, key as keyof ClientsCountsRouteParams, value); } }); } diff --git a/ui/app/controllers/vault/cluster/clients/counts/acme.ts b/ui/app/controllers/vault/cluster/clients/counts/acme.ts deleted file mode 100644 index e176bf4c76..0000000000 --- a/ui/app/controllers/vault/cluster/clients/counts/acme.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Controller, { inject as controller } from '@ember/controller'; -import type ClientsCountsController from '../counts'; - -export default class ClientsCountsAcmeController extends Controller { - // not sure why this needs to be cast to never but this definitely accepts a string to point to the controller - @controller('vault.cluster.clients.counts' as never) - declare readonly countsController: ClientsCountsController; -} diff --git a/ui/app/controllers/vault/cluster/clients/counts/client-list.ts b/ui/app/controllers/vault/cluster/clients/counts/client-list.ts new file mode 100644 index 0000000000..7a68bf7d8f --- /dev/null +++ b/ui/app/controllers/vault/cluster/clients/counts/client-list.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Controller, { inject as controller } from '@ember/controller'; + +import type ClientsCountsController from '../counts'; + +export default class ClientsCountsClientListController extends Controller { + @controller('vault.cluster.clients.counts') declare readonly countsController: ClientsCountsController; +} diff --git a/ui/app/controllers/vault/cluster/clients/counts/overview.ts b/ui/app/controllers/vault/cluster/clients/counts/overview.ts index b5c60816ce..4d3ad83eb8 100644 --- a/ui/app/controllers/vault/cluster/clients/counts/overview.ts +++ b/ui/app/controllers/vault/cluster/clients/counts/overview.ts @@ -8,7 +8,5 @@ import Controller, { inject as controller } from '@ember/controller'; import type ClientsCountsController from '../counts'; export default class ClientsCountsOverviewController extends Controller { - // not sure why this needs to be cast to never but this definitely accepts a string to point to the controller - @controller('vault.cluster.clients.counts' as never) - declare readonly countsController: ClientsCountsController; + @controller('vault.cluster.clients.counts') declare readonly countsController: ClientsCountsController; } diff --git a/ui/app/controllers/vault/cluster/clients/counts/sync.ts b/ui/app/controllers/vault/cluster/clients/counts/sync.ts deleted file mode 100644 index a5acc1037d..0000000000 --- a/ui/app/controllers/vault/cluster/clients/counts/sync.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Controller, { inject as controller } from '@ember/controller'; - -import type ClientsCountsController from '../counts'; - -export default class ClientsCountsSyncController extends Controller { - // not sure why this needs to be cast to never but this definitely accepts a string to point to the controller - @controller('vault.cluster.clients.counts' as never) - declare readonly countsController: ClientsCountsController; -} diff --git a/ui/app/controllers/vault/cluster/clients/counts/token.ts b/ui/app/controllers/vault/cluster/clients/counts/token.ts deleted file mode 100644 index eaa3b3cbc0..0000000000 --- a/ui/app/controllers/vault/cluster/clients/counts/token.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Controller, { inject as controller } from '@ember/controller'; - -import type ClientsCountsController from '../counts'; - -export default class ClientsCountsTokenController extends Controller { - // not sure why this needs to be cast to never but this definitely accepts a string to point to the controller - @controller('vault.cluster.clients.counts' as never) - declare readonly countsController: ClientsCountsController; -} diff --git a/ui/app/routes/vault/cluster/clients/counts.ts b/ui/app/routes/vault/cluster/clients/counts.ts index 2c87de494f..f58469a311 100644 --- a/ui/app/routes/vault/cluster/clients/counts.ts +++ b/ui/app/routes/vault/cluster/clients/counts.ts @@ -21,7 +21,8 @@ export interface ClientsCountsRouteParams { start_time?: string | number | undefined; end_time?: string | number | undefined; ns?: string | undefined; - mountPath?: string | undefined; + mountPath?: string; + mountType?: string; } interface ActivityAdapterQuery { @@ -131,7 +132,7 @@ export default class ClientsCountsRoute extends Route { start_time: undefined, end_time: undefined, ns: undefined, - mountPath: undefined, + mountPath: '', }); } } diff --git a/ui/app/templates/vault/cluster/clients/counts/client-list.hbs b/ui/app/templates/vault/cluster/clients/counts/client-list.hbs index 8708133703..79a0edba6e 100644 --- a/ui/app/templates/vault/cluster/clients/counts/client-list.hbs +++ b/ui/app/templates/vault/cluster/clients/counts/client-list.hbs @@ -3,4 +3,12 @@ SPDX-License-Identifier: BUSL-1.1 }} - \ No newline at end of file + \ No newline at end of file diff --git a/ui/lib/core/addon/utils/client-count-utils.ts b/ui/lib/core/addon/utils/client-count-utils.ts index 30aa1b89da..d2067345d7 100644 --- a/ui/lib/core/addon/utils/client-count-utils.ts +++ b/ui/lib/core/addon/utils/client-count-utils.ts @@ -27,6 +27,15 @@ export const CLIENT_TYPES = [ export type ClientTypes = (typeof CLIENT_TYPES)[number]; +// map to dropdowns for filtering client count tables +export enum ClientFilters { + NAMESPACE = 'nsLabel', + MOUNT_PATH = 'mountPath', + MOUNT_TYPE = 'mountType', +} + +export type ClientFilterTypes = (typeof ClientFilters)[keyof typeof ClientFilters]; + // generates a block of total clients with 0's for use as defaults function emptyCounts() { return CLIENT_TYPES.reduce((prev, type) => { @@ -199,7 +208,7 @@ export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByN if (!Array.isArray(namespaceArray)) return []; return namespaceArray.map((ns) => { // i.e. 'namespace_path' is an empty string for 'root', so use namespace_id - const label = ns.namespace_path === '' ? ns.namespace_id : ns.namespace_path; + const nsLabel = ns.namespace_path === '' ? ns.namespace_id : ns.namespace_path; // data prior to adding mount granularity will still have a mounts array, // but the mount_path value will be "no mount accessor (pre-1.10 upgrade?)" (ref: vault/activity_log_util_common.go) // transform to an empty array for type consistency @@ -207,12 +216,13 @@ export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByN if (Array.isArray(ns.mounts)) { mounts = ns.mounts.map((m) => ({ label: m.mount_path, + namespace_path: nsLabel, mount_type: m.mount_type, ...destructureClientCounts(m.counts), })); } return { - label, + label: nsLabel, ...destructureClientCounts(ns.counts), mounts, }; diff --git a/ui/tests/acceptance/clients/counts/client-list-test.js b/ui/tests/acceptance/clients/counts/client-list-test.js index 4fe8ef41fc..9b614ecb8f 100644 --- a/ui/tests/acceptance/clients/counts/client-list-test.js +++ b/ui/tests/acceptance/clients/counts/client-list-test.js @@ -5,15 +5,26 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; import { visit, click, currentURL } from '@ember/test-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; +import { ClientFilters } from 'core/utils/client-count-utils'; +import { FILTERS } from 'vault/tests/helpers/clients/client-count-selectors'; +import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers'; // integration test handle general display assertions, acceptance handles nav + filtering -module.skip('Acceptance | clients | counts | client list', function (hooks) { +module('Acceptance | clients | counts | client list', function (hooks) { setupApplicationTest(hooks); + setupMirage(hooks); hooks.beforeEach(async function () { + this.server.get('sys/internal/counters/activity', () => { + return { + request_id: 'some-activity-id', + data: ACTIVITY_RESPONSE_STUB, + }; + }); await login(); return visit('/vault'); }); @@ -27,4 +38,45 @@ module.skip('Acceptance | clients | counts | client list', function (hooks) { await click(GENERAL.navLink('Back to main navigation')); assert.strictEqual(currentURL(), '/vault/dashboard', 'it navigates back to dashboard'); }); + + test('filters are preset if URL includes query params', async function (assert) { + assert.expect(4); + const ns = 'ns1'; + const mPath = 'auth/userpass-0'; + const mType = 'userpass'; + await visit( + `vault/clients/counts/client-list?nsLabel=${ns}&mountPath=${mPath}&mountType=${mType}&&start_time=1717113600` + ); + assert.dom(FILTERS.tag()).exists({ count: 3 }, '3 filter tags render'); + assert.dom(FILTERS.tag(ClientFilters.NAMESPACE, ns)).exists(); + assert.dom(FILTERS.tag(ClientFilters.MOUNT_PATH, mPath)).exists(); + assert.dom(FILTERS.tag(ClientFilters.MOUNT_TYPE, mType)).exists(); + }); + + test('selecting filters update URL query params', async function (assert) { + assert.expect(3); + const ns = 'ns1'; + const mPath = 'auth/userpass-0'; + const mType = 'userpass'; + const url = '/vault/clients/counts/client-list'; + await visit(url); + assert.strictEqual(currentURL(), url, 'URL does not contain query params'); + // select namespace + await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); + await click(FILTERS.dropdownItem(ns)); + // select mount path + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); + await click(FILTERS.dropdownItem(mPath)); + // select mount type + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); + await click(FILTERS.dropdownItem(mType)); + await click(GENERAL.button('Apply filters')); + assert.strictEqual( + currentURL(), + `${url}?mountPath=${encodeURIComponent(mPath)}&mountType=${mType}&nsLabel=${ns}`, + 'url query params match filters' + ); + await click(GENERAL.button('Clear filters')); + assert.strictEqual(currentURL(), url, '"Clear filters" resets URL query params'); + }); }); diff --git a/ui/tests/helpers/clients/client-count-helpers.js b/ui/tests/helpers/clients/client-count-helpers.js index b43e60b3cf..1d4d4efde6 100644 --- a/ui/tests/helpers/clients/client-count-helpers.js +++ b/ui/tests/helpers/clients/client-count-helpers.js @@ -687,6 +687,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { { label: 'auth/userpass-0', mount_type: 'userpass', + namespace_path: 'ns1', acme_clients: 0, clients: 8394, entity_clients: 4256, @@ -696,6 +697,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { { label: 'kvv2-engine-0', mount_type: 'kv', + namespace_path: 'ns1', acme_clients: 0, clients: 4810, entity_clients: 0, @@ -705,6 +707,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { { label: 'pki-engine-0', mount_type: 'pki', + namespace_path: 'ns1', acme_clients: 5699, clients: 5699, entity_clients: 0, @@ -724,6 +727,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { { label: 'auth/userpass-0', mount_type: 'userpass', + namespace_path: 'root', acme_clients: 0, clients: 8091, entity_clients: 4002, @@ -733,6 +737,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { { label: 'kvv2-engine-0', mount_type: 'kv', + namespace_path: 'root', acme_clients: 0, clients: 4290, entity_clients: 0, @@ -742,6 +747,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { { label: 'pki-engine-0', mount_type: 'pki', + namespace_path: 'root', acme_clients: 4003, clients: 4003, entity_clients: 0, @@ -781,6 +787,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { mounts: [ { label: 'pki-engine-0', + namespace_path: 'root', mount_type: 'pki', acme_clients: 100, clients: 100, @@ -790,6 +797,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'auth/userpass-0', + namespace_path: 'root', mount_type: 'userpass', acme_clients: 0, clients: 200, @@ -799,6 +807,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'kvv2-engine-0', + namespace_path: 'root', mount_type: 'kv', acme_clients: 0, clients: 100, @@ -828,6 +837,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { mounts: [ { label: 'pki-engine-0', + namespace_path: 'root', mount_type: 'pki', acme_clients: 100, clients: 100, @@ -838,6 +848,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { { label: 'auth/userpass-0', mount_type: 'userpass', + namespace_path: 'root', acme_clients: 0, clients: 200, entity_clients: 100, @@ -847,6 +858,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { { label: 'kvv2-engine-0', mount_type: 'kv', + namespace_path: 'root', acme_clients: 0, clients: 100, entity_clients: 0, @@ -877,6 +889,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { mounts: [ { label: 'pki-engine-0', + namespace_path: 'root', mount_type: 'pki', acme_clients: 100, clients: 100, @@ -886,6 +899,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'auth/userpass-0', + namespace_path: 'root', mount_type: 'userpass', acme_clients: 0, clients: 200, @@ -895,6 +909,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'kvv2-engine-0', + namespace_path: 'root', mount_type: 'kv', acme_clients: 0, clients: 100, diff --git a/ui/tests/helpers/clients/client-count-selectors.ts b/ui/tests/helpers/clients/client-count-selectors.ts index d0a7e9df1c..6124f4d814 100644 --- a/ui/tests/helpers/clients/client-count-selectors.ts +++ b/ui/tests/helpers/clients/client-count-selectors.ts @@ -52,3 +52,11 @@ export const CHARTS = { xAxisLabel: '[data-test-x-axis] text', plotPoint: '[data-test-plot-point]', }; + +export const FILTERS = { + dropdownToggle: (name: string) => `[data-test-dropdown="${name}"] button`, + dropdownItem: (name: string) => `[data-test-dropdown-item="${name}"]`, + tag: (filter?: string, value?: string) => + filter && value ? `[data-test-filter-tag="${filter} ${value}"]` : '[data-test-filter-tag]', + clearTag: (value: string) => `[aria-label="Dismiss ${value}"]`, +}; diff --git a/ui/tests/integration/components/clients/filter-toolbar-test.js b/ui/tests/integration/components/clients/filter-toolbar-test.js new file mode 100644 index 0000000000..0ef84d34c0 --- /dev/null +++ b/ui/tests/integration/components/clients/filter-toolbar-test.js @@ -0,0 +1,203 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { click, findAll, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import sinon from 'sinon'; +import { FILTERS } from 'vault/tests/helpers/clients/client-count-selectors'; +import { ClientFilters } from 'core/utils/client-count-utils'; + +module('Integration | Component | clients/filter-toolbar', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.namespaces = ['root', 'admin/', 'ns1/']; + this.mountPaths = ['auth/token/', 'auth/auto/eng/core/auth/core-gh-auth/', 'auth/userpass-root/']; + this.mountTypes = ['token/', 'userpass/', 'ns_token/']; + this.onFilter = sinon.spy(); + this.appliedFilters = { nsLabel: '', mountPath: '', mountType: '' }; + + this.renderComponent = async () => { + await render(hbs` + `); + }; + + this.presetFilters = () => { + this.appliedFilters = { nsLabel: 'admin/', mountPath: 'auth/userpass-root/', mountType: 'token/' }; + }; + + this.selectFilters = async () => { + // select namespace + await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); + await click(FILTERS.dropdownItem('admin/')); + // select mount path + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); + await click(FILTERS.dropdownItem('auth/userpass-root/')); + // select mount type + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); + await click(FILTERS.dropdownItem('token/')); + }; + }); + + test('it renders dropdowns', async function (assert) { + await this.renderComponent(); + + assert.dom(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)).hasText('Namespace'); + assert.dom(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)).hasText('Mount path'); + assert.dom(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)).hasText('Mount type'); + assert.dom(GENERAL.button('Apply filters')).exists(); + assert + .dom(GENERAL.button('Clear filters')) + .doesNotExist('"Clear filters" button does not render when filters are unset'); + }); + + test('it renders dropdown items', async function (assert) { + await this.renderComponent(); + + await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); + findAll('li button').forEach((item, idx) => { + assert.dom(item).hasText(this.namespaces[idx]); + }); + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); + findAll('li button').forEach((item, idx) => { + assert.dom(item).hasText(this.mountPaths[idx]); + }); + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); + findAll('li button').forEach((item, idx) => { + assert.dom(item).hasText(this.mountTypes[idx]); + }); + }); + + test('it selects dropdown items', async function (assert) { + await this.renderComponent(); + await this.selectFilters(); + + // dropdown closes when an item is selected, reopen each one to assert the correct item is selected + await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); + assert.dom(FILTERS.dropdownItem('admin/')).hasAttribute('aria-selected', 'true'); + assert.dom(`${FILTERS.dropdownItem('admin/')} ${GENERAL.icon('check')}`).exists(); + + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); + assert.dom(FILTERS.dropdownItem('auth/userpass-root/')).hasAttribute('aria-selected', 'true'); + assert.dom(`${FILTERS.dropdownItem('auth/userpass-root/')} ${GENERAL.icon('check')}`).exists(); + + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); + assert.dom(FILTERS.dropdownItem('token/')).hasAttribute('aria-selected', 'true'); + assert.dom(`${FILTERS.dropdownItem('token/')} ${GENERAL.icon('check')}`).exists(); + }); + + test('it applies filters when no filters are set', async function (assert) { + await this.renderComponent(); + await this.selectFilters(); + + await click(GENERAL.button('Apply filters')); + const [obj] = this.onFilter.lastCall.args; + assert.strictEqual( + obj[ClientFilters.NAMESPACE], + 'admin/', + `onFilter callback has expected "${ClientFilters.NAMESPACE}"` + ); + assert.strictEqual( + obj[ClientFilters.MOUNT_PATH], + 'auth/userpass-root/', + `onFilter callback has expected "${ClientFilters.MOUNT_PATH}"` + ); + assert.strictEqual( + obj[ClientFilters.MOUNT_TYPE], + 'token/', + `onFilter callback has expected "${ClientFilters.MOUNT_TYPE}"` + ); + }); + + test('it applies updated filters when filters are preset', async function (assert) { + this.appliedFilters = { mountPath: 'auth/token/', mountType: 'ns_token/', nsLabel: 'ns1' }; + await this.renderComponent(); + // Check initial filters + await click(GENERAL.button('Apply filters')); + const [beforeUpdate] = this.onFilter.lastCall.args; + assert.propEqual(beforeUpdate, this.appliedFilters, 'callback fires with preset filters'); + // Change filters and confirm callback has updated values + await this.selectFilters(); + await click(GENERAL.button('Apply filters')); + const [afterUpdate] = this.onFilter.lastCall.args; + assert.propEqual( + afterUpdate, + { mountPath: 'auth/userpass-root/', mountType: 'token/', nsLabel: 'admin/' }, + 'callback fires with updated selection' + ); + }); + + test('it renders a tag for each filter', async function (assert) { + this.presetFilters(); + await this.renderComponent(); + + assert.dom(FILTERS.tag()).exists({ count: 3 }, '3 filter tags render'); + assert.dom(FILTERS.tag(ClientFilters.NAMESPACE, 'admin/')).exists(); + assert.dom(FILTERS.tag(ClientFilters.MOUNT_PATH, 'auth/userpass-root/')).exists(); + assert.dom(FILTERS.tag(ClientFilters.MOUNT_TYPE, 'token/')).exists(); + assert + .dom(GENERAL.button('Clear filters')) + .exists('"Clear filters" button renders when filters are present'); + }); + + test('it resets all filters', async function (assert) { + this.presetFilters(); + await this.renderComponent(); + // first check that filters have preset values + await click(GENERAL.button('Apply filters')); + const [beforeClear] = this.onFilter.lastCall.args; + assert.propEqual( + beforeClear, + { mountPath: 'auth/userpass-root/', mountType: 'token/', nsLabel: 'admin/' }, + 'callback fires with preset filters' + ); + // now clear filters and confirm values are cleared + await click(GENERAL.button('Clear filters')); + const [afterClear] = this.onFilter.lastCall.args; + assert.propEqual( + afterClear, + { mountPath: '', mountType: '', nsLabel: '' }, + 'onFilter callback has empty values when "Clear filters" is clicked' + ); + }); + + test('it clears individual filters', async function (assert) { + this.presetFilters(); + await this.renderComponent(); + // first check that filters have preset values + await click(GENERAL.button('Apply filters')); + const [beforeClear] = this.onFilter.lastCall.args; + assert.propEqual( + beforeClear, + { mountPath: 'auth/userpass-root/', mountType: 'token/', nsLabel: 'admin/' }, + 'callback fires with preset filters' + ); + await click(FILTERS.clearTag('admin/')); + const afterClear = this.onFilter.lastCall.args[0]; + assert.propEqual( + afterClear, + { mountPath: 'auth/userpass-root/', mountType: 'token/', nsLabel: '' }, + 'onFilter callback fires with empty nsLabel' + ); + }); + + test('it only renders tags for supported filters', async function (assert) { + this.appliedFilters = { start_time: '2025-08-31T23:59:59Z' }; + await this.renderComponent(); + assert + .dom(GENERAL.button('Clear filters')) + .doesNotExist('"Clear filters" button does not render when filters are unset'); + assert.dom(FILTERS.tag()).doesNotExist(); + }); +}); diff --git a/ui/tests/integration/components/clients/page/counts-test.js b/ui/tests/integration/components/clients/page/counts-test.js index 552f81f1db..8c97b4cfee 100644 --- a/ui/tests/integration/components/clients/page/counts-test.js +++ b/ui/tests/integration/components/clients/page/counts-test.js @@ -171,7 +171,7 @@ module('Integration | Component | clients | Page::Counts', function (hooks) { this.mountPath = 'auth/authid0'; let assertion = (params) => - assert.deepEqual(params, { ns: undefined, mountPath: undefined }, 'Auth mount cleared with namespace'); + assert.deepEqual(params, { ns: undefined, mountPath: '' }, 'Auth mount cleared with namespace'); this.onFilterChange = (params) => { if (assertion) { assertion(params); diff --git a/ui/tests/integration/utils/client-count-utils-test.js b/ui/tests/integration/utils/client-count-utils-test.js index 712923aea1..7433d4d440 100644 --- a/ui/tests/integration/utils/client-count-utils-test.js +++ b/ui/tests/integration/utils/client-count-utils-test.js @@ -157,15 +157,14 @@ module('Integration | Util | client count utils', function (hooks) { const original = [...RESPONSE.by_namespace]; const expectedNs1 = SERIALIZED_ACTIVITY_RESPONSE.by_namespace.find((ns) => ns.label === 'ns1'); const formattedNs1 = formatByNamespace(RESPONSE.by_namespace).find((ns) => ns.label === 'ns1'); - assert.expect(2 + expectedNs1.mounts.length * 2); + assert.expect(2 + formattedNs1.mounts.length); assert.propEqual(formattedNs1, expectedNs1, 'it formats ns1/ namespace'); assert.propEqual(RESPONSE.by_namespace, original, 'it does not modify original by_namespace array'); formattedNs1.mounts.forEach((mount) => { const expectedMount = expectedNs1.mounts.find((m) => m.label === mount.label); - assert.propEqual(Object.keys(mount), Object.keys(expectedMount), `${mount} as expected keys`); - assert.propEqual(Object.values(mount), Object.values(expectedMount), `${mount} as expected values`); + assert.propEqual(mount, expectedMount, `${mount.label} has expected key/value pairs`); }); }); @@ -235,6 +234,7 @@ module('Integration | Util | client count utils', function (hooks) { entity_clients: 10, label: 'no mount accessor (pre-1.10 upgrade?)', mount_type: '', + namespace_path: 'root', non_entity_clients: 20, secret_syncs: 0, }, @@ -267,6 +267,7 @@ module('Integration | Util | client count utils', function (hooks) { entity_clients: 2, label: 'no mount accessor (pre-1.10 upgrade?)', mount_type: 'no mount path (pre-1.10 upgrade?)', + namespace_path: 'root', non_entity_clients: 0, secret_syncs: 0, }, @@ -276,6 +277,7 @@ module('Integration | Util | client count utils', function (hooks) { entity_clients: 1, label: 'auth/userpass-0', mount_type: 'userpass', + namespace_path: 'root', non_entity_clients: 0, secret_syncs: 0, }, @@ -306,6 +308,7 @@ module('Integration | Util | client count utils', function (hooks) { entity_clients: 2, label: 'no mount accessor (pre-1.10 upgrade?)', mount_type: 'no mount path (pre-1.10 upgrade?)', + namespace_path: 'root', non_entity_clients: 0, secret_syncs: 0, }, @@ -315,6 +318,7 @@ module('Integration | Util | client count utils', function (hooks) { entity_clients: 1, label: 'auth/userpass-0', mount_type: 'userpass', + namespace_path: 'root', non_entity_clients: 0, secret_syncs: 0, }, @@ -533,6 +537,7 @@ module('Integration | Util | client count utils', function (hooks) { expected: { label: 'auth/userpass-0', mount_type: 'userpass', + namespace_path: 'ns1', acme_clients: 0, clients: 8394, entity_clients: 4256, @@ -548,6 +553,7 @@ module('Integration | Util | client count utils', function (hooks) { expected: { label: 'kvv2-engine-0', mount_type: 'kv', + namespace_path: 'root', acme_clients: 0, clients: 4290, entity_clients: 0,