ui: KV Form and List Components (#8307)

* Add components for KV form, KV list and Session form

* Pass through a @label attribute for a human label + don't require error

* Ignore transition aborted errors for if you are re-transitioning

* Make old confirmation dialog more ember-like and tagless

* Make sure data-source and data-sink supports KV and sessions

* Use new components and delete all the things

* Fix up tests

* Make list component tagless

* Add component pageobject and fixup tests from that

* Add eslint warning back in
This commit is contained in:
John Cowen 2020-07-20 18:04:43 +01:00 committed by hashicorp-ci
parent ce6481d93d
commit fce4311f55
39 changed files with 386 additions and 499 deletions

View File

@ -1,11 +1,13 @@
<div class={{concat "with-confirmation" (if confirming " confirming" "")}} ...attributes>
{{yield}} {{yield}}
<YieldSlot @name="action" @params={{block-params confirm cancel}}> <YieldSlot @name="action" @params={{block-params (action "confirm") (action "cancel")}}>
{{#if (or permanent (not confirming))}} {{#if (or permanent (not confirming))}}
{{yield}} {{yield}}
{{/if}} {{/if}}
</YieldSlot> </YieldSlot>
<YieldSlot @name="dialog" @params={{block-params execute cancel message actionName}}> <YieldSlot @name="dialog" @params={{block-params (action "execute") (action "cancel") message actionName}}>
{{#if confirming }} {{#if confirming }}
{{yield}} {{yield}}
{{/if}} {{/if}}
</YieldSlot> </YieldSlot>
</div>

View File

@ -1,31 +1,27 @@
/*eslint ember/closure-actions: "warn"*/ /*eslint ember/closure-actions: "warn"*/
import Component from '@ember/component'; import Component from '@ember/component';
import SlotsMixin from 'block-slots'; import Slotted from 'block-slots';
import { set } from '@ember/object'; import { set } from '@ember/object';
const cancel = function() { export default Component.extend(Slotted, {
set(this, 'confirming', false); tagName: '',
};
const execute = function() {
this.sendAction(...['actionName', ...this['arguments']]);
};
const confirm = function() {
const [action, ...args] = arguments;
set(this, 'actionName', action);
set(this, 'arguments', args);
set(this, 'confirming', true);
};
export default Component.extend(SlotsMixin, {
classNameBindings: ['confirming'],
classNames: ['with-confirmation'],
message: 'Are you sure?', message: 'Are you sure?',
confirming: false, confirming: false,
permanent: false, permanent: false,
init: function() { actions: {
this._super(...arguments); cancel: function() {
this.cancel = cancel.bind(this); set(this, 'confirming', false);
this.execute = execute.bind(this); },
this.confirm = confirm.bind(this); execute: function() {
set(this, 'confirming', false);
this.sendAction(...['actionName', ...this['arguments']]);
},
confirm: function() {
const [action, ...args] = arguments;
set(this, 'actionName', action);
set(this, 'arguments', args);
set(this, 'confirming', true);
},
}, },
}); });

View File

@ -0,0 +1,59 @@
<DataForm
@dc={{dc}}
@nspace={{nspace}}
@type="kv"
@label="key"
@autofill={{autofill}}
@item={{item}}
@src={{src}}
@onchange={{action "change"}}
@onsubmit={{action onsubmit}}
as |api|
>
<BlockSlot @name="content">
<form onsubmit={{action api.submit}}>
<fieldset disabled={{api.disabled}}>
{{#if api.isCreate}}
<label class="type-text{{if api.data.error.Key ' has-error'}}">
<span>Key or folder</span>
<input autofocus="autofocus" type="text" value={{left-trim api.data.Key parent.Key}} name="additional" oninput={{action api.change}} placeholder="Key or folder" />
<em>To create a folder, end a key with <code>/</code></em>
</label>
{{/if}}
{{#if (or (eq (left-trim api.data.Key parent.Key) '') (not-eq (last api.data.Key) '/'))}}
<div>
<div class="type-toggle">
<label>
<input type="checkbox" name="json" checked={{if json 'checked'}} onchange={{action api.change}} />
<span>Code</span>
</label>
</div>
<label for="" class="type-text{{if api.data.error.Value ' has-error'}}">
<span>Value</span>
{{#if json}}
<CodeEditor @value={{atob api.data.Value}} @onkeyup={{action api.change "value"}} />
{{else}}
<textarea autofocus={{not api.isCreate}} name="value" oninput={{action api.change}}>{{atob api.data.Value}}</textarea>
{{/if}}
</label>
</div>
{{/if}}
</fieldset>
{{#if api.isCreate}}
<button type="submit" disabled={{or api.data.isPristine api.data.isInvalid api.disabled}}>Save</button>
<button type="reset" onclick={{action oncancel api.data}} disabled={{api.disabled}}>Cancel</button>
{{else}}
<button type="submit" disabled={{or api.data.isInvalid api.disabled}}>Save</button>
<button type="reset" onclick={{action oncancel api.data}} disabled={{api.disabled}}>Cancel</button>
<ConfirmationDialog @message="Are you sure you want to delete this key?">
<BlockSlot @name="action" as |confirm|>
<button data-test-delete type="button" class="type-delete" {{action confirm api.delete}} disabled={{api.disabled}}>Delete</button>
</BlockSlot>
<BlockSlot @name="dialog" as |execute cancel message|>
<DeleteConfirmation @message={{message}} @execute={{execute}} @cancel={{cancel}} />
</BlockSlot>
</ConfirmationDialog>
{{/if}}
</form>
</BlockSlot>
</DataForm>

View File

@ -1,45 +1,33 @@
import Controller from '@ember/controller'; import Component from '@ember/component';
import { get, set } from '@ember/object'; import { get, set } from '@ember/object';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
export default Controller.extend({ export default Component.extend({
dom: service('dom'), tagName: '',
builder: service('form'),
encoder: service('btoa'), encoder: service('btoa'),
json: true, json: true,
init: function() { ondelete: function() {
this._super(...arguments); this.onsubmit(...arguments);
this.form = this.builder.form('kv');
}, },
setProperties: function(model) { oncancel: function() {
// essentially this replaces the data with changesets this.onsubmit(...arguments);
this._super(
Object.keys(model).reduce((prev, key, i) => {
switch (key) {
case 'item':
prev[key] = this.form.setData(prev[key]).getData();
break;
}
return prev;
}, model)
);
}, },
onsubmit: function() {},
actions: { actions: {
change: function(e, value, item) { change: function(e, form) {
const event = this.dom.normalizeEvent(e, value); const item = form.getData();
const form = this.form;
try { try {
form.handleEvent(event); form.handleEvent(e);
} catch (err) { } catch (err) {
const target = event.target; const target = e.target;
let parent; let parent;
switch (target.name) { switch (target.name) {
case 'value': case 'value':
set(this.item, 'Value', this.encoder.execute(target.value)); set(item, 'Value', this.encoder.execute(target.value));
break; break;
case 'additional': case 'additional':
parent = get(this, 'parent.Key'); parent = get(this, 'parent.Key');
set(this.item, 'Key', `${parent !== '/' ? parent : ''}${target.value}`); set(item, 'Key', `${parent !== '/' ? parent : ''}${target.value}`);
break; break;
case 'json': case 'json':
// TODO: Potentially save whether json has been clicked to the model, // TODO: Potentially save whether json has been clicked to the model,

View File

@ -0,0 +1,58 @@
<DataWriter
@sink={{concat '/' dc '/' nspace '/kv/'}}
@type="kv"
@label="key"
@ondelete={{action ondelete}}
as |writer|>
<BlockSlot @name="content">
{{#if (gt items.length 0)}}
<TabularCollection class="consul-kv-list" @items={{items}} as |item index|>
<BlockSlot @name="header">
<th>Name</th>
</BlockSlot>
<BlockSlot @name="row">
<td data-test-kv={{item.Key}} class={{if item.isFolder 'folder' 'file' }}>
<a href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>{{right-trim (left-trim item.Key parent.Key) '/'}}</a>
</td>
</BlockSlot>
<BlockSlot @name="actions" as |index change checked|>
<PopoverMenu @expanded={{if (eq checked index) true false}} @onchange={{action change index}} @keyboardAccess={{false}}>
<BlockSlot @name="trigger">
More
</BlockSlot>
<BlockSlot @name="menu" as |confirm send keypressClick|>
<li role="none">
<a data-test-edit role="menuitem" tabindex="-1" href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>{{if item.isFolder 'View' 'Edit'}}</a>
</li>
<li role="none" class="dangerous">
<label for={{confirm}} role="menuitem" tabindex="-1" onkeypress={{keypressClick}} data-test-delete>Delete</label>
<div role="menu">
<div class="confirmation-alert warning">
<div>
<header>
Confirm Delete
</header>
<p>
Are you sure you want to delete this key?
</p>
</div>
<ul>
<li class="dangerous">
<button tabindex="-1" type="button" class="type-delete" onclick={{queue (action change) (action writer.delete item)}}>Delete</button>
</li>
<li>
<label for={{confirm}}>Cancel</label>
</li>
</ul>
</div>
</div>
</li>
</BlockSlot>
</PopoverMenu>
</BlockSlot>
</TabularCollection>
{{else}}
{{yield}}
{{/if}}
</BlockSlot>
</DataWriter>

View File

@ -0,0 +1,6 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
ondelete: function() {},
});

View File

@ -0,0 +1,8 @@
export default (collection, clickable, attribute, deletable) => () => {
return collection('.consul-kv-list [data-test-tabular-row]', {
name: attribute('data-test-kv', '[data-test-kv]'),
kv: clickable('a'),
actions: clickable('label'),
...deletable(),
});
};

View File

@ -0,0 +1,54 @@
<DataForm
@dc={{dc}}
@nspace={{nspace}}
@item={{item}}
@type="session"
@onsubmit={{action onsubmit}}
as |api|
>
<BlockSlot @name="form">
<div class="definition-table" data-test-session={{api.data.ID}}>
<h2>
<a href="{{env 'CONSUL_DOCS_URL'}}/internals/sessions.html#session-design" rel="help noopener noreferrer" target="_blank">Lock Session</a>
</h2>
<dl>
<dt>Name</dt>
<dd>{{api.data.Name}}</dd>
<dt>Agent</dt>
<dd>
<a href={{href-to 'dc.nodes.show' api.data.Node}}>{{api.data.Node}}</a>
</dd>
<dt>ID</dt>
<dd>{{api.data.ID}}</dd>
<dt>Behavior</dt>
<dd>{{api.data.Behavior}}</dd>
{{#if form.data.Delay }}
<dt>Delay</dt>
<dd>{{api.data.LockDelay}}</dd>
{{/if}}
{{#if form.data.TTL }}
<dt>TTL</dt>
<dd>{{api.data.TTL}}</dd>
{{/if}}
{{#if (gt api.data.Checks.length 0)}}
<dt>Health Checks</dt>
<dd>
{{ join ', ' api.data.Checks}}
</dd>
{{/if}}
</dl>
<ConfirmationDialog @message="Are you sure you want to invalidate this session?">
<BlockSlot @name="action" as |confirm|>
<button type="button" data-test-delete class="type-delete" {{action confirm api.delete session}} disabled={{api.disabled}}>Invalidate Session</button>
</BlockSlot>
<BlockSlot @name="dialog" as |execute cancel message|>
<p>
{{message}}
</p>
<button type="button" class="type-delete" {{action execute}}>Confirm Invalidation</button>
<button type="button" class="type-cancel" {{action cancel}}>Cancel</button>
</BlockSlot>
</ConfirmationDialog>
</div>
</BlockSlot>
</DataForm>

View File

@ -0,0 +1,3 @@
import Component from '@ember/component';
export default Component.extend({});

View File

@ -4,6 +4,7 @@
<DataWriter <DataWriter
@sink={{concat '/' nspace '/' (or data.Datacenter dc) '/' type '/'}} @sink={{concat '/' nspace '/' (or data.Datacenter dc) '/' type '/'}}
@type={{type}} @type={{type}}
@label={{label}}
@ondelete={{action ondelete}} @ondelete={{action ondelete}}
@onchange={{action onsubmit}} @onchange={{action onsubmit}}
as |writer|> as |writer|>
@ -19,12 +20,13 @@
) as |api|}} ) as |api|}}
{{yield api}} {{yield api}}
{{#if hasError}}
<BlockSlot @name="error"> <BlockSlot @name="error">
<YieldSlot @name="error"> <YieldSlot @name="error">
{{yield api}} {{yield api}}
</YieldSlot> </YieldSlot>
</BlockSlot> </BlockSlot>
{{/if}}
<BlockSlot @name="content"> <BlockSlot @name="content">
<YieldSlot @name="form"> <YieldSlot @name="form">

View File

@ -27,6 +27,10 @@ export default Component.extend(Slotted, {
// this lets us load view only data that doesn't have a form // this lets us load view only data that doesn't have a form
} }
}, },
willRender: function() {
this._super(...arguments);
set(this, 'hasError', this._isRegistered('error'));
},
willDestroyElement: function() { willDestroyElement: function() {
this._super(...arguments); this._super(...arguments);
if (get(this, 'data.isNew')) { if (get(this, 'data.isNew')) {

View File

@ -38,7 +38,7 @@
<Notification @after={{queue (action dispatch "RESET") (action ondelete)}}> <Notification @after={{queue (action dispatch "RESET") (action ondelete)}}>
<p data-notification role="alert" class="success notification-delete"> <p data-notification role="alert" class="success notification-delete">
<strong>Success!</strong> <strong>Success!</strong>
Your {{type}} has been deleted. Your {{or label type}} has been deleted.
</p> </p>
</Notification> </Notification>
{{/yield-slot}} {{/yield-slot}}
@ -51,7 +51,7 @@
{{else}} {{else}}
<p data-notification role="alert" class="success notification-update"> <p data-notification role="alert" class="success notification-update">
<strong>Success!</strong> <strong>Success!</strong>
Your {{type}} has been saved. Your {{or label type}} has been saved.
</p> </p>
{{/yield-slot}} {{/yield-slot}}
</Notification> </Notification>
@ -64,7 +64,7 @@
<Notification @after={{action dispatch "RESET"}}> <Notification @after={{action dispatch "RESET"}}>
<p data-notification role="alert" class="error notification-update"> <p data-notification role="alert" class="error notification-update">
<strong>Error!</strong> <strong>Error!</strong>
There was an error saving your {{type}}. There was an error saving your {{or label type}}.
{{#if (and api.error.status api.error.detail)}} {{#if (and api.error.status api.error.detail)}}
<br />{{api.error.status}}: {{api.error.detail}} <br />{{api.error.status}}: {{api.error.detail}}
{{/if}} {{/if}}

View File

@ -24,9 +24,15 @@ export default Component.extend({
$el.remove(); $el.remove();
this.notify.clearMessages(); this.notify.clearMessages();
if (typeof this.after === 'function') { if (typeof this.after === 'function') {
Promise.resolve(this.after()).then(res => { Promise.resolve(this.after())
this.notify.add(options); .catch(e => {
}); if (e.name !== 'TransitionAborted') {
throw e;
}
})
.then(res => {
this.notify.add(options);
});
} else { } else {
this.notify.add(options); this.notify.add(options);
} }

View File

@ -1,2 +0,0 @@
import Controller from './edit';
export default Controller.extend();

View File

@ -1,2 +0,0 @@
import Controller from './index';
export default Controller.extend();

View File

@ -1,2 +0,0 @@
import Controller from './create';
export default Controller.extend();

View File

@ -1,35 +0,0 @@
import Mixin from '@ember/object/mixin';
import { get, set } from '@ember/object';
import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions';
export default Mixin.create(WithBlockingActions, {
// afterCreate just calls afterUpdate
afterUpdate: function(item, parent) {
const key = get(parent, 'Key');
if (key === '/') {
return this.transitionTo('dc.kv.index');
} else {
return this.transitionTo('dc.kv.folder', key);
}
},
afterDelete: function(item, parent) {
if (this.routeName === 'dc.kv.folder') {
return this.refresh();
}
return this._super(...arguments);
},
actions: {
invalidateSession: function(item) {
const controller = this.controller;
const repo = this.sessionRepo;
return this.feedback.execute(() => {
return repo.remove(item).then(() => {
const item = get(controller, 'item');
set(item, 'Session', null);
delete item.Session;
set(controller, 'session', null);
});
}, 'deletesession');
},
},
});

View File

@ -1,36 +1,5 @@
import Route from '@ember/routing/route'; import Route from './edit';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithKvActions from 'consul-ui/mixins/kv/with-actions';
export default Route.extend(WithKvActions, { export default Route.extend({
templateName: 'dc/kv/edit', templateName: 'dc/kv/edit',
repo: service('repository/kv'),
beforeModel: function() {
this.repo.invalidate();
},
model: function(params) {
const key = params.key || '/';
const dc = this.modelFor('dc').dc.Name;
const nspace = this.modelFor('nspace').nspace.substr(1);
this.item = this.repo.create({
Datacenter: dc,
Namespace: nspace,
});
return hash({
create: true,
isLoading: false,
item: this.item,
parent: this.repo.findBySlug(key, dc, nspace),
});
},
setupController: function(controller, model) {
controller.setProperties(model);
},
deactivate: function() {
if (get(this.item, 'isNew')) {
this.item.destroyRecord();
}
},
}); });

View File

@ -2,11 +2,10 @@ import Route from '@ember/routing/route';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { hash } from 'rsvp'; import { hash } from 'rsvp';
import { get } from '@ember/object'; import { get } from '@ember/object';
import WithKvActions from 'consul-ui/mixins/kv/with-actions';
import ascend from 'consul-ui/utils/ascend'; import ascend from 'consul-ui/utils/ascend';
export default Route.extend(WithKvActions, { export default Route.extend({
repo: service('repository/kv'), repo: service('repository/kv'),
sessionRepo: service('repository/session'), sessionRepo: service('repository/session'),
model: function(params) { model: function(params) {
@ -14,20 +13,26 @@ export default Route.extend(WithKvActions, {
const dc = this.modelFor('dc').dc.Name; const dc = this.modelFor('dc').dc.Name;
const nspace = this.modelFor('nspace').nspace.substr(1); const nspace = this.modelFor('nspace').nspace.substr(1);
return hash({ return hash({
isLoading: false, dc: dc,
parent: this.repo.findBySlug(ascend(key, 1) || '/', dc, nspace), nspace: nspace || 'default',
item: this.repo.findBySlug(key, dc, nspace), parent:
typeof key !== 'undefined'
? this.repo.findBySlug(ascend(key, 1) || '/', dc, nspace)
: this.repo.findBySlug('/', dc, nspace),
item: typeof key !== 'undefined' ? this.repo.findBySlug(key, dc, nspace) : undefined,
session: null, session: null,
}).then(model => { }).then(model => {
// TODO: Consider loading this after initial page load // TODO: Consider loading this after initial page load
const session = get(model.item, 'Session'); if (typeof model.item !== 'undefined') {
if (session) { const session = get(model.item, 'Session');
return hash({ if (session) {
...model, return hash({
...{ ...model,
session: this.sessionRepo.findByKey(session, dc, nspace), ...{
}, session: this.sessionRepo.findByKey(session, dc, nspace),
}); },
});
}
} }
return model; return model;
}); });

View File

@ -3,9 +3,8 @@ import { inject as service } from '@ember/service';
import { hash } from 'rsvp'; import { hash } from 'rsvp';
import { get } from '@ember/object'; import { get } from '@ember/object';
import isFolder from 'consul-ui/utils/isFolder'; import isFolder from 'consul-ui/utils/isFolder';
import WithKvActions from 'consul-ui/mixins/kv/with-actions';
export default Route.extend(WithKvActions, { export default Route.extend({
queryParams: { queryParams: {
search: { search: {
as: 'filter', as: 'filter',

View File

@ -4,6 +4,7 @@ import { setProperties } from '@ember/object';
export default Service.extend({ export default Service.extend({
settings: service('settings'), settings: service('settings'),
intention: service('repository/intention'), intention: service('repository/intention'),
kv: service('repository/kv'),
session: service('repository/session'), session: service('repository/session'),
prepare: function(sink, data, instance) { prepare: function(sink, data, instance) {
return setProperties(instance, data); return setProperties(instance, data);

View File

@ -113,6 +113,7 @@ export default Service.extend({
repo.findInstanceBySlug(rest[0], rest[1], rest[2], dc, nspace, configuration); repo.findInstanceBySlug(rest[0], rest[1], rest[2], dc, nspace, configuration);
break; break;
case 'policy': case 'policy':
case 'kv':
case 'intention': case 'intention':
slug = rest[0]; slug = rest[0];
if (slug) { if (slug) {

View File

@ -1,46 +0,0 @@
<form>
<fieldset>
{{#if create }}
<label class="type-text{{if item.error.Key ' has-error'}}">
<span>Key or folder</span>
<input autofocus="autofocus" type="text" value={{left-trim item.Key parent.Key}} name="additional" oninput={{action 'change'}} placeholder="Key or folder" />
<em>To create a folder, end a key with <code>/</code></em>
</label>
{{/if}}
{{#if (or (eq (left-trim item.Key parent.Key) '') (not-eq (last item.Key) '/')) }}
<div>
<div class="type-toggle">
<label>
<input type="checkbox" name="json" checked={{if json 'checked' }} onchange={{action 'change'}} />
<span>Code</span>
</label>
</div>
<label for="" class="type-text{{if item.error.Value ' has-error'}}">
<span>Value</span>
{{#if json}}
<CodeEditor @value={{atob item.Value}} @onkeyup={{action "change" "value"}} />
{{else}}
<textarea autofocus={{not create}} name="value" oninput={{action 'change'}}>{{atob item.Value}}</textarea>
{{/if}}
</label>
</div>
{{/if}}
</fieldset>
{{!TODO This has a <div> around it in acls, remove or add for consistency }}
{{#if create }}
{{! we only need to check for an empty keyname here as ember munges autofocus, once we have autofocus back revisit this}}
<button type="submit" {{ action "create" item parent}} disabled={{if (or item.isPristine item.isInvalid (eq (left-trim item.Key parent.Key) '')) 'disabled'}}>Save</button>
{{ else }}
<button type="submit" {{ action "update" item parent}} disabled={{if item.isInvalid 'disabled'}}>Save</button>
<button type="reset" {{ action "cancel" item parent}}>Cancel changes</button>
<ConfirmationDialog @message="Are you sure you want to delete this key?">
<BlockSlot @name="action" as |confirm|>
<button data-test-delete type="button" class="type-delete" {{action confirm 'delete' item parent}}>Delete</button>
</BlockSlot>
<BlockSlot @name="dialog" as |execute cancel message|>
<DeleteConfirmation @message={{message}} @execute={{execute}} @cancel={{cancel}} />
</BlockSlot>
</ConfirmationDialog>
{{/if}}
</form>

View File

@ -1,31 +0,0 @@
{{#if (eq type 'create')}}
{{#if (eq status 'success') }}
Your key has been added.
{{else}}
There was an error adding your key.
{{/if}}
{{else if (eq type 'update') }}
{{#if (eq status 'success') }}
Your key has been saved.
{{else}}
There was an error saving your key.
{{/if}}
{{ else if (eq type 'delete')}}
{{#if (eq status 'success') }}
Your key was deleted.
{{else}}
There was an error deleting your key.
{{/if}}
{{ else if (eq type 'deletesession')}}
{{#if (eq status 'success') }}
Your session was invalidated.
{{else}}
There was an error invalidating your session.
{{/if}}
{{/if}}
{{#let error.errors.firstObject as |error|}}
{{#if error.detail }}
<br />{{concat '(' (if error.status (concat error.status ': ')) error.detail ')'}}
{{/if}}
{{/let}}

View File

@ -1,12 +1,9 @@
{{#if create }} {{#if item.Key }}
{{title 'New Key/Value'}}
{{else}}
{{title 'Edit Key/Value'}} {{title 'Edit Key/Value'}}
{{else}}
{{title 'New Key/Value'}}
{{/if}} {{/if}}
<AppView @class="kv edit" @loading={{isLoading}}> <AppView @class="kv edit">
<BlockSlot @name="notification" as |status type item error|>
{{partial 'dc/kv/notifications'}}
</BlockSlot>
<BlockSlot @name="breadcrumbs"> <BlockSlot @name="breadcrumbs">
<ol> <ol>
<li><a data-test-back href={{href-to 'dc.kv.index'}}>Key / Values</a></li> <li><a data-test-back href={{href-to 'dc.kv.index'}}>Key / Values</a></li>
@ -27,56 +24,25 @@
</h1> </h1>
</BlockSlot> </BlockSlot>
<BlockSlot @name="content"> <BlockSlot @name="content">
{{#if session}} {{#if session}}
<p class="notice warning"> <p class="notice warning">
<strong>Warning.</strong> This KV has a lock session. You can edit KV's with lock sessions, but we recommend doing so with care, or not doing so at all. It may negatively impact the active node it's associated with. See below for more details on the Lock Session and see <a href="{{env 'CONSUL_DOCS_URL'}}/internals/sessions.html" target="_blank" rel="noopener noreferrer">our documentation</a> for more information. <strong>Warning.</strong> This KV has a lock session. You can edit KV's with lock sessions, but we recommend doing so with care, or not doing so at all. It may negatively impact the active node it's associated with. See below for more details on the Lock Session and see <a href="{{env 'CONSUL_DOCS_URL'}}/internals/sessions.html" target="_blank" rel="noopener noreferrer">our documentation</a> for more information.
</p> </p>
{{/if}} {{/if}}
{{partial 'dc/kv/form'}} <ConsulKvForm
{{#if session}} @item={{item}}
<div class="definition-table" data-test-session={{session.ID}}> @dc={{dc}}
<h2> @nspace={{nspace}}
<a href="{{env 'CONSUL_DOCS_URL'}}/internals/sessions.html#session-design" rel="help noopener noreferrer" target="_blank">Lock Session</a> @onsubmit={{if (eq parent.Key '/') (transition-to 'dc.kv.index') (transition-to 'dc.kv.folder' parent.Key)}}
</h2> @parent={{parent}}
<dl> />
<dt>Name</dt> {{#if session}}
<dd>{{session.Name}}</dd> <ConsulSessionForm
<dt>Agent</dt> @item={{session}}
<dd> @dc={{dc}}
<a href={{href-to 'dc.nodes.show' session.Node}}>{{session.Node}}</a> @nspace={{nspace}}
</dd> @onsubmit={{action (mut session) undefined}}
<dt>ID</dt> />
<dd>{{session.ID}}</dd> {{/if}}
<dt>Behavior</dt>
<dd>{{session.Behavior}}</dd>
{{#if session.Delay }}
<dt>Delay</dt>
<dd>{{session.LockDelay}}</dd>
{{/if}}
{{#if session.TTL }}
<dt>TTL</dt>
<dd>{{session.TTL}}</dd>
{{/if}}
{{#if (gt session.Checks.length 0)}}
<dt>Health Checks</dt>
<dd>
{{ join ', ' session.Checks}}
</dd>
{{/if}}
</dl>
<ConfirmationDialog @message="Are you sure you want to invalidate this session?">
<BlockSlot @name="action" as |confirm|>
<button type="button" data-test-delete class="type-delete" {{action confirm "invalidateSession" session}}>Invalidate Session</button>
</BlockSlot>
<BlockSlot @name="dialog" as |execute cancel message|>
<p>
{{message}}
</p>
<button type="button" class="type-delete" {{action execute}}>Confirm Invalidation</button>
<button type="button" class="type-cancel" {{action cancel}}>Cancel</button>
</BlockSlot>
</ConfirmationDialog>
</div>
{{/if}}
</BlockSlot> </BlockSlot>
</AppView> </AppView>

View File

@ -1,8 +1,5 @@
{{title 'Key/Value'}} {{title 'Key/Value'}}
<AppView @class="kv list" @loading={{isLoading}}> <AppView @class="kv list">
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/kv/notifications'}}
</BlockSlot>
<BlockSlot @name="breadcrumbs"> <BlockSlot @name="breadcrumbs">
<ol> <ol>
{{#if (not-eq parent.Key '/') }} {{#if (not-eq parent.Key '/') }}
@ -41,82 +38,41 @@
</BlockSlot> </BlockSlot>
<BlockSlot @name="content"> <BlockSlot @name="content">
<ChangeableSet @dispatcher={{searchable 'kv' items}} @terms={{search}}> <ChangeableSet @dispatcher={{searchable 'kv' items}} @terms={{search}}>
<BlockSlot @name="set" as |filtered|> <BlockSlot @name="content" as |filtered|>
<TabularCollection @items={{sort-by "isFolder:desc" "Key:asc" filtered}} as |item index|> <ConsulKvList
<BlockSlot @name="header"> @items={{sort-by "isFolder:desc" "Key:asc" filtered}}
<th>Name</th> @parent={{parent}}
</BlockSlot> @ondelete={{refresh-route}}
<BlockSlot @name="row"> >
<td data-test-kv={{item.Key}} class={{if item.isFolder 'folder' 'file' }}> <EmptyState @allowLogin={{true}}>
<a href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>{{right-trim (left-trim item.Key parent.Key) '/'}}</a> <BlockSlot @name="header">
</td> <h2>
</BlockSlot> {{#if (gt items.length 0)}}
<BlockSlot @name="actions" as |index change checked|> No K/V pairs found
<PopoverMenu @expanded={{if (eq checked index) true false}} @onchange={{action change index}} @keyboardAccess={{false}}> {{else}}
<BlockSlot @name="trigger"> Welcome to Key/Value
More {{/if}}
</BlockSlot> </h2>
<BlockSlot @name="menu" as |confirm send keypressClick clickTrigger|> </BlockSlot>
<li role="none"> <BlockSlot @name="body">
<a data-test-edit role="menuitem" tabindex="-1" href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>{{if item.isFolder 'View' 'Edit'}}</a> <p>
</li> {{#if (gt items.length 0)}}
<li role="none" class="dangerous"> No K/V pairs where found matching that search, or you may not have access to view the K/V pairs you are searching for.
<label for={{confirm}} role="menuitem" tabindex="-1" onkeypress={{keypressClick}} data-test-delete>Delete</label> {{else}}
<div role="menu"> You don't have any K/V pairs, or you may not have access to view K/V pairs yet.
<div class="confirmation-alert warning"> {{/if}}
<div> </p>
<header> </BlockSlot>
Confirm Delete <BlockSlot @name="actions">
</header> <li class="docs-link">
<p> <a href="{{env 'CONSUL_DOCS_URL'}}/agent/kv" rel="noopener noreferrer" target="_blank">Documentation on K/V</a>
Are you sure you want to delete this key? </li>
</p> <li class="learn-link">
</div> <a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/getting-started/kv" rel="noopener noreferrer" target="_blank">Read the guide</a>
<ul> </li>
<li class="dangerous"> </BlockSlot>
<button tabindex="-1" type="button" class="type-delete" onclick={{queue (action send 'delete' item) (action clickTrigger)}}>Delete</button> </EmptyState>
</li> </ConsulKvList>
<li>
<label for={{confirm}}>Cancel</label>
</li>
</ul>
</div>
</div>
</li>
</BlockSlot>
</PopoverMenu>
</BlockSlot>
</TabularCollection>
</BlockSlot>
<BlockSlot @name="empty">
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>
{{#if (gt items.length 0)}}
No K/V pairs found
{{else}}
Welcome to Key/Value
{{/if}}
</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
{{#if (gt items.length 0)}}
No K/V pairs where found matching that search, or you may not have access to view the K/V pairs you are searching for.
{{else}}
You don't have any K/V pairs, or you may not have access to view K/V pairs yet.
{{/if}}
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/agent/kv" rel="noopener noreferrer" target="_blank">Documentation on K/V</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/getting-started/kv" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
</BlockSlot> </BlockSlot>
</ChangeableSet> </ChangeableSet>
</BlockSlot> </BlockSlot>

View File

@ -1,6 +1,6 @@
@setupApplicationTest @setupApplicationTest
Feature: components / kv-filter Feature: components / kv-filter
Scenario: Filtering using the freetext filter Scenario: Filtering using the freetext filter with [Text]
Given 1 datacenter model with the value "dc-1" Given 1 datacenter model with the value "dc-1"
And 2 [Model] models from yaml And 2 [Model] models from yaml
--- ---

View File

@ -0,0 +1,42 @@
@setupApplicationTest
Feature: dc / kvs / deleting: Deleting items with confirmations, success and error notifications
Background:
Given 1 datacenter model with the value "datacenter"
Scenario: Deleting a kv model from the kv listing page
Given 1 kv model from yaml
---
["key-name"]
---
When I visit the kvs page for yaml
---
dc: datacenter
---
And I click actions on the kvs
And I click delete on the kvs
And I click confirmDelete on the kvs
Then a DELETE request was made to "/v1/kv/key-name?dc=datacenter&ns=@!namespace"
And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class
Scenario: Deleting an kv from the kv detail page
When I visit the kv page for yaml
---
dc: datacenter
kv: key-name
---
And I click delete
And I click confirmDelete
Then a DELETE request was made to "/v1/kv/key-name?dc=datacenter&ns=@!namespace"
And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class
Scenario: Deleting an kv from the kv detail page and getting an error
When I visit the kv page for yaml
---
dc: datacenter
kv: key-name
---
Given the url "/v1/kv/key-name?dc=datacenter&ns=@!namespace" responds with a 500 status
And I click delete
And I click confirmDelete
And "[data-notification]" has the "notification-update" class
And "[data-notification]" has the "error" class

View File

@ -21,12 +21,12 @@ Feature: dc / kvs / sessions / invalidate: Invalidate Lock Sessions
And I click confirmDelete on the session And I click confirmDelete on the session
Then a PUT request was made to "/v1/session/destroy/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter&ns=@!namespace" Then a PUT request was made to "/v1/session/destroy/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter&ns=@!namespace"
Then the url should be /datacenter/kv/key/edit Then the url should be /datacenter/kv/key/edit
And "[data-notification]" has the "notification-deletesession" class And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class And "[data-notification]" has the "success" class
Scenario: Invalidating a lock session and receiving an error Scenario: Invalidating a lock session and receiving an error
Given the url "/v1/session/destroy/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter&ns=@!namespace" responds with a 500 status Given the url "/v1/session/destroy/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter&ns=@!namespace" responds with a 500 status
And I click delete on the session And I click delete on the session
And I click confirmDelete on the session And I click confirmDelete on the session
Then the url should be /datacenter/kv/key/edit Then the url should be /datacenter/kv/key/edit
And "[data-notification]" has the "notification-deletesession" class And "[data-notification]" has the "notification-update" class
And "[data-notification]" has the "error" class And "[data-notification]" has the "error" class

View File

@ -23,7 +23,6 @@ Feature: deleting: Deleting items with confirmations, success and error notifica
Where: Where:
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Edit | Listing | Method | URL | Data | | Edit | Listing | Method | URL | Data |
| kv | kvs | DELETE | /v1/kv/key-name?dc=datacenter&ns=@!namespace | ["key-name"] |
| token | tokens | DELETE | /v1/acl/token/001fda31-194e-4ff1-a5ec-589abf2cafd0?dc=datacenter&ns=@!namespace | {"AccessorID": "001fda31-194e-4ff1-a5ec-589abf2cafd0"} | | token | tokens | DELETE | /v1/acl/token/001fda31-194e-4ff1-a5ec-589abf2cafd0?dc=datacenter&ns=@!namespace | {"AccessorID": "001fda31-194e-4ff1-a5ec-589abf2cafd0"} |
# | acl | acls | PUT | /v1/acl/destroy/something?dc=datacenter | {"Name": "something", "ID": "something"} | # | acl | acls | PUT | /v1/acl/destroy/something?dc=datacenter | {"Name": "something", "ID": "something"} |
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
@ -51,7 +50,6 @@ Feature: deleting: Deleting items with confirmations, success and error notifica
Where: Where:
----------------------------------------------------------------------------------------------------------------------------------------------------------- -----------------------------------------------------------------------------------------------------------------------------------------------------------
| Model | Method | URL | Slug | | Model | Method | URL | Slug |
| kv | DELETE | /v1/kv/key-name?dc=datacenter&ns=@!namespace | kv: key-name |
| token | DELETE | /v1/acl/token/001fda31-194e-4ff1-a5ec-589abf2cafd0?dc=datacenter&ns=@!namespace | token: 001fda31-194e-4ff1-a5ec-589abf2cafd0 | | token | DELETE | /v1/acl/token/001fda31-194e-4ff1-a5ec-589abf2cafd0?dc=datacenter&ns=@!namespace | token: 001fda31-194e-4ff1-a5ec-589abf2cafd0 |
# | acl | PUT | /v1/acl/destroy/something?dc=datacenter | acl: something | # | acl | PUT | /v1/acl/destroy/something?dc=datacenter | acl: something |
----------------------------------------------------------------------------------------------------------------------------------------------------------- -----------------------------------------------------------------------------------------------------------------------------------------------------------

View File

@ -0,0 +1,10 @@
import steps from '../../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -1,26 +0,0 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | consul-kind', function(hooks) {
setupRenderingTest(hooks);
skip('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<ConsulKind />`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
<ConsulKind>
template block text
</ConsulKind>
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});

View File

@ -41,6 +41,7 @@ import consulTokenListFactory from 'consul-ui/components/consul-token-list/pageo
import consulRoleListFactory from 'consul-ui/components/consul-role-list/pageobject'; import consulRoleListFactory from 'consul-ui/components/consul-role-list/pageobject';
import consulPolicyListFactory from 'consul-ui/components/consul-policy-list/pageobject'; import consulPolicyListFactory from 'consul-ui/components/consul-policy-list/pageobject';
import consulIntentionListFactory from 'consul-ui/components/consul-intention-list/pageobject'; import consulIntentionListFactory from 'consul-ui/components/consul-intention-list/pageobject';
import consulKvListFactory from 'consul-ui/components/consul-kv-list/pageobject';
// pages // pages
import index from 'consul-ui/tests/pages/index'; import index from 'consul-ui/tests/pages/index';
@ -96,6 +97,7 @@ const morePopoverMenu = morePopoverMenuFactory(clickable);
const popoverSelect = popoverSelectFactory(clickable, collection); const popoverSelect = popoverSelectFactory(clickable, collection);
const consulIntentionList = consulIntentionListFactory(collection, clickable, attribute, deletable); const consulIntentionList = consulIntentionListFactory(collection, clickable, attribute, deletable);
const consulKvList = consulKvListFactory(collection, clickable, attribute, deletable);
const consulTokenList = consulTokenListFactory( const consulTokenList = consulTokenListFactory(
collection, collection,
clickable, clickable,
@ -149,7 +151,7 @@ export default {
instance: create(instance(visitable, attribute, collection, text, tabgroup)), instance: create(instance(visitable, attribute, collection, text, tabgroup)),
nodes: create(nodes(visitable, clickable, attribute, collection, catalogFilter)), nodes: create(nodes(visitable, clickable, attribute, collection, catalogFilter)),
node: create(node(visitable, deletable, clickable, attribute, collection, tabgroup, text)), node: create(node(visitable, deletable, clickable, attribute, collection, tabgroup, text)),
kvs: create(kvs(visitable, deletable, creatable, clickable, attribute, collection)), kvs: create(kvs(visitable, creatable, consulKvList)),
kv: create(kv(visitable, attribute, submitable, deletable, cancelable, clickable)), kv: create(kv(visitable, attribute, submitable, deletable, cancelable, clickable)),
acls: create(acls(visitable, deletable, creatable, clickable, attribute, collection, aclFilter)), acls: create(acls(visitable, deletable, creatable, clickable, attribute, collection, aclFilter)),
acl: create(acl(visitable, submitable, deletable, cancelable, clickable)), acl: create(acl(visitable, submitable, deletable, cancelable, clickable)),

View File

@ -1,13 +1,6 @@
export default function(visitable, deletable, creatable, clickable, attribute, collection) { export default function(visitable, creatable, kvs) {
return creatable({ return creatable({
visit: visitable(['/:dc/kv/:kv', '/:dc/kv'], str => str), visit: visitable(['/:dc/kv/:kv', '/:dc/kv'], str => str),
kvs: collection( kvs: kvs(),
'[data-test-tabular-row]',
deletable({
name: attribute('data-test-kv', '[data-test-kv]'),
kv: clickable('a'),
actions: clickable('label'),
})
),
}); });
} }

View File

@ -1,12 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | dc/kv/create', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.owner.lookup('controller:dc/kv/create');
assert.ok(controller);
});
});

View File

@ -1,12 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | dc/kv/edit', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.owner.lookup('controller:dc/kv/edit');
assert.ok(controller);
});
});

View File

@ -1,12 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | dc/kv/folder', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.owner.lookup('controller:dc/kv/folder');
assert.ok(controller);
});
});

View File

@ -1,12 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | dc/kv/root-create', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.owner.lookup('controller:dc/kv/root-create');
assert.ok(controller);
});
});

View File

@ -1,49 +0,0 @@
import { module, skip } from 'qunit';
import { setupTest } from 'ember-qunit';
import test from 'ember-sinon-qunit/test-support/test';
import Route from '@ember/routing/route';
import Mixin from 'consul-ui/mixins/kv/with-actions';
module('Unit | Mixin | kv/with actions', function(hooks) {
setupTest(hooks);
hooks.beforeEach(function() {
this.subject = function() {
const MixedIn = Route.extend(Mixin);
this.owner.register('test-container:kv/with-actions-object', MixedIn);
return this.owner.lookup('test-container:kv/with-actions-object');
};
});
// Replace this with your real tests.
test('it works', function(assert) {
const subject = this.subject();
assert.ok(subject);
});
test('afterUpdate calls transitionTo index when the key is a single slash', function(assert) {
const subject = this.subject();
const expected = 'dc.kv.index';
const transitionTo = this.stub(subject, 'transitionTo').returnsArg(0);
const actual = subject.afterUpdate({}, { Key: '/' });
assert.equal(actual, expected);
assert.ok(transitionTo.calledOnce);
});
test('afterUpdate calls transitionTo folder when the key is not a single slash', function(assert) {
const subject = this.subject();
const expected = 'dc.kv.folder';
const transitionTo = this.stub(subject, 'transitionTo').returnsArg(0);
['', '/key', 'key/name'].forEach(item => {
const actual = subject.afterUpdate({}, { Key: item });
assert.equal(actual, expected);
});
assert.ok(transitionTo.calledThrice);
});
test('afterDelete calls refresh folder when the routeName is `folder`', function(assert) {
const subject = this.subject();
subject.routeName = 'dc.kv.folder';
const refresh = this.stub(subject, 'refresh');
subject.afterDelete({}, {});
assert.ok(refresh.calledOnce);
});
skip('action invalidateSession test');
});