Merge pull request #7574 from hashicorp/f-ui/configurable-page-sizes

UI Configurable Page Sizes
This commit is contained in:
Michael Lange
2020-04-03 16:06:17 -07:00
committed by GitHub
23 changed files with 258 additions and 25 deletions

View File

@@ -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() {},
});

View File

@@ -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,

View File

@@ -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);
}
},
}
);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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),
});

View File

@@ -75,6 +75,7 @@
}
}
.ember-power-select-selected-item,
.dropdown-trigger-label {
margin-left: 8px;
margin-right: 8px;

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;
}
}
}

View File

@@ -66,12 +66,13 @@
{{/t.body}}
{{/list-table}}
<div class="table-foot">
{{page-size-select onChange=(action resetPagination)}}
<nav class="pagination" data-test-pagination>
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{sortedNodes.length}}
</div>
{{#p.prev class="pagination-previous"}} &lt; {{/p.prev}}
{{#p.next class="pagination-next"}} &gt; {{/p.next}}
{{#p.prev class="pagination-previous"}}{{x-icon "chevron-left"}}{{/p.prev}}
{{#p.next class="pagination-next"}}{{x-icon "chevron-right"}}{{/p.next}}
<ul class="pagination-list"></ul>
</nav>
</div>

View File

@@ -0,0 +1,15 @@
<div class="field is-horizontal">
<span class="field-label is-small">Per page</span>
<PowerSelect
@tagName="div"
class="field-body"
data-test-page-size-select
@options={{pageSizeOptions}}
@selected={{userSettings.pageSize}}
@onChange={{action (queue
(action (mut userSettings.pageSize))
(action onChange)
)}} as |option|>
{{option}}
</PowerSelect>
</div>

View File

@@ -41,12 +41,13 @@
{{/t.body}}
{{/list-table}}
<div class="table-foot">
{{page-size-select onChange=(action resetPagination)}}
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{sortedVolumes.length}}
</div>
{{#p.prev class="pagination-previous"}} &lt; {{/p.prev}}
{{#p.next class="pagination-next"}} &gt; {{/p.next}}
{{#p.prev class="pagination-previous"}}{{x-icon "chevron-left"}}{{/p.prev}}
{{#p.next class="pagination-next"}}{{x-icon "chevron-right"}}{{/p.next}}
<ul class="pagination-list"></ul>
</nav>
</div>

View File

@@ -93,6 +93,7 @@
{{/t.body}}
{{/list-table}}
<div class="table-foot">
{{page-size-select onChange=(action resetPagination)}}
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{sortedJobs.length}}
@@ -100,8 +101,8 @@
<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}}
{{#p.prev class="pagination-previous"}}{{x-icon "chevron-left"}}{{/p.prev}}
{{#p.next class="pagination-next"}}{{x-icon "chevron-right"}}{{/p.next}}
<ul class="pagination-list"></ul>
</nav>
</div>

View File

@@ -81,12 +81,13 @@
{{/t.body}}
{{/list-table}}
<div class="table-foot">
{{page-size-select onChange=(action resetPagination)}}
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{sortedAllocations.length}}
</div>
{{#p.prev class="pagination-previous"}} &lt; {{/p.prev}}
{{#p.next class="pagination-next"}} &gt; {{/p.next}}
{{#p.prev class="pagination-previous"}}{{x-icon "chevron-left"}}{{/p.prev}}
{{#p.next class="pagination-next"}}{{x-icon "chevron-right"}}{{/p.next}}
<ul class="pagination-list"></ul>
</nav>
</div>

View File

@@ -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);
});
}

View File

@@ -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',

View File

@@ -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();

View File

@@ -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 });
},
});
});

View File

@@ -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]'),

View File

@@ -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(),
}),
});

View File

@@ -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(),
});

View File

@@ -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]'),