UI - kmip role edit form (#6973)

* extend edit form with a custom kmip role form

* adjust model fields and use new kmip role edit form

* customize serialize adapter hook for kmip/role

* refresh list routes in the list mixin

* style up kmip role edit form

* return a promise from preSave so that the queue helper waits to call save

* add serialize tests for the kmip/role adapter

* rename component to edit-form-kmip-role

* add tests for edit-form-kmip-role

* add some clarifying comments

* make input more realistic in tests

* remove delete toolbar
This commit is contained in:
Matthew Irish 2019-06-25 15:57:50 -05:00 committed by GitHub
parent 19b1a30071
commit deb137ebf0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 420 additions and 15 deletions

View File

@ -11,7 +11,7 @@ export default BaseAdapter.extend({
},
name
);
return this.ajax(url, 'POST', { data: snapshot.serialize() }).then(() => {
return this.ajax(url, 'POST', { data: this.serialize(snapshot) }).then(() => {
return {
id: name,
name,
@ -19,6 +19,21 @@ export default BaseAdapter.extend({
});
},
serialize(snapshot) {
// the endpoint here won't allow sending `operation_all` and `operation_none` at the same time or with
// other values, so we manually check for them and send an abbreviated object
let json = snapshot.serialize();
if (json.operation_all) {
return { operation_all: true };
}
if (json.operation_none) {
return { operation_none: true };
}
delete json.operation_none;
delete json.operation_all;
return json;
},
updateRecord() {
return this.createRecord(...arguments);
},

View File

@ -1,6 +1,7 @@
import DS from 'ember-data';
import fieldToAttrs from 'vault/utils/field-to-attrs';
import { computed } from '@ember/object';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import fieldToAttrs from 'vault/utils/field-to-attrs';
const { attr } = DS;
export default DS.Model.extend({
@ -11,18 +12,15 @@ export default DS.Model.extend({
return `/v1/${path}/scope/example/role/example?help=1`;
},
name: attr('string'),
allowedOperations: attr(),
name: attr({ readOnly: true }),
fieldGroups: computed(function() {
let fields = this.newFields.without('role');
const groups = [
{
default: ['name'],
},
{ 'Allowed Operations': fields },
];
const groups = [{ 'Allowed Operations': fields }];
return fieldToAttrs(this, groups);
}),
fields: computed(function() {
let fields = this.newFields.removeObjects(['role', 'operationAll', 'operationNone']);
return expandAttributeMeta(this, fields);
}),
});

View File

@ -0,0 +1,39 @@
.vlt-radio {
position: relative;
input[type='radio'] {
position: absolute;
z-index: 1;
opacity: 0;
}
input[type='radio'] + label {
content: '';
border: 1px solid $grey-light;
border-radius: 50%;
cursor: pointer;
display: inline-block;
margin: 0.25rem 0;
height: 1rem;
width: 1rem;
flex-shrink: 0;
flex-grow: 0;
position: relative;
left: 0;
top: 0.3rem;
}
input[type='radio']:checked + label {
content: '';
background: $blue;
border: 1px solid $blue;
box-shadow: inset 0 0 0 0.15rem $white;
position: relative;
left: 0;
}
input[type='radio']:focus + label {
content: '';
box-shadow: 0 0 10px 1px rgba($blue, 0.4), inset 0 0 0 0.15rem $white;
position: relative;
left: 0;
}
}

View File

@ -85,6 +85,7 @@
@import './components/unseal-warning';
@import './components/ui-wizard';
@import './components/vault-loading';
@import './components/vlt-radio';
// bulma-free-zone
@import './components/hs-icon';

View File

@ -27,4 +27,17 @@ export default Mixin.create({
controller.set('filter', null);
}
},
actions: {
willTransition(transition) {
window.scrollTo(0, 0);
if (transition.targetName !== this.routeName) {
this.store.clearAllDatasets();
}
return true;
},
reload() {
this.store.clearAllDatasets();
this.refresh();
},
},
});

View File

@ -0,0 +1,55 @@
import EditForm from 'core/components/edit-form';
import layout from '../templates/components/edit-form-kmip-role';
import { Promise } from 'rsvp';
export default EditForm.extend({
layout,
display: null,
init() {
this._super(...arguments);
let display = 'operationAll';
if (this.model.operationNone) {
display = 'operationNone';
}
if (!this.model.isNew && !this.model.operationNone && !this.model.operationAll) {
display = 'choose';
}
this.set('display', display);
},
actions: {
updateModel(val) {
// here we only want to toggle operation(None|All) because we don't want to clear the other options in
// the case where the user clicks back to "choose" before saving
if (val === 'operationAll') {
this.model.set('operationNone', false);
this.model.set('operationAll', true);
}
if (val === 'operationNone') {
this.model.set('operationNone', true);
this.model.set('operationAll', false);
}
},
preSave(model) {
let { display } = this;
return new Promise(function(resolve) {
if (display === 'choose') {
model.set('operationNone', null);
model.set('operationAll', null);
return resolve(model);
}
model.newFields.without('role').forEach(field => {
// this will set operationAll or operationNone to true
if (field === display) {
model.set(field, true);
} else {
model.set(field, null);
}
});
return resolve(model);
});
},
},
});

View File

@ -0,0 +1,63 @@
<form {{action (queue (action "preSave" model) (perform save model)) on="submit"}}>
<MessageError @model={{model}} data-test-edit-form-error />
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="save" />
{{#if (eq @mode "create")}}
<FormField
data-test-field
@attr={{hash name="name" type="string"}}
@model={{model}}
/>
{{/if}}
<h3 class="title is-5">
Allowed Operations
</h3>
{{#each (array
(hash label="Allow all" value="operationAll")
(hash label="Allow none" value="operationNone")
(hash label="Let me choose" value="choose")
) as |displayType|}}
<RadioButton
@value={{displayType.value}}
@groupValue={{this.display}}
@changed={{queue
(action (mut this.display))
(action "updateModel")
}}
@name="role-display"
@radioId={{displayType.value}}
@classNames="vlt-radio is-block"
>
<label for={{displayType.value}} />
{{displayType.label}}
</RadioButton>
{{/each}}
{{#if (eq this.display "choose")}}
<div class="box is-sideless is-shadowless is-marginless">
{{#each this.model.fields as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{model}}
/>
{{/each}}
</div>
{{/if}}
</div>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="field is-grouped">
<div class="control">
<button type="submit" data-test-edit-form-submit class="button is-primary {{if save.isRunning 'loading'}}" disabled={{save.isRunning}}>
{{saveButtonText}}
</button>
</div>
{{#if cancelLinkParams}}
<div class="control">
{{#link-to params=cancelLinkParams class="button"}}
Cancel
{{/link-to}}
</div>
{{/if}}
</div>
</div>
</form>

View File

@ -8,6 +8,8 @@
</h1>
</p.levelLeft>
</PageHeader>
<EditForm @model={{model}}
<EditFormKmipRole
@model={{model}}
@onSave={{transition-to "vault.cluster.secrets.backend.kmip.role" this.scope this.role}}
@cancelLinkParams={{array "role" this.scope this.role}} />
@cancelLinkParams={{array "role" this.scope this.role}}
/>

View File

@ -8,7 +8,7 @@
</h1>
</p.levelLeft>
</PageHeader>
<EditForm
<EditFormKmipRole
@model={{model}}
@mode="create"
@onSave={{transition-to "vault.cluster.secrets.backend.kmip.scope.roles" this.scope}}

View File

@ -0,0 +1,160 @@
import { later, run } from '@ember/runloop';
import { resolve } from 'rsvp';
import EmberObject, { computed } from '@ember/object';
import Service from '@ember/service';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, settled, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import engineResolverFor from 'ember-engines/test-support/engine-resolver-for';
const resolver = engineResolverFor('kmip');
const flash = Service.extend({
success: sinon.stub(),
});
const namespace = Service.extend({});
const createModel = options => {
let model = EmberObject.extend({
fields: computed('newFields', function() {
return this.newFields.map(field => ({ name: field, type: 'boolean' }));
}),
/* eslint-disable ember/avoid-leaking-state-in-ember-objects */
newFields: ['operationAll', 'operationNone', 'operationGet', 'operationCreate', 'operationDestroy'],
destroyRecord() {
return resolve();
},
save() {
return resolve();
},
rollbackAttributes() {},
});
return model.create({
...options,
});
};
module('Integration | Component | edit form kmip role', function(hooks) {
setupRenderingTest(hooks, { resolver });
hooks.beforeEach(function() {
run(() => {
this.owner.unregister('service:flash-messages');
this.owner.register('service:flash-messages', flash);
this.owner.register('service:namespace', namespace);
});
});
test('it renders: new model', async function(assert) {
let model = createModel({ isNew: true });
this.set('model', model);
await render(hbs`<EditFormKmipRole @model={{model}} />`);
assert.dom('[name=role-display]:checked').hasValue('operationAll', 'defaults to all on new models');
});
test('it renders: operationAll', async function(assert) {
let model = createModel({ operationAll: true });
this.set('model', model);
await render(hbs`<EditFormKmipRole @model={{model}} />`);
assert.dom('[name=role-display]:checked').hasValue('operationAll', 'sets operationAll');
});
test('it renders: operationNone', async function(assert) {
let model = createModel({ operationNone: true });
this.set('model', model);
await render(hbs`<EditFormKmipRole @model={{model}} />`);
assert.dom('[name=role-display]:checked').hasValue('operationNone', 'sets operationNone');
});
test('it renders: choose operations', async function(assert) {
let model = createModel({ operationGet: true });
this.set('model', model);
await render(hbs`<EditFormKmipRole @model={{model}} />`);
assert.dom('[name=role-display]:checked').hasValue('choose', 'sets choose');
});
let savingTests = [
[
'setting operationAll',
{ operationNone: true, operationGet: true },
'operationAll',
{
operationAll: true,
operationNone: false,
operationGet: true,
},
{
operationGet: null,
operationNone: null,
},
],
[
'setting operationNone',
{ operationAll: true, operationCreate: true },
'operationNone',
{
operationAll: false,
operationNone: true,
operationCreate: true,
},
{
operationNone: true,
operationCreate: null,
operationAll: null,
},
],
[
'setting choose, and selecting an additional item',
{ operationAll: true, operationGet: true, operationCreate: true },
'choose,operationDestroy',
{
operationAll: true,
operationCreate: true,
operationGet: true,
},
{
operationGet: true,
operationCreate: true,
operationDestroy: true,
operationAll: null,
operationNone: null,
},
],
];
for (let testCase of savingTests) {
let [name, initialState, displayClicks, stateBeforeSave, stateAfterSave] = testCase;
test(name, async function(assert) {
let model = createModel(initialState);
this.set('model', model);
let clickTargets = displayClicks.split(',');
await render(hbs`<EditFormKmipRole @model={{model}} />`);
for (let clickTarget of clickTargets) {
await click(`label[for=${clickTarget}]`);
}
for (let beforeStateKey of Object.keys(stateBeforeSave)) {
assert.equal(model.get(beforeStateKey), stateBeforeSave[beforeStateKey], `sets ${beforeStateKey}`);
}
assert.dom('[name=role-display]:checked').hasValue(clickTargets[0], `sets clickTargets[0]`);
click('[data-test-edit-form-submit]');
later(() => run.cancelTimers(), 50);
return settled().then(() => {
for (let afterStateKey of Object.keys(stateAfterSave)) {
assert.equal(
model.get(afterStateKey),
stateAfterSave[afterStateKey],
`sets ${afterStateKey} on save`
);
}
});
});
}
});

View File

@ -0,0 +1,59 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Adapter | kmip/role', function(hooks) {
setupTest(hooks);
let serializeTests = [
[
'operation_all is the only item present after serialization',
{
serialize() {
return { operation_all: true, operation_get: true, operation_create: true };
},
},
{
operation_all: true,
},
],
[
'operation_none is the only item present after serialization',
{
serialize() {
return { operation_none: true, operation_get: true, operation_add_attribute: true };
},
},
{
operation_none: true,
},
],
[
'operation_all and operation_none are removed if not truthy',
{
serialize() {
return {
operation_all: false,
operation_none: false,
operation_get: true,
operation_add_attribute: true,
operation_destroy: true,
};
},
},
{
operation_get: true,
operation_add_attribute: true,
operation_destroy: true,
},
],
];
for (let testCase of serializeTests) {
let [name, snapshotStub, expected] = testCase;
test(`adapter serialize: ${name}`, function(assert) {
let adapter = this.owner.lookup('adapter:kmip/role');
let result = adapter.serialize(snapshotStub);
assert.deepEqual(result, expected, 'output matches expected');
});
}
});