From fe9f18b7f265e7aa8032c895bfc817556aaa0a91 Mon Sep 17 00:00:00 2001 From: "Shannon Roberts (Beagin)" Date: Mon, 5 May 2025 16:12:36 -0700 Subject: [PATCH] [VAULT-34216] UI: Namespace picker feature branch (#30490) --- changelog/30490.txt | 3 + ui/app/components/namespace-picker.hbs | 92 ++++++ ui/app/components/namespace-picker.js | 170 ----------- ui/app/components/namespace-picker.ts | 229 ++++++++++++++ ui/app/components/sidebar/frame.hbs | 5 +- .../styles/components/namespace-picker.scss | 130 -------- ui/app/styles/core.scss | 1 - ui/app/styles/helper-classes/layout.scss | 8 + ui/app/styles/helper-classes/spacing.scss | 8 + ui/app/styles/utils/_size_variables.scss | 1 + .../templates/components/namespace-picker.hbs | 119 -------- .../acceptance/enterprise-namespaces-test.js | 285 ++++++++++++++++-- ui/tests/helpers/commands.js | 4 + ui/tests/helpers/namespace-picker.js | 12 + .../components/namespace-picker-test.js | 206 +++++++++++++ .../components/sidebar/frame-test.js | 3 +- ui/types/vault/services/namespace.d.ts | 4 +- 17 files changed, 821 insertions(+), 459 deletions(-) create mode 100644 changelog/30490.txt create mode 100644 ui/app/components/namespace-picker.hbs delete mode 100644 ui/app/components/namespace-picker.js create mode 100644 ui/app/components/namespace-picker.ts delete mode 100644 ui/app/styles/components/namespace-picker.scss delete mode 100644 ui/app/templates/components/namespace-picker.hbs create mode 100644 ui/tests/helpers/namespace-picker.js create mode 100644 ui/tests/integration/components/namespace-picker-test.js diff --git a/changelog/30490.txt b/changelog/30490.txt new file mode 100644 index 0000000000..bdc3e00665 --- /dev/null +++ b/changelog/30490.txt @@ -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. +``` \ No newline at end of file diff --git a/ui/app/components/namespace-picker.hbs b/ui/app/components/namespace-picker.hbs new file mode 100644 index 0000000000..98696a25cb --- /dev/null +++ b/ui/app/components/namespace-picker.hbs @@ -0,0 +1,92 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + +
+ + + + + {{#if this.errorLoadingNamespaces}} + + + + + + {{else}} + + +
+ +
+
+ + + {{#if (and this.hasSearchInput (not this.showNoNamespacesMessage))}} +
+ {{this.searchInputHelpText}} +
+ {{/if}} + +
+ {{this.namespaceLabel}} + +
+
+ + {{#if this.showNoNamespacesMessage}} + + {{this.noNamespacesMessage}} + + {{/if}} + +
+ {{#each this.visibleNamespaceOptions as |option|}} + + {{option.label}} + + {{/each}} +
+ + {{/if}} + + + + {{#if this.canRefreshNamespaces}} + + {{/if}} + {{#if this.canManageNamespaces}} + + {{/if}} + + + + +
+
\ No newline at end of file diff --git a/ui/app/components/namespace-picker.js b/ui/app/components/namespace-picker.js deleted file mode 100644 index 4781fc7e44..0000000000 --- a/ui/app/components/namespace-picker.js +++ /dev/null @@ -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(); - }, - }, -}); diff --git a/ui/app/components/namespace-picker.ts b/ui/app/components/namespace-picker.ts new file mode 100644 index 0000000000..ce66d10ca4 --- /dev/null +++ b/ui/app/components/namespace-picker.ts @@ -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 + * + */ +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) { + 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 { + 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 { + 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 { + this.selected = selected; + this.searchInput = ''; + this.router.transitionTo('vault.cluster.dashboard', { queryParams: { namespace: selected.path } }); + } + + @action + async onKeyDown(event: KeyboardEvent): Promise { + 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 { + this.searchInput = ''; + await this.loadOptions(); + } +} diff --git a/ui/app/components/sidebar/frame.hbs b/ui/app/components/sidebar/frame.hbs index 0a8ec4d21c..70ecda1383 100644 --- a/ui/app/components/sidebar/frame.hbs +++ b/ui/app/components/sidebar/frame.hbs @@ -38,10 +38,7 @@ <:footer> {{#if (has-feature "Namespaces")}} - + {{/if}} diff --git a/ui/app/styles/components/namespace-picker.scss b/ui/app/styles/components/namespace-picker.scss deleted file mode 100644 index 10f78985b1..0000000000 --- a/ui/app/styles/components/namespace-picker.scss +++ /dev/null @@ -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; -} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index 782bc58d92..a281e7787c 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -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'; diff --git a/ui/app/styles/helper-classes/layout.scss b/ui/app/styles/helper-classes/layout.scss index 1cc0d30a61..04d4a70762 100644 --- a/ui/app/styles/helper-classes/layout.scss +++ b/ui/app/styles/helper-classes/layout.scss @@ -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; diff --git a/ui/app/styles/helper-classes/spacing.scss b/ui/app/styles/helper-classes/spacing.scss index 250b649e3f..158ea4a1f5 100644 --- a/ui/app/styles/helper-classes/spacing.scss +++ b/ui/app/styles/helper-classes/spacing.scss @@ -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; } diff --git a/ui/app/styles/utils/_size_variables.scss b/ui/app/styles/utils/_size_variables.scss index aaeb869d43..348496a4c5 100644 --- a/ui/app/styles/utils/_size_variables.scss +++ b/ui/app/styles/utils/_size_variables.scss @@ -52,3 +52,4 @@ $easing: ease-out; /* Nav */ $drawer-width: 300px; +$drawer-height: 300px; diff --git a/ui/app/templates/components/namespace-picker.hbs b/ui/app/templates/components/namespace-picker.hbs deleted file mode 100644 index f4a06286cb..0000000000 --- a/ui/app/templates/components/namespace-picker.hbs +++ /dev/null @@ -1,119 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -}} - -
- - -
- - {{this.namespaceDisplay}} -
- -
- -
-
Current namespace
-
-
-
-
- -
-
-
-
- {{#if this.auth.isRootToken}} -
- - - You are logged in with a root token and will have to reauthenticate when switching namespaces. - -
- {{/if}} - -
- {{#if this.isUserRootNamespace}} -
Namespaces
- {{else}} - -
- -

Namespaces

-
-
- {{/if}} -
- {{#if (includes "" this.lastMenuLeaves)}} - {{! leaf is '' which is the root namespace, and then we need to iterate the root leaves }} -
- {{#each this.rootLeaves as |rootLeaf|}} - - {{/each}} -
- {{/if}} - {{#each this.lastMenuLeaves as |leaf|}} - {{#if leaf}} -
- {{#each-in (get this.namespaceTree leaf) as |leafName|}} - - {{/each-in}} -
- {{/if}} - {{/each}} - {{#if this.canList}} -
-
- - - Manage Namespaces - - - - - -
-
- {{/if}} -
-
-
-
\ No newline at end of file diff --git a/ui/tests/acceptance/enterprise-namespaces-test.js b/ui/tests/acceptance/enterprise-namespaces-test.js index c8f25ca298..37c6436992 100644 --- a/ui/tests/acceptance/enterprise-namespaces-test.js +++ b/ui/tests/acceptance/enterprise-namespaces-test.js @@ -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) { diff --git a/ui/tests/helpers/commands.js b/ui/tests/helpers/commands.js index 77af014a4b..a585b4d0ef 100644 --- a/ui/tests/helpers/commands.js +++ b/ui/tests/helpers/commands.js @@ -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`; +} diff --git a/ui/tests/helpers/namespace-picker.js b/ui/tests/helpers/namespace-picker.js new file mode 100644 index 0000000000..983792fef9 --- /dev/null +++ b/ui/tests/helpers/namespace-picker.js @@ -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]', +}; diff --git a/ui/tests/integration/components/namespace-picker-test.js b/ui/tests/integration/components/namespace-picker-test.js new file mode 100644 index 0000000000..f7257dbf6e --- /dev/null +++ b/ui/tests/integration/components/namespace-picker-test.js @@ -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``); + 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``); + 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``); + 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``); + 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``); + 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``); + 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'); + }); +}); diff --git a/ui/tests/integration/components/sidebar/frame-test.js b/ui/tests/integration/components/sidebar/frame-test.js index f597b13389..70d7835875 100644 --- a/ui/tests/integration/components/sidebar/frame-test.js +++ b/ui/tests/integration/components/sidebar/frame-test.js @@ -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) { `); - assert.dom('.namespace-picker').exists('Namespace picker renders in sidebar footer'); + assert.dom(NAMESPACE_PICKER_SELECTORS.toggle).exists('Namespace picker renders in sidebar footer'); }); }); diff --git a/ui/types/vault/services/namespace.d.ts b/ui/types/vault/services/namespace.d.ts index dc9f8d2e0c..6d5728d7fa 100644 --- a/ui/types/vault/services/namespace.d.ts +++ b/ui/types/vault/services/namespace.d.ts @@ -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, []>; reset: () => void; }