From 8ed8a85b9b110fb16e7a20c378f62d445ac58c37 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 16 Jan 2019 16:29:31 -0800 Subject: [PATCH 01/10] Scaffold the facets and facet options for the jobs list page --- ui/app/controllers/jobs/index.js | 27 +++++++++++++++++++++++++++ ui/app/templates/jobs/index.hbs | 28 ++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index e2bdb4a01061..80f842528f89 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -28,6 +28,33 @@ export default Controller.extend(Sortable, Searchable, { fuzzySearchProps: computed(() => ['name']), fuzzySearchEnabled: true, + facetOptionsType: computed(() => [ + { key: 'batch', label: 'Batch' }, + { key: 'parameterized', label: 'Parameterized' }, + { key: 'periodic', label: 'Periodic' }, + { key: 'service', label: 'Service' }, + { key: 'system', label: 'System' }, + ]), + + facetOptionsStatus: computed(() => [ + { key: 'pending', label: 'Pending' }, + { key: 'running', label: 'Running' }, + { key: 'dead', label: 'Dead' }, + ]), + + facetOptionsDatacenter: computed('model.[]', function() { + return [{ key: 'dc1', label: 'dc1' }]; + }), + + facetOptionsPrefix: computed('model.[]', function() { + return [{ key: 'atlas-', label: 'atlas-' }]; + }), + + facetSelectionType: computed(() => []), + facetSelectionStatus: computed(() => []), + facetSelectionDatacenter: computed(() => []), + facetSelectionPrefix: computed(() => []), + /** Filtered jobs are those that match the selected namespace and aren't children of periodic or parameterized jobs. diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index 97e5d1a76259..2ef98ccf2743 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -4,7 +4,7 @@ {{else}}
{{#if filteredJobs.length}} -
+
{{search-box data-test-jobs-search searchTerm=(mut searchTerm) @@ -13,7 +13,31 @@
{{/if}}
- {{#link-to "jobs.run" data-test-run-job class="button is-primary is-pulled-right"}}Run Job{{/link-to}} +
+ {{multi-select-dropdown + label="Type" + options=facetOptionsType + selection=facetSelectionType + onSelect=(action (mut facetSelectionType))}} + {{multi-select-dropdown + label="Status" + options=facetOptionsStatus + selection=facetSelectionStatus + onSelect=(action (mut facetSelectionStatus))}} + {{multi-select-dropdown + label="Datacenter" + options=facetOptionsDatacenter + selection=facetSelectionDatacenter + onSelect=(action (mut facetSelectionDatacenter))}} + {{multi-select-dropdown + label="Prefix" + options=facetOptionsPrefix + selection=facetSelectionPrefix + onSelect=(action (mut facetSelectionPrefix))}} +
+
+
+ {{#link-to "jobs.run" data-test-run-job class="button is-primary"}}Run Job{{/link-to}}
{{#list-pagination From 3d87c47d04134499503a83a585aabffc4371e078 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 17 Jan 2019 11:10:42 -0800 Subject: [PATCH 02/10] Implement the dynamic facet lists (datacenters and prefixes) --- ui/app/controllers/jobs/index.js | 49 ++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index 80f842528f89..e0d74159f1c4 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -42,12 +42,51 @@ export default Controller.extend(Sortable, Searchable, { { key: 'dead', label: 'Dead' }, ]), - facetOptionsDatacenter: computed('model.[]', function() { - return [{ key: 'dc1', label: 'dc1' }]; + facetOptionsDatacenter: computed('visibleJobs.[]', function() { + const flatten = (acc, val) => acc.concat(val); + const allDatacenters = new Set( + this.get('visibleJobs') + .mapBy('datacenters') + .reduce(flatten, []) + ); + + return Array.from(allDatacenters) + .compact() + .sort() + .map(dc => ({ key: dc, label: dc })); }), - facetOptionsPrefix: computed('model.[]', function() { - return [{ key: 'atlas-', label: 'atlas-' }]; + facetOptionsPrefix: 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, then convert to an + // options array, including the counts in the label + return nameTable + .filter(name => name.count > 1) + .sortBy('prefix') + .reverse() + .map(name => ({ + key: name.prefix, + label: `${name.prefix} (${name.count})`, + })); }), facetSelectionType: computed(() => []), @@ -59,7 +98,7 @@ export default Controller.extend(Sortable, Searchable, { Filtered 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'); From 3b235a1b6e2c12986ed3affdbfa07165cb553d0b Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 17 Jan 2019 11:11:17 -0800 Subject: [PATCH 03/10] Implement the job list filtering based on facet selection --- ui/app/controllers/jobs/index.js | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index e0d74159f1c4..a6dfd31fa727 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -110,6 +110,40 @@ export default Controller.extend(Sortable, Searchable, { .filter(job => !job.get('parent.content')); }), + filteredJobs: computed( + 'visibleJobs.[]', + 'facetSelectionType', + 'facetSelectionStatus', + 'facetSelectionDatacenter', + 'facetSelectionPrefix', + function() { + const { + facetSelectionType: types, + facetSelectionStatus: statuses, + facetSelectionDatacenter: datacenters, + facetSelectionPrefix: prefixes, + } = this.getProperties( + 'facetSelectionType', + 'facetSelectionStatus', + 'facetSelectionDatacenter', + 'facetSelectionPrefix' + ); + + // 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'), From 5aef4d92ff03de373be773ca18e62500ac6420d2 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 22 Jan 2019 16:29:09 -0800 Subject: [PATCH 04/10] Support for the no options case --- ui/app/styles/components/dropdown.scss | 6 ++++++ .../templates/components/multi-select-dropdown.hbs | 2 ++ ui/tests/integration/multi-select-dropdown-test.js | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/ui/app/styles/components/dropdown.scss b/ui/app/styles/components/dropdown.scss index 53bab9929b11..7eb4c33f32c7 100644 --- a/ui/app/styles/components/dropdown.scss +++ b/ui/app/styles/components/dropdown.scss @@ -142,4 +142,10 @@ } } } + + .dropdown-empty { + display: block; + padding: 8px 12px; + color: $grey-light; + } } diff --git a/ui/app/templates/components/multi-select-dropdown.hbs b/ui/app/templates/components/multi-select-dropdown.hbs index 5e1f4aa09df5..fed2fdf4729b 100644 --- a/ui/app/templates/components/multi-select-dropdown.hbs +++ b/ui/app/templates/components/multi-select-dropdown.hbs @@ -28,6 +28,8 @@ {{option.label}} + {{else}} + No options {{/each}} {{/dd.content}} diff --git a/ui/tests/integration/multi-select-dropdown-test.js b/ui/tests/integration/multi-select-dropdown-test.js index 9b9ff1b0371e..f619ba808651 100644 --- a/ui/tests/integration/multi-select-dropdown-test.js +++ b/ui/tests/integration/multi-select-dropdown-test.js @@ -297,3 +297,15 @@ test('pressing ESC when the options list is open closes the list and returns foc 'The trigger has focus' ); }); + +test('when there are no list options, an empty message is shown', function(assert) { + const props = commonProperties(); + props.options = []; + this.setProperties(props); + this.render(commonTemplate); + + click('[data-test-dropdown-trigger]'); + assert.ok(find('[data-test-dropdown-options]'), 'The dropdown is still shown'); + assert.ok(find('[data-test-dropdown-empty]'), 'The empty state is shown'); + assert.notOk(find('[data-test-dropdown-option]'), 'No options are shown'); +}); From 3b8187fbe90e98d063ff3a8e6bc9e79ce1624c7c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 23 Jan 2019 13:56:32 -0800 Subject: [PATCH 05/10] Introduce encode/decode for array query params --- ui/app/controllers/jobs/index.js | 72 ++++++++++++++++++++++++++------ ui/app/templates/jobs/index.hbs | 8 ++-- 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index a6dfd31fa727..747e4f8c604f 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -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 selectionFromQP = qpKey => + computed(qpKey, function() { + return qpDeserialize(this.get(qpKey)); + }); + export default Controller.extend(Sortable, Searchable, { system: service(), jobsController: controller('jobs'), @@ -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, @@ -50,10 +73,16 @@ export default Controller.extend(Sortable, Searchable, { .reduce(flatten, []) ); - return Array.from(allDatacenters) - .compact() - .sort() - .map(dc => ({ key: dc, label: dc })); + // 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('facetSelectionDatacenter'))) + ); + }); + + return availableDatacenters.sort().map(dc => ({ key: dc, label: dc })); }), facetOptionsPrefix: computed('visibleJobs.[]', function() { @@ -77,10 +106,20 @@ export default Controller.extend(Sortable, Searchable, { count: nameHistogram[key], })); - // Only consider prefixes that match more than one name, then convert to an - // options array, including the counts in the label - return nameTable - .filter(name => name.count > 1) + // 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('facetSelectionPrefix'))) + ); + }); + + // Sort, format, and include the count in the label + return prefixes .sortBy('prefix') .reverse() .map(name => ({ @@ -89,10 +128,15 @@ export default Controller.extend(Sortable, Searchable, { })); }), - facetSelectionType: computed(() => []), - facetSelectionStatus: computed(() => []), - facetSelectionDatacenter: computed(() => []), - facetSelectionPrefix: computed(() => []), + qpType: '', + qpStatus: '', + qpDatacenter: '', + qpPrefix: '', + + facetSelectionType: selectionFromQP('qpType'), + facetSelectionStatus: selectionFromQP('qpStatus'), + facetSelectionDatacenter: selectionFromQP('qpDatacenter'), + facetSelectionPrefix: selectionFromQP('qpPrefix'), /** Filtered jobs are those that match the selected namespace and aren't children @@ -150,6 +194,10 @@ export default Controller.extend(Sortable, Searchable, { isShowingDeploymentDetails: false, + setFacetQueryParam(queryParam, selection) { + this.set(queryParam, qpSerialize(selection)); + }, + actions: { gotoJob(job) { this.transitionToRoute('jobs.job', job.get('plainId')); diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index 2ef98ccf2743..c5c27d53596f 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -18,22 +18,22 @@ label="Type" options=facetOptionsType selection=facetSelectionType - onSelect=(action (mut facetSelectionType))}} + onSelect=(action setFacetQueryParam "qpType")}} {{multi-select-dropdown label="Status" options=facetOptionsStatus selection=facetSelectionStatus - onSelect=(action (mut facetSelectionStatus))}} + onSelect=(action setFacetQueryParam "qpStatus")}} {{multi-select-dropdown label="Datacenter" options=facetOptionsDatacenter selection=facetSelectionDatacenter - onSelect=(action (mut facetSelectionDatacenter))}} + onSelect=(action setFacetQueryParam "qpDatacenter")}} {{multi-select-dropdown label="Prefix" options=facetOptionsPrefix selection=facetSelectionPrefix - onSelect=(action (mut facetSelectionPrefix))}} + onSelect=(action setFacetQueryParam "qpPrefix")}}
From b20dc67019e0893060972f9f182cf73206ef478c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 23 Jan 2019 20:33:47 -0800 Subject: [PATCH 06/10] Model facets in the page object --- .../components/multi-select-dropdown.hbs | 2 +- ui/tests/pages/components/facet.js | 17 +++++++++++++++++ ui/tests/pages/jobs/list.js | 9 +++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 ui/tests/pages/components/facet.js diff --git a/ui/app/templates/components/multi-select-dropdown.hbs b/ui/app/templates/components/multi-select-dropdown.hbs index fed2fdf4729b..f7b48e14988d 100644 --- a/ui/app/templates/components/multi-select-dropdown.hbs +++ b/ui/app/templates/components/multi-select-dropdown.hbs @@ -18,7 +18,7 @@ {{#dd.content class="dropdown-options"}}
    {{#each options key="key" as |option|}} -