Merge pull request #4201 from hashicorp/f-ui-fuzzy-job-search

UI: fuzzy and tokenized job search
This commit is contained in:
Michael Lange 2018-05-05 09:56:15 -07:00 committed by GitHub
commit 4774a16bcd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 213 additions and 7 deletions

View file

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

View file

@ -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
@ -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:
@ -17,17 +25,83 @@ import { get, computed } from '@ember/object';
export default Mixin.create({
searchTerm: '',
listToSearch: computed(() => []),
searchProps: null,
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');
searchProps: null,
exactMatchSearchProps: reads('searchProps'),
regexSearchProps: reads('searchProps'),
fuzzySearchProps: reads('searchProps'),
// 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 {
@ -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 [];
}
}

View file

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

View file

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

View file

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

View file

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