UI kmip scope delete and role form (#7169)

* always use ?force for kmip scope delete

* update the delete message when deleting a scope

* support disabling and not showing help text for checkboxes

* group TLS fields and render new allowed operations widget

* add operation-field-display component for kmip roles

* use operation-field-display component

* switch glyph for false value in info-table-row

* divvy up roles and tls

* fix JSDoc - showHelpText defaults to true

* fix tests and linting

* rename vars in operation-field-display component

* make the action name clearer re: what it's actually doing

* align the allowed-ops header

* show all operations as checked if you check to allow all
This commit is contained in:
Matthew Irish 2019-07-23 16:00:00 -05:00 committed by GitHub
parent 18fa9e418c
commit b0dfbde741
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 248 additions and 105 deletions

View file

@ -14,6 +14,8 @@ export default BaseAdapter.extend({
},
deleteRecord(store, type, snapshot) {
return this.ajax(this._url(type.modelName, { backend: snapshot.record.backend }, snapshot.id), 'DELETE');
let url = this._url(type.modelName, { backend: snapshot.record.backend }, snapshot.id);
url = `${url}?force=true`;
return this.ajax(url, 'DELETE');
},
});

View file

@ -15,8 +15,12 @@ export const COMPUTEDS = {
return this.operationFields.slice().removeObjects(['operationAll', 'operationNone']);
}),
nonOperationFields: computed('operationFields', function() {
let excludeFields = ['role'].concat(this.operationFields);
tlsFields: computed(function() {
return ['tlsClientKeyBits', 'tlsClientKeyType', 'tlsClientTtl'];
}),
nonOperationFields: computed('tlsFields', 'operationFields', function() {
let excludeFields = ['role'].concat(this.operationFields, this.tlsFields);
return this.newFields.slice().removeObjects(excludeFields);
}),
};
@ -29,14 +33,43 @@ const Model = DS.Model.extend(COMPUTEDS, {
getHelpUrl(path) {
return `/v1/${path}/scope/example/role/example?help=1`;
},
fieldGroups: computed('fields', 'nonOperationFields', function() {
const groups = [{ default: this.nonOperationFields }, { 'Allowed Operations': this.operationFields }];
fieldGroups: computed('fields', 'tlsFields', 'nonOperationFields', function() {
const groups = [{ TLS: this.tlsFields }];
if (this.nonOperationFields.length) {
groups.unshift({ default: this.nonOperationFields });
}
let ret = fieldToAttrs(this, groups);
return ret;
}),
operationFormFields: computed('operationFieldsWithoutSpecial', function() {
return expandAttributeMeta(this, this.operationFieldsWithoutSpecial);
let objects = [
'operationCreate',
'operationActivate',
'operationGet',
'operationLocate',
'operationRekey',
'operationRevoke',
'operationDestroy',
];
let attributes = ['operationAddAttribute', 'operationGetAttributes'];
let server = ['operationDiscoverVersion'];
let others = this.operationFieldsWithoutSpecial.slice().removeObjects(objects.concat(attributes, server));
const groups = [
{ 'Managed Cryptographic Objects': objects },
{ 'Object Attributes': attributes },
{ Server: server },
];
if (others.length) {
groups.push({
'': others,
});
}
return fieldToAttrs(this, groups);
}),
tlsFormFields: computed('tlsFields', function() {
return expandAttributeMeta(this, this.tlsFields);
}),
fields: computed('nonOperationFields', function() {
return expandAttributeMeta(this, this.nonOperationFields);

View file

@ -0,0 +1,14 @@
.kmip-allowed-operations-header {
@extend .title;
@extend .is-6;
padding-left: $spacing-s;
}
.kmip-role-allowed-operations {
@extend .box;
flex: 1 1 auto;
box-shadow: none;
padding: 0;
}
.kmip-role-allowed-operations .field {
margin-bottom: $spacing-xxs;
}

View file

@ -60,6 +60,7 @@
@import './components/init-illustration';
@import './components/info-table-row';
@import './components/input-hint';
@import './components/kmip-role-edit';
@import './components/linked-block';
@import './components/list-item-row';
@import './components/list-pagination';

View file

@ -19,6 +19,9 @@ import layout from '../templates/components/form-field';
* @param [onChange=null] {Func} - Called whenever a value on the model changes via the component.
* @param attr=null {Object} - This is usually derived from ember model `attributes` lookup, and all members of `attr.options` are optional.
* @param model=null {DS.Model} - The Ember Data model that `attr` is defined on
* @param [disabled=false] {Boolean} - whether the field is disabled
* @param [showHelpText=true] {Boolean} - whether to show the tooltip with help text from OpenAPI
*
*
*/
@ -26,6 +29,8 @@ export default Component.extend({
layout,
'data-test-field': true,
classNames: ['field'],
disabled: false,
showHelpText: true,
onChange() {},

View file

@ -162,14 +162,14 @@
</div>
{{else if (eq attr.type "boolean")}}
<div class="b-checkbox">
<input type="checkbox" id="{{attr.name}}" class="styled" checked={{get model attr.name}} onchange={{action
<input disabled={{this.disabled}} type="checkbox" id="{{attr.name}}" class="styled" checked={{get model attr.name}} onchange={{action
(action "setAndBroadcast" valuePath)
value="target.checked"
}} data-test-input={{attr.name}} />
<label for="{{attr.name}}" class="is-label">
{{labelString}}
{{#if attr.options.helpText}}
{{#if (and this.showHelpText attr.options.helpText)}}
{{#info-tooltip}}{{attr.options.helpText}}{{/info-tooltip}}
{{/if}}
</label>

View file

@ -18,7 +18,7 @@
aria-hidden="true"
class="icon-false"
@size="l"
@glyph="cancel-circle-outline"
@glyph="cancel-square-outline"
/> No
{{/if}}
{{else}}

View file

@ -1,55 +1,38 @@
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,
model: null,
init() {
this._super(...arguments);
let display = 'operationAll';
if (this.model.operationNone) {
display = 'operationNone';
if (this.model.isNew) {
this.model.set('operationAll', true);
}
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);
}
toggleOperationSpecial(checked) {
this.model.set('operationNone', !checked);
this.model.set('operationAll', checked);
},
// when operationAll is true, we want all of the items
// to appear checked, but we don't want to override what items
// a user has selected - so this action creates an object that we
// pass to the FormField component as the model instead of the real model
placeholderOrModel(isOperationAll, attr) {
return isOperationAll ? { [attr.name]: true } : this.model;
},
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.operationFields.concat(['operationAll', 'operationNone']).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);
});
// if we have operationAll or operationNone, we want to clear
// out the others so that display shows the right data
if (model.operationAll || model.operationNone) {
model.operationFieldsWithoutSpecial.forEach(field => model.set(field, null));
}
},
},
});

View file

@ -0,0 +1,39 @@
/**
* @module OperationFieldDisplay
* OperationFieldDisplay components are used on KMIP role show pages to display the allowed operations on that model
*
* @example
* ```js
* <OperationFieldDisplay @model={{model}} />
* ```
*
* @param model {DS.Model} - model is the KMIP role model that needs to display its allowed operations
*
*/
import Component from '@ember/component';
import layout from '../templates/components/operation-field-display';
export default Component.extend({
layout,
tagName: '',
model: null,
trueOrFalseString(model, field, trueString, falseString) {
if (model.operationAll) {
return trueString;
}
if (model.operationNone) {
return falseString;
}
return model.get(field.name) ? trueString : falseString;
},
actions: {
iconClass(model, field) {
return this.trueOrFalseString(model, field, 'icon-true', 'icon-false');
},
iconGlyph(model, field) {
return this.trueOrFalseString(model, field, 'check-circle-outline', 'cancel-square-outline');
},
},
});

View file

@ -1,6 +1,5 @@
<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">
<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
@ -9,48 +8,86 @@
@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.operationFormFields as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{model}}
/>
{{/each}}
<div class="control is-flex box is-shadowless is-fullwidth is-marginless">
<input
data-test-input="operationNone"
id="operationNone"
type="checkbox"
class="switch is-rounded is-success is-small"
checked={{not this.model.operationNone}}
onchange={{action "toggleOperationSpecial" value="target.checked"}}
/>
<label for="operationNone">
Allow this role to perform KMIP operations
</label>
</div>
{{#if (not this.model.operationNone) }}
<Toolbar>
<h3 class="kmip-allowed-operations-header">
Allowed Operations
</h3>
</Toolbar>
<div class="box">
<FormField
@attr={{hash name="operationAll" type="boolean" options=(hash label="Allow this role to perform all operations")}}
@model={{this.model}}
/>
<hr />
<div class="is-flex">
<div class="kmip-role-allowed-operations">
{{#each-in this.model.operationFormFields.firstObject as |groupName fieldsInGroup|}}
<h4 class="title is-7">{{groupName}}</h4>
{{#each fieldsInGroup as |attr|}}
<FormField
data-test-field
@disabled={{or this.model.operationNone this.model.operationAll}}
@attr={{attr}}
@model={{compute (action "placeholderOrModel") this.model.operationAll attr}}
@showHelpText={{false}}
/>
{{/each}}
{{/each-in}}
</div>
<div class="kmip-role-allowed-operations">
{{#each (drop 1 (or this.model.operationFormFields (array))) as |group|}}
<div class="kmip-role-allowed-operations">
{{#each-in group as |groupName fieldsInGroup|}}
<h4 class="title is-7">{{groupName}}</h4>
{{#each fieldsInGroup as |attr|}}
<FormField
data-test-field
@disabled={{or this.model.operationNone this.model.operationAll}}
@attr={{attr}}
@model={{compute (action "placeholderOrModel") this.model.operationAll attr}}
@showHelpText={{false}}
/>
{{/each}}
{{/each-in}}
</div>
{{/each}}
</div>
</div>
</div>
{{/if}}
{{#each this.model.fields as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{model}}
/>
{{/each}}
<div class="box is-fullwidth is-shadowless">
<h3 class="title is-3">
TLS
</h3>
{{#each this.model.tlsFormFields as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{model}}
/>
{{/each}}
</div>
{{#each this.model.fields as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{model}}
/>
{{/each}}
</div>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="field is-grouped">

View file

@ -0,0 +1,22 @@
{{#if model.operationAll}}
<AlertInline @type="info" @message="This role allows all KMIP operations" class="is-marginless" />
{{/if}}
{{#each @model.operationFormFields as |group|}}
{{#each-in group as |groupName fieldsInGroup|}}
<InfoTableRow @alwaysRender={{true}}
@label={{groupName}}
@value={{true}}
>
<div>
{{#each fieldsInGroup as |field|}}
<Icon
aria-hidden="true"
class={{compute (action "iconClass") model field}}
@glyph={{compute (action "iconGlyph") model field}}
/> {{field.options.label}}
<br />
{{/each}}
</div>
</InfoTableRow>
{{/each-in}}
{{/each}}

View file

@ -33,4 +33,10 @@
</Toolbar>
<div class="box is-fullwidth is-sideless is-shadowless">
<FieldGroupShow @model={{model}} @showAllFields={{false}} />
<div class="box is-fullwidth is-shadowless">
<h2 class="title is-5">
Allowed Operations
</h2>
<OperationFieldDisplay @model={{this.model}} />
</div>
</div>

View file

@ -73,7 +73,8 @@
(action "refresh")
)
}}
@confirmMessage={{concat "Are you sure you want to delete " list.item.id "?"}}
@confirmTitle={{concat "Delete scope " list.item.id "?"}}
@confirmMessage="This will permanently delete this scope and all roles and credentials contained within"
@cancelButtonText="Cancel"
data-test-scope-delete="true"
>

View file

@ -4,7 +4,7 @@ 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 { click, find, render, settled } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import engineResolverFor from 'ember-engines/test-support/engine-resolver-for';
@ -16,6 +16,8 @@ const flash = Service.extend({
});
const namespace = Service.extend({});
const fieldToCheckbox = field => ({ name: field, type: 'boolean' });
const createModel = options => {
let model = EmberObject.extend(COMPUTEDS, {
/* eslint-disable ember/avoid-leaking-state-in-ember-objects */
@ -38,7 +40,7 @@ const createModel = options => {
'tlsClientTtl',
],
fields: computed('operationFields', function() {
return this.operationFields.map(field => ({ name: field, type: 'boolean' }));
return this.operationFields.map(fieldToCheckbox);
}),
destroyRecord() {
return resolve();
@ -69,15 +71,14 @@ module('Integration | Component | edit form kmip role', function(hooks) {
this.set('model', model);
await render(hbs`<EditFormKmipRole @model={{model}} />`);
assert.dom('[name=role-display]:checked').hasValue('operationAll', 'defaults to all on new models');
assert.dom('[data-test-input="operationAll"').isChecked('sets operationAll');
});
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');
assert.dom('[data-test-input="operationAll"').isChecked('sets operationAll');
});
test('it renders: operationNone', async function(assert) {
@ -85,7 +86,7 @@ module('Integration | Component | edit form kmip role', function(hooks) {
this.set('model', model);
await render(hbs`<EditFormKmipRole @model={{model}} />`);
assert.dom('[name=role-display]:checked').hasValue('operationNone', 'sets operationNone');
assert.dom('[data-test-input="operationNone"]').isNotChecked('sets operationNone');
});
test('it renders: choose operations', async function(assert) {
@ -93,14 +94,15 @@ module('Integration | Component | edit form kmip role', function(hooks) {
this.set('model', model);
await render(hbs`<EditFormKmipRole @model={{model}} />`);
assert.dom('[name=role-display]:checked').hasValue('choose', 'sets choose');
assert.dom('[data-test-input="operationNone"]').isChecked('sets operationNone');
assert.dom('[data-test-input="operationAll"').isNotChecked('sets operationAll');
});
let savingTests = [
[
'setting operationAll',
{ operationNone: true, operationGet: true },
'operationAll',
'operationNone',
{
operationAll: true,
operationNone: false,
@ -108,7 +110,7 @@ module('Integration | Component | edit form kmip role', function(hooks) {
},
{
operationGet: null,
operationNone: null,
operationNone: false,
},
],
[
@ -123,16 +125,16 @@ module('Integration | Component | edit form kmip role', function(hooks) {
{
operationNone: true,
operationCreate: null,
operationAll: null,
operationAll: false,
},
],
[
'setting choose, and selecting an additional item',
{ operationAll: true, operationGet: true, operationCreate: true },
'choose,operationDestroy',
'operationAll,operationDestroy',
{
operationAll: true,
operationAll: false,
operationCreate: true,
operationGet: true,
},
@ -140,8 +142,7 @@ module('Integration | Component | edit form kmip role', function(hooks) {
operationGet: true,
operationCreate: true,
operationDestroy: true,
operationAll: null,
operationNone: null,
operationAll: false,
},
],
];
@ -159,7 +160,6 @@ module('Integration | Component | edit form kmip role', function(hooks) {
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]');