Skip to content

Commit

Permalink
Improve global search UX (#8249)
Browse files Browse the repository at this point in the history
This updates the look of the search control, adds a hint about the slash
shortcut, adds highlighting of fuzzy search results, and addresses a few
edge case UX failures. It moves to using a fork of Ember Power Select
to handle an edge case where pressing escape would put the control
in an undesirable active-but-not-open state.
  • Loading branch information
backspace authored Jun 25, 2020
1 parent 2ce820a commit fe445a0
Show file tree
Hide file tree
Showing 15 changed files with 319 additions and 70 deletions.
17 changes: 13 additions & 4 deletions ui/app/components/global-search/control.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import Component from '@ember/component';
import { tagName } from '@ember-decorators/component';
import { classNames } from '@ember-decorators/component';
import { task } from 'ember-concurrency';
import EmberObject, { action, computed, set } from '@ember/object';
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { run } from '@ember/runloop';
import { debounce, run } from '@ember/runloop';
import Searchable from 'nomad-ui/mixins/searchable';
import classic from 'ember-classic-decorator';

const SLASH_KEY = 191;

@tagName('')
@classNames('global-search-container')
export default class GlobalSearchControl extends Component {
@service dataCaches;
@service router;
Expand Down Expand Up @@ -117,8 +117,15 @@ export default class GlobalSearchControl extends Component {
const triggerIsNotActive = !targetClassList.contains('ember-power-select-trigger--active');

if (targetIsTrigger && triggerIsNotActive) {
debounce(this, this.open, 150);
}
}

@action
onCloseEvent(select, event) {
if (event.key === 'Escape') {
run.next(() => {
select.actions.open();
this.select.actions.setIsActive(false);
});
}
}
Expand Down Expand Up @@ -151,6 +158,7 @@ class JobSearch extends EmberObject.extend(Searchable) {
@alias('dataSource.searchString') searchTerm;

fuzzySearchEnabled = true;
includeFuzzySearchMatches = true;
}

@classic
Expand All @@ -169,4 +177,5 @@ class NodeSearch extends EmberObject.extend(Searchable) {
@alias('dataSource.searchString') searchTerm;

fuzzySearchEnabled = true;
includeFuzzySearchMatches = true;
}
30 changes: 30 additions & 0 deletions ui/app/components/global-search/match.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Component from '@ember/component';
import { tagName } from '@ember-decorators/component';
import { computed, get } from '@ember/object';
import { alias } from '@ember/object/computed';

@tagName('')
export default class GlobalSearchMatch extends Component {
@alias('match.fuzzySearchMatches.firstObject') firstMatch;
@alias('firstMatch.indices.firstObject') firstIndices;

@computed('match.name')
get label() {
return get(this, 'match.name') || '';
}

@computed('label', 'firstIndices.[]')
get beforeHighlighted() {
return this.label.substring(0, this.firstIndices[0]);
}

@computed('label', 'firstIndices.[]')
get highlighted() {
return this.label.substring(this.firstIndices[0], this.firstIndices[1] + 1);
}

@computed('label', 'firstIndices.[]')
get afterHighlighted() {
return this.label.substring(this.firstIndices[1] + 1);
}
}
14 changes: 13 additions & 1 deletion ui/app/mixins/searchable.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default Mixin.create({
// Three search modes
exactMatchEnabled: true,
fuzzySearchEnabled: false,
includeFuzzySearchMatches: false,
regexEnabled: true,

// Search should reset pagination. Not every instance of
Expand All @@ -59,6 +60,7 @@ export default Mixin.create({
matchAllTokens: true,
maxPatternLength: 32,
minMatchCharLength: 1,
includeMatches: this.includeFuzzySearchMatches,
keys: this.fuzzySearchProps || [],
getFn(item, key) {
return get(item, key);
Expand Down Expand Up @@ -91,7 +93,17 @@ export default Mixin.create({
}

if (this.fuzzySearchEnabled) {
results.push(...this.fuse.search(searchTerm));
let fuseSearchResults = this.fuse.search(searchTerm);

if (this.includeFuzzySearchMatches) {
fuseSearchResults = fuseSearchResults.map(result => {
const item = result.item;
item.set('fuzzySearchMatches', result.matches);
return item;
});
}

results.push(...fuseSearchResults);
}

if (this.regexEnabled) {
Expand Down
2 changes: 1 addition & 1 deletion ui/app/styles/components.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
@import './components/exec-button';
@import './components/exec-window';
@import './components/fs-explorer';
@import './components/global-search-control';
@import './components/global-search-container';
@import './components/global-search-dropdown';
@import './components/gutter';
@import './components/gutter-toggle';
Expand Down
68 changes: 68 additions & 0 deletions ui/app/styles/components/global-search-container.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
.global-search-container {
position: absolute;
width: 100%;
left: 0;
top: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;

.global-search {
width: 30em;

@media #{$mq-hidden-gutter} {
width: 20em;
}

.ember-power-select-trigger {
background: $nomad-green-darker;
border: 0;

.icon {
margin-top: 1px;
margin-left: 2px;

fill: white;
opacity: 0.7;
}

.placeholder {
opacity: 0.7;
display: inline-block;
padding-left: 2px;
transform: translateY(-1px);
font-weight: $weight-semibold;
}

.shortcut {
position: absolute;
right: 5px;
top: 5px;
bottom: 5px;
width: 1.4rem;
padding-top: 3px;
text-align: center;
opacity: 0.7;
background: $nomad-green-dark;
font-weight: $weight-semibold;
}

&.ember-power-select-trigger--active {
background: white;
border-color: white;

.icon {
fill: black;
opacity: 1;
}
}
}

.ember-basic-dropdown-content-wormhole-origin {
position: absolute;
top: 0;
width: 100%;
}
}
}
37 changes: 0 additions & 37 deletions ui/app/styles/components/global-search-control.scss

This file was deleted.

14 changes: 14 additions & 0 deletions ui/app/styles/components/global-search-dropdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
outline: 0;
}

// Prevent Safari from disrupting styling, adapted from http://geek.michaelgrace.org/2011/06/webkit-search-input-styling/
input[type='search'] {
-webkit-appearance: textfield;
}

input::-webkit-search-decoration,
input::-webkit-search-cancel-button {
display: none;
}

.ember-power-select-options {
background: white;
padding: 0.35rem;
Expand All @@ -32,6 +42,10 @@
background: transparentize($blue, 0.8);
color: $blue;
}

.highlighted {
font-weight: $weight-semibold;
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion ui/app/templates/components/global-search/control.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
@search={{perform this.search}}
@onChange={{action 'selectOption'}}
@onFocus={{action 'openOnClickOrTab'}}
@onClose={{action 'onCloseEvent'}}
@dropdownClass="global-search-dropdown"
@calculatePosition={{this.calculatePosition}}
@searchMessageComponent="global-search/message"
@triggerComponent="global-search/trigger"
@registerAPI={{action 'storeSelect'}}
as |option|>
{{option.name}}
<GlobalSearch::Match @match={{option}} />
</PowerSelect>
5 changes: 5 additions & 0 deletions ui/app/templates/components/global-search/match.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{#if firstIndices}}
{{beforeHighlighted}}<span class='highlighted'>{{highlighted}}</span>{{afterHighlighted}}
{{else}}
{{label}}
{{/if}}
7 changes: 5 additions & 2 deletions ui/app/templates/components/global-search/trigger.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{{x-icon "search" class="is-small"}}
{{#unless select.isOpen}}
<span class='placeholder'>Search</span>
{{/unless}}
<span class='placeholder'>Jump to</span>
{{/unless}}
{{#if (not (or select.isActive select.isOpen))}}
<span class='shortcut' title="Type '/' to search">/</span>
{{/if}}
2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"ember-moment": "^7.8.1",
"ember-overridable-computed": "^1.0.0",
"ember-page-title": "^5.0.2",
"ember-power-select": "^3.0.4",
"ember-power-select": "backspace/ember-power-select#setIsActiveAPI",
"ember-qunit": "^4.4.1",
"ember-qunit-nice-errors": "^1.2.0",
"ember-resolver": "^5.0.1",
Expand Down
30 changes: 30 additions & 0 deletions ui/tests/acceptance/search-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,36 @@ module('Acceptance | search', function(hooks) {
clock.restore();
});

test('search highlights matching substrings', async function(assert) {
server.create('node', { name: 'xyz' });

server.create('job', { id: 'traefik', namespaceId: 'default' });
server.create('job', { id: 'tracking', namespace: 'default' });

await visit('/');

await selectSearch(PageLayout.navbar.search.scope, 'trae');

PageLayout.navbar.search.as(search => {
search.groups[0].as(jobs => {
assert.equal(jobs.options[0].text, 'traefik');
assert.equal(jobs.options[0].highlighted, 'trae');

assert.equal(jobs.options[1].text, 'tracking');
assert.equal(jobs.options[1].highlighted, 'tra');
});
});

await selectSearch(PageLayout.navbar.search.scope, 'ra');

PageLayout.navbar.search.as(search => {
search.groups[0].as(jobs => {
assert.equal(jobs.options[0].highlighted, 'ra');
assert.equal(jobs.options[1].highlighted, 'ra');
});
});
});

test('clicking the search field starts search immediately', async function(assert) {
await visit('/');

Expand Down
1 change: 1 addition & 0 deletions ui/tests/pages/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default create({

options: collection('.ember-power-select-option', {
label: text(),
highlighted: text('.highlighted'),
}),
}),

Expand Down
33 changes: 33 additions & 0 deletions ui/tests/unit/mixins/searchable-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,39 @@ module('Unit | Mixin | Searchable', function(hooks) {
);
});

test('the fuzzy search can include match results', function(assert) {
const subject = this.subject();
subject.set('source', [
EmberObject.create({ id: '1', name: 'United States of America', continent: 'North America' }),
EmberObject.create({ id: '2', name: 'Canada', continent: 'North America' }),
EmberObject.create({ id: '3', name: 'Mexico', continent: 'North America' }),
]);

subject.set('fuzzySearchEnabled', true);
subject.set('includeFuzzySearchMatches', true);
subject.set('searchTerm', 'Ameerica');
assert.deepEqual(
subject
.get('listSearched')
.map(object => object.getProperties('id', 'name', 'continent', 'fuzzySearchMatches')),
[
{
id: '1',
name: 'United States of America',
continent: 'North America',
fuzzySearchMatches: [
{
indices: [[2, 2], [4, 4], [9, 9], [11, 11], [17, 23]],
value: 'United States of America',
key: 'name',
},
],
},
],
'America is matched due to fuzzy matching'
);
});

test('the exact match search mode can be disabled', function(assert) {
const subject = this.subject();
subject.set('source', [
Expand Down
Loading

0 comments on commit fe445a0

Please sign in to comment.