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:
claire bontempo
2022-11-18 17:29:04 -08:00
committed by GitHub
parent 1b442c5c20
commit 56a10bb10f
41 changed files with 1120 additions and 496 deletions

View File

@@ -1,23 +1,13 @@
{{! template-lint-configure simple-unless "warn" }}
<div class="field" data-test-field={{or @attr.name true}}>
{{#unless
(or
(eq @attr.type "boolean")
(includes
@attr.options.editType
(array "boolean" "optionalText" "searchSelect" "mountAccessor" "kv" "file" "ttl" "stringArray" "json" "regex")
)
)
}}
{{#if (not (eq @attr.type "object"))}}
<FormFieldLabel
for={{@attr.name}}
@label={{this.labelString}}
@helpText={{(if this.showHelpText @attr.options.helpText)}}
@subText={{@attr.options.subText}}
@docLink={{@attr.options.docLink}}
/>
{{/if}}
{{#unless this.hideLabel}}
<FormFieldLabel
for={{@attr.name}}
@label={{this.labelString}}
@helpText={{(if this.showHelpText @attr.options.helpText)}}
@subText={{@attr.options.subText}}
@docLink={{@attr.options.docLink}}
/>
{{/unless}}
{{#if @attr.options.possibleValues}}
{{#if (eq @attr.options.editType "radio")}}

View File

@@ -42,9 +42,21 @@ import { dasherize } from 'vault/helpers/dasherize';
*/
export default class FormFieldComponent extends Component {
emptyData = '{\n}';
shouldHideLabel = [
'boolean',
'file',
'json',
'kv',
'mountAccessor',
'optionalText',
'regex',
'searchSelect',
'stringArray',
'ttl',
];
@tracked showInput = false;
@tracked file = { value: '' }; // used by the pgp-file component when an attr is editType of 'file'
emptyData = '{\n}';
constructor() {
super(...arguments);
@@ -54,6 +66,15 @@ export default class FormFieldComponent extends Component {
this.showInput = !!modelValue;
}
get hideLabel() {
const { type, options } = this.args.attr;
if (type === 'boolean' || type === 'object' || options?.isSectionHeader) {
return true;
}
// falsey values render a <FormFieldLabel>
return this.shouldHideLabel.includes(options?.editType);
}
get disabled() {
return this.args.disabled || false;
}

View File

@@ -1,4 +1,10 @@
<div {{did-insert this.fetchOptions}} ...attributes data-test-search-select-with-modal>
<div
{{did-insert (perform this.fetchOptions)}}
id={{@id}}
class="field search-select {{if @displayInherit 'display-inherit'}}"
data-test-search-select-with-modal
...attributes
>
{{#if this.shouldUseFallback}}
{{component
@fallbackComponent
@@ -12,7 +18,7 @@
}}
{{else}}
{{#if @label}}
<label for={{@id}} class="is-label" data-test-field-label>
<label for={{@id}} class={{or @labelClass "is-label"}} data-test-field-label>
{{@label}}
{{#if @helpText}}
<InfoTooltip>{{@helpText}}</InfoTooltip>
@@ -20,16 +26,15 @@
</label>
{{/if}}
{{#if @subText}}
<p class="sub-text">{{@subText}}</p>
<p data-test-modal-subtext class="sub-text">{{@subText}}</p>
{{/if}}
{{! template-lint-configure simple-unless "warn" }}
{{#unless (gte this.selectedOptions.length @selectLimit)}}
{{#unless this.hidePowerSelect}}
<PowerSelect
@eventType="click"
@placeholder={{@placeholder}}
@searchEnabled={{true}}
@search={{this.searchAndSuggest}}
@options={{this.allOptions}}
@options={{this.dropdownOptions}}
@onChange={{this.selectOrCreate}}
@placeholderComponent={{component "search-select-placeholder"}}
@verticalPosition="below"
@@ -37,20 +42,22 @@
>
{{#if this.shouldRenderName}}
{{option.name}}
<small class="search-select-list-key" data-test-smaller-id="true">
{{option.id}}
</small>
{{#unless option.__isSuggestion__}}
<small class="search-select-list-key" data-test-smaller-id="true">
{{option.id}}
</small>
{{/unless}}
{{else}}
{{option.id}}
{{/if}}
</PowerSelect>
{{/unless}}
<ul class="search-select-list">
{{#each this.selectedOptions as |selected|}}
<li class="search-select-list-item" data-test-selected-option="true">
{{#each this.selectedOptions as |selected index|}}
<li class="search-select-list-item" data-test-selected-option={{index}}>
{{#if this.shouldRenderName}}
{{selected.name}}
<small class="search-select-list-key" data-test-smaller-id="true">
<small class="search-select-list-key" data-test-smaller-id={{index}}>
{{selected.id}}
</small>
{{else}}
@@ -73,7 +80,9 @@
</ul>
{{/if}}
{{#if this.newModelRecord}}
{{! wait until user has selected 'create a new item' before rendering modal}}
{{#if this.nameInput}}
<Modal
@title="Create new {{singularize @id}}"
@onClose={{action (mut this.showModal) false}}
@@ -82,21 +91,17 @@
@showCloseButton={{false}}
>
<section class="modal-card-body">
<p class="has-bottom-margin-s" data-test-modal-subtext>
{{@modalSubtext}}
</p>
{{#if @modalSubtext}}
<p class="has-bottom-margin-s" data-test-modal-subtext>
{{@modalSubtext}}
</p>
{{/if}}
{{#if (has-block)}}
{{yield}}
{{else}}
{{! dynamically render passed in form component }}
{{! dynamically render template from modal-form/ folder}}
{{! form must receive an @onSave and @onCancel arg that executes the callback}}
{{component
@modalFormComponent
model=this.newModelRecord
onSave=this.resetModal
onCancel=this.resetModal
isInline=true
}}
{{component @modalFormTemplate nameInput=this.nameInput onSave=this.resetModal onCancel=this.resetModal}}
{{/if}}
</section>
</Modal>

View File

@@ -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;
}
}

View File

@@ -2,7 +2,7 @@
{{did-insert (perform this.fetchOptions)}}
{{did-update (perform this.fetchOptions) (or @options @models)}}
id={{@id}}
class={{concat "field search-select" (if (eq @displayInherit true) " display-inherit")}}
class="field search-select {{if @displayInherit 'display-inherit'}}"
data-test-component="search-select"
...attributes
>
@@ -19,7 +19,7 @@
}}
{{else}}
{{#if @label}}
<label for={{@id}} class={{if @labelClass @labelClass "is-label"}} data-test-field-label>
<label for={{@id}} class={{or @labelClass "is-label"}} data-test-field-label>
{{@label}}
{{#if @helpText}}
<InfoTooltip>{{@helpText}}</InfoTooltip>
@@ -56,7 +56,7 @@
<li class="search-select-list-item" data-test-selected-option={{index}}>
{{#if this.shouldRenderName}}
{{selected.name}}
<small class="search-select-list-key" data-test-smaller-id="true">
<small class="search-select-list-key" data-test-smaller-id={{index}}>
{{get selected this.idKey}}
</small>
{{else}}