diff --git a/ui/app/components/page-size-select.js b/ui/app/components/page-size-select.js new file mode 100644 index 000000000..0776d7084 --- /dev/null +++ b/ui/app/components/page-size-select.js @@ -0,0 +1,11 @@ +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; + +export default Component.extend({ + userSettings: service(), + + tagName: '', + pageSizeOptions: Object.freeze([10, 25, 50]), + + onChange() {}, +}); diff --git a/ui/app/controllers/clients/index.js b/ui/app/controllers/clients/index.js index 2bffd56c9..2c1463c41 100644 --- a/ui/app/controllers/clients/index.js +++ b/ui/app/controllers/clients/index.js @@ -1,4 +1,5 @@ -import { alias } from '@ember/object/computed'; +import { alias, readOnly } from '@ember/object/computed'; +import { inject as service } from '@ember/service'; import Controller, { inject as controller } from '@ember/controller'; import { computed } from '@ember/object'; import { scheduleOnce } from '@ember/runloop'; @@ -11,6 +12,7 @@ export default Controller.extend( SortableFactory(['id', 'name', 'compositeStatus', 'datacenter']), Searchable, { + userSettings: service(), clientsController: controller('clients'), nodes: alias('model.nodes'), @@ -28,7 +30,7 @@ export default Controller.extend( }, currentPage: 1, - pageSize: 8, + pageSize: readOnly('userSettings.pageSize'), sortProperty: 'modifyIndex', sortDescending: true, diff --git a/ui/app/controllers/csi/volumes/index.js b/ui/app/controllers/csi/volumes/index.js index 3eb926864..d251ab1bc 100644 --- a/ui/app/controllers/csi/volumes/index.js +++ b/ui/app/controllers/csi/volumes/index.js @@ -1,5 +1,5 @@ import { inject as service } from '@ember/service'; -import { alias } from '@ember/object/computed'; +import { alias, readOnly } from '@ember/object/computed'; import Controller, { inject as controller } from '@ember/controller'; import SortableFactory from 'nomad-ui/mixins/sortable-factory'; @@ -13,6 +13,7 @@ export default Controller.extend( ]), { system: service(), + userSettings: service(), csiController: controller('csi'), isForbidden: alias('csiController.isForbidden'), @@ -24,12 +25,19 @@ export default Controller.extend( }, currentPage: 1, - pageSize: 10, + pageSize: readOnly('userSettings.pageSize'), sortProperty: 'id', sortDescending: true, listToSort: alias('model'), sortedVolumes: alias('listSorted'), + + // TODO: Remove once this page gets search capability + resetPagination() { + if (this.currentPage != null) { + this.set('currentPage', 1); + } + }, } ); diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index 279b69434..7052e773a 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -1,5 +1,5 @@ import { inject as service } from '@ember/service'; -import { alias } from '@ember/object/computed'; +import { alias, readOnly } from '@ember/object/computed'; import Controller, { inject as controller } from '@ember/controller'; import { computed } from '@ember/object'; import { scheduleOnce } from '@ember/runloop'; @@ -10,6 +10,7 @@ import { serialize, deserializedQueryParam as selection } from 'nomad-ui/utils/q export default Controller.extend(Sortable, Searchable, { system: service(), + userSettings: service(), jobsController: controller('jobs'), isForbidden: alias('jobsController.isForbidden'), @@ -26,7 +27,7 @@ export default Controller.extend(Sortable, Searchable, { }, currentPage: 1, - pageSize: 10, + pageSize: readOnly('userSettings.pageSize'), sortProperty: 'modifyIndex', sortDescending: true, diff --git a/ui/app/controllers/jobs/job/task-group.js b/ui/app/controllers/jobs/job/task-group.js index 6f8740ce0..3e1a5d9c7 100644 --- a/ui/app/controllers/jobs/job/task-group.js +++ b/ui/app/controllers/jobs/job/task-group.js @@ -1,4 +1,5 @@ -import { alias } from '@ember/object/computed'; +import { alias, readOnly } from '@ember/object/computed'; +import { inject as service } from '@ember/service'; import Controller from '@ember/controller'; import { computed } from '@ember/object'; import Sortable from 'nomad-ui/mixins/sortable'; @@ -6,6 +7,8 @@ import Searchable from 'nomad-ui/mixins/searchable'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; export default Controller.extend(Sortable, Searchable, WithNamespaceResetting, { + userSettings: service(), + queryParams: { currentPage: 'page', searchTerm: 'search', @@ -14,7 +17,7 @@ export default Controller.extend(Sortable, Searchable, WithNamespaceResetting, { }, currentPage: 1, - pageSize: 10, + pageSize: readOnly('userSettings.pageSize'), sortProperty: 'modifyIndex', sortDescending: true, diff --git a/ui/app/services/user-settings.js b/ui/app/services/user-settings.js new file mode 100644 index 000000000..f9841ad57 --- /dev/null +++ b/ui/app/services/user-settings.js @@ -0,0 +1,6 @@ +import Service from '@ember/service'; +import localStorageProperty from 'nomad-ui/utils/properties/local-storage'; + +export default Service.extend({ + pageSize: localStorageProperty('nomadPageSize', 25), +}); diff --git a/ui/app/styles/components/dropdown.scss b/ui/app/styles/components/dropdown.scss index 3c9de0a70..15682f0df 100644 --- a/ui/app/styles/components/dropdown.scss +++ b/ui/app/styles/components/dropdown.scss @@ -75,6 +75,7 @@ } } +.ember-power-select-selected-item, .dropdown-trigger-label { margin-left: 8px; margin-right: 8px; diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index 5381921ea..b34e7ca89 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -41,6 +41,23 @@ display: inline-block; } + &.is-horizontal { + .field-label { + margin-right: 1rem; + padding-top: 0.3em; + font-weight: $weight-semibold; + white-space: nowrap; + + &.is-small { + padding-top: 0.55em; + } + + &.is-multiline { + white-space: normal; + } + } + } + &.is-sub-field { margin-left: 2em; } diff --git a/ui/app/styles/core/pagination.scss b/ui/app/styles/core/pagination.scss index c33495a73..5923b4c27 100644 --- a/ui/app/styles/core/pagination.scss +++ b/ui/app/styles/core/pagination.scss @@ -4,7 +4,8 @@ .pagination-numbers { padding: 0.75rem 0.5rem; white-space: nowrap; - order: 2; + font-weight: $weight-semibold; + font-size: 0.85rem; } .pagination-previous, diff --git a/ui/app/styles/core/table.scss b/ui/app/styles/core/table.scss index 181230637..ce9fa7aed 100644 --- a/ui/app/styles/core/table.scss +++ b/ui/app/styles/core/table.scss @@ -263,9 +263,47 @@ border-bottom-right-radius: $radius; border-bottom-left-radius: $radius; overflow: hidden; + display: flex; + justify-content: space-between; + align-items: center; .pagination { padding: 0; margin: 0; } + + // Field overrides specifically for use of Field within a table foot. + // Bulma does a lot of typically helpful layout tweaks at different + // breakpoints that are undesirable in this context. + .field { + margin-bottom: 0; + + &:first-child { + margin-left: 1.5em; + } + + &.is-horizontal { + display: flex; + } + + .label, + .field-label { + color: $grey; + flex-basis: 0; + flex-grow: 1; + flex-shrink: 0; + text-align: right; + + &.is-small { + font-size: $size-7; + } + } + + .field-body { + display: flex; + flex-basis: 0; + flex-grow: 5; + flex-shrink: 1; + } + } } diff --git a/ui/app/templates/clients/index.hbs b/ui/app/templates/clients/index.hbs index f4d88052c..2bfc1fc73 100644 --- a/ui/app/templates/clients/index.hbs +++ b/ui/app/templates/clients/index.hbs @@ -66,12 +66,13 @@ {{/t.body}} {{/list-table}}
+ {{page-size-select onChange=(action resetPagination)}}
diff --git a/ui/app/templates/components/page-size-select.hbs b/ui/app/templates/components/page-size-select.hbs new file mode 100644 index 000000000..210fac3ec --- /dev/null +++ b/ui/app/templates/components/page-size-select.hbs @@ -0,0 +1,15 @@ +
+ Per page + + {{option}} + +
diff --git a/ui/app/templates/csi/volumes/index.hbs b/ui/app/templates/csi/volumes/index.hbs index 45a9cfa94..5ca706e94 100644 --- a/ui/app/templates/csi/volumes/index.hbs +++ b/ui/app/templates/csi/volumes/index.hbs @@ -41,12 +41,13 @@ {{/t.body}} {{/list-table}}
+ {{page-size-select onChange=(action resetPagination)}}
diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index 714d82c53..bcbc76767 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -93,6 +93,7 @@ {{/t.body}} {{/list-table}}
+ {{page-size-select onChange=(action resetPagination)}}
diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index fdfa5d001..c4e3f66b8 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -81,12 +81,13 @@ {{/t.body}} {{/list-table}}
+ {{page-size-select onChange=(action resetPagination)}}
diff --git a/ui/tests/acceptance/behaviors/page-size-select.js b/ui/tests/acceptance/behaviors/page-size-select.js new file mode 100644 index 000000000..e261db0ce --- /dev/null +++ b/ui/tests/acceptance/behaviors/page-size-select.js @@ -0,0 +1,42 @@ +import { pluralize } from 'ember-inflector'; +import { test } from 'qunit'; +import { selectChoose } from 'ember-power-select/test-support'; + +export default function pageSizeSelect({ resourceName, pageObject, pageObjectList, setup }) { + test(`the number of ${pluralize( + resourceName + )} is equal to the localStorage user setting for page size`, async function(assert) { + const storedPageSize = 10; + window.localStorage.nomadPageSize = storedPageSize; + + await setup.call(this); + + assert.equal(pageObjectList.length, storedPageSize); + assert.equal(pageObject.pageSizeSelect.selectedOption, storedPageSize); + }); + + test('when the page size user setting is unset, the default page size is 25', async function(assert) { + await setup.call(this); + + assert.equal(pageObjectList.length, pageObject.pageSize); + assert.equal(pageObject.pageSizeSelect.selectedOption, pageObject.pageSize); + }); + + test(`changing the page size updates the ${pluralize( + resourceName + )} list and also updates the user setting in localStorage`, async function(assert) { + const desiredPageSize = 10; + + await setup.call(this); + + assert.equal(window.localStorage.nomadPageSize, null); + assert.equal(pageObjectList.length, pageObject.pageSize); + assert.equal(pageObject.pageSizeSelect.selectedOption, pageObject.pageSize); + + await selectChoose('[data-test-page-size-select]', desiredPageSize); + + assert.equal(window.localStorage.nomadPageSize, desiredPageSize); + assert.equal(pageObjectList.length, desiredPageSize); + assert.equal(pageObject.pageSizeSelect.selectedOption, desiredPageSize); + }); +} diff --git a/ui/tests/acceptance/clients-list-test.js b/ui/tests/acceptance/clients-list-test.js index 653ce7a08..f71c3c5da 100644 --- a/ui/tests/acceptance/clients-list-test.js +++ b/ui/tests/acceptance/clients-list-test.js @@ -2,23 +2,27 @@ import { currentURL, settled } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; +import pageSizeSelect from './behaviors/page-size-select'; import ClientsList from 'nomad-ui/tests/pages/clients/list'; module('Acceptance | clients list', function(hooks) { setupApplicationTest(hooks); setupMirage(hooks); + hooks.beforeEach(function() { + window.localStorage.clear(); + }); + test('/clients should list one page of clients', async function(assert) { // Make sure to make more nodes than 1 page to assert that pagination is working - const nodesCount = 10; - const pageSize = 8; + const nodesCount = ClientsList.pageSize + 1; server.createList('node', nodesCount); server.createList('agent', 1); await ClientsList.visit(); - assert.equal(ClientsList.nodes.length, pageSize); + assert.equal(ClientsList.nodes.length, ClientsList.pageSize); assert.ok(ClientsList.hasPagination, 'Pagination found on the page'); const sortedNodes = server.db.nodes.sortBy('modifyIndex').reverse(); @@ -174,6 +178,17 @@ module('Acceptance | clients list', function(hooks) { assert.equal(currentURL(), '/settings/tokens'); }); + pageSizeSelect({ + resourceName: 'client', + pageObject: ClientsList, + pageObjectList: ClientsList.nodes, + async setup() { + server.createList('node', ClientsList.pageSize); + server.createList('agent', 1); + await ClientsList.visit(); + }, + }); + testFacet('Class', { facet: ClientsList.facets.class, paramName: 'class', diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index 31b3042de..5e0a15c68 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -2,6 +2,7 @@ import { currentURL } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; +import pageSizeSelect from './behaviors/page-size-select'; import JobsList from 'nomad-ui/tests/pages/jobs/list'; let managementToken, clientToken; @@ -17,6 +18,7 @@ module('Acceptance | jobs list', function(hooks) { managementToken = server.create('token'); clientToken = server.create('token'); + window.localStorage.clear(); window.localStorage.nomadTokenSecret = managementToken.secretId; }); @@ -339,6 +341,16 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(JobsList.jobs.length, 1, 'Only one job shown due to query param'); }); + pageSizeSelect({ + resourceName: 'job', + pageObject: JobsList, + pageObjectList: JobsList.jobs, + async setup() { + server.createList('job', JobsList.pageSize, { shallow: true, createAllocations: false }); + await JobsList.visit(); + }, + }); + function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) { test(`the ${label} facet has the correct options`, async function(assert) { await beforeEach(); diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js index 9aba20ba5..c3001aa5f 100644 --- a/ui/tests/acceptance/task-group-detail-test.js +++ b/ui/tests/acceptance/task-group-detail-test.js @@ -4,7 +4,7 @@ import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { formatBytes } from 'nomad-ui/helpers/format-bytes'; import TaskGroup from 'nomad-ui/tests/pages/jobs/job/task-group'; -import JobsList from 'nomad-ui/tests/pages/jobs/list'; +import pageSizeSelect from './behaviors/page-size-select'; import moment from 'moment'; let job; @@ -61,7 +61,7 @@ module('Acceptance | task group detail', function(hooks) { previousAllocation: allocations[0].id, }); - await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + window.localStorage.clear(); }); test('/jobs/:id/:task-group should list high-level metrics for the allocation', async function(assert) { @@ -69,6 +69,8 @@ module('Acceptance | task group detail', function(hooks) { const totalMemory = tasks.mapBy('Resources.MemoryMB').reduce(sum, 0); const totalDisk = taskGroup.ephemeralDisk.SizeMB; + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + assert.equal(TaskGroup.tasksCount, `# Tasks ${tasks.length}`, '# Tasks'); assert.equal( TaskGroup.cpu, @@ -90,6 +92,8 @@ module('Acceptance | task group detail', function(hooks) { }); test('/jobs/:id/:task-group should have breadcrumbs for job and jobs', async function(assert) { + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + assert.equal(TaskGroup.breadcrumbFor('jobs.index').text, 'Jobs', 'First breadcrumb says jobs'); assert.equal( TaskGroup.breadcrumbFor('jobs.job.index').text, @@ -104,11 +108,15 @@ module('Acceptance | task group detail', function(hooks) { }); test('/jobs/:id/:task-group first breadcrumb should link to jobs', async function(assert) { + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + await TaskGroup.breadcrumbFor('jobs.index').visit(); assert.equal(currentURL(), '/jobs', 'First breadcrumb links back to jobs'); }); test('/jobs/:id/:task-group second breadcrumb should link to the job for the task group', async function(assert) { + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + await TaskGroup.breadcrumbFor('jobs.job.index').visit(); assert.equal( currentURL(), @@ -124,7 +132,6 @@ module('Acceptance | task group detail', function(hooks) { clientStatus: 'running', }); - await JobsList.visit(); await TaskGroup.visit({ id: job.id, name: taskGroup.name }); assert.ok( @@ -140,6 +147,8 @@ module('Acceptance | task group detail', function(hooks) { }); test('each allocation should show basic information about the allocation', async function(assert) { + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + const allocation = allocations.sortBy('modifyIndex').reverse()[0]; const allocationRow = TaskGroup.allocations.objectAt(0); @@ -173,6 +182,8 @@ module('Acceptance | task group detail', function(hooks) { }); test('each allocation should show stats about the allocation', async function(assert) { + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + const allocation = allocations.sortBy('name')[0]; const allocationRow = TaskGroup.allocations.objectAt(0); @@ -208,6 +219,8 @@ module('Acceptance | task group detail', function(hooks) { }); test('when the allocation search has no matches, there is an empty message', async function(assert) { + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + await TaskGroup.search('zzzzzz'); assert.ok(TaskGroup.isEmpty, 'Empty state is shown'); @@ -219,6 +232,8 @@ module('Acceptance | task group detail', function(hooks) { }); test('when the allocation has reschedule events, the allocation row is denoted with an icon', async function(assert) { + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + const rescheduleRow = TaskGroup.allocationFor(allocations[0].id); const normalRow = TaskGroup.allocationFor(allocations[1].id); @@ -227,6 +242,8 @@ module('Acceptance | task group detail', function(hooks) { }); test('when the task group depends on volumes, the volumes table is shown', async function(assert) { + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + assert.ok(TaskGroup.hasVolumes); assert.equal(TaskGroup.volumes.length, Object.keys(taskGroup.volumes).length); }); @@ -241,6 +258,8 @@ module('Acceptance | task group detail', function(hooks) { }); test('each row in the volumes table lists information about the volume', async function(assert) { + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + TaskGroup.volumes[0].as(volumeRow => { const volume = taskGroup.volumes[volumeRow.name]; assert.equal(volumeRow.name, volume.Name); @@ -279,4 +298,19 @@ module('Acceptance | task group detail', function(hooks) { assert.ok(TaskGroup.error.isPresent, 'Error message is shown'); assert.equal(TaskGroup.error.title, 'Not Found', 'Error message is for 404'); }); + + pageSizeSelect({ + resourceName: 'allocation', + pageObject: TaskGroup, + pageObjectList: TaskGroup.allocations, + async setup() { + server.createList('allocation', TaskGroup.pageSize, { + jobId: job.id, + taskGroup: taskGroup.name, + clientStatus: 'running', + }); + + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + }, + }); }); diff --git a/ui/tests/pages/clients/list.js b/ui/tests/pages/clients/list.js index d6d4c3d64..c0d04319a 100644 --- a/ui/tests/pages/clients/list.js +++ b/ui/tests/pages/clients/list.js @@ -12,8 +12,11 @@ import { } from 'ember-cli-page-object'; import facet from 'nomad-ui/tests/pages/components/facet'; +import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select'; export default create({ + pageSize: 25, + visit: visitable('/clients'), search: fillable('.search-box input'), @@ -59,6 +62,8 @@ export default create({ headline: text('[data-test-empty-clients-list-headline]'), }, + pageSizeSelect: pageSizeSelect(), + error: { isPresent: isPresent('[data-test-error]'), title: text('[data-test-error-title]'), diff --git a/ui/tests/pages/components/page-size-select.js b/ui/tests/pages/components/page-size-select.js new file mode 100644 index 000000000..ef664643d --- /dev/null +++ b/ui/tests/pages/components/page-size-select.js @@ -0,0 +1,12 @@ +import { clickable, collection, isPresent, text } from 'ember-cli-page-object'; + +export default () => ({ + isPresent: isPresent('[data-test-page-size-select]'), + open: clickable('[data-test-page-size-select] .ember-power-select-trigger'), + selectedOption: text('[data-test-page-size-select] .ember-power-select-selected-item'), + options: collection('.ember-power-select-option', { + testContainer: '#ember-testing', + resetScope: true, + label: text(), + }), +}); diff --git a/ui/tests/pages/jobs/job/task-group.js b/ui/tests/pages/jobs/job/task-group.js index b593e08e4..d3d6b048d 100644 --- a/ui/tests/pages/jobs/job/task-group.js +++ b/ui/tests/pages/jobs/job/task-group.js @@ -11,9 +11,10 @@ import { import allocations from 'nomad-ui/tests/pages/components/allocations'; import error from 'nomad-ui/tests/pages/components/error'; +import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select'; export default create({ - pageSize: 10, + pageSize: 25, visit: visitable('/jobs/:id/:name'), @@ -51,4 +52,6 @@ export default create({ emptyState: { headline: text('[data-test-empty-allocations-list-headline]'), }, + + pageSizeSelect: pageSizeSelect(), }); diff --git a/ui/tests/pages/jobs/list.js b/ui/tests/pages/jobs/list.js index 5d8017cba..a6286564d 100644 --- a/ui/tests/pages/jobs/list.js +++ b/ui/tests/pages/jobs/list.js @@ -11,9 +11,10 @@ import { } from 'ember-cli-page-object'; import facet from 'nomad-ui/tests/pages/components/facet'; +import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select'; export default create({ - pageSize: 10, + pageSize: 25, visit: visitable('/jobs'), @@ -64,6 +65,8 @@ export default create({ }), }, + pageSizeSelect: pageSizeSelect(), + facets: { type: facet('[data-test-type-facet]'), status: facet('[data-test-status-facet]'),