mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-11-01 19:17:58 +00:00
UI: Implement new policy SS + modal designs (#17749)
* refactor ss+modal to accept multiple models * create policy form * cleanup and fix test * add tabs to policy modal form * add search select with modal to entity form * update group form; * allow modal to fit-content * add changelog * add check for policy create ability * add id so tests pass * filter out root option * fix test * add cleanup method * add ACL policy link * cleanup from comments * refactor sending action to parent * refactor, data down actions up! * cleanup comments * form field refactor * add ternary to options * update tests * Remodel component structure for clearer logic Includes fixing the wizard * address comments * cleanup args * refactor inline oidc assignment form * add line break * cleanup comments * fix tests * add policy template to ss+modal test * cleanup =true from test * final cleanup!!!!!! * actual final cleanup * fix typo, please be done Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com>
This commit is contained in:
@@ -2,148 +2,151 @@ import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { singularize } from 'ember-inflector';
|
||||
import { resolve } from 'rsvp';
|
||||
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 can only accept a single model.
|
||||
* It renders a passed in form component so records can be created inline, via a modal that pops up after clicking "Create new <id>" 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-form.hbs` as an example.
|
||||
* 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"
|
||||
* @model="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"
|
||||
* @modalFormComponent="oidc/assignment-form"
|
||||
* @modalSubtext="Use assignment to specify which Vault entities and groups are allowed to authenticate."
|
||||
* />
|
||||
* @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."
|
||||
* />
|
||||
*
|
||||
* @param {string} id - the model's attribute for the form field, will be interpolated into create new text: `Create new ${singularize(this.args.id)}`
|
||||
* @param {Array} model - model type to fetch from API (can only be a single model)
|
||||
* @param {string} label - Label that appears above the form field
|
||||
// * 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 {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 - an array of strings -- array of ids for models.
|
||||
* @param {string} fallbackComponent - name of component to be rendered if the API returns a 403s
|
||||
* @param {boolean} [passObject=false] - When true, the onChange callback returns an array of objects with id (string) and isNew (boolean)
|
||||
* @param {number} [selectLimit] - A number that sets the limit to how many select options they can choose
|
||||
* @param {array} [excludeOptions] - array of strings containing model ids to filter from the dropdown (ex: ['allow_all'])
|
||||
* @param {function} search - *Advanced usage* - Customizes how the power-select component searches for matches -
|
||||
* see the power-select docs for more information.
|
||||
*
|
||||
* @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 selectedOptions = null; // list of selected options
|
||||
@tracked allOptions = null; // all possible options
|
||||
@tracked showModal = false;
|
||||
@tracked newModelRecord = null;
|
||||
@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;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.selectedOptions = this.inputValue;
|
||||
}
|
||||
|
||||
get inputValue() {
|
||||
return this.args.inputValue || [];
|
||||
get hidePowerSelect() {
|
||||
return this.selectedOptions.length >= this.args.selectLimit;
|
||||
}
|
||||
|
||||
get shouldRenderName() {
|
||||
return this.args.shouldRenderName || false;
|
||||
return this.args.models?.some((model) => model.includes('identity')) || this.args.shouldRenderName
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
|
||||
get excludeOptions() {
|
||||
return this.args.excludeOptions || null;
|
||||
}
|
||||
|
||||
get passObject() {
|
||||
return this.args.passObject || false;
|
||||
}
|
||||
|
||||
@action
|
||||
async fetchOptions() {
|
||||
try {
|
||||
const queryOptions = {};
|
||||
const options = await this.store.query(this.args.model, queryOptions);
|
||||
this.formatOptions(options);
|
||||
} catch (err) {
|
||||
if (err.httpStatus === 404) {
|
||||
if (!this.allOptions) {
|
||||
// If the call failed but the resource has items
|
||||
// from a different namespace, this allows the
|
||||
// selected items to display
|
||||
this.allOptions = [];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (err.httpStatus === 403) {
|
||||
this.shouldUseFallback = true;
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
formatOptions(options) {
|
||||
options = options.toArray();
|
||||
if (this.excludeOptions) {
|
||||
options = options.filter((o) => !this.excludeOptions.includes(o.id));
|
||||
}
|
||||
options = options.map((option) => {
|
||||
addSearchText(optionsToFormat) {
|
||||
// maps over array models from query
|
||||
return optionsToFormat.toArray().map((option) => {
|
||||
option.searchText = `${option.name} ${option.id}`;
|
||||
return option;
|
||||
});
|
||||
|
||||
if (this.selectedOptions.length > 0) {
|
||||
this.selectedOptions = this.selectedOptions.map((option) => {
|
||||
const matchingOption = options.findBy('id', option);
|
||||
options.removeObject(matchingOption);
|
||||
return {
|
||||
id: option,
|
||||
name: matchingOption ? matchingOption.name : option,
|
||||
searchText: matchingOption ? matchingOption.searchText : option,
|
||||
};
|
||||
});
|
||||
}
|
||||
this.allOptions = options;
|
||||
}
|
||||
|
||||
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') {
|
||||
if (this.passObject) {
|
||||
this.args.onChange(
|
||||
Array.from(this.selectedOptions, (option) => ({ id: option.id, isNew: !!option.new }))
|
||||
);
|
||||
} else {
|
||||
this.args.onChange(Array.from(this.selectedOptions, (option) => option.id));
|
||||
}
|
||||
this.args.onChange(Array.from(this.selectedOptions, (option) => option.id));
|
||||
} else {
|
||||
this.args.onChange(this.selectedOptions);
|
||||
}
|
||||
}
|
||||
shouldShowCreate(id, options) {
|
||||
if (options && options.length && options.firstObject.groupName) {
|
||||
return !options.some((group) => group.options.findBy('id', id));
|
||||
|
||||
shouldShowCreate(id, searchResults) {
|
||||
if (searchResults && searchResults.length && searchResults.firstObject.groupName) {
|
||||
return !searchResults.some((group) => group.options.findBy('id', id));
|
||||
}
|
||||
const existingOption =
|
||||
this.allOptions && (this.allOptions.findBy('id', id) || this.allOptions.findBy('name', id));
|
||||
this.dropdownOptions &&
|
||||
(this.dropdownOptions.findBy('id', id) || this.dropdownOptions.findBy('name', id));
|
||||
return !existingOption;
|
||||
}
|
||||
//----- adapted from ember-power-select-with-create
|
||||
|
||||
// ----- adapted from ember-power-select-with-create
|
||||
addCreateOption(term, results) {
|
||||
if (this.shouldShowCreate(term, results)) {
|
||||
const name = `Click to create new ${singularize(this.args.id)}: ${term}`;
|
||||
const name = `No results found for "${term}". Click here to create it.`;
|
||||
const suggestion = {
|
||||
__isSuggestion__: true,
|
||||
__value__: term,
|
||||
@@ -153,51 +156,44 @@ export default class SearchSelectWithModal extends Component {
|
||||
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.allOptions.pushObject(selected);
|
||||
this.dropdownOptions.pushObject(selected);
|
||||
this.handleChange();
|
||||
}
|
||||
|
||||
// ----- adapted from ember-power-select-with-create
|
||||
@action
|
||||
searchAndSuggest(term, select) {
|
||||
searchAndSuggest(term) {
|
||||
if (term.length === 0) {
|
||||
return this.allOptions;
|
||||
return this.dropdownOptions;
|
||||
}
|
||||
if (this.search) {
|
||||
return resolve(this.search(term, select)).then((results) => {
|
||||
if (results.toArray) {
|
||||
results = results.toArray();
|
||||
}
|
||||
this.addCreateOption(term, results);
|
||||
return results;
|
||||
});
|
||||
if (this.args.models?.some((model) => model.includes('policy'))) {
|
||||
term = term.toLowerCase();
|
||||
}
|
||||
const newOptions = this.filter(this.allOptions, term);
|
||||
const newOptions = this.filter(this.dropdownOptions, term);
|
||||
this.addCreateOption(term, newOptions);
|
||||
return newOptions;
|
||||
}
|
||||
|
||||
@action
|
||||
async selectOrCreate(selection) {
|
||||
// if creating we call handleChange in the resetModal action to ensure the model is valid and successfully created
|
||||
// before adding it to the DOM (and parent model)
|
||||
// if just selecting, then we handleChange immediately
|
||||
selectOrCreate(selection) {
|
||||
if (selection && selection.__isSuggestion__) {
|
||||
const name = selection.__value__;
|
||||
// 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;
|
||||
const createRecord = await this.store.createRecord(this.args.model);
|
||||
createRecord.name = name;
|
||||
this.newModelRecord = createRecord;
|
||||
} else {
|
||||
// user has selected an existing item, handleChange immediately
|
||||
this.selectedOptions.pushObject(selection);
|
||||
this.allOptions.removeObject(selection);
|
||||
this.dropdownOptions.removeObject(selection);
|
||||
this.handleChange();
|
||||
}
|
||||
}
|
||||
@@ -205,12 +201,13 @@ export default class SearchSelectWithModal extends Component {
|
||||
|
||||
@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.newModelRecord = null;
|
||||
this.nameInput = null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user