From eeb7a858e2e22219cb091507cf5700614575be34 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Tue, 30 Apr 2019 18:54:28 +0100 Subject: [PATCH] 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 --- ui-v2/app/components/phrase-editor.js | 44 ++++++++++++++++++ ui-v2/app/controllers/dc/services/index.js | 29 ++++++------ ui-v2/app/routes/dc/services/index.js | 19 ++++++++ ui-v2/app/search/filters/service.js | 36 +++++++++++---- .../styles/base/icons/base-placeholders.scss | 14 ++++++ ui-v2/app/styles/base/icons/index.scss | 1 + ui-v2/app/styles/components/index.scss | 1 + .../app/styles/components/phrase-editor.scss | 4 ++ .../components/phrase-editor/index.scss | 2 + .../components/phrase-editor/layout.scss | 46 +++++++++++++++++++ .../styles/components/phrase-editor/skin.scss | 18 ++++++++ ui-v2/app/styles/components/pill/layout.scss | 4 ++ ui-v2/app/styles/components/pill/skin.scss | 10 ++++ .../templates/components/phrase-editor.hbs | 11 +++++ ui-v2/app/templates/dc/services/index.hbs | 2 +- ui-v2/app/utils/search/filterable.js | 29 ++++++++---- .../components/catalog-filter.feature | 34 ++++++++------ .../components/phrase-editor-test.js | 34 ++++++++++++++ 18 files changed, 291 insertions(+), 47 deletions(-) create mode 100644 ui-v2/app/components/phrase-editor.js create mode 100644 ui-v2/app/styles/base/icons/base-placeholders.scss create mode 100644 ui-v2/app/styles/components/phrase-editor.scss create mode 100644 ui-v2/app/styles/components/phrase-editor/index.scss create mode 100644 ui-v2/app/styles/components/phrase-editor/layout.scss create mode 100644 ui-v2/app/styles/components/phrase-editor/skin.scss create mode 100644 ui-v2/app/templates/components/phrase-editor.hbs create mode 100644 ui-v2/tests/integration/components/phrase-editor-test.js diff --git a/ui-v2/app/components/phrase-editor.js b/ui-v2/app/components/phrase-editor.js new file mode 100644 index 0000000000..e280b571c1 --- /dev/null +++ b/ui-v2/app/components/phrase-editor.js @@ -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')); + }); + }, +}); diff --git a/ui-v2/app/controllers/dc/services/index.js b/ui-v2/app/controllers/dc/services/index.js index 912a5316b3..e8bfff9a45 100644 --- a/ui-v2/app/controllers/dc/services/index.js +++ b/ui-v2/app/controllers/dc/services/index.js @@ -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'))); diff --git a/ui-v2/app/routes/dc/services/index.js b/ui-v2/app/routes/dc/services/index.js index 2b900f502e..7d479a6adc 100644 --- a/ui-v2/app/routes/dc/services/index.js +++ b/ui-v2/app/routes/dc/services/index.js @@ -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), }); }, diff --git a/ui-v2/app/search/filters/service.js b/ui-v2/app/search/filters/service.js index c1fcc0daaf..a5c6547bc7 100644 --- a/ui-v2/app/search/filters/service.js +++ b/ui-v2/app/search/filters/service.js @@ -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); + } }); } diff --git a/ui-v2/app/styles/base/icons/base-placeholders.scss b/ui-v2/app/styles/base/icons/base-placeholders.scss new file mode 100644 index 0000000000..a708206da9 --- /dev/null +++ b/ui-v2/app/styles/base/icons/base-placeholders.scss @@ -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; +} diff --git a/ui-v2/app/styles/base/icons/index.scss b/ui-v2/app/styles/base/icons/index.scss index bb221c7e8f..3fe0d277f0 100644 --- a/ui-v2/app/styles/base/icons/index.scss +++ b/ui-v2/app/styles/base/icons/index.scss @@ -1 +1,2 @@ @import './base-variables'; +@import './base-placeholders'; diff --git a/ui-v2/app/styles/components/index.scss b/ui-v2/app/styles/components/index.scss index edfda9d16e..3493041c17 100644 --- a/ui-v2/app/styles/components/index.scss +++ b/ui-v2/app/styles/components/index.scss @@ -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'; diff --git a/ui-v2/app/styles/components/phrase-editor.scss b/ui-v2/app/styles/components/phrase-editor.scss new file mode 100644 index 0000000000..b7852453a1 --- /dev/null +++ b/ui-v2/app/styles/components/phrase-editor.scss @@ -0,0 +1,4 @@ +@import './phrase-editor/index'; +.phrase-editor { + @extend %phrase-editor; +} diff --git a/ui-v2/app/styles/components/phrase-editor/index.scss b/ui-v2/app/styles/components/phrase-editor/index.scss new file mode 100644 index 0000000000..bc18252196 --- /dev/null +++ b/ui-v2/app/styles/components/phrase-editor/index.scss @@ -0,0 +1,2 @@ +@import './skin'; +@import './layout'; diff --git a/ui-v2/app/styles/components/phrase-editor/layout.scss b/ui-v2/app/styles/components/phrase-editor/layout.scss new file mode 100644 index 0000000000..d998a37807 --- /dev/null +++ b/ui-v2/app/styles/components/phrase-editor/layout.scss @@ -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; + } +} diff --git a/ui-v2/app/styles/components/phrase-editor/skin.scss b/ui-v2/app/styles/components/phrase-editor/skin.scss new file mode 100644 index 0000000000..78046d57bc --- /dev/null +++ b/ui-v2/app/styles/components/phrase-editor/skin.scss @@ -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; +} diff --git a/ui-v2/app/styles/components/pill/layout.scss b/ui-v2/app/styles/components/pill/layout.scss index fceb4a5a55..325dc503fb 100644 --- a/ui-v2/app/styles/components/pill/layout.scss +++ b/ui-v2/app/styles/components/pill/layout.scss @@ -2,3 +2,7 @@ display: inline-block; padding: 1px 5px; } +%pill button { + padding: 0; + margin-right: 3px; +} diff --git a/ui-v2/app/styles/components/pill/skin.scss b/ui-v2/app/styles/components/pill/skin.scss index b234ac46df..169bda915f 100644 --- a/ui-v2/app/styles/components/pill/skin.scss +++ b/ui-v2/app/styles/components/pill/skin.scss @@ -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; +} diff --git a/ui-v2/app/templates/components/phrase-editor.hbs b/ui-v2/app/templates/components/phrase-editor.hbs new file mode 100644 index 0000000000..480849c5f9 --- /dev/null +++ b/ui-v2/app/templates/components/phrase-editor.hbs @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/ui-v2/app/templates/dc/services/index.hbs b/ui-v2/app/templates/dc/services/index.hbs index ccc7e02e7e..82f803566d 100644 --- a/ui-v2/app/templates/dc/services/index.hbs +++ b/ui-v2/app/templates/dc/services/index.hbs @@ -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'}} diff --git a/ui-v2/app/utils/search/filterable.js b/ui-v2/app/utils/search/filterable.js index f14d0139ed..a0bec4a99a 100644 --- a/ui-v2/app/utils/search/filterable.js +++ b/ui-v2/app/utils/search/filterable.js @@ -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