Files
vault/ui/lib/core/addon/components/kv-suggestion-input.ts
claire bontempo e61bd967e3 Add docfy for addon components (#27188)
* move script to scripts folder

* add docfy to router and scripts

* add docfy to router and scripts

* fix jsdoc syntax

* add component markdown files to gitignore

* improve error handling for scripts

* tidy up remaining jsdoc syntax

* add sample jsdoc components

* add known issue info

* make not using multi-line components clearer

* make generating docs clearer

* update copy

* final how to docfy cleanup

* fix ts file @module syntax

* fix read more syntax

* make docfy typescript compatible
2024-05-29 14:06:38 -07:00

144 lines
5.4 KiB
TypeScript

/**
* 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
* @description
* 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
* Input is disabled when mount path is not provided
*
* @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="my-kv/" @onChange={{fn (mut this.secretPath)}} />
*
* <KvSuggestionInput @label="Select a secret to sync" @subText="Disabled because no mount path provided" @value={{this.secretPath}} @mountPath={{false}} @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());
}
}