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:
parent
275479cdd9
commit
f58990677f
|
@ -0,0 +1,3 @@
|
|||
```release-note:feature
|
||||
ui: Add inline policy creation when creating an identity entity or group
|
||||
```
|
|
@ -0,0 +1,3 @@
|
|||
These templates parent form components that render within a modal to create inline items via SearchSelectWithModal.
|
||||
The modal opens when a user searches for an item that doesn't exist and clicks 'No results found for "${term}". Click here to create it.'
|
||||
The templates are rendered using the {{component}} helper in 'search-select-with-modal.hbs' and are responsible for creating the model passed to the form.
|
|
@ -0,0 +1 @@
|
|||
<Oidc::AssignmentForm @onSave={{this.onSave}} @model={{this.assignment}} @onCancel={{@onCancel}} />
|
|
@ -0,0 +1,36 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
/**
|
||||
* @module ModalForm::OidcAssignmentTemplate
|
||||
* ModalForm::OidcAssignmentTemplate components render within a modal and create a model using the input from the search select. The model is passed to the oidc/assignment-form.
|
||||
*
|
||||
* @example
|
||||
* <ModalForm::OidcAssignmentTemplate
|
||||
* @nameInput="new-item-name"
|
||||
* @onSave={{this.closeModal}}
|
||||
* @onCancel={{@onCancel}}
|
||||
* />
|
||||
* ```
|
||||
* @callback onCancel - callback triggered when cancel button is clicked
|
||||
* @callback onSave - callback triggered when save button is clicked
|
||||
* @param {string} nameInput - the name of the newly created assignment
|
||||
*/
|
||||
|
||||
export default class OidcAssignmentTemplate extends Component {
|
||||
@service store;
|
||||
@tracked assignment = null; // model record passed to oidc/assignment-form
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.assignment = this.store.createRecord('oidc/assignment', { name: this.args.nameInput });
|
||||
}
|
||||
|
||||
@action onSave(assignmentModel) {
|
||||
this.args.onSave(assignmentModel);
|
||||
// Reset component assignment for next use
|
||||
this.assignment = null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
{{#if this.policy.policyType}}
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
<li class={{unless this.showExamplePolicy "active"}}>
|
||||
<button
|
||||
data-test-tab-your-policy
|
||||
type="button"
|
||||
name="form"
|
||||
class="link link-plain tab has-text-weight-semibold {{unless this.showExamplePolicy ' is-active'}}"
|
||||
{{on "click" (fn (mut this.showExamplePolicy) false)}}
|
||||
>
|
||||
Your Policy
|
||||
</button>
|
||||
</li>
|
||||
<li class={{if this.showExamplePolicy "active"}}>
|
||||
<button
|
||||
data-test-tab-example-policy
|
||||
type="button"
|
||||
name="form"
|
||||
class="link link-plain tab has-text-weight-semibold {{if this.showExamplePolicy ' is-active'}}"
|
||||
{{on "click" (fn (mut this.showExamplePolicy) true)}}
|
||||
>
|
||||
Example Policy
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{{/if}}
|
||||
{{#if this.showExamplePolicy}}
|
||||
<div class="has-bottom-margin-s">
|
||||
{{#if (eq this.policy.policyType "acl")}}
|
||||
<p>
|
||||
ACL Policies are written in Hashicorp Configuration Language (
|
||||
<DocLink @host="https://github.com/hashicorp/hcl">HCL</DocLink>
|
||||
) or JSON and describe which paths in Vault a user or machine is allowed to access. Here is an example policy:
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="has-bottom-margin-s">
|
||||
Role Governing Policies (RGPs) are tied to client tokens or identities which is similar to
|
||||
<DocLink @host="https://developer.hashicorp.com" @path="/vault/tutorials/policies/policies">ACL policies</DocLink>.
|
||||
They use
|
||||
<DocLink @host="https://developer.hashicorp.com" @path="/vault/docs/enterprise/sentinel">Sentinel</DocLink>
|
||||
as a language framework to enable fine-grained policy decisions.
|
||||
</p>
|
||||
<p>
|
||||
Here is an example policy that uses RGP to restrict access to the
|
||||
<code class="tag is-marginless is-paddingless">admin</code>
|
||||
policy such that a user named James or has the
|
||||
<code class="tag is-marginless is-paddingless">Team Lead</code>
|
||||
role can manage the
|
||||
<code class="tag is-marginless is-paddingless">admin</code>
|
||||
policy:
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<JsonEditor
|
||||
@value={{get this.policyTemplates this.policy.policyType}}
|
||||
@mode="ruby"
|
||||
@readOnly={{true}}
|
||||
@showToolbar={{true}}
|
||||
/>
|
||||
{{else}}
|
||||
<Select
|
||||
@name="policyType"
|
||||
@label="Type"
|
||||
@options={{this.policyOptions}}
|
||||
@isFullwidth={{true}}
|
||||
@selectedValue={{this.policy.policyType}}
|
||||
@onChange={{this.setPolicyType}}
|
||||
@noDefault={{true}}
|
||||
/>
|
||||
{{#if this.policy.policyType}}
|
||||
<PolicyForm @onSave={{this.onSave}} @model={{this.policy}} @onCancel={{@onCancel}} />
|
||||
{{else}}
|
||||
<EmptyState @title="No policy type selected" @message="Select a policy type to continue creating." />
|
||||
{{/if}}
|
||||
{{/if}}
|
|
@ -0,0 +1,82 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
/**
|
||||
* @module ModalForm::PolicyTemplate
|
||||
* ModalForm::PolicyTemplate components are meant to render within a modal for creating a new policy of unknown type.
|
||||
*
|
||||
* @example
|
||||
* <ModalForm::PolicyTemplate
|
||||
* @nameInput="new-item-name"
|
||||
* @onSave={{this.closeModal}}
|
||||
* @onCancel={{this.closeModal}}
|
||||
* />
|
||||
* ```
|
||||
* @callback onCancel - callback triggered when cancel button is clicked
|
||||
* @callback onSave - callback triggered when save button is clicked
|
||||
* @param {string} nameInput - the name of the newly created policy
|
||||
*/
|
||||
|
||||
export default class PolicyTemplate extends Component {
|
||||
@service store;
|
||||
@service version;
|
||||
|
||||
@tracked policy = null; // model record passed to policy-form
|
||||
@tracked showExamplePolicy = false;
|
||||
|
||||
get policyOptions() {
|
||||
return [
|
||||
{ label: 'ACL Policy', value: 'acl', isDisabled: false },
|
||||
{ label: 'Role Governing Policy', value: 'rgp', isDisabled: !this.version.hasSentinel },
|
||||
];
|
||||
}
|
||||
// formatting here is purposeful so that whitespace renders correctly in JsonEditor
|
||||
policyTemplates = {
|
||||
acl: `
|
||||
# Grant 'create', 'read' , 'update', and ‘list’ permission
|
||||
# to paths prefixed by 'secret/*'
|
||||
path "secret/*" {
|
||||
capabilities = [ "create", "read", "update", "list" ]
|
||||
}
|
||||
|
||||
# Even though we allowed secret/*, this line explicitly denies
|
||||
# secret/super-secret. This takes precedence.
|
||||
path "secret/super-secret" {
|
||||
capabilities = ["deny"]
|
||||
}
|
||||
`,
|
||||
rgp: `
|
||||
# Import strings library that exposes common string operations
|
||||
import "strings"
|
||||
|
||||
# Conditional rule (precond) checks the incoming request endpoint
|
||||
# targeted to sys/policies/acl/admin
|
||||
precond = rule {
|
||||
strings.has_prefix(request.path, "sys/policies/admin")
|
||||
}
|
||||
|
||||
# Vault checks to see if the request was made by an entity
|
||||
# named James Thomas or Team Lead role defined as its metadata
|
||||
main = rule when precond {
|
||||
identity.entity.metadata.role is "Team Lead" or
|
||||
identity.entity.name is "James Thomas"
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@action
|
||||
setPolicyType(type) {
|
||||
if (this.policy) this.policy.unloadRecord(); // if user selects a different type, clear from store before creating a new record
|
||||
// Create form model once type is chosen
|
||||
this.policy = this.store.createRecord(`policy/${type}`, { name: this.args.nameInput });
|
||||
}
|
||||
|
||||
@action
|
||||
onSave(policyModel) {
|
||||
this.args.onSave(policyModel);
|
||||
// Reset component policy for next use
|
||||
this.policy = null;
|
||||
}
|
||||
}
|
|
@ -15,11 +15,10 @@ import { tracked } from '@glimmer/tracking';
|
|||
* @onSave={transition-to "vault.cluster.access.oidc.assignments.assignment.details" this.model.name}
|
||||
* />
|
||||
* ```
|
||||
* @callback onCancel
|
||||
* @callback onSave
|
||||
|
||||
* @param {object} model - The parent's model
|
||||
* @param {string} onCancel - callback triggered when cancel button is clicked
|
||||
* @param {string} onSave - callback triggered when save button is clicked
|
||||
* @callback onCancel - callback triggered when cancel button is clicked
|
||||
* @callback onSave - callback triggered when save button is clicked*
|
||||
*/
|
||||
|
||||
export default class OidcAssignmentFormComponent extends Component {
|
||||
|
|
|
@ -11,15 +11,13 @@ import { task } from 'ember-concurrency';
|
|||
* ```js
|
||||
* <OidcClientForm @model={{this.model}} />
|
||||
* ```
|
||||
* @callback onCancel
|
||||
* @callback onSave
|
||||
* @param {Object} model - oidc client model
|
||||
* @param {onCancel} onCancel - callback triggered when cancel button is clicked
|
||||
* @param {onSave} onSave - callback triggered on save success
|
||||
* @callback onCancel - callback triggered when cancel button is clicked
|
||||
* @callback onSave - callback triggered on save success
|
||||
* @param {boolean} [isInline=false] - true when form is rendered within a modal
|
||||
*/
|
||||
|
||||
export default class OidcClientForm extends Component {
|
||||
@service store;
|
||||
@service flashMessages;
|
||||
@tracked modelValidations;
|
||||
@tracked errorBanner;
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
<form {{on "submit" (perform this.save)}} data-test-policy-form>
|
||||
<div class="box is-bottomless is-fullwidth is-marginless">
|
||||
<MessageError @errorMessage={{this.errorBanner}} />
|
||||
<NamespaceReminder @mode={{if @model.isNew "create" "edit"}} @noun="policy" />
|
||||
{{#if @model.isNew}}
|
||||
<div class="field">
|
||||
<label for="policy-name" class="is-label">Name</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@type="text"
|
||||
@value={{lowercase @model.name}}
|
||||
id="policy-name"
|
||||
class="input"
|
||||
{{on "input" this.setModelName}}
|
||||
data-test-policy-input="name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="field">
|
||||
{{#if @model.isNew}}
|
||||
<Toolbar>
|
||||
<label class="is-label">Policy</label>
|
||||
<ToolbarActions>
|
||||
<div class="toolbar-separator"></div>
|
||||
<div class="control is-flex">
|
||||
<Input
|
||||
id="fileUploadToggle"
|
||||
@type="checkbox"
|
||||
name="fileUploadToggle"
|
||||
class="switch is-rounded is-success is-small"
|
||||
@checked={{this.showFileUpload}}
|
||||
{{on "change" (fn (mut this.showFileUpload) (not this.showFileUpload))}}
|
||||
data-test-policy-edit-toggle
|
||||
/>
|
||||
<label for="fileUploadToggle">Upload file</label>
|
||||
</div>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{#if this.showFileUpload}}
|
||||
<TextFile @inputOnly={{true}} @file={{this.file}} @onChange={{this.setPolicyFromFile}} />
|
||||
{{else}}
|
||||
<JsonEditor
|
||||
@title="Policy"
|
||||
@helpText="You can use Alt+Tab (Option+Tab on MacOS) in the code editor to skip to the next field"
|
||||
@showToolbar={{false}}
|
||||
@value={{@model.policy}}
|
||||
@valueUpdated={{action (mut @model.policy)}}
|
||||
@mode="ruby"
|
||||
@extraKeys={{hash Shift-Enter=(perform this.save)}}
|
||||
data-test-policy-editor
|
||||
/>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{! EDITING - no file upload toggle}}
|
||||
<JsonEditor
|
||||
@title="Policy"
|
||||
@helpText="You can use Alt+Tab (Option+Tab on MacOS) in the code editor to skip to the next field"
|
||||
@value={{@model.policy}}
|
||||
@valueUpdated={{action (mut @model.policy)}}
|
||||
@mode="ruby"
|
||||
@extraKeys={{hash Shift-Enter=(perform this.save)}}
|
||||
data-test-policy-editor
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#each @model.additionalAttrs as |attr|}}
|
||||
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} />
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="has-bottom-margin-m">
|
||||
<p>
|
||||
More information about
|
||||
{{uppercase @model.policyType}}
|
||||
policies can be found
|
||||
<DocLink
|
||||
@host="https://developer.hashicorp.com"
|
||||
@path={{if
|
||||
(eq @model.policyType "acl")
|
||||
"/vault/docs/concepts/policies#capabilities"
|
||||
"/vault/tutorials/policies/sentinel#role-governing-policies-rgps"
|
||||
}}
|
||||
>
|
||||
here.
|
||||
</DocLink>
|
||||
</p>
|
||||
</div>
|
||||
<div class="field is-grouped box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
<button
|
||||
type="submit"
|
||||
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
|
||||
disabled={{this.save.isRunning}}
|
||||
data-test-policy-save
|
||||
>
|
||||
{{if @model.isNew "Create policy" "Save"}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button has-left-margin-s"
|
||||
disabled={{this.save.isRunning}}
|
||||
{{on "click" this.cancel}}
|
||||
data-test-policy-cancel
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
|
@ -0,0 +1,69 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import trimRight from 'vault/utils/trim-right';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
/**
|
||||
* @module PolicyForm
|
||||
* PolicyForm components are the forms to create and edit all types of policies. This is only the form, not the outlying layout, and expects that the form model is passed from the parent.
|
||||
*
|
||||
* @example
|
||||
* <PolicyForm
|
||||
* @model={{this.model}}
|
||||
* @onSave={{transition-to "vault.cluster.policy.show" this.model.policyType this.model.name}}
|
||||
* @onCancel={{transition-to "vault.cluster.policies.index"}}
|
||||
* />
|
||||
* ```
|
||||
* @callback onCancel - callback triggered when cancel button is clicked
|
||||
* @callback onSave - callback triggered when save button is clicked. Passes saved model
|
||||
* @param {object} model - ember data model from createRecord
|
||||
*/
|
||||
|
||||
export default class PolicyFormComponent extends Component {
|
||||
@service flashMessages;
|
||||
|
||||
@tracked errorBanner = '';
|
||||
@tracked file = null;
|
||||
@tracked showFileUpload = false;
|
||||
|
||||
@task
|
||||
*save(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const { name, policyType, isNew } = this.args.model;
|
||||
yield this.args.model.save();
|
||||
this.flashMessages.success(
|
||||
`${policyType.toUpperCase()} policy "${name}" was successfully ${isNew ? 'created' : 'updated'}.`
|
||||
);
|
||||
this.args.onSave(this.args.model);
|
||||
} catch (error) {
|
||||
const message = error.errors ? error.errors.join('. ') : error.message;
|
||||
this.errorBanner = message;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
setModelName({ target }) {
|
||||
this.args.model.name = target.value.toLowerCase();
|
||||
}
|
||||
|
||||
@action
|
||||
setPolicyFromFile(index, fileInfo) {
|
||||
const { value, fileName } = fileInfo;
|
||||
this.args.model.policy = value;
|
||||
if (!this.args.model.name) {
|
||||
const trimmedFileName = trimRight(fileName, ['.json', '.txt', '.hcl', '.policy']);
|
||||
this.args.model.name = trimmedFileName.toLowerCase();
|
||||
}
|
||||
this.showFileUpload = false;
|
||||
}
|
||||
|
||||
@action
|
||||
cancel() {
|
||||
const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
|
||||
this.args.model[method]();
|
||||
this.args.onCancel();
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ export default Component.extend({
|
|||
classNames: ['ui-wizard-container'],
|
||||
wizard: service(),
|
||||
auth: service(),
|
||||
router: service(),
|
||||
|
||||
shouldRender: or('auth.currentToken', 'wizard.showWhenUnauthenticated'),
|
||||
currentState: alias('wizard.currentState'),
|
||||
|
@ -16,6 +17,7 @@ export default Component.extend({
|
|||
componentState: alias('wizard.componentState'),
|
||||
nextFeature: alias('wizard.nextFeature'),
|
||||
nextStep: alias('wizard.nextStep'),
|
||||
currentRouteName: alias('router.currentRouteName'),
|
||||
|
||||
actions: {
|
||||
dismissWizard() {
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
import Controller from '@ember/controller';
|
||||
import trimRight from 'vault/utils/trim-right';
|
||||
import PolicyEditController from 'vault/mixins/policy-edit-controller';
|
||||
|
||||
export default Controller.extend(PolicyEditController, {
|
||||
showFileUpload: false,
|
||||
file: null,
|
||||
actions: {
|
||||
setPolicyFromFile(index, fileInfo) {
|
||||
const { value, fileName } = fileInfo;
|
||||
const model = this.model;
|
||||
model.set('policy', value);
|
||||
if (!model.get('name')) {
|
||||
const trimmedFileName = trimRight(fileName, ['.json', '.txt', '.hcl', '.policy']);
|
||||
model.set('name', trimmedFileName);
|
||||
}
|
||||
this.set('showFileUpload', false);
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,4 +1,27 @@
|
|||
import Controller from '@ember/controller';
|
||||
import PolicyEditController from 'vault/mixins/policy-edit-controller';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default Controller.extend(PolicyEditController);
|
||||
export default class PolicyEditController extends Controller {
|
||||
@service router;
|
||||
@service flashMessages;
|
||||
@service wizard;
|
||||
|
||||
@action
|
||||
async deletePolicy() {
|
||||
const { policyType, name } = this.model;
|
||||
try {
|
||||
await this.model.destroyRecord();
|
||||
this.flashMessages.success(`${policyType.toUpperCase()} policy "${name}" was successfully deleted.`);
|
||||
this.router.transitionTo('vault.cluster.policies', policyType);
|
||||
if (this.wizard.featureState === 'delete') {
|
||||
this.wizard.transitionFeatureMachine('delete', 'CONTINUE', policyType);
|
||||
}
|
||||
} catch (error) {
|
||||
this.model.rollbackAttributes();
|
||||
const errors = error.errors ? error.errors.join('. ') : error.message;
|
||||
const message = `There was an error deleting the ${policyType.toUpperCase()} policy "${name}": ${errors}.`;
|
||||
this.flashMessages.danger(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
import { inject as service } from '@ember/service';
|
||||
import Mixin from '@ember/object/mixin';
|
||||
|
||||
export default Mixin.create({
|
||||
flashMessages: service(),
|
||||
wizard: service(),
|
||||
actions: {
|
||||
deletePolicy(model) {
|
||||
const policyType = model.get('policyType');
|
||||
const name = model.get('name');
|
||||
const flash = this.flashMessages;
|
||||
model
|
||||
.destroyRecord()
|
||||
.then(() => {
|
||||
flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully deleted.`);
|
||||
return this.transitionToRoute('vault.cluster.policies', policyType);
|
||||
})
|
||||
.catch((e) => {
|
||||
const errors = e.errors ? e.errors.join('') : e.message;
|
||||
flash.danger(
|
||||
`There was an error deleting the ${policyType.toUpperCase()} policy "${name}": ${errors}.`
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
savePolicy(model) {
|
||||
const flash = this.flashMessages;
|
||||
const policyType = model.get('policyType');
|
||||
const name = model.get('name');
|
||||
model
|
||||
.save()
|
||||
.then((m) => {
|
||||
flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully saved.`);
|
||||
if (this.wizard.featureState === 'create') {
|
||||
this.wizard.transitionFeatureMachine('create', 'CONTINUE', policyType);
|
||||
}
|
||||
return this.transitionToRoute('vault.cluster.policy.show', m.get('policyType'), m.get('name'));
|
||||
})
|
||||
.catch(() => {
|
||||
// swallow error -- model.errors set by Adapter
|
||||
return;
|
||||
});
|
||||
},
|
||||
|
||||
setModelName(model, e) {
|
||||
model.set('name', e.target.value.toLowerCase());
|
||||
},
|
||||
},
|
||||
});
|
|
@ -4,6 +4,7 @@ import { alias } from '@ember/object/computed';
|
|||
import IdentityModel from './_base';
|
||||
import apiPath from 'vault/utils/api-path';
|
||||
import attachCapabilities from 'vault/lib/attach-capabilities';
|
||||
import lazyCapabilities from 'vault/macros/lazy-capabilities';
|
||||
|
||||
const Model = IdentityModel.extend({
|
||||
formFields: computed(function () {
|
||||
|
@ -20,11 +21,8 @@ const Model = IdentityModel.extend({
|
|||
editType: 'kv',
|
||||
}),
|
||||
policies: attr({
|
||||
label: 'Policies',
|
||||
editType: 'searchSelect',
|
||||
editType: 'yield',
|
||||
isSectionHeader: true,
|
||||
fallbackComponent: 'string-list',
|
||||
models: ['policy/acl', 'policy/rgp'],
|
||||
}),
|
||||
creationTime: attr('string', {
|
||||
readOnly: true,
|
||||
|
@ -46,6 +44,8 @@ const Model = IdentityModel.extend({
|
|||
canEdit: alias('updatePath.canUpdate'),
|
||||
canRead: alias('updatePath.canRead'),
|
||||
canAddAlias: alias('aliasPath.canCreate'),
|
||||
policyPath: lazyCapabilities(apiPath`sys/policies`),
|
||||
canCreatePolicies: alias('policyPath.canCreate'),
|
||||
});
|
||||
|
||||
export default attachCapabilities(Model, {
|
||||
|
|
|
@ -34,11 +34,8 @@ export default IdentityModel.extend({
|
|||
editType: 'kv',
|
||||
}),
|
||||
policies: attr({
|
||||
label: 'Policies',
|
||||
editType: 'searchSelect',
|
||||
editType: 'yield',
|
||||
isSectionHeader: true,
|
||||
fallbackComponent: 'string-list',
|
||||
models: ['policy/acl', 'policy/rgp'],
|
||||
}),
|
||||
memberGroupIds: attr({
|
||||
label: 'Member Group IDs',
|
||||
|
@ -73,7 +70,8 @@ export default IdentityModel.extend({
|
|||
return numEntities + numGroups > 0;
|
||||
}
|
||||
),
|
||||
|
||||
policyPath: lazyCapabilities(apiPath`sys/policies`),
|
||||
canCreatePolicies: alias('policyPath.canCreate'),
|
||||
alias: belongsTo('identity/group-alias', { async: false, readOnly: true }),
|
||||
updatePath: identityCapabilities(),
|
||||
canDelete: alias('updatePath.canDelete'),
|
||||
|
|
|
@ -1,4 +1,13 @@
|
|||
import UnsavedModelRoute from 'vault/mixins/unsaved-model-route';
|
||||
import ShowRoute from './show';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default ShowRoute.extend(UnsavedModelRoute);
|
||||
export default ShowRoute.extend(UnsavedModelRoute, {
|
||||
wizard: service(),
|
||||
|
||||
activate() {
|
||||
if (this.wizard.featureState === 'details') {
|
||||
this.wizard.transitionFeatureMachine('details', 'CONTINUE', this.policyType());
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -5,6 +5,13 @@ import { inject as service } from '@ember/service';
|
|||
|
||||
export default Route.extend(UnloadModelRoute, {
|
||||
store: service(),
|
||||
wizard: service(),
|
||||
|
||||
activate() {
|
||||
if (this.wizard.featureState === 'create') {
|
||||
this.wizard.transitionFeatureMachine('create', 'CONTINUE', this.policyType());
|
||||
}
|
||||
},
|
||||
|
||||
beforeModel() {
|
||||
const params = this.paramsFor(this.routeName);
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
border: 1px solid $grey-light;
|
||||
max-height: calc(100vh - 70px);
|
||||
margin-top: 60px;
|
||||
min-width: calc(100vw * 0.3);
|
||||
|
||||
&-head {
|
||||
border-radius: 0;
|
||||
|
|
|
@ -24,7 +24,34 @@
|
|||
/>
|
||||
{{/if}}
|
||||
{{#each this.model.fields as |attr|}}
|
||||
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} />
|
||||
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}}>
|
||||
<div class="form-section">
|
||||
{{#if this.model.canCreatePolicies}}
|
||||
<SearchSelectWithModal
|
||||
@id="policies"
|
||||
@label="Policies"
|
||||
@labelClass="title is-4"
|
||||
@models={{array "policy/acl" "policy/rgp"}}
|
||||
@inputValue={{@model.policies}}
|
||||
@onChange={{action (mut this.model.policies)}}
|
||||
@fallbackComponent="string-list"
|
||||
@modalFormTemplate="modal-form/policy-template"
|
||||
@excludeOptions={{array "root"}}
|
||||
/>
|
||||
{{else}}
|
||||
<SearchSelect
|
||||
@id="policies"
|
||||
@label="Policies"
|
||||
@labelClass="title is-4"
|
||||
@models={{array "policy/acl" "policy/rgp"}}
|
||||
@inputValue={{@model.policies}}
|
||||
@onChange={{action (mut this.model.policies)}}
|
||||
@fallbackComponent="string-list"
|
||||
@disallowNewItems={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</FormField>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,32 +1,3 @@
|
|||
{{#unless @isInline}}
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<span class="sep">/</span>
|
||||
{{#if @model.isNew}}
|
||||
<LinkTo @route={{"vault.cluster.access.oidc.assignments"}}>
|
||||
Assignments
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
{{! You're editing in this view }}
|
||||
<LinkTo @route={{"vault.cluster.access.oidc.assignments.assignment.details"}} @model={{@model.name}}>
|
||||
Details
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-oidc-assignment-title>
|
||||
{{if @model.isNew "Create" "Edit"}}
|
||||
assignment
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
{{/unless}}
|
||||
<form {{on "submit" (perform this.save)}}>
|
||||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
|
||||
|
|
|
@ -60,12 +60,12 @@
|
|||
@id="assignments"
|
||||
@label="Assignment name"
|
||||
@subText="Search for an existing assignment, or type a new name to create it."
|
||||
@model="oidc/assignment"
|
||||
@models={{array "oidc/assignment"}}
|
||||
@inputValue={{this.modelAssignments}}
|
||||
@onChange={{this.handleAssignmentSelection}}
|
||||
@excludeOptions={{array "allow_all"}}
|
||||
@fallbackComponent="string-list"
|
||||
@modalFormComponent="oidc/assignment-form"
|
||||
@modalFormTemplate="modal-form/oidc-assignment-template"
|
||||
@modalSubtext="Use assignment to specify which Vault entities and groups are allowed to authenticate."
|
||||
/>
|
||||
{{/if}}
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
onRepeat=(action "repeatStep")
|
||||
onReset=(action "resetFeature")
|
||||
onAdvance=(action "advanceFeature")
|
||||
currentRouteName=this.currentRouteName
|
||||
}}
|
||||
{{/component}}
|
||||
{{/if}}
|
|
@ -7,7 +7,11 @@
|
|||
@instructions='Click on "Delete" to remove the policy that you created.'
|
||||
>
|
||||
<p>
|
||||
{{#if (eq @currentRouteName "vault.cluster.policies.index")}}
|
||||
You can delete your test policy by clicking the "..." icon to the right of the policy name.
|
||||
{{else}}
|
||||
You can delete your test policy by clicking "Delete" in the toolbar.
|
||||
{{/if}}
|
||||
</p>
|
||||
</WizardSection>
|
||||
</WizardContent>
|
|
@ -7,7 +7,7 @@
|
|||
@instructions='Click on "ACL Policies" in the sidebar to go back to the list of policies.'
|
||||
>
|
||||
<p>
|
||||
Good job! Here you can see your new policy. If you'd like to edit it, you'd just click the "Edit" toggle.
|
||||
Good job! Here you can see your new policy. If you'd like to edit it, you'd just click "Edit policy" in the toolbar.
|
||||
</p>
|
||||
</WizardSection>
|
||||
</WizardContent>
|
|
@ -1,3 +1,22 @@
|
|||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<span class="sep">/</span>
|
||||
<LinkTo @route={{"vault.cluster.access.oidc.assignments.assignment.details"}} @model={{this.model.name}}>
|
||||
Details
|
||||
</LinkTo>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-oidc-assignment-title>
|
||||
Edit assignment
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
<Oidc::AssignmentForm
|
||||
@model={{this.model}}
|
||||
@onCancel={{transition-to "vault.cluster.access.oidc.assignments.assignment.details" this.model.name}}
|
||||
|
|
|
@ -1,3 +1,22 @@
|
|||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<span class="sep">/</span>
|
||||
<LinkTo @route={{"vault.cluster.access.oidc.assignments"}}>
|
||||
Assignments
|
||||
</LinkTo>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-oidc-assignment-title>
|
||||
Create assignment
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
<Oidc::AssignmentForm
|
||||
@model={{this.model}}
|
||||
@onCancel={{transition-to "vault.cluster.access.oidc.assignments"}}
|
||||
|
|
|
@ -17,70 +17,8 @@
|
|||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<form {{action "savePolicy" this.model on="submit"}}>
|
||||
<div class="box is-bottomless is-fullwidth is-marginless">
|
||||
<MessageError @model={{this.model}} @errors={{this.model.errors}} />
|
||||
<NamespaceReminder @mode="create" @noun="policy" />
|
||||
<div class="field">
|
||||
<label for="policy-name" class="is-label">Name</label>
|
||||
<div class="control">
|
||||
<input
|
||||
type="text"
|
||||
id="policy-name"
|
||||
class="input"
|
||||
value={{this.model.name}}
|
||||
oninput={{action "setModelName" this.model}}
|
||||
data-test-policy-input="name"
|
||||
<PolicyForm
|
||||
@model={{this.model}}
|
||||
@onSave={{transition-to "vault.cluster.policy.show" this.model.policyType this.model.name}}
|
||||
@onCancel={{transition-to "vault.cluster.policies.index"}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<Toolbar>
|
||||
<label class="is-label">Policy</label>
|
||||
<ToolbarActions>
|
||||
<div class="toolbar-separator"></div>
|
||||
<div class="control is-flex">
|
||||
<Input
|
||||
id="fileUploadToggle"
|
||||
@type="checkbox"
|
||||
name="fileUploadToggle"
|
||||
class="switch is-rounded is-success is-small"
|
||||
@checked={{this.showFileUpload}}
|
||||
{{on "change" (toggle-action "showFileUpload" this)}}
|
||||
data-test-policy-edit-toggle={{true}}
|
||||
/>
|
||||
<label for="fileUploadToggle">Upload file</label>
|
||||
</div>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{#if this.showFileUpload}}
|
||||
<TextFile @inputOnly={{true}} @file={{this.file}} @onChange={{action "setPolicyFromFile"}} />
|
||||
{{else}}
|
||||
<JsonEditor
|
||||
@title="Policy"
|
||||
@helpText="You can use Alt+Tab (Option+Tab on MacOS) in the code editor to skip to the next field"
|
||||
@showToolbar={{false}}
|
||||
@value={{this.model.policy}}
|
||||
@valueUpdated={{action (mut this.model.policy)}}
|
||||
@mode="ruby"
|
||||
@extraKeys={{hash Shift-Enter=(action "savePolicy" this.model)}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#each this.model.additionalAttrs as |attr|}}
|
||||
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} />
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="field is-grouped box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
<button type="submit" disabled={{this.buttonDisabled}} class="button is-primary" data-test-policy-save={{true}}>
|
||||
Create policy
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<LinkTo @route="vault.cluster.policies" @replace={{true}} class="button">
|
||||
Cancel
|
||||
</LinkTo>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
|
@ -28,7 +28,7 @@
|
|||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@confirmMessage="This may affect access to Vault data."
|
||||
@onConfirmAction={{action "deletePolicy" this.model}}
|
||||
@onConfirmAction={{this.deletePolicy}}
|
||||
data-test-policy-delete="true"
|
||||
>
|
||||
Delete
|
||||
|
@ -41,42 +41,9 @@
|
|||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{/if}}
|
||||
<form {{action "savePolicy" this.model on="submit"}}>
|
||||
<div class="box is-bottomless is-fullwidth is-marginless">
|
||||
<MessageError @model={{this.model}} />
|
||||
<NamespaceReminder @mode="edit" @noun="policy" />
|
||||
<div class="field">
|
||||
<JsonEditor
|
||||
@title="Policy"
|
||||
@value={{this.model.policy}}
|
||||
@valueUpdated={{action (mut this.model.policy)}}
|
||||
@mode="ruby"
|
||||
@extraKeys={{hash Shift-Enter=(action "savePolicy" this.model)}}
|
||||
|
||||
<PolicyForm
|
||||
@model={{this.model}}
|
||||
@onSave={{transition-to "vault.cluster.policy.show" this.model.policyType this.model.name}}
|
||||
@onCancel={{transition-to "vault.cluster.policy.show" this.model.policyType this.model.name}}
|
||||
/>
|
||||
<div class="box is-shadowless is-fullwidth has-short-padding">
|
||||
<p class="help-text has-text-grey-dark is-size-7">
|
||||
You can use Alt+Tab (Option+Tab on MacOS) in the code editor to skip to the next field
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{#each this.model.additionalAttrs as |attr|}}
|
||||
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} />
|
||||
{{/each}}
|
||||
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
|
||||
<div class="field is-grouped">
|
||||
{{#if this.capabilities.canUpdate}}
|
||||
<div class="control">
|
||||
<button type="submit" disabled={{this.buttonDisabled}} class="button is-primary" data-test-policy-save={{true}}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="control">
|
||||
<LinkTo @route="vault.cluster.policy.show" @model={{this.model.id}} @replace={{true}} class="button">
|
||||
Cancel
|
||||
</LinkTo>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
|
@ -1,15 +1,6 @@
|
|||
{{! 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"))}}
|
||||
{{#unless this.hideLabel}}
|
||||
<FormFieldLabel
|
||||
for={{@attr.name}}
|
||||
@label={{this.labelString}}
|
||||
|
@ -17,7 +8,6 @@
|
|||
@subText={{@attr.options.subText}}
|
||||
@docLink={{@attr.options.docLink}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
{{#if @attr.options.possibleValues}}
|
||||
{{#if (eq @attr.options.editType "radio")}}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}}
|
||||
{{#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">
|
||||
{{#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>
|
||||
|
|
|
@ -2,19 +2,18 @@ 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"
|
||||
* @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}}
|
||||
|
@ -22,93 +21,62 @@ import { filterOptions, defaultMatcher } from 'ember-power-select/utils/group-ut
|
|||
* {{! 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"
|
||||
* @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);
|
||||
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,
|
||||
|
@ -116,34 +84,69 @@ export default class SearchSelectWithModal extends Component {
|
|||
};
|
||||
});
|
||||
}
|
||||
this.allOptions = options;
|
||||
|
||||
@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));
|
||||
}
|
||||
} 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
|
||||
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();
|
||||
if (this.args.models?.some((model) => model.includes('policy'))) {
|
||||
term = term.toLowerCase();
|
||||
}
|
||||
this.addCreateOption(term, results);
|
||||
return results;
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
{{/if}}
|
||||
{{#each this.options as |op|}}
|
||||
<option
|
||||
disabled={{or op.isDisabled false}}
|
||||
value={{or (get op this.valueAttribute) op}}
|
||||
selected={{eq this.selectedValue (or (get op this.valueAttribute) op)}}
|
||||
>
|
||||
|
|
|
@ -207,7 +207,7 @@ module('Acceptance | oidc-config clients and assignments', function (hooks) {
|
|||
});
|
||||
|
||||
test('it creates, updates, and deletes an assignment', async function (assert) {
|
||||
assert.expect(12);
|
||||
assert.expect(14);
|
||||
await visit(OIDC_BASE_URL + '/assignments');
|
||||
|
||||
//* ensure clean test state
|
||||
|
@ -220,6 +220,7 @@ module('Acceptance | oidc-config clients and assignments', function (hooks) {
|
|||
'vault.cluster.access.oidc.assignments.create',
|
||||
'navigates to create form'
|
||||
);
|
||||
assert.dom('[data-test-oidc-assignment-title]').hasText('Create assignment', 'Form title renders');
|
||||
await fillIn('[data-test-input="name"]', 'test-assignment');
|
||||
await click('[data-test-component="search-select"]#entities .ember-basic-dropdown-trigger');
|
||||
await click('.ember-power-select-option');
|
||||
|
@ -246,6 +247,7 @@ module('Acceptance | oidc-config clients and assignments', function (hooks) {
|
|||
'vault.cluster.access.oidc.assignments.assignment.edit',
|
||||
'navigates to the assignment edit page from details'
|
||||
);
|
||||
assert.dom('[data-test-oidc-assignment-title]').hasText('Edit assignment', 'Form title renders');
|
||||
await click('[data-test-component="search-select"]#groups .ember-basic-dropdown-trigger');
|
||||
await click('.ember-power-select-option');
|
||||
assert.dom('[data-test-oidc-assignment-save]').hasText('Update');
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { click, fillIn, find, findAll, currentURL, waitUntil } from '@ember/test-helpers';
|
||||
import { click, fillIn, find, currentURL, waitUntil } from '@ember/test-helpers';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import page from 'vault/tests/pages/policies/index';
|
||||
|
@ -23,8 +23,9 @@ module('Acceptance | policies (old)', function (hooks) {
|
|||
|
||||
await fillIn('[data-test-policy-input="name"]', policyName);
|
||||
await click('[data-test-policy-save]');
|
||||
const errors = await waitUntil(() => findAll('[data-test-error]'));
|
||||
assert.strictEqual(errors.length, 1, 'renders error messages on save');
|
||||
assert
|
||||
.dom('[data-test-error]')
|
||||
.hasText(`Error 'policy' parameter not supplied or empty`, 'renders error message on save');
|
||||
find('.CodeMirror').CodeMirror.setValue(policyString);
|
||||
await click('[data-test-policy-save]');
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ module('Integration | Component | oidc/assignment-form', function (hooks) {
|
|||
});
|
||||
|
||||
test('it should save new assignment', async function (assert) {
|
||||
assert.expect(6);
|
||||
assert.expect(5);
|
||||
this.model = this.store.createRecord('oidc/assignment');
|
||||
this.server.post('/identity/oidc/assignment/test', (schema, req) => {
|
||||
assert.ok(true, 'Request made to save assignment');
|
||||
|
@ -40,7 +40,6 @@ module('Integration | Component | oidc/assignment-form', function (hooks) {
|
|||
/>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-oidc-assignment-title]').hasText('Create assignment', 'Form title renders');
|
||||
assert.dom('[data-test-oidc-assignment-save]').hasText('Create', 'Save button has correct label');
|
||||
await click('[data-test-oidc-assignment-save]');
|
||||
assert
|
||||
|
@ -58,7 +57,7 @@ module('Integration | Component | oidc/assignment-form', function (hooks) {
|
|||
});
|
||||
|
||||
test('it should populate fields with model data on edit view and update an assignment', async function (assert) {
|
||||
assert.expect(6);
|
||||
assert.expect(5);
|
||||
|
||||
this.store.pushPayload('oidc/assignment', {
|
||||
modelName: 'oidc/assignment',
|
||||
|
@ -76,15 +75,14 @@ module('Integration | Component | oidc/assignment-form', function (hooks) {
|
|||
/>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-oidc-assignment-title]').hasText('Edit assignment', 'Form title renders');
|
||||
assert.dom('[data-test-oidc-assignment-save]').hasText('Update', 'Save button has correct label');
|
||||
assert.dom('[data-test-input="name"]').isDisabled('Name input is disabled when editing');
|
||||
assert.dom('[data-test-input="name"]').hasValue('test', 'Name input is populated with model value');
|
||||
assert
|
||||
.dom('[data-test-search-select="entities"] [data-test-smaller-id="true"]')
|
||||
.dom('[data-test-search-select="entities"] [data-test-smaller-id]')
|
||||
.hasText('1234-12345', 'entity id renders in selected option');
|
||||
assert
|
||||
.dom('[data-test-search-select="groups"] [data-test-smaller-id="true"]')
|
||||
.dom('[data-test-search-select="groups"] [data-test-smaller-id]')
|
||||
.hasText('abcdef-123', 'group id renders in selected option');
|
||||
});
|
||||
|
||||
|
|
|
@ -192,7 +192,7 @@ module('Integration | Component | oidc/client-form', function (hooks) {
|
|||
});
|
||||
|
||||
test('it should show create assignment modal', async function (assert) {
|
||||
assert.expect(2);
|
||||
assert.expect(3);
|
||||
this.model = this.store.createRecord('oidc/client');
|
||||
|
||||
await render(hbs`
|
||||
|
@ -207,9 +207,10 @@ module('Integration | Component | oidc/client-form', function (hooks) {
|
|||
await clickTrigger();
|
||||
await fillIn('.ember-power-select-search input', 'test-new');
|
||||
await searchSelect.options.objectAt(0).click();
|
||||
assert.dom('[data-test-modal-div]').hasClass('is-active', 'modal with form opens');
|
||||
assert.dom('[data-test-modal-title]').hasText('Create new assignment', 'Create assignment modal renders');
|
||||
await click(SELECTORS.assignmentCancelButton);
|
||||
assert.dom('[data-test-modal-div]').doesNotExist('Modal disappears after clicking cancel');
|
||||
assert.dom('[data-test-modal-div]').doesNotExist('modal disappears onCancel');
|
||||
});
|
||||
|
||||
test('it should render fallback for search select', async function (assert) {
|
||||
|
|
|
@ -0,0 +1,204 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import Sinon from 'sinon';
|
||||
import Pretender from 'pretender';
|
||||
|
||||
const SELECTORS = {
|
||||
nameInput: '[data-test-policy-input="name"]',
|
||||
uploadFileToggle: '[data-test-policy-edit-toggle]',
|
||||
policyEditor: '[data-test-policy-editor]',
|
||||
policyUpload: '[data-test-text-file-input]',
|
||||
saveButton: '[data-test-policy-save]',
|
||||
cancelButton: '[data-test-policy-cancel]',
|
||||
error: '[data-test-error]',
|
||||
};
|
||||
|
||||
module('Integration | Component | policy-form', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.model = this.store.createRecord('policy/acl');
|
||||
this.onSave = Sinon.spy();
|
||||
this.onCancel = Sinon.spy();
|
||||
this.server = new Pretender(function () {
|
||||
this.put('/v1/sys/policies/acl/bad-policy', () => {
|
||||
return [
|
||||
400,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
JSON.stringify({ errors: ['An error occurred'] }),
|
||||
];
|
||||
});
|
||||
this.put('/v1/sys/policies/acl/**', () => {
|
||||
return [204, { 'Content-Type': 'application/json' }];
|
||||
});
|
||||
this.put('/v1/sys/policies/rgp/**', () => {
|
||||
return [204, { 'Content-Type': 'application/json' }];
|
||||
});
|
||||
});
|
||||
});
|
||||
hooks.afterEach(function () {
|
||||
this.server.shutdown();
|
||||
});
|
||||
|
||||
test('it renders the form for new ACL policy', async function (assert) {
|
||||
const saveSpy = Sinon.spy();
|
||||
const model = this.store.createRecord('policy/acl');
|
||||
const policy = `
|
||||
path "secret/*" {
|
||||
capabilities = [ "create", "read", "update", "list" ]
|
||||
}
|
||||
`;
|
||||
this.set('model', model);
|
||||
this.set('onSave', saveSpy);
|
||||
await render(hbs`
|
||||
<PolicyForm
|
||||
@model={{this.model}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onSave={{this.onSave}}
|
||||
/>
|
||||
`);
|
||||
assert.dom(SELECTORS.nameInput).exists({ count: 1 }, 'Name input exists');
|
||||
assert.dom(SELECTORS.nameInput).hasNoText('Name field is not filled');
|
||||
assert.dom(SELECTORS.uploadFileToggle).exists({ count: 1 }, 'Upload file toggle exists');
|
||||
await fillIn(SELECTORS.nameInput, 'Foo');
|
||||
assert.strictEqual(this.model.name, 'foo', 'Input sets name on model to lowercase input');
|
||||
await fillIn(`${SELECTORS.policyEditor} textarea`, policy);
|
||||
assert.strictEqual(this.model.policy, policy, 'Policy editor sets policy on model');
|
||||
assert.ok(saveSpy.notCalled);
|
||||
assert.dom(SELECTORS.saveButton).hasText('Create policy');
|
||||
await click(SELECTORS.saveButton);
|
||||
assert.ok(saveSpy.calledOnceWith(this.model));
|
||||
});
|
||||
|
||||
test('it renders the form for new RGP policy', async function (assert) {
|
||||
const saveSpy = Sinon.spy();
|
||||
const model = this.store.createRecord('policy/rgp');
|
||||
const policy = `
|
||||
path "secret/*" {
|
||||
capabilities = [ "create", "read", "update", "list" ]
|
||||
}
|
||||
`;
|
||||
this.set('model', model);
|
||||
this.set('onSave', saveSpy);
|
||||
await render(hbs`
|
||||
<PolicyForm
|
||||
@model={{this.model}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onSave={{this.onSave}}
|
||||
/>
|
||||
`);
|
||||
assert.dom(SELECTORS.nameInput).exists({ count: 1 }, 'Name input exists');
|
||||
assert.dom(SELECTORS.nameInput).hasNoText('Name field is not filled');
|
||||
assert.dom(SELECTORS.uploadFileToggle).exists({ count: 1 }, 'Upload file toggle exists');
|
||||
await fillIn(SELECTORS.nameInput, 'Foo');
|
||||
assert.strictEqual(this.model.name, 'foo', 'Input sets name on model to lowercase input');
|
||||
await fillIn(`${SELECTORS.policyEditor} textarea`, policy);
|
||||
assert.strictEqual(this.model.policy, policy, 'Policy editor sets policy on model');
|
||||
assert.ok(saveSpy.notCalled);
|
||||
assert.dom(SELECTORS.saveButton).hasText('Create policy');
|
||||
await click(SELECTORS.saveButton);
|
||||
assert.ok(saveSpy.calledOnceWith(this.model));
|
||||
});
|
||||
|
||||
test('it toggles upload on new policy', async function (assert) {
|
||||
await render(hbs`
|
||||
<PolicyForm
|
||||
@model={{this.model}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onSave={{this.onSave}}
|
||||
/>
|
||||
`);
|
||||
assert.dom(SELECTORS.uploadFileToggle).exists({ count: 1 }, 'Upload file toggle exists');
|
||||
assert.dom(SELECTORS.policyEditor).exists({ count: 1 }, 'Policy editor is shown');
|
||||
assert.dom(SELECTORS.policyUpload).doesNotExist('Policy upload is not shown');
|
||||
await click(SELECTORS.uploadFileToggle);
|
||||
assert.dom(SELECTORS.policyUpload).exists({ count: 1 }, 'Policy upload is shown after toggle');
|
||||
assert.dom(SELECTORS.policyEditor).doesNotExist('Policy editor is not shown');
|
||||
});
|
||||
|
||||
test('it renders the form to edit existing ACL policy', async function (assert) {
|
||||
const saveSpy = Sinon.spy();
|
||||
const model = this.store.createRecord('policy/acl', {
|
||||
name: 'bar',
|
||||
policy: 'some policy content',
|
||||
});
|
||||
model.save();
|
||||
|
||||
this.set('model', model);
|
||||
this.set('onSave', saveSpy);
|
||||
await render(hbs`
|
||||
<PolicyForm
|
||||
@model={{this.model}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onSave={{this.onSave}}
|
||||
/>
|
||||
`);
|
||||
assert.dom(SELECTORS.nameInput).doesNotExist('Name input is not rendered');
|
||||
assert.dom(SELECTORS.uploadFileToggle).doesNotExist('Upload file toggle does not exist');
|
||||
|
||||
await fillIn(`${SELECTORS.policyEditor} textarea`, 'updated-');
|
||||
assert.strictEqual(
|
||||
this.model.policy,
|
||||
'updated-some policy content',
|
||||
'Policy editor updates policy value on model'
|
||||
);
|
||||
assert.ok(saveSpy.notCalled);
|
||||
assert.dom(SELECTORS.saveButton).hasText('Save', 'Save button text is correct');
|
||||
await click(SELECTORS.saveButton);
|
||||
assert.ok(saveSpy.calledOnceWith(this.model));
|
||||
});
|
||||
test('it renders the form to edit existing RGP policy', async function (assert) {
|
||||
const saveSpy = Sinon.spy();
|
||||
const model = this.store.createRecord('policy/rgp', {
|
||||
name: 'bar',
|
||||
policy: 'some policy content',
|
||||
});
|
||||
model.save();
|
||||
|
||||
this.set('model', model);
|
||||
this.set('onSave', saveSpy);
|
||||
await render(hbs`
|
||||
<PolicyForm
|
||||
@model={{this.model}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onSave={{this.onSave}}
|
||||
/>
|
||||
`);
|
||||
assert.dom(SELECTORS.nameInput).doesNotExist('Name input is not rendered');
|
||||
assert.dom(SELECTORS.uploadFileToggle).doesNotExist('Upload file toggle does not exist');
|
||||
|
||||
await fillIn(`${SELECTORS.policyEditor} textarea`, 'updated-');
|
||||
assert.strictEqual(
|
||||
this.model.policy,
|
||||
'updated-some policy content',
|
||||
'Policy editor updates policy value on model'
|
||||
);
|
||||
assert.ok(saveSpy.notCalled);
|
||||
assert.dom(SELECTORS.saveButton).hasText('Save', 'Save button text is correct');
|
||||
await click(SELECTORS.saveButton);
|
||||
assert.ok(saveSpy.calledOnceWith(this.model));
|
||||
});
|
||||
test('it shows the error message on form when save fails', async function (assert) {
|
||||
const saveSpy = Sinon.spy();
|
||||
const model = this.store.createRecord('policy/acl', {
|
||||
name: 'bad-policy',
|
||||
policy: 'some policy content',
|
||||
});
|
||||
|
||||
this.set('model', model);
|
||||
this.set('onSave', saveSpy);
|
||||
await render(hbs`
|
||||
<PolicyForm
|
||||
@model={{this.model}}
|
||||
@onCancel={{this.onCancel}}
|
||||
@onSave={{this.onSave}}
|
||||
/>
|
||||
`);
|
||||
await click(SELECTORS.saveButton);
|
||||
assert.ok(saveSpy.notCalled);
|
||||
assert.dom(SELECTORS.error).includesText('An error occurred');
|
||||
});
|
||||
});
|
|
@ -4,9 +4,10 @@ import { create } from 'ember-cli-page-object';
|
|||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { Response } from 'miragejs';
|
||||
import { clickTrigger, typeInSearch } from 'ember-power-select/test-support/helpers';
|
||||
import { render, fillIn, click } from '@ember/test-helpers';
|
||||
import { render, fillIn, click, findAll } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import ss from 'vault/tests/pages/components/search-select';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const component = create(ss);
|
||||
|
||||
|
@ -14,128 +15,196 @@ module('Integration | Component | search select with modal', function (hooks) {
|
|||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
hooks.beforeEach(function () {
|
||||
this.server.get('identity/entity/id', () => {
|
||||
return {
|
||||
request_id: 'entity-list-id',
|
||||
data: {
|
||||
key_info: {
|
||||
'entity-1-id': {
|
||||
name: 'entity-1',
|
||||
},
|
||||
'entity-2-id': {
|
||||
name: 'entity-2',
|
||||
},
|
||||
},
|
||||
keys: ['entity-1-id', 'entity-2-id'],
|
||||
},
|
||||
};
|
||||
});
|
||||
this.set('onChange', sinon.spy());
|
||||
this.server.get('sys/policies/acl', () => {
|
||||
return {
|
||||
request_id: 'acl-policy-list-id',
|
||||
request_id: 'acl-policy-list',
|
||||
data: {
|
||||
keys: ['default', 'root'],
|
||||
keys: ['default', 'root', 'acl-test'],
|
||||
},
|
||||
};
|
||||
});
|
||||
this.server.get('sys/policies/rgp', () => {
|
||||
return {
|
||||
request_id: 'rgp-policy-list-id',
|
||||
request_id: 'rgp-policy-list',
|
||||
data: {
|
||||
keys: ['default', 'root'],
|
||||
keys: ['rgp-test'],
|
||||
},
|
||||
};
|
||||
});
|
||||
this.server.get('/identity/entity/id/entity-1-id', () => {
|
||||
this.server.get('/sys/policies/acl/acl-test', () => {
|
||||
return {
|
||||
request_id: 'some-entity-id-1',
|
||||
request_id: 'policy-acl',
|
||||
data: {
|
||||
id: 'entity-1-id',
|
||||
name: 'entity-1',
|
||||
namespace_id: 'root',
|
||||
policies: ['default'],
|
||||
name: 'acl-test',
|
||||
policy:
|
||||
'\n# Grant \'create\', \'read\' , \'update\', and ‘list’ permission\n# to paths prefixed by \'secret/*\'\npath "secret/*" {\n capabilities = [ "create", "read", "update", "list" ]\n}\n\n# Even though we allowed secret/*, this line explicitly denies\n# secret/super-secret. This takes precedence.\npath "secret/super-secret" {\n capabilities = ["deny"]\n}\n',
|
||||
},
|
||||
};
|
||||
});
|
||||
this.server.get('/identity/entity/id/entity-2-id', () => {
|
||||
this.server.get('/sys/policies/rgp/rgp-test', () => {
|
||||
return {
|
||||
request_id: 'some-entity-id-2',
|
||||
request_id: 'policy-rgp',
|
||||
data: {
|
||||
id: 'entity-2-id',
|
||||
name: 'entity-2',
|
||||
namespace_id: 'root',
|
||||
policies: ['default'],
|
||||
name: 'rgp-test',
|
||||
enforcement_level: 'hard-mandatory',
|
||||
policy:
|
||||
'\n# Import strings library that exposes common string operations\nimport "strings"\n\n# Conditional rule (precond) checks the incoming request endpoint\n# targeted to sys/policies/acl/admin\nprecond = rule {\n strings.has_prefix(request.path, "sys/policies/admin")\n}\n\n# Vault checks to see if the request was made by an entity\n# named James Thomas or Team Lead role defined as its metadata\nmain = rule when precond {\n identity.entity.metadata.role is "Team Lead" or\n identity.entity.name is "James Thomas"\n}\n',
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders passed in model', async function (assert) {
|
||||
test('it renders passed in models', async function (assert) {
|
||||
await render(hbs`
|
||||
<SearchSelectWithModal
|
||||
@id="entity"
|
||||
@label="Entity ID"
|
||||
@subText="Search for an existing entity, or type a new name to create it."
|
||||
@model="identity/entity"
|
||||
@id="policies"
|
||||
@label="Policies"
|
||||
@labelClass="title is-4"
|
||||
@models={{array "policy/acl" "policy/rgp"}}
|
||||
@inputValue={{this.policies}}
|
||||
@onChange={{this.onChange}}
|
||||
@fallbackComponent="string-list"
|
||||
@modalFormComponent="identity/edit-form"
|
||||
@modalSubtext="Some modal subtext"
|
||||
@modalFormTemplate="modal-form/policy-template"
|
||||
@excludeOptions={{array "root"}}
|
||||
@subText="Some modal subtext"
|
||||
/>
|
||||
<div id="modal-wormhole"></div>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-search-select-with-modal]').exists('the component renders');
|
||||
assert.strictEqual(component.labelText, 'Entity ID', 'label text is correct');
|
||||
assert.dom('[data-test-modal-subtext]').hasText('Some modal subtext', 'renders modal text');
|
||||
assert.strictEqual(component.labelText, 'Policies', 'label text is correct');
|
||||
assert.ok(component.hasTrigger, 'it renders the power select trigger');
|
||||
assert.strictEqual(component.selectedOptions.length, 0, 'there are no selected options');
|
||||
|
||||
await clickTrigger();
|
||||
assert.strictEqual(component.options.length, 2, 'dropdown renders passed in models as options');
|
||||
const dropdownOptions = findAll('[data-option-index]').map((o) => o.innerText);
|
||||
assert.notOk(dropdownOptions.includes('root'), 'root policy is not listed as option');
|
||||
assert.strictEqual(component.options.length, 3, 'dropdown renders passed in models as options');
|
||||
assert.ok(this.onChange.notCalled, 'onChange is not called');
|
||||
});
|
||||
|
||||
test('it filters options and adds option to create new item', async function (assert) {
|
||||
test('it renders input value', async function (assert) {
|
||||
this.policies = ['acl-test'];
|
||||
await render(hbs`
|
||||
<SearchSelectWithModal
|
||||
@id="policies"
|
||||
@label="Policies"
|
||||
@labelClass="title is-4"
|
||||
@models={{array "policy/acl" "policy/rgp"}}
|
||||
@inputValue={{this.policies}}
|
||||
@onChange={{this.onChange}}
|
||||
@fallbackComponent="string-list"
|
||||
@modalFormTemplate="modal-form/policy-template"
|
||||
@subText="Some modal subtext"
|
||||
/>
|
||||
`);
|
||||
assert.strictEqual(component.selectedOptions.length, 1, 'there is one selected option');
|
||||
assert.strictEqual(component.selectedOptions.objectAt(0).text, 'acl-test', 'renders inputted policies');
|
||||
|
||||
await clickTrigger();
|
||||
assert.strictEqual(component.options.length, 3, 'does not render all options returned from query');
|
||||
const dropdownOptions = findAll('[data-option-index]').map((o) => o.innerText);
|
||||
assert.notOk(dropdownOptions.includes('acl-test'), 'selected option is not included in the dropdown');
|
||||
assert.ok(this.onChange.notCalled, 'onChange is not called');
|
||||
});
|
||||
|
||||
test('it filters options, shows option to create new item and opens modal on select', async function (assert) {
|
||||
assert.expect(7);
|
||||
await render(hbs`
|
||||
<SearchSelectWithModal
|
||||
@id="entity"
|
||||
@label="entity"
|
||||
@subText="Search for an existing entity, or type a new name to create it."
|
||||
@model="identity/entity"
|
||||
@id="policies"
|
||||
@label="Policies"
|
||||
@labelClass="title is-4"
|
||||
@models={{array "policy/acl" "policy/rgp"}}
|
||||
@inputValue={{this.policies}}
|
||||
@onChange={{this.onChange}}
|
||||
@fallbackComponent="string-list"
|
||||
@modalFormComponent="identity/edit-form"
|
||||
@modalSubtext="Some modal subtext"
|
||||
@modalFormTemplate="modal-form/policy-template"
|
||||
/>
|
||||
<div id="modal-wormhole"></div>
|
||||
`);
|
||||
|
||||
await clickTrigger();
|
||||
assert.strictEqual(component.options.length, 2, 'dropdown renders all options');
|
||||
assert.strictEqual(component.options.length, 4, 'dropdown renders all options');
|
||||
|
||||
await typeInSearch('e');
|
||||
assert.strictEqual(component.options.length, 3, 'dropdown renders all options plus add option');
|
||||
await typeInSearch('a');
|
||||
assert.strictEqual(component.options.length, 3, 'dropdown renders all matching options plus add option');
|
||||
await typeInSearch('acl-test');
|
||||
assert.strictEqual(component.options[0].text, 'acl-test', 'dropdown renders only matching option');
|
||||
|
||||
await typeInSearch('entity-1');
|
||||
assert.strictEqual(component.options[0].text, 'entity-1-id', 'dropdown renders only matching option');
|
||||
|
||||
await typeInSearch('entity-1-new');
|
||||
await typeInSearch('acl-test-new');
|
||||
assert.strictEqual(
|
||||
component.options[0].text,
|
||||
'Click to create new entity: entity-1-new',
|
||||
'No results found for "acl-test-new". Click here to create it.',
|
||||
'dropdown gives option to create new option'
|
||||
);
|
||||
|
||||
await component.selectOption();
|
||||
|
||||
assert.dom('[data-test-modal-div]').hasAttribute('class', 'modal is-info is-active', 'modal is active');
|
||||
assert.dom('[data-test-modal-subtext]').hasText('Some modal subtext', 'renders modal text');
|
||||
assert.dom('[data-test-component="identity-edit-form"]').exists('renders identity form');
|
||||
assert.dom('[data-test-empty-state-title]').hasText('No policy type selected');
|
||||
assert.ok(this.onChange.notCalled, 'onChange is not called');
|
||||
});
|
||||
|
||||
test('it renders fallback component', async function (assert) {
|
||||
assert.expect(7);
|
||||
this.onChange = () => assert.ok(true, 'onChange callback fires');
|
||||
this.server.get('identity/entity/id', () => {
|
||||
test('it renders policy template and selects policy type', async function (assert) {
|
||||
assert.expect(9);
|
||||
this.server.put('/sys/policies/acl/acl-test-new', async (schema, req) => {
|
||||
const requestBody = JSON.parse(req.requestBody);
|
||||
assert.propEqual(
|
||||
requestBody,
|
||||
{
|
||||
name: 'acl-test-new',
|
||||
policy: 'path "secret/super-secret" { capabilities = ["deny"] }',
|
||||
},
|
||||
'onSave sends request to endpoint with correct policy attributes'
|
||||
);
|
||||
});
|
||||
await render(hbs`
|
||||
<SearchSelectWithModal
|
||||
@id="policies"
|
||||
@label="Policies"
|
||||
@labelClass="title is-4"
|
||||
@models={{array "policy/acl" "policy/rgp"}}
|
||||
@inputValue={{this.policies}}
|
||||
@onChange={{this.onChange}}
|
||||
@fallbackComponent="string-list"
|
||||
@modalFormTemplate="modal-form/policy-template"
|
||||
/>
|
||||
<div id="modal-wormhole"></div>
|
||||
`);
|
||||
await clickTrigger();
|
||||
await typeInSearch('acl-test-new');
|
||||
assert.strictEqual(
|
||||
component.options[0].text,
|
||||
'No results found for "acl-test-new". Click here to create it.',
|
||||
'dropdown gives option to create new option'
|
||||
);
|
||||
await component.selectOption();
|
||||
assert.dom('[data-test-empty-state-title]').hasText('No policy type selected');
|
||||
await fillIn('[data-test-select="policyType"]', 'acl');
|
||||
assert.dom('[data-test-policy-form]').exists('policy form renders after type is selected');
|
||||
await click('[data-test-tab-example-policy]');
|
||||
assert.dom('[data-test-tab-example-policy]').hasClass('is-active');
|
||||
await click('[data-test-tab-your-policy]');
|
||||
assert.dom('[data-test-tab-your-policy]').hasClass('is-active');
|
||||
await fillIn(
|
||||
'[data-test-component="code-mirror-modifier"] textarea',
|
||||
'path "secret/super-secret" { capabilities = ["deny"] }'
|
||||
);
|
||||
await click('[data-test-policy-save]');
|
||||
assert.dom('[data-test-modal-div]').doesNotExist('modal closes after save');
|
||||
assert
|
||||
.dom('[data-test-selected-option="0"]')
|
||||
.hasText('acl-test-new', 'adds newly created policy to selected options');
|
||||
assert.ok(
|
||||
this.onChange.calledWithExactly(['acl-test-new']),
|
||||
'onChange is called only after item is created'
|
||||
);
|
||||
});
|
||||
|
||||
test('it still renders search select if only second model returns 403', async function (assert) {
|
||||
assert.expect(4);
|
||||
this.server.get('sys/policies/rgp', () => {
|
||||
return new Response(
|
||||
403,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
|
@ -145,25 +214,62 @@ module('Integration | Component | search select with modal', function (hooks) {
|
|||
|
||||
await render(hbs`
|
||||
<SearchSelectWithModal
|
||||
@id="entity"
|
||||
@label="Entity ID"
|
||||
@subText="Search for an existing entity, or type a new name to create it."
|
||||
@model="identity/entity"
|
||||
@id="policies"
|
||||
@label="Policies"
|
||||
@labelClass="title is-4"
|
||||
@models={{array "policy/acl" "policy/rgp"}}
|
||||
@inputValue={{this.policies}}
|
||||
@onChange={{this.onChange}}
|
||||
@fallbackComponent="string-list"
|
||||
@modalFormComponent="identity/edit-form"
|
||||
@modalSubtext="Some modal subtext"
|
||||
@modalFormTemplate="modal-form/policy-template"
|
||||
/>
|
||||
<div id="modal-wormhole"></div>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-search-select-with-modal]').exists('the component renders');
|
||||
assert.dom('[data-test-component="string-list"]').doesNotExist('does not render fallback component');
|
||||
await clickTrigger();
|
||||
assert.strictEqual(component.options.length, 3, 'only options from successful query render');
|
||||
assert.ok(this.onChange.notCalled, 'onChange is not called');
|
||||
});
|
||||
|
||||
test('it renders fallback component if both models return 403', async function (assert) {
|
||||
assert.expect(7);
|
||||
this.server.get('sys/policies/acl', () => {
|
||||
return new Response(
|
||||
403,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
JSON.stringify({ errors: ['permission denied'] })
|
||||
);
|
||||
});
|
||||
this.server.get('sys/policies/rgp', () => {
|
||||
return new Response(
|
||||
403,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
JSON.stringify({ errors: ['permission denied'] })
|
||||
);
|
||||
});
|
||||
|
||||
await render(hbs`
|
||||
<SearchSelectWithModal
|
||||
@id="policies"
|
||||
@label="Policies"
|
||||
@labelClass="title is-4"
|
||||
@models={{array "policy/acl" "policy/rgp"}}
|
||||
@inputValue={{this.policies}}
|
||||
@onChange={{this.onChange}}
|
||||
@fallbackComponent="string-list"
|
||||
@modalFormTemplate="modal-form/policy-template"
|
||||
/>
|
||||
<div id="modal-wormhole"></div>
|
||||
`);
|
||||
assert.dom('[data-test-component="string-list"]').exists('renders fallback component');
|
||||
assert.false(component.hasTrigger, 'does not render power select trigger');
|
||||
await fillIn('[data-test-string-list-input="0"]', 'some-entity');
|
||||
await fillIn('[data-test-string-list-input="0"]', 'string-list-policy');
|
||||
await click('[data-test-string-list-button="add"]');
|
||||
assert
|
||||
.dom('[data-test-string-list-input="0"]')
|
||||
.hasValue('some-entity', 'first row renders inputted string');
|
||||
.hasValue('string-list-policy', 'first row renders inputted string');
|
||||
assert
|
||||
.dom('[data-test-string-list-row="0"] [data-test-string-list-button="delete"]')
|
||||
.exists('first row renders delete icon');
|
||||
|
@ -171,5 +277,9 @@ module('Integration | Component | search select with modal', function (hooks) {
|
|||
assert
|
||||
.dom('[data-test-string-list-row="1"] [data-test-string-list-button="add"]')
|
||||
.exists('second row renders add icon');
|
||||
assert.ok(
|
||||
this.onChange.calledWithExactly(['string-list-policy']),
|
||||
'onChange is called only after item is created'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue