UI: Namespace page component (#11956) (#11977)

* move namespace logic over into page component

* add onPageChange arg and pass @model

* update type and arg accessor

Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
This commit is contained in:
Vault Automation 2026-01-26 14:15:23 -05:00 committed by GitHub
parent 06ddf792f2
commit 7e7a1bfe63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 226 additions and 155 deletions

View file

@ -0,0 +1,91 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (has-feature "Namespaces")}}
<Page::Header @title="Namespaces">
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array (hash label="Vault" route="vault.cluster.dashboard" icon="vault") (hash label="Namespaces")}}
/>
</:breadcrumbs>
</Page::Header>
<Toolbar>
<ToolbarFilters>
<FilterInputExplicit
@query={{@model.pageFilter}}
@placeholder="Search"
@handleSearch={{this.handleSearch}}
@handleInput={{this.handleInput}}
@handleKeyDown={{this.handleKeyDown}}
/>
</ToolbarFilters>
<ToolbarActions>
<Hds::Button
class="has-right-margin-4"
@color="secondary"
@icon="reload"
@iconPosition="trailing"
@text="Refresh list"
{{on "click" this.refreshNamespaceList}}
data-test-button="refresh-namespace-list"
/>
<ToolbarLink @route="vault.cluster.access.namespaces.create" @type="add" data-test-link-to="create-namespace">
Create namespace
</ToolbarLink>
</ToolbarActions>
</Toolbar>
<ListView
@items={{@model.namespaces}}
@itemNoun="namespace"
@paginationRouteName="vault.cluster.access.namespaces"
@onPageChange={{this.handlePageChange}}
as |list|
>
{{#if @model.namespaces.length}}
<ListItem as |Item|>
<Item.content>
{{list.item.id}}
</Item.content>
<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.namespace.path (if this.namespace.path "/") list.item.id) as |targetNamespace|}}
{{#if (includes targetNamespace this.namespace.accessibleNamespaces)}}
<dd.Interactive {{on "click" (fn this.switchNamespace targetNamespace)}} data-test-popup-menu="switch">Switch
to namespace</dd.Interactive>
{{/if}}
{{/let}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.nsToDelete) list.item)}}
data-test-popup-menu="delete"
>Delete</dd.Interactive>
</Hds::Dropdown>
{{#if (eq this.nsToDelete list.item)}}
<ConfirmModal
@color="critical"
@onClose={{fn (mut this.nsToDelete) null}}
@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}}
<UpgradePage @title="Namespaces" @minimumEdition="Vault Enterprise Pro" />
{{/if}}

View file

@ -0,0 +1,128 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import Component from '@glimmer/component';
import keys from 'core/utils/keys';
import type ApiService from 'vault/services/api';
import type FlashMessageService from 'vault/services/flash-messages';
import type NamespaceService from 'vault/services/namespace';
import type RouterService from '@ember/routing/router-service';
import type { HTMLElementEvent } from 'vault/forms';
/**
* @module PageNamespaces
* PageNamespaces component handles the display and management of namespaces,
* including the namespace wizard for first-time users.
*
* @param {object} namespaces - list of namespaces
* @param {string} pageFilter - current page filter value
* @param {function} onFilterChange - callback function to handle filter changes, receives filter string or null to clear
* @param {function} onRefresh - callback function to refresh the namespace list from the route/controller
*/
interface Args {
model: {
namespaces: NamespaceModel[];
pageFilter: string | null;
};
onFilterChange: CallableFunction;
onRefresh: CallableFunction;
}
interface NamespaceModel {
id: string;
destroyRecord: () => Promise<void>;
[key: string]: unknown;
}
export default class PageNamespacesComponent extends Component<Args> {
@service declare readonly api: ApiService;
@service declare readonly router: RouterService;
@service declare readonly flashMessages: FlashMessageService;
// Use namespaceService alias to avoid collision with namespaces
// input parameter from the route.
@service declare namespace: NamespaceService;
// The `query` property is used to track the filter
// input value separately from updating the `pageFilter`
// browser query param to prevent unnecessary re-renders.
@tracked query;
@tracked nsToDelete = null;
constructor(owner: unknown, args: Args) {
super(owner, args);
this.query = this.args.model.pageFilter || '';
}
@action
handleKeyDown(event: KeyboardEvent) {
const isEscKeyPressed = keys.ESC.includes(event.key);
if (isEscKeyPressed) {
// On escape, clear the filter
this.args.onFilterChange(null);
}
// ignore all other key events
}
@action
handleInput(evt: HTMLElementEvent<HTMLInputElement>) {
this.query = evt.target.value;
}
@action
handleSearch(evt: HTMLElementEvent<HTMLInputElement>) {
evt.preventDefault();
this.args.onFilterChange(this.query);
}
@action
async deleteNamespace(nsToDelete: NamespaceModel) {
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) {
const { message } = await this.api.parseError(error);
this.flashMessages.danger(`There was an error deleting this namespace: ${message}`);
}
this.nsToDelete = null;
}
@action
async refreshNamespaceList() {
try {
// Await the async operation to complete
await this.namespace.findNamespacesForUser.perform();
this.args.onRefresh();
} catch (error) {
this.flashMessages.danger('There was an error refreshing the namespace list.');
}
}
@action handlePageChange() {
this.args.onRefresh();
}
@action
switchNamespace(targetNamespace: string) {
this.router.transitionTo('vault.cluster.dashboard', {
queryParams: { namespace: targetNamespace },
});
}
async createNamespace(path: string, header?: string) {
const headers = header ? this.api.buildHeaders({ namespace: header }) : undefined;
await this.api.sys.systemWriteNamespacesPath(path, {}, headers);
}
}

View file

@ -3,11 +3,9 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import Controller from '@ember/controller';
import keys from 'core/utils/keys';
import { service } from '@ember/service';
/**
* @module ManageNamespaces
@ -18,26 +16,15 @@ import keys from 'core/utils/keys';
* @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;
}
@action
navigate(pageFilter) {
const route = 'vault.cluster.access.namespaces.index';
const args = [route, { queryParams: { page: 1, pageFilter: pageFilter || null } }];
@ -45,56 +32,7 @@ export default class ManageNamespacesController extends Controller {
}
@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}`);
}
}
@action
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.');
}
}
@action
switchNamespace(targetNamespace) {
this.router.transitionTo('vault.cluster.dashboard', {
queryParams: { namespace: targetNamespace },
});
refreshRoute() {
this.send('reload');
}
}

View file

@ -3,91 +3,4 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (has-feature "Namespaces")}}
<Page::Header @title="Namespaces">
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array (hash label="Vault" route="vault.cluster.dashboard" icon="vault") (hash label="Namespaces")}}
/>
</:breadcrumbs>
</Page::Header>
<Toolbar>
<ToolbarFilters>
<FilterInputExplicit
@query={{this.pageFilter}}
@placeholder="Search"
@handleSearch={{this.handleSearch}}
@handleInput={{this.handleInput}}
@handleKeyDown={{this.handleKeyDown}}
/>
</ToolbarFilters>
<ToolbarActions>
<Hds::Button
class="has-right-margin-4"
@color="secondary"
@icon="reload"
@iconPosition="trailing"
@text="Refresh list"
{{on "click" this.refreshNamespaceList}}
data-test-button="refresh-namespace-list"
/>
<ToolbarLink @route="vault.cluster.access.namespaces.create" @type="add" data-test-link-to="create-namespace">
Create namespace
</ToolbarLink>
</ToolbarActions>
</Toolbar>
<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}}
</Item.content>
<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.namespaceService.path (if this.namespaceService.path "/") list.item.id)
as |targetNamespace|
}}
{{#if (includes targetNamespace this.namespaceService.accessibleNamespaces)}}
<dd.Interactive {{on "click" (fn this.switchNamespace targetNamespace)}} data-test-popup-menu="switch">Switch
to namespace</dd.Interactive>
{{/if}}
{{/let}}
<dd.Interactive
@color="critical"
{{on "click" (fn (mut this.nsToDelete) list.item)}}
data-test-popup-menu="delete"
>Delete</dd.Interactive>
</Hds::Dropdown>
{{#if (eq this.nsToDelete list.item)}}
<ConfirmModal
@color="critical"
@onClose={{fn (mut this.nsToDelete) null}}
@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}}
<UpgradePage @title="Namespaces" @minimumEdition="Vault Enterprise Pro" />
{{/if}}
<Page::Namespaces @model={{this.model}} @onFilterChange={{this.navigate}} @onRefresh={{this.refreshRoute}} />

View file

@ -18,6 +18,7 @@
@showSizeSelector={{false}}
@totalItems={{@items.meta.total}}
@queryFunction={{this.paginationQueryParams}}
@onPageChange={{@onPageChange}}
/>
{{/if}}
</div>