From 7fb5eb72b8b514aa96e308dfe5619d5e772eeaf8 Mon Sep 17 00:00:00 2001 From: "Shannon Roberts (Beagin)" Date: Tue, 20 May 2025 09:30:05 -0700 Subject: [PATCH] [VAULT-34484] UI: Manage Namespaces: Enable Search (#30680) * [VAULT-34484] UI: Manage Namespaces: Enable Search * load query param in search field on page load * add jsdocs * add test coverage * fix naming collision issue * refresh namespace list on delete namespace + test coverage * revert unnecessary check * return model * add changelog --- changelog/30680.txt | 7 ++ .../vault/cluster/access/namespaces/index.js | 99 +++++++++++++-- .../vault/cluster/access/namespaces/index.js | 114 ++++++++++-------- .../vault/cluster/access/namespaces/index.hbs | 55 +++++---- ui/lib/core/addon/components/list-view.hbs | 3 +- .../app/components/filter-input-explicit.js | 21 ++++ ui/mirage/handlers/base.js | 8 ++ .../access/namespaces/index-test.js | 100 ++++++++++++++- .../acceptance/enterprise-namespaces-test.js | 35 ++++++ 9 files changed, 350 insertions(+), 92 deletions(-) create mode 100644 changelog/30680.txt diff --git a/changelog/30680.txt b/changelog/30680.txt new file mode 100644 index 0000000000..a6f2af0fd9 --- /dev/null +++ b/changelog/30680.txt @@ -0,0 +1,7 @@ +```release-note:improvement +ui: Enable search for a namespace on the namespace list page. +``` + +```release-note:bug +ui: Fix refresh namespace list after deleting a namespace. +``` diff --git a/ui/app/controllers/vault/cluster/access/namespaces/index.js b/ui/app/controllers/vault/cluster/access/namespaces/index.js index 5eb3e744d8..125c498105 100644 --- a/ui/app/controllers/vault/cluster/access/namespaces/index.js +++ b/ui/app/controllers/vault/cluster/access/namespaces/index.js @@ -4,17 +4,90 @@ */ import { service } from '@ember/service'; -import { alias } from '@ember/object/computed'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import Controller from '@ember/controller'; -export default Controller.extend({ - namespaceService: service('namespace'), - accessibleNamespaces: alias('namespaceService.accessibleNamespaces'), - currentNamespace: alias('namespaceService.path'), - actions: { - refreshNamespaceList() { - // fetch new namespaces for the namespace picker - this.namespaceService.findNamespacesForUser.perform(); - this.send('reload'); - }, - }, -}); +import keys from 'core/utils/keys'; + +/** + * @module ManageNamespaces + * ManageNamespacesController is the controller for the + * vault.cluster.access.namespaces.index route. + * + * @param {object} namespaces - list of namespaces + * @param {string} pageFilter - value of queryParam + * @param {string} page - value of queryParam + */ +export default class ManageNamespacesController extends Controller { + queryParams = ['pageFilter', 'page']; + + // Use namespaceService alias to avoid collision with namespaces + // input parameter from the route. + @service('namespace') namespaceService; + @service router; + @service flashMessages; + + // The `query` property is used to track the filter + // input value seperately from updating the `pageFilter` + // browser query param to prevent unnecessary re-renders. + @tracked query; + @tracked pageFilter = ''; + + constructor() { + super(...arguments); + this.query = this.pageFilter; + } + + navigate(pageFilter) { + const route = 'vault.cluster.access.namespaces.index'; + const args = [route, { queryParams: { page: 1, pageFilter: pageFilter || null } }]; + this.router.transitionTo(...args); + } + + @action + handleKeyDown(event) { + const isEscKeyPressed = keys.ESC.includes(event.key); + if (isEscKeyPressed) { + // On escape, transition to roles index route. + this.navigate(); + } + // ignore all other key events + } + + @action handleInput(evt) { + this.query = evt.target.value; + } + + @action + handleSearch(evt) { + evt.preventDefault(); + this.navigate(this.query); + } + + @action + async deleteNamespace(nsToDelete) { + try { + // Attempt to destroy the record + await nsToDelete.destroyRecord(); + + // Log success and optionally update the UI + this.flashMessages.success(`Successfully deleted namespace: ${nsToDelete.id}`); + + // Call the refresh method to update the list + this.refreshNamespaceList(); + } catch (error) { + this.flashMessages.danger(`There was an error deleting this namespace: ${error.message}`); + } + } + + // Refresh the namespace list + async refreshNamespaceList() { + try { + // Await the async operation to complete + await this.namespaceService.findNamespacesForUser.perform(); + this.send('reload'); // Trigger the reload only after the task completes + } catch (error) { + this.flashMessages.danger('There was an error refreshing the namespace list.'); + } + } +} diff --git a/ui/app/routes/vault/cluster/access/namespaces/index.js b/ui/app/routes/vault/cluster/access/namespaces/index.js index 5ff4ee85bc..5ed540c09f 100644 --- a/ui/app/routes/vault/cluster/access/namespaces/index.js +++ b/ui/app/routes/vault/cluster/access/namespaces/index.js @@ -5,47 +5,54 @@ import { service } from '@ember/service'; import Route from '@ember/routing/route'; -import UnloadModel from 'vault/mixins/unload-model-route'; +import { action } from '@ember/object'; +import { hash } from 'rsvp'; -export default Route.extend(UnloadModel, { - pagination: service(), - store: service(), +export default class NamespaceListRoute extends Route { + @service pagination; + @service store; + @service version; - queryParams: { + queryParams = { + pageFilter: { + refreshModel: true, + }, page: { refreshModel: true, }, - }, - - version: service(), + }; beforeModel() { this.store.unloadAll('namespace'); return this.version.fetchFeatures().then(() => { - return this._super(...arguments); + return super.beforeModel(...arguments); }); - }, + } + + async fetchNamespaces(params) { + return await this.pagination + .lazyPaginatedQuery('namespace', { + responsePath: 'data.keys', + page: Number(params?.page) || 1, + pageFilter: params?.pageFilter, + }) + .then((model) => model) + .catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + } model(params) { - if (this.version.hasNamespaces) { - return this.pagination - .lazyPaginatedQuery('namespace', { - responsePath: 'data.keys', - page: Number(params?.page) || 1, - }) - .then((model) => { - return model; - }) - .catch((err) => { - if (err.httpStatus === 404) { - return []; - } else { - throw err; - } - }); - } - return null; - }, + const { pageFilter } = params; + return hash({ + namespaces: this.fetchNamespaces(params), + pageFilter, + }); + } setupController(controller, model) { const has404 = this.has404; @@ -59,29 +66,32 @@ export default Route.extend(UnloadModel, { page: Number(model?.meta?.currentPage) || 1, }); } - }, + } - actions: { - error(error, transition) { - /* eslint-disable-next-line ember/no-controller-access-in-routes */ - const hasModel = this.controllerFor(this.routeName).hasModel; - if (hasModel && error.httpStatus === 404) { - this.set('has404', true); - transition.abort(); - } else { - return true; - } - }, - willTransition(transition) { - window.scrollTo(0, 0); - if (!transition || transition.targetName !== this.routeName) { - this.pagination.clearDataset(); - } + @action + error(error, transition) { + /* eslint-disable-next-line ember/no-controller-access-in-routes */ + const hasModel = this.controllerFor(this.routeName).hasModel; + if (hasModel && error.httpStatus === 404) { + this.has404 = true; + transition.abort(); + } else { return true; - }, - reload() { + } + } + + @action + willTransition(transition) { + window.scrollTo(0, 0); + if (!transition || transition.targetName !== this.routeName) { this.pagination.clearDataset(); - this.refresh(); - }, - }, -}); + } + return true; + } + + @action + reload() { + this.pagination.clearDataset(); + this.refresh(); + } +} diff --git a/ui/app/templates/vault/cluster/access/namespaces/index.hbs b/ui/app/templates/vault/cluster/access/namespaces/index.hbs index 300d4c7545..50460659c9 100644 --- a/ui/app/templates/vault/cluster/access/namespaces/index.hbs +++ b/ui/app/templates/vault/cluster/access/namespaces/index.hbs @@ -13,6 +13,15 @@ + + + Create namespace @@ -20,16 +29,14 @@ - - {{#if list.empty}} - - - - {{else}} + + {{#if this.model.namespaces.length}} + {{list.item.id}} @@ -37,11 +44,14 @@ - {{#let (concat this.currentNamespace (if this.currentNamespace "/") list.item.id) as |targetNamespace|}} - {{#if (includes targetNamespace this.accessibleNamespaces)}} + {{#let + (concat this.namespaceService.path (if this.namespaceService.path "/") list.item.id) + as |targetNamespace| + }} + {{#if (includes targetNamespace this.namespaceService.accessibleNamespaces)}} - Switch to Namespace + Switch to namespace {{/if}} @@ -52,22 +62,21 @@ {{/if}} + {{else}} + + + {{/if}} {{else}} diff --git a/ui/lib/core/addon/components/list-view.hbs b/ui/lib/core/addon/components/list-view.hbs index 16eede94a7..11b75bccd4 100644 --- a/ui/lib/core/addon/components/list-view.hbs +++ b/ui/lib/core/addon/components/list-view.hbs @@ -2,8 +2,7 @@ Copyright (c) HashiCorp, Inc. SPDX-License-Identifier: BUSL-1.1 }} - -{{#if (or (and @items.meta @items.meta.total) @items.length)}} +{{#if (or (and @items.meta @items.meta.filteredTotal) @items.length)}}
{{#each @items as |item|}} {{yield (hash item=item)}} diff --git a/ui/lib/core/app/components/filter-input-explicit.js b/ui/lib/core/app/components/filter-input-explicit.js index 1327ecdafc..70fb36fd1b 100644 --- a/ui/lib/core/app/components/filter-input-explicit.js +++ b/ui/lib/core/app/components/filter-input-explicit.js @@ -3,4 +3,25 @@ * SPDX-License-Identifier: BUSL-1.1 */ +/** + * @module FilterInputExplicit + * + * @description FilterInputExplicit component is a child component to show filter input. + * It also handles the filtering actions of roles. + * + * @example + * + * + * @param {string} query - value of queryParam, such as pageFilter + * @param {string} placeholder - placeholder for the input field + * @param {function} handleSearch - callback function to handle search + * @param {function} handleInput - callback function to handle input + * @param {function} handleKeyDown - callback function to handle keydown + */ export { default } from 'core/components/filter-input-explicit'; diff --git a/ui/mirage/handlers/base.js b/ui/mirage/handlers/base.js index f057dea4bb..99e5eea7db 100644 --- a/ui/mirage/handlers/base.js +++ b/ui/mirage/handlers/base.js @@ -94,4 +94,12 @@ export default function (server) { }, }; }); + + server.get('sys/internal/ui/namespaces', function () { + return { + data: { + keys: ['ns1/', 'ns2/', 'ns3/', 'ns4/', 'ns5/', 'ns6/', 'ns7/', 'ns8/', 'ns9/', 'ns10/'], + }, + }; + }); } diff --git a/ui/tests/acceptance/access/namespaces/index-test.js b/ui/tests/acceptance/access/namespaces/index-test.js index ae361e9505..9ddca0bcf3 100644 --- a/ui/tests/acceptance/access/namespaces/index-test.js +++ b/ui/tests/acceptance/access/namespaces/index-test.js @@ -3,16 +3,20 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { currentRouteName, visit } from '@ember/test-helpers'; +import { currentRouteName, visit, click, fillIn, currentURL } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; module('Acceptance | Enterprise | /access/namespaces', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + const searchInput = GENERAL.filterInputExplicit; + const searchButton = GENERAL.filterInputExplicitSearch; + hooks.beforeEach(function () { return login(); }); @@ -33,7 +37,99 @@ module('Acceptance | Enterprise | /access/namespaces', function (hooks) { const store = this.owner.lookup('service:store'); // Default page size is 15 assert.strictEqual(store.peekAll('namespace').length, 15, 'Store has 15 namespaces records'); - assert.dom('.list-item-row').exists({ count: 15 }); + assert.dom('.list-item-row').exists({ count: 15 }, 'Should display 15 namespaces'); assert.dom('.hds-pagination').exists(); }); + + test('it should show button to create new namespace', async function (assert) { + await visit('/vault/access/namespaces'); + const createNamespaceButton = 'nav.toolbar-actions a.toolbar-link'; + assert + .dom(createNamespaceButton) + .hasText('Create namespace', 'Create namespace button is rendered correctly'); + assert + .dom(createNamespaceButton) + .hasAttribute( + 'href', + '/ui/vault/access/namespaces/create', + 'Create namespace button has the correct href attribute' + ); + }); + + test('it should filter namespaces based on search input', async function (assert) { + await visit('/vault/access/namespaces'); + + // Enter search text + await fillIn(searchInput, 'ns4'); + assert.dom(searchInput).hasValue('ns4', 'Search input contains the entered text'); + + // Click the search button + await click(searchButton); + + // Verify the filtered results + assert.dom('.list-item-row').exists({ count: 1 }, 'Filtered results are displayed correctly'); + assert.dom('.list-item-row').hasText('ns4', 'Correct namespace is displayed in the filtered results'); + + // Verify the URL query param is updated + assert.strictEqual( + currentURL(), + '/vault/access/namespaces?page=1&pageFilter=ns4', + 'URL query param is updated to reflect the search field as pageFilter' + ); + + // Clear the search input + await fillIn(searchInput, ''); + await click(searchButton); + assert.dom(searchInput).hasValue('', 'Search input is cleared'); + assert + .dom('.list-item-row') + .exists({ count: 15 }, 'All namespaces are displayed after clearing the search input'); + assert.strictEqual( + currentURL(), + '/vault/access/namespaces?page=1', + 'URL query param is updated to remove pageFilter' + ); + }); + + test('it should show options menu for each namespace', async function (assert) { + await visit('/vault/access/namespaces'); + assert.dom(GENERAL.menuTrigger).exists(); + await click(GENERAL.menuTrigger); + assert.dom('.hds-dropdown-list-item').exists({ count: 2 }, 'Should display 2 options in the menu.'); + + // Verify that the user can switch to the namespace + const switchNamespaceButton = '.hds-dropdown-list-item:nth-of-type(1)'; + assert + .dom(switchNamespaceButton) + .hasText('Switch to namespace', 'Allow users to switch to different namespace'); + assert + .dom(`${switchNamespaceButton} a`) + .hasAttribute( + 'href', + 'http://localhost:7357/ui/vault/dashboard?namespace=ns1', + 'Switch namespace button has the correct href attribute' + ); + + // Verify that the user can delete the namespace + const deleteNamespaceButton = '.hds-dropdown-list-item:nth-of-type(2)'; + assert.dom(deleteNamespaceButton).hasText('Delete', 'Allow users to delete the namespace'); + }); + + test('it should hide the switch to namespace option for unaccessible namespaces', async function (assert) { + await visit('/vault/access/namespaces'); + + // Search for a namespace that is not accessible + await fillIn(searchInput, 'ns12'); + await click(searchButton); + + assert.dom(GENERAL.menuTrigger).exists(); + await click(GENERAL.menuTrigger); + + // Verify that only the delete option is available for the unaccessible namespace + assert.dom('.hds-dropdown-list-item').exists({ count: 1 }, 'Should display 1 option in the menu.'); + + // Verify that the user can delete the namespace + const deleteNamespaceButton = '.hds-dropdown-list-item:nth-of-type(1)'; + assert.dom(deleteNamespaceButton).hasText('Delete', 'Allow users to delete the namespace'); + }); }); diff --git a/ui/tests/acceptance/enterprise-namespaces-test.js b/ui/tests/acceptance/enterprise-namespaces-test.js index f6f047e0e4..28d523d676 100644 --- a/ui/tests/acceptance/enterprise-namespaces-test.js +++ b/ui/tests/acceptance/enterprise-namespaces-test.js @@ -294,4 +294,39 @@ module('Acceptance | Enterprise | namespaces', function (hooks) { 'correctly sanitizes namespace' ); }); + + test('it should allow the user to delete a namespace', async function (assert) { + // Test Setup + const namespaces = ['test-delete-me']; + await createNamespaces(namespaces); + + await visit('/vault/access/namespaces'); + + const searchInput = GENERAL.filterInputExplicit; + const searchButton = GENERAL.filterInputExplicitSearch; + + await fillIn(searchInput, 'test-delete-me'); + await click(searchButton); + + assert.dom(GENERAL.menuTrigger).exists(); + await click(GENERAL.menuTrigger); + + // Verify that the user can delete the namespace + const deleteNamespaceButton = '.hds-dropdown-list-item:nth-of-type(1)'; + assert.dom(deleteNamespaceButton).hasText('Delete', 'Allow users to delete the namespace'); + await click(`${deleteNamespaceButton} button`); + + assert.dom(GENERAL.confirmButton).hasText('Confirm', 'Allow users to delete the namespace'); + await click(GENERAL.confirmButton); + + assert.strictEqual( + currentURL(), + '/vault/access/namespaces?page=1&pageFilter=test-delete-me', + 'Should remain on the manage namespaces page after deletion' + ); + + assert + .dom('.list-item-row') + .exists({ count: 0 }, 'Namespace should be deleted and not displayed in the list'); + }); });