Skip to content

Commit

Permalink
KV Suggestion Input (#24447)
Browse files Browse the repository at this point in the history
* updates filter-input component to conditionally show search icon

* adds kv-suggestion-input component to core addon

* updates destination sync page component to use KvSuggestionInput component

* fixes issue in kv-suggestion-input where a partial search term was not replaced with the selected suggestion value

* updates kv-suggestion-input to retain focus on suggestion click

* fixes test

* updates kv-suggestion-input to conditionally render label component

* adds comments to kv-suggestion-input regarding trigger

* moves alert banner in sync page below button set

* moves inputId from getter to class property on kv-suggestion-input
  • Loading branch information
zofskeez authored Dec 11, 2023
1 parent 4d7a3cf commit 49eab64
Show file tree
Hide file tree
Showing 12 changed files with 371 additions and 133 deletions.
6 changes: 4 additions & 2 deletions ui/lib/core/addon/components/filter-input.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
SPDX-License-Identifier: BUSL-1.1
~}}

<div class="control has-icons-left">
<div class="control {{unless @hideIcon 'has-icons-left'}}" data-test-filter-input-container>
<input class="filter input" ...attributes {{on "input" this.onInput}} {{did-insert this.focus}} data-test-filter-input />
<Icon @name="search" class="search-icon has-text-grey-light" />
{{#unless @hideIcon}}
<Icon @name="search" class="search-icon has-text-grey-light" data-test-filter-input-icon />
{{/unless}}
</div>
3 changes: 2 additions & 1 deletion ui/lib/core/addon/components/filter-input.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
* SPDX-License-Identifier: BUSL-1.1
*/

import Component from '@glimmer/component';
Expand All @@ -12,6 +12,7 @@ import type { HTMLElementEvent } from 'vault/forms';
interface Args {
wait?: number; // defaults to 500
autofocus?: boolean; // initially focus the input on did-insert
hideIcon?: boolean; // hide the search icon in the input
onInput(value: string): void; // invoked with input value after debounce timer expires
}

Expand Down
35 changes: 35 additions & 0 deletions ui/lib/core/addon/components/kv-suggestion-input.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div ...attributes {{did-update this.updateSuggestions @mountPath}}>
{{#if @label}}
<FormFieldLabel @label={{@label}} @subText={{@subText}} />
{{/if}}
<FilterInput
id={{this.inputId}}
placeholder="Path to secret"
value={{@value}}
disabled={{not @mountPath}}
@hideIcon={{true}}
@onInput={{this.onInput}}
{{! used to trigger dropdown to open }}
{{on "click" this.onInputClick}}
data-test-kv-suggestion-input
/>
<PowerSelect
@eventType="click"
@options={{this.secrets}}
@onChange={{this.onSuggestionSelect}}
@renderInPlace={{true}}
@disabled={{not @mountPath}}
@registerAPI={{fn (mut this.powerSelectAPI)}}
{{! hide trigger component and use API to toggle dropdown }}
@triggerClass="is-hidden"
@noMatchesMessage="No suggestions for this path"
data-test-kv-suggestion-select
as |secret|
>
{{secret.path}}
</PowerSelect>
</div>
145 changes: 145 additions & 0 deletions ui/lib/core/addon/components/kv-suggestion-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import { run } from '@ember/runloop';
import { keyIsFolder, parentKeyForKey, keyWithoutParentKey } from 'core/utils/key-utils';

import type StoreService from 'vault/services/store';
import type KvSecretMetadataModel from 'vault/models/kv/metadata';

/**
* @module KvSuggestionInput
* Input component that fetches secrets at a provided mount path and displays them as suggestions in a dropdown
* As the user types the result set will be filtered providing suggestions for the user to select
* After the input debounce wait time (500ms), if the value ends in a slash, secrets will be fetched at that path
* The new result set will then be displayed in the dropdown as suggestions for the newly inputted path
* Selecting a suggestion will append it to the input value
* This allows the user to build a full path to a secret for the provided mount
* This is useful for helping the user find deeply nested secrets given the path based policy system
* If the user does not have list permission they are still able to enter a path to a secret but will not see suggestions
*
* @example
* <KvSuggestionInput
@label="Select a secret to sync"
@subText="Enter the full path to the secret. Suggestions will display below if permitted by policy."
@value={{this.secretPath}}
@mountPath={{this.mountPath}} // input disabled when mount path is not provided
@onChange={{fn (mut this.secretPath)}}
/>
*/

interface Args {
label: string;
subText?: string;
mountPath: string;
value: string;
onChange: CallableFunction;
}

interface PowerSelectAPI {
actions: {
open(): void;
close(): void;
};
}

export default class KvSuggestionInputComponent extends Component<Args> {
@service declare readonly store: StoreService;

@tracked secrets: KvSecretMetadataModel[] = [];
powerSelectAPI: PowerSelectAPI | undefined;
_cachedSecrets: KvSecretMetadataModel[] = []; // cache the response for filtering purposes
inputId = `suggestion-input-${guidFor(this)}`; // add unique segment to id in case multiple instances of component are used on the same page

constructor(owner: unknown, args: Args) {
super(owner, args);
if (this.args.mountPath) {
this.updateSuggestions();
}
}

async fetchSecrets(isDirectory: boolean) {
const { mountPath } = this.args;
try {
const backend = keyIsFolder(mountPath) ? mountPath.slice(0, -1) : mountPath;
const parentDirectory = parentKeyForKey(this.args.value);
const pathToSecret = isDirectory ? this.args.value : parentDirectory;
const kvModels = (await this.store.query('kv/metadata', {
backend,
pathToSecret,
})) as unknown;
// this will be used to filter the existing result set when the search term changes within the same path
this._cachedSecrets = kvModels as KvSecretMetadataModel[];
return this._cachedSecrets;
} catch (error) {
console.log(error); // eslint-disable-line
return [];
}
}

filterSecrets(kvModels: KvSecretMetadataModel[] | undefined = [], isDirectory: boolean) {
const { value } = this.args;
const secretName = keyWithoutParentKey(value) || '';
return kvModels.filter((model) => {
if (!value || isDirectory) {
return true;
}
if (value === model.fullSecretPath) {
// don't show suggestion if it's currently selected
return false;
}
return model.path.toLowerCase().includes(secretName.toLowerCase());
});
}

@action
async updateSuggestions() {
const isFirstUpdate = !this._cachedSecrets.length;
const isDirectory = keyIsFolder(this.args.value);
if (!this.args.mountPath) {
this.secrets = [];
} else if (this.args.value && !isDirectory && this.secrets) {
// if we don't need to fetch from a new path, filter the previous result set with the updated search term
this.secrets = this.filterSecrets(this._cachedSecrets, isDirectory);
} else {
const kvModels = await this.fetchSecrets(isDirectory);
this.secrets = this.filterSecrets(kvModels, isDirectory);
}
// don't do anything on first update -- allow dropdown to open on input click
if (!isFirstUpdate) {
const action = this.secrets.length ? 'open' : 'close';
this.powerSelectAPI?.actions[action]();
}
}

@action
onInput(value: string) {
this.args.onChange(value);
this.updateSuggestions();
}

@action
onInputClick() {
if (this.secrets.length) {
this.powerSelectAPI?.actions.open();
}
}

@action
onSuggestionSelect(secret: KvSecretMetadataModel) {
// user may partially type a value to filter result set and then select a suggestion
// in this case the partially typed value must be replaced with suggestion value
// the fullSecretPath contains the previous selections or typed path segments
this.args.onChange(secret.fullSecretPath);
this.updateSuggestions();
// refocus the input after selection
run(() => document.getElementById(this.inputId)?.focus());
}
}
6 changes: 6 additions & 0 deletions ui/lib/core/app/components/kv-suggestion-input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

export { default } from 'core/components/kv-suggestion-input';
Original file line number Diff line number Diff line change
Expand Up @@ -36,51 +36,38 @@
<FilterInput
placeholder="KV engine mount path"
value={{this.mountPath}}
@onInput={{this.onMountInput}}
@onInput={{fn (mut this.mountPath)}}
data-test-sync-mount-input
/>
{{/if}}

<FormFieldLabel
<KvSuggestionInput
@label="Select a secret to sync"
@subText="Enter the full path to the secret. Suggestions will display below if permitted by policy."
@value={{this.secretPath}}
@mountPath={{this.mountPath}}
@onChange={{fn (mut this.secretPath)}}
/>
<FilterInput
placeholder="Path to secret"
value={{this.secretPath}}
disabled={{not this.mountPath}}
@onInput={{this.onSecretInput}}
{{on "click" this.onSecretInputClick}}
data-test-sync-secret-input
/>
<PowerSelect
@eventType="click"
@options={{this.secrets}}
@onChange={{this.onSuggestionSelect}}
@renderInPlace={{true}}
@closeOnSelect={{false}}
@disabled={{not this.mountPath}}
@registerAPI={{fn (mut this.powerSelectAPI)}}
@triggerClass="is-hidden"
@noMatchesMessage="No suggestions for this path"
data-test-sync-secret-select
as |secret|
>
{{secret.path}}
</PowerSelect>

<div class="field is-grouped-split box is-fullwidth is-bottomless has-top-margin-xxxl">
<div class="field box is-fullwidth is-bottomless has-top-margin-xxxl">
<Hds::ButtonSet>
<Hds::Button
@text="Sync to destination"
@color="primary"
@icon={{if this.setAssociation.isRunning "loading"}}
type="submit"
disabled={{or (not (and this.mountPath this.secretPath)) this.setAssociation.isRunning}}
disabled={{or (not (and this.mountPath this.secretPath)) this.isSecretDirectory this.setAssociation.isRunning}}
data-test-sync-submit
/>
<Hds::Button @text="Cancel" @color="secondary" {{on "click" this.cancel}} data-test-sync-cancel />
<Hds::Button @text="Back" @color="secondary" {{on "click" this.back}} data-test-sync-cancel />
</Hds::ButtonSet>
{{#if this.isSecretDirectory}}
<AlertInline
@type="warning"
@message="Syncing secret directories is not available at this time, please type a path to a single secret"
class="has-top-margin-s"
/>
{{/if}}
</div>
</div>
</form>
Loading

0 comments on commit 49eab64

Please sign in to comment.