mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
[VAULT-34216] UI: Namespace picker feature branch (#30490)
This commit is contained in:
parent
97fcf46e02
commit
fe9f18b7f2
17 changed files with 821 additions and 459 deletions
3
changelog/30490.txt
Normal file
3
changelog/30490.txt
Normal 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.
|
||||
```
|
||||
92
ui/app/components/namespace-picker.hbs
Normal file
92
ui/app/components/namespace-picker.hbs
Normal 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>
|
||||
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
});
|
||||
229
ui/app/components/namespace-picker.ts
Normal file
229
ui/app/components/namespace-picker.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,3 +52,4 @@ $easing: ease-out;
|
|||
|
||||
/* Nav */
|
||||
$drawer-width: 300px;
|
||||
$drawer-height: 300px;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
12
ui/tests/helpers/namespace-picker.js
Normal file
12
ui/tests/helpers/namespace-picker.js
Normal 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]',
|
||||
};
|
||||
206
ui/tests/integration/components/namespace-picker-test.js
Normal file
206
ui/tests/integration/components/namespace-picker-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
4
ui/types/vault/services/namespace.d.ts
vendored
4
ui/types/vault/services/namespace.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue