Skip to content

Commit 0f9ee35

Browse files
committed
[VAULT-34482] UI: Update Namespace Picker to use HDS Super Select component (#30068)
* [VAULT-34482] UI: Update Namespace Picker to use HDS Super Select component * changelog updates * update jsdoc * update classname * update test comment * remove unnecessary label attribute * move hbs file to components directory alongside js file * fix replication test * undo changes to replication test * test update
1 parent d17b626 commit 0f9ee35

File tree

8 files changed

+123
-292
lines changed

8 files changed

+123
-292
lines changed

changelog/29995.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:feature
2+
ui: Updating the Vault Namespace Picker to enable search functionality, allow direct navigation to nested namespaces and improve accessibility.
3+
```
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{{!
2+
Copyright (c) HashiCorp, Inc.
3+
SPDX-License-Identifier: BUSL-1.1
4+
}}
5+
6+
<div class="namespace-picker" ...attributes>
7+
<Hds::Form::SuperSelect::Single::Field
8+
@ariaLabel="Namespace"
9+
@onChange={{this.onChange}}
10+
@options={{this.options}}
11+
@placeholder="Search"
12+
@searchEnabled={{true}}
13+
@selected={{this.selected}}
14+
@selectedItemComponent={{component "namespace-picker/selected-option"}}
15+
@verticalPosition="above"
16+
data-test-namespace-toggle
17+
as |F|
18+
>
19+
<F.Options>
20+
{{#let F.options as |option|}}
21+
<span data-test-namespace-link="{{option.label}}">{{option.label}}</span>
22+
{{/let}}
23+
</F.Options>
24+
</Hds::Form::SuperSelect::Single::Field>
25+
</div>

ui/app/components/namespace-picker.js

Lines changed: 74 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -3,168 +3,80 @@
33
* SPDX-License-Identifier: BUSL-1.1
44
*/
55

6+
import Component from '@glimmer/component';
7+
import { action } from '@ember/object';
8+
import { tracked } from '@glimmer/tracking';
69
import { service } from '@ember/service';
7-
import { alias, gt } from '@ember/object/computed';
8-
import Component from '@ember/component';
9-
import { computed } from '@ember/object';
10-
import { task, timeout } from 'ember-concurrency';
11-
import pathToTree from 'vault/lib/path-to-tree';
12-
import { ancestorKeysForKey } from 'core/utils/key-utils';
1310

14-
const DOT_REPLACEMENT = '☃';
15-
const ANIMATION_DURATION = 250;
16-
17-
export default Component.extend({
18-
tagName: '',
19-
namespaceService: service('namespace'),
20-
auth: service(),
21-
store: service(),
22-
namespace: null,
23-
listCapability: null,
24-
canList: false,
25-
26-
init() {
27-
this._super(...arguments);
28-
this.namespaceService?.findNamespacesForUser.perform();
29-
},
30-
31-
didReceiveAttrs() {
32-
this._super(...arguments);
33-
34-
const ns = this.namespace;
35-
const oldNS = this.oldNamespace;
36-
if (!oldNS || ns !== oldNS) {
37-
this.setForAnimation.perform();
38-
this.fetchListCapability.perform();
39-
}
40-
this.set('oldNamespace', ns);
41-
},
42-
43-
fetchListCapability: task(function* () {
44-
try {
45-
const capability = yield this.store.findRecord('capabilities', 'sys/namespaces/');
46-
this.set('listCapability', capability);
47-
this.set('canList', true);
48-
} catch (e) {
49-
// If error out on findRecord call it's because you don't have permissions
50-
// and therefore don't have permission to manage namespaces
51-
this.set('canList', false);
52-
}
53-
}),
54-
setForAnimation: task(function* () {
55-
const leaves = this.menuLeaves;
56-
const lastLeaves = this.lastMenuLeaves;
57-
if (!lastLeaves) {
58-
this.set('lastMenuLeaves', leaves);
59-
yield timeout(0);
60-
return;
61-
}
62-
const isAdding = leaves.length > lastLeaves.length;
63-
const changedLeaves = isAdding ? leaves : lastLeaves;
64-
const [changedLeaf] = changedLeaves.slice(-1);
65-
this.set('isAdding', isAdding);
66-
this.set('changedLeaf', changedLeaf);
67-
68-
// if we're adding we want to render immediately an animate it in
69-
// if we're not adding, we need time to move the item out before
70-
// a rerender removes it
71-
if (isAdding) {
72-
this.set('lastMenuLeaves', leaves);
73-
yield timeout(0);
74-
return;
75-
}
76-
yield timeout(ANIMATION_DURATION);
77-
this.set('lastMenuLeaves', leaves);
78-
}).drop(),
79-
80-
isAnimating: alias('setForAnimation.isRunning'),
81-
82-
namespacePath: alias('namespaceService.path'),
83-
84-
// this is an array of namespace paths that the current user
85-
// has access to
86-
accessibleNamespaces: alias('namespaceService.accessibleNamespaces'),
87-
inRootNamespace: alias('namespaceService.inRootNamespace'),
88-
89-
namespaceTree: computed('accessibleNamespaces', function () {
90-
const nsList = this.accessibleNamespaces;
91-
92-
if (!nsList) {
93-
return [];
94-
}
95-
return pathToTree(nsList);
96-
}),
97-
98-
maybeAddRoot(leaves) {
99-
const userRoot = this.auth.authData.userRootNamespace;
100-
if (userRoot === '') {
101-
leaves.unshift('');
102-
}
103-
104-
return leaves.uniq();
105-
},
106-
107-
pathToLeaf(path) {
108-
// dots are allowed in namespace paths
109-
// so we need to preserve them, and replace slashes with dots
110-
// in order to use Ember's get function on the namespace tree
111-
// to pull out the correct level
112-
return (
113-
path
114-
// trim trailing slash
115-
.replace(/\/$/, '')
116-
// replace dots with snowman
117-
.replace(/\.+/g, DOT_REPLACEMENT)
118-
// replace slash with dots
119-
.replace(/\/+/g, '.')
120-
);
121-
},
122-
123-
// an array that keeps track of what additional panels to render
124-
// on the menu stack
125-
// if you're in 'foo/bar/baz',
126-
// this array will be: ['foo', 'foo.bar', 'foo.bar.baz']
127-
// the template then iterates over this, and does Ember.get(namespaceTree, leaf)
128-
// to render the nodes of each leaf
129-
130-
// gets set as 'lastMenuLeaves' in the ember concurrency task above
131-
menuLeaves: computed('namespacePath', 'namespaceTree', 'pathToLeaf', function () {
132-
let ns = this.namespacePath;
133-
ns = (ns || '').replace(/^\//, '');
134-
let leaves = ancestorKeysForKey(ns);
135-
leaves.push(ns);
136-
leaves = this.maybeAddRoot(leaves);
137-
138-
leaves = leaves.map(this.pathToLeaf);
139-
return leaves;
140-
}),
141-
142-
// the nodes at the root of the namespace tree
143-
// these will get rendered as the bottom layer
144-
rootLeaves: computed('namespaceTree', function () {
145-
const tree = this.namespaceTree;
146-
const leaves = Object.keys(tree);
147-
return leaves;
148-
}),
149-
150-
currentLeaf: alias('lastMenuLeaves.lastObject'),
151-
canAccessMultipleNamespaces: gt('accessibleNamespaces.length', 1),
152-
isUserRootNamespace: computed('auth.authData.userRootNamespace', 'namespacePath', function () {
153-
return this.auth.authData.userRootNamespace === this.namespacePath;
154-
}),
155-
156-
namespaceDisplay: computed('namespacePath', 'accessibleNamespaces', 'accessibleNamespaces.[]', function () {
157-
const namespace = this.namespacePath;
158-
if (!namespace) {
159-
return 'root';
160-
}
161-
const parts = namespace?.split('/');
162-
return parts[parts.length - 1];
163-
}),
11+
/**
12+
* @module NamespacePicker
13+
* @description component is used to display a dropdown listing all namespaces that the current user has access to.
14+
* The user can select a namespace from the dropdown to navigate directly to that namespace.
15+
* The "Manage" button directs the user to the namespace management page.
16+
* The "Refresh List" button refrehes the list of namespaces in the dropdown.
17+
*
18+
* @example
19+
* <NamespacePicker class="hds-side-nav-hide-when-minimized" />
20+
*/
16421

165-
actions: {
166-
refreshNamespaceList() {
167-
this.namespaceService.findNamespacesForUser.perform();
168-
},
169-
},
170-
});
22+
export default class NamespacePicker extends Component {
23+
@service namespace;
24+
25+
@tracked selected = {};
26+
@tracked options = [];
27+
28+
constructor() {
29+
super(...arguments);
30+
this.loadOptions();
31+
}
32+
33+
#matchesPath(option, currentNamespace) {
34+
// TODO: Revisit. A hardcoded check for "path" & "/path" seems hacky, but it fixes a breaking test:
35+
// "Acceptance | Enterprise | namespaces: it shows nested namespaces if you log in with a namespace starting with a /"
36+
// My assumption is that namespace shouldn't start with a "/", but is this a HVD thing? or is the test outdated?
37+
return option?.path === currentNamespace?.path || `/${option?.path}` === currentNamespace?.path;
38+
}
39+
40+
#getSelected(options, currentNamespace) {
41+
return options.find((option) => this.#matchesPath(option, currentNamespace));
42+
}
43+
44+
#getOptions(namespace) {
45+
/* Each namespace option has 3 properties: { id, path, and label }
46+
* - id: node / namespace name (displayed when the namespace picker is closed)
47+
* - path: full namespace path (used to navigate to the namespace)
48+
* - label: text displayed inside the namespace picker dropdown (if root, then label = id, else label = path)
49+
*
50+
* Example:
51+
* | id | path | label |
52+
* | --- | ---- | ----- |
53+
* | root | '' | 'root' |
54+
* | namespace1 | 'namespace1' | 'namespace1' |
55+
* | namespace2 | 'namespace1/namespace2' | 'namespace1/namespace2' |
56+
*/
57+
return [
58+
// TODO: Some users (including HDS Admin User) should never see the root namespace. Address this in a followup PR.
59+
{ id: 'root', path: '', label: 'root' },
60+
...(namespace?.accessibleNamespaces || []).map((ns) => {
61+
const parts = ns.split('/');
62+
return { id: parts[parts.length - 1], path: ns, label: ns };
63+
}),
64+
];
65+
}
66+
67+
@action
68+
async loadOptions() {
69+
// TODO: namespace service's findNamespacesForUser will never throw an error.
70+
// Check with design to determine if we should continue to ignore or handle an error situation here.
71+
await this.namespace?.findNamespacesForUser.perform();
72+
73+
this.options = this.#getOptions(this.namespace);
74+
this.selected = this.#getSelected(this.options, this.namespace);
75+
}
76+
77+
@action
78+
async onChange(selectedOption) {
79+
// TODO: redirect to selected namespace
80+
this.selected = selectedOption;
81+
}
82+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{{!
2+
Copyright (c) HashiCorp, Inc.
3+
SPDX-License-Identifier: BUSL-1.1
4+
}}
5+
6+
<Hds::Text::Body>
7+
<div class="is-flex-center is-flex-1">
8+
<Icon @name="org" />
9+
<span data-test-selected-namespace="{{@option.label}}">{{@option.id}}</span>
10+
</div>
11+
</Hds::Text::Body>

ui/app/components/sidebar/frame.hbs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,7 @@
3838

3939
<:footer>
4040
{{#if (has-feature "Namespaces")}}
41-
<NamespacePicker
42-
@namespace={{this.clusterController.namespaceQueryParam}}
43-
class="hds-side-nav-hide-when-minimized"
44-
/>
41+
<NamespacePicker class="hds-side-nav-hide-when-minimized" />
4542
{{/if}}
4643
</:footer>
4744
</Hds::SideNav>

0 commit comments

Comments
 (0)