feat: add pagination and filtering to evaluations view

This commit is contained in:
Jai Bhagat 2021-12-23 11:54:47 -05:00
parent 1f80081c9d
commit e5b154e295
8 changed files with 593 additions and 88 deletions

View File

@ -1,9 +1,12 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
export default class EvaluationsController extends Controller { export default class EvaluationsController extends Controller {
queryParams = ['nextToken', 'pageSize']; @service userSettings;
queryParams = ['nextToken', 'pageSize', 'status'];
get shouldDisableNext() { get shouldDisableNext() {
return !this.model.meta?.nextToken; return !this.model.meta?.nextToken;
@ -13,9 +16,21 @@ export default class EvaluationsController extends Controller {
return !this.previousTokens.length; return !this.previousTokens.length;
} }
@tracked pageSize = 25; get optionsEvaluationsStatus() {
return [
{ key: null, label: 'All' },
{ key: 'blocked', label: 'Blocked' },
{ key: 'pending', label: 'Pending' },
{ key: 'complete', label: 'Complete' },
{ key: 'failed', label: 'Failed' },
{ key: 'canceled', label: 'Canceled' },
];
}
@tracked pageSize = this.userSettings.pageSize;
@tracked nextToken = null; @tracked nextToken = null;
@tracked previousTokens = []; @tracked previousTokens = [];
@tracked status = null;
@action @action
onChange(newPageSize) { onChange(newPageSize) {
@ -29,14 +44,26 @@ export default class EvaluationsController extends Controller {
} }
@action @action
onPrev(lastToken) { onPrev() {
this.previousTokens.pop(); const lastToken = this.previousTokens.pop();
this.previousTokens = [...this.previousTokens]; this.previousTokens = [...this.previousTokens];
this.nextToken = lastToken; this.nextToken = lastToken;
} }
@action @action
refresh() { refresh() {
this._resetTokens();
this.status = null;
this.pageSize = this.userSettings.pageSize;
}
@action
setStatus(selection) {
this._resetTokens();
this.status = selection;
}
_resetTokens() {
this.nextToken = null; this.nextToken = null;
this.previousTokens = []; this.previousTokens = [];
} }

View File

@ -6,6 +6,7 @@ import shortUUIDProperty from '../utils/properties/short-uuid';
export default class Evaluation extends Model { export default class Evaluation extends Model {
@shortUUIDProperty('id') shortId; @shortUUIDProperty('id') shortId;
@shortUUIDProperty('nodeId') shortNodeId;
@attr('number') priority; @attr('number') priority;
@attr('string') type; @attr('string') type;
@attr('string') triggeredBy; @attr('string') triggeredBy;
@ -18,6 +19,7 @@ export default class Evaluation extends Model {
@equal('status', 'blocked') isBlocked; @equal('status', 'blocked') isBlocked;
@belongsTo('job') job; @belongsTo('job') job;
@belongsTo('node') node;
@attr('number') modifyIndex; @attr('number') modifyIndex;
@attr('date') modifyTime; @attr('date') modifyTime;
@ -26,4 +28,18 @@ export default class Evaluation extends Model {
@attr('date') createTime; @attr('date') createTime;
@attr('date') waitUntil; @attr('date') waitUntil;
@attr('string') namespace;
@attr('string') plainJobId;
get hasJob() {
return !!this.plainJobId;
}
get hasNode() {
return !!this.belongsTo('node').id();
}
get nodeId() {
return this.belongsTo('node').id();
}
} }

View File

@ -13,13 +13,17 @@ export default class EvaluationsIndexRoute extends Route {
nextToken: { nextToken: {
refreshModel: true, refreshModel: true,
}, },
status: {
refreshModel: true,
},
}; };
model({ pageSize, nextToken }) { model({ pageSize, status, nextToken }) {
return this.store.query('evaluation', { return this.store.query('evaluation', {
namespace: ALL_NAMESPACE_WILDCARD, namespace: ALL_NAMESPACE_WILDCARD,
per_page: pageSize, per_page: pageSize,
next_token: nextToken, next_token: nextToken,
status,
}); });
} }
} }

View File

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

View File

@ -1,79 +1,136 @@
{{page-title "Evaluations"}} {{page-title "Evaluations"}}
<section class="section"> <section class="section">
<ListTable @source={{@model}} as |t|> <div class="toolbar">
<t.head> <div class="toolbar-item">
<th> <SingleSelectDropdown
Evaluation ID data-test-evaluation-status-facet
</th> @label="Status"
<th> @options={{this.optionsEvaluationsStatus}}
Resource @selection={{this.status}}
</th> @onSelect={{action this.setStatus}}
<th> />
Priority </div>
</th>
<th>
Created
</th>
<th>
Triggered By
</th>
<th>
Status
</th>
<th>
Placement Failures
</th>
</t.head>
<t.body as |row|>
<tr data-test-evaluation="{{row.model.shortId}}">
<td data-test-id>
{{row.model.shortId}}
</td>
<td data-test-id>
Resource Placeholder
</td>
<td data-test-priority>
{{row.model.priority}}
</td>
<td data-test-create-time>
{{format-month-ts row.model.createTime}}
</td>
<td data-test-triggered-by>
{{row.model.triggeredBy}}
</td>
<td data-test-status>
<StatusCell @status={{row.model.status}} />
</td>
<td data-test-blocked>
{{#if (eq row.model.status "blocked")}}
N/A - In Progress
{{else if row.model.hasPlacementFailures}}
True
{{else}}
False
{{/if}}
</td>
</tr>
</t.body>
</ListTable>
<div class="table-foot">
<PageSizeSelect @onChange={{this.onChange}} />
<button type="button" {{on "click" this.refresh}}>
Refresh
</button>
<button
type="button"
disabled={{this.shouldDisablePrev}}
{{on "click" (fn this.onPrev this.lastToken)}}
>
Prev
</button>
<button
type="button"
disabled={{this.shouldDisableNext}}
{{on "click" (fn this.onNext @model.meta.nextToken)}}
>
Next >
</button>
</div> </div>
{{#if @model.length}}
<ListTable data-test-eval-table @source={{@model}} as |t|>
<t.head>
<th>
Evaluation ID
</th>
<th>
Resource
</th>
<th>
Priority
</th>
<th>
Created
</th>
<th>
Triggered By
</th>
<th>
Status
</th>
<th>
Placement Failures
</th>
</t.head>
<t.body as |row|>
<tr data-test-evaluation="{{row.model.shortId}}">
<td data-test-id>
{{row.model.shortId}}
</td>
<td data-test-id>
{{#if row.model.hasJob}}
<LinkTo
data-test-evaluation-resource
@model={{row.model.plainJobId}}
@route="jobs.job"
@query={{hash namespace=row.model.namespace}}
>
{{row.model.plainJobId}}
</LinkTo>
{{else}}
<LinkTo
data-test-evaluation-resource
@model={{row.model.nodeId}}
@route="clients.client"
>
{{row.model.shortNodeId}}
</LinkTo>
{{/if}}
</td>
<td data-test-priority>
{{row.model.priority}}
</td>
<td data-test-create-time>
{{format-month-ts row.model.createTime}}
</td>
<td data-test-triggered-by>
{{row.model.triggeredBy}}
</td>
<td data-test-status class="is-one-line">
<StatusCell @status={{row.model.status}} />
</td>
<td data-test-blocked>
{{#if (eq row.model.status "blocked")}}
N/A - In Progress
{{else if row.model.hasPlacementFailures}}
True
{{else}}
False
{{/if}}
</td>
</tr>
</t.body>
</ListTable>
<div class="table-foot">
<PageSizeSelect data-test-per-page @onChange={{this.onChange}} />
<button data-test-eval-refresh type="button" {{on "click" this.refresh}}>
Refresh
</button>
<button
data-test-eval-pagination-prev
type="button"
disabled={{this.shouldDisablePrev}}
{{on "click" (fn this.onPrev this.lastToken)}}
>
Prev
</button>
<button
data-test-eval-pagination-next
type="button"
disabled={{this.shouldDisableNext}}
{{on "click" (fn this.onNext @model.meta.nextToken)}}
>
Next >
</button>
</div>
{{else}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-evaluations-list>
<h3 class="empty-message-headline" data-test-empty-evalations-list-headline>
No Matches
</h3>
<p class="empty-message-body">
{{#if this.status}}
<span data-test-no-eval-match>
No evaluations match the status
<strong>
{{this.status}}
</strong>
</span>
<button type="button" {{on "click" this.refresh}}>
Show all evaluations
</button>
{{else}}
<span data-test-no-eval>
There are no evaluations
</span>
{{/if}}
</p>
</div>
</div>
{{/if}}
</section> </section>

View File

@ -261,6 +261,7 @@ export default function () {
return this.serialize(evaluations.where({ jobId: params.id })); return this.serialize(evaluations.where({ jobId: params.id }));
}); });
this.get('/evaluations');
this.get('/evaluation/:id'); this.get('/evaluation/:id');
this.get('/deployment/allocations/:id', function (schema, { params }) { this.get('/deployment/allocations/:id', function (schema, { params }) {

View File

@ -91,7 +91,9 @@ export default Factory.extend({
}), }),
afterCreate(evaluation, server) { afterCreate(evaluation, server) {
assignJob(evaluation, server); if (!evaluation.nodeId) {
assignJob(evaluation, server);
}
}, },
}); });

View File

@ -0,0 +1,397 @@
import { click, currentRouteName, visit } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { Response } from 'ember-cli-mirage';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import { selectChoose, clickTrigger } from 'ember-power-select/test-support/helpers';
const getStandardRes = () => [
{
CreateIndex: 1249,
CreateTime: 1640181894162724000,
DeploymentID: '12efbb28-840e-7794-b215-a7b112e40a4f',
ID: '5fb1b8cd-00f8-fff8-de0c-197dc37f5053',
JobID: 'cores-example',
JobModifyIndex: 694,
ModifyIndex: 1251,
ModifyTime: 1640181894167194000,
Namespace: 'ted-lasso',
Priority: 50,
QueuedAllocations: {
lb: 0,
webapp: 0,
},
SnapshotIndex: 1249,
Status: 'complete',
TriggeredBy: 'job-register',
Type: 'service',
},
{
CreateIndex: 1304,
CreateTime: 1640183201719510000,
DeploymentID: '878435bf-7265-62b1-7902-d45c44b23b79',
ID: '66cb98a6-7740-d5ef-37e4-fa0f8b1de44b',
JobID: 'cores-example',
JobModifyIndex: 1304,
ModifyIndex: 1306,
ModifyTime: 1640183201721418000,
Namespace: 'default',
Priority: 50,
QueuedAllocations: {
webapp: 0,
lb: 0,
},
SnapshotIndex: 1304,
Status: 'complete',
TriggeredBy: 'job-register',
Type: 'service',
},
{
CreateIndex: 1267,
CreateTime: 1640182198255685000,
DeploymentID: '12efbb28-840e-7794-b215-a7b112e40a4f',
ID: '78009518-574d-eee6-919a-e83879175dd3',
JobID: 'cores-example',
JobModifyIndex: 1250,
ModifyIndex: 1274,
ModifyTime: 1640182228112823000,
Namespace: 'ted-lasso',
PreviousEval: '84f1082f-3e6e-034d-6df4-c6a321e7bd63',
Priority: 50,
QueuedAllocations: {
lb: 0,
},
SnapshotIndex: 1272,
Status: 'complete',
TriggeredBy: 'alloc-failure',
Type: 'service',
WaitUntil: '2021-12-22T14:10:28.108136Z',
},
{
CreateIndex: 1322,
CreateTime: 1640183505760099000,
DeploymentID: '878435bf-7265-62b1-7902-d45c44b23b79',
ID: 'c184f72b-68a3-5180-afd6-af01860ad371',
JobID: 'cores-example',
JobModifyIndex: 1305,
ModifyIndex: 1329,
ModifyTime: 1640183535540881000,
Namespace: 'default',
PreviousEval: '9a917a93-7bc3-6991-ffc9-15919a38f04b',
Priority: 50,
QueuedAllocations: {
lb: 0,
},
SnapshotIndex: 1326,
Status: 'complete',
TriggeredBy: 'alloc-failure',
Type: 'service',
WaitUntil: '2021-12-22T14:32:15.539556Z',
},
];
module('Acceptance | evaluations list', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
test('it passes an accessibility audit', async function(assert) {
await visit('/evaluations');
assert.equal(
currentRouteName(),
'evaluations.index',
'The default route in evaluations is evaluations index'
);
await a11yAudit(assert);
});
test('it renders an empty message if there are no evaluations rendered', async function(assert) {
await visit('/evaluations');
assert.dom('[data-test-empty-evaluations-list]').exists('We display empty table message.');
assert
.dom('[data-test-no-eval]')
.exists('We display a message saying there are no evaluations.');
});
test('it renders a list of evaluations', async function(assert) {
assert.expect(3);
server.get('/evaluations', function(_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: '*',
per_page: '25',
status: '',
next_token: '',
},
'Forwards the correct query parameters on default query when route initially loads'
);
return getStandardRes();
});
await visit('/evaluations');
assert.dom('[data-test-eval-table]').exists('Evaluations table should render');
assert
.dom('[data-test-evaluation]')
.exists({ count: 4 }, 'Should render the correct number of evaluations');
});
test('it should enable filtering by evaluation status', async function(assert) {
assert.expect(2);
server.get('/evaluations', getStandardRes);
await visit('/evaluations');
server.get('/evaluations', function(_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: '*',
per_page: '25',
status: 'pending',
next_token: '',
},
'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]', 'Pending');
assert
.dom('[data-test-no-eval-match]')
.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('/evaluations', getStandardRes);
await visit('/evaluations');
server.get('/evaluations', function(_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: '*',
per_page: '50',
status: '',
next_token: '',
},
'It makes a request with the per_page set by the user'
);
return getStandardRes();
});
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('/evaluations', function() {
return new Response(200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes());
});
await visit('/evaluations');
server.get('/evaluations', function(_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: '*',
per_page: '25',
status: '',
next_token: 'next-token-1',
},
'It makes another server request using the options selected by the user'
);
return new Response(200, { 'x-nomad-nexttoken': 'next-token-2' }, getStandardRes());
});
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('/evaluations', function(_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: '*',
per_page: '25',
status: '',
next_token: 'next-token-2',
},
'It makes another server request using the options selected by the user'
);
return getStandardRes();
});
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('/evaluations', function(_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: '*',
per_page: '25',
status: '',
next_token: 'next-token-1',
},
'It makes a request using the stored old token.'
);
return new Response(200, { 'x-nomad-nexttoken': 'next-token-2' }, getStandardRes());
});
await click('[data-test-eval-pagination-prev]');
server.get('/evaluations', function(_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: '*',
per_page: '25',
status: '',
next_token: '',
},
'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' }, getStandardRes());
});
await click('[data-test-eval-pagination-prev]');
});
test('it should clear all query parameters on refresh', async function(assert) {
assert.expect(1);
server.get('/evaluations', function() {
return new Response(200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes());
});
await visit('/evaluations');
server.get('/evaluations', function() {
return getStandardRes();
});
await click('[data-test-eval-pagination-next]');
await clickTrigger('[data-test-evaluation-status-facet]');
await selectChoose('[data-test-evaluation-status-facet]', 'Pending');
server.get('/evaluations', function(_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: '*',
per_page: '25',
status: '',
next_token: '',
},
'It clears all query parameters when making a refresh'
);
return new Response(200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes());
});
await click('[data-test-eval-refresh]');
});
test('it should reset pagination when filters are applied', async function(assert) {
assert.expect(1);
server.get('/evaluations', function() {
return new Response(200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes());
});
await visit('/evaluations');
server.get('/evaluations', function() {
return new Response(200, { 'x-nomad-nexttoken': 'next-token-2' }, getStandardRes());
});
await click('[data-test-eval-pagination-next]');
server.get('/evaluations', getStandardRes);
await click('[data-test-eval-pagination-next]');
server.get('/evaluations', function(_server, fakeRequest) {
assert.deepEqual(
fakeRequest.queryParams,
{
namespace: '*',
per_page: '25',
status: 'pending',
next_token: '',
},
'It clears all next token when filtered request is made'
);
return getStandardRes();
});
await clickTrigger('[data-test-evaluation-status-facet]');
await selectChoose('[data-test-evaluation-status-facet]', 'Pending');
});
});
module('resource linking', function() {
test('it should generate a link to the job resource', async function(assert) {
server.create('node');
const job = server.create('job', { shallow: true });
server.create('evaluation', { jobId: job.id });
await visit('/evaluations');
assert
.dom('[data-test-evaluation-resource]')
.hasText(job.name, 'It conditionally renders the correct resource name');
await click('[data-test-evaluation-resource]');
assert
.dom('[data-test-job-name]')
.includesText(job.name, 'We navigate to the correct job page.');
});
test('it should generate a link to the node resource', async function(assert) {
const node = server.create('node');
server.create('evaluation', { nodeId: node.id });
await visit('/evaluations');
const shortNodeId = node.id.split('-')[0];
assert
.dom('[data-test-evaluation-resource]')
.hasText(shortNodeId, 'It conditionally renders the correct resource name');
await click('[data-test-evaluation-resource]');
assert
.dom('[data-test-title]')
.includesText(node.name, 'We navigate to the correct client page.');
});
});
});