mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-06 04:46:25 +02:00
[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:
parent
3b78c15846
commit
7fb5eb72b8
7
changelog/30680.txt
Normal file
7
changelog/30680.txt
Normal 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.
|
||||
```
|
||||
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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)}}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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/'],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user