[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
This commit is contained in:
Shannon Roberts (Beagin) 2025-05-20 09:30:05 -07:00 committed by GitHub
parent 3b78c15846
commit 7fb5eb72b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 350 additions and 92 deletions

7
changelog/30680.txt Normal file
View File

@ -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.
```

View File

@ -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.');
}
}
}

View File

@ -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();
}
}

View File

@ -13,6 +13,15 @@
</PageHeader>
<Toolbar>
<ToolbarFilters>
<FilterInputExplicit
@query={{this.pageFilter}}
@placeholder="Search"
@handleSearch={{this.handleSearch}}
@handleInput={{this.handleInput}}
@handleKeyDown={{this.handleKeyDown}}
/>
</ToolbarFilters>
<ToolbarActions>
<ToolbarLink @route="vault.cluster.access.namespaces.create" @type="add">
Create namespace
@ -20,16 +29,14 @@
</ToolbarActions>
</Toolbar>
<ListView @items={{this.model}} @itemNoun="namespace" @paginationRouteName="vault.cluster.access.namespaces" as |list|>
{{#if list.empty}}
<list.empty>
<Hds::Link::Standalone
@icon="learn-link"
@text="Secure multi-tenancy with namespaces tutorial"
@href={{doc-link "/vault/tutorials/enterprise/namespaces"}}
/>
</list.empty>
{{else}}
<ListView
@items={{this.model.namespaces}}
@itemNoun="namespace"
@paginationRouteName="vault.cluster.access.namespaces"
as |list|
>
{{#if this.model.namespaces.length}}
<ListItem as |Item|>
<Item.content>
{{list.item.id}}
@ -37,11 +44,14 @@
<Item.menu>
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
<dd.ToggleIcon @icon="more-horizontal" @text="More options" @hasChevron={{false}} data-test-popup-menu-trigger />
{{#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)}}
<dd.Generic>
<NamespaceLink @targetNamespace={{targetNamespace}} @unparsed={{true}} @class="ns-dropdown-item">
Switch to Namespace
Switch to namespace
</NamespaceLink>
</dd.Generic>
{{/if}}
@ -52,22 +62,21 @@
<ConfirmModal
@color="critical"
@onClose={{fn (mut this.nsToDelete) null}}
@onConfirm={{action
(perform
Item.callMethod
"destroyRecord"
this.nsToDelete
(concat "Successfully deleted namespace: " this.nsToDelete.id)
"There was an error deleting this namespace: "
(action "refreshNamespaceList")
)
}}
@onConfirm={{fn this.deleteNamespace list.item}}
@confirmTitle="Delete this namespace?"
@confirmMessage="Any engines or mounts in this namespace will also be removed."
/>
{{/if}}
</Item.menu>
</ListItem>
{{else}}
<list.empty>
<Hds::Link::Standalone
@icon="learn-link"
@text="Secure multi-tenancy with namespaces tutorial"
@href={{doc-link "/vault/tutorials/enterprise/namespaces"}}
/>
</list.empty>
{{/if}}
</ListView>
{{else}}

View File

@ -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)}}
<div class="box is-fullwidth is-bottomless is-sideless is-paddingless" data-test-list-view-list>
{{#each @items as |item|}}
{{yield (hash item=item)}}

View File

@ -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
* <FilterInputExplicit
* @query={{this.pageFilter}}
* @placeholder="Search"
* @handleSearch={{this.handleSearch}}
* @handleInput={{this.handleInput}}
* @handleKeyDown={{this.handleKeyDown}}
* />
*
* @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';

View File

@ -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/'],
},
};
});
}

View File

@ -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');
});
});

View File

@ -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');
});
});