diff --git a/ui/app/components/dashboard/secrets-engines-card.hbs b/ui/app/components/dashboard/secrets-engines-card.hbs index 36f4553829..937dc005e1 100644 --- a/ui/app/components/dashboard/secrets-engines-card.hbs +++ b/ui/app/components/dashboard/secrets-engines-card.hbs @@ -34,7 +34,7 @@ {{backend.path}} @@ -50,7 +50,7 @@ {{/if}} {{#if backend.description}} -
+
{{backend.description}}
{{/if}} diff --git a/ui/app/components/secret-engine/list.hbs b/ui/app/components/secret-engine/list.hbs index 06cce7506a..bebd611a13 100644 --- a/ui/app/components/secret-engine/list.hbs +++ b/ui/app/components/secret-engine/list.hbs @@ -20,6 +20,7 @@ +{{! Filters section }} - + {{#if (and (not this.engineTypeFilters) (not this.engineVersionFilters))}} - No filters applied: - - - + No filters applied. {{else}} Filters applied: {{#each this.engineTypeFilters as |type|}} @@ -98,78 +92,92 @@ /> {{/if}} +{{! End Filters Section }} -{{#each this.sortedDisplayableBackends as |backend|}} - -
-
- {{#if backend.icon}} - - - - {{/if}} - {{#if backend.path}} - {{#if backend.isSupportedBackend}} - - {{backend.path}} - - {{else}} - {{backend.path}} - {{/if}} - {{/if}} -
- - {{backend.accessor}} - {{backend.running_plugin_version}} - - {{#if backend.description}} - - {{backend.description}} - + <:selectedItems> + {{#if this.selectedItems}} + + + {{this.selectedItems.length}} + selected out of + {{this.sortedDisplayableBackends.length}} + + + {{/if}} -
-
+ + <:customTableItem as |itemData|> + + + + {{#if itemData.isSupportedBackend}} + {{itemData.path}} + {{else}} + {{itemData.path}} + {{/if}} + + + <:popupMenu as |rowData|> - View configuration - - {{#if (not-eq backend.type "cubbyhole")}} + @icon="settings" + >View configuration + {{#if (not-eq rowData.type "cubbyhole")}} Disable {{/if}} -
-
+ + {{else}} -{{/each}} +{{/if}} +{{! End Table Section }} {{#if this.engineToDisable}} +{{/if}} + +{{#if this.enginesToDisable}} + {{/if}} \ No newline at end of file diff --git a/ui/app/components/secret-engine/list.ts b/ui/app/components/secret-engine/list.ts index ef13aa2425..8da3e17df5 100644 --- a/ui/app/components/secret-engine/list.ts +++ b/ui/app/components/secret-engine/list.ts @@ -39,6 +39,7 @@ export default class SecretEngineList extends Component { @tracked secretEngineOptions: Array | [] = []; @tracked engineToDisable: SecretsEngineResource | undefined = undefined; + @tracked enginesToDisable: Array | null = null; @tracked engineTypeFilters: Array = []; @tracked engineVersionFilters: Array = []; @@ -48,6 +49,39 @@ export default class SecretEngineList extends Component { @tracked typeSearchText = ''; @tracked versionSearchText = ''; + @tracked selectedItems = Array(); + + 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 { 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) { + 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; } diff --git a/ui/lib/core/addon/components/list-table.hbs b/ui/lib/core/addon/components/list-table.hbs new file mode 100644 index 0000000000..445c91532a --- /dev/null +++ b/ui/lib/core/addon/components/list-table.hbs @@ -0,0 +1,56 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} +{{#if (has-block "selectedItems")}} + {{yield to="selectedItems"}} +{{/if}} + + <:body as |B|> + + {{#each this.columnKeys as |key index|}} + {{#if (and (eq key "popupMenu") (has-block "popupMenu"))}} + + {{yield B.data to="popupMenu"}} + + {{else}} + {{#let (get B.data key) as |value|}} + + {{#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}} + + {{/let}} + {{/if}} + {{/each}} + + + +{{! WORKAROUND to manually re-render Hds::Pagination::Numbered to force update @currentPage }} +{{#if this.renderPagination}} + +{{/if}} \ No newline at end of file diff --git a/ui/lib/core/addon/components/list-table.ts b/ui/lib/core/addon/components/list-table.ts new file mode 100644 index 0000000000..aa2081f9f9 --- /dev/null +++ b/ui/lib/core/addon/components/list-table.ts @@ -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 + * + * + * @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; + columns: TableColumn[]; +} + +export default class ListTable extends Component { + @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; + }); + } +} diff --git a/ui/lib/core/addon/components/list-view.js b/ui/lib/core/addon/components/list-view.js index d0f31d40ee..2f9919e394 100644 --- a/ui/lib/core/addon/components/list-view.js +++ b/ui/lib/core/addon/components/list-view.js @@ -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. * diff --git a/ui/lib/core/app/components/list-table.js b/ui/lib/core/app/components/list-table.js new file mode 100644 index 0000000000..9c34c8e7bd --- /dev/null +++ b/ui/lib/core/app/components/list-table.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/components/list-table'; diff --git a/ui/tests/acceptance/console-test.js b/ui/tests/acceptance/console-test.js index 6fa4bccdca..88fbb02ed4 100644 --- a/ui/tests/acceptance/console-test.js +++ b/ui/tests/acceptance/console-test.js @@ -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'); } }); diff --git a/ui/tests/acceptance/secret-engine-list-view-test.js b/ui/tests/acceptance/secret-engine-list-view-test.js index 8a1e5d1f3a..b0d3e0a1ba 100644 --- a/ui/tests/acceptance/secret-engine-list-view-test.js +++ b/ui/tests/acceptance/secret-engine-list-view-test.js @@ -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); } diff --git a/ui/tests/acceptance/secrets/backend/alicloud/secret-test.js b/ui/tests/acceptance/secrets/backend/alicloud/secret-test.js deleted file mode 100644 index 8844fdf5f9..0000000000 --- a/ui/tests/acceptance/secrets/backend/alicloud/secret-test.js +++ /dev/null @@ -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'); - }); -}); diff --git a/ui/tests/acceptance/secrets/backend/database/secret-test.js b/ui/tests/acceptance/secrets/backend/database/secret-test.js index da4296de90..19c098e680 100644 --- a/ui/tests/acceptance/secrets/backend/database/secret-test.js +++ b/ui/tests/acceptance/secrets/backend/database/secret-test.js @@ -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(), diff --git a/ui/tests/acceptance/secrets/backend/gcpkms/secrets-test.js b/ui/tests/acceptance/secrets/backend/gcpkms/secrets-test.js deleted file mode 100644 index 9643c92c5c..0000000000 --- a/ui/tests/acceptance/secrets/backend/gcpkms/secrets-test.js +++ /dev/null @@ -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'); - }); -}); diff --git a/ui/tests/acceptance/secrets/backend/generic/secret-test.js b/ui/tests/acceptance/secrets/backend/generic/secret-test.js index e865735d16..c7029c20fd 100644 --- a/ui/tests/acceptance/secrets/backend/generic/secret-test.js +++ b/ui/tests/acceptance/secrets/backend/generic/secret-test.js @@ -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', diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js index 3f4be6d652..66de8f2426 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js @@ -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']) => { diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js index 574732d7a6..3c0da9b149 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js @@ -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(' ')); diff --git a/ui/tests/acceptance/secrets/mounts-test.js b/ui/tests/acceptance/secrets/mounts-test.js index 1720738c66..54b819f8ba 100644 --- a/ui/tests/acceptance/secrets/mounts-test.js +++ b/ui/tests/acceptance/secrets/mounts-test.js @@ -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 diff --git a/ui/tests/helpers/secret-engine/secret-engine-selectors.ts b/ui/tests/helpers/secret-engine/secret-engine-selectors.ts index 1d7dda4c6f..91c64f679a 100644 --- a/ui/tests/helpers/secret-engine/secret-engine-selectors.ts +++ b/ui/tests/helpers/secret-engine/secret-engine-selectors.ts @@ -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]'), diff --git a/ui/tests/integration/components/dashboard/secrets-engines-card-test.js b/ui/tests/integration/components/dashboard/secrets-engines-card-test.js index 750bee8b22..f19ab96536 100644 --- a/ui/tests/integration/components/dashboard/secrets-engines-card-test.js +++ b/ui/tests/integration/components/dashboard/secrets-engines-card-test.js @@ -31,10 +31,10 @@ module('Integration | Component | dashboard/secrets-engines-card', function (hoo await render(hbs``); - // 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(); }); diff --git a/ui/tests/integration/components/list-table-test.js b/ui/tests/integration/components/list-table-test.js new file mode 100644 index 0000000000..291c872f69 --- /dev/null +++ b/ui/tests/integration/components/list-table-test.js @@ -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` + + <:customTableItem as |itemData|> + Custom Table Item rendered! + + + <:popupMenu as |rowData|> + + + + + Delete + + + + `); + }; + }); + + test('it renders and paginates data', async function (assert) { + this.data = MOCK_DATA; + await this.renderComponent(); + assert.dom(GENERAL.paginationInfo).hasText(`1–6 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(`11–12 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(`1–2 of ${this.data.length}`); + assert.dom(GENERAL.paginationSizeSelector).hasValue('10', 'page selector is unchanged when data updates'); + }); +}); diff --git a/ui/tests/integration/components/list-test.js b/ui/tests/integration/components/list-test.js index 4b0f146e8c..bad08d3ec8 100644 --- a/ui/tests/integration/components/list-test.js +++ b/ui/tests/integration/components/list-test.js @@ -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``); - 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``); + 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``); - // 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 '); }); }); diff --git a/ui/tests/pages/secrets/backends.js b/ui/tests/pages/secrets/backends.js index 9b688b7e3c..09c7cff342 100644 --- a/ui/tests/pages/secrets/backends.js +++ b/ui/tests/pages/secrets/backends.js @@ -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,