mirror of
https://github.com/hashicorp/vault.git
synced 2025-11-28 14:11:10 +01:00
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:
parent
32e806f88a
commit
7d4854d549
@ -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;
|
||||
|
||||
76
ui/app/components/clients/filter-toolbar.hbs
Normal file
76
ui/app/components/clients/filter-toolbar.hbs
Normal 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>
|
||||
69
ui/app/components/clients/filter-toolbar.ts
Normal file
69
ui/app/components/clients/filter-toolbar.ts
Normal 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);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}}
|
||||
/>
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}"]`,
|
||||
};
|
||||
|
||||
203
ui/tests/integration/components/clients/filter-toolbar-test.js
Normal file
203
ui/tests/integration/components/clients/filter-toolbar-test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user