[VAULT-34216] UI: Namespace picker feature branch (#30490)

This commit is contained in:
Shannon Roberts (Beagin) 2025-05-05 16:12:36 -07:00 committed by GitHub
parent 97fcf46e02
commit fe9f18b7f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 821 additions and 459 deletions

3
changelog/30490.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:feature
**Vault Namespace Picker**: Updating the Vault Namespace Picker to enable search functionality, allow direct navigation to nested namespaces and improve accessibility.
```

View file

@ -0,0 +1,92 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<div class="top-padding-4 has-bottom-padding-4 has-side-padding-8" ...attributes>
<Hds::Dropdown @enableCollisionDetection={{true}} as |D|>
<D.ToggleButton @icon="org" @text={{or this.selected.id "-"}} @isFullWidth={{true}} data-test-namespace-toggle />
{{#if this.errorLoadingNamespaces}}
<D.Header>
<MessageError @errorMessage={{this.errorLoadingNamespaces}} />
</D.Header>
{{else}}
<D.Header @hasDivider={{true}}>
<div class="has-padding-8">
<Hds::Form::TextInput::Field
@value={{this.searchInput}}
@type="search"
aria-label="Search namespaces"
placeholder="Search"
{{on "keydown" this.onKeyDown}}
{{on "input" this.onSearchInput}}
{{did-insert this.focusSearchInput}}
/>
</div>
</D.Header>
<D.Header>
{{#if (and this.hasSearchInput (not this.showNoNamespacesMessage))}}
<div class="sub-text has-padding-8">
{{this.searchInputHelpText}}
</div>
{{/if}}
<div class="is-size-8 has-text-black has-text-weight-semibold has-padding-8">
{{this.namespaceLabel}}
<Hds::BadgeCount @text={{or this.namespaceCount 0}} />
</div>
</D.Header>
{{#if this.showNoNamespacesMessage}}
<D.Generic class="sub-text">
{{this.noNamespacesMessage}}
</D.Generic>
{{/if}}
<div class="is-overflow-y-auto is-max-drawer-height" {{did-insert this.setupScrollListener}}>
{{#each this.visibleNamespaceOptions as |option|}}
<D.Checkmark
@selected={{eq option.id this.selected.id}}
{{on "click" (fn this.onChange option)}}
data-test-namespace-link={{option.path}}
>
{{option.label}}
</D.Checkmark>
{{/each}}
</div>
{{/if}}
<D.Footer @hasDivider={{true}} class="is-flex-center">
<Hds::ButtonSet class="is-fullwidth">
{{#if this.canRefreshNamespaces}}
<Hds::Button
@color="secondary"
@text="Refresh list"
@isFullWidth={{(not this.canManageNamespaces)}}
{{on "click" this.refreshList}}
data-test-refresh-namespaces
/>
{{/if}}
{{#if this.canManageNamespaces}}
<Hds::Button
@color="secondary"
@text="Manage"
@isFullWidth={{(not this.canRefreshNamespaces)}}
@icon="settings"
@route="vault.cluster.access.namespaces"
data-test-manage-namespaces
/>
{{/if}}
</Hds::ButtonSet>
</D.Footer>
</Hds::Dropdown>
</div>

View file

@ -1,170 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { service } from '@ember/service';
import { alias, gt } from '@ember/object/computed';
import Component from '@ember/component';
import { computed } from '@ember/object';
import { task, timeout } from 'ember-concurrency';
import pathToTree from 'vault/lib/path-to-tree';
import { ancestorKeysForKey } from 'core/utils/key-utils';
const DOT_REPLACEMENT = '☃';
const ANIMATION_DURATION = 250;
export default Component.extend({
tagName: '',
namespaceService: service('namespace'),
auth: service(),
store: service(),
namespace: null,
listCapability: null,
canList: false,
init() {
this._super(...arguments);
this.namespaceService?.findNamespacesForUser.perform();
},
didReceiveAttrs() {
this._super(...arguments);
const ns = this.namespace;
const oldNS = this.oldNamespace;
if (!oldNS || ns !== oldNS) {
this.setForAnimation.perform();
this.fetchListCapability.perform();
}
this.set('oldNamespace', ns);
},
fetchListCapability: task(function* () {
try {
const capability = yield this.store.findRecord('capabilities', 'sys/namespaces/');
this.set('listCapability', capability);
this.set('canList', true);
} catch (e) {
// If error out on findRecord call it's because you don't have permissions
// and therefore don't have permission to manage namespaces
this.set('canList', false);
}
}),
setForAnimation: task(function* () {
const leaves = this.menuLeaves;
const lastLeaves = this.lastMenuLeaves;
if (!lastLeaves) {
this.set('lastMenuLeaves', leaves);
yield timeout(0);
return;
}
const isAdding = leaves.length > lastLeaves.length;
const changedLeaves = isAdding ? leaves : lastLeaves;
const [changedLeaf] = changedLeaves.slice(-1);
this.set('isAdding', isAdding);
this.set('changedLeaf', changedLeaf);
// if we're adding we want to render immediately an animate it in
// if we're not adding, we need time to move the item out before
// a rerender removes it
if (isAdding) {
this.set('lastMenuLeaves', leaves);
yield timeout(0);
return;
}
yield timeout(ANIMATION_DURATION);
this.set('lastMenuLeaves', leaves);
}).drop(),
isAnimating: alias('setForAnimation.isRunning'),
namespacePath: alias('namespaceService.path'),
// this is an array of namespace paths that the current user
// has access to
accessibleNamespaces: alias('namespaceService.accessibleNamespaces'),
inRootNamespace: alias('namespaceService.inRootNamespace'),
namespaceTree: computed('accessibleNamespaces', function () {
const nsList = this.accessibleNamespaces;
if (!nsList) {
return [];
}
return pathToTree(nsList);
}),
maybeAddRoot(leaves) {
const userRoot = this.auth.authData.userRootNamespace;
if (userRoot === '') {
leaves.unshift('');
}
return leaves.uniq();
},
pathToLeaf(path) {
// dots are allowed in namespace paths
// so we need to preserve them, and replace slashes with dots
// in order to use Ember's get function on the namespace tree
// to pull out the correct level
return (
path
// trim trailing slash
.replace(/\/$/, '')
// replace dots with snowman
.replace(/\.+/g, DOT_REPLACEMENT)
// replace slash with dots
.replace(/\/+/g, '.')
);
},
// an array that keeps track of what additional panels to render
// on the menu stack
// if you're in 'foo/bar/baz',
// this array will be: ['foo', 'foo.bar', 'foo.bar.baz']
// the template then iterates over this, and does Ember.get(namespaceTree, leaf)
// to render the nodes of each leaf
// gets set as 'lastMenuLeaves' in the ember concurrency task above
menuLeaves: computed('namespacePath', 'namespaceTree', 'pathToLeaf', function () {
let ns = this.namespacePath;
ns = (ns || '').replace(/^\//, '');
let leaves = ancestorKeysForKey(ns);
leaves.push(ns);
leaves = this.maybeAddRoot(leaves);
leaves = leaves.map(this.pathToLeaf);
return leaves;
}),
// the nodes at the root of the namespace tree
// these will get rendered as the bottom layer
rootLeaves: computed('namespaceTree', function () {
const tree = this.namespaceTree;
const leaves = Object.keys(tree);
return leaves;
}),
currentLeaf: alias('lastMenuLeaves.lastObject'),
canAccessMultipleNamespaces: gt('accessibleNamespaces.length', 1),
isUserRootNamespace: computed('auth.authData.userRootNamespace', 'namespacePath', function () {
return this.auth.authData.userRootNamespace === this.namespacePath;
}),
namespaceDisplay: computed('namespacePath', 'accessibleNamespaces', 'accessibleNamespaces.[]', function () {
const namespace = this.namespacePath;
if (!namespace) {
return 'root';
}
const parts = namespace?.split('/');
return parts[parts.length - 1];
}),
actions: {
refreshNamespaceList() {
this.namespaceService.findNamespacesForUser.perform();
},
},
});

View file

@ -0,0 +1,229 @@
/**
* 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 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 {
id: string;
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 namespace: NamespaceService;
@service declare router: Router;
@service declare store: Store;
// Load 200 namespaces in the namespace picker at a time
@tracked batchSize = 200;
@tracked allNamespaces: NamespaceOption[] = [];
@tracked canManageNamespaces = false; // Show/hide manage namespaces button
@tracked canRefreshNamespaces = false; // Show/hide refresh list 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.";
@tracked selected: NamespaceOption | null = null;
constructor(owner: unknown, args: Record<string, never>) {
super(owner, args);
this.loadOptions();
}
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(namespace: NamespaceService): NamespaceOption[] {
/* Each namespace option has 3 properties: { id, path, and label }
* - id: node / namespace name (displayed when the namespace picker is closed)
* - path: full namespace path (used to navigate to the namespace)
* - label: text displayed inside the namespace picker dropdown (if root, then label = id, else label = path)
*
* Example:
* | id | path | label |
* | --- | ---- | ----- |
* | 'root' | '' | 'root' |
* | 'parent' | 'parent' | 'parent' |
* | 'child' | 'parent/child' | 'parent/child' |
*/
const options = [
...(namespace?.accessibleNamespaces || []).map((ns: string) => {
const parts = ns.split('/');
return { id: parts[parts.length - 1] || '', path: ns, label: ns };
}),
];
// Conditionally add the root namespace
if (this.auth?.authData?.userRootNamespace === '') {
options.unshift({ id: 'root', path: '', label: 'root' });
}
// 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) {
options.push({
id: namespace.currentNamespace,
path: namespace.path,
label: 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
async fetchListCapability(): Promise<void> {
try {
const namespacePermission = await this.store.findRecord('capabilities', 'sys/namespaces/');
this.canRefreshNamespaces = namespacePermission.get('canList');
this.canManageNamespaces = true;
} catch (error) {
// If the findRecord call fails, the user lacks permissions to refresh or manage namespaces.
this.canRefreshNamespaces = this.canManageNamespaces = false;
}
}
@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);
}
this.allNamespaces = this.getOptions(this.namespace);
this.selected = this.getSelected(this.allNamespaces, this.namespace?.path) ?? null;
await this.fetchListCapability();
}
@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.selected = selected;
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.selected = 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();
}
}

View file

@ -38,10 +38,7 @@
<:footer>
{{#if (has-feature "Namespaces")}}
<NamespacePicker
@namespace={{this.clusterController.namespaceQueryParam}}
class="hds-side-nav-hide-when-minimized"
/>
<NamespacePicker class="hds-side-nav-hide-when-minimized" />
{{/if}}
</:footer>
</Hds::SideNav>

View file

@ -1,130 +0,0 @@
@use '../utils/box-shadow_variables';
@use '../utils/color_variables';
@use '../utils/font_variables';
@use '../utils/size_variables';
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
.namespace-picker {
position: relative;
color: var(--token-color-palette-neutral-300);
padding: size_variables.$spacing-4 size_variables.$spacing-8;
width: 100%;
}
.namespace-picker.no-namespaces {
border: none;
padding-right: 0;
}
.namespace-picker-trigger {
align-items: center;
display: flex;
flex: 1 1 auto;
height: 2rem;
justify-content: space-between;
margin-right: size_variables.$spacing-4;
width: 100%;
}
.namespace-picker-content {
width: 250px;
max-height: 300px;
overflow: auto;
color: var(--token-color-foreground-primary);
border-radius: size_variables.$radius;
box-shadow: box-shadow_variables.$box-shadow, box-shadow_variables.$box-shadow-high;
&.ember-basic-dropdown-content {
background: color_variables.$white;
}
}
.namespace-picker-content .level-left {
max-width: 210px;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.namespace-header-bar {
padding: size_variables.$spacing-4 size_variables.$spacing-10;
border-bottom: 1px solid rgba(color_variables.$black, 0.1);
font-weight: font_variables.$font-weight-semibold;
min-height: 32px;
}
.namespace-manage-link {
border-top: 1px solid rgba(color_variables.$black, 0.1);
.level-left {
font-weight: font_variables.$font-weight-bold;
font-size: 14px;
}
.level-right {
margin-right: 10px;
}
}
.namespace-list {
position: relative;
overflow: hidden;
}
.namespace-link {
color: color_variables.$black;
text-decoration: none;
font-weight: font_variables.$font-weight-semibold;
padding: size_variables.$spacing-8 size_variables.$spacing-10 size_variables.$spacing-8 0;
}
.namespace-link.is-current {
margin-top: size_variables.$spacing-12;
margin-right: -(size_variables.$spacing-8);
svg {
margin-top: 2px;
color: var(--token-color-border-strong);
}
}
.leaf-panel {
padding: size_variables.$spacing-4 size_variables.$spacing-10;
transition: transform ease-in-out 250ms;
will-change: transform;
transform: translateX(0);
background: color_variables.$white;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.leaf-panel-left {
transform: translateX(-(size_variables.$drawer-width));
}
.leaf-panel-adding,
.leaf-panel-current {
position: relative;
& .namespace-link:last-child {
margin-bottom: 4px;
}
}
.animated-list {
.leaf-panel-exiting,
.leaf-panel-adding {
transform: translateX(size_variables.$drawer-width);
z-index: 20;
}
}
.leaf-panel-adding {
z-index: 100;
}

View file

@ -77,7 +77,6 @@
@use 'components/loader';
@use 'components/login-form';
@use 'components/masked-input';
@use 'components/namespace-picker';
@use 'components/namespace-reminder';
@use 'components/navigate-input';
@use 'components/overview-card';

View file

@ -57,6 +57,10 @@
overflow: hidden;
}
.is-overflow-y-auto {
overflow-y: auto;
}
// width and height
.is-fullwidth {
width: 100%;
@ -94,6 +98,10 @@
height: calc(size_variables.$desktop * 0.66);
}
.is-max-drawer-height {
max-height: size_variables.$drawer-height;
}
// float
.is-pulled-left {
float: left !important;

View file

@ -33,6 +33,10 @@
padding-right: size_variables.$spacing-12;
}
.has-padding-8 {
padding: size_variables.$spacing-8;
}
.has-padding-s {
padding: size_variables.$spacing-12;
}
@ -69,6 +73,10 @@
padding-top: size_variables.$spacing-4;
}
.has-bottom-padding-4 {
padding-bottom: size_variables.$spacing-4;
}
.has-top-padding-s {
padding-top: size_variables.$spacing-12;
}

View file

@ -52,3 +52,4 @@ $easing: ease-out;
/* Nav */
$drawer-width: 300px;
$drawer-height: 300px;

View file

@ -1,119 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<div class="namespace-picker" ...attributes>
<BasicDropdown @horizontalPosition="left" @verticalPosition="above" @renderInPlace={{true}} as |D|>
<D.Trigger
@htmlTag="button"
class="button is-transparent namespace-picker-trigger has-current-color"
data-test-namespace-toggle
>
<div class="is-flex-center is-flex-1">
<Icon @name="org" />
<span class="is-flex-1 is-word-break has-text-left is-size-6 has-left-margin-xs">{{this.namespaceDisplay}}</span>
</div>
<Icon @name="caret" />
</D.Trigger>
<D.Content @defaultClass="namespace-picker-content">
<div class="is-mobile level-left">
<h5 class="list-header">Current namespace</h5>
</div>
<div class="namespace-header-bar level is-mobile">
<div class="level-left">
<header>
<div class="level is-mobile namespace-link">
<span class="level-left" data-test-current-namespace>
{{if this.namespacePath (concat this.namespacePath "/") "root"}}
</span>
</div>
</header>
</div>
</div>
<div class="namespace-list {{if this.isAnimating 'animated-list'}}">
{{#if this.auth.isRootToken}}
<div class="has-left-margin-s has-right-margin-s">
<span><Icon @name="alert-triangle-fill" class="has-text-highlight" /></span>
<span class="is-size-8 has-text-semibold">
You are logged in with a root token and will have to reauthenticate when switching namespaces.
</span>
</div>
{{/if}}
<div class="is-mobile level-left">
{{#if this.isUserRootNamespace}}
<h5 class="list-header">Namespaces</h5>
{{else}}
<NamespaceLink
@targetNamespace={{or
(object-at (dec 2 this.menuLeaves.length) this.lastMenuLeaves)
this.auth.authData.userRootNamespace
}}
@class="namespace-link is-current button is-transparent icon"
>
<div class="is-flex-align-baseline">
<Hds::Button
@text="Go back"
@icon="chevron-left"
@isIconOnly={{true}}
@color="tertiary"
class="is-flex-align-baseline"
/>
<p class="is-size-8 has-text-grey has-text-weight-semibold is-uppercase">Namespaces</p>
</div>
</NamespaceLink>
{{/if}}
</div>
{{#if (includes "" this.lastMenuLeaves)}}
{{! leaf is '' which is the root namespace, and then we need to iterate the root leaves }}
<div class="leaf-panel {{if (eq '' this.currentLeaf) 'leaf-panel-current' 'leaf-panel-left'}} ">
{{#each this.rootLeaves as |rootLeaf|}}
<NamespaceLink @targetNamespace={{rootLeaf}} @class="namespace-link" @showLastSegment={{true}} />
{{/each}}
</div>
{{/if}}
{{#each this.lastMenuLeaves as |leaf|}}
{{#if leaf}}
<div
class="leaf-panel
{{if (eq leaf this.currentLeaf) 'leaf-panel-current' 'leaf-panel-left'}}
{{if (and this.isAdding (eq leaf this.changedLeaf)) 'leaf-panel-adding'}}
{{if (and (not this.isAdding) (eq leaf this.changedLeaf)) 'leaf-panel-exiting'}}
"
>
{{#each-in (get this.namespaceTree leaf) as |leafName|}}
<NamespaceLink
@targetNamespace={{concat leaf "/" leafName}}
@class="namespace-link"
@showLastSegment={{true}}
/>
{{/each-in}}
</div>
{{/if}}
{{/each}}
{{#if this.canList}}
<div class="leaf-panel leaf-panel-current">
<div class="level">
<span class="level-left">
<LinkTo @route="vault.cluster.access.namespaces" class="is-block namespace-link namespace-manage-link">
Manage Namespaces
</LinkTo>
</span>
<span class="level-right">
<Hds::Button
@text="Refresh namespace list"
@icon="reload"
@isIconOnly={{true}}
@color="tertiary"
data-test-refresh-namespaces
{{on "click" (action "refreshNamespaceList")}}
/>
</span>
</div>
</div>
{{/if}}
</div>
</D.Content>
</BasicDropdown>
</div>

View file

@ -3,62 +3,281 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, settled, visit, fillIn, currentURL, waitFor } from '@ember/test-helpers';
import {
click,
settled,
visit,
fillIn,
currentURL,
findAll,
triggerKeyEvent,
find,
} from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { runCmd, createNS } from 'vault/tests/helpers/commands';
import { runCmd, createNS, deleteNS } from 'vault/tests/helpers/commands';
import { login, loginNs, logout } from 'vault/tests/helpers/auth/auth-helpers';
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
import { GENERAL } from '../helpers/general-selectors';
import { NAMESPACE_PICKER_SELECTORS } from '../helpers/namespace-picker';
import sinon from 'sinon';
async function createNamespaces(namespaces) {
for (const ns of namespaces) {
// Note: iterate through the namespace parts to create the full namespace path
const parts = ns.split('/');
let currentPath = '';
for (const part of parts) {
// Visit the parent namespace
const url = `/vault/dashboard${currentPath && `?namespace=${currentPath.replaceAll('/', '%2F')}`}`;
await visit(url);
currentPath = currentPath ? `${currentPath}/${part}` : part;
// Create the current namespace
await runCmd(createNS(part), false);
await settled();
}
// Reset to the root namespace
const url = '/vault/dashboard';
await visit(url);
}
}
async function deleteNamespaces(namespaces) {
// Reset to the root namespace
const url = '/vault/dashboard';
await visit(url);
for (const ns of namespaces) {
// Note: delete the parent namespace to delete all child namespaces
const part = ns.split('/')[0];
await runCmd(deleteNS(part), false);
await settled();
}
}
module('Acceptance | Enterprise | namespaces', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
let fetchSpy;
hooks.beforeEach(() => {
fetchSpy = sinon.spy(window, 'fetch');
return login();
});
hooks.afterEach(() => {
fetchSpy.restore();
});
test('it focuses the search input field when the component is loaded', async function (assert) {
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Verify that the search input field is focused
const searchInput = find(NAMESPACE_PICKER_SELECTORS.searchInput);
assert.strictEqual(
document.activeElement,
searchInput,
'The search input field is focused on component load'
);
});
test('it navigates to the matching namespace when Enter is pressed', async function (assert) {
// Test Setup
const namespaces = ['beep/boop'];
await createNamespaces(namespaces);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(NAMESPACE_PICKER_SELECTORS.refreshList);
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
// Simulate typing into the search input
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'beep/boop');
assert
.dom(NAMESPACE_PICKER_SELECTORS.searchInput)
.hasValue('beep/boop', 'The search input field has the correct value');
// Simulate pressing Enter
await triggerKeyEvent(NAMESPACE_PICKER_SELECTORS.searchInput, 'keydown', 'Enter');
// Verify navigation to the matching namespace
assert.strictEqual(
this.owner.lookup('service:router').currentURL,
'/vault/dashboard?namespace=beep%2Fboop',
'Navigates to the correct namespace when Enter is pressed'
);
// Test Cleanup
await deleteNamespaces(namespaces);
});
test('it filters namespaces based on search input', async function (assert) {
// Test Setup
const namespaces = ['beep/boop/bop'];
await createNamespaces(namespaces);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(NAMESPACE_PICKER_SELECTORS.refreshList);
// Verify all namespaces are displayed initially
assert.dom(NAMESPACE_PICKER_SELECTORS.link()).exists('Namespace link(s) exist');
const allNamespaces = findAll(NAMESPACE_PICKER_SELECTORS.link());
// Verify the search input field exists
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
// Verify 3 namespaces are displayed after searching for "beep"
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'beep');
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
3,
'Display 3 namespaces matching "beep" after searching'
);
// Verify 1 namespace is displayed after searching for "bop"
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'bop');
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
1,
'Display 1 namespace matching "bop" after searching'
);
// Verify no namespaces are displayed after searching for "other"
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'other');
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
0,
'No namespaces are displayed after searching for "other"'
);
// Clear the search input & verify all namespaces are displayed again
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, '');
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
allNamespaces.length,
'All namespaces are displayed after clearing search input'
);
// Test Cleanup
await deleteNamespaces(namespaces);
});
test('it updates the namespace list after clicking "Refresh list"', async function (assert) {
// Test Setup
const namespaces = ['beep'];
await createNamespaces(namespaces);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Verify that the namespace list was fetched on load
let listNamespaceRequests = fetchSpy
.getCalls()
.filter((call) => call.args[0].includes('/v1/sys/internal/ui/namespaces'));
assert.strictEqual(
listNamespaceRequests.length,
1,
'The network call to the specific endpoint was made twice (once on load, once on refresh)'
);
// Refresh the list of namespaces
assert.dom(NAMESPACE_PICKER_SELECTORS.refreshList).exists('Refresh list button exists');
await click(NAMESPACE_PICKER_SELECTORS.refreshList);
// Verify that the namespace list was fetched on refresh
listNamespaceRequests = fetchSpy
.getCalls()
.filter((call) => call.args[0].includes('/v1/sys/internal/ui/namespaces'));
assert.strictEqual(
listNamespaceRequests.length,
2,
'The network call to the specific endpoint was made twice (once on load, once on refresh)'
);
// Test Cleanup
await deleteNamespaces(namespaces);
});
test('it displays the "Manage" button with the correct URL', async function (assert) {
// Test Setup
const namespaces = ['beep'];
await createNamespaces(namespaces);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(NAMESPACE_PICKER_SELECTORS.refreshList);
// Verify the "Manage" button is rendered and has the correct URL
assert
.dom('[href="/ui/vault/access/namespaces"]')
.exists('The "Manage" button is displayed with the correct URL');
// Test Cleanup
await deleteNamespaces(namespaces);
});
// This test originated from this PR: https://github.com/hashicorp/vault/pull/7186
test('it clears namespaces when you log out', async function (assert) {
// Test Setup
const namespaces = ['foo'];
await createNamespaces(namespaces);
const ns = 'foo';
await runCmd(createNS(ns), false);
const token = await runCmd(`write -field=client_token auth/token/create policies=default`);
await login(token);
await click('[data-test-namespace-toggle]');
assert.dom('[data-test-current-namespace]').hasText('root', 'root renders as current namespace');
assert.dom('[data-test-namespace-link]').doesNotExist('Additional namespace have been cleared');
await click(NAMESPACE_PICKER_SELECTORS.toggle);
assert.dom(NAMESPACE_PICKER_SELECTORS.link()).hasText('root', 'root renders as current namespace');
assert
.dom(`${NAMESPACE_PICKER_SELECTORS.link()} svg${GENERAL.icon('check')}`)
.exists('The root namespace is selected');
// Test Cleanup
await deleteNamespaces(namespaces);
});
test('it shows nested namespaces if you log in with a namespace starting with a /', async function (assert) {
assert.expect(5);
// This test originated from this PR: https://github.com/hashicorp/vault/pull/7186
test('it displays namespaces whether you log in with a namespace prefixed with / or not', async function (assert) {
// Test Setup
const namespaces = ['beep/boop/bop'];
await createNamespaces(namespaces);
await click('[data-test-namespace-toggle]');
const nses = ['beep', 'boop', 'bop'];
for (const [i, ns] of nses.entries()) {
await runCmd(createNS(ns), false);
await settled();
// the namespace path will include all of the namespaces up to this point
const targetNamespace = nses.slice(0, i + 1).join('/');
const url = `/vault/secrets?namespace=${targetNamespace}`;
// this is usually triggered when creating a ns in the form -- trigger a reload of the namespaces manually
await click('[data-test-namespace-toggle]');
await click('[data-test-refresh-namespaces]');
await waitFor(`[data-test-namespace-link="${targetNamespace}"]`);
// check that the single namespace "beep" or "boop" not "beep/boop" shows in the toggle display
assert
.dom(`[data-test-namespace-link="${targetNamespace}"]`)
.hasText(ns, `shows the namespace ${ns} in the toggle component`);
// because quint does not like page reloads, visiting url directly instead of clicking on namespace in toggle
await visit(url);
}
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(NAMESPACE_PICKER_SELECTORS.refreshList);
// Login with a namespace prefixed with /
await loginNs('/beep/boop');
await settled();
await click('[data-test-namespace-toggle]');
await waitFor('[data-test-current-namespace]');
assert.dom('[data-test-current-namespace]').hasText('beep/boop/');
assert
.dom('[data-test-namespace-link="beep/boop/bop"]')
.exists('renders the link to the nested namespace');
.dom(NAMESPACE_PICKER_SELECTORS.toggle)
.hasText('boop', `shows the namespace 'boop' in the toggle component`);
// Open the namespace picker & wait for it to render
await click(NAMESPACE_PICKER_SELECTORS.toggle);
assert.dom(`svg${GENERAL.icon('check')}`).exists('The check icon is rendered');
// Find the selected element with the check icon & ensure it exists
const checkIcon = find(`${NAMESPACE_PICKER_SELECTORS.link()} ${GENERAL.icon('check')}`);
assert.dom(checkIcon).exists('A selected namespace link with the check icon exists');
// Get the selected namespace with the data-test-namespace-link attribute & ensure it exists
const selectedNamespace = checkIcon?.closest(NAMESPACE_PICKER_SELECTORS.link());
assert.dom(selectedNamespace).exists('The selected namespace link exists');
// Verify that the selected namespace has the correct data-test-namespace-link attribute and path value
assert.strictEqual(
selectedNamespace.getAttribute('data-test-namespace-link'),
'beep/boop',
'The current namespace does not begin or end with /'
);
// Test Cleanup
await deleteNamespaces(namespaces);
});
test('it shows the regular namespace toolbar when not managed', async function (assert) {

View file

@ -116,3 +116,7 @@ export const tokenWithPolicyCmd = function (name, policy) {
export function createNS(namespace) {
return `write sys/namespaces/${namespace} -f`;
}
export function deleteNS(namespace) {
return `delete sys/namespaces/${namespace} -f`;
}

View file

@ -0,0 +1,12 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export const NAMESPACE_PICKER_SELECTORS = {
link: (link) => (link ? `[data-test-namespace-link="${link}"]` : '[data-test-namespace-link]'),
refreshList: '[data-test-refresh-namespaces]',
toggle: '[data-test-namespace-toggle]',
searchInput: 'input[type="search"]',
manageButton: '[data-test-manage-namespaces]',
};

View file

@ -0,0 +1,206 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn, findAll, waitFor, click, find } from '@ember/test-helpers';
import sinon from 'sinon';
import hbs from 'htmlbars-inline-precompile';
import Service from '@ember/service';
import { NAMESPACE_PICKER_SELECTORS } from 'vault/tests/helpers/namespace-picker';
class AuthService extends Service {
authData = { userRootNamespace: '' };
}
class NamespaceService extends Service {
accessibleNamespaces = ['parent1', 'parent1/child1'];
path = 'parent1/child1';
findNamespacesForUser = {
perform: () => Promise.resolve(),
};
}
class StoreService extends Service {
findRecord(modelType, id) {
return new Promise((resolve, reject) => {
if (modelType === 'capabilities' && id === 'sys/namespaces/') {
resolve(); // Simulate a successful response
} else {
reject({ httpStatus: 404, message: 'not found' }); // Simulate an error response
}
});
}
}
function getMockCapabilitiesModel(canList) {
// Mock for the Capabilities model
return {
path: 'sys/namespaces/',
capabilities: canList ? ['list'] : [],
get(property) {
if (property === 'canList') {
return this.capabilities.includes('list');
}
return undefined;
},
};
}
module('Integration | Component | namespace-picker', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:auth', AuthService);
this.owner.register('service:namespace', NamespaceService);
this.owner.register('service:store', StoreService);
});
test('it focuses the search input field when the component is loaded', async function (assert) {
await render(hbs`<NamespacePicker />`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Verify that the search input field is focused
const searchInput = find(NAMESPACE_PICKER_SELECTORS.searchInput);
assert.strictEqual(
document.activeElement,
searchInput,
'The search input field is focused on component load'
);
});
test('it filters namespace options based on search input', async function (assert) {
await render(hbs`<NamespacePicker/>`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Verify all namespaces are displayed initially
await waitFor(NAMESPACE_PICKER_SELECTORS.link());
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
3,
'All namespaces are displayed initially'
);
// Simulate typing into the search input
await waitFor(NAMESPACE_PICKER_SELECTORS.searchInput);
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'child1');
// Verify that only namespaces matching the search input are displayed
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
1,
'Only matching namespaces are displayed after filtering'
);
// Clear the search input
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, '');
// Verify all namespaces are displayed after clearing the search input
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
3,
'All namespaces are displayed after clearing the search input'
);
});
test('it shows both action buttons when canList is true', async function (assert) {
const storeStub = this.owner.lookup('service:store');
sinon.stub(storeStub, 'findRecord').callsFake((modelType, id) => {
if (modelType === 'capabilities' && id === 'sys/namespaces/') {
return Promise.resolve(getMockCapabilitiesModel(true));
}
return Promise.reject();
});
await render(hbs`<NamespacePicker />`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Verify that the "Refresh List" button is visible
assert.dom(NAMESPACE_PICKER_SELECTORS.refreshList).exists('Refresh List button is visible');
assert.dom(NAMESPACE_PICKER_SELECTORS.manageButton).exists('Manage button is visible');
});
test('it hides the refresh button when canList is false', async function (assert) {
const storeStub = this.owner.lookup('service:store');
sinon.stub(storeStub, 'findRecord').callsFake((modelType, id) => {
if (modelType === 'capabilities' && id === 'sys/namespaces/') {
return Promise.resolve(getMockCapabilitiesModel(false));
}
return Promise.reject();
});
await render(hbs`<NamespacePicker />`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Verify that the buttons are hidden
assert.dom(NAMESPACE_PICKER_SELECTORS.refreshList).doesNotExist('Refresh List button is hidden');
assert.dom(NAMESPACE_PICKER_SELECTORS.manageButton).exists('Manage button is hidden');
});
test('it hides both action buttons when the capabilities store throws an error', async function (assert) {
const storeStub = this.owner.lookup('service:store');
sinon.stub(storeStub, 'findRecord').callsFake(() => {
return Promise.reject();
});
await render(hbs`<NamespacePicker />`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Verify that the buttons are hidden
assert.dom(NAMESPACE_PICKER_SELECTORS.refreshList).doesNotExist('Refresh List button is hidden');
assert.dom(NAMESPACE_PICKER_SELECTORS.manageButton).doesNotExist('Manage button is hidden');
});
test('it updates the namespace list after clicking "Refresh list"', async function (assert) {
this.owner.lookup('service:namespace').set('hasListPermissions', true);
const storeStub = this.owner.lookup('service:store');
sinon.stub(storeStub, 'findRecord').callsFake((modelType, id) => {
if (modelType === 'capabilities' && id === 'sys/namespaces/') {
return Promise.resolve(getMockCapabilitiesModel(true)); // Return the mock model
}
return Promise.reject();
});
await render(hbs`<NamespacePicker />`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Dynamically modify the `findNamespacesForUser.perform` method for this test
const namespaceService = this.owner.lookup('service:namespace');
namespaceService.set('findNamespacesForUser', {
perform: () => {
namespaceService.set('accessibleNamespaces', [
'parent1',
'parent1/child1',
'new-namespace', // Add a new namespace
]);
return Promise.resolve();
},
});
// Verify initial namespaces are displayed
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
3,
'Initially, three namespaces are displayed'
);
// Click the "Refresh list" button
await click(NAMESPACE_PICKER_SELECTORS.refreshList);
// Verify the new namespace is displayed
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
4,
'After refreshing, four namespaces are displayed'
);
// Verify the new namespace is specifically shown
assert
.dom(NAMESPACE_PICKER_SELECTORS.link('new-namespace'))
.exists('The new namespace "new-namespace" is displayed after refreshing');
});
});

View file

@ -9,6 +9,7 @@ import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { NAMESPACE_PICKER_SELECTORS } from 'vault/tests/helpers/namespace-picker';
module('Integration | Component | sidebar-frame', function (hooks) {
setupRenderingTest(hooks);
@ -88,6 +89,6 @@ module('Integration | Component | sidebar-frame', function (hooks) {
<Sidebar::Frame @showSidebar={{true}} />
`);
assert.dom('.namespace-picker').exists('Namespace picker renders in sidebar footer');
assert.dom(NAMESPACE_PICKER_SELECTORS.toggle).exists('Namespace picker renders in sidebar footer');
});
});

View file

@ -4,6 +4,7 @@
*/
import Service from '@ember/service';
import { TaskGenerator, Task } from 'ember-concurrency';
interface PathsResponse {
[key: string]: {
@ -11,6 +12,7 @@ interface PathsResponse {
};
}
export default class NamespaceService extends Service {
accessibleNamespaces: string[];
userRootNamespace: string;
inRootNamespace: boolean;
inHvdAdminNamespace: boolean;
@ -18,6 +20,6 @@ export default class NamespaceService extends Service {
relativeNamespace: string;
path: string;
setNamespace: () => void;
findNamespacesForUser: () => void;
findNamespacesForUser: Task<TaskGenerator<[string]>, []>;
reset: () => void;
}