From fb70217802d666d70a3ec1cfda468f4a48949544 Mon Sep 17 00:00:00 2001 From: Jai Bhagat Date: Wed, 26 Oct 2022 14:52:21 -0400 Subject: [PATCH] temp: filtering DSL test --- ui/app/adapters/job.js | 6 + ui/app/controllers/jobs/index.js | 83 +++-- ui/app/routes/jobs/index.js | 112 +++++- ui/app/templates/jobs/index.hbs | 60 +-- ui/app/templates/jobs/loading.hbs | 2 +- ui/tests/acceptance/jobs-list-test.js | 512 +++++++++++++++++++++++++- 6 files changed, 708 insertions(+), 67 deletions(-) diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index 08cc9b98e28..805ec213004 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -106,4 +106,10 @@ export default class JobAdapter extends WatchableNamespaceIDs { }, }); } + + handleResponse(_status, headers) { + const result = super.handleResponse(...arguments); + result.meta = { nextToken: headers['x-nomad-nexttoken'] }; + return result; + } } diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index 3a38d623602..44fb0b74c2d 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -25,43 +25,56 @@ export default class IndexController extends Controller.extend( isForbidden = false; queryParams = [ - { - pageSize: 'pageSize', - }, - { - currentPage: 'page', - }, - { - searchTerm: 'search', - }, + 'pageSize', + 'nextToken', + 'status', + 'type', + 'searchTerm', + 'datacenter', + 'prefix', { sortProperty: 'sort', }, { sortDescending: 'desc', }, - { - qpType: 'type', - }, - { - qpStatus: 'status', - }, - { - qpDatacenter: 'dc', - }, - { - qpPrefix: 'prefix', - }, { qpNamespace: 'namespace', }, ]; - currentPage = 1; - sortProperty = 'modifyIndex'; sortDescending = true; @tracked pageSize = this.userSettings.pageSize; + @tracked nextToken = null; + @tracked previousTokens = []; + + get shouldDisableNext() { + return !this.model.jobs.meta?.nextToken; + } + + get shouldDisablePrev() { + return !this.previousTokens.length; + } + + @action + onNext(nextToken) { + this.previousTokens = [...this.previousTokens, this.nextToken]; + this.nextToken = nextToken; + } + + @action + onPrev(lastToken) { + this.previousTokens.pop(); + this.previousTokens = [...this.previousTokens]; + this.nextToken = lastToken; + } + + @action + refresh() { + this.nextToken = null; + this.previousTokens = []; + } @action setPageSize(newPageSize) { @@ -80,15 +93,15 @@ export default class IndexController extends Controller.extend( fuzzySearchEnabled = true; - qpType = ''; - qpStatus = ''; - qpDatacenter = ''; - qpPrefix = ''; + @tracked type = ''; + @tracked status = ''; + @tracked datacenter = ''; + @tracked prefix = ''; - @selection('qpType') selectionType; - @selection('qpStatus') selectionStatus; - @selection('qpDatacenter') selectionDatacenter; - @selection('qpPrefix') selectionPrefix; + @selection('type') selectionType; + @selection('status') selectionStatus; + @selection('datacenter') selectionDatacenter; + @selection('prefix') selectionPrefix; @computed get optionsType() { @@ -260,7 +273,15 @@ export default class IndexController extends Controller.extend( isShowingDeploymentDetails = false; + @action setFacetQueryParam(queryParam, selection) { + this._resetTokens(); this.set(queryParam, serialize(selection)); } + + @action + _resetTokens() { + this.nextToken = null; + this.previousTokens = []; + } } diff --git a/ui/app/routes/jobs/index.js b/ui/app/routes/jobs/index.js index c3b957c747b..d6bc14ff7ab 100644 --- a/ui/app/routes/jobs/index.js +++ b/ui/app/routes/jobs/index.js @@ -20,15 +20,118 @@ export default class IndexRoute extends Route.extend( pageSize: { refreshModel: true, }, + nextToken: { + refreshModel: true, + }, + status: { + refreshModel: true, + }, + type: { + refreshModel: true, + }, + searchTerm: { + refreshModel: true, + }, + datacenter: { + refreshModel: true, + }, + prefix: { + refreshModel: true, + }, }; - model(params) { + model({ + searchTerm, + type, + qpNamespace, + pageSize, + status, + nextToken, + datacenter, + }) { + const parseJSON = (qp) => (qp ? JSON.parse(qp) : null); + const iterateOverList = (list, type) => { + const dictionary = { + type: ['Type', '=='], + status: ['Status', '=='], + datacenter: ['Datacenters', 'contains'], + }; + const [selector, matcher] = dictionary[type]; + if (!list) return null; + return list.reduce((accum, val, idx) => { + if (idx !== list.length - 1) { + accum += `${selector} ${matcher} "${val}" or `; + } else { + accum += `${selector} ${matcher} "${val}")`; + } + return accum; + }, '('); + }; + /* + We use our own DSL for filter expressions. This function takes our query parameters and builds a query that matches our DSL. + Documentation can be found here: https://www.nomadproject.io/api-docs#filtering + */ + const generateFilterExpression = () => { + const searchFilter = searchTerm ? `Name contains "${searchTerm}"` : null; + const typeFilter = iterateOverList(parseJSON(type), 'type'); + const datacenterFilter = iterateOverList( + parseJSON(datacenter), + 'datacenter' + ); + const statusFilter = iterateOverList(parseJSON(status), 'status'); + + let filterExp; + if (searchTerm) { + if (!type && !status && !datacenter) { + return searchFilter; + } + filterExp = `(${searchFilter})`; + if (type) { + filterExp = `${filterExp} and ${typeFilter}`; + } + if (datacenter) { + filterExp = `${filterExp} and ${datacenterFilter}`; + } + if (status) { + filterExp = `${filterExp} and ${statusFilter}`; + } + return filterExp; + } + + if (type || status || datacenter) { + const lookup = { + [type]: typeFilter, + [status]: statusFilter, + [datacenter]: datacenterFilter, + }; + + filterExp = [type, status, datacenter].reduce((result, filter) => { + const expression = lookup[filter]; + if (!!filter && result !== '') { + result = result.concat(` and ${expression}`); + } else if (filter) { + result = expression; + } + return result; + }, ''); + debugger; + return filterExp; + } + + return null; + }; + + const hasFilters = !!generateFilterExpression(); + return RSVP.hash({ jobs: this.store .query('job', { - namespace: params.qpNamespace, - per_page: params.pageSize, - filter: `ParentID is empty`, + namespace: qpNamespace, + per_page: pageSize, + filter: hasFilters + ? `ParentID is empty and ${generateFilterExpression()}` + : `ParentID is empty`, + next_token: nextToken, }) .catch(notifyForbidden(this)), namespaces: this.store.findAll('namespace'), @@ -43,6 +146,7 @@ export default class IndexRoute extends Route.extend( namespace: controller.qpNamespace, per_page: controller.pageSize, filter: `ParentID is empty`, + next_token: controller.nextToken, }) ); } diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index 6d10be31856..32e5c53666a 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -51,28 +51,28 @@ @label="Type" @options={{this.optionsType}} @selection={{this.selectionType}} - @onSelect={{action this.setFacetQueryParam "qpType"}} + @onSelect={{action this.setFacetQueryParam "type"}} /> @@ -107,7 +107,6 @@ -
+
- +
+ + + +
{{else}} diff --git a/ui/app/templates/jobs/loading.hbs b/ui/app/templates/jobs/loading.hbs index 1b60ae2223e..56d4ff205ad 100644 --- a/ui/app/templates/jobs/loading.hbs +++ b/ui/app/templates/jobs/loading.hbs @@ -1 +1 @@ -
+
diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index 431a6e0374e..aabed0d150e 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -1,9 +1,13 @@ /* eslint-disable qunit/require-expect */ -import { currentURL } from '@ember/test-helpers'; +import { click, currentURL, typeIn, visit, waitFor } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; +import { + selectChoose, + clickTrigger, +} from 'ember-power-select/test-support/helpers'; import pageSizeSelect from './behaviors/page-size-select'; import JobsList from 'nomad-ui/tests/pages/jobs/list'; import percySnapshot from '@percy/ember'; @@ -38,7 +42,7 @@ module('Acceptance | jobs list', function (hooks) { }); test('/jobs should list the first page of jobs sorted by modify index', async function (assert) { - const jobsCount = JobsList.pageSize + 1; + const jobsCount = JobsList.pageSize - 1; server.createList('job', jobsCount, { createAllocations: false }); await JobsList.visit(); @@ -489,6 +493,510 @@ module('Acceptance | jobs list', function (hooks) { assert.ok(JobsList.runJobButton.isDisabled); }); + module('filters', function () { + test('it should enable filtering by job status', async function (assert) { + assert.expect(2); + + const jobsCount = JobsList.pageSize + 1; + server.createList('job', jobsCount, { createAllocations: false }); + + await visit('/jobs'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: 'ParentID is empty and Status == "pending"', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + + await click('[data-test-status-facet] > [data-test-dropdown-trigger]'); + await click('[data-test-dropdown-option=pending] > label > input'); + console.log('currentURL', currentURL()); + + assert + .dom('[data-test-empty-jobs-list-headline]') + .exists('Renders a message saying no jobs match filter status'); + }); + + test('it should enable filtering by namespace', async function (assert) { + assert.expect(2); + + const jobsCount = JobsList.pageSize + 1; + server.createList('namespace', 2); + server.createList('job', jobsCount, { + createAllocations: false, + namespaceId: server.db.namespaces[0].id, + }); + + await visit('/jobs'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: 'default', + per_page: '25', + next_token: '', + filter: 'ParentID is empty', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + + await clickTrigger('[data-test-namespace-facet]'); + await selectChoose('[data-test-namespace-facet]', 'default'); + + assert + .dom('[data-test-empty-jobs-list]') + .exists('Renders a message saying no jobs match filter status'); + }); + + test('it should enable filtering by job type', async function (assert) { + assert.expect(2); + + const jobsCount = JobsList.pageSize + 1; + server.createList('job', jobsCount, { createAllocations: false }); + + await visit('/jobs'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: `TriggeredBy contains "periodic-job"`, + reverse: 'true', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + + await click('[data-test-type-facet] > [data-test-dropdown-trigger]'); + await click('[data-test-dropdown-option=parameterized] > label > input'); + + assert + .dom('[data-test-empty-jobs-list]') + .exists('Renders a message saying no jobs match filter status'); + }); + + test('it should enable filtering by datacenter', async function (assert) { + assert.expect(2); + + const jobsCount = JobsList.pageSize + 1; + server.createList('job', jobsCount, { createAllocations: false }); + + await visit('/jobs'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: 'NodeID is not empty', + reverse: 'true', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + + await click( + '[data-test-datacenter-facet] > [data-test-dropdown-trigger]' + ); + await click('[data-test-dropdown-option=dc1] > label > input'); + + assert + .dom('[data-test-empty-evaluations-list]') + .exists('Renders a message saying no evaluations match filter status'); + }); + + test.skip('it should enable filtering by prefix', async function (assert) { + // TODO + assert.expect(2); + + const jobsCount = JobsList.pageSize + 1; + server.createList('job', jobsCount, { createAllocations: false }); + + await visit('/jobs'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: 'NodeID is not empty', + reverse: 'true', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + + await click('[data-test-prefix-facet] > [data-test-dropdown-trigger]'); + await click('[data-test-dropdown-option=dc1] > label > input'); + + assert + .dom('[data-test-empty-evaluations-list]') + .exists('Renders a message saying no evaluations match filter status'); + }); + + test('it should enable filtering by search term', async function (assert) { + assert.expect(2); + + server.get('/jobs', server.db.jobs); + + await visit('/jobs'); + + const searchTerm = 'Lasso'; + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: `ID contains "${searchTerm}" or JobID contains "${searchTerm}" or NodeID contains "${searchTerm}" or TriggeredBy contains "${searchTerm}"`, + reverse: 'true', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + + await typeIn('[data-test-evaluations-search] input', searchTerm); + + assert + .dom('[data-test-empty-evaluations-list]') + .exists('Renders a message saying no evaluations match filter status'); + }); + + test('it should enable combining filters and search', async function (assert) { + assert.expect(5); + + server.get('/jobs', server.db.jobs); + + await visit('/jobs'); + + const searchTerm = 'Lasso'; + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: `ID contains "${searchTerm}" or JobID contains "${searchTerm}" or NodeID contains "${searchTerm}" or TriggeredBy contains "${searchTerm}"`, + reverse: 'true', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + await typeIn('[data-test-evaluations-search] input', searchTerm); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: `(ID contains "${searchTerm}" or JobID contains "${searchTerm}" or NodeID contains "${searchTerm}" or TriggeredBy contains "${searchTerm}") and NodeID is not empty`, + reverse: 'true', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + await clickTrigger('[data-test-evaluation-type-facet]'); + await selectChoose('[data-test-evaluation-type-facet]', 'Client'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: `NodeID is not empty`, + reverse: 'true', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + await click('[data-test-evaluations-search] button'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: `NodeID is not empty and Status contains "complete"`, + reverse: 'true', + }, + 'It makes another server request using the options selected by the user' + ); + return []; + }); + await clickTrigger('[data-test-evaluation-status-facet]'); + await selectChoose('[data-test-evaluation-status-facet]', 'Complete'); + + assert + .dom('[data-test-empty-evaluations-list]') + .exists('Renders a message saying no evaluations match filter status'); + }); + }); + + module('page size', function (hooks) { + hooks.afterEach(function () { + // PageSizeSelect and the Evaluations Controller are both using localStorage directly + // Will come back and invert the dependency + window.localStorage.clear(); + }); + + test('it is possible to change page size', async function (assert) { + assert.expect(1); + + server.get('/jobs', server.db.jobs); + + await visit('/jobs'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '50', + next_token: '', + filter: '', + reverse: 'true', + }, + 'It makes a request with the per_page set by the user' + ); + return server.db.jobs; + }); + + await clickTrigger('[data-test-per-page]'); + await selectChoose('[data-test-per-page]', 50); + }); + }); + + module('pagination', function () { + test('it should enable pagination by using next tokens', async function (assert) { + assert.expect(7); + + server.get('/jobs', function () { + return new Response( + 200, + { 'x-nomad-nexttoken': 'next-token-1' }, + server.db.jobs + ); + }); + + await visit('/jobs'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: 'next-token-1', + filter: '', + reverse: 'true', + }, + 'It makes another server request using the options selected by the user' + ); + return new Response( + 200, + { 'x-nomad-nexttoken': 'next-token-2' }, + server.db.jobs + ); + }); + + assert + .dom('[data-test-eval-pagination-next]') + .isEnabled( + 'If there is a next-token in the API response the next button should be enabled.' + ); + await click('[data-test-eval-pagination-next]'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: 'next-token-2', + filter: '', + reverse: 'true', + }, + 'It makes another server request using the options selected by the user' + ); + return server.db.jobs; + }); + await click('[data-test-eval-pagination-next]'); + + assert + .dom('[data-test-eval-pagination-next]') + .isDisabled('If there is no next-token, the next button is disabled.'); + + assert + .dom('[data-test-eval-pagination-prev]') + .isEnabled( + 'After we transition to the next page, the previous page button is enabled.' + ); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: 'next-token-1', + filter: '', + reverse: 'true', + }, + 'It makes a request using the stored old token.' + ); + return new Response( + 200, + { 'x-nomad-nexttoken': 'next-token-2' }, + server.db.jobs + ); + }); + + await click('[data-test-eval-pagination-prev]'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: '', + reverse: 'true', + }, + 'When there are no more stored previous tokens, we will request with no next-token.' + ); + return new Response( + 200, + { 'x-nomad-nexttoken': 'next-token-1' }, + server.db.jobs + ); + }); + + await click('[data-test-eval-pagination-prev]'); + }); + + test('it should clear all query parameters on refresh', async function (assert) { + assert.expect(1); + + server.get('/jobs', function () { + return new Response( + 200, + { 'x-nomad-nexttoken': 'next-token-1' }, + server.db.jobs + ); + }); + + await visit('/jobs'); + + server.get('/jobs', function () { + return server.db.jobs; + }); + + await click('[data-test-eval-pagination-next]'); + + await clickTrigger('[data-test-evaluation-status-facet]'); + await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: '', + reverse: 'true', + }, + 'It clears all query parameters when making a refresh' + ); + return new Response( + 200, + { 'x-nomad-nexttoken': 'next-token-1' }, + server.db.jobs + ); + }); + + await click('[data-test-eval-refresh]'); + }); + + test('it should reset pagination when filters are applied', async function (assert) { + assert.expect(1); + + server.get('/jobs', function () { + return new Response( + 200, + { 'x-nomad-nexttoken': 'next-token-1' }, + server.db.jobs + ); + }); + + await visit('/jobs'); + + server.get('/jobs', function () { + return new Response( + 200, + { 'x-nomad-nexttoken': 'next-token-2' }, + server.db.jobs + ); + }); + + await click('[data-test-eval-pagination-next]'); + + server.get('/jobs', server.db.jobs); + await click('[data-test-eval-pagination-next]'); + + server.get('/jobs', function (_server, fakeRequest) { + assert.deepEqual( + fakeRequest.queryParams, + { + namespace: '*', + per_page: '25', + next_token: '', + filter: 'Status contains "pending"', + reverse: 'true', + }, + 'It clears all next token when filtered request is made' + ); + return server.db.jobs; + }); + await clickTrigger('[data-test-evaluation-status-facet]'); + await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); + }); + }); + pageSizeSelect({ resourceName: 'job', pageObject: JobsList,