Skip to content

Commit

Permalink
Merge pull request #5236 from hashicorp/f-ui-jobs-filtering
Browse files Browse the repository at this point in the history
UI: Faceted search on the jobs list page
  • Loading branch information
DingoEatingFuzz committed Feb 11, 2019
2 parents 2e52b92 + b769365 commit 83d9190
Show file tree
Hide file tree
Showing 8 changed files with 499 additions and 6 deletions.
159 changes: 157 additions & 2 deletions ui/app/controllers/jobs/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,28 @@ import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import Controller, { inject as controller } from '@ember/controller';
import { computed } from '@ember/object';
import { scheduleOnce } from '@ember/runloop';
import intersection from 'lodash.intersection';
import Sortable from 'nomad-ui/mixins/sortable';
import Searchable from 'nomad-ui/mixins/searchable';

// An unattractive but robust way to encode query params
const qpSerialize = arr => (arr.length ? JSON.stringify(arr) : '');
const qpDeserialize = str => {
try {
return JSON.parse(str)
.compact()
.without('');
} catch (e) {
return [];
}
};

const qpSelection = qpKey =>
computed(qpKey, function() {
return qpDeserialize(this.get(qpKey));
});

export default Controller.extend(Sortable, Searchable, {
system: service(),
jobsController: controller('jobs'),
Expand All @@ -16,6 +35,10 @@ export default Controller.extend(Sortable, Searchable, {
searchTerm: 'search',
sortProperty: 'sort',
sortDescending: 'desc',
qpType: 'type',
qpStatus: 'status',
qpDatacenter: 'dc',
qpPrefix: 'prefix',
},

currentPage: 1,
Expand All @@ -28,11 +51,95 @@ export default Controller.extend(Sortable, Searchable, {
fuzzySearchProps: computed(() => ['name']),
fuzzySearchEnabled: true,

qpType: '',
qpStatus: '',
qpDatacenter: '',
qpPrefix: '',

selectionType: qpSelection('qpType'),
selectionStatus: qpSelection('qpStatus'),
selectionDatacenter: qpSelection('qpDatacenter'),
selectionPrefix: qpSelection('qpPrefix'),

optionsType: computed(() => [
{ key: 'batch', label: 'Batch' },
{ key: 'parameterized', label: 'Parameterized' },
{ key: 'periodic', label: 'Periodic' },
{ key: 'service', label: 'Service' },
{ key: 'system', label: 'System' },
]),

optionsStatus: computed(() => [
{ key: 'pending', label: 'Pending' },
{ key: 'running', label: 'Running' },
{ key: 'dead', label: 'Dead' },
]),

optionsDatacenter: computed('visibleJobs.[]', function() {
const flatten = (acc, val) => acc.concat(val);
const allDatacenters = new Set(
this.get('visibleJobs')
.mapBy('datacenters')
.reduce(flatten, [])
);

// Remove any invalid datacenters from the query param/selection
const availableDatacenters = Array.from(allDatacenters).compact();
scheduleOnce('actions', () => {
this.set(
'qpDatacenter',
qpSerialize(intersection(availableDatacenters, this.get('selectionDatacenter')))
);
});

return availableDatacenters.sort().map(dc => ({ key: dc, label: dc }));
}),

optionsPrefix: computed('visibleJobs.[]', function() {
// A prefix is defined as the start of a job name up to the first - or .
// ex: mktg-analytics -> mktg, ds.supermodel.classifier -> ds
const hasPrefix = /.[-._]/;

// Collect and count all the prefixes
const allNames = this.get('visibleJobs').mapBy('name');
const nameHistogram = allNames.reduce((hist, name) => {
if (hasPrefix.test(name)) {
const prefix = name.match(/(.+?)[-.]/)[1];
hist[prefix] = hist[prefix] ? hist[prefix] + 1 : 1;
}
return hist;
}, {});

// Convert to an array
const nameTable = Object.keys(nameHistogram).map(key => ({
prefix: key,
count: nameHistogram[key],
}));

// Only consider prefixes that match more than one name
const prefixes = nameTable.filter(name => name.count > 1);

// Remove any invalid prefixes from the query param/selection
const availablePrefixes = prefixes.mapBy('prefix');
scheduleOnce('actions', () => {
this.set(
'qpPrefix',
qpSerialize(intersection(availablePrefixes, this.get('selectionPrefix')))
);
});

// Sort, format, and include the count in the label
return prefixes.sortBy('prefix').map(name => ({
key: name.prefix,
label: `${name.prefix} (${name.count})`,
}));
}),

/**
Filtered jobs are those that match the selected namespace and aren't children
Visible jobs are those that match the selected namespace and aren't children
of periodic or parameterized jobs.
*/
filteredJobs: computed('model.[]', 'model.@each.parent', function() {
visibleJobs: computed('model.[]', 'model.@each.parent', function() {
// Namespace related properties are ommitted from the dependent keys
// due to a prop invalidation bug caused by region switching.
const hasNamespaces = this.get('system.namespaces.length');
Expand All @@ -44,12 +151,60 @@ export default Controller.extend(Sortable, Searchable, {
.filter(job => !job.get('parent.content'));
}),

filteredJobs: computed(
'visibleJobs.[]',
'selectionType',
'selectionStatus',
'selectionDatacenter',
'selectionPrefix',
function() {
const {
selectionType: types,
selectionStatus: statuses,
selectionDatacenter: datacenters,
selectionPrefix: prefixes,
} = this.getProperties(
'selectionType',
'selectionStatus',
'selectionDatacenter',
'selectionPrefix'
);

// A job must match ALL filter facets, but it can match ANY selection within a facet
// Always return early to prevent unnecessary facet predicates.
return this.get('visibleJobs').filter(job => {
if (types.length && !types.includes(job.get('displayType'))) {
return false;
}

if (statuses.length && !statuses.includes(job.get('status'))) {
return false;
}

if (datacenters.length && !job.get('datacenters').find(dc => datacenters.includes(dc))) {
return false;
}

const name = job.get('name');
if (prefixes.length && !prefixes.find(prefix => name.startsWith(prefix))) {
return false;
}

return true;
});
}
),

listToSort: alias('filteredJobs'),
listToSearch: alias('listSorted'),
sortedJobs: alias('listSearched'),

isShowingDeploymentDetails: false,

setFacetQueryParam(queryParam, selection) {
this.set(queryParam, qpSerialize(selection));
},

actions: {
gotoJob(job) {
this.transitionToRoute('jobs.job', job.get('plainId'));
Expand Down
6 changes: 6 additions & 0 deletions ui/app/styles/components/dropdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,10 @@
}
}
}

.dropdown-empty {
display: block;
padding: 8px 12px;
color: $grey-light;
}
}
4 changes: 3 additions & 1 deletion ui/app/templates/components/multi-select-dropdown.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
{{#dd.content class="dropdown-options"}}
<ul role="listbox" data-test-dropdown-options>
{{#each options key="key" as |option|}}
<li data-test-dropdown-option class="dropdown-option" tabindex="1" onkeydown={{action "traverseList" option}}>
<li data-test-dropdown-option={{option.key}} class="dropdown-option" tabindex="1" onkeydown={{action "traverseList" option}}>
<label>
<input
type="checkbox"
Expand All @@ -28,6 +28,8 @@
{{option.label}}
</label>
</li>
{{else}}
<em data-test-dropdown-empty class="dropdown-empty">No options</em>
{{/each}}
</ul>
{{/dd.content}}
Expand Down
39 changes: 36 additions & 3 deletions ui/app/templates/jobs/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{{else}}
<div class="columns">
{{#if filteredJobs.length}}
<div class="column">
<div class="column is-one-third">
{{search-box
data-test-jobs-search
searchTerm=(mut searchTerm)
Expand All @@ -13,7 +13,35 @@
</div>
{{/if}}
<div class="column is-centered">
{{#link-to "jobs.run" data-test-run-job class="button is-primary is-pulled-right"}}Run Job{{/link-to}}
<div class="button-bar is-pulled-right">
{{multi-select-dropdown
data-test-type-facet
label="Type"
options=optionsType
selection=selectionType
onSelect=(action setFacetQueryParam "qpType")}}
{{multi-select-dropdown
data-test-status-facet
label="Status"
options=optionsStatus
selection=selectionStatus
onSelect=(action setFacetQueryParam "qpStatus")}}
{{multi-select-dropdown
data-test-datacenter-facet
label="Datacenter"
options=optionsDatacenter
selection=selectionDatacenter
onSelect=(action setFacetQueryParam "qpDatacenter")}}
{{multi-select-dropdown
data-test-prefix-facet
label="Prefix"
options=optionsPrefix
selection=selectionPrefix
onSelect=(action setFacetQueryParam "qpPrefix")}}
</div>
</div>
<div class="column is-minimum is-centered">
{{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
</div>
</div>
{{#list-pagination
Expand Down Expand Up @@ -52,11 +80,16 @@
</div>
{{else}}
<div data-test-empty-jobs-list class="empty-message">
{{#if (eq filteredJobs.length 0)}}
{{#if (eq visibleJobs.length 0)}}
<h3 data-test-empty-jobs-list-headline class="empty-message-headline">No Jobs</h3>
<p class="empty-message-body">
The cluster is currently empty.
</p>
{{else if (eq filteredJobs.length 0)}}
<h3 data-test-empty-jobs-list-headline class="empty-message-headline">No Matches</h3>
<p class="empty-message-body">
No jobs match your current filter selection.
</p>
{{else if searchTerm}}
<h3 data-test-empty-jobs-list-headline class="empty-message-headline">No Matches</h3>
<p class="empty-message-body">No jobs match the term <strong>{{searchTerm}}</strong></p>
Expand Down
Loading

0 comments on commit 83d9190

Please sign in to comment.