UI: Update kv list filter to not search on type (#22648)

This commit is contained in:
Chelsea Shaw 2023-08-31 16:30:00 -05:00 committed by GitHub
parent 16f805419f
commit 8da06f9b54
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 140 additions and 270 deletions

View File

@ -1,35 +1,21 @@
<div class="navigate-filter">
<div class="field" data-test-nav-input>
<p class="control has-icons-left">
<Input
id="secret-filter"
class="filter input"
placeholder="Filter secrets"
@value={{@filterValue}}
@type="text"
data-test-component="kv-list-filter"
{{on "input" this.handleInput}}
{{on "keydown" this.handleKeyDown}}
{{on "focus" this.setFilterIsFocused}}
{{did-insert this.focusInput}}
autocomplete="off"
/>
<Icon @name="search" class="search-icon has-text-grey-light" />
</p>
</div>
</div>
{{#if (and this.filterIsFocused this.isFilterMatch)}}
<p class="help has-text-grey is-size-8 has-left-padding-xs" data-test-help-enter>
<kbd>ENTER</kbd>
to go to see details.
<kbd>ESC</kbd>
to clear input.
</p>
{{else}}
<p class="help has-text-grey is-size-8 has-left-padding-xs" data-test-help-tab>
<kbd>TAB</kbd>
to autocomplete.
<kbd>ESC</kbd>
to clear input.
</p>
{{/if}}
<form {{on "submit" (perform this.handleSearch)}}>
<Hds::SegmentedGroup as |S|>
<S.TextInput
id="secret-id"
@value={{this.query}}
placeholder="Search secret path"
aria-describedby="Search by secret path"
size="32"
{{on "input" this.handleInput}}
{{on "keydown" this.handleKeyDown}}
data-test-kv-list-filter
/>
<S.Button
@color="secondary"
@text="Search"
@icon={{if this.handleSearch.isRunning "loading" "search"}}
type="submit"
data-test-kv-list-filter-submit
/>
</Hds::SegmentedGroup>
</form>

View File

@ -3,33 +3,41 @@
* SPDX-License-Identifier: MPL-2.0
*/
import Ember from 'ember';
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import keys from 'core/utils/key-codes';
import { keyIsFolder, parentKeyForKey, keyWithoutParentKey } from 'core/utils/key-utils';
import escapeStringRegexp from 'escape-string-regexp';
import { tracked } from '@glimmer/tracking';
import { task, timeout } from 'ember-concurrency';
/**
* @module KvListFilter
* `KvListFilter` filters through the KV metadata LIST response. It allows users to search through the current list, navigate into directories, and use keyboard functions to: autocomplete, view a secret, create a new secret, or clear the input field.
* `KvListFilter` is used for filtering on the KV metadata LIST response.
* It allows users to search for any text, and will transition to the list
* page with the appropriate parameters depending on the query. This component
* expects that the component will be re-constructed after search, since the
* route will reload the model and completely refresh the page.
* *
* <KvListFilter
* @secrets={{this.model.secrets}}
* @mountPoint={{this.model.mountPoint}}
* @filterValue="beep/my-"
* @pageFilter="my-"
* />
* @param {array} secrets - An array of secret models.
* @param {string} mountPoint - Where in the router files we're located. For this component it will always be vault.cluster.secrets.backend.kv
* @param {string} filterValue - A concatenation between the list-directory's dynamic path "path-to-secret" and the queryParam "pageFilter". For example, if we're inside the beep/ directory searching for any secret that starts with "my-" this value will equal "beep/my-".
* @param {string} pageFilter - The queryParam value, does not include pathToSecret ex: my-.
* @param {string} filterValue - Full initial search value. A concatenation between the list-directory's dynamic path "path-to-secret" and the queryParam "pageFilter". For example, if we're inside the beep/ directory searching for any secret that starts with "my-" this value will equal "beep/my-".
*/
export default class KvListFilterComponent extends Component {
@service router;
@tracked filterIsFocused = false;
@tracked query;
constructor() {
super(...arguments);
this.query = this.args.filterValue;
}
navigate(pathToSecret, pageFilter) {
const route = pathToSecret ? `${this.args.mountPoint}.list-directory` : `${this.args.mountPoint}.list`;
@ -45,122 +53,37 @@ export default class KvListFilterComponent extends Component {
this.router.transitionTo(...args);
}
/*
- partialMatch returns the secret that most closely matches the pageFilter queryParam.
- Searches pageFilter and not filterValue because if we're inside a directory we only care about the secrets listed there and not the directory.
- If pageFilter is empty this returns the first secret model in the list.
**/
get partialMatch() {
// If pageFilter is empty we replace it with an empty string because you cannot pass 'undefined' to RegEx.
const value = !this.args.pageFilter ? '' : this.args.pageFilter;
const reg = new RegExp('^' + escapeStringRegexp(value));
const match = this.args.secrets.filter((path) => reg.test(path.fullSecretPath))[0];
if (this.isFilterMatch || !match) return null;
return match.fullSecretPath;
}
/*
- isFilterMatch returns true if the filterValue matches a fullSecretPath.
**/
get isFilterMatch() {
return !!this.args.secrets?.findBy('fullSecretPath', this.args.filterValue);
}
/*
-handleInput is triggered after the value of the input has changed. It is not triggered when input looses focus.
**/
@action
handleInput(event) {
const input = event.target.value;
const isDirectory = keyIsFolder(input);
const parentDirectory = parentKeyForKey(input);
const secretWithinDirectory = keyWithoutParentKey(input);
handleKeyDown(event) {
if (event.keyCode === keys.ESC) {
// On escape, transition to the nearest parentDirectory.
// If no parentDirectory, then to the list route.
const input = event.target.value;
const parentDirectory = parentKeyForKey(input);
!parentDirectory ? this.navigate() : this.navigate(parentDirectory);
}
// ignore all other key events
}
@action handleInput(evt) {
this.query = evt.target.value;
}
@task
*handleSearch(evt) {
evt.preventDefault();
// shows loader to indicate that the search was executed
yield timeout(Ember.testing ? 0 : 250);
const searchTerm = this.query;
const isDirectory = keyIsFolder(searchTerm);
const parentDirectory = parentKeyForKey(searchTerm);
const secretWithinDirectory = keyWithoutParentKey(searchTerm);
if (isDirectory) {
this.navigate(input);
this.navigate(searchTerm);
} else if (parentDirectory) {
this.navigate(parentDirectory, secretWithinDirectory);
} else {
this.navigate(null, input);
}
}
/*
-handleKeyDown handles: tab, enter, backspace and escape. Ignores everything else.
**/
@action
handleKeyDown(event) {
const input = event.target.value;
const parentDirectory = parentKeyForKey(input);
if (event.keyCode === keys.BACKSPACE) {
this.handleBackspace(input, parentDirectory);
}
if (event.keyCode === keys.TAB) {
event.preventDefault();
this.handleTab();
}
if (event.keyCode === keys.ENTER) {
event.preventDefault();
this.handleEnter(input);
}
if (event.keyCode === keys.ESC) {
this.handleEscape(parentDirectory);
}
// ignore all other key events
return;
}
// key-code specific methods
handleBackspace(input, parentDirectory) {
const isInputDirectory = keyIsFolder(input);
const inputWithoutParentKey = keyWithoutParentKey(input);
const pageFilter = isInputDirectory ? '' : inputWithoutParentKey.slice(0, -1);
this.navigate(parentDirectory, pageFilter);
}
handleTab() {
const isMatchDirectory = keyIsFolder(this.partialMatch);
const matchParentDirectory = parentKeyForKey(this.partialMatch);
const matchWithinDirectory = keyWithoutParentKey(this.partialMatch);
if (isMatchDirectory) {
// ex: beep/boop/
this.navigate(this.partialMatch);
} else if (!isMatchDirectory && matchParentDirectory) {
// ex: beep/boop/my-
this.navigate(matchParentDirectory, matchWithinDirectory);
} else {
// ex: my-
this.navigate(null, this.partialMatch);
}
}
handleEnter(input) {
if (this.isFilterMatch) {
// if secret exists send to details
this.router.transitionTo(`${this.args.mountPoint}.secret.details`, input);
} else {
// if secret does not exists send to create with the path prefilled with input value.
this.router.transitionTo(`${this.args.mountPoint}.create`, {
queryParams: { initialKey: input },
});
}
}
handleEscape(parentDirectory) {
// transition to the nearest parentDirectory. If no parentDirectory, then to the list route.
!parentDirectory ? this.navigate() : this.navigate(parentDirectory);
}
@action
setFilterIsFocused() {
// tracked property used to show or hide the help-text next to the input. Not involved in focus event itself.
this.filterIsFocused = true;
}
@action
focusInput() {
// set focus to the input when there is either a pageFilter queryParam value and/or list-directory's dynamic path-to-secret has a value.
if (this.args.filterValue) {
document.getElementById('secret-filter')?.focus();
this.navigate(null, searchTerm);
}
}
}

View File

@ -7,12 +7,7 @@
<:toolbarFilters>
{{#unless @noMetadataListPermissions}}
{{#if (or @secrets @filterValue)}}
<KvListFilter
@secrets={{@secrets}}
@mountPoint={{this.mountPoint}}
@filterValue={{@filterValue}}
@pageFilter={{@pageFilter}}
/>
<KvListFilter @secrets={{@secrets}} @mountPoint={{this.mountPoint}} @filterValue={{@filterValue}} />
{{/if}}
{{/unless}}
</:toolbarFilters>

View File

@ -60,7 +60,7 @@ export const PAGE = {
list: {
createSecret: '[data-test-toolbar-create-secret]',
item: (secret) => (!secret ? '[data-test-list-item]' : `[data-test-list-item="${secret}"]`),
filter: `[data-test-component="kv-list-filter"]`,
filter: `[data-test-kv-list-filter]`,
overviewCard: '[data-test-overview-card-container="View secret"]',
overviewInput: '[data-test-view-secret] input',
overviewButton: '[data-test-get-secret-detail]',

View File

@ -4,10 +4,11 @@
*/
import { module, test } from 'qunit';
import sinon from 'sinon';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, focus, triggerKeyEvent, fillIn } from '@ember/test-helpers';
import { render, triggerKeyEvent, typeIn, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { kvMetadataPath } from 'vault/utils/kv-path';
@ -47,14 +48,16 @@ module('Integration | Component | kv | kv-list-filter', function (hooks) {
this.mountPoint = MOUNT_POINT;
});
test('it renders and TAB defaults to first secret in list', async function (assert) {
assert.expect(4);
// mirage hook for TAB
this.owner.lookup('service:router').reopen({
transitionTo(route, { queryParams: { pageFilter } }) {
assert.strictEqual(route, `${MOUNT_POINT}.list`, 'List route sent when TAB on empty input.');
assert.deepEqual(pageFilter, 'my-secret', 'Filters to the first secret in the list.');
},
test('it transitions correctly for query without slash', async function (assert) {
assert.expect(3);
const routerSvc = this.owner.lookup('service:router');
sinon.stub(routerSvc, 'transitionTo').callsFake((route, params) => {
assert.strictEqual(route, `${MOUNT_POINT}.list`, 'List route sent when no pathToSecret.');
assert.deepEqual(
params,
{ queryParams: { pageFilter: 'my-secret' } },
'Sends correct transition params.'
);
});
await render(hbs`<KvListFilter @secrets={{this.model.secrets}} @mountPoint={{this.mountPoint}} />`, {
@ -62,136 +65,99 @@ module('Integration | Component | kv | kv-list-filter', function (hooks) {
});
assert
.dom('[data-test-component="kv-list-filter"]')
.hasAttribute('placeholder', 'Filter secrets', 'Placeholder applied to input.');
.dom('[data-test-kv-list-filter]')
.hasAttribute('placeholder', 'Search secret path', 'Placeholder applied to input.');
await focus('[data-test-component="kv-list-filter"]');
assert.dom('[data-test-help-tab]').exists('on focus, with no filterValue, displays help text');
// trigger tab
await triggerKeyEvent('[data-test-component="kv-list-filter"]', 'keydown', 9);
await typeIn('[data-test-kv-list-filter]', 'my-secret');
await click('[data-test-kv-list-filter-submit]');
});
test('it filters partial matches', async function (assert) {
assert.expect(2);
// mirage hook for TAB
this.owner.lookup('service:router').reopen({
transitionTo(route, { queryParams: { pageFilter } }) {
assert.strictEqual(route, `${MOUNT_POINT}.list`, 'List route sent when no pathToSecret.');
assert.deepEqual(pageFilter, 'my-secret', 'Sets page filter to my-secret.');
},
test('it transitions correctly for query ending in /', async function (assert) {
assert.expect(3);
const routerSvc = this.owner.lookup('service:router');
sinon.stub(routerSvc, 'transitionTo').callsFake((route, params) => {
assert.strictEqual(route, `${MOUNT_POINT}.list-directory`, 'List route sent when params');
assert.deepEqual(params, 'beep/', 'Sends directory as param');
});
await render(hbs`<KvListFilter @secrets={{this.model.secrets}} @mountPoint={{this.mountPoint}} />`, {
owner: this.engine,
});
assert
.dom('[data-test-kv-list-filter]')
.hasAttribute('placeholder', 'Search secret path', 'Placeholder applied to input.');
await typeIn('[data-test-kv-list-filter]', 'beep/');
await click('[data-test-kv-list-filter-submit]');
});
test('it transitions correctly for nested query', async function (assert) {
assert.expect(4);
const routerSvc = this.owner.lookup('service:router');
sinon.stub(routerSvc, 'transitionTo').callsFake((route, params, { queryParams }) => {
assert.strictEqual(route, `${MOUNT_POINT}.list-directory`, 'List route sent when params');
assert.deepEqual(params, 'beep/', 'Sends directory as url param');
assert.deepEqual(queryParams, { pageFilter: 'boo' }, 'Sends directory as query param');
});
await render(hbs`<KvListFilter @secrets={{this.model.secrets}} @mountPoint={{this.mountPoint}} />`, {
owner: this.engine,
});
assert
.dom('[data-test-kv-list-filter]')
.hasAttribute('placeholder', 'Search secret path', 'Placeholder applied to input.');
await typeIn('[data-test-kv-list-filter]', 'beep/boo');
await click('[data-test-kv-list-filter-submit]');
});
test('it prefills filterbar from pageFilter', async function (assert) {
await render(
hbs`<KvListFilter @secrets={{this.model.secrets}} @mountPoint={{this.mountPoint}} @pageFilter="my-" />`,
hbs`<KvListFilter @secrets={{this.model.secrets}} @mountPoint={{this.mountPoint}} @filterValue="beep/boop/bop" />`,
{
owner: this.engine,
}
);
// focus on input and trigger TAB
await focus('[data-test-component="kv-list-filter"]');
await triggerKeyEvent('[data-test-component="kv-list-filter"]', 'keydown', 9);
assert.dom('[data-test-kv-list-filter]').hasValue('beep/boop/bop');
});
test('it clears last item on backspace and clears to directory on esc', async function (assert) {
assert.expect(8);
// mirage hook for filling in the input
this.owner.lookup('service:router').reopen({
transitionTo(route, pathToSecret, { queryParams: { pageFilter } }) {
assert.deepEqual(pageFilter, 'boop-', 'Sends the correct pageFilter on fillIn.');
},
test('it clears to directory on esc', async function (assert) {
assert.expect(3);
const routerSvc = this.owner.lookup('service:router');
sinon.stub(routerSvc, 'transitionTo').callsFake((route, params, { queryParams }) => {
assert.strictEqual(route, `${MOUNT_POINT}.list-directory`, 'List route sent when params');
assert.deepEqual(params, 'beep/boop/', 'Sends base directory as url param');
assert.deepEqual(queryParams, { pageFilter: null }, 'clears pageFilter param');
});
await render(
hbs`<KvListFilter @secrets={{this.model.secrets}} @mountPoint={{this.mountPoint}} @filterValue="beep/" @pageFilter=""/>`,
hbs`<KvListFilter @secrets={{this.model.secrets}} @mountPoint={{this.mountPoint}} @filterValue="beep/boop/bop" />`,
{
owner: this.engine,
}
);
// focus on input and trigger backspace
await focus('[data-test-component="kv-list-filter"]');
await fillIn('[data-test-component="kv-list-filter"]', 'beep/boop-');
this.owner.lookup('service:router').reopen({
transitionTo(route, pathToSecret, { queryParams: { pageFilter } }) {
assert.strictEqual(route, `${MOUNT_POINT}.list-directory`, 'Correct route sent.');
assert.strictEqual(pathToSecret, 'beep/', 'PathToSecret is the parent directory.');
assert.deepEqual(pageFilter, 'boop', 'Clears last item in pageFilter on backspace.');
},
});
await triggerKeyEvent('[data-test-component="kv-list-filter"]', 'keydown', 8);
assert.strictEqual(
document.activeElement.id,
'secret-filter',
'the input still remains focused after delete.'
);
this.owner.lookup('service:router').reopen({
transitionTo(route, pathToSecret, { queryParams: { pageFilter } }) {
assert.strictEqual(route, `${MOUNT_POINT}.list-directory`, 'Still on a directory route.');
assert.strictEqual(pathToSecret, 'beep/', 'Parent directory still shown.');
assert.deepEqual(pageFilter, null, 'Clears pageFilter on escape.');
},
});
// trigger escape
await triggerKeyEvent('[data-test-component="kv-list-filter"]', 'keydown', 27);
// trigger esc
await triggerKeyEvent('[data-test-kv-list-filter]', 'keydown', 27);
});
test('it transitions create page on enter when secret path is new', async function (assert) {
assert.expect(5);
// mirage hook for fillIn
this.owner.lookup('service:router').reopen({
transitionTo(route, pathToSecret, { queryParams: { pageFilter } }) {
assert.strictEqual(route, `${MOUNT_POINT}.list-directory`, 'Still on a directory route.');
assert.strictEqual(pathToSecret, 'beep/boop/', 'Parent directory still shown.');
assert.deepEqual(pageFilter, 'new-secret', 'Sends correct pageFilter.');
},
test('it clears to previous directory on esc', async function (assert) {
assert.expect(3);
const routerSvc = this.owner.lookup('service:router');
sinon.stub(routerSvc, 'transitionTo').callsFake((route, params, { queryParams }) => {
assert.strictEqual(route, `${MOUNT_POINT}.list-directory`, 'List route sent when params');
assert.deepEqual(params, 'beep/', 'Sends base directory as url param');
assert.deepEqual(queryParams, { pageFilter: null }, 'clears pageFilter param');
});
await render(
hbs`<KvListFilter @secrets={{this.model.secrets}} @mountPoint={{this.mountPoint}} @filterValue="beep/boop/"/>`,
hbs`<KvListFilter @secrets={{this.model.secrets}} @mountPoint={{this.mountPoint}} @filterValue="beep/boop/" />`,
{
owner: this.engine,
}
);
// focus on input, fillIn and then trigger enter
await focus('[data-test-component="kv-list-filter"]');
await fillIn('[data-test-component="kv-list-filter"]', 'beep/boop/new-secret');
// mirage hook for entering to create
this.owner.lookup('service:router').reopen({
transitionTo(route, { queryParams: { initialKey } }) {
assert.strictEqual(
route,
`${MOUNT_POINT}.create`,
'Sends to create route when secret does not exists.'
);
assert.deepEqual(initialKey, 'beep/boop/new-secret', 'It sends full secret path.');
},
});
await triggerKeyEvent('[data-test-component="kv-list-filter"]', 'keydown', 13);
});
test('it transitions details page on enter when secret path exists', async function (assert) {
assert.expect(1);
// mirage hook for entering to details
this.owner.lookup('service:router').reopen({
transitionTo(route) {
assert.strictEqual(
route,
`${MOUNT_POINT}.secret.details`,
'Sends to details route when secret does exists.'
);
},
});
await render(
hbs`<KvListFilter @secrets={{this.model.secrets}} @mountPoint={{this.mountPoint}} @filterValue="beep/boop/bop"/>`,
{
owner: this.engine,
}
);
// focus on input, fillIn and then trigger enter
await focus('[data-test-component="kv-list-filter"]');
await triggerKeyEvent('[data-test-component="kv-list-filter"]', 'keydown', 13);
// trigger esc
await triggerKeyEvent('[data-test-kv-list-filter]', 'keydown', 27);
});
});