27bb03bbc0
* adding copyright header * fix fmt and a test
296 lines
12 KiB
JavaScript
296 lines
12 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 { task } from 'ember-concurrency';
|
|
import { action } from '@ember/object';
|
|
import { tracked } from '@glimmer/tracking';
|
|
import { resolve } from 'rsvp';
|
|
import { filterOptions, defaultMatcher } from 'ember-power-select/utils/group-utils';
|
|
/**
|
|
* @module SearchSelect
|
|
* The `SearchSelect` 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.
|
|
* @example
|
|
* <SearchSelect
|
|
* @id="policy"
|
|
* @models={{array "policies/acl"}}
|
|
* @onChange={{this.onChange}}
|
|
* @inputValue={{get @model this.valuePath}}
|
|
* @wildcardLabel="role"
|
|
* @fallbackComponent="string-list"
|
|
* @selectLimit={{1}}
|
|
* @backend={{@model.backend}}
|
|
* @disallowNewItems={{true}}
|
|
* class={{if this.validationError "dropdown-has-error-border"}}
|
|
* />
|
|
*
|
|
// * 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} [disallowNewItems=false] - Controls whether or not the user can add a new item if none found
|
|
* @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 or if this.idKey !== 'id'
|
|
* @param {array} [parentManageSelected] - Array of selected items if the parent is keeping track of selections, see mfa-login-enforcement-form.js
|
|
* @param {boolean} [passObject=false] - When true, the onChange callback returns an array of objects with id (string) and isNew (boolean) (and any params from objectKeys). By default - onChange returns an array of id strings.
|
|
* @param {array} [objectKeys] - Array of values that correlate to model attrs. Used to render attr other than 'id' beside the name if shouldRenderName=true. If passObject=true, objectKeys are added to the passed, selected object.
|
|
* @param {number} [selectLimit] - Sets select limit
|
|
|
|
// * query params for dropdown items
|
|
* @param {Array} models - An array of model types to fetch from the API.
|
|
* @param {string} [backend] - name of the backend if the query for options needs additional information (eg. secret backend)
|
|
* @param {object} [queryObject] - object passed as query options to this.store.query(). NOTE: will override @backend
|
|
|
|
// * template only/display args
|
|
* @param {string} id - The name of the form field
|
|
* @param {string} [label] - Label for this form field
|
|
* @param {string} [labelClass] - overwrite default label size (14px) from class="is-label"
|
|
* @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} [helpText] - Text to be displayed in the info tooltip for this form field
|
|
* @param {string} [wildcardLabel] - string (singular) for rendering label tag beside a wildcard selection (i.e. 'role*'), for the number of items it includes, e.g. @wildcardLabel="role" -> "includes 4 roles"
|
|
* @param {string} [placeholder] - text you wish to replace the default "search" with
|
|
* @param {boolean} [displayInherit=false] - if you need the search select component to display inherit instead of box.
|
|
* @param {function} [renderInfoTooltip] - receives each inputValue string and list of dropdownOptions as args, so parent can determine when to render a tooltip beside a selectedOption and the tooltip text. see 'oidc/provider-form.js'
|
|
*
|
|
// * advanced customization
|
|
* @param {Array} options - array of objects passed directly to the power-select component. If doing this, `models` should not also be passed as that will overwrite the
|
|
* passed options. ex: [{ name: 'namespace45', id: 'displayedName' }]. It's recommended the parent should manage the array of selected items if manually passing in options.
|
|
* @param {function} search - Customizes how the power-select component searches for matches - see the power-select docs for more information.
|
|
*
|
|
*/
|
|
|
|
export default class SearchSelect extends Component {
|
|
@service store;
|
|
@tracked shouldUseFallback = false;
|
|
@tracked selectedOptions = []; // array of selected options (initially set by @inputValue)
|
|
@tracked dropdownOptions = []; // options that will render in dropdown, updates as selections are added/discarded
|
|
@tracked allOptions = []; // both selected and unselected options, used for wildcard filter
|
|
|
|
get hidePowerSelect() {
|
|
return this.selectedOptions.length >= this.args.selectLimit;
|
|
}
|
|
|
|
get idKey() {
|
|
// if objectKeys exists, use the first element of the array as the identifier
|
|
// make 'id' as the first element in objectKeys if you do not want to override the default of 'id'
|
|
return this.args.objectKeys ? this.args.objectKeys[0] : 'id';
|
|
}
|
|
|
|
get shouldRenderName() {
|
|
return this.args.models?.some((model) => model.includes('identity')) ||
|
|
this.idKey !== 'id' ||
|
|
this.args.shouldRenderName
|
|
? true
|
|
: false;
|
|
}
|
|
|
|
addSearchText(optionsToFormat) {
|
|
// maps over array of objects or response from query
|
|
return optionsToFormat.toArray().map((option) => {
|
|
const id = option[this.idKey] ? option[this.idKey] : option.id;
|
|
option.searchText = `${option.name} ${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(this.idKey, option);
|
|
// tooltip text comes from return of parent function
|
|
const addTooltip = this.args.renderInfoTooltip
|
|
? this.args.renderInfoTooltip(option, this.dropdownOptions)
|
|
: false;
|
|
|
|
// remove any matches from dropdown list
|
|
this.dropdownOptions.removeObject(matchingOption);
|
|
return {
|
|
id: option,
|
|
name: matchingOption ? matchingOption.name : option,
|
|
searchText: matchingOption ? matchingOption.searchText : option,
|
|
addTooltip,
|
|
// add additional attrs if we're using a dynamic idKey
|
|
...(this.idKey !== 'id' && this.customizeObject(matchingOption)),
|
|
};
|
|
});
|
|
}
|
|
|
|
@task
|
|
*fetchOptions() {
|
|
this.dropdownOptions = []; // reset dropdown anytime we re-fetch
|
|
|
|
if (this.args.parentManageSelected) {
|
|
// works in tandem with parent passing in @options directly
|
|
this.selectedOptions = this.args.parentManageSelected;
|
|
}
|
|
|
|
if (!this.args.models) {
|
|
if (this.args.options) {
|
|
const { options } = this.args;
|
|
// if options are nested, let parent handle formatting - see path-filter-config-list.js
|
|
this.dropdownOptions = options.some((e) => Object.keys(e).includes('groupName'))
|
|
? options
|
|
: [...this.addSearchText(options)];
|
|
|
|
if (!this.args.parentManageSelected) {
|
|
// set selectedOptions and remove matches from dropdown list
|
|
this.selectedOptions = this.args.inputValue
|
|
? this.formatInputAndUpdateDropdown(this.args.inputValue)
|
|
: [];
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (const modelType of this.args.models) {
|
|
try {
|
|
let queryParams = {};
|
|
if (this.args.backend) {
|
|
queryParams = { backend: this.args.backend };
|
|
}
|
|
if (this.args.queryObject) {
|
|
queryParams = this.args.queryObject;
|
|
}
|
|
// fetch options from the store
|
|
const options = yield this.store.query(modelType, queryParams);
|
|
|
|
// store both select + unselected options in tracked property used by wildcard filter
|
|
this.allOptions = [...this.allOptions, ...options.mapBy('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) {
|
|
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) =>
|
|
this.args.passObject ? this.customizeObject(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));
|
|
if (this.args.disallowNewItems && !existingOption) {
|
|
return false;
|
|
}
|
|
return !existingOption;
|
|
}
|
|
|
|
// ----- adapted from ember-power-select-with-create
|
|
addCreateOption(term, results) {
|
|
if (this.shouldShowCreate(term, results)) {
|
|
const name = `Click to add new item: ${term}`;
|
|
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);
|
|
}
|
|
// -----
|
|
|
|
customizeObject(option) {
|
|
if (!option) return;
|
|
|
|
let additionalKeys;
|
|
if (this.args.objectKeys) {
|
|
// pull attrs corresponding to objectKeys from model record, add to the selection
|
|
additionalKeys = Object.fromEntries(this.args.objectKeys.map((key) => [key, option[key]]));
|
|
// filter any undefined attrs, which could mean the model was not hydrated,
|
|
// the record is new or the model doesn't have that attribute
|
|
Object.keys(additionalKeys).forEach((key) => {
|
|
if (additionalKeys[key] === undefined) {
|
|
delete additionalKeys[key];
|
|
}
|
|
});
|
|
}
|
|
return {
|
|
id: option.id,
|
|
isNew: !!option.new,
|
|
...additionalKeys,
|
|
};
|
|
}
|
|
|
|
@action
|
|
discardSelection(selected) {
|
|
this.selectedOptions.removeObject(selected);
|
|
if (!selected.new) {
|
|
this.dropdownOptions.pushObject(selected);
|
|
}
|
|
this.handleChange();
|
|
}
|
|
|
|
// ----- adapted from ember-power-select-with-create
|
|
@action
|
|
searchAndSuggest(term, select) {
|
|
if (term.length === 0) {
|
|
return this.dropdownOptions;
|
|
}
|
|
if (this.args.search) {
|
|
return resolve(this.args.search(term, select)).then((results) => {
|
|
if (results.toArray) {
|
|
results = results.toArray();
|
|
}
|
|
this.addCreateOption(term, results);
|
|
return results;
|
|
});
|
|
}
|
|
const newOptions = this.filter(this.dropdownOptions, term);
|
|
this.addCreateOption(term, newOptions);
|
|
return newOptions;
|
|
}
|
|
|
|
@action
|
|
selectOrCreate(selection) {
|
|
if (selection && selection.__isSuggestion__) {
|
|
const name = selection.__value__;
|
|
this.selectedOptions.pushObject({ name, id: name, new: true });
|
|
} else {
|
|
this.selectedOptions.pushObject(selection);
|
|
this.dropdownOptions.removeObject(selection);
|
|
}
|
|
this.handleChange();
|
|
}
|
|
|
|
// -----
|
|
}
|