vault/ui/app/components/namespace-picker.ts
2025-07-17 15:56:59 -07:00

252 lines
8.4 KiB
TypeScript

/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import keys from 'core/utils/keys';
import type CapabilitiesService from 'vault/services/capabilities';
import type Router from 'vault/router';
import type NamespaceService from 'vault/services/namespace';
import type AuthService from 'vault/vault/services/auth';
import type Store from '@ember-data/store';
import errorMessage from 'vault/utils/error-message';
interface NamespaceOption {
path: string;
label: string;
}
/**
* @module NamespacePicker
* @description component is used to display a dropdown listing all namespaces that the current user has access to.
* The user can select a namespace from the dropdown to navigate directly to that namespace.
* The "Manage" button directs the user to the namespace management page.
* The "Refresh List" button refreshes the list of namespaces in the dropdown.
*
* @example
* <NamespacePicker class="hds-side-nav-hide-when-minimized" />
*/
export default class NamespacePicker extends Component {
@service declare auth: AuthService;
@service declare capabilities: CapabilitiesService;
@service declare namespace: NamespaceService;
@service declare router: Router;
@service declare store: Store;
// Load 200 namespaces in the namespace picker at a time
@tracked batchSize = 200;
@tracked canManageNamespaces = false; // Show/hide manage namespaces button
@tracked errorLoadingNamespaces = '';
@tracked hasNamespaces = false;
@tracked searchInput = '';
@tracked searchInputHelpText =
"Enter a full path in the search bar and hit the 'Enter' ↵ key to navigate faster.";
constructor(owner: unknown, args: Record<string, never>) {
super(owner, args);
this.loadOptions();
this.fetchManageCapability();
}
get allNamespaces(): NamespaceOption[] {
return this.getOptions(this.namespace?.accessibleNamespaces);
}
get selectedNamespace(): NamespaceOption | null {
return this.getSelected(this.allNamespaces, this.namespace?.path) ?? null;
}
private matchesPath(option: NamespaceOption, currentPath: string): boolean {
return option?.path === currentPath;
}
private getSelected(options: NamespaceOption[], currentPath: string): NamespaceOption | undefined {
return options.find((option) => this.matchesPath(option, currentPath));
}
private getOptions(accessibleNamespaces: string[]): NamespaceOption[] {
/* Each namespace option has 2 properties: { path and label }
* - path: full namespace path (used to navigate to the namespace)
* - label: text displayed inside the namespace picker dropdown (if root, then path is "", else label = path)
*
* Example:
* | path | label |
* | ---- | ----- |
* | '' | 'root' |
* | 'parent' | 'parent' |
* | 'parent/child' | 'parent/child' |
*/
const options = (accessibleNamespaces || []).map((ns: string) => ({ path: ns, label: ns }));
// Add the user's root namespace because `sys/internal/ui/namespaces` does not include it.
const userRootNamespace = this.auth.authData?.userRootNamespace;
if (!options?.find((o) => o.path === userRootNamespace)) {
// the 'root' namespace is technically an empty string so we manually add the 'root' label.
const label = userRootNamespace === '' ? 'root' : userRootNamespace;
options.unshift({ path: userRootNamespace, label });
}
// If there are no namespaces returned by the internal endpoint, add the current namespace
// to the list of options. This is a fallback for when the user has access to a single namespace.
if (options.length === 0) {
// 'path' defined in the namespace service is the full namespace path
options.push({ path: this.namespace.path, label: this.namespace.path });
}
return options;
}
get hasSearchInput(): boolean {
return this.searchInput?.trim().length > 0;
}
get namespaceCount(): number {
return this.namespaceOptions.length;
}
get namespaceLabel(): string {
return this.searchInput === '' ? 'All namespaces' : 'Matching namespaces';
}
get namespaceOptions(): NamespaceOption[] {
if (this.searchInput.trim() === '') {
return this.allNamespaces || [];
} else {
const filtered = this.allNamespaces.filter((ns) =>
ns.label.toLowerCase().includes(this.searchInput.toLowerCase())
);
return filtered || [];
}
}
get noNamespacesMessage(): string {
const noNamespacesMessage = 'No namespaces found.';
const noMatchingNamespacesHelpText =
'No matching namespaces found. Try searching for a different namespace.';
return this.hasSearchInput ? noMatchingNamespacesHelpText : noNamespacesMessage;
}
get showNoNamespacesMessage(): boolean {
const hasError = this.errorLoadingNamespaces !== '';
return this.namespaceCount === 0 && !hasError;
}
get visibleNamespaceOptions(): NamespaceOption[] {
return this.namespaceOptions.slice(0, this.batchSize);
}
@action
adjustElementWidth(element: HTMLElement): void {
// Hide the element so that it doesn't affect the layout
element.style.display = 'none';
let maxWidth = 240; // Default minimum width
// Calculate the maximum width of the visible namespace options
// The namespace is displayed as an HDS::checkmark button, so we need to find the width of the checkmark element
this.visibleNamespaceOptions.forEach((namespace: NamespaceOption) => {
const checkmarkElement = document.querySelector(`[data-test-button="${namespace.label}"]`);
const width = (checkmarkElement as HTMLElement).offsetWidth;
if (width > maxWidth) {
maxWidth = width;
}
});
// Set the width of the target element
element.style.width = `${maxWidth}px`;
// Show the element once the width is set
element.style.display = '';
}
@action
async fetchManageCapability(): Promise<void> {
// The namespace picker options are from `sys/internal/ui/namespaces` which all users have permissions to request.
// The UI view for managing namespaces (i.e. CRUD actions) calls `sys/namespaces` and DOES require LIST permissions.
// This is the capability check to hide/show the button that navigates to that route.
const { canList } = await this.capabilities.fetchPathCapabilities('sys/namespaces');
this.canManageNamespaces = canList;
}
@action
focusSearchInput(element: HTMLInputElement): void {
// On mount, cursor should default to the search input field
element.focus();
}
@action
async loadOptions(): Promise<void> {
try {
await this.namespace?.findNamespacesForUser?.perform();
this.errorLoadingNamespaces = '';
} catch (error) {
this.errorLoadingNamespaces = errorMessage(error);
}
}
@action
loadMore(): void {
// Increase the batch size to load more items
this.batchSize += 200;
}
@action
setupScrollListener(element: HTMLElement): void {
element.addEventListener('scroll', this.onScroll);
}
@action
onScroll(event: Event): void {
const element = event.target as HTMLElement;
// Check if the user has scrolled to the bottom
if (element.scrollTop + element.clientHeight >= element.scrollHeight) {
this.loadMore();
}
}
@action
async onChange(selected: NamespaceOption): Promise<void> {
this.searchInput = '';
this.router.transitionTo('vault.cluster.dashboard', { queryParams: { namespace: selected.path } });
}
@action
async onKeyDown(event: KeyboardEvent): Promise<void> {
if (event.key === keys.ENTER && this.searchInput?.trim()) {
const matchingNamespace = this.allNamespaces.find((ns) => ns.label === this.searchInput.trim());
if (matchingNamespace) {
this.searchInput = '';
this.router.transitionTo('vault.cluster.dashboard', {
queryParams: { namespace: matchingNamespace.path },
});
}
}
}
@action
onSearchInput(event: Event): void {
const target = event.target as HTMLInputElement;
this.searchInput = target.value;
}
@action
async refreshList(): Promise<void> {
this.searchInput = '';
await this.loadOptions();
}
@action
toggleNamespacePicker() {
// Reset the search input when the dropdown is toggled
this.searchInput = '';
}
}