mirror of
https://github.com/status-im/consul.git
synced 2025-02-18 08:36:46 +00:00
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
cbbfaba6ac
commit
eeb7a858e2
44
ui-v2/app/components/phrase-editor.js
Normal file
44
ui-v2/app/components/phrase-editor.js
Normal 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'));
|
||||
});
|
||||
},
|
||||
});
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
14
ui-v2/app/styles/base/icons/base-placeholders.scss
Normal file
14
ui-v2/app/styles/base/icons/base-placeholders.scss
Normal 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;
|
||||
}
|
@ -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';
|
||||
|
4
ui-v2/app/styles/components/phrase-editor.scss
Normal file
4
ui-v2/app/styles/components/phrase-editor.scss
Normal file
@ -0,0 +1,4 @@
|
||||
@import './phrase-editor/index';
|
||||
.phrase-editor {
|
||||
@extend %phrase-editor;
|
||||
}
|
2
ui-v2/app/styles/components/phrase-editor/index.scss
Normal file
2
ui-v2/app/styles/components/phrase-editor/index.scss
Normal file
@ -0,0 +1,2 @@
|
||||
@import './skin';
|
||||
@import './layout';
|
46
ui-v2/app/styles/components/phrase-editor/layout.scss
Normal file
46
ui-v2/app/styles/components/phrase-editor/layout.scss
Normal 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;
|
||||
}
|
||||
}
|
18
ui-v2/app/styles/components/phrase-editor/skin.scss
Normal file
18
ui-v2/app/styles/components/phrase-editor/skin.scss
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
11
ui-v2/app/templates/components/phrase-editor.hbs
Normal file
11
ui-v2/app/templates/components/phrase-editor.hbs
Normal 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>
|
@ -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
|
||||
|
34
ui-v2/tests/integration/components/phrase-editor-test.js
Normal file
34
ui-v2/tests/integration/components/phrase-editor-test.js
Normal 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'
|
||||
);
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user