ui: Search improvements (#5540)
* ui: Replaces Service listing filterbar with a phrase-editor search (#5507) 1. New phrase-editor restricting search to whole phrases (acts on enter key). Allows removal of previously entered phrases 2. Searching now allows arrays of terms, multiple terms work via AND
This commit is contained in:
parent
839e79d16e
commit
08c5b376e7
|
@ -0,0 +1,44 @@
|
|||
import Component from '@ember/component';
|
||||
import { get, set } from '@ember/object';
|
||||
|
||||
export default Component.extend({
|
||||
classNames: ['phrase-editor'],
|
||||
item: '',
|
||||
remove: function(index, e) {
|
||||
this.items.removeAt(index, 1);
|
||||
this.onchange(e);
|
||||
},
|
||||
add: function(e) {
|
||||
const value = get(this, 'item').trim();
|
||||
if (value !== '') {
|
||||
set(this, 'item', '');
|
||||
const currentItems = get(this, 'items') || [];
|
||||
const items = new Set(currentItems).add(value);
|
||||
if (items.size > currentItems.length) {
|
||||
set(this, 'items', [...items]);
|
||||
this.onchange(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
onkeydown: function(e) {
|
||||
switch (e.keyCode) {
|
||||
case 8:
|
||||
if (e.target.value == '' && this.items.length > 0) {
|
||||
this.remove(this.items.length - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
oninput: function(e) {
|
||||
set(this, 'item', e.target.value);
|
||||
},
|
||||
onchange: function(e) {
|
||||
let searchable = get(this, 'searchable');
|
||||
if (!Array.isArray(searchable)) {
|
||||
searchable = [searchable];
|
||||
}
|
||||
searchable.forEach(item => {
|
||||
item.search(get(this, 'items'));
|
||||
});
|
||||
},
|
||||
});
|
|
@ -2,7 +2,6 @@ import Controller from '@ember/controller';
|
|||
import { get, computed } from '@ember/object';
|
||||
import { htmlSafe } from '@ember/string';
|
||||
import WithEventSource from 'consul-ui/mixins/with-event-source';
|
||||
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) {
|
||||
|
@ -26,21 +25,23 @@ const width = function(num) {
|
|||
const widthDeclaration = function(num) {
|
||||
return htmlSafe(`width: ${num}px`);
|
||||
};
|
||||
export default Controller.extend(WithEventSource, WithSearching, WithHealthFiltering, {
|
||||
export default Controller.extend(WithEventSource, WithSearching, {
|
||||
queryParams: {
|
||||
s: {
|
||||
as: 'filter',
|
||||
},
|
||||
},
|
||||
init: function() {
|
||||
this.searchParams = {
|
||||
service: 's',
|
||||
};
|
||||
this._super(...arguments);
|
||||
},
|
||||
searchable: computed('filtered', function() {
|
||||
searchable: computed('items.[]', function() {
|
||||
return get(this, 'searchables.service')
|
||||
.add(get(this, 'filtered'))
|
||||
.search(get(this, this.searchParams.service));
|
||||
.add(get(this, 'items'))
|
||||
.search(get(this, 'terms'));
|
||||
}),
|
||||
filter: function(item, { s = '', status = '' }) {
|
||||
return item.hasStatus(status);
|
||||
},
|
||||
maxWidth: computed('{maxPassing,maxWarning,maxCritical}', function() {
|
||||
const PADDING = 32 * 3 + 13;
|
||||
return ['maxPassing', 'maxWarning', 'maxCritical'].reduce((prev, item) => {
|
||||
|
@ -58,14 +59,14 @@ export default Controller.extend(WithEventSource, WithSearching, WithHealthFilte
|
|||
// so again divide that by 2 and take it off each fluid column
|
||||
return htmlSafe(`width: calc(50% - 50px - ${Math.round(get(this, 'maxWidth') / 2)}px)`);
|
||||
}),
|
||||
maxPassing: computed('filtered', function() {
|
||||
return max(get(this, 'filtered'), 'ChecksPassing');
|
||||
maxPassing: computed('items.[]', function() {
|
||||
return max(get(this, 'items'), 'ChecksPassing');
|
||||
}),
|
||||
maxWarning: computed('filtered', function() {
|
||||
return max(get(this, 'filtered'), 'ChecksWarning');
|
||||
maxWarning: computed('items.[]', function() {
|
||||
return max(get(this, 'items'), 'ChecksWarning');
|
||||
}),
|
||||
maxCritical: computed('filtered', function() {
|
||||
return max(get(this, 'filtered'), 'ChecksCritical');
|
||||
maxCritical: computed('items.[]', function() {
|
||||
return max(get(this, 'items'), 'ChecksCritical');
|
||||
}),
|
||||
passingWidth: computed('maxPassing', function() {
|
||||
return widthDeclaration(width(get(this, 'maxPassing')));
|
||||
|
|
|
@ -10,10 +10,29 @@ export default Route.extend({
|
|||
as: 'filter',
|
||||
replace: true,
|
||||
},
|
||||
// temporary support of old style status
|
||||
status: {
|
||||
as: 'status',
|
||||
},
|
||||
},
|
||||
model: function(params) {
|
||||
const repo = get(this, 'repo');
|
||||
let terms = params.s || '';
|
||||
// we check for the old style `status` variable here
|
||||
// and convert it to the new style filter=status:critical
|
||||
let status = params.status;
|
||||
if (status) {
|
||||
status = `status:${status}`;
|
||||
if (terms.indexOf(status) === -1) {
|
||||
terms = terms
|
||||
.split('\n')
|
||||
.concat(status)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
return hash({
|
||||
terms: terms !== '' ? terms.split('\n') : [],
|
||||
items: repo.findAllByDatacenter(this.modelFor('dc').dc.Name),
|
||||
});
|
||||
},
|
||||
|
|
|
@ -1,14 +1,34 @@
|
|||
import { get } from '@ember/object';
|
||||
import ucfirst from 'consul-ui/utils/ucfirst';
|
||||
const find = function(obj, term) {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.some(function(item) {
|
||||
return find(item, term);
|
||||
});
|
||||
}
|
||||
return obj.toLowerCase().indexOf(term) !== -1;
|
||||
};
|
||||
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;
|
||||
})
|
||||
);
|
||||
let status;
|
||||
switch (true) {
|
||||
case term.startsWith('service:'):
|
||||
return find(get(item, 'Name'), term.substr(8));
|
||||
case term.startsWith('tag:'):
|
||||
return find(get(item, 'Tags') || [], term.substr(4));
|
||||
case term.startsWith('status:'):
|
||||
status = term.substr(7);
|
||||
switch (term.substr(7)) {
|
||||
case 'warning':
|
||||
case 'critical':
|
||||
case 'passing':
|
||||
return get(item, `Checks${ucfirst(status)}`) > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return find(get(item, 'Name'), term) || find(get(item, 'Tags') || [], term);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
%with-icon {
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
%as-pseudo {
|
||||
display: inline-block;
|
||||
content: '';
|
||||
visibility: visible;
|
||||
background-size: contain;
|
||||
}
|
||||
%with-cancel-plain-icon {
|
||||
@extend %with-icon;
|
||||
background-image: $cancel-plain-svg;
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
@import './base-variables';
|
||||
@import './base-placeholders';
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
@import './healthcheck-info';
|
||||
@import './healthchecked-resource';
|
||||
@import './freetext-filter';
|
||||
@import './phrase-editor';
|
||||
@import './filter-bar';
|
||||
@import './tomography-graph';
|
||||
@import './action-group';
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
@import './phrase-editor/index';
|
||||
.phrase-editor {
|
||||
@extend %phrase-editor;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
@import './skin';
|
||||
@import './layout';
|
|
@ -0,0 +1,46 @@
|
|||
%phrase-editor {
|
||||
display: flex;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
%phrase-editor ul {
|
||||
overflow: hidden;
|
||||
}
|
||||
%phrase-editor li {
|
||||
@extend %pill;
|
||||
float: left;
|
||||
margin-right: 4px;
|
||||
}
|
||||
%phrase-editor span {
|
||||
display: none;
|
||||
}
|
||||
%phrase-editor label {
|
||||
flex-grow: 1;
|
||||
}
|
||||
%phrase-editor input {
|
||||
width: 100%;
|
||||
height: 33px;
|
||||
padding: 8px 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@media #{$--horizontal-selects} {
|
||||
%phrase-editor {
|
||||
margin-top: 14px;
|
||||
}
|
||||
%phrase-editor ul {
|
||||
padding-top: 5px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
%phrase-editor input {
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
@media #{$--lt-horizontal-selects} {
|
||||
%phrase-editor {
|
||||
margin-top: 9px;
|
||||
}
|
||||
%phrase-editor label {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
@media #{$--horizontal-selects} {
|
||||
%phrase-editor {
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: 2px;
|
||||
}
|
||||
%phrase-editor input:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
@media #{$--lt-horizontal-selects} {
|
||||
%phrase-editor label {
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
%phrase-editor input {
|
||||
-webkit-appearance: none;
|
||||
}
|
|
@ -2,3 +2,7 @@
|
|||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
%pill button {
|
||||
padding: 0;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
|
|
@ -2,3 +2,13 @@
|
|||
@extend %frame-gray-900;
|
||||
border-radius: $radius-small;
|
||||
}
|
||||
%pill button {
|
||||
background-color: transparent;
|
||||
font-size: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
%pill button::before {
|
||||
@extend %with-cancel-plain-icon, %as-pseudo;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<ul>
|
||||
{{#each items as |item index|}}
|
||||
<li>
|
||||
<button type="button" onclick={{action remove index}}>Remove</button>{{item}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<label class="type-search">
|
||||
<span>Search</span>
|
||||
<input onchange={{action add}} onsearch={{action add}} oninput={{action oninput}} onkeydown={{action onkeydown}} placeholder="{{placeholder}}" value="{{item}}" type="search" name="s" autofocus="autofocus" />
|
||||
</label>
|
|
@ -10,7 +10,7 @@
|
|||
{{/block-slot}}
|
||||
{{#block-slot 'toolbar'}}
|
||||
{{#if (gt items.length 0) }}
|
||||
{{catalog-filter searchable=searchable search=filters.s status=filters.status onchange=(action 'filter')}}
|
||||
{{#phrase-editor placeholder=(if (eq terms.length 0) 'service:name tag:name status:critical search-term' '') items=terms searchable=searchable}}{{/phrase-editor}}
|
||||
{{/if}}
|
||||
{{/block-slot}}
|
||||
{{#block-slot 'content'}}
|
||||
|
|
|
@ -8,22 +8,31 @@ export default function(EventTarget = RSVP.EventTarget, P = Promise) {
|
|||
this.data = data;
|
||||
return this;
|
||||
},
|
||||
search: function(term = '') {
|
||||
this.value = term === null ? '' : term.trim();
|
||||
find: function(terms = []) {
|
||||
this.value = terms
|
||||
.filter(function(item) {
|
||||
return typeof item === 'string' && item !== '';
|
||||
})
|
||||
.map(function(term) {
|
||||
return term.trim();
|
||||
});
|
||||
return P.resolve(
|
||||
this.value.reduce(function(prev, term) {
|
||||
return prev.filter(item => {
|
||||
return filter(item, { s: term });
|
||||
});
|
||||
}, this.data)
|
||||
);
|
||||
},
|
||||
search: function(terms = []) {
|
||||
// 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 => {
|
||||
this.find(Array.isArray(terms) ? terms : [terms]).then(data => {
|
||||
// TODO: For the moment, lets just fake a target
|
||||
this.trigger('change', {
|
||||
target: {
|
||||
value: this.value,
|
||||
value: this.value.join('\n'),
|
||||
// TODO: selectedOptions is what <select> uses, consider that
|
||||
data: data,
|
||||
},
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
# TODO: If we keep separate types of catalog filters then
|
||||
# these tests need splitting out, if we are moving nodes
|
||||
# to use the name filter UI also, then they can stay together
|
||||
@setupApplicationTest
|
||||
Feature: components / catalog-filter
|
||||
Scenario: Filtering [Model]
|
||||
|
@ -60,7 +63,6 @@ Feature: components / catalog-filter
|
|||
Where:
|
||||
-------------------------------------------------
|
||||
| Model | Page | Url |
|
||||
| service | services | /dc-1/services |
|
||||
| node | nodes | /dc-1/nodes |
|
||||
-------------------------------------------------
|
||||
Scenario: Filtering [Model] in [Page]
|
||||
|
@ -123,13 +125,22 @@ Feature: components / catalog-filter
|
|||
| Model | Page | Url |
|
||||
| service | node | /dc-1/nodes/node-0 |
|
||||
-------------------------------------------------
|
||||
Scenario:
|
||||
Scenario: Freetext filtering the service listing
|
||||
Given 1 datacenter model with the value "dc-1"
|
||||
And 3 service models from yaml
|
||||
---
|
||||
- Tags: ['one', 'two', 'three']
|
||||
ChecksPassing: 0
|
||||
ChecksWarning: 0
|
||||
ChecksCritical: 1
|
||||
- Tags: ['two', 'three']
|
||||
ChecksPassing: 0
|
||||
ChecksWarning: 1
|
||||
ChecksCritical: 0
|
||||
- Tags: ['three']
|
||||
ChecksPassing: 1
|
||||
ChecksWarning: 0
|
||||
ChecksCritical: 0
|
||||
---
|
||||
When I visit the services page for yaml
|
||||
---
|
||||
|
@ -139,21 +150,16 @@ Feature: components / catalog-filter
|
|||
Then I see 3 service models
|
||||
Then I fill in with yaml
|
||||
---
|
||||
s: one
|
||||
---
|
||||
And I see 1 service model with the name "service-0"
|
||||
Then I fill in with yaml
|
||||
---
|
||||
s: two
|
||||
---
|
||||
And I see 2 service models
|
||||
Then I fill in with yaml
|
||||
---
|
||||
s: three
|
||||
---
|
||||
And I see 3 service models
|
||||
Then I fill in with yaml
|
||||
---
|
||||
s: wothre
|
||||
s: 'tag:two'
|
||||
---
|
||||
And I see 0 service models
|
||||
And I see 2 service models
|
||||
Then I fill in with yaml
|
||||
---
|
||||
s: 'status:critical'
|
||||
---
|
||||
And I see 1 service model
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { moduleForComponent, test } from 'ember-qunit';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
|
||||
moduleForComponent('phrase-editor', 'Integration | Component | phrase editor', {
|
||||
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`{{phrase-editor}}`);
|
||||
|
||||
assert.equal(
|
||||
this.$()
|
||||
.text()
|
||||
.trim(),
|
||||
'Search'
|
||||
);
|
||||
|
||||
// Template block usage:
|
||||
this.render(hbs`
|
||||
{{#phrase-editor}}
|
||||
template block text
|
||||
{{/phrase-editor}}
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
this.$()
|
||||
.text()
|
||||
.trim(),
|
||||
'Search'
|
||||
);
|
||||
});
|
Loading…
Reference in New Issue