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:
John Cowen 2019-04-30 18:54:28 +01:00 committed by John Cowen
parent cbbfaba6ac
commit eeb7a858e2
18 changed files with 291 additions and 47 deletions

View File

@ -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'));
});
},
});

View File

@ -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')));

View File

@ -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),
});
},

View File

@ -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);
}
});
}

View File

@ -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;
}

View File

@ -1 +1,2 @@
@import './base-variables';
@import './base-placeholders';

View File

@ -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';

View File

@ -0,0 +1,4 @@
@import './phrase-editor/index';
.phrase-editor {
@extend %phrase-editor;
}

View File

@ -0,0 +1,2 @@
@import './skin';
@import './layout';

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -2,3 +2,7 @@
display: inline-block;
padding: 1px 5px;
}
%pill button {
padding: 0;
margin-right: 3px;
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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'}}

View File

@ -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,
},

View File

@ -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

View File

@ -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'
);
});