open-vault/ui/lib/core/addon/components/search-select-with-modal.js
Hamid Ghaf 27bb03bbc0
adding copyright header (#19555)
* adding copyright header

* fix fmt and a test
2023-03-15 09:00:52 -07:00

219 lines
8.7 KiB
JavaScript

/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { filterOptions, defaultMatcher } from 'ember-power-select/utils/group-utils';
/**
* @module SearchSelectWithModal
* The `SearchSelectWithModal` is an implementation of the [ember-power-select](https://github.com/cibernox/ember-power-select) used for form elements where options come dynamically from the API.
* It renders a passed template component that parents a form so records can be created inline, via a modal that pops up after clicking 'No results found for "${term}". Click here to create it.' from the dropdown menu.
* **!! NOTE: any form passed must be able to receive an @onSave and @onCancel arg so that the modal will close properly. See `oidc/client-form.hbs` that renders a modal for the `oidc-assignment-template.hbs` as an example.
* @example
* <SearchSelectWithModal
* @id="assignments"
* @models={{array "oidc/assignment"}}
* @label="assignment name"
* @subText="Search for an existing assignment, or type a new name to create it."
* @inputValue={{map-by "id" @model.assignments}}
* @onChange={{this.handleSearchSelect}}
* {{! since this is the "limited" radio select option we do not want to include 'allow_all' }}
* @excludeOptions={{array "allow_all"}}
* @fallbackComponent="string-list"
* @modalFormTemplate="modal-form/some-template"
* @modalSubtext="Use assignment to specify which Vault entities and groups are allowed to authenticate."
* />
*
// * component functionality
* @param {function} onChange - The onchange action for this form field. ** SEE UTIL ** search-select-has-many.js if selecting models from a hasMany relationship
* @param {array} [inputValue] - Array of strings corresponding to the input's initial value, e.g. an array of model ids that on edit will appear as selected items below the input
* @param {boolean} [shouldRenderName=false] - By default an item's id renders in the dropdown, `true` displays the name with its id in smaller text beside it *NOTE: the boolean flips automatically with 'identity' models
* @param {array} [excludeOptions] - array of strings containing model ids to filter from the dropdown (ex: ['allow_all'])
// * query params for dropdown items
* @param {array} models - models to fetch from API. models with varying permissions should be ordered from least restricted to anticipated most restricted (ex. if one model is an enterprise only feature, pass it in last)
// * template only/display args
* @param {string} id - The name of the form field
* @param {string} [label] - Label appears above the form field
* @param {string} [labelClass] - overwrite default label size (14px) from class="is-label"
* @param {string} [helpText] - Text to be displayed in the info tooltip for this form field
* @param {string} [subText] - Text to be displayed below the label
* @param {string} fallbackComponent - name of component to be rendered if the API call 403s
* @param {string} [placeholder] - placeholder text to override the default text of "Search"
* @param {boolean} [displayInherit=false] - if you need the search select component to display inherit instead of box.
*/
export default class SearchSelectWithModal extends Component {
@service store;
@tracked shouldUseFallback = false;
@tracked selectedOptions = []; // list of selected options
@tracked dropdownOptions = []; // options that will render in dropdown, updates as selections are added/discarded
@tracked showModal = false;
@tracked nameInput = null;
get hidePowerSelect() {
return this.selectedOptions.length >= this.args.selectLimit;
}
get shouldRenderName() {
return this.args.models?.some((model) => model.includes('identity')) || this.args.shouldRenderName
? true
: false;
}
addSearchText(optionsToFormat) {
// maps over array models from query
return optionsToFormat.toArray().map((option) => {
option.searchText = `${option.name} ${option.id}`;
return option;
});
}
formatInputAndUpdateDropdown(inputValues) {
// inputValues are initially an array of strings from @inputValue
// map over so selectedOptions are objects
return inputValues.map((option) => {
const matchingOption = this.dropdownOptions.findBy('id', option);
// remove any matches from dropdown list
this.dropdownOptions.removeObject(matchingOption);
return {
id: option,
name: matchingOption ? matchingOption.name : option,
searchText: matchingOption ? matchingOption.searchText : option,
};
});
}
@task
*fetchOptions() {
this.dropdownOptions = []; // reset dropdown anytime we re-fetch
if (!this.args.models) {
return;
}
for (const modelType of this.args.models) {
try {
// fetch options from the store
let options = yield this.store.query(modelType, {});
if (this.args.excludeOptions) {
options = options.filter((o) => !this.args.excludeOptions.includes(o.id));
}
// add to dropdown options
this.dropdownOptions = [...this.dropdownOptions, ...this.addSearchText(options)];
} catch (err) {
if (err.httpStatus === 404) {
// continue to query other models even if one 404s
// and so selectedOptions will be set after for loop
continue;
}
if (err.httpStatus === 403) {
// when multiple models are passed in, don't use fallback if the first query is successful
// (i.e. policies ['acl', 'rgp'] - rgp policies are ENT only so will always fail on OSS)
if (this.dropdownOptions.length > 0 && this.args.models.length > 1) continue;
this.shouldUseFallback = true;
return;
}
throw err;
}
}
// after all models are queried, set selectedOptions and remove matches from dropdown list
this.selectedOptions = this.args.inputValue
? this.formatInputAndUpdateDropdown(this.args.inputValue)
: [];
}
@action
handleChange() {
if (this.selectedOptions.length && typeof this.selectedOptions.firstObject === 'object') {
this.args.onChange(Array.from(this.selectedOptions, (option) => option.id));
} else {
this.args.onChange(this.selectedOptions);
}
}
shouldShowCreate(id, searchResults) {
if (searchResults && searchResults.length && searchResults.firstObject.groupName) {
return !searchResults.some((group) => group.options.findBy('id', id));
}
const existingOption =
this.dropdownOptions &&
(this.dropdownOptions.findBy('id', id) || this.dropdownOptions.findBy('name', id));
return !existingOption;
}
// ----- adapted from ember-power-select-with-create
addCreateOption(term, results) {
if (this.shouldShowCreate(term, results)) {
const name = `No results found for "${term}". Click here to create it.`;
const suggestion = {
__isSuggestion__: true,
__value__: term,
name,
id: name,
};
results.unshift(suggestion);
}
}
filter(options, searchText) {
const matcher = (option, text) => defaultMatcher(option.searchText, text);
return filterOptions(options || [], searchText, matcher);
}
// -----
@action
discardSelection(selected) {
this.selectedOptions.removeObject(selected);
this.dropdownOptions.pushObject(selected);
this.handleChange();
}
// ----- adapted from ember-power-select-with-create
@action
searchAndSuggest(term) {
if (term.length === 0) {
return this.dropdownOptions;
}
if (this.args.models?.some((model) => model.includes('policy'))) {
term = term.toLowerCase();
}
const newOptions = this.filter(this.dropdownOptions, term);
this.addCreateOption(term, newOptions);
return newOptions;
}
@action
selectOrCreate(selection) {
if (selection && selection.__isSuggestion__) {
// user has clicked to create a new item
// wait to handleChange below in resetModal
this.nameInput = selection.__value__; // input is passed to form component
this.showModal = true;
} else {
// user has selected an existing item, handleChange immediately
this.selectedOptions.pushObject(selection);
this.dropdownOptions.removeObject(selection);
this.handleChange();
}
}
// -----
@action
resetModal(model) {
// resetModal fires when the form component calls onSave or onCancel
this.showModal = false;
if (model && model.currentState.isSaved) {
const { name } = model;
this.selectedOptions.pushObject({ name, id: name });
this.handleChange();
}
this.nameInput = null;
}
}