diff --git a/ui/lib/kv/addon/components/kv-list-filter.hbs b/ui/lib/kv/addon/components/kv-list-filter.hbs index 521be2e3f4..dae80dd4e4 100644 --- a/ui/lib/kv/addon/components/kv-list-filter.hbs +++ b/ui/lib/kv/addon/components/kv-list-filter.hbs @@ -1,35 +1,21 @@ - -{{#if (and this.filterIsFocused this.isFilterMatch)}} -

- ENTER - to go to see details. - ESC - to clear input. -

-{{else}} -

- TAB - to autocomplete. - ESC - to clear input. -

-{{/if}} \ No newline at end of file +
+ + + + +
\ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-list-filter.js b/ui/lib/kv/addon/components/kv-list-filter.js index 03bc4effbc..d2980f8b35 100644 --- a/ui/lib/kv/addon/components/kv-list-filter.js +++ b/ui/lib/kv/addon/components/kv-list-filter.js @@ -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. * * * * @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); } } } diff --git a/ui/lib/kv/addon/components/page/list.hbs b/ui/lib/kv/addon/components/page/list.hbs index b6b632276a..e03e5744c0 100644 --- a/ui/lib/kv/addon/components/page/list.hbs +++ b/ui/lib/kv/addon/components/page/list.hbs @@ -7,12 +7,7 @@ <:toolbarFilters> {{#unless @noMetadataListPermissions}} {{#if (or @secrets @filterValue)}} - + {{/if}} {{/unless}} diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js index 2d935aaca2..3c9e5efb69 100644 --- a/ui/tests/helpers/kv/kv-selectors.js +++ b/ui/tests/helpers/kv/kv-selectors.js @@ -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]', diff --git a/ui/tests/integration/components/kv/kv-list-filter-test.js b/ui/tests/integration/components/kv/kv-list-filter-test.js index edeee644a8..c3d2616b39 100644 --- a/ui/tests/integration/components/kv/kv-list-filter-test.js +++ b/ui/tests/integration/components/kv/kv-list-filter-test.js @@ -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``, { @@ -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``, { + 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``, { + 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``, + hbs``, { 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``, + hbs``, { 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``, + hbs``, { 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``, - { - 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); }); });