UI: Fix namespace picker selecting all namespaces with matching nodes (#31326)

* add test

* add changelog
This commit is contained in:
claire bontempo 2025-07-17 15:56:59 -07:00 committed by GitHub
parent 4c828c389d
commit 41d8301927
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 47 additions and 34 deletions

3
changelog/31326.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:bug
ui: Fix selecting multiple namespaces in the namespace picker when the path contains matching nodes
```

View file

@ -8,7 +8,9 @@
<D.ToggleButton
@icon="org"
@text={{or this.selectedNamespace.id "-"}}
{{! Displays the node of the current namespace context in the toggle }}
{{! For example, if a user navigates to 'parent/child' the toggle just displays 'child' }}
@text={{this.namespace.currentNamespace}}
@isFullWidth={{true}}
data-test-button="namespace-picker"
{{on "click" this.toggleNamespacePicker}}
@ -59,7 +61,7 @@
<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.selectedNamespace.id}}
@selected={{eq option.path this.selectedNamespace.path}}
{{on "click" (fn this.onChange option)}}
data-test-button={{option.label}}
>

View file

@ -17,7 +17,6 @@ import type Store from '@ember-data/store';
import errorMessage from 'vault/utils/error-message';
interface NamespaceOption {
id: string;
path: string;
label: string;
}
@ -56,11 +55,7 @@ export default class NamespacePicker extends Component {
}
get allNamespaces(): NamespaceOption[] {
return this.getOptions(
this.namespace?.accessibleNamespaces,
this.namespace?.currentNamespace,
this.namespace?.path
);
return this.getOptions(this.namespace?.accessibleNamespaces);
}
get selectedNamespace(): NamespaceOption | null {
@ -75,45 +70,33 @@ export default class NamespacePicker extends Component {
return options.find((option) => this.matchesPath(option, currentPath));
}
private getOptions(
accessibleNamespaces: string[],
currentNamespace: string,
path: string
): NamespaceOption[] {
/* Each namespace option has 3 properties: { id, path, and label }
* - id: node / namespace name (displayed when the namespace picker is closed)
private getOptions(accessibleNamespaces: string[]): NamespaceOption[] {
/* Each namespace option has 2 properties: { path and label }
* - path: full namespace path (used to navigate to the namespace)
* - label: text displayed inside the namespace picker dropdown (if root, then label = id, else label = path)
* - label: text displayed inside the namespace picker dropdown (if root, then path is "", else label = path)
*
* Example:
* | id | path | label |
* | --- | ---- | ----- |
* | 'root' | '' | 'root' |
* | 'parent' | 'parent' | 'parent' |
* | 'child' | 'parent/child' | 'parent/child' |
* | path | label |
* | ---- | ----- |
* | '' | 'root' |
* | 'parent' | 'parent' |
* | 'parent/child' | 'parent/child' |
*/
const options = [
...(accessibleNamespaces || []).map((ns: string) => {
const parts = ns.split('/');
return { id: parts[parts.length - 1] || '', path: ns, label: ns };
}),
];
const options = (accessibleNamespaces || []).map((ns: string) => ({ path: ns, label: ns }));
// Add the user's root namespace because `sys/internal/ui/namespaces` does not include it.
const userRootNamespace = this.auth.authData?.userRootNamespace;
if (!options?.find((o) => o.path === userRootNamespace)) {
const ns = userRootNamespace === '' ? 'root' : userRootNamespace;
options.unshift({ id: ns, path: userRootNamespace, label: ns });
// the 'root' namespace is technically an empty string so we manually add the 'root' label.
const label = userRootNamespace === '' ? 'root' : userRootNamespace;
options.unshift({ path: userRootNamespace, label });
}
// If there are no namespaces returned by the internal endpoint, add the current namespace
// to the list of options. This is a fallback for when the user has access to a single namespace.
if (options.length === 0) {
options.push({
id: currentNamespace,
path: path,
label: path,
});
// 'path' defined in the namespace service is the full namespace path
options.push({ path: this.namespace.path, label: this.namespace.path });
}
return options;

View file

@ -48,6 +48,16 @@ module('Integration | Component | namespace-picker', function (hooks) {
);
});
test('it selects the current namespace', async function (assert) {
await render(hbs`<NamespacePicker />`);
assert.dom(GENERAL.button('namespace-picker')).hasText('child1', 'it just displays the namespace node');
await click(GENERAL.button('namespace-picker'));
assert
.dom(GENERAL.button(this.nsService.path))
.hasAttribute('aria-selected', 'true', 'the current namespace path is selected');
assert.dom(`${GENERAL.button(this.nsService.path)} ${GENERAL.icon('check')}`).exists();
});
test('it filters namespace options based on search input', async function (assert) {
await render(hbs`<NamespacePicker/>`);
await click(GENERAL.button('namespace-picker'));
@ -160,4 +170,19 @@ module('Integration | Component | namespace-picker', function (hooks) {
assert.dom(GENERAL.button('admin')).exists();
assert.dom(GENERAL.button('admin/child1')).exists();
});
test('it selects the correct namespace when matching nodes exist', async function (assert) {
// stub response so that two namespaces have matching node names 'child1'
this.server.get('/sys/internal/ui/namespaces', () => {
return { data: { keys: ['parent1/', 'parent1/child1', 'anotherParent/', 'anotherParent/child1'] } };
});
await render(hbs`<NamespacePicker />`);
assert.dom(GENERAL.button('namespace-picker')).hasText('child1', 'it displays the namespace node');
await click(GENERAL.button('namespace-picker'));
assert
.dom(GENERAL.button(this.nsService.path))
.hasAttribute('aria-selected', 'true', 'the current namespace path is selected');
assert.dom('[aria-selected="true"]').exists({ count: 1 }, 'only one option is selected');
assert.dom(GENERAL.icon('check')).exists({ count: 1 }, 'only one check mark renders');
});
});