Skip to content

Commit 1ac19c9

Browse files
committed
[VAULT-35469] UI: navigate to namespace on enter/return (#30372)
* [VAULT-35469] UI: navigate to namespace on enter/return * address github copilot suggestions * address PR comments
1 parent b50331a commit 1ac19c9

File tree

7 files changed

+166
-73
lines changed

7 files changed

+166
-73
lines changed

ui/app/components/namespace-picker.hbs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,20 @@
1212
<Hds::Form::TextInput::Field
1313
@value={{this.searchInput}}
1414
@type="search"
15+
aria-label="Search namespaces"
1516
placeholder="Search"
17+
{{on "keydown" this.onKeyDown}}
1618
{{on "input" this.onSearchInput}}
19+
{{did-insert this.focusSearchInput}}
1720
/>
1821
</D.Generic>
1922

23+
{{#if this.hasSearchInput}}
24+
<D.Generic>
25+
{{this.searchInputHelpText}}
26+
</D.Generic>
27+
{{/if}}
28+
2029
<D.Separator />
2130

2231
<D.Generic>

ui/app/components/namespace-picker.js

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Component from '@glimmer/component';
77
import { action } from '@ember/object';
88
import { tracked } from '@glimmer/tracking';
99
import { service } from '@ember/service';
10+
import { KEYS } from 'core/utils/keyboard-keys';
1011

1112
/**
1213
* @module NamespacePicker
@@ -30,6 +31,8 @@ export default class NamespacePicker extends Component {
3031

3132
@tracked allNamespaces = [];
3233
@tracked searchInput = '';
34+
@tracked searchInputHelpText =
35+
"Enter a full path in the search bar and hit the 'Enter' ↵ key to navigate faster.";
3336
@tracked selected = {};
3437

3538
constructor() {
@@ -72,13 +75,17 @@ export default class NamespacePicker extends Component {
7275
];
7376

7477
// Conditionally add the root namespace
75-
if (this.auth.authData.userRootNamespace === '') {
78+
if (this.auth?.authData?.userRootNamespace === '') {
7679
options.unshift({ id: 'root', path: '', label: 'root' });
7780
}
7881

7982
return options;
8083
}
8184

85+
get hasSearchInput() {
86+
return this.searchInput?.trim().length > 0;
87+
}
88+
8289
get namespaceLabel() {
8390
return this.searchInput === '' ? 'All namespaces' : 'Matching namespaces';
8491
}
@@ -95,11 +102,6 @@ export default class NamespacePicker extends Component {
95102
}
96103
}
97104

98-
@action
99-
onSearchInput(event) {
100-
this.searchInput = event.target.value;
101-
}
102-
103105
@action
104106
async fetchListCapability() {
105107
// TODO: Revist. This logic was carried over from previous component implmenetation.
@@ -115,6 +117,12 @@ export default class NamespacePicker extends Component {
115117
}
116118
}
117119

120+
@action
121+
focusSearchInput(element) {
122+
// On mount, cursor should default to the search input field
123+
element.focus();
124+
}
125+
118126
@action
119127
async loadOptions() {
120128
// TODO: namespace service's findNamespacesForUser will never throw an error.
@@ -127,15 +135,35 @@ export default class NamespacePicker extends Component {
127135
await this.fetchListCapability();
128136
}
129137

130-
@action
131-
async refreshList() {
132-
this.searchInput = '';
133-
await this.loadOptions();
134-
}
135-
136138
@action
137139
async onChange(selected) {
138140
this.selected = selected;
139141
this.router.transitionTo('vault.cluster.dashboard', { queryParams: { namespace: selected.path } });
140142
}
143+
144+
@action
145+
async onKeyDown(event) {
146+
if (event.key === KEYS.ENTER && this.searchInput?.trim()) {
147+
const matchingNamespace = this.allNamespaces.find((ns) => ns.label === this.searchInput.trim());
148+
149+
if (matchingNamespace) {
150+
this.selected = matchingNamespace;
151+
this.searchInput = '';
152+
this.router.transitionTo('vault.cluster.dashboard', {
153+
queryParams: { namespace: matchingNamespace.path },
154+
});
155+
}
156+
}
157+
}
158+
159+
@action
160+
onSearchInput(event) {
161+
this.searchInput = event.target.value;
162+
}
163+
164+
@action
165+
async refreshList() {
166+
this.searchInput = '';
167+
await this.loadOptions();
168+
}
141169
}

ui/lib/core/addon/utils/key-codes.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
* SPDX-License-Identifier: BUSL-1.1
44
*/
55

6+
/*
7+
* DEPRCATED (see: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode).
8+
*
9+
* TODO: Replace all instances with `event.key` (use lib/core/addon/utils/keyboard-keys.ts).
10+
* `event.keyCode` is deprecated and will be removed in future versions of browsers.
11+
*/
12+
613
// a map of keyCode for use in keyboard event handlers
714
export default {
815
ENTER: 13,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) HashiCorp, Inc.
3+
* SPDX-License-Identifier: BUSL-1.1
4+
*/
5+
6+
/**
7+
* @module keyboard-keys
8+
* @description Utility for mapping key names to their corresponding `event.key` values for use in keyboard event handlers.
9+
*/
10+
export enum KEYS {
11+
ENTER = 'Enter',
12+
ESC = 'Escape',
13+
TAB = 'Tab',
14+
LEFT = 'ArrowLeft',
15+
UP = 'ArrowUp',
16+
RIGHT = 'ArrowRight',
17+
DOWN = 'ArrowDown',
18+
T = 'F5',
19+
BACKSPACE = 'Backspace',
20+
}

ui/tests/acceptance/enterprise-namespaces-test.js

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,17 @@
33
* SPDX-License-Identifier: BUSL-1.1
44
*/
55

6-
import { click, settled, visit, fillIn, currentURL, waitFor, findAll } from '@ember/test-helpers';
6+
import {
7+
click,
8+
settled,
9+
visit,
10+
fillIn,
11+
currentURL,
12+
waitFor,
13+
findAll,
14+
triggerKeyEvent,
15+
find,
16+
} from '@ember/test-helpers';
717
import { module, test } from 'qunit';
818
import { setupApplicationTest } from 'ember-qunit';
919
import { runCmd, createNS } from 'vault/tests/helpers/commands';
@@ -28,58 +38,82 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
2838
fetchSpy.restore();
2939
});
3040

31-
test('it filters namespaces based on search input', async function (assert) {
32-
assert.expect(7);
41+
test('it focuses the search input field when the component is loaded', async function (assert) {
42+
await click(NAMESPACE_PICKER_SELECTORS.toggle);
43+
44+
// Verify that the search input field is focused
45+
const searchInput = find(NAMESPACE_PICKER_SELECTORS.searchInput);
46+
assert.strictEqual(
47+
document.activeElement,
48+
searchInput,
49+
'The search input field is focused on component load'
50+
);
51+
});
3352

53+
test('it navigates to the matching namespace when Enter is pressed', async function (assert) {
3454
await click(NAMESPACE_PICKER_SELECTORS.toggle);
3555

36-
// Verify all namespaces are displayed initially
37-
assert.dom(NAMESPACE_PICKER_SELECTORS.link()).exists('Namespace link(s) exist');
56+
// Simulate typing into the search input
57+
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'beep/boop');
58+
assert
59+
.dom(NAMESPACE_PICKER_SELECTORS.searchInput)
60+
.hasValue('beep/boop', 'The search input field has the correct value');
61+
62+
// Simulate pressing Enter
63+
await triggerKeyEvent(NAMESPACE_PICKER_SELECTORS.searchInput, 'keydown', 'Enter');
64+
65+
// Verify navigation to the matching namespace
3866
assert.strictEqual(
39-
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
40-
5,
41-
'All namespaces are displayed initially'
67+
this.owner.lookup('service:router').currentURL,
68+
'/vault/dashboard?namespace=beep%2Fboop',
69+
'Navigates to the correct namespace when Enter is pressed'
4270
);
71+
});
72+
73+
test('it filters namespaces based on search input', async function (assert) {
74+
await click(NAMESPACE_PICKER_SELECTORS.toggle);
75+
76+
// Verify all namespaces are displayed initially
77+
assert.dom(NAMESPACE_PICKER_SELECTORS.link()).exists('Namespace link(s) exist');
78+
const allNamespaces = findAll(NAMESPACE_PICKER_SELECTORS.link());
4379

4480
// Verify the search input field exists
45-
assert.dom('[type="search"]').exists('The namespace search field exists');
81+
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
4682

4783
// Verify 3 namespaces are displayed after searching for "beep"
48-
await fillIn('[type="search"]', 'beep');
84+
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'beep');
4985
assert.strictEqual(
5086
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
5187
3,
5288
'Display 3 namespaces matching "beep" after searching'
5389
);
5490

5591
// Verify 1 namespace is displayed after searching for "bop"
56-
await fillIn('[type="search"]', 'bop');
92+
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'bop');
5793
assert.strictEqual(
5894
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
5995
1,
6096
'Display 1 namespace matching "bop" after searching'
6197
);
6298

6399
// Verify no namespaces are displayed after searching for "other"
64-
await fillIn('[type="search"]', 'other');
100+
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'other');
65101
assert.strictEqual(
66102
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
67103
0,
68104
'No namespaces are displayed after searching for "other"'
69105
);
70106

71107
// Clear the search input & verify all namespaces are displayed again
72-
await fillIn('[type="search"]', '');
108+
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, '');
73109
assert.strictEqual(
74110
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
75-
5,
111+
allNamespaces.length,
76112
'All namespaces are displayed after clearing search input'
77113
);
78114
});
79115

80116
test('it updates the namespace list after clicking "Refresh list"', async function (assert) {
81-
assert.expect(3);
82-
83117
await click(NAMESPACE_PICKER_SELECTORS.toggle);
84118

85119
// Verify that the namespace list was fetched on load
@@ -108,8 +142,6 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
108142
});
109143

110144
test('it displays the "Manage" button with the correct URL', async function (assert) {
111-
assert.expect(1);
112-
113145
await click(NAMESPACE_PICKER_SELECTORS.toggle);
114146

115147
// Verify the "Manage" button is rendered and has the correct URL
@@ -154,6 +186,7 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
154186

155187
// check that the full namespace path, like "beep/boop", shows in the toggle display
156188
await waitFor(NAMESPACE_PICKER_SELECTORS.link(targetNamespace));
189+
157190
assert
158191
.dom(NAMESPACE_PICKER_SELECTORS.link(targetNamespace))
159192
.hasText(targetNamespace, `shows the namespace ${targetNamespace} in the toggle component`);
@@ -170,14 +203,12 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
170203
await waitFor(`svg${GENERAL.icon('check')}`);
171204

172205
// Find the selected element with the check icon & ensure it exists
173-
const checkIcon = document.querySelector(
174-
`${NAMESPACE_PICKER_SELECTORS.link()} svg${GENERAL.icon('check')}`
175-
);
176-
assert.ok(checkIcon, 'A selected namespace link with the check icon exists');
206+
const checkIcon = find(`${NAMESPACE_PICKER_SELECTORS.link()} ${GENERAL.icon('check')}`);
207+
assert.dom(checkIcon).exists('A selected namespace link with the check icon exists');
177208

178209
// Get the selected namespace with the data-test-namespace-link attribute & ensure it exists
179-
const selectedNamespace = checkIcon.closest(NAMESPACE_PICKER_SELECTORS.link());
180-
assert.ok(selectedNamespace, 'The selected namespace link exists');
210+
const selectedNamespace = checkIcon?.closest(NAMESPACE_PICKER_SELECTORS.link());
211+
assert.dom(selectedNamespace).exists('The selected namespace link exists');
181212

182213
// Verify that the selected namespace has the correct data-test-namespace-link attribute and path value
183214
assert.strictEqual(

ui/tests/helpers/namespace-picker.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export const NAMESPACE_PICKER_SELECTORS = {
77
link: (link) => (link ? `[data-test-namespace-link="${link}"]` : '[data-test-namespace-link]'),
88
refreshList: '[data-test-refresh-namespaces]',
99
toggle: '[data-test-namespace-toggle]',
10+
searchInput: 'input[type="search"]',
1011
};

0 commit comments

Comments
 (0)