UI: Replace toolbar filters on Secret Engines List page (#9577) (#9692)

* first full pass with new filtering

* updates and making dropdowns searchable

* fixing tests

* updates, test fix

* update version dropdown

* update icons

* comments and cleanup

* filter fixes, update template and add test

* fix tests

* fix tests but not insane

* update, changelog

Co-authored-by: Dan Rivera <dan.rivera@hashicorp.com>
This commit is contained in:
Vault Automation 2025-09-26 14:34:43 -04:00 committed by GitHub
parent 7e2f411859
commit d17181c596
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 250 additions and 110 deletions

3
changelog/_9577.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
ui/secrets: Updated filters on secret engines list to sort by path, engine type and version
```

View file

@ -20,35 +20,84 @@
</PH.Actions>
</Hds::PageHeader>
<Toolbar class="has-top-margin-m">
<ToolbarFilters>
<SearchSelect
@id="filter-by-engine-type"
@options={{this.secretEngineArrayByType}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.filterEngineType}}
@placeholder="Filter by engine type"
@displayInherit={{true}}
@inputValue={{if this.selectedEngineType (array this.selectedEngineType)}}
@disabled={{if this.selectedEngineName true false}}
class="is-marginless"
<Hds::SegmentedGroup class="has-top-margin-m" as |SG|>
<SG.TextInput
@width="300px"
@type="search"
placeholder="Search by path"
aria-label="Search"
data-test-input-search="secret-engine-path"
{{on "input" (fn this.setSearchText "path")}}
/>
<SG.Dropdown @width="175px" as |D|>
<D.Header @hasDivider={{true}}>
<SG.TextInput @type="search" placeholder="Search" aria-label="Search" {{on "input" (fn this.setSearchText "type")}} />
</D.Header>
<D.ToggleButton @color="secondary" @text="Engine type" data-test-toggle-input="filter-by-engine-type" />
{{#each this.secretEngineArrayByType as |type|}}
<D.Checkbox
value={{type.name}}
checked={{includes type.name this.engineTypeFilters}}
{{on "click" (fn this.filterByEngineType type.name)}}
data-test-checkbox={{type.name}}
><Hds::Icon @name={{type.icon}} @isInline={{true}} /> {{type.name}}</D.Checkbox>
{{/each}}
</SG.Dropdown>
<SG.Dropdown @width="250px" as |D|>
<D.ToggleButton @color="secondary" @text="Version" data-test-toggle-input="filter-by-engine-version" />
{{#if this.engineTypeFilters.length}}
<D.Header @hasDivider={{true}}>
<SG.TextInput
@type="search"
placeholder="Search"
aria-label="Search"
{{on "input" (fn this.setSearchText "version")}}
/>
</D.Header>
{{#each this.secretEngineArrayByVersions as |backend|}}
<D.Checkbox
value={{backend.version}}
checked={{includes backend.version this.engineVersionFilters}}
{{on "click" (fn this.filterByEngineVersion backend.version)}}
data-test-checkbox={{backend.version}}
>
{{backend.version}}
</D.Checkbox>
{{/each}}
{{else}}
<D.Description class="has-top-padding-s" @text="Select an engine type first to filter by versions." />
{{/if}}
</SG.Dropdown>
</Hds::SegmentedGroup>
<Hds::Layout::Flex @gap="8" class="has-top-margin-xs has-bottom-margin-m" @align="center">
{{#if (and (not this.engineTypeFilters) (not this.engineVersionFilters))}}
<Hds::Text::Body>No filters applied:</Hds::Text::Body>
<Hds::TooltipButton
@text="Select the desired filters in the dropdowns above to narrow your search."
aria-label="More information"
data-test-tooltip="Filter info"
>
<Hds::Icon @name="info" />
</Hds::TooltipButton>
{{else}}
<Hds::Text::Body>Filters applied:</Hds::Text::Body>
{{#each this.engineTypeFilters as |type|}}
<Hds::Tag @text={{type}} @onDismiss={{fn this.filterByEngineType type}} data-test-button={{type}} />
{{/each}}
{{#each this.engineVersionFilters as |version|}}
<Hds::Tag @text={{version}} @onDismiss={{fn this.filterByEngineVersion version}} data-test-button={{version}} />
{{/each}}
<Hds::Button
@text="Clear all"
@color="tertiary"
@icon="x"
@size="small"
data-test-button="Clear all"
{{on "click" this.clearAllFilters}}
/>
<SearchSelect
@id="filter-by-engine-name"
@options={{this.secretEngineArrayByName}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.filterEngineName}}
@placeholder="Filter by engine name"
@displayInherit={{true}}
@inputValue={{if this.selectedEngineName (array this.selectedEngineName)}}
class="is-marginless has-left-padding-s"
/>
</ToolbarFilters>
</Toolbar>
{{/if}}
</Hds::Layout::Flex>
{{#each this.sortedDisplayableBackends as |backend|}}
<LinkedBlock
@ -60,7 +109,11 @@
<div>
<div class="has-text-grey is-grid align-items-center linked-block-title">
{{#if backend.icon}}
<Hds::TooltipButton aria-label="Type of backend" @text={{this.generateToolTipText backend}}>
<Hds::TooltipButton
aria-label="Type of backend"
@text={{this.generateToolTipText backend}}
data-test-tooltip="Backend type"
>
<Icon @name={{backend.icon}} class="has-text-grey-light" />
</Hds::TooltipButton>
{{/if}}

View file

@ -38,10 +38,16 @@ export default class SecretEngineList extends Component<Args> {
@service declare readonly version: VersionService;
@tracked secretEngineOptions: Array<string> | [] = [];
@tracked selectedEngineType = '';
@tracked selectedEngineName = '';
@tracked engineToDisable: SecretsEngineResource | undefined = undefined;
@tracked engineTypeFilters: Array<string> = [];
@tracked engineVersionFilters: Array<string> = [];
@tracked searchText = '';
// search text for dropdown filters
@tracked typeSearchText = '';
@tracked versionSearchText = '';
get clusterName() {
return this.version.clusterName;
}
@ -52,28 +58,86 @@ export default class SecretEngineList extends Component<Args> {
get sortedDisplayableBackends() {
// show supported secret engines first and then organize those by id.
const sortedBackends = this.displayableBackends.sort(
(a, b) => Number(b.isSupportedBackend) - Number(a.isSupportedBackend) || a.id.localeCompare(b.id)
);
let sortedBackends = this.displayableBackends
.slice()
.sort(
(a, b) => Number(b.isSupportedBackend) - Number(a.isSupportedBackend) || a.id.localeCompare(b.id)
);
// return an options list to filter by engine type, ex: 'kv'
if (this.selectedEngineType) {
// check first if the user has also filtered by name.
if (this.selectedEngineName) {
return sortedBackends.filter((backend) => this.selectedEngineName === backend.id);
}
// otherwise filter by engine type
return sortedBackends.filter((backend) => this.selectedEngineType === backend.engineType);
// filters by engine type, ex: 'kv'
if (this.engineTypeFilters.length > 0) {
sortedBackends = sortedBackends.filter((backend) =>
this.engineTypeFilters.includes(backend.engineType)
);
}
// return an options list to filter by engine name, ex: 'secret'
if (this.selectedEngineName) {
return sortedBackends.filter((backend) => this.selectedEngineName === backend.id);
// filters by engine version, ex: 'v1.21.0...'
if (this.engineVersionFilters.length > 0) {
sortedBackends = sortedBackends.filter((backend) =>
this.engineVersionFilters.includes(backend.running_plugin_version)
);
}
// if there is search text, filter path name by that
if (this.searchText.trim() !== '') {
sortedBackends = sortedBackends.filter((backend) =>
backend.path.toLowerCase().includes(this.searchText.toLowerCase())
);
}
// no filters, return full sorted list.
return sortedBackends;
}
// Returns filter options for engine type dropdown
get typeFilterOptions() {
// if there is search text, filter types by that
if (this.typeSearchText.trim() !== '') {
return this.displayableBackends.filter((backend) =>
backend.engineType.toLowerCase().includes(this.typeSearchText.toLowerCase())
);
}
return this.displayableBackends;
}
// Returns filter options for version dropdown
get versionFilterOptions() {
// if there is search text, filter versions by that
if (this.versionSearchText.trim() !== '') {
// filtered by sorted backends array since an engine type filter has to be selected first
return this.sortedDisplayableBackends.filter((backend) =>
backend.running_plugin_version.toLowerCase().includes(this.versionSearchText.toLowerCase())
);
}
return this.sortedDisplayableBackends;
}
// Returns filtered engines list by type
get secretEngineArrayByType() {
const arrayOfAllEngineTypes = this.typeFilterOptions.map((modelObject) => modelObject.engineType);
// filter out repeated engineTypes (e.g. [kv, kv] => [kv])
const arrayOfUniqueEngineTypes = [...new Set(arrayOfAllEngineTypes)];
return arrayOfUniqueEngineTypes.map((engineType) => ({
name: engineType,
id: engineType,
icon: engineDisplayData(engineType)?.glyph ?? 'lock',
}));
}
// Returns filtered engines list by version
get secretEngineArrayByVersions() {
const arrayOfAllEngineVersions = this.versionFilterOptions.map(
(modelObject) => modelObject.running_plugin_version
);
// filter out repeated engineVersions (e.g. [1.0, 1.0] => [1.0])
const arrayOfUniqueEngineVersions = [...new Set(arrayOfAllEngineVersions)];
return arrayOfUniqueEngineVersions.map((version) => ({
version,
id: version,
}));
}
generateToolTipText = (backend: SecretsEngineResource) => {
const displayData = engineDisplayData(backend.type);
@ -96,35 +160,40 @@ export default class SecretEngineList extends Component<Args> {
}
};
// Filtering & searching
get secretEngineArrayByType() {
const arrayOfAllEngineTypes = this.sortedDisplayableBackends.map((modelObject) => modelObject.engineType);
// filter out repeated engineTypes (e.g. [kv, kv] => [kv])
const arrayOfUniqueEngineTypes = [...new Set(arrayOfAllEngineTypes)];
return arrayOfUniqueEngineTypes.map((engineType) => ({
name: engineType,
id: engineType,
}));
}
get secretEngineArrayByName() {
return this.sortedDisplayableBackends.map((modelObject) => ({
name: modelObject.id,
id: modelObject.id,
}));
@action
setSearchText(type: string, event: Event) {
const target = event.target as HTMLInputElement;
if (type === 'type') {
this.typeSearchText = target.value;
} else if (type === 'version') {
this.versionSearchText = target.value;
} else {
this.searchText = target.value;
}
}
@action
filterEngineType(type: string[]) {
const [selectedType] = type;
this.selectedEngineType = selectedType || '';
filterByEngineType(type: string) {
if (this.engineTypeFilters.includes(type)) {
this.engineTypeFilters = this.engineTypeFilters.filter((t) => t !== type);
} else {
this.engineTypeFilters = [...this.engineTypeFilters, type];
}
}
@action
filterEngineName(name: string[]) {
const [selectedName] = name;
this.selectedEngineName = selectedName || '';
filterByEngineVersion(version: string) {
if (this.engineVersionFilters.includes(version)) {
this.engineVersionFilters = this.engineVersionFilters.filter((v) => v !== version);
} else {
this.engineVersionFilters = [...this.engineVersionFilters, version];
}
}
@action
clearAllFilters() {
this.engineTypeFilters = [];
this.engineVersionFilters = [];
}
@dropTask

View file

@ -4,7 +4,6 @@
*/
import { click, fillIn, currentRouteName, visit, currentURL } from '@ember/test-helpers';
import { selectChoose } from 'ember-power-select/test-support';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
@ -162,7 +161,7 @@ module('Acceptance | secret-engine list view', function (hooks) {
await runCmd(mountEngineCmd('alicloud', enginePath));
await visit('/vault/secrets');
// to reduce flakiness, searching by engine name first in case there are pagination issues
await selectChoose(GENERAL.searchSelect.trigger('filter-by-engine-name'), enginePath);
await fillIn(GENERAL.inputSearch('secret-engine-path'), enginePath);
assert.dom(SES.secretsBackendLink(enginePath)).exists('the alicloud engine is mounted');
await click(GENERAL.menuTrigger);

View file

@ -3,8 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, currentRouteName, settled, visit } from '@ember/test-helpers';
import { selectChoose } from 'ember-power-select/test-support';
import { click, currentRouteName, fillIn, visit } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
@ -20,6 +19,7 @@ import { createSecret } from 'vault/tests/helpers/secret-engine/secret-engine-he
import { create } from 'ember-cli-page-object';
import { deleteEngineCmd, runCmd } from 'vault/tests/helpers/commands';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const cli = create(consolePanel);
@ -66,8 +66,7 @@ module('Acceptance | secrets/generic/create', function (hooks) {
`write sys/mounts/${path}/tune options=version=2`,
]);
await visit('/vault/secrets');
await selectChoose('[data-test-component="search-select"]#filter-by-engine-name', path);
await settled();
await fillIn(GENERAL.inputSearch('secret-engine-path'), path);
await click(SES.secretsBackendLink(path));
assert.strictEqual(
currentRouteName(),

View file

@ -4,7 +4,6 @@
*/
import { currentURL, visit, click, fillIn } from '@ember/test-helpers';
import { selectChoose } from 'ember-power-select/test-support';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
@ -56,7 +55,7 @@ module('Acceptance | secret engine mount settings', function (hooks) {
await visit('/vault/secrets/mounts');
await runCmd(mountEngineCmd(type, path), false);
await visit('/vault/secrets');
await selectChoose(GENERAL.searchSelect.trigger('filter-by-engine-name'), path);
await fillIn(GENERAL.inputSearch('secret-engine-path'), path);
await click(GENERAL.menuTrigger);
await click(GENERAL.menuItem('view-configuration'));
assert.strictEqual(
@ -75,7 +74,7 @@ module('Acceptance | secret engine mount settings', function (hooks) {
await visit('/vault/secrets/mounts');
await runCmd(mountEngineCmd(type, path), false);
await visit('/vault/secrets');
await selectChoose(GENERAL.searchSelect.trigger('filter-by-engine-name'), path);
await fillIn(GENERAL.inputSearch('secret-engine-path'), path);
await click(GENERAL.menuTrigger);
await click(GENERAL.menuItem('view-configuration'));
assert.strictEqual(

View file

@ -171,4 +171,5 @@ export const GENERAL = {
/* ────── Misc ────── */
icon: (name: string) => (name ? `[data-test-icon="${name}"]` : '[data-test-icon]'),
badge: (name: string) => (name ? `[data-test-badge="${name}"]` : '[data-test-badge]'),
tooltip: (label: string) => `[data-test-tooltip="${label}"]`,
};

View file

@ -19,13 +19,14 @@ export async function createSecret(path, key, value) {
return;
}
export const createSecretsEngine = (store, type, path) => {
export const createSecretsEngine = (store, type, path, version) => {
if (store) {
store.pushPayload('secret-engine', {
modelName: 'secret-engine',
id: path,
path: `${path}/`,
type: type,
running_plugin_version: version,
data: {
type: type,
},
@ -36,6 +37,7 @@ export const createSecretsEngine = (store, type, path) => {
return new SecretsEngineResource({
path: `${path}/`,
type,
running_plugin_version: version,
});
};
/* Create configurations methods

View file

@ -5,14 +5,12 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, click, find, findAll, triggerEvent } from '@ember/test-helpers';
import { render, click, findAll, triggerEvent, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { v4 as uuidv4 } from 'uuid';
import sinon from 'sinon';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { overrideResponse } from 'vault/tests/helpers/stubs';
import { clickTrigger } from 'ember-power-select/test-support/helpers';
import { selectChoose } from 'ember-power-select/test-support';
import { createSecretsEngine } from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
@ -37,8 +35,8 @@ module('Integration | Component | secret-engine/list', function (hooks) {
this.secretEngineModels = [
createSecretsEngine(undefined, 'cubbyhole', 'cubbyhole-test'),
createSecretsEngine(undefined, 'kv', 'kv-test'),
createSecretsEngine(undefined, 'aws', 'aws-1'),
createSecretsEngine(undefined, 'aws', 'aws-2'),
createSecretsEngine(undefined, 'aws', 'aws-1', 'v1.0.0'),
createSecretsEngine(undefined, 'aws', 'aws-2', 'v2.0.0'),
createSecretsEngine(undefined, 'nomad', 'nomad-test'),
createSecretsEngine(undefined, 'badType', 'external-test'),
];
@ -66,12 +64,13 @@ module('Integration | Component | secret-engine/list', function (hooks) {
test('hovering over the icon of an external unrecognized engine type sets unrecognized tooltip text', async function (assert) {
await render(hbs`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
await fillIn(GENERAL.inputSearch('secret-engine-path'), 'external-test');
await selectChoose(GENERAL.searchSelect.trigger('filter-by-engine-name'), 'external-test');
await triggerEvent('.hds-tooltip-button', 'mouseenter');
const engineTooltip = document.querySelector(GENERAL.tooltip('Backend type'));
await triggerEvent(engineTooltip, 'mouseenter');
assert
.dom('.hds-tooltip-container')
.dom(engineTooltip.nextSibling)
.hasText(
`This engine's type is not recognized by the UI. Please use the CLI to manage this engine.`,
'shows tooltip text for unknown engine'
@ -80,12 +79,13 @@ module('Integration | Component | secret-engine/list', function (hooks) {
test('hovering over the icon of an unsupported engine sets unsupported tooltip text', async function (assert) {
await render(hbs`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
await fillIn(GENERAL.inputSearch('secret-engine-path'), 'nomad');
await selectChoose(GENERAL.searchSelect.trigger('filter-by-engine-type'), 'nomad');
await triggerEvent('.hds-tooltip-button', 'mouseenter');
const engineTooltip = document.querySelector(GENERAL.tooltip('Backend type'));
await triggerEvent(engineTooltip, 'mouseenter');
assert
.dom('.hds-tooltip-container')
.dom(engineTooltip.nextSibling)
.hasText(
'The UI only supports configuration views for these secret engines. The CLI must be used to manage other engine resources.',
'shows tooltip text for unsupported engine'
@ -94,21 +94,22 @@ module('Integration | Component | secret-engine/list', function (hooks) {
test('hovering over the icon of a supported engine sets engine name as tooltip', async function (assert) {
await render(hbs`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
await selectChoose(GENERAL.searchSelect.trigger('filter-by-engine-name'), 'aws-1');
await fillIn(GENERAL.inputSearch('secret-engine-path'), 'aws-1');
await triggerEvent('.hds-tooltip-button', 'mouseenter');
const engineTooltip = document.querySelector(GENERAL.tooltip('Backend type'));
await triggerEvent(engineTooltip, 'mouseenter');
assert.dom('.hds-tooltip-container').hasText('AWS', 'shows tooltip text for supported engine with name');
assert.dom(engineTooltip.nextSibling).hasText('AWS', 'shows tooltip text for supported engine with name');
});
test('hovering over the icon of a kv engine shows engine name and version', async function (assert) {
await render(hbs`<SecretEngine::List @secretEngines={{this.secretEngineModels}}/>`);
await fillIn(GENERAL.inputSearch('secret-engine-path'), `kv-test`);
await selectChoose(GENERAL.searchSelect.trigger('filter-by-engine-name'), `kv-test`);
await triggerEvent('.hds-tooltip-button', 'mouseenter');
const engineTooltip = document.querySelector(GENERAL.tooltip('Backend type'));
await triggerEvent(engineTooltip, 'mouseenter');
assert
.dom('.hds-tooltip-container')
.dom(engineTooltip.nextSibling)
.hasText('KV version 1', 'shows tooltip text for kv engine with version');
});
@ -126,28 +127,42 @@ module('Integration | Component | secret-engine/list', function (hooks) {
.hasClass('linked-block', `linked-block class is added to supported aws engines.`);
});
test('it filters by name and engine type', async function (assert) {
test('it filters by engine path and engine type', async function (assert) {
await render(hbs`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
// filter by type
await clickTrigger('#filter-by-engine-type');
await click(GENERAL.searchSelect.option());
await click(GENERAL.toggleInput('filter-by-engine-type'));
await click(GENERAL.checkboxByAttr('aws'));
const rows = findAll(SES.secretsBackendLink());
const rowsAws = Array.from(rows).filter((row) => row.innerText.includes('aws'));
assert.strictEqual(rows.length, rowsAws.length, 'all rows returned are aws');
// filter by name
await clickTrigger('#filter-by-engine-name');
const firstItemToSelect = find(GENERAL.searchSelect.option()).innerText;
await click(GENERAL.searchSelect.option());
const singleRow = document.querySelectorAll(SES.secretsBackendLink());
assert.strictEqual(singleRow.length, 1, 'returns only one row');
assert.dom(singleRow[0]).includesText(firstItemToSelect, 'shows the filtered by name engine');
// clear filter by engine name
await click(`#filter-by-engine-name ${GENERAL.searchSelect.removeSelected}`);
// clear filter by type
await click(GENERAL.button('Clear all'));
assert.true(document.querySelectorAll(SES.secretsBackendLink()).length > 1, 'filter has been removed');
// filter by path
await fillIn(GENERAL.inputSearch('secret-engine-path'), 'kv');
const singleRow = document.querySelectorAll(SES.secretsBackendLink());
assert.dom(singleRow[0]).includesText('kv', 'shows the filtered by path engine');
// clear filter by engine path
await fillIn(GENERAL.inputSearch('secret-engine-path'), '');
const rowsAgain = document.querySelectorAll(SES.secretsBackendLink());
assert.true(rowsAgain.length > 1, 'filter has been removed');
assert.true(rowsAgain.length > 1, 'search filter text has been removed');
});
test('it filters by engine version', async function (assert) {
await render(hbs`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
// select engine type
await click(GENERAL.toggleInput('filter-by-engine-type'));
await click(GENERAL.checkboxByAttr('aws'));
// filter by version
await click(GENERAL.toggleInput('filter-by-engine-version'));
await click(GENERAL.checkboxByAttr('v2.0.0'));
const singleRow = document.querySelectorAll(SES.secretsBackendLink());
assert.dom(singleRow[0]).includesText('aws-2', 'shows the single engine filtered by version');
});
test('it applies overflow styling', async function (assert) {