ui: Async Search (#4859)

This does several things to make improving the search experience easier
moving forwards:

1. Separate searching off from filtering. 'Searching' can be thought of
as specifically 'text searching' whilst filtering is more of a
boolean/flag search.
2. Decouple the actual searching functionality to almost pure,
isolated / unit testable units and unit test. (I still import embers get
which, once I upgrade to 3.5, I shouldn't need)
3. Searching rules are now configurable from the outside, i.e. not
wrapped in Controllers or Components.
4. General searching itself now can use an asynchronous approach based on
events. This prepares for future possibilities of handing off the
searching to a web worker or elsewhere, which should aid in large scale
searching and prepares the way for other searching methods.
5. Adds the possibility of have multiple searches in one
template/route/page.

Additionally, this adds a WithSearching mixin to aid linking the
searching to ember in an ember-like way in a single place. Plus a
WithListeners mixin to aid with cleaning up of event listeners on
Controller/Component destruction.

Post-initial work I slightly changed the API of create listeners:

Returning the handler from a `remover` means you can re-add it again if you
want to, this avoids having to save a reference to the handler elsewhere
to do the same.

The `remove` method itself now returns an array of handlers, again you
might want to use these again or something, and its also more useful
then just returning an empty array.

The more I look at this the more I doubt that you'll ever use `remove`
to remove individual handlers, you may aswell just use the `remover`
returned from add. I've added some comments to reflect this, but they'll
likely be removed once I'm absolutely sure of this.

I also added some comments for WithSearching to explain possible further
work re: moving `searchParams` so it can be `hung` off the
controller object
This commit is contained in:
John Cowen 2018-11-06 09:10:20 +00:00 committed by John Cowen
parent 9c77f2c52a
commit 74390f2d24
60 changed files with 1543 additions and 510 deletions

View File

@ -0,0 +1,19 @@
import Component from '@ember/component';
import { get, set } from '@ember/object';
import SlotsMixin from 'ember-block-slots';
import WithListeners from 'consul-ui/mixins/with-listeners';
export default Component.extend(WithListeners, SlotsMixin, {
tagName: '',
didReceiveAttrs: function() {
this._super(...arguments);
this.removeListeners();
const dispatcher = get(this, 'dispatcher');
if (dispatcher) {
this.listen(dispatcher, 'change', e => {
set(this, 'items', e.target.data);
});
set(this, 'items', get(dispatcher, 'data'));
}
},
});

View File

@ -1,7 +1,15 @@
import Component from '@ember/component';
import { get } from '@ember/object';
export default Component.extend({
tagName: 'fieldset',
classNames: ['freetext-filter'],
onchange: function(){}
onchange: function(e) {
let searchable = get(this, 'searchable');
if (!Array.isArray(searchable)) {
searchable = [searchable];
}
searchable.forEach(function(item) {
item.search(e.target.value);
});
},
});

View File

@ -1,23 +1,23 @@
import Controller from '@ember/controller';
import { get } from '@ember/object';
import WithFiltering from 'consul-ui/mixins/with-filtering';
export default Controller.extend(WithFiltering, {
import { get, computed } from '@ember/object';
import WithSearching from 'consul-ui/mixins/with-searching';
export default Controller.extend(WithSearching, {
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
filter: function(item, { s = '', type = '' }) {
const sLower = s.toLowerCase();
return (
get(item, 'Name')
.toLowerCase()
.indexOf(sLower) !== -1 ||
get(item, 'Description')
.toLowerCase()
.indexOf(sLower) !== -1
);
init: function() {
this.searchParams = {
policy: 's',
};
this._super(...arguments);
},
searchable: computed('items', function() {
return get(this, 'searchables.policy')
.add(get(this, 'items'))
.search(get(this, this.searchParams.policy));
}),
actions: {},
});

View File

@ -1,30 +1,24 @@
import Controller from '@ember/controller';
import { get } from '@ember/object';
import WithFiltering from 'consul-ui/mixins/with-filtering';
export default Controller.extend(WithFiltering, {
import { computed, get } from '@ember/object';
import WithSearching from 'consul-ui/mixins/with-searching';
export default Controller.extend(WithSearching, {
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
filter: function(item, { s = '', type = '' }) {
const sLower = s.toLowerCase();
return (
get(item, 'AccessorID')
.toLowerCase()
.indexOf(sLower) !== -1 ||
get(item, 'Name')
.toLowerCase()
.indexOf(sLower) !== -1 ||
get(item, 'Description')
.toLowerCase()
.indexOf(sLower) !== -1 ||
(get(item, 'Policies') || []).some(function(item) {
return item.Name.toLowerCase().indexOf(sLower) !== -1;
})
);
init: function() {
this.searchParams = {
token: 's',
};
this._super(...arguments);
},
searchable: computed('items', function() {
return get(this, 'searchables.token')
.add(get(this, 'items'))
.search(get(this, this.searchParams.token));
}),
actions: {
sendClone: function(item) {
this.send('clone', item);

View File

@ -1,6 +1,7 @@
import Controller from '@ember/controller';
import { computed, get } from '@ember/object';
import WithFiltering from 'consul-ui/mixins/with-filtering';
import WithSearching from 'consul-ui/mixins/with-searching';
import ucfirst from 'consul-ui/utils/ucfirst';
// TODO: DRY out in acls at least
const createCounter = function(prop) {
@ -9,7 +10,7 @@ const createCounter = function(prop) {
};
};
const countAction = createCounter('Action');
export default Controller.extend(WithFiltering, {
export default Controller.extend(WithSearching, WithFiltering, {
queryParams: {
action: {
as: 'action',
@ -19,6 +20,17 @@ export default Controller.extend(WithFiltering, {
replace: true,
},
},
init: function() {
this.searchParams = {
intention: 's',
};
this._super(...arguments);
},
searchable: computed('filtered', function() {
return get(this, 'searchables.intention')
.add(get(this, 'filtered'))
.search(get(this, this.searchParams.intention));
}),
actionFilters: computed('items', function() {
const items = get(this, 'items');
return ['', 'allow', 'deny'].map(function(item) {
@ -32,16 +44,6 @@ export default Controller.extend(WithFiltering, {
});
}),
filter: function(item, { s = '', action = '' }) {
const source = get(item, 'SourceName').toLowerCase();
const destination = get(item, 'DestinationName').toLowerCase();
const sLower = s.toLowerCase();
const allLabel = 'All Services (*)'.toLowerCase();
return (
(source.indexOf(sLower) !== -1 ||
destination.indexOf(sLower) !== -1 ||
(source === '*' && allLabel.indexOf(sLower) !== -1) ||
(destination === '*' && allLabel.indexOf(sLower) !== -1)) &&
(action === '' || get(item, 'Action') === action)
);
return action === '' || get(item, 'Action') === action;
},
});

View File

@ -1,18 +1,22 @@
import Controller from '@ember/controller';
import { get } from '@ember/object';
import WithFiltering from 'consul-ui/mixins/with-filtering';
import rightTrim from 'consul-ui/utils/right-trim';
export default Controller.extend(WithFiltering, {
import { get, computed } from '@ember/object';
import WithSearching from 'consul-ui/mixins/with-searching';
export default Controller.extend(WithSearching, {
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
filter: function(item, { s = '' }) {
const key = rightTrim(get(item, 'Key'), '/')
.split('/')
.pop();
return key.toLowerCase().indexOf(s.toLowerCase()) !== -1;
init: function() {
this.searchParams = {
kv: 's',
};
this._super(...arguments);
},
searchable: computed('items', function() {
return get(this, 'searchables.kv')
.add(get(this, 'items'))
.search(get(this, this.searchParams.kv));
}),
});

View File

@ -1,12 +1,26 @@
import Controller from '@ember/controller';
import { computed } from '@ember/object';
import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering';
import WithSearching from 'consul-ui/mixins/with-searching';
import { get } from '@ember/object';
export default Controller.extend(WithHealthFiltering, {
export default Controller.extend(WithSearching, WithHealthFiltering, {
init: function() {
this.searchParams = {
healthyNode: 's',
unhealthyNode: 's',
};
this._super(...arguments);
this.columns = [25, 25, 25, 25];
},
searchableHealthy: computed('healthy', function() {
return get(this, 'searchables.healthyNode')
.add(get(this, 'healthy'))
.search(get(this, this.searchParams.healthyNode));
}),
searchableUnhealthy: computed('unhealthy', function() {
return get(this, 'searchables.unhealthyNode')
.add(get(this, 'unhealthy'))
.search(get(this, this.searchParams.unhealthyNode));
}),
unhealthy: computed('filtered', function() {
return get(this, 'filtered').filter(function(item) {
return get(item, 'isUnhealthy');
@ -18,10 +32,6 @@ export default Controller.extend(WithHealthFiltering, {
});
}),
filter: function(item, { s = '', status = '' }) {
return (
get(item, 'Node')
.toLowerCase()
.indexOf(s.toLowerCase()) !== -1 && item.hasStatus(status)
);
return item.hasStatus(status);
},
});

View File

@ -1,18 +1,29 @@
import Controller from '@ember/controller';
import { get, set } from '@ember/object';
import { get, set, computed } from '@ember/object';
import { getOwner } from '@ember/application';
import WithFiltering from 'consul-ui/mixins/with-filtering';
import WithSearching from 'consul-ui/mixins/with-searching';
import qsaFactory from 'consul-ui/utils/dom/qsa-factory';
import getComponentFactory from 'consul-ui/utils/get-component-factory';
const $$ = qsaFactory();
export default Controller.extend(WithFiltering, {
export default Controller.extend(WithSearching, {
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
init: function() {
this.searchParams = {
nodeservice: 's',
};
this._super(...arguments);
},
searchable: computed('items', function() {
return get(this, 'searchables.nodeservice')
.add(get(this, 'items'))
.search(get(this, this.searchParams.nodeservice));
}),
setProperties: function() {
this._super(...arguments);
// the default selected tab depends on whether you have any healthchecks or not
@ -22,24 +33,6 @@ export default Controller.extend(WithFiltering, {
// need this variable
set(this, 'selectedTab', get(this.item, 'Checks.length') > 0 ? 'health-checks' : 'services');
},
filter: function(item, { s = '' }) {
const term = s.toLowerCase();
return (
get(item, 'Service')
.toLowerCase()
.indexOf(term) !== -1 ||
get(item, 'ID')
.toLowerCase()
.indexOf(term) !== -1 ||
(get(item, 'Tags') || []).some(function(item) {
return item.toLowerCase().indexOf(term) !== -1;
}) ||
get(item, 'Port')
.toString()
.toLowerCase()
.indexOf(term) !== -1
);
},
actions: {
change: function(e) {
set(this, 'selectedTab', e.target.value);

View File

@ -2,6 +2,7 @@ import Controller from '@ember/controller';
import { get, computed } from '@ember/object';
import { htmlSafe } from '@ember/string';
import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering';
import WithSearching from 'consul-ui/mixins/with-searching';
const max = function(arr, prop) {
return arr.reduce(function(prev, item) {
return Math.max(prev, get(item, prop));
@ -24,18 +25,20 @@ const width = function(num) {
const widthDeclaration = function(num) {
return htmlSafe(`width: ${num}px`);
};
export default Controller.extend(WithHealthFiltering, {
export default Controller.extend(WithSearching, WithHealthFiltering, {
init: function() {
this.searchParams = {
service: 's',
};
this._super(...arguments);
},
searchable: computed('filtered', function() {
return get(this, 'searchables.service')
.add(get(this, 'filtered'))
.search(get(this, this.searchParams.service));
}),
filter: function(item, { s = '', status = '' }) {
const term = s.toLowerCase();
return (
(get(item, 'Name')
.toLowerCase()
.indexOf(term) !== -1 ||
(get(item, 'Tags') || []).some(function(item) {
return item.toLowerCase().indexOf(term) !== -1;
})) &&
item.hasStatus(status)
);
return item.hasStatus(status);
},
maxWidth: computed('{maxPassing,maxWarning,maxCritical}', function() {
const PADDING = 32 * 3 + 13;

View File

@ -4,10 +4,25 @@ import { computed } from '@ember/object';
import sumOfUnhealthy from 'consul-ui/utils/sumOfUnhealthy';
import hasStatus from 'consul-ui/utils/hasStatus';
import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering';
export default Controller.extend(WithHealthFiltering, {
import WithSearching from 'consul-ui/mixins/with-searching';
export default Controller.extend(WithSearching, WithHealthFiltering, {
init: function() {
this.searchParams = {
healthyServiceNode: 's',
unhealthyServiceNode: 's',
};
this._super(...arguments);
},
searchableHealthy: computed('healthy', function() {
return get(this, 'searchables.healthyServiceNode')
.add(get(this, 'healthy'))
.search(get(this, this.searchParams.healthyServiceNode));
}),
searchableUnhealthy: computed('unhealthy', function() {
return get(this, 'searchables.unhealthyServiceNode')
.add(get(this, 'unhealthy'))
.search(get(this, this.searchParams.unhealthyServiceNode));
}),
unhealthy: computed('filtered', function() {
return get(this, 'filtered').filter(function(item) {
return sumOfUnhealthy(item.Checks) > 0;
@ -19,16 +34,6 @@ export default Controller.extend(WithHealthFiltering, {
});
}),
filter: function(item, { s = '', status = '' }) {
const term = s.toLowerCase();
return (
get(item, 'Node.Node')
.toLowerCase()
.indexOf(term) !== -1 ||
(get(item, 'Service.ID')
.toLowerCase()
.indexOf(term) !== -1 &&
hasStatus(get(item, 'Checks'), status))
);
return hasStatus(get(item, 'Checks'), status);
},
});

View File

@ -0,0 +1,37 @@
import intention from 'consul-ui/search/filters/intention';
import token from 'consul-ui/search/filters/token';
import policy from 'consul-ui/search/filters/policy';
import kv from 'consul-ui/search/filters/kv';
import node from 'consul-ui/search/filters/node';
// service instance
import nodeService from 'consul-ui/search/filters/node/service';
import serviceNode from 'consul-ui/search/filters/service/node';
import service from 'consul-ui/search/filters/service';
import filterableFactory from 'consul-ui/utils/search/filterable';
const filterable = filterableFactory();
export function initialize(application) {
// Service-less injection using private properties at a per-project level
const Builder = application.resolveRegistration('service:search');
const searchables = {
intention: intention(filterable),
token: token(filterable),
policy: policy(filterable),
kv: kv(filterable),
healthyNode: node(filterable),
unhealthyNode: node(filterable),
healthyServiceNode: serviceNode(filterable),
unhealthyServiceNode: serviceNode(filterable),
nodeservice: nodeService(filterable),
service: service(filterable),
};
Builder.reopen({
searchable: function(name) {
return searchables[name];
},
});
}
export default {
initialize,
};

View File

@ -0,0 +1,27 @@
import Component from '@ember/component';
import Mixin from '@ember/object/mixin';
import { inject as service } from '@ember/service';
import { get } from '@ember/object';
export default Mixin.create({
dom: service('dom'),
init: function() {
this._super(...arguments);
this._listeners = get(this, 'dom').listeners();
let method = 'willDestroy';
if (this instanceof Component) {
method = 'willDestroyElement';
}
const destroy = this[method];
this[method] = function() {
destroy(...arguments);
this.removeListeners();
};
},
listen: function(target, event, handler) {
return this._listeners.add(...arguments);
},
removeListeners: function() {
return this._listeners.remove(...arguments);
},
});

View File

@ -0,0 +1,32 @@
import Mixin from '@ember/object/mixin';
import { inject as service } from '@ember/service';
import { get, set } from '@ember/object';
import WithListeners from 'consul-ui/mixins/with-listeners';
/**
* WithSearching mostly depends on a `searchParams` object which must be set
* inside the `init` function. The naming and usage of this is modelled on
* `queryParams` but in contrast cannot _yet_ be 'hung' of the Controller
* object, it MUST be set in the `init` method.
* Reasons: As well as producing a eslint error, it can also be 'shared' amongst
* child Classes of the component. It is not clear _yet_ whether mixing this in
* avoids this and is something to be looked at in future to slightly improve DX
* Please also see:
* https://emberjs.com/api/ember/2.12/classes/Ember.Object/properties?anchor=mergedProperties
*
*/
export default Mixin.create(WithListeners, {
builder: service('search'),
init: function() {
this._super(...arguments);
const params = this.searchParams || {};
this.searchables = {};
Object.keys(params).forEach(type => {
const key = params[type];
this.searchables[type] = get(this, 'builder').searchable(type);
this.listen(this.searchables[type], 'change', e => {
const value = e.target.value;
set(this, key, value === '' ? null : value);
});
});
},
});

View File

@ -0,0 +1,15 @@
import { get } from '@ember/object';
export default function(filterable) {
return filterable(function(item, { s = '' }) {
const source = get(item, 'SourceName').toLowerCase();
const destination = get(item, 'DestinationName').toLowerCase();
const sLower = s.toLowerCase();
const allLabel = 'All Services (*)'.toLowerCase();
return (
source.indexOf(sLower) !== -1 ||
destination.indexOf(sLower) !== -1 ||
(source === '*' && allLabel.indexOf(sLower) !== -1) ||
(destination === '*' && allLabel.indexOf(sLower) !== -1)
);
});
}

View File

@ -0,0 +1,10 @@
import { get } from '@ember/object';
import rightTrim from 'consul-ui/utils/right-trim';
export default function(filterable) {
return filterable(function(item, { s = '' }) {
const key = rightTrim(get(item, 'Key'), '/')
.split('/')
.pop();
return key.toLowerCase().indexOf(s.toLowerCase()) !== -1;
});
}

View File

@ -0,0 +1,11 @@
import { get } from '@ember/object';
export default function(filterable) {
return filterable(function(item, { s = '' }) {
const sLower = s.toLowerCase();
return (
get(item, 'Node')
.toLowerCase()
.indexOf(sLower) !== -1
);
});
}

View File

@ -0,0 +1,21 @@
import { get } from '@ember/object';
export default function(filterable) {
return filterable(function(item, { s = '' }) {
const term = s.toLowerCase();
return (
get(item, 'Service')
.toLowerCase()
.indexOf(term) !== -1 ||
get(item, 'ID')
.toLowerCase()
.indexOf(term) !== -1 ||
(get(item, 'Tags') || []).some(function(item) {
return item.toLowerCase().indexOf(term) !== -1;
}) ||
get(item, 'Port')
.toString()
.toLowerCase()
.indexOf(term) !== -1
);
});
}

View File

@ -0,0 +1,14 @@
import { get } from '@ember/object';
export default function(filterable) {
return filterable(function(item, { s = '' }) {
const sLower = s.toLowerCase();
return (
get(item, 'Name')
.toLowerCase()
.indexOf(sLower) !== -1 ||
get(item, 'Description')
.toLowerCase()
.indexOf(sLower) !== -1
);
});
}

View File

@ -0,0 +1,14 @@
import { get } from '@ember/object';
export default function(filterable) {
return filterable(function(item, { s = '' }) {
const term = s.toLowerCase();
return (
get(item, 'Name')
.toLowerCase()
.indexOf(term) !== -1 ||
(get(item, 'Tags') || []).some(function(item) {
return item.toLowerCase().indexOf(term) !== -1;
})
);
});
}

View File

@ -0,0 +1,14 @@
import { get } from '@ember/object';
export default function(filterable) {
return filterable(function(item, { s = '' }) {
const term = s.toLowerCase();
return (
get(item, 'Node.Node')
.toLowerCase()
.indexOf(term) !== -1 ||
get(item, 'Service.ID')
.toLowerCase()
.indexOf(term) !== -1
);
});
}

View File

@ -0,0 +1,21 @@
import { get } from '@ember/object';
export default function(filterable) {
return filterable(function(item, { s = '' }) {
const sLower = s.toLowerCase();
return (
get(item, 'AccessorID')
.toLowerCase()
.indexOf(sLower) !== -1 ||
// TODO: Check if Name can go, it was just for legacy
get(item, 'Name')
.toLowerCase()
.indexOf(sLower) !== -1 ||
get(item, 'Description')
.toLowerCase()
.indexOf(sLower) !== -1 ||
(get(item, 'Policies') || []).some(function(item) {
return item.Name.toLowerCase().indexOf(sLower) !== -1;
})
);
});
}

View File

@ -6,6 +6,7 @@ import qsaFactory from 'consul-ui/utils/dom/qsa-factory';
// TODO: Move to utils/dom
import getComponentFactory from 'consul-ui/utils/get-component-factory';
import normalizeEvent from 'consul-ui/utils/dom/normalize-event';
import createListeners from 'consul-ui/utils/dom/create-listeners';
// ember-eslint doesn't like you using a single $ so use double
// use $_ for components
@ -20,6 +21,7 @@ export default Service.extend({
normalizeEvent: function() {
return normalizeEvent(...arguments);
},
listeners: createListeners,
root: function() {
return get(this, 'doc').documentElement;
},

View File

@ -0,0 +1,2 @@
import Service from '@ember/service';
export default Service.extend({});

View File

@ -1,4 +1,4 @@
{{!<form>}}
{{freetext-filter value=search placeholder="Search by name" onchange=(action onchange)}}
{{freetext-filter searchable=searchable value=search placeholder="Search by name"}}
{{radio-group name="status" value=status items=filters onchange=(action onchange)}}
{{!</form>}}

View File

@ -0,0 +1,6 @@
{{yield}}
{{#if (gt items.length 0)}}
{{#yield-slot 'set' (block-params items)}}{{yield}}{{/yield-slot}}
{{else}}
{{#yield-slot 'empty'}}{{yield}}{{/yield-slot}}
{{/if}}

View File

@ -1,4 +1,4 @@
{{!<form>}}
{{freetext-filter onchange=(action onchange) value=search placeholder="Search by Source or Destination"}}
{{freetext-filter searchable=searchable value=search placeholder="Search by Source or Destination"}}
{{radio-group name="action" value=action items=filters onchange=(action onchange)}}
{{!</form>}}

View File

@ -22,60 +22,63 @@
{{#block-slot 'content'}}
{{#if (gt items.length 0) }}
<form class="filter-bar">
{{freetext-filter onchange=(action 'filter') value=filter.s placeholder="Search"}}
{{freetext-filter searchable=searchable value=s placeholder="Search"}}
</form>
{{/if}}
{{#if (gt filtered.length 0)}}
{{#tabular-collection
items=(sort-by 'CreateIndex:desc' 'Name:asc' filtered) as |item index|
}}
{{#block-slot 'header'}}
<th>Name</th>
<th>Datacenters</th>
<th>Description</th>
{{/block-slot}}
{{#block-slot 'row' }}
<td data-test-policy="{{item.Name}}">
<a href={{href-to 'dc.acls.policies.edit' item.ID}} class={{if (policy/is-management item) 'is-management'}}>{{item.Name}}</a>
</td>
<td>
{{join ', ' (policy/datacenters item)}}
</td>
<td>
{{item.Description}}
</td>
{{/block-slot}}
{{#block-slot 'actions' as |index change checked|}}
{{#confirmation-dialog confirming=false index=index message="Are you sure you want to delete this Policy?"}}
{{#block-slot 'action' as |confirm|}}
{{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}}
<ul>
{{#if (policy/is-management item)}}
<li>
<a data-test-edit href={{href-to 'dc.acls.policies.edit' item.ID}}>View</a>
</li>
{{else}}
{{#changeable-set dispatcher=searchable}}
{{#block-slot 'set' as |filtered|}}
{{#tabular-collection
items=(sort-by 'CreateIndex:desc' 'Name:asc' filtered) as |item index|
}}
{{#block-slot 'header'}}
<th>Name</th>
<th>Datacenters</th>
<th>Description</th>
{{/block-slot}}
{{#block-slot 'row' }}
<td data-test-policy="{{item.Name}}">
<a href={{href-to 'dc.acls.policies.edit' item.ID}} class={{if (policy/is-management item) 'is-management'}}>{{item.Name}}</a>
</td>
<td>
{{join ', ' (policy/datacenters item)}}
</td>
<td>
{{item.Description}}
</td>
{{/block-slot}}
{{#block-slot 'actions' as |index change checked|}}
{{#confirmation-dialog confirming=false index=index message="Are you sure you want to delete this Policy?"}}
{{#block-slot 'action' as |confirm|}}
{{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}}
<ul>
{{#if (policy/is-management item)}}
<li>
<a data-test-edit href={{href-to 'dc.acls.policies.edit' item.ID}}>View</a>
</li>
{{else}}
<li>
<a data-test-edit href={{href-to 'dc.acls.policies.edit' item.ID}}>Edit</a>
</li>
<li>
<a data-test-delete onclick={{action confirm 'delete' item}}>Delete</a>
</li>
{{/if}}
</ul>
{{/action-group}}
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message name|}}
{{delete-confirmation message=message execute=execute cancel=cancel}}
{{/block-slot}}
{{/confirmation-dialog}}
{{/block-slot}}
{{/tabular-collection}}
{{else}}
<p>
There are no Policies.
</p>
{{/if}}
<li>
<a data-test-edit href={{href-to 'dc.acls.policies.edit' item.ID}}>Edit</a>
</li>
<li>
<a data-test-delete onclick={{action confirm 'delete' item}}>Delete</a>
</li>
{{/if}}
</ul>
{{/action-group}}
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message name|}}
{{delete-confirmation message=message execute=execute cancel=cancel}}
{{/block-slot}}
{{/confirmation-dialog}}
{{/block-slot}}
{{/tabular-collection}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no Policies.
</p>
{{/block-slot}}
{{/changeable-set}}
{{/block-slot}}
{{/app-view}}

View File

@ -1,131 +1,133 @@
{{#app-view class=(concat 'token ' (if (and isEnabled (not isAuthorized)) 'edit' 'list')) loading=isLoading authorized=isAuthorized enabled=isEnabled}}
{{#block-slot 'notification' as |status type subject|}}
{{partial 'dc/acls/tokens/notifications'}}
{{/block-slot}}
{{#block-slot 'header'}}
<h1>
Access Controls
</h1>
{{#if isAuthorized }}
{{partial 'dc/acls/nav'}}
{{/if}}
{{/block-slot}}
{{#block-slot 'disabled'}}
{{partial 'dc/acls/disabled'}}
{{/block-slot}}
{{#block-slot 'authorization'}}
{{partial 'dc/acls/authorization'}}
{{/block-slot}}
{{#block-slot 'actions'}}
<a data-test-create href="{{href-to 'dc.acls.tokens.create'}}" class="type-create">Create</a>
{{/block-slot}}
{{#block-slot 'content'}}
{{#if (gt items.length 0) }}
<form class="filter-bar">
{{freetext-filter onchange=(action 'filter') value=filter.s placeholder="Search"}}
</form>
{{#block-slot 'notification' as |status type subject|}}
{{partial 'dc/acls/tokens/notifications'}}
{{/block-slot}}
{{#block-slot 'header'}}
<h1>
Access Controls
</h1>
{{#if isAuthorized }}
{{partial 'dc/acls/nav'}}
{{/if}}
{{#if (token/is-legacy items)}}
{{/block-slot}}
{{#block-slot 'disabled'}}
{{partial 'dc/acls/disabled'}}
{{/block-slot}}
{{#block-slot 'authorization'}}
{{partial 'dc/acls/authorization'}}
{{/block-slot}}
{{#block-slot 'actions'}}
<a data-test-create href="{{href-to 'dc.acls.tokens.create'}}" class="type-create">Create</a>
{{/block-slot}}
{{#block-slot 'content'}}
{{#if (gt items.length 0) }}
<form class="filter-bar">
{{freetext-filter searchable=searchable value=s placeholder="Search"}}
</form>
{{/if}}
{{#if (token/is-legacy items)}}
<p data-test-notification-update class="notice info"><strong>Update.</strong> We have upgraded our ACL System to allow the creation of reusable policies that can be applied to tokens. Read more about the changes and how to upgrade legacy tokens in our <a href="{{env 'CONSUL_DOCUMENTATION_URL'}}/guides/acl-migrate-tokens.html" target="_blank" rel="noopener noreferrer">documentation</a>.</p>
{{/if}}
{{#if (gt filtered.length 0)}}
{{/if}}
{{#changeable-set dispatcher=searchable}}
{{#block-slot 'set' as |filtered|}}
{{#tabular-collection
items=(sort-by 'CreateTime:desc' filtered) as |item index|
}}
{{#block-slot 'header'}}
<th>Accessor ID</th>
<th>Scope</th>
<th>Description</th>
<th>Policies</th>
<th>&nbsp;</th>
{{/block-slot}}
{{#block-slot 'row'}}
<td data-test-token="{{item.AccessorID}}" class={{if (eq item.AccessorID token.AccessorID) 'me' }}>
<a href={{href-to 'dc.acls.tokens.edit' item.AccessorID}}>{{truncate item.AccessorID 8 false}}</a>
</td>
<td>
{{if item.Local 'local' 'global' }}
</td>
<td>
{{default item.Description item.Name}}
</td>
<td colspan={{if (not-eq item.AccessorID token.AccessorID) '2' }}>
{{#if (token/is-legacy item) }}
Legacy tokens have embedded policies.
{{ else }}
{{#each item.Policies as |item|}}
<strong class={{if (policy/is-management item) 'policy-management' }}>{{item.Name}}</strong>
{{/each}}
{{/if}}
</td>
{{#if (eq item.AccessorID token.AccessorID)}}
<td>Your token</td>
{{/if}}
{{/block-slot}}
{{#block-slot 'actions' as |index change checked|}}
{{#confirmation-dialog confirming=false index=index message="Are you sure you want to delete this Token?"}}
{{#block-slot 'action' as |confirm|}}
{{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}}
<ul>
{{#if false}}
<li>
{{#copy-button-feedback title="Copy AccessorID to the clipboard" copy=item.AccessorID name="AccessorID"}}Copy AccessorID{{/copy-button-feedback}}
</li>
{{/if}}
<li>
<a data-test-edit href={{href-to 'dc.acls.tokens.edit' item.AccessorID}}>Edit</a>
</li>
{{#block-slot 'header'}}
<th>Accessor ID</th>
<th>Scope</th>
<th>Description</th>
<th>Policies</th>
<th>&nbsp;</th>
{{/block-slot}}
{{#block-slot 'row'}}
<td data-test-token="{{item.AccessorID}}" class={{if (eq item.AccessorID token.AccessorID) 'me' }}>
<a href={{href-to 'dc.acls.tokens.edit' item.AccessorID}}>{{truncate item.AccessorID 8 false}}</a>
</td>
<td>
{{if item.Local 'local' 'global' }}
</td>
<td>
{{default item.Description item.Name}}
</td>
<td colspan={{if (not-eq item.AccessorID token.AccessorID) '2' }}>
{{#if (token/is-legacy item) }}
Legacy tokens have embedded policies.
{{ else }}
{{#each item.Policies as |item|}}
<strong class={{if (policy/is-management item) 'policy-management' }}>{{item.Name}}</strong>
{{/each}}
{{/if}}
</td>
{{#if (eq item.AccessorID token.AccessorID)}}
<td>Your token</td>
{{/if}}
{{/block-slot}}
{{#block-slot 'actions' as |index change checked|}}
{{#confirmation-dialog confirming=false index=index message="Are you sure you want to delete this Token?"}}
{{#block-slot 'action' as |confirm|}}
{{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}}
<ul>
{{#if false}}
<li>
{{#copy-button-feedback title="Copy AccessorID to the clipboard" copy=item.AccessorID name="AccessorID"}}Copy AccessorID{{/copy-button-feedback}}
</li>
{{/if}}
<li>
<a data-test-edit href={{href-to 'dc.acls.tokens.edit' item.AccessorID}}>Edit</a>
</li>
{{#if (not (token/is-legacy item))}}
<li>
<a data-test-clone onclick={{action 'sendClone' item}}>Duplicate</a>
</li>
<li>
<a data-test-clone onclick={{action 'sendClone' item}}>Duplicate</a>
</li>
{{/if}}
{{#if (eq item.AccessorID token.AccessorID) }}
<li>
<a data-test-logout onclick={{queue (action confirm 'logout' item) (action change)}}>Stop using</a>
</li>
<li>
<a data-test-logout onclick={{queue (action confirm 'logout' item) (action change)}}>Stop using</a>
</li>
{{else}}
<li>
<a data-test-use onclick={{queue (action confirm 'use' item) (action change)}}>Use</a>
</li>
<li>
<a data-test-use onclick={{queue (action confirm 'use' item) (action change)}}>Use</a>
</li>
{{/if}}
{{#unless (or (token/is-anonymous item) (eq item.AccessorID token.AccessorID)) }}
<li>
<a data-test-delete onclick={{action confirm 'delete' item}}>Delete</a>
</li>
<li>
<a data-test-delete onclick={{action confirm 'delete' item}}>Delete</a>
</li>
{{/unless}}
</ul>
{{/action-group}}
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message name|}}
<p>
{{#if (eq name 'delete')}}
{{message}}
{{else if (eq name 'logout')}}
Are you sure you want to stop using this ACL token? This will log you out.
{{else if (eq name 'use')}}
Are you sure you want to use this ACL token?
{{/if}}
</p>
<button type="button" class="type-delete" {{action execute}}>
{{#if (eq name 'delete')}}
Confirm Delete
{{else if (eq name 'logout')}}
Confirm Logout
{{ else if (eq name 'use')}}
Confirm Use
{{/if}}
</button>
<button type="button" class="type-cancel" {{action cancel}}>Cancel</button>
{{/block-slot}}
{{/confirmation-dialog}}
{{/block-slot}}
</ul>
{{/action-group}}
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message name|}}
<p>
{{#if (eq name 'delete')}}
{{message}}
{{else if (eq name 'logout')}}
Are you sure you want to stop using this ACL token? This will log you out.
{{else if (eq name 'use')}}
Are you sure you want to use this ACL token?
{{/if}}
</p>
<button type="button" class="type-delete" {{action execute}}>
{{#if (eq name 'delete')}}
Confirm Delete
{{else if (eq name 'logout')}}
Confirm Logout
{{ else if (eq name 'use')}}
Confirm Use
{{/if}}
</button>
<button type="button" class="type-cancel" {{action cancel}}>Cancel</button>
{{/block-slot}}
{{/confirmation-dialog}}
{{/block-slot}}
{{/tabular-collection}}
{{else}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no Tokens.
There are no Tokens.
</p>
{{/if}}
{{/block-slot}}
{{/block-slot}}
{{/changeable-set}}
{{/block-slot}}
{{/app-view}}

View File

@ -13,70 +13,73 @@
{{/block-slot}}
{{#block-slot 'toolbar'}}
{{#if (gt items.length 0) }}
{{intention-filter filters=actionFilters search=filters.s type=filters.action onchange=(action 'filter')}}
{{intention-filter searchable=searchable filters=actionFilters search=filters.s type=filters.action onchange=(action 'filter')}}
{{/if}}
{{/block-slot}}
{{#block-slot 'content'}}
{{#if (gt filtered.length 0) }}
{{#tabular-collection
route='dc.intentions.edit'
key='SourceName'
items=filtered as |item index|
}}
{{#block-slot 'header'}}
<th>Source</th>
<th>&nbsp;</th>
<th>Destination</th>
<th>Precedence</th>
{{/block-slot}}
{{#block-slot 'row'}}
<td class="source" data-test-intention="{{item.ID}}">
<a href={{href-to 'dc.intentions.edit' item.ID}} data-test-intention-source="{{item.SourceName}}">
{{#if (eq item.SourceName '*') }}
All Services (*)
{{else}}
{{item.SourceName}}
{{/if}}
</a>
</td>
<td class="intent-{{item.Action}}" data-test-intention-action="{{item.Action}}">
<strong>{{item.Action}}</strong>
</td>
<td class="destination" data-test-intention-destination="{{item.DestinationName}}">
{{#if (eq item.DestinationName '*') }}
All Services (*)
{{else}}
{{item.DestinationName}}
{{/if}}
</td>
<td class="precedence">
{{item.Precedence}}
</td>
{{/block-slot}}
{{#block-slot 'actions' as |index change checked|}}
{{#confirmation-dialog confirming=false index=index message='Are you sure you want to delete this intention?'}}
{{#block-slot 'action' as |confirm|}}
{{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}}
<ul>
<li>
<a href={{href-to 'dc.intentions.edit' item.ID}}>Edit</a>
</li>
<li>
<a data-test-delete onclick={{action confirm 'delete' item}}>Delete</a>
</li>
</ul>
{{/action-group}}
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message|}}
{{delete-confirmation message=message execute=execute cancel=cancel}}
{{/block-slot}}
{{/confirmation-dialog}}
{{/block-slot}}
{{/tabular-collection}}
{{else}}
<p>
There are no intentions.
</p>
{{/if}}
{{#changeable-set dispatcher=searchable}}
{{#block-slot 'set' as |filtered|}}
{{#tabular-collection
route='dc.intentions.edit'
key='SourceName'
items=filtered as |item index|
}}
{{#block-slot 'header'}}
<th>Source</th>
<th>&nbsp;</th>
<th>Destination</th>
<th>Precedence</th>
{{/block-slot}}
{{#block-slot 'row'}}
<td class="source" data-test-intention="{{item.ID}}">
<a href={{href-to 'dc.intentions.edit' item.ID}} data-test-intention-source="{{item.SourceName}}">
{{#if (eq item.SourceName '*') }}
All Services (*)
{{else}}
{{item.SourceName}}
{{/if}}
</a>
</td>
<td class="intent-{{item.Action}}" data-test-intention-action="{{item.Action}}">
<strong>{{item.Action}}</strong>
</td>
<td class="destination" data-test-intention-destination="{{item.DestinationName}}">
{{#if (eq item.DestinationName '*') }}
All Services (*)
{{else}}
{{item.DestinationName}}
{{/if}}
</td>
<td class="precedence">
{{item.Precedence}}
</td>
{{/block-slot}}
{{#block-slot 'actions' as |index change checked|}}
{{#confirmation-dialog confirming=false index=index message='Are you sure you want to delete this intention?'}}
{{#block-slot 'action' as |confirm|}}
{{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}}
<ul>
<li>
<a href={{href-to 'dc.intentions.edit' item.ID}}>Edit</a>
</li>
<li>
<a data-test-delete onclick={{action confirm 'delete' item}}>Delete</a>
</li>
</ul>
{{/action-group}}
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message|}}
{{delete-confirmation message=message execute=execute cancel=cancel}}
{{/block-slot}}
{{/confirmation-dialog}}
{{/block-slot}}
{{/tabular-collection}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no intentions.
</p>
{{/block-slot}}
{{/changeable-set}}
{{/block-slot}}
{{/app-view}}

View File

@ -25,7 +25,7 @@
{{#block-slot 'toolbar'}}
{{#if (gt items.length 0) }}
<form class="filter-bar">
{{freetext-filter onchange=(action 'filter') value=filter.s placeholder="Search by name"}}
{{freetext-filter searchable=searchable value=s placeholder="Search by name"}}
</form>
{{/if}}
{{/block-slot}}
@ -37,40 +37,45 @@
{{/if}}
{{/block-slot}}
{{#block-slot 'content'}}
{{#if (gt filtered.length 0)}}
{{#tabular-collection
items=(sort-by 'isFolder:desc' 'Key:asc' filtered) as |item index|
}}
{{#block-slot 'header'}}
<th>Name</th>
{{/block-slot}}
{{#block-slot '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>
{{/block-slot}}
{{#block-slot 'actions' as |index change checked|}}
{{#confirmation-dialog confirming=false index=index message='Are you sure you want to delete this key?'}}
{{#block-slot 'action' as |confirm|}}
{{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}}
<ul>
<li>
<a data-test-edit href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>{{if item.isFolder 'View' 'Edit'}}</a>
</li>
<li>
<a data-test-delete onclick={{action confirm 'delete' item}}>Delete</a>
</li>
</ul>
{{/action-group}}
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message|}}
{{delete-confirmation message=message execute=execute cancel=cancel}}
{{/block-slot}}
{{/confirmation-dialog}}
{{/block-slot}}
{{/tabular-collection}}
{{else}}
<p>There are no Key / Value pairs.</p>
{{/if}}
{{#changeable-set dispatcher=searchable}}
{{#block-slot 'set' as |filtered|}}
{{#tabular-collection
items=(sort-by 'isFolder:desc' 'Key:asc' filtered) as |item index|
}}
{{#block-slot 'header'}}
<th>Name</th>
{{/block-slot}}
{{#block-slot '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>
{{/block-slot}}
{{#block-slot 'actions' as |index change checked|}}
{{#confirmation-dialog confirming=false index=index message='Are you sure you want to delete this key?'}}
{{#block-slot 'action' as |confirm|}}
{{#action-group index=index onchange=(action change) checked=(if (eq checked index) 'checked')}}
<ul>
<li>
<a data-test-edit href={{href-to (if item.isFolder 'dc.kv.folder' 'dc.kv.edit') item.Key}}>{{if item.isFolder 'View' 'Edit'}}</a>
</li>
<li>
<a data-test-delete onclick={{action confirm 'delete' item}}>Delete</a>
</li>
</ul>
{{/action-group}}
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message|}}
{{delete-confirmation message=message execute=execute cancel=cancel}}
{{/block-slot}}
{{/confirmation-dialog}}
{{/block-slot}}
{{/tabular-collection}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no Key / Value pairs.
</p>
{{/block-slot}}
{{/changeable-set}}
{{/block-slot}}
{{/app-view}}

View File

@ -1,40 +1,43 @@
{{#if (gt items.length 0) }}
<input type="checkbox" id="toolbar-toggle" />
<form class="filter-bar">
{{freetext-filter onchange=(action 'filter') value=filter.s placeholder="Search by name/port"}}
</form>
<input type="checkbox" id="toolbar-toggle" />
<form class="filter-bar">
{{freetext-filter searchable=searchable value=s placeholder="Search by name/port"}}
</form>
{{/if}}
{{#if (gt filtered.length 0)}}
{{#tabular-collection
data-test-services
items=filtered as |item index|
}}
{{#block-slot 'header'}}
<th>Service</th>
<th>Port</th>
<th>Tags</th>
{{/block-slot}}
{{#block-slot 'row'}}
<td data-test-service-name="{{item.Service}}">
<a href={{href-to 'dc.services.show' item.Service}}>
<span data-test-external-source="{{service/external-source item}}" style={{{ concat 'background-image: ' (css-var (concat '--' (service/external-source item) '-color-svg') 'none')}}}></span>
{{item.Service}}{{#if (not-eq item.ID item.Service) }}<em data-test-service-id="{{item.ID}}">({{item.ID}})</em>{{/if}}
</a>
</td>
<td data-test-service-port="{{item.Port}}" class="port">
{{item.Port}}
</td>
<td data-test-service-tags class="tags">
{{#if (gt item.Tags.length 0)}}
{{#each item.Tags as |item|}}
<span>{{item}}</span>
{{/each}}
{{/if}}
</td>
{{/block-slot}}
{{/tabular-collection}}
{{else}}
<p>
{{#changeable-set dispatcher=searchable}}
{{#block-slot 'set' as |filtered|}}
{{#tabular-collection
data-test-services
items=filtered as |item index|
}}
{{#block-slot 'header'}}
<th>Service</th>
<th>Port</th>
<th>Tags</th>
{{/block-slot}}
{{#block-slot 'row'}}
<td data-test-service-name="{{item.Service}}">
<a href={{href-to 'dc.services.show' item.Service}}>
<span data-test-external-source="{{service/external-source item}}" style={{{ concat 'background-image: ' (css-var (concat '--' (service/external-source item) '-color-svg') 'none')}}}></span>
{{item.Service}}{{#if (not-eq item.ID item.Service) }}<em data-test-service-id="{{item.ID}}">({{item.ID}})</em>{{/if}}
</a>
</td>
<td data-test-service-port="{{item.Port}}" class="port">
{{item.Port}}
</td>
<td data-test-service-tags class="tags">
{{#if (gt item.Tags.length 0)}}
{{#each item.Tags as |item|}}
<span>{{item}}</span>
{{/each}}
{{/if}}
</td>
{{/block-slot}}
{{/tabular-collection}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no services.
</p>
{{/if}}
</p>
{{/block-slot}}
{{/changeable-set}}

View File

@ -7,7 +7,7 @@
{{/block-slot}}
{{#block-slot 'toolbar'}}
{{#if (gt items.length 0) }}
{{catalog-filter filters=healthFilters search=filters.s status=filters.status onchange=(action 'filter')}}
{{catalog-filter searchable=(array searchableHealthy searchableUnhealthy) filters=healthFilters search=s status=filters.status onchange=(action 'filter')}}
{{/if}}
{{/block-slot}}
{{#block-slot 'content'}}
@ -16,33 +16,51 @@
<h2>Unhealthy Nodes</h2>
<div>
{{! think about 2 differing views here }}
<ol>
{{#each unhealthy as |item|}}
{{healthchecked-resource
tagName='li'
data-test-node=item.Node
href=(href-to 'dc.nodes.show' item.Node)
name=item.Node
address=item.Address
checks=item.Checks
}}
{{/each}}
</ol>
<ul>
{{#changeable-set dispatcher=searchableUnhealthy}}
{{#block-slot 'set' as |unhealthy|}}
{{#each unhealthy as |item|}}
{{healthchecked-resource
tagName='li'
data-test-node=item.Node
href=(href-to 'dc.nodes.show' item.Node)
name=item.Node
address=item.Address
checks=item.Checks
}}
{{/each}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no unhealthy nodes for that search.
</p>
{{/block-slot}}
{{/changeable-set}}
</ul>
</div>
</div>
{{/if}}
{{#if (gt healthy.length 0) }}
<div class="healthy">
<h2>Healthy Nodes</h2>
{{#list-collection cellHeight=92 items=healthy as |item index|}}
{{healthchecked-resource
data-test-node=item.Node
href=(href-to 'dc.nodes.show' item.Node)
name=item.Node
address=item.Address
status=item.Checks.[0].Status
}}
{{/list-collection}}
{{#changeable-set dispatcher=searchableHealthy}}
{{#block-slot 'set' as |healthy|}}
{{#list-collection cellHeight=92 items=healthy as |item index|}}
{{healthchecked-resource
data-test-node=item.Node
href=(href-to 'dc.nodes.show' item.Node)
name=item.Node
address=item.Address
status=item.Checks.[0].Status
}}
{{/list-collection}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no healthy nodes for that search.
</p>
{{/block-slot}}
{{/changeable-set}}
</div>
{{/if}}
{{#if (and (eq healthy.length 0) (eq unhealthy.length 0)) }}

View File

@ -11,57 +11,59 @@
{{/block-slot}}
{{#block-slot 'toolbar'}}
{{#if (gt items.length 0) }}
{{catalog-filter filters=healthFilters search=filters.s status=filters.status onchange=(action 'filter')}}
{{catalog-filter searchable=searchable filters=healthFilters search=filters.s status=filters.status onchange=(action 'filter')}}
{{/if}}
{{/block-slot}}
{{#block-slot 'content'}}
{{#if (gt filtered.length 0) }}
{{#tabular-collection
route='dc.services.show'
key='Name'
items=filtered as |item index|
}}
{{#block-slot 'header'}}
<th style={{remainingWidth}}>Service</th>
<th style={{totalWidth}}>Health Checks<span><em>The number of health checks for the service on all nodes</em></span></th>
<th style={{remainingWidth}}>Tags</th>
{{/block-slot}}
{{#block-slot 'row'}}
<td data-test-service="{{item.Name}}" style={{remainingWidth}}>
<a href={{href-to 'dc.services.show' item.Name}}>
<span data-test-external-source="{{service/external-source item}}" style={{{ concat 'background-image: ' (css-var (concat '--' (service/external-source item) '-color-svg') 'none')}}}></span>
{{item.Name}}
</a>
</td>
<td style={{totalWidth}}>
{{#if (and (lt item.ChecksPassing 1) (lt item.ChecksWarning 1) (lt item.ChecksCritical 1) )}}
<span title="No Healthchecks" class="zero">0</span>
{{else}}
<dl>
<dt title="Passing" class="passing{{if (lt item.ChecksPassing 1) ' zero'}}">Healthchecks Passing</dt>
<dd title="Passing" class={{if (lt item.ChecksPassing 1) 'zero'}} style={{passingWidth}}>{{format-number item.ChecksPassing}}</dd>
<dt title="Warning" class="warning{{if (lt item.ChecksWarning 1) ' zero'}}">Healthchecks Warning</dt>
<dd title="Warning" class={{if (lt item.ChecksWarning 1) 'zero'}} style={{warningWidth}}>{{format-number item.ChecksWarning}}</dd>
<dt title="Critical" class="critical{{if (lt item.ChecksCritical 1) ' zero'}}">Healthchecks Critical</dt>
<dd title="Critical" class={{if (lt item.ChecksCritical 1) 'zero'}} style={{criticalWidth}}>{{format-number item.ChecksCritical}}</dd>
</dl>
{{/if}}
</td>
<td class="tags" style={{remainingWidth}}>
{{#if (gt item.Tags.length 0)}}
{{#each item.Tags as |item|}}
<span>{{item}}</span>
{{/each}}
{{/if}}
</td>
{{/block-slot}}
{{/tabular-collection}}
{{else}}
<p>
There are no services.
</p>
{{/if}}
{{#changeable-set dispatcher=searchable}}
{{#block-slot 'set' as |filtered|}}
{{#tabular-collection
route='dc.services.show'
key='Name'
items=filtered as |item index|
}}
{{#block-slot 'header'}}
<th style={{remainingWidth}}>Service</th>
<th style={{totalWidth}}>Health Checks<span><em>The number of health checks for the service on all nodes</em></span></th>
<th style={{remainingWidth}}>Tags</th>
{{/block-slot}}
{{#block-slot 'row'}}
<td data-test-service="{{item.Name}}" style={{remainingWidth}}>
<a href={{href-to 'dc.services.show' item.Name}}>
<span data-test-external-source="{{service/external-source item}}" style={{{ concat 'background-image: ' (css-var (concat '--' (service/external-source item) '-color-svg') 'none')}}}></span>
{{item.Name}}
</a>
</td>
<td style={{totalWidth}}>
{{#if (and (lt item.ChecksPassing 1) (lt item.ChecksWarning 1) (lt item.ChecksCritical 1) )}}
<span title="No Healthchecks" class="zero">0</span>
{{else}}
<dl>
<dt title="Passing" class="passing{{if (lt item.ChecksPassing 1) ' zero'}}">Healthchecks Passing</dt>
<dd title="Passing" class={{if (lt item.ChecksPassing 1) 'zero'}} style={{passingWidth}}>{{format-number item.ChecksPassing}}</dd>
<dt title="Warning" class="warning{{if (lt item.ChecksWarning 1) ' zero'}}">Healthchecks Warning</dt>
<dd title="Warning" class={{if (lt item.ChecksWarning 1) 'zero'}} style={{warningWidth}}>{{format-number item.ChecksWarning}}</dd>
<dt title="Critical" class="critical{{if (lt item.ChecksCritical 1) ' zero'}}">Healthchecks Critical</dt>
<dd title="Critical" class={{if (lt item.ChecksCritical 1) 'zero'}} style={{criticalWidth}}>{{format-number item.ChecksCritical}}</dd>
</dl>
{{/if}}
</td>
<td class="tags" style={{remainingWidth}}>
{{#if (gt item.Tags.length 0)}}
{{#each item.Tags as |item|}}
<span>{{item}}</span>
{{/each}}
{{/if}}
</td>
{{/block-slot}}
{{/tabular-collection}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no services.
</p>
{{/block-slot}}
{{/changeable-set}}
{{/block-slot}}
{{/app-view}}

View File

@ -18,7 +18,7 @@
{{/block-slot}}
{{#block-slot 'toolbar'}}
{{#if (gt items.length 0) }}
{{catalog-filter filters=healthFilters search=filters.s status=filters.status onchange=(action 'filter')}}
{{catalog-filter searchable=(array searchableHealthy searchableUnhealthy) filters=healthFilters search=s status=filters.status onchange=(action 'filter')}}
{{/if}}
{{/block-slot}}
{{#block-slot 'content'}}
@ -37,17 +37,26 @@
<h2>Unhealthy Nodes</h2>
<div>
<ul>
{{#changeable-set dispatcher=searchableUnhealthy}}
{{#block-slot 'set' as |unhealthy|}}
{{#each unhealthy as |item|}}
{{healthchecked-resource
tagName='li'
data-test-node=item.Node.Node
href=(href-to 'dc.nodes.show' item.Node.Node)
name=item.Node.Node
service=item.Service.ID
address=(concat (default item.Service.Address item.Node.Address) ':' item.Service.Port)
checks=item.Checks
}}
{{healthchecked-resource
tagName='li'
data-test-node=item.Node.Node
href=(href-to 'dc.nodes.show' item.Node.Node)
name=item.Node.Node
service=item.Service.ID
address=(concat (default item.Service.Address item.Node.Address) ':' item.Service.Port)
checks=item.Checks
}}
{{/each}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no unhealthy nodes for that search.
</p>
{{/block-slot}}
{{/changeable-set}}
</ul>
</div>
</div>
@ -55,17 +64,26 @@
{{#if (gt healthy.length 0) }}
<div data-test-healthy class="healthy">
<h2>Healthy Nodes</h2>
{{#list-collection cellHeight=113 items=healthy as |item index|}}
{{healthchecked-resource
href=(href-to 'dc.nodes.show' item.Node.Node)
data-test-node=item.Node.Node
name=item.Node.Node
service=item.Service.ID
address=(concat (default item.Service.Address item.Node.Address) ':' item.Service.Port)
checks=item.Checks
status=item.Checks.[0].Status
}}
{{/list-collection}}
{{#changeable-set dispatcher=searchableHealthy}}
{{#block-slot 'set' as |healthy|}}
{{#list-collection cellHeight=113 items=healthy as |item index|}}
{{healthchecked-resource
href=(href-to 'dc.nodes.show' item.Node.Node)
data-test-node=item.Node.Node
name=item.Node.Node
service=item.Service.ID
address=(concat (default item.Service.Address item.Node.Address) ':' item.Service.Port)
checks=item.Checks
status=item.Checks.[0].Status
}}
{{/list-collection}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no healthy nodes for that search.
</p>
{{/block-slot}}
{{/changeable-set}}
</div>
{{/if}}
{{/block-slot}}

View File

@ -0,0 +1,32 @@
export default function(listeners = []) {
const add = function(target, event, handler) {
let addEventListener = 'addEventListener';
let removeEventListener = 'removeEventListener';
if (typeof target[addEventListener] === 'undefined') {
addEventListener = 'on';
removeEventListener = 'off';
}
target[addEventListener](event, handler);
const remove = function() {
target[removeEventListener](event, handler);
return handler;
};
listeners.push(remove);
return remove;
};
// TODO: Allow passing of a 'listener remove' in here
// call it, find in the array and remove
// Post-thoughts, pretty sure this is covered now by returning the remove
// function above, use-case for wanting to use this method to remove individual
// listeners is probably pretty limited, this method itself could be easily implemented
// from the outside also, but I suppose its handy to keep here
const remove = function() {
const handlers = listeners.map(item => item());
listeners.splice(0, listeners.length);
return handlers;
};
return {
add: add,
remove: remove,
};
}

View File

@ -0,0 +1,38 @@
import RSVP, { Promise } from 'rsvp';
export default function(EventTarget = RSVP.EventTarget, P = Promise) {
// TODO: Class-ify
return function(filter) {
return EventTarget.mixin({
value: '',
add: function(data) {
this.data = data;
return this;
},
search: function(term = '') {
this.value = term === null ? '' : term.trim();
// specifically no return here we return `this` instead
// right now filtering is sync but we introduce an async
// flow now for later on
P.resolve(
this.value !== ''
? this.data.filter(item => {
return filter(item, { s: term });
})
: this.data
).then(data => {
// TODO: For the moment, lets just fake a target
this.trigger('change', {
target: {
value: this.value,
// TODO: selectedOptions is what <select> uses, consider that
data: data,
},
});
// not returned
return data;
});
return this;
},
});
};
}

View File

@ -0,0 +1,33 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('changeable-set', 'Integration | Component | changeable set', {
integration: true,
});
test('it renders', function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
this.render(hbs`{{changeable-set}}`);
assert.equal(
this.$()
.text()
.trim(),
''
);
// Template block usage:
this.render(hbs`
{{#changeable-set}}
{{/changeable-set}}
`);
assert.equal(
this.$()
.text()
.trim(),
''
);
});

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/acls/policies/create', 'Unit | Controller | dc/acls/policies/create', {
// Specify the other units that are required for this test.
needs: ['service:dom', 'service:form'],
needs: ['service:form', 'service:dom'],
});
// Replace this with your real tests.

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/acls/policies/index', 'Unit | Controller | dc/acls/policies/index', {
// Specify the other units that are required for this test.
// needs: ['controller:foo']
needs: ['service:search', 'service:dom'],
});
// Replace this with your real tests.

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/acls/tokens/index', 'Unit | Controller | dc/acls/tokens/index', {
// Specify the other units that are required for this test.
// needs: ['controller:foo']
needs: ['service:search', 'service:dom'],
});
// Replace this with your real tests.

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/intentions/index', 'Unit | Controller | dc/intentions/index', {
// Specify the other units that are required for this test.
// needs: ['controller:foo']
needs: ['service:search', 'service:dom'],
});
// Replace this with your real tests.

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/kv/folder', 'Unit | Controller | dc/kv/folder', {
// Specify the other units that are required for this test.
// needs: ['controller:foo']
needs: ['service:search', 'service:dom'],
});
// Replace this with your real tests.

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/kv/index', 'Unit | Controller | dc/kv/index', {
// Specify the other units that are required for this test.
// needs: ['controller:foo']
needs: ['service:search', 'service:dom'],
});
// Replace this with your real tests.

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/nodes/index', 'Unit | Controller | dc/nodes/index', {
// Specify the other units that are required for this test.
// needs: ['controller:foo']
needs: ['service:search', 'service:dom'],
});
// Replace this with your real tests.

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/nodes/show', 'Unit | Controller | dc/nodes/show', {
// Specify the other units that are required for this test.
// needs: ['controller:foo']
needs: ['service:search', 'service:dom'],
});
// Replace this with your real tests.

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/services/index', 'Unit | Controller | dc/services/index', {
// Specify the other units that are required for this test.
// needs: ['controller:foo']
needs: ['service:search', 'service:dom'],
});
// Replace this with your real tests.

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/services/show', 'Unit | Controller | dc/services/show', {
// Specify the other units that are required for this test.
// needs: ['controller:foo']
needs: ['service:search', 'service:dom'],
});
// Replace this with your real tests.

View File

@ -0,0 +1,21 @@
import { moduleFor } from 'ember-qunit';
import test from 'ember-sinon-qunit/test-support/test';
import { getOwner } from '@ember/application';
import Controller from '@ember/controller';
import Mixin from 'consul-ui/mixins/with-listeners';
moduleFor('mixin:with-listeners', 'Unit | Mixin | with listeners', {
// Specify the other units that are required for this test.
needs: ['service:dom'],
subject: function() {
const MixedIn = Controller.extend(Mixin);
this.register('test-container:with-listeners-object', MixedIn);
return getOwner(this).lookup('test-container:with-listeners-object');
},
});
// Replace this with your real tests.
test('it works', function(assert) {
const subject = this.subject();
assert.ok(subject);
});

View File

@ -0,0 +1,21 @@
import { moduleFor } from 'ember-qunit';
import test from 'ember-sinon-qunit/test-support/test';
import { getOwner } from '@ember/application';
import Controller from '@ember/controller';
import Mixin from 'consul-ui/mixins/with-searching';
moduleFor('mixin:with-searching', 'Unit | Mixin | with searching', {
// Specify the other units that are required for this test.
needs: ['service:search', 'service:dom'],
subject: function() {
const MixedIn = Controller.extend(Mixin);
this.register('test-container:with-searching-object', MixedIn);
return getOwner(this).lookup('test-container:with-searching-object');
},
});
// Replace this with your real tests.
test('it works', function(assert) {
const subject = this.subject();
assert.ok(subject);
});

View File

@ -0,0 +1,72 @@
import getFilter from 'consul-ui/search/filters/intention';
import { module, test } from 'qunit';
module('Unit | Search | Filter | intention');
const filter = getFilter(cb => cb);
test('items are found by properties', function(assert) {
[
{
SourceName: 'Hit',
DestinationName: 'destination',
},
{
SourceName: 'source',
DestinationName: 'hiT',
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(actual);
});
});
test('items are not found', function(assert) {
[
{
SourceName: 'source',
DestinationName: 'destination',
},
].forEach(function(item) {
const actual = filter(item, {
s: '*',
});
assert.ok(!actual);
});
});
test('items are found by *', function(assert) {
[
{
SourceName: '*',
DestinationName: 'destination',
},
{
SourceName: 'source',
DestinationName: '*',
},
].forEach(function(item) {
const actual = filter(item, {
s: '*',
});
assert.ok(actual);
});
});
test("* items are found by searching anything in 'All Services (*)'", function(assert) {
[
{
SourceName: '*',
DestinationName: 'destination',
},
{
SourceName: 'source',
DestinationName: '*',
},
].forEach(function(item) {
['All Services (*)', 'SerVices', '(*)', '*', 'vIces', 'lL Ser'].forEach(function(term) {
const actual = filter(item, {
s: term,
});
assert.ok(actual);
});
});
});

View File

@ -0,0 +1,36 @@
import getFilter from 'consul-ui/search/filters/kv';
import { module, test } from 'qunit';
module('Unit | Search | Filter | kv');
const filter = getFilter(cb => cb);
test('items are found by properties', function(assert) {
[
{
Key: 'HIT-here',
},
{
Key: 'folder-HIT/',
},
{
Key: 'really/long/path/HIT-here',
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(actual);
});
});
test('items are not found', function(assert) {
[
{
Key: 'key',
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(!actual);
});
});

View File

@ -0,0 +1,30 @@
import getFilter from 'consul-ui/search/filters/node';
import { module, test } from 'qunit';
module('Unit | Search | Filter | node');
const filter = getFilter(cb => cb);
test('items are found by properties', function(assert) {
[
{
Node: 'node-HIT',
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(actual);
});
});
test('items are not found', function(assert) {
[
{
Node: 'name',
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(!actual);
});
});

View File

@ -0,0 +1,94 @@
import getFilter from 'consul-ui/search/filters/node/service';
import { module, test } from 'qunit';
module('Unit | Search | Filter | node/service');
const filter = getFilter(cb => cb);
test('items are found by properties', function(assert) {
[
{
Service: 'service-HIT',
ID: 'id',
Port: 8500,
Tags: [],
},
{
Service: 'service',
ID: 'id-HiT',
Port: 8500,
Tags: [],
},
{
Service: 'service',
ID: 'id',
Port: 8500,
Tags: ['tag', 'tag-withHiT'],
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(actual);
});
});
test('items are found by port (non-string)', function(assert) {
[
{
Service: 'service',
ID: 'id',
Port: 8500,
Tags: ['tag', 'tag'],
},
].forEach(function(item) {
const actual = filter(item, {
s: '8500',
});
assert.ok(actual);
});
});
test('items are not found', function(assert) {
[
{
Service: 'service',
ID: 'id',
Port: 8500,
},
{
Service: 'service',
ID: 'id',
Port: 8500,
Tags: ['one', 'two'],
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(!actual);
});
});
test('tags can be empty', function(assert) {
[
{
Service: 'service',
ID: 'id',
Port: 8500,
},
{
Service: 'service',
ID: 'id',
Port: 8500,
Tags: null,
},
{
Service: 'service',
ID: 'id',
Port: 8500,
Tags: [],
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(!actual);
});
});

View File

@ -0,0 +1,36 @@
import getFilter from 'consul-ui/search/filters/policy';
import { module, test } from 'qunit';
module('Unit | Search | Filter | policy');
const filter = getFilter(cb => cb);
test('items are found by properties', function(assert) {
[
{
Name: 'name-HIT',
Description: 'description',
},
{
Name: 'name',
Description: 'desc-HIT-ription',
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(actual);
});
});
test('items are not found', function(assert) {
[
{
Name: 'name',
Description: 'description',
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(!actual);
});
});

View File

@ -0,0 +1,59 @@
import getFilter from 'consul-ui/search/filters/service';
import { module, test } from 'qunit';
module('Unit | Search | Filter | service');
const filter = getFilter(cb => cb);
test('items are found by properties', function(assert) {
[
{
Name: 'name-HIT',
Tags: [],
},
{
Name: 'name',
Tags: ['tag', 'tag-withHiT'],
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(actual);
});
});
test('items are not found', function(assert) {
[
{
Name: 'name',
},
{
Name: 'name',
Tags: ['one', 'two'],
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(!actual);
});
});
test('tags can be empty', function(assert) {
[
{
Name: 'name',
},
{
Name: 'name',
Tags: null,
},
{
Name: 'name',
Tags: [],
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(!actual);
});
});

View File

@ -0,0 +1,56 @@
import getFilter from 'consul-ui/search/filters/service/node';
import { module, test } from 'qunit';
module('Unit | Search | Filter | service/node');
const filter = getFilter(cb => cb);
test('items are found by properties', function(assert) {
[
{
Service: {
ID: 'hit',
},
Node: {
Node: 'node',
},
},
{
Service: {
ID: 'id',
},
Node: {
Node: 'nodeHiT',
},
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(actual);
});
});
test('items are not found', function(assert) {
[
{
Service: {
ID: 'ID',
},
Node: {
Node: 'node',
},
},
{
Service: {
ID: 'id',
},
Node: {
Node: 'node',
},
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(!actual);
});
});

View File

@ -0,0 +1,86 @@
import getFilter from 'consul-ui/search/filters/token';
import { module, test } from 'qunit';
module('Unit | Search | Filter | token');
const filter = getFilter(cb => cb);
test('items are found by properties', function(assert) {
[
{
AccessorID: 'HIT-id',
Name: 'name',
Description: 'description',
Policies: [],
},
{
AccessorID: 'id',
Name: 'name-HIT',
Description: 'description',
Policies: [],
},
{
AccessorID: 'id',
Name: 'name',
Description: 'desc-HIT-ription',
Policies: [],
},
{
AccessorID: 'id',
Name: 'name',
Description: 'description',
Policies: [{ Name: 'policy' }, { Name: 'policy-HIT' }],
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(actual);
});
});
test('items are not found', function(assert) {
[
{
AccessorID: 'id',
Name: 'name',
Description: 'description',
Policies: [],
},
{
AccessorID: 'id',
Name: 'name',
Description: 'description',
Policies: [{ Name: 'policy' }, { Name: 'policy-second' }],
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(!actual);
});
});
test('policies can be empty', function(assert) {
[
{
AccessorID: 'id',
Name: 'name',
Description: 'description',
},
{
AccessorID: 'id',
Name: 'name',
Description: 'description',
Policies: null,
},
{
AccessorID: 'id',
Name: 'name',
Description: 'description',
Policies: [],
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
assert.ok(!actual);
});
});

View File

@ -0,0 +1,12 @@
import { moduleFor, test } from 'ember-qunit';
moduleFor('service:search', 'Unit | Service | search', {
// Specify the other units that are required for this test.
// needs: ['service:foo']
});
// Replace this with your real tests.
test('it exists', function(assert) {
let service = this.subject();
assert.ok(service);
});

View File

@ -0,0 +1,79 @@
import createListeners from 'consul-ui/utils/dom/create-listeners';
import { module } from 'ember-qunit';
import test from 'ember-sinon-qunit/test-support/test';
module('Unit | Utility | dom/create listeners');
test('it has add and remove methods', function(assert) {
const listeners = createListeners();
assert.ok(typeof listeners.add === 'function');
assert.ok(typeof listeners.remove === 'function');
});
test('add returns an remove function', function(assert) {
const listeners = createListeners();
const remove = listeners.add({
addEventListener: function() {},
});
assert.ok(typeof remove === 'function');
});
test('remove returns an array of removed handlers (the return of a saved remove)', function(assert) {
// just use true here to prove that it's what gets returned
const expected = true;
const handlers = [
function() {
return expected;
},
];
const listeners = createListeners(handlers);
const actual = listeners.remove();
assert.deepEqual(actual, [expected]);
// handlers should now be empty
assert.equal(handlers.length, 0);
});
test('remove calls the remove functions', function(assert) {
const expected = this.stub();
const arr = [expected];
const listeners = createListeners(arr);
listeners.remove();
assert.ok(expected.calledOnce);
assert.equal(arr.length, 0);
});
test('listeners are added on add', function(assert) {
const listeners = createListeners();
const stub = this.stub();
const target = {
addEventListener: stub,
};
const name = 'test';
const handler = function(e) {};
listeners.add(target, name, handler);
assert.ok(stub.calledOnce);
assert.ok(stub.calledWith(name, handler));
});
test('listeners are removed on remove', function(assert) {
const listeners = createListeners();
const stub = this.stub();
const target = {
addEventListener: function() {},
removeEventListener: stub,
};
const name = 'test';
const handler = function(e) {};
const remove = listeners.add(target, name, handler);
remove();
assert.ok(stub.calledOnce);
assert.ok(stub.calledWith(name, handler));
});
test('remove returns the original handler', function(assert) {
const listeners = createListeners();
const target = {
addEventListener: function() {},
removeEventListener: function() {},
};
const name = 'test';
const expected = this.stub();
const remove = listeners.add(target, name, expected);
const actual = remove();
actual();
assert.ok(expected.calledOnce);
});

View File

@ -0,0 +1,10 @@
import searchFilterable from 'consul-ui/utils/search/filterable';
import { module, test } from 'qunit';
module('Unit | Utility | search/filterable');
// Replace this with your real tests.
test('it works', function(assert) {
let result = searchFilterable();
assert.ok(result);
});