Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: fuzzy and tokenized job search #4201

Merged
merged 8 commits into from
May 5, 2018
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
81 changes: 75 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,77 @@ 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) {
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();
}
return this.get('listToSearch');
Copy link
Member

@alisdair alisdair Apr 30, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inverting the condition and returning the full list earlier would reduce indentation for the search code and might be a little clearer.

}
),
});

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 +106,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