UI: Add new List-table component & replace list with table for Secret Engines (#9724) (#10113)

* sample setup with table & pagination

* adding selected, hooking actions

* no side border

* adding multi disable

* updates to placement, test labels

* updates for pagination, design change on filter

* update test

* update test selectors

* straggler test

* more test fixes

* minor fixes

* change back

* refactor components

* fixing tests, pls be the end

* some cleanup, adding comments

* adding cleanup for these tests

* added changelog

* modernize test and pray

* last try

* updates

* updates and I love tests, pt1

* i love tests pt2

* minor fix

* update card

* update card test

* pr comments

* updating disable tasks, test fix

* fix test

Co-authored-by: Dan Rivera <dan.rivera@hashicorp.com>
This commit is contained in:
Vault Automation 2025-10-14 14:07:21 -04:00 committed by GitHub
parent afeb2e0985
commit ceed8011de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 520 additions and 197 deletions

View File

@ -34,7 +34,7 @@
<LinkTo
@route={{backend.backendLink}}
@model={{backend.id}}
class="has-text-black has-text-weight-semibold overflow-wrap"
class="has-text-black has-text-weight-semibold truncate-first-line"
data-test-secret-path={{backend.path}}
>
{{backend.path}}
@ -50,7 +50,7 @@
</code>
{{/if}}
{{#if backend.description}}
<div data-test-description class="truncate-first-line">
<div data-test-description class="truncate-first-line overflow-wrap word-break">
{{backend.description}}
</div>
{{/if}}

View File

@ -20,6 +20,7 @@
</PH.Actions>
</Hds::PageHeader>
{{! Filters section }}
<Hds::SegmentedGroup class="has-top-margin-m" as |SG|>
<SG.TextInput
@width="300px"
@ -70,16 +71,9 @@
</SG.Dropdown>
</Hds::SegmentedGroup>
<Hds::Layout::Flex @gap="8" class="has-top-margin-xs has-bottom-margin-m" @align="center">
<Hds::Layout::Flex @gap="8" class="has-top-margin-xs has-bottom-margin-xs" @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>
<Hds::Text::Body class="has-top-padding-xs">No filters applied.</Hds::Text::Body>
{{else}}
<Hds::Text::Body>Filters applied:</Hds::Text::Body>
{{#each this.engineTypeFilters as |type|}}
@ -98,78 +92,92 @@
/>
{{/if}}
</Hds::Layout::Flex>
{{! End Filters Section }}
{{#each this.sortedDisplayableBackends as |backend|}}
<LinkedBlock
@params={{array backend.backendLink backend.id}}
class="list-item-row linked-block-item is-no-underline"
data-test-secrets-backend-link={{backend.id}}
@disabled={{not backend.isSupportedBackend}}
{{! Table Section }}
{{#if this.sortedDisplayableBackends}}
<ListTable
class="has-top-margin-xs"
@columns={{this.tableColumns}}
@selectionKey="path"
@data={{this.sortedDisplayableBackends}}
@onSelectionChange={{this.updateSelectedItems}}
>
<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}}
data-test-tooltip="Backend type"
>
<Icon @name={{backend.icon}} class="has-text-grey-light" />
</Hds::TooltipButton>
{{/if}}
{{#if backend.path}}
{{#if backend.isSupportedBackend}}
<LinkTo
@route={{backend.backendLink}}
@model={{backend.id}}
class="has-text-black has-text-weight-semibold overflow-wrap"
data-test-secret-path={{backend.path}}
>
{{backend.path}}
</LinkTo>
{{else}}
<span data-test-secret-path={{backend.path}}>{{backend.path}}</span>
{{/if}}
{{/if}}
</div>
<code class="has-text-grey is-size-8" data-test-engine-accessor>
{{backend.accessor}}
{{backend.running_plugin_version}}
</code>
{{#if backend.description}}
<ReadMore>
{{backend.description}}
</ReadMore>
<:selectedItems>
{{#if this.selectedItems}}
<Hds::Layout::Flex
@gap="8"
@direction="row"
@justify="end"
@align="center"
class="has-bottom-margin-s has-top-margin-negative-xxl"
@isInline="true"
>
<Hds::Text::Body role="status" @tag="p" @size="200" @color="foreground-primary">
{{this.selectedItems.length}}
selected out of
{{this.sortedDisplayableBackends.length}}
</Hds::Text::Body>
<Hds::Button
@text="Disable selected"
@color="critical"
@icon="trash"
{{on "click" (fn (mut this.enginesToDisable) this.selectedItems)}}
/>
</Hds::Layout::Flex>
{{/if}}
</div>
<div class="linked-block-popup-menu">
</:selectedItems>
<:customTableItem as |itemData|>
<Hds::TooltipButton
aria-label="Type of backend"
@text={{this.generateToolTipText itemData}}
data-test-tooltip="Backend type"
isInline={{true}}
class="is-v-centered"
>
<Hds::Icon @name={{if itemData.icon itemData.icon "lock"}} />
</Hds::TooltipButton>
{{#if itemData.isSupportedBackend}}
<Hds::Link::Inline
@route={{itemData.backendLink}}
@model={{itemData.id}}
@color="secondary"
class="has-text-weight-semibold"
>{{itemData.path}}</Hds::Link::Inline>
{{else}}
{{itemData.path}}
{{/if}}
</:customTableItem>
<:popupMenu as |rowData|>
<Hds::Dropdown @isInline={{true}} as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@text="{{if backend.isSupportedBackend 'supported' 'unsupported'}} secrets engine menu"
@text="{{if rowData.isSupportedBackend 'supported' 'unsupported'}} secrets engine menu"
@hasChevron={{false}}
data-test-popup-menu-trigger
/>
<dd.Interactive
@route={{backend.backendConfigurationLink}}
@model={{backend.id}}
@route={{rowData.backendConfigurationLink}}
@model={{rowData.id}}
data-test-popup-menu="view-configuration"
>
View configuration
</dd.Interactive>
{{#if (not-eq backend.type "cubbyhole")}}
@icon="settings"
>View configuration</dd.Interactive>
{{#if (not-eq rowData.type "cubbyhole")}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.engineToDisable) backend)}}
{{on "click" (fn (mut this.engineToDisable) rowData)}}
data-test-popup-menu="disable-engine"
@icon="trash"
>Disable</dd.Interactive>
{{/if}}
</Hds::Dropdown>
</div>
</LinkedBlock>
</:popupMenu>
</ListTable>
{{else}}
<EmptyState @title="No Secrets engines found" />
{{/each}}
{{/if}}
{{! End Table Section }}
{{#if this.engineToDisable}}
<ConfirmModal
@ -179,4 +187,14 @@
@onClose={{fn (mut this.engineToDisable) null}}
@onConfirm={{perform this.disableEngine this.engineToDisable}}
/>
{{/if}}
{{#if this.enginesToDisable}}
<ConfirmModal
@color="critical"
@confirmMessage="Any data in these engines will be permanently deleted."
@confirmTitle="Disable engines?"
@onClose={{fn (mut this.enginesToDisable) null}}
@onConfirm={{perform this.disableMultipleEngines this.enginesToDisable}}
/>
{{/if}}

View File

@ -39,6 +39,7 @@ export default class SecretEngineList extends Component<Args> {
@tracked secretEngineOptions: Array<string> | [] = [];
@tracked engineToDisable: SecretsEngineResource | undefined = undefined;
@tracked enginesToDisable: Array<SecretsEngineResource> | null = null;
@tracked engineTypeFilters: Array<string> = [];
@tracked engineVersionFilters: Array<string> = [];
@ -48,6 +49,39 @@ export default class SecretEngineList extends Component<Args> {
@tracked typeSearchText = '';
@tracked versionSearchText = '';
@tracked selectedItems = Array<string>();
tableColumns = [
{
key: 'path',
label: 'Engine path',
isSortable: true,
width: '250px',
customTableItem: true,
},
{
key: 'accessor',
label: 'Accessor',
width: '150px',
},
{
key: 'description',
label: 'Description',
width: '300px',
},
{
key: 'running_plugin_version',
label: 'Version',
isSortable: true,
width: '150px',
},
{
key: 'popupMenu',
label: 'Action',
width: '75px',
},
];
get clusterName() {
return this.version.clusterName;
}
@ -196,18 +230,46 @@ export default class SecretEngineList extends Component<Args> {
this.engineVersionFilters = [];
}
@dropTask
*disableEngine(engine: SecretsEngineResource) {
@action
updateSelectedItems(tableData: { selectedRowsKeys: string[] }) {
this.selectedItems = tableData.selectedRowsKeys;
}
async disableSingleEngine(engine: SecretsEngineResource) {
const { engineType, id, path } = engine;
try {
yield this.api.sys.mountsDisableSecretsEngine(id);
await this.api.sys.mountsDisableSecretsEngine(id);
this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`);
this.router.transitionTo('vault.cluster.secrets.backends');
} catch (err) {
const { message } = yield this.api.parseError(err);
const { message } = await this.api.parseError(err);
this.flashMessages.danger(
`There was an error disabling the ${engineType} Secrets Engines at ${path}: ${message}.`
`There was an error disabling the ${engineType} Secrets Engine at ${path}: ${message}.`
);
}
}
@dropTask
*disableMultipleEngines(enginePathsToDisable: Array<string>) {
const enginesToDisable = this.displayableBackends.filter((engine: SecretsEngineResource) =>
enginePathsToDisable.includes(engine.path)
);
try {
for (const engine of enginesToDisable) {
yield this.disableSingleEngine(engine);
}
// Navigate once all operations are complete
this.router.transitionTo('vault.cluster.secrets.backends');
} finally {
this.enginesToDisable = null;
}
}
@dropTask
*disableEngine(engine: SecretsEngineResource) {
try {
yield this.disableSingleEngine(engine);
this.router.transitionTo('vault.cluster.secrets.backends');
} finally {
this.engineToDisable = undefined;
}

View File

@ -0,0 +1,56 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (has-block "selectedItems")}}
{{yield to="selectedItems"}}
{{/if}}
<Hds::Table
@model={{this.paginatedTableData}}
@columns={{@columns}}
@isSelectable={{if @selectionKey true false}}
class="has-bottom-margin-s"
@onSelectionChange={{@onSelectionChange}}
@isFixedLayout={{true}}
{{did-update this.resetPagination @data}}
>
<:body as |B|>
<B.Tr
@selectionKey={{get B.data @selectionKey}}
@selectionAriaLabelSuffix="row {{B.data.path}}"
data-test-table-row={{get B.data @selectionKey}}
>
{{#each this.columnKeys as |key index|}}
{{#if (and (eq key "popupMenu") (has-block "popupMenu"))}}
<B.Td data-test-table-data="popupMenu">
{{yield B.data to="popupMenu"}}
</B.Td>
{{else}}
{{#let (get B.data key) as |value|}}
<B.Td data-test-table-data={{key}} class="text-overflow-ellipsis">
{{#if (get (get @columns index) "customTableItem")}}
{{yield B.data to="customTableItem"}}
{{else}}
{{! stringify value if it is an array or object, otherwise render directly }}
{{if (this.isObject value) (stringify value) value}}
{{/if}}
</B.Td>
{{/let}}
{{/if}}
{{/each}}
</B.Tr>
</:body>
</Hds::Table>
{{! WORKAROUND to manually re-render Hds::Pagination::Numbered to force update @currentPage }}
{{#if this.renderPagination}}
<Hds::Pagination::Numbered
class="has-bottom-margin-m is-fullwidth"
@currentPage={{this.currentPage}}
@currentPageSize={{this.pageSize}}
@onPageChange={{fn this.handlePaginationChange "currentPage"}}
@onPageSizeChange={{fn this.handlePaginationChange "pageSize"}}
@pageSizes={{array 5 10 25 50 100}}
data-test-pagination
@totalItems={{@data.length}}
/>
{{/if}}

View File

@ -0,0 +1,87 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { action } from '@ember/object';
import { next } from '@ember/runloop';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { paginate } from 'core/utils/paginate-list';
/**
* @module ListTable
* `ListTable` component is used for rendering a list of items in a table.
*
* @example
* <ListTable
* @columns={{this.tableColumns}}
* @data={{this.data}}
* @isSelectable={{true}}
* @onSelectionChange={{this.updateSelectedItems}}
* >
*
* @param {array} columns - An array of type TableColumn, for populating table headers and any optional functionality (ie. isSortable...)
* @param {array} data - An array of data to display corresponding to columns. (ie. the key from a column corresponds to the parameter value of the data object your passing in)
* @param {string} [selectionKey] - string of desired param value to be used as a unique identifier for a selected row - setting this arg automatically sets 'isSelectable' on the table to make rows selectable
* @param {function} [onSelectionChange] - Provided function for handling when rows are selected
*
* For custom column items that are not 1 to 1 with their dataset (ie. these items have conditional icons, colors, generated text etc)
* within the column type, the 'customTableItem' flag will allow the parent component to {{yield}} any custom implementation from the parent for those items
* but the yield block in the parent must be 'customTableItem'.
*
* similarly, if 'isSelectable' is true and 'onSelectionChange' is being handled
* For displaying new content based on what's selected, the parent component can also pass in a yield block 'selectedItems'
*
* If there's an 'Action' column (ie. possibly for manipulating data rows, or navigating to a page per that row data, etc)
* The parent component must specify the key as 'popupMenu' for that column and pass in a yield block 'popupMenu' for it to render per each item under the 'action' column.
*
*/
interface TableColumn {
key: string;
label: string;
selectionKey?: string;
customTableItem?: boolean;
onSelectionChange?: CallableFunction;
}
interface Args {
data: Array<object>;
columns: TableColumn[];
}
export default class ListTable extends Component<Args> {
@tracked currentPage = 1;
@tracked pageSize = 10;
// WORKAROUND to manually re-render Hds::Pagination::Numbered to force update @currentPage
@tracked renderPagination = true;
get paginatedTableData() {
const paginated = paginate(this.args.data, {
page: this.currentPage,
pageSize: this.pageSize,
});
return paginated;
}
get columnKeys() {
return this.args.columns.map((k: TableColumn) => k['key'] ?? k['label']);
}
@action
handlePaginationChange(action: 'currentPage' | 'pageSize', value: number) {
this[action] = value;
}
@action
async resetPagination() {
this.renderPagination = false;
this.currentPage = 1;
// WORKAROUND to manually re-render Hds::Pagination::Numbered to force update @currentPage
next(() => {
this.renderPagination = true;
});
}
}

View File

@ -7,6 +7,7 @@ import Component from '@glimmer/component';
import { pluralize } from 'ember-inflector';
/**
* @deprecated As of 2025, This component is deprecated. Please refer to other related components such as 'list-table'
* @module ListView
* `ListView` components are used in conjunction with `ListItem` for rendering a list.
*

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/components/list-table';

View File

@ -4,12 +4,13 @@
*/
import { module, test } from 'qunit';
import { settled, waitUntil, click } from '@ember/test-helpers';
import { settled, waitUntil, click, visit } from '@ember/test-helpers';
import { create } from 'ember-cli-page-object';
import { setupApplicationTest } from 'ember-qunit';
import enginesPage from 'vault/tests/pages/secrets/backends';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { v4 as uuidv4 } from 'uuid';
const consoleComponent = create(consoleClass);
@ -23,8 +24,7 @@ module('Acceptance | console', function (hooks) {
test("refresh reloads the current route's data", async function (assert) {
assert.expect(6);
await enginesPage.visit();
await settled();
await visit(`/vault/secrets`);
const ids = [uuidv4(), uuidv4(), uuidv4()];
for (const id of ids) {
const inputString = `write sys/mounts/console-route-${id} type=kv`;
@ -34,10 +34,7 @@ module('Acceptance | console', function (hooks) {
await consoleComponent.runCommands('refresh');
await settled();
for (const id of ids) {
assert.ok(
enginesPage.rows.findOneBy('path', `console-route-${id}/`),
'new engine is shown on the page'
);
assert.dom(GENERAL.tableRow(`console-route-${id}/`)).exists('new engine is shown on the page');
}
// Clean up
for (const id of ids) {
@ -48,9 +45,7 @@ module('Acceptance | console', function (hooks) {
await consoleComponent.runCommands('refresh');
await settled();
for (const id of ids) {
assert.throws(() => {
enginesPage.rows.findOneBy('path', `console-route-${id}/`);
}, 'engine was removed');
assert.dom(GENERAL.tableRow(`console-route-${id}/`)).doesNotExist('engine was removed');
}
});

View File

@ -50,7 +50,7 @@ module('Acceptance | secret-engine list view', function (hooks) {
'vault.cluster.secrets.backends',
'breadcrumb navigates to the list page'
);
assert.dom(SES.secretsBackendLink('aws_engine')).hasTextContaining('aws_engine/');
assert.dom(GENERAL.tableData('aws_engine/', 'path')).hasTextContaining('aws_engine/');
// cleanup
await runCmd(deleteEngineCmd('aws_engine'));
});
@ -101,7 +101,7 @@ module('Acceptance | secret-engine list view', function (hooks) {
`/vault/secrets?namespace=${this.namespace}`,
'Should be on main secret engines list page within namespace.'
);
assert.dom(SES.secretsBackendLink(enginePath1)).doesNotExist(); // without permissions, engine should not show for this user
assert.dom(GENERAL.tableData(`${enginePath1}/`, 'path')).doesNotExist(); // without permissions, engine should not show for this user
// cleanup namespace
await login();
@ -133,7 +133,7 @@ module('Acceptance | secret-engine list view', function (hooks) {
'Should be on main secret engines list page within namespace.'
);
assert.dom(SES.secretsBackendLink(enginePath1)).exists(); // with permissions, able to see the engine in list
assert.dom(GENERAL.tableData(`${enginePath1}/`, 'path')).exists(); // with permissions, able to see the engine in list
// cleanup namespace
await login();
@ -146,7 +146,7 @@ module('Acceptance | secret-engine list view', function (hooks) {
await runCmd([`write sys/namespaces/${this.namespace} -force`]);
await loginNs(this.namespace);
await visit(`/vault/secrets?namespace=${this.namespace}`);
await click(SES.secretsBackendLink('cubbyhole'));
await click(`${GENERAL.tableData('cubbyhole/', 'path')} a`);
assert.dom(GENERAL.emptyStateTitle).hasText('No secrets in this backend');
@ -162,7 +162,7 @@ module('Acceptance | secret-engine list view', function (hooks) {
await visit('/vault/secrets');
// to reduce flakiness, searching by engine name first in case there are pagination issues
await fillIn(GENERAL.inputSearch('secret-engine-path'), enginePath);
assert.dom(SES.secretsBackendLink(enginePath)).exists('the alicloud engine is mounted');
assert.dom(GENERAL.tableData(`${enginePath}/`, 'path')).exists('the alicloud engine is mounted');
await click(GENERAL.menuTrigger);
await click(GENERAL.menuItem('disable-engine'));
@ -183,7 +183,7 @@ module('Acceptance | secret-engine list view', function (hooks) {
// check kv1
await visit('/vault/secrets');
await click(SES.secretsBackendLink(enginePath1));
await click(`${GENERAL.tableData(`${enginePath1}/`, 'path')} a`);
for (let i = 0; i <= 15; i++) {
await createSecret(`secret-${i}`, 'foo', 'bar', enginePath1);
}
@ -216,7 +216,7 @@ module('Acceptance | secret-engine list view', function (hooks) {
// check kv1
await visit('/vault/secrets');
await click(SES.secretsBackendLink(enginePath1));
await click(`${GENERAL.tableData(`${enginePath1}/`, 'path')} a`);
for (let i = 0; i <= 15; i++) {
await createSecret(`${parentPath}/secret-${i}`, 'foo', 'bar', enginePath1);
}

View File

@ -1,38 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { currentRouteName, settled } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
import mountSecrets from 'vault/tests/pages/settings/mount-secret-backend';
import backendsPage from 'vault/tests/pages/secrets/backends';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers';
module('Acceptance | alicloud/enable', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.uid = uuidv4();
return login();
});
test('enable alicloud', async function (assert) {
const enginePath = `alicloud-${this.uid}`;
await mountSecrets.visit();
await settled();
await mountBackend('alicloud', enginePath);
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backends',
'redirects to the backends page'
);
await settled();
assert.ok(backendsPage.rows.filterBy('path', `${enginePath}/`)[0], 'shows the alicloud engine');
});
});

View File

@ -41,7 +41,7 @@ const newConnection = async (
const navToConnection = async (backend, connection) => {
await visit('/vault/secrets');
await click(SES.secretsBackendLink(backend));
await click(`${GENERAL.tableData(`${backend}/`, 'path')} a`);
await click(GENERAL.secretTab('Connections'));
await click(SES.secretLink(connection));
return;
@ -524,7 +524,7 @@ module('Acceptance | secrets/database/*', function (hooks) {
// Check with restricted permissions
await login(token);
await click('[data-test-sidebar-nav-link="Secrets Engines"]');
assert.dom(SES.secretsBackendLink(backend)).exists('Shows backend on secret list page');
assert.dom(GENERAL.tableData(`${backend}/`, 'path')).exists('Shows backend on secret list page');
await navToConnection(backend, connection);
assert.strictEqual(
currentURL(),

View File

@ -1,38 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { currentRouteName, settled } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
import mountSecrets from 'vault/tests/pages/settings/mount-secret-backend';
import backendsPage from 'vault/tests/pages/secrets/backends';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers';
module('Acceptance | gcpkms/enable', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.uid = uuidv4();
return login();
});
test('enable gcpkms', async function (assert) {
// Error: Cannot call `visit` without having first called `setupApplicationContext`.
const enginePath = `gcpkms-${this.uid}`;
await mountSecrets.visit();
await settled();
await mountBackend('gcpkms', enginePath);
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backends',
'redirects to the backends page'
);
assert.ok(backendsPage.rows.filterBy('path', `${enginePath}/`)[0], 'shows the gcpkms engine');
});
});

View File

@ -67,7 +67,7 @@ module('Acceptance | secrets/generic/create', function (hooks) {
]);
await visit('/vault/secrets');
await fillIn(GENERAL.inputSearch('secret-engine-path'), path);
await click(SES.secretsBackendLink(path));
await click(`${GENERAL.tableData(`${path}/`, 'path')} a`);
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.list',

View File

@ -35,7 +35,6 @@ import {
import { writeSecret, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import codemirror, { getCodeEditorValue, setCodeEditorValue } from 'vault/tests/helpers/codemirror';
import { personas } from 'vault/tests/helpers/kv/policy-generator';
import { capabilitiesStub } from 'vault/tests/helpers/stubs';
@ -511,7 +510,7 @@ module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks)
const navToEngine = async (backend) => {
await click(GENERAL.navLink('Secrets Engines'));
return await click(SES.secretsBackendLink(backend));
return await click(`${GENERAL.tableData(`${backend}/`, 'path')} a`);
};
const assertDeleteActions = (assert, expected = ['delete', 'destroy']) => {

View File

@ -34,7 +34,6 @@ import {
} from 'vault/tests/helpers/kv/kv-run-commands';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import { setupControlGroup, grantAccess } from 'vault/tests/helpers/control-groups';
const secretPath = `my-#:$=?-secret`;
@ -44,7 +43,7 @@ const secretPathUrlEncoded = `my-%23:$=%3F-secret`;
const ALL_TABS = ['Overview', 'Secret', 'Metadata', 'Paths', 'Version History'];
const navToBackend = async (backend) => {
await visit(`/vault/secrets`);
return click(SES.secretsBackendLink(backend));
return click(`${GENERAL.tableData(`${backend}/`, 'path')} a`);
};
const assertCorrectBreadcrumbs = (assert, expected) => {
assert.dom(PAGE.breadcrumbs).hasText(expected.join(' '));

View File

@ -138,7 +138,9 @@ module('Acceptance | secrets/mounts', function (hooks) {
await page.secretList();
await settled();
assert.dom(SES.secretsBackendLink(path)).exists({ count: 1 }, 'renders only one instance of the engine');
assert
.dom(GENERAL.tableData(`${path}/`, 'path'))
.exists({ count: 1 }, 'renders only one instance of the engine');
});
test('version 2 with no update to config endpoint still allows mount of secret engine', async function (assert) {
@ -319,6 +321,40 @@ module('Acceptance | secrets/mounts', function (hooks) {
);
});
// Condensed tests for these specific engines here as they just check if they are added to the list after mounting
test('enable alicloud', async function (assert) {
const enginePath = `alicloud-${this.uid}`;
await mountSecrets.visit();
await mountBackend('alicloud', enginePath);
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backends',
'redirects to the backends page'
);
assert.ok(GENERAL.tableData(`${enginePath}/`, 'path'), 'shows the alicloud engine');
// cleanup
await runCmd(`delete sys/mounts/${enginePath}`);
});
test('enable gcpkms', async function (assert) {
const enginePath = `gcpkms-${this.uid}`;
await mountSecrets.visit();
await mountBackend('gcpkms', enginePath);
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backends',
'redirects to the backends page'
);
assert.ok(GENERAL.tableData(`${enginePath}/`, 'path'), 'shows the gcpkms engine');
// cleanup
await runCmd(`delete sys/mounts/${enginePath}`);
});
module('WIF secret engines', function () {
test('it sets identity_token_key on mount config using search select list, resets after', async function (assert) {
// create an oidc/key

View File

@ -18,7 +18,7 @@ export const SECRET_ENGINE_SELECTORS = {
secretsBackendLink: (path: string) =>
path ? `[data-test-secrets-backend-link="${path}"]` : '[data-test-secrets-backend-link]',
createSecretLink: '[data-test-create-secret-link]',
secretPath: (name: string) => `[data-test-secret-path="${name}"]`,
secretPath: (name: string) => (name ? `[data-test-secret-path="${name}"]` : '[data-test-secret-path]'),
secretKey: (name: string) => `[data-test-secret-key="${name}"]`,
secretHeader: '[data-test-secret-header]',
secretLink: (name: string) => (name ? `[data-test-secret-link="${name}"]` : '[data-test-secret-link]'),

View File

@ -31,10 +31,10 @@ module('Integration | Component | dashboard/secrets-engines-card', function (hoo
await render(hbs`<Dashboard::SecretsEnginesCard @secretsEngines={{this.secretsEngines}} />`);
// verify overflow style exists on secret engine text
// verify truncate class style exists on secret engine path text
assert
.dom(SES.secretPath('kubernetes-test/'))
.hasClass('overflow-wrap', 'secret engine name has overflow class ');
.hasClass('truncate-first-line', 'secret engine name has truncate class to handle overflow');
assert.dom('[data-test-secrets-engines-card-show-all]').doesNotExist();
});

View File

@ -0,0 +1,139 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, fillIn, render, waitFor, find } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const MOCK_DATA = [
{ island: 'Maldives', visit_length: 5, trip_date: '2025-06-22T00:00:00.000Z' },
{ island: 'Bora Bora', visit_length: 7, trip_date: '2025-03-15T00:00:00.000Z' },
{ island: 'Fiji', visit_length: 10, trip_date: '2025-09-08T00:00:00.000Z' },
{ island: 'Santorini', visit_length: 4, trip_date: '2026-04-10T00:00:00.000Z' },
{ island: 'Maui', visit_length: 8, trip_date: '2026-01-18T00:00:00.000Z' },
{ island: 'Seychelles', visit_length: 6, trip_date: '2025-12-03T00:00:00.000Z' },
];
module('Integration | Component | list-table', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(async function () {
this.data = undefined;
this.columns = [
{ key: 'island', label: 'Islands', isSortable: true },
{ key: 'visit_length', label: 'Visit length', customTableItem: true },
{ key: 'trip_date', label: 'Date trip starts' },
{ key: 'popupMenu', label: 'Action' },
];
this;
this.renderComponent = async () => {
return render(hbs`
<ListTable
@columns={{this.columns}}
@data={{this.data}}
@selectionKey="island"
>
<:customTableItem as |itemData|>
Custom Table Item rendered!
</:customTableItem>
<:popupMenu as |rowData|>
<Hds::Dropdown as |D|>
<D.ToggleButton @text="Menu" data-test-popup-menu-trigger />
<D.Title @text="Title Text" />
<D.Description @text="Sample text" />
<D.Interactive @route="components" @icon="trash" @color="critical">Delete</D.Interactive>
</Hds::Dropdown>
</:popupMenu>
</ListTable>`);
};
});
test('it renders and paginates data', async function (assert) {
this.data = MOCK_DATA;
await this.renderComponent();
assert.dom(GENERAL.paginationInfo).hasText(`16 of ${this.data.length}`);
await fillIn(GENERAL.paginationSizeSelector, '5'); // Default is 10, so change to something else
await click(GENERAL.nextPage);
assert.dom(GENERAL.tableRow('Seychelles', 'island')).exists('it paginates the data');
});
test('it sorts table data by a sortable column', async function (assert) {
this.data = MOCK_DATA;
const assertSortOrder = (expectedValues, { column, page }) => {
expectedValues.forEach((value, idx) => {
assert
.dom(GENERAL.tableData(value, column))
.hasText(value, `page ${page}, row ${idx} has ${column}: ${value}`);
});
};
await this.renderComponent();
const column = find(GENERAL.icon('swap-vertical'));
await click(column);
assertSortOrder(['Bora Bora', 'Fiji', 'Maldives', 'Maui', 'Santorini', 'Seychelles'], {
column: 'island',
page: 1,
});
});
test('action column renders provided yield block with popup menu', async function (assert) {
this.data = MOCK_DATA;
await this.renderComponent();
assert.dom(GENERAL.tableData('Maldives', 'popupMenu')).exists('action column renders');
assert.dom(GENERAL.menuTrigger).exists('button trigger exists for popup menu');
});
test('selectable column renders when isSelectable is true', async function (assert) {
this.data = MOCK_DATA;
await this.renderComponent();
assert
.dom(`${GENERAL.tableRow('Maldives')} > th`)
.hasClass('hds-table__th--is-selectable', 'selectable column renders for row');
});
// check that a custom item block will render
test('custom item renders provided yield block with customTableItem for a column has customTableItem set to true', async function (assert) {
this.data = MOCK_DATA;
await this.renderComponent();
assert
.dom(GENERAL.tableData('Maldives', 'visit_length'))
.hasText('Custom Table Item rendered!', 'custom item renders');
});
test('it resets pagination when data changes', async function (assert) {
const moreData = [
{ island: 'Tahiti', visit_length: 12, trip_date: '2025-05-10T00:00:00.000Z' },
{ island: 'Barbados', visit_length: 6, trip_date: '2025-08-25T00:00:00.000Z' },
{ island: 'Cyprus', visit_length: 9, trip_date: '2026-03-12T00:00:00.000Z' },
{ island: 'Jamaica', visit_length: 7, trip_date: '2025-11-05T00:00:00.000Z' },
{ island: 'Crete', visit_length: 11, trip_date: '2026-06-18T00:00:00.000Z' },
{ island: 'Aruba', visit_length: 5, trip_date: '2025-10-14T00:00:00.000Z' },
];
this.data = [...MOCK_DATA, ...moreData];
await this.renderComponent();
await click(GENERAL.nextPage);
``;
assert.dom(GENERAL.paginationInfo).hasText(`1112 of ${this.data.length}`, 'it navigates to next page');
// Changing the @data arg should trigger an update and reset pagination
this.set('data', [
{ island: 'Palawan', visit_length: 9, trip_date: '2025-11-14T00:00:00.000Z' },
{ island: 'Mykonos', visit_length: 3, trip_date: '2026-02-28T00:00:00.000Z' },
]);
// There's a workaround using next() from @ember/runloop because the Hds::Pagination::Numbered component
// doesn't re-render when @currentPage updates. When that's fixed at the source we should be able to remove waitFor
await waitFor(GENERAL.paginationInfo);
assert.dom(GENERAL.paginationInfo).hasText(`12 of ${this.data.length}`);
assert.dom(GENERAL.paginationSizeSelector).hasValue('10', 'page selector is unchanged when data updates');
});
});

View File

@ -5,7 +5,7 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, click, findAll, triggerEvent, fillIn } from '@ember/test-helpers';
import { render, click, findAll, triggerEvent, fillIn, find } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { v4 as uuidv4 } from 'uuid';
import sinon from 'sinon';
@ -26,6 +26,8 @@ module('Integration | Component | secret-engine/list', function (hooks) {
},
}));
this.version = this.owner.lookup('service:version');
this.router = this.owner.lookup('service:router');
this.router.transitionTo = sinon.stub();
this.flashMessages = this.owner.lookup('service:flash-messages');
this.flashMessages.registerTypes(['success', 'danger']);
this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success');
@ -50,8 +52,10 @@ module('Integration | Component | secret-engine/list', function (hooks) {
});
await render(hbs`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
assert.dom(SES.secretsBackendLink(enginePath)).exists('shows the link for the kvv2 secrets engine');
const row = SES.secretsBackendLink(enginePath);
assert
.dom(GENERAL.tableData(`${enginePath}/`, 'path'))
.exists('shows the link for the kvv2 secrets engine');
const row = GENERAL.tableRow(`${enginePath}/`);
await click(`${row} ${GENERAL.menuTrigger}`);
await click(GENERAL.menuItem('disable-engine'));
await click(GENERAL.confirmButton);
@ -113,18 +117,15 @@ module('Integration | Component | secret-engine/list', function (hooks) {
.hasText('KV version 1', 'shows tooltip text for kv engine with version');
});
test('it adds disabled css styling to unsupported secret engines', async function (assert) {
test('path name does not render as link for unsupported secret engines', async function (assert) {
await render(hbs`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
const unsupportedPath = find(`${GENERAL.tableData('nomad-test/', 'path')} a`);
assert
.dom(SES.secretsBackendLink('nomad-test'))
.doesNotHaveClass(
'linked-block',
`the linked-block class is not added to the unsupported nomad engine, which effectively disables it.`
);
.dom(unsupportedPath)
.doesNotExist(`path text doesn't render as a link for unsupported nomad engine.`);
assert
.dom(SES.secretsBackendLink('aws-1'))
.hasClass('linked-block', `linked-block class is added to supported aws engines.`);
const supportedPath = find(`${GENERAL.tableData('aws-1/', 'path')} a`);
assert.dom(supportedPath).exists(`path text renders as a link for supported aws engines.`);
});
test('it filters by engine path and engine type', async function (assert) {
@ -133,22 +134,22 @@ module('Integration | Component | secret-engine/list', function (hooks) {
await click(GENERAL.toggleInput('filter-by-engine-type'));
await click(GENERAL.checkboxByAttr('aws'));
const rows = findAll(SES.secretsBackendLink());
const rows = findAll(SES.secretPath());
const rowsAws = Array.from(rows).filter((row) => row.innerText.includes('aws'));
assert.strictEqual(rows.length, rowsAws.length, 'all rows returned are aws');
// clear filter by type
await click(GENERAL.button('Clear all'));
assert.true(document.querySelectorAll(SES.secretsBackendLink()).length > 1, 'filter has been removed');
assert.true(document.querySelectorAll(GENERAL.tableRow()).length > 1, 'filter has been removed');
// filter by path
await fillIn(GENERAL.inputSearch('secret-engine-path'), 'kv');
const singleRow = document.querySelectorAll(SES.secretsBackendLink());
const singleRow = document.querySelectorAll(SES.secretPath());
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());
const rowsAgain = document.querySelectorAll(GENERAL.tableRow());
assert.true(rowsAgain.length > 1, 'search filter text has been removed');
});
@ -161,14 +162,14 @@ module('Integration | Component | secret-engine/list', function (hooks) {
// filter by version
await click(GENERAL.toggleInput('filter-by-engine-version'));
await click(GENERAL.checkboxByAttr('v2.0.0'));
const singleRow = document.querySelectorAll(SES.secretsBackendLink());
const singleRow = document.querySelectorAll(SES.secretPath());
assert.dom(singleRow[0]).includesText('aws-2', 'shows the single engine filtered by version');
});
test('it applies overflow styling', async function (assert) {
await render(hbs`<SecretEngine::List @secretEngines={{this.secretEngineModels}} />`);
// not using the secret-engine-selector "secretPath" because I want to return the first node of a querySelectorAll
const firstSecretEngine = document.querySelectorAll('[data-test-secret-path]')[0];
assert.dom(firstSecretEngine).hasClass('overflow-wrap', 'secret engine name has overflow class ');
assert
.dom(GENERAL.tableData('aws-1/', 'path'))
.hasClass('text-overflow-ellipsis', 'secret engine name has text overflow class ');
});
});

View File

@ -9,8 +9,8 @@ import uiPanel from 'vault/tests/pages/components/console/ui-panel';
export default create({
consoleToggle: clickable('[data-test-console-toggle]'),
visit: visitable('/vault/secrets'),
rows: collection('[data-test-secrets-backend-link]', {
path: text('[data-test-secret-path]'),
rows: collection('[data-test-table-row]', {
path: text('[data-test-table-data]'),
menu: clickable('[data-test-popup-menu-trigger]'),
}),
console: uiPanel,