Skip to content

Commit

Permalink
Merge pull request #4201 from hashicorp/f-ui-fuzzy-job-search
Browse files Browse the repository at this point in the history
UI: fuzzy and tokenized job search
  • Loading branch information
DingoEatingFuzz committed May 5, 2018
2 parents bd4e761 + 4f260e2 commit f92d364
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 6 deletions.
2 changes: 2 additions & 0 deletions ui/app/controllers/jobs/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export default Controller.extend(Sortable, Searchable, {
sortDescending: true,

searchProps: computed(() => ['id', 'name']),
fuzzySearchProps: computed(() => ['name']),
fuzzySearchEnabled: true,

/**
Filtered jobs are those that match the selected namespace and aren't children
Expand Down
87 changes: 81 additions & 6 deletions ui/app/mixins/searchable.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Mixin from '@ember/object/mixin';
import { get, computed } from '@ember/object';
import { reads } from '@ember/object/computed';
import Fuse from 'npm:fuse.js';

/**
Searchable mixin
Expand All @@ -9,6 +11,12 @@ import { get, computed } from '@ember/object';
Properties to override:
- searchTerm: the string to use as a query
- searchProps: the props on each object to search
-- exactMatchSearchProps: the props for exact search when props are different per search type
-- regexSearchProps: the props for regex search when props are different per search type
-- fuzzySearchProps: the props for fuzzy search when props are different per search type
- exactMatchEnabled: (true) disable to not use the exact match search type
- fuzzySearchEnabled: (false) enable to use the fuzzy search type
- regexEnabled: (true) disable to disable the regex search type
- listToSearch: the list of objects to search
Properties provided:
Expand All @@ -17,17 +25,83 @@ import { get, computed } from '@ember/object';
export default Mixin.create({
searchTerm: '',
listToSearch: computed(() => []),

searchProps: null,
exactMatchSearchProps: reads('searchProps'),
regexSearchProps: reads('searchProps'),
fuzzySearchProps: reads('searchProps'),

listSearched: computed('searchTerm', 'listToSearch.[]', 'searchProps.[]', function() {
const searchTerm = this.get('searchTerm');
if (searchTerm && searchTerm.length) {
return regexSearch(searchTerm, this.get('listToSearch'), this.get('searchProps'));
}
return this.get('listToSearch');
// Three search modes
exactMatchEnabled: true,
fuzzySearchEnabled: false,
regexEnabled: true,

fuse: computed('listToSearch.[]', 'fuzzySearchProps.[]', function() {
return new Fuse(this.get('listToSearch'), {
shouldSort: true,
threshold: 0.4,
location: 0,
distance: 100,
tokenize: true,
matchAllTokens: true,
maxPatternLength: 32,
minMatchCharLength: 1,
keys: this.get('fuzzySearchProps') || [],
getFn(item, key) {
return get(item, key);
},
});
}),

listSearched: computed(
'searchTerm',
'listToSearch.[]',
'exactMatchEnabled',
'fuzzySearchEnabled',
'regexEnabled',
'exactMatchSearchProps.[]',
'fuzzySearchProps.[]',
'regexSearchProps.[]',
function() {
const searchTerm = this.get('searchTerm').trim();

if (!searchTerm || !searchTerm.length) {
return this.get('listToSearch');
}

const results = [];

if (this.get('exactMatchEnabled')) {
results.push(
...exactMatchSearch(
searchTerm,
this.get('listToSearch'),
this.get('exactMatchSearchProps')
)
);
}

if (this.get('fuzzySearchEnabled')) {
results.push(...this.get('fuse').search(searchTerm));
}

if (this.get('regexEnabled')) {
results.push(
...regexSearch(searchTerm, this.get('listToSearch'), this.get('regexSearchProps'))
);
}

return results.uniq();
}
),
});

function exactMatchSearch(term, list, keys) {
if (term.length) {
return list.filter(item => keys.some(key => get(item, key) === term));
}
}

function regexSearch(term, list, keys) {
if (term.length) {
try {
Expand All @@ -38,5 +112,6 @@ function regexSearch(term, list, keys) {
} catch (e) {
// Swallow the error; most likely due to an eager search of an incomplete regex
}
return [];
}
}
3 changes: 3 additions & 0 deletions ui/app/templates/jobs/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{sortedJobs.length}}
{{#if searchTerm}}
<em>({{dec sortedJobs.length filteredJobs.length}} hidden by search term)</em>
{{/if}}
</div>
{{#p.prev class="pagination-previous"}} &lt; {{/p.prev}}
{{#p.next class="pagination-next"}} &gt; {{/p.next}}
Expand Down
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"ember-welcome-page": "^3.0.0",
"eslint": "^4.13.1",
"flat": "^4.0.0",
"fuse.js": "~3.2.0",
"husky": "^0.14.3",
"json-formatter-js": "^2.2.0",
"lint-staged": "^6.0.0",
Expand Down
121 changes: 121 additions & 0 deletions ui/tests/unit/mixins/searchable-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,124 @@ test('the searchable mixin only searches the declared search props', function(as
'Only USA matched, since continent is not a search prop'
);
});

test('the fuzzy search mode is off by default', function(assert) {
const subject = this.subject();
subject.set('source', [
{ id: '1', name: 'United States of America', continent: 'North America' },
{ id: '2', name: 'Canada', continent: 'North America' },
{ id: '3', name: 'Mexico', continent: 'North America' },
]);

subject.set('searchTerm', 'Ameerica');
assert.deepEqual(
subject.get('listSearched'),
[],
'Nothing is matched since America is spelled incorrectly'
);
});

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

subject.set('fuzzySearchEnabled', true);
subject.set('searchTerm', 'Ameerica');
assert.deepEqual(
subject.get('listSearched'),
[{ id: '1', name: 'United States of America', continent: 'North America' }],
'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', [
{ id: '1', name: 'United States of America', continent: 'North America' },
{ id: '2', name: 'Canada', continent: 'North America' },
{ id: '3', name: 'Mexico', continent: 'North America' },
]);

subject.set('regexSearchProps', []);
subject.set('searchTerm', 'Mexico');

assert.deepEqual(
subject.get('listSearched'),
[{ id: '3', name: 'Mexico', continent: 'North America' }],
'Mexico is matched exactly'
);

subject.set('exactMatchEnabled', false);

assert.deepEqual(
subject.get('listSearched'),
[],
'Nothing is matched now that exactMatch is disabled'
);
});

test('the regex search mode can be disabled', function(assert) {
const subject = this.subject();
subject.set('source', [
{ id: '1', name: 'United States of America', continent: 'North America' },
{ id: '2', name: 'Canada', continent: 'North America' },
{ id: '3', name: 'Mexico', continent: 'North America' },
]);

subject.set('searchTerm', '^.{6}$');
assert.deepEqual(
subject.get('listSearched'),
[
{ id: '2', name: 'Canada', continent: 'North America' },
{ id: '3', name: 'Mexico', continent: 'North America' },
],
'Canada and Mexico meet the regex criteria'
);

subject.set('regexEnabled', false);

assert.deepEqual(
subject.get('listSearched'),
[],
'Nothing is matched now that regex is disabled'
);
});

test('each search mode has independent search props', function(assert) {
const subject = this.subject();
subject.set('source', [
{ id: '1', name: 'United States of America', continent: 'North America' },
{ id: '2', name: 'Canada', continent: 'North America' },
{ id: '3', name: 'Mexico', continent: 'North America' },
]);

subject.set('fuzzySearchEnabled', true);
subject.set('regexSearchProps', ['id']);
subject.set('exactMatchSearchProps', ['continent']);
subject.set('fuzzySearchProps', ['name']);

subject.set('searchTerm', 'Nor America');
assert.deepEqual(
subject.get('listSearched'),
[],
'Not an exact match on continent, not a matchAllTokens match on fuzzy, not a regex match on id'
);

subject.set('searchTerm', 'America States');
assert.deepEqual(
subject.get('listSearched'),
[{ id: '1', name: 'United States of America', continent: 'North America' }],
'Fuzzy match on one country, but not an exact match on continent'
);

subject.set('searchTerm', '^(.a){3}$');
assert.deepEqual(
subject.get('listSearched'),
[],
'Canada is not matched by the regex because only id is looked at for regex search'
);
});
4 changes: 4 additions & 0 deletions ui/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4280,6 +4280,10 @@ functional-red-black-tree@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"

fuse.js@~3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.2.0.tgz#f0448e8069855bf2a3e683cdc1d320e7e2a07ef4"

gauge@~2.7.3:
version "2.7.4"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
Expand Down

0 comments on commit f92d364

Please sign in to comment.