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
This commit is contained in:
claire bontempo 2025-08-12 10:41:15 -07:00 committed by GitHub
parent 32e806f88a
commit 7d4854d549
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 521 additions and 126 deletions

View File

@ -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<Args> {
@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;

View File

@ -0,0 +1,76 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::SegmentedGroup as |SG|>
{{#if @namespaces}}
<SG.Dropdown data-test-dropdown={{this.filterTypes.NAMESPACE}} as |D|>
<D.ToggleButton @color="secondary" @text="Namespace" />
{{#each @namespaces as |namespace|}}
<D.Checkmark
{{on "click" (fn this.updateFilter this.filterTypes.NAMESPACE namespace D.close)}}
@selected={{eq namespace this.nsLabel}}
data-test-dropdown-item={{namespace}}
>{{namespace}}</D.Checkmark>
{{/each}}
</SG.Dropdown>
{{/if}}
{{#if @mountPaths}}
<SG.Dropdown data-test-dropdown={{this.filterTypes.MOUNT_PATH}} as |D|>
<D.ToggleButton @color="secondary" @text="Mount path" />
{{#each @mountPaths as |mountPath|}}
<D.Checkmark
{{on "click" (fn this.updateFilter this.filterTypes.MOUNT_PATH mountPath D.close)}}
@selected={{eq mountPath this.mountPath}}
data-test-dropdown-item={{mountPath}}
>{{mountPath}}</D.Checkmark>
{{/each}}
</SG.Dropdown>
{{/if}}
{{#if @mountTypes}}
<SG.Dropdown data-test-dropdown={{this.filterTypes.MOUNT_TYPE}} as |D|>
<D.ToggleButton @color="secondary" @text="Mount type" />
{{#each @mountTypes as |mountType|}}
<D.Checkmark
{{on "click" (fn this.updateFilter this.filterTypes.MOUNT_TYPE mountType D.close)}}
@selected={{eq mountType this.mountType}}
data-test-dropdown-item={{mountType}}
>{{mountType}}</D.Checkmark>
{{/each}}
</SG.Dropdown>
{{/if}}
<Hds::Button
@icon="filter"
@text="Apply filters"
@color="primary"
{{on "click" this.applyFilters}}
data-test-button="Apply filters"
/>
</Hds::SegmentedGroup>
<Hds::Layout::Flex class="has-top-margin-s" @gap="8" @align="center">
{{#if this.anyFilters}}
<Hds::Text::Body @color="faint">Filters applied:</Hds::Text::Body>
{{! render tags based on applied @filters and not the internally tracked properties }}
{{#each-in @appliedFilters as |filter value|}}
{{#if (and (this.supportedFilter filter) value)}}
<div>
<Hds::Tag
@text={{value}}
@tooltipPlacement="bottom"
@onDismiss={{fn this.clearFilters filter}}
data-test-filter-tag="{{filter}} {{value}}"
/>
</div>
{{/if}}
{{/each-in}}
<Hds::Button
@icon="x-circle"
@text="Clear filters"
@color="tertiary"
{{on "click" (fn this.clearFilters "")}}
data-test-button="Clear filters"
/>
{{/if}}
</Hds::Layout::Flex>

View File

@ -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<ClientFilterTypes, string>;
}
export default class ClientsFilterToolbar extends Component<Args> {
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);
}

View File

@ -5,33 +5,13 @@
<Clients::CountsCard @title="Client counts by path">
<:subheader>
<Hds::SegmentedGroup as |SG|>
<SG.Dropdown as |D|>
<D.ToggleButton
@color="secondary"
@text="Namespace{{if this.selectedNamespace (concat ': ' this.selectedNamespace)}}"
/>
{{#each this.namespaces as |namespace|}}
<D.Checkmark
{{on "click" (fn this.setFilter "selectedNamespace" namespace)}}
@selected={{eq namespace this.selectedNamespace}}
>{{namespace}}</D.Checkmark>
{{/each}}
</SG.Dropdown>
<SG.Dropdown as |D|>
<D.ToggleButton
@color="secondary"
@text="Mount path{{if this.selectedMountPath (concat ': ' this.selectedMountPath)}}"
/>
{{#each this.mountPaths as |mountPath|}}
<D.Checkmark
{{on "click" (fn this.setFilter "selectedMountPath" mountPath)}}
@selected={{eq mountPath this.selectedMountPath}}
>{{mountPath}}</D.Checkmark>
{{/each}}
</SG.Dropdown>
</Hds::SegmentedGroup>
<Hds::Button @icon="reload" @text="Clear filters" @color="tertiary" {{on "click" this.resetFilters}} />
<Clients::FilterToolbar
@namespaces={{this.namespaceLabels}}
@mountPaths={{this.mountPaths}}
@mountTypes={{this.mountTypes}}
@onFilter={{this.handleFilter}}
@appliedFilters={{@filterQueryParams}}
/>
</:subheader>
<:table>

View File

@ -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<ClientFilterTypes, string>) {
const { nsLabel, mountPath, mountType } = filters;
this.args.onFilterChange({ nsLabel, mountPath, mountType });
}
}

View File

@ -192,11 +192,11 @@ export default class ClientsCountsPageComponent extends Component<Args> {
}
@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<Args> {
this.args.onFilterChange({
start_time: undefined,
end_time: undefined,
ns: undefined,
mountPath: undefined,
ns: '',
mountPath: '',
});
}
}

View File

@ -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);
}
});
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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: '',
});
}
}

View File

@ -3,4 +3,12 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Clients::Page::ClientList />
<Clients::Page::ClientList
@activity={{this.model.activity}}
@onFilterChange={{this.countsController.updateQueryParams}}
@filterQueryParams={{hash
nsLabel=this.countsController.nsLabel
mountPath=this.countsController.mountPath
mountType=this.countsController.mountType
}}
/>

View File

@ -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,
};

View File

@ -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');
});
});

View File

@ -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,

View File

@ -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}"]`,
};

View File

@ -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`
<Clients::FilterToolbar
@namespaces={{this.namespaces}}
@mountPaths={{this.mountPaths}}
@mountTypes={{this.mountTypes}}
@onFilter={{this.onFilter}}
@appliedFilters={{this.appliedFilters}}
/>`);
};
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();
});
});

View File

@ -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);

View File

@ -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,