Glimmer Navigate Input component (#19517)

* wip

* wip

* todo hackeweek remove

* clean up

* add documetnation and fix test failure

* pr review changes

* spelling

* remove unused method
This commit is contained in:
Angel Garbarino 2023-03-14 20:23:04 -06:00 committed by GitHub
parent 65e5730c6c
commit bd8d3d4e07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 132 additions and 140 deletions

View File

@ -16,7 +16,7 @@
{{#if this.model.meta.total}}
<ToolbarFilters>
<NavigateInput
filterFocusDidChange={{action "setFilterFocus"}}
@filterFocusDidChange={{action "setFilterFocus"}}
@filterDidChange={{action "setFilter"}}
@filter={{this.filter}}
@filterMatchesKey={{this.filterMatchesKey}}

View File

@ -0,0 +1,20 @@
<div class="navigate-filter">
<div class="field" data-test-nav-input>
<p class="control has-icons-left">
{{! template-lint-disable no-down-event-binding }}
<input
class="filter input"
value={{@filter}}
placeholder={{or @placeholder "Filter items"}}
type="text"
data-test-component="navigate-input"
{{on "input" this.handleInput}}
{{on "keyup" this.handleKeyUp}}
{{on "keydown" this.handleKeyPress}}
{{on "focus" (fn this.setFilterFocused true)}}
{{on "blur" (fn this.setFilterFocused false)}}
/>
<Icon @name="search" class="search-icon has-text-grey-light" />
</p>
</div>
</div>

View File

@ -1,14 +1,34 @@
import { schedule, debounce } from '@ember/runloop';
import { debounce } from '@ember/runloop';
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import Component from '@glimmer/component';
import { action } from '@ember/object';
//TODO MOVE THESE TO THE ADDON
// TODO MOVE THESE TO THE ADDON
import utils from 'vault/lib/key-utils';
import keys from 'vault/lib/keycodes';
import FocusOnInsertMixin from 'vault/mixins/focus-on-insert';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import layout from '../templates/components/navigate-input';
/**
* @module NavigateInput
* `NavigateInput` components are used to filter list data.
*
* @example
* ```js
* <NavigateInput @filter={@roleFiltered} @placeholder="placeholder text" urls="{{hash list="vault.cluster.secrets.backend.kubernetes.roles"}}"/>
* ```
*
* @param {String} filter=null - The filtered string.
* @param {String} [placeholder="Filter items"] - The message inside the input to indicate what the user should enter into the space.
* @param {Object} [urls=null] - An object containing list=route url.
* @param {Function} [filterFocusDidChange=null] - A function called when the focus changes.
* @param {Function} [filterDidChange=null] - A function called when the filter string changes.
* @param {Function} [filterMatchesKey=null] - A function used to match to a specific key, such as an Id.
* @param {Function} [filterPartialMatch=null] - A function used to filter through a partial match. Such as "oo" of "root".
* @param {String} [baseKey=""] - A string to transition by Id.
* @param {Boolean} [shouldNavigateTree=false] - If true, navigate a larger tree, such as when you're navigating leases under access.
* @param {String} [mode="secrets"] - Mode which plays into navigation type.
* @param {String} [extraNavParams=""] - A string used in route transition when necessary.
*/
const routeFor = function (type, mode, urls) {
const MODES = {
@ -34,25 +54,12 @@ const routeFor = function (type, mode, urls) {
return useSuffix ? modeVal + '.' + typeVal : modeVal;
};
export default Component.extend(FocusOnInsertMixin, {
layout,
router: service(),
export default class NavigateInput extends Component {
@service router;
classNames: ['navigate-filter'],
urls: null,
// these get passed in from the outside
// actions that get passed in
filterFocusDidChange: null,
filterDidChange: null,
mode: 'secrets',
shouldNavigateTree: false,
extraNavParams: null,
baseKey: null,
filter: null,
filterMatchesKey: null,
firstPartialMatch: null,
get mode() {
return this.args.mode || 'secrets';
}
transitionToRoute(...args) {
const params = args.map((param, index) => {
@ -63,43 +70,37 @@ export default Component.extend(FocusOnInsertMixin, {
});
this.router.transitionTo(...params);
},
shouldFocus: false,
didReceiveAttrs() {
this._super(...arguments);
if (!this.filter) return;
schedule('afterRender', this, 'forceFocus');
},
}
keyForNav(key) {
if (this.mode !== 'secrets-cert') {
return key;
}
return `cert/${key}`;
},
onEnter: function (val) {
const { baseKey, mode } = this;
const extraParams = this.extraNavParams;
}
onEnter(val) {
const mode = this.mode;
const baseKey = this.args.baseKey;
const extraParams = this.args.extraNavParams;
if (mode.startsWith('secrets') && (!val || val === baseKey)) {
return;
}
if (this.filterMatchesKey && !utils.keyIsFolder(val)) {
const params = [routeFor('show', mode, this.urls), extraParams, this.keyForNav(val)].compact();
if (this.args.filterMatchesKey && !utils.keyIsFolder(val)) {
const params = [routeFor('show', mode, this.args.urls), extraParams, this.keyForNav(val)].compact();
this.transitionToRoute(...params);
} else {
if (mode === 'policies') {
return;
}
const route = routeFor('create', mode, this.urls);
const route = routeFor('create', mode, this.args.urls);
if (baseKey) {
this.transitionToRoute(route, this.keyForNav(baseKey), {
queryParams: {
initialKey: val,
},
});
} else if (this.urls) {
} else if (this.args.urls) {
this.transitionToRoute(route, {
queryParams: {
initialKey: this.keyForNav(val),
@ -113,35 +114,35 @@ export default Component.extend(FocusOnInsertMixin, {
});
}
}
},
}
// pop to the nearest parentKey or to the root
onEscape: function (val) {
var key = utils.parentKeyForKey(val) || '';
this.filterDidChange(key);
onEscape(val) {
const key = utils.parentKeyForKey(val) || '';
this.args.filterDidChange(key);
this.filterUpdated(key);
},
}
onTab: function (event) {
var firstPartialMatch = this.firstPartialMatch.id;
onTab(event) {
const firstPartialMatch = this.args.firstPartialMatch.id;
if (!firstPartialMatch) {
return;
}
event.preventDefault();
this.filterDidChange(firstPartialMatch);
this.args.filterDidChange(firstPartialMatch);
this.filterUpdated(firstPartialMatch);
},
}
// as you type, navigates through the k/v tree
filterUpdated: function (val) {
var mode = this.mode;
if (mode === 'policies' || !this.shouldNavigateTree) {
filterUpdated(val) {
const mode = this.mode;
if (mode === 'policies' || !this.args.shouldNavigateTree) {
this.filterUpdatedNoNav(val, mode);
return;
}
// select the key to nav to, assumed to be a folder
var key = val ? val.trim() : '';
var isFolder = utils.keyIsFolder(key);
let key = val ? val.trim() : '';
const isFolder = utils.keyIsFolder(key);
if (!isFolder) {
// nav to the closest parentKey (or the root)
@ -150,10 +151,10 @@ export default Component.extend(FocusOnInsertMixin, {
const pageFilter = val.replace(key, '');
this.navigate(this.keyForNav(key), mode, pageFilter);
},
}
navigate(key, mode, pageFilter) {
const route = routeFor(key ? 'list' : 'list-root', mode, this.urls);
const route = routeFor(key ? 'list' : 'list-root', mode, this.args.urls);
const args = [route];
if (key) {
args.push(key);
@ -174,47 +175,46 @@ export default Component.extend(FocusOnInsertMixin, {
});
}
this.transitionToRoute(...args);
},
}
filterUpdatedNoNav: function (val, mode) {
var key = val ? val.trim() : null;
this.transitionToRoute(routeFor('list-root', mode, this.urls), {
filterUpdatedNoNav(val, mode) {
const key = val ? val.trim() : null;
this.transitionToRoute(routeFor('list-root', mode, this.args.urls), {
queryParams: {
pageFilter: key,
page: 1,
},
});
},
}
actions: {
handleInput: function (filter) {
if (this.filterDidChange) {
this.filterDidChange(filter);
}
debounce(this, 'filterUpdated', filter, 200);
},
setFilterFocused: function (isFocused) {
if (this.filterFocusDidChange) {
this.filterFocusDidChange(isFocused);
}
},
handleKeyPress: function (event) {
if (event.keyCode === keys.TAB) {
this.onTab(event);
}
},
handleKeyUp: function (event) {
var keyCode = event.keyCode;
const val = event.target.value;
if (keyCode === keys.ENTER) {
this.onEnter(val);
}
if (keyCode === keys.ESC) {
this.onEscape(val);
}
},
},
});
@action
handleInput(filter) {
if (this.args.filterDidChange) {
this.args.filterDidChange(filter.target.value);
}
debounce(this, this.filterUpdated, filter.target.value, 200);
}
@action
setFilterFocused(isFocused) {
if (this.args.filterFocusDidChange) {
this.args.filterFocusDidChange(isFocused);
}
}
@action
handleKeyPress(event) {
if (event.keyCode === keys.TAB) {
this.onTab(event);
}
}
@action
handleKeyUp(event) {
const keyCode = event.keyCode;
const val = event.target.value;
if (keyCode === keys.ENTER) {
this.onEnter(val);
}
if (keyCode === keys.ESC) {
this.onEscape(val);
}
}
}

View File

@ -1,20 +0,0 @@
<div class="field" data-test-nav-input>
<p class="control has-icons-left">
{{! template-lint-disable no-down-event-binding }}
<input
class="filter input"
disabled={{this.disabled}}
value={{@filter}}
placeholder={{or @placeholder "Filter keys"}}
type="text"
data-test-comoponent="navigate-input"
oninput={{action "handleInput" value="target.value"}}
onkeyup={{action "handleKeyUp"}}
onkeydown={{action "handleKeyPress"}}
onfocus={{action "setFilterFocused" true}}
onblur={{action "setFilterFocused" false}}
/>
{{! template-lint-enable no-down-event-binding }}
<Icon @name="search" class="search-icon has-text-grey-light" />
</p>
</div>

View File

@ -12,7 +12,7 @@
<Toolbar>
{{#if this.model.certificates.length}}
<ToolbarFilters>
{{! ARG TODO glimmerize the NavigateInput and refactor so you can use it in an engine }}
{{! TODO add NavigateInput component }}
</ToolbarFilters>
{{/if}}
</Toolbar>

View File

@ -27,6 +27,7 @@ module('Acceptance | aws secret backend', function (hooks) {
],
};
test('aws backend', async function (assert) {
assert.expect(12);
const now = new Date().getTime();
const path = `aws-${now}`;
const roleName = 'awsrole';
@ -91,13 +92,10 @@ module('Acceptance | aws secret backend', function (hooks) {
assert.ok(findAll(`[data-test-secret-link="${roleName}"]`).length, `aws: role shows in the list`);
//and delete
// TODO the button does not populate quickly enough.
// await click(`[data-test-secret-link="${roleName}"] [data-test-popup-menu-trigger]`);
// await settled();
// await click(`[data-test-aws-role-delete="${roleName}"]`);
// await click(`[data-test-confirm-button]`);
// await settled();
// assert.dom(`[data-test-secret-link="${roleName}"]`).doesNotExist(`aws: role is no longer in the list`);
await click(`[data-test-secret-link="${roleName}"] [data-test-popup-menu-trigger]`);
await waitUntil(() => find(`[data-test-aws-role-delete="${roleName}"]`)); // flaky without
await click(`[data-test-aws-role-delete="${roleName}"]`);
await click(`[data-test-confirm-button]`);
assert.dom(`[data-test-secret-link="${roleName}"]`).doesNotExist(`aws: role is no longer in the list`);
});
});

View File

@ -1,5 +1,5 @@
import { click, currentRouteName, visit } from '@ember/test-helpers';
// TESTS HERE ARE SKPPED
// TESTS HERE ARE SKIPPED
// running vault with -dev-leased-kv flag lets you run some of these tests
// but generating leases programmatically is currently difficult
//

View File

@ -30,7 +30,7 @@ module('Acceptance | kubernetes | roles', function (hooks) {
test('it should filter roles', async function (assert) {
await this.visitRoles();
assert.dom('[data-test-list-item-link]').exists({ count: 3 }, 'Roles list renders');
await fillIn('[data-test-comoponent="navigate-input"]', '1');
await fillIn('[data-test-component="navigate-input"]', '1');
assert.dom('[data-test-list-item-link]').exists({ count: 1 }, 'Filtered roles list renders');
assert.ok(currentURL().includes('pageFilter=1'), 'pageFilter query param value is set');
});

View File

@ -75,7 +75,7 @@ module('Acceptance | ssh secret backend', function (hooks) {
},
];
test('ssh backend', async function (assert) {
assert.expect(26);
assert.expect(28);
const now = new Date().getTime();
const sshPath = `ssh-${now}`;
@ -157,17 +157,13 @@ module('Acceptance | ssh secret backend', function (hooks) {
);
//and delete
// TODO confirmed functionality works, but it can not find the data-test-ssh-role-delete in time.
// await click(`[data-test-secret-link="${role.name}"] [data-test-popup-menu-trigger]`);
// await settled();
// await click(`[data-test-ssh-role-delete]`);
// await settled();
// await click(`[data-test-confirm-button]`);
// await settled();
// assert
// .dom(`[data-test-secret-link="${role.name}"]`)
// .doesNotExist(`${role.type}: role is no longer in the list`);
await click(`[data-test-secret-link="${role.name}"] [data-test-popup-menu-trigger]`);
await waitUntil(() => find('[data-test-ssh-role-delete]')); // flaky without
await click(`[data-test-ssh-role-delete]`);
await click(`[data-test-confirm-button]`);
assert
.dom(`[data-test-secret-link="${role.name}"]`)
.doesNotExist(`${role.type}: role is no longer in the list`);
}
});
});

View File

@ -88,7 +88,7 @@ module('Integration | Component | auth form', function (hooks) {
this.set('cluster', EmberObject.create({}));
this.set('selectedAuth', 'token');
await render(hbs`{{auth-form cluster=this.cluster selectedAuth=this.selectedAuth}}`);
// ARG TODO research and see if adapter errors changed, but null used to be Bad Request
// returns null because test does not return details of failed network request. On the app it will return the details of the error instead of null.
return component.login().then(() => {
assert.strictEqual(component.errorText, 'Error Authentication failed: null');
server.shutdown();

View File

@ -6,9 +6,7 @@ export default create({
visit: visitable('/vault/secrets/:backend/kmip/scopes/:scope/roles/:role/credentials'),
visitDetail: visitable('/vault/secrets/:backend/kmip/scopes/:scope/roles/:role/credentials/:serial'),
create: clickable('[data-test-role-create]'),
credentialsLink: clickable('[data-test-kmip-link-credentials]'),
generateCredentialsLink: clickable('[data-test-kmip-link-generate-credentials]'),
roleDetailsLink: clickable('[data-test-kmip-link-role-details]'),
backToRoleLink: clickable('[data-test-kmip-link-back-to-role]'),
submit: clickable('[data-test-edit-form-submit]'),
});