diff --git a/ui/app/templates/components/job-page/system.hbs b/ui/app/templates/components/job-page/system.hbs
index 74b7f5c2874..d92573adda2 100644
--- a/ui/app/templates/components/job-page/system.hbs
+++ b/ui/app/templates/components/job-page/system.hbs
@@ -6,10 +6,9 @@
{{/each}}
{{/global-header}}
{{#job-page/parts/body job=job onNamespaceChange=onNamespaceChange}}
-
+ {{job-page/parts/error errorMessage=errorMessage onDismiss=(action "clearErrorMessage")}}
+
+ {{job-page/parts/title job=job handleError=(action "handleError")}}
diff --git a/ui/app/templates/components/two-step-button.hbs b/ui/app/templates/components/two-step-button.hbs
new file mode 100644
index 00000000000..88a5097815e
--- /dev/null
+++ b/ui/app/templates/components/two-step-button.hbs
@@ -0,0 +1,19 @@
+{{#if isIdle}}
+
+{{else if isPendingConfirmation}}
+ {{confirmationMessage}}
+
+
+{{/if}}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index 6731f341cc7..689716dd937 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -106,6 +106,12 @@ export default function() {
return new Response(200, {}, '{}');
});
+ this.delete('/job/:id', function(schema, { params }) {
+ const job = schema.jobs.find(params.id);
+ job.update({ status: 'dead' });
+ return new Response(204, {}, '');
+ });
+
this.get('/deployment/:id');
this.get('/job/:id/evaluations', function({ evaluations }, { params }) {
diff --git a/ui/tests/integration/job-page/helpers.js b/ui/tests/integration/job-page/helpers.js
new file mode 100644
index 00000000000..ef64e86af45
--- /dev/null
+++ b/ui/tests/integration/job-page/helpers.js
@@ -0,0 +1,51 @@
+import { click, find } from 'ember-native-dom-helpers';
+import wait from 'ember-test-helpers/wait';
+
+export function jobURL(job, path = '') {
+ const id = job.get('plainId');
+ const namespace = job.get('namespace.name') || 'default';
+ let expectedURL = `/v1/job/${id}${path}`;
+ if (namespace !== 'default') {
+ expectedURL += `?namespace=${namespace}`;
+ }
+ return expectedURL;
+}
+
+export function stopJob() {
+ click('[data-test-stop] [data-test-idle-button]');
+ return wait().then(() => {
+ click('[data-test-stop] [data-test-confirm-button]');
+ return wait();
+ });
+}
+
+export function expectStopError(assert) {
+ return () => {
+ assert.equal(
+ find('[data-test-job-error-title]').textContent,
+ 'Could Not Stop Job',
+ 'Appropriate error is shown'
+ );
+ assert.ok(
+ find('[data-test-job-error-body]').textContent.includes('ACL'),
+ 'The error message mentions ACLs'
+ );
+
+ click('[data-test-job-error-close]');
+ assert.notOk(find('[data-test-job-error-title]'), 'Error message is dismissable');
+ return wait();
+ };
+}
+
+export function expectDeleteRequest(assert, server, job) {
+ const expectedURL = jobURL(job);
+
+ assert.ok(
+ server.pretender.handledRequests
+ .filterBy('method', 'DELETE')
+ .find(req => req.url === expectedURL),
+ 'DELETE URL was made correctly'
+ );
+
+ return wait();
+}
diff --git a/ui/tests/integration/job-page/periodic-test.js b/ui/tests/integration/job-page/periodic-test.js
index a9dd30bf71d..ba93d1a51ad 100644
--- a/ui/tests/integration/job-page/periodic-test.js
+++ b/ui/tests/integration/job-page/periodic-test.js
@@ -4,6 +4,7 @@ import { click, find, findAll } from 'ember-native-dom-helpers';
import wait from 'ember-test-helpers/wait';
import hbs from 'htmlbars-inline-precompile';
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+import { jobURL, stopJob, expectStopError, expectDeleteRequest } from './helpers';
moduleForComponent('job-page/periodic', 'Integration | Component | job-page/periodic', {
integration: true,
@@ -19,6 +20,23 @@ moduleForComponent('job-page/periodic', 'Integration | Component | job-page/peri
},
});
+const commonTemplate = hbs`
+ {{job-page/periodic
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ currentPage=currentPage
+ gotoJob=gotoJob}}
+`;
+
+const commonProperties = job => ({
+ job,
+ sortProperty: 'name',
+ sortDescending: true,
+ currentPage: 1,
+ gotoJob: () => {},
+});
+
test('Clicking Force Launch launches a new periodic child job', function(assert) {
const childrenCount = 3;
@@ -32,22 +50,9 @@ test('Clicking Force Launch launches a new periodic child job', function(assert)
return wait().then(() => {
const job = this.store.peekAll('job').findBy('plainId', 'parent');
- this.setProperties({
- job,
- sortProperty: 'name',
- sortDescending: true,
- currentPage: 1,
- gotoJob: () => {},
- });
- this.render(hbs`
- {{job-page/periodic
- job=job
- sortProperty=sortProperty
- sortDescending=sortDescending
- currentPage=currentPage
- gotoJob=gotoJob}}
- `);
+ this.setProperties(commonProperties(job));
+ this.render(commonTemplate);
return wait().then(() => {
const currentJobCount = server.db.jobs.length;
@@ -61,15 +66,10 @@ test('Clicking Force Launch launches a new periodic child job', function(assert)
click('[data-test-force-launch]');
return wait().then(() => {
- const id = job.get('plainId');
- const namespace = job.get('namespace.name') || 'default';
- let expectedURL = `/v1/job/${id}/periodic/force`;
- if (namespace !== 'default') {
- expectedURL += `?namespace=${namespace}`;
- }
+ const expectedURL = jobURL(job, '/periodic/force');
assert.ok(
- server.pretender.handledRequests
+ this.server.pretender.handledRequests
.filterBy('method', 'POST')
.find(req => req.url === expectedURL),
'POST URL was correct'
@@ -82,55 +82,90 @@ test('Clicking Force Launch launches a new periodic child job', function(assert)
});
test('Clicking force launch without proper permissions shows an error message', function(assert) {
- server.pretender.post('/v1/job/:id/periodic/force', () => [403, {}, null]);
+ this.server.pretender.post('/v1/job/:id/periodic/force', () => [403, {}, null]);
this.server.create('job', 'periodic', {
id: 'parent',
childrenCount: 1,
createAllocations: false,
+ status: 'running',
});
this.store.findAll('job');
return wait().then(() => {
const job = this.store.peekAll('job').findBy('plainId', 'parent');
- this.setProperties({
- job,
- sortProperty: 'name',
- sortDescending: true,
- currentPage: 1,
- gotoJob: () => {},
- });
- this.render(hbs`
- {{job-page/periodic
- job=job
- sortProperty=sortProperty
- sortDescending=sortDescending
- currentPage=currentPage
- gotoJob=gotoJob}}
- `);
+ this.setProperties(commonProperties(job));
+ this.render(commonTemplate);
return wait().then(() => {
- assert.notOk(find('[data-test-force-error-title]'), 'No error message yet');
+ assert.notOk(find('[data-test-job-error-title]'), 'No error message yet');
click('[data-test-force-launch]');
return wait().then(() => {
assert.equal(
- find('[data-test-force-error-title]').textContent,
+ find('[data-test-job-error-title]').textContent,
'Could Not Force Launch',
'Appropriate error is shown'
);
assert.ok(
- find('[data-test-force-error-body]').textContent.includes('ACL'),
+ find('[data-test-job-error-body]').textContent.includes('ACL'),
'The error message mentions ACLs'
);
- click('[data-test-force-error-close]');
+ click('[data-test-job-error-close]');
- assert.notOk(find('[data-test-force-error-title]'), 'Error message is dismissable');
+ assert.notOk(find('[data-test-job-error-title]'), 'Error message is dismissable');
});
});
});
});
+
+test('Stopping a job sends a delete request for the job', function(assert) {
+ const mirageJob = this.server.create('job', 'periodic', {
+ childrenCount: 0,
+ createAllocations: false,
+ status: 'running',
+ });
+
+ let job;
+ this.store.findAll('job');
+
+ return wait()
+ .then(() => {
+ job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
+
+ this.setProperties(commonProperties(job));
+ this.render(commonTemplate);
+
+ return wait();
+ })
+ .then(stopJob)
+ .then(() => expectDeleteRequest(assert, this.server, job));
+});
+
+test('Stopping a job without proper permissions shows an error message', function(assert) {
+ this.server.pretender.delete('/v1/job/:id', () => [403, {}, null]);
+
+ const mirageJob = this.server.create('job', 'periodic', {
+ childrenCount: 0,
+ createAllocations: false,
+ status: 'running',
+ });
+
+ this.store.findAll('job');
+
+ return wait()
+ .then(() => {
+ const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
+
+ this.setProperties(commonProperties(job));
+ this.render(commonTemplate);
+
+ return wait();
+ })
+ .then(stopJob)
+ .then(expectStopError(assert));
+});
diff --git a/ui/tests/integration/job-page/service-test.js b/ui/tests/integration/job-page/service-test.js
new file mode 100644
index 00000000000..5b9bf69f345
--- /dev/null
+++ b/ui/tests/integration/job-page/service-test.js
@@ -0,0 +1,82 @@
+import { getOwner } from '@ember/application';
+import { test, moduleForComponent } from 'ember-qunit';
+import wait from 'ember-test-helpers/wait';
+import hbs from 'htmlbars-inline-precompile';
+import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+import { stopJob, expectStopError, expectDeleteRequest } from './helpers';
+
+moduleForComponent('job-page/service', 'Integration | Component | job-page/service', {
+ integration: true,
+ beforeEach() {
+ window.localStorage.clear();
+ this.store = getOwner(this).lookup('service:store');
+ this.server = startMirage();
+ this.server.create('namespace');
+ },
+ afterEach() {
+ this.server.shutdown();
+ window.localStorage.clear();
+ },
+});
+
+const commonTemplate = hbs`
+ {{job-page/service
+ job=job
+ sortProperty=sortProperty
+ sortDescending=sortDescending
+ currentPage=currentPage
+ gotoJob=gotoJob}}
+`;
+
+const commonProperties = job => ({
+ job,
+ sortProperty: 'name',
+ sortDescending: true,
+ currentPage: 1,
+ gotoJob() {},
+});
+
+const makeMirageJob = server =>
+ server.create('job', {
+ type: 'service',
+ createAllocations: false,
+ status: 'running',
+ });
+
+test('Stopping a job sends a delete request for the job', function(assert) {
+ let job;
+
+ const mirageJob = makeMirageJob(this.server);
+ this.store.findAll('job');
+
+ return wait()
+ .then(() => {
+ job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
+
+ this.setProperties(commonProperties(job));
+ this.render(commonTemplate);
+
+ return wait();
+ })
+ .then(stopJob)
+ .then(() => expectDeleteRequest(assert, this.server, job));
+});
+
+test('Stopping a job without proper permissions shows an error message', function(assert) {
+ this.server.pretender.delete('/v1/job/:id', () => [403, {}, null]);
+
+ const mirageJob = makeMirageJob(this.server);
+ this.store.findAll('job');
+
+ return wait()
+ .then(() => {
+ const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
+
+ this.setProperties(commonProperties(job));
+ this.render(commonTemplate);
+
+ return wait();
+ })
+ .then(stopJob)
+ .then(expectStopError(assert));
+});
diff --git a/ui/tests/integration/two-step-button-test.js b/ui/tests/integration/two-step-button-test.js
new file mode 100644
index 00000000000..41eff1e6e4a
--- /dev/null
+++ b/ui/tests/integration/two-step-button-test.js
@@ -0,0 +1,111 @@
+import { find, click } from 'ember-native-dom-helpers';
+import { test, moduleForComponent } from 'ember-qunit';
+import wait from 'ember-test-helpers/wait';
+import hbs from 'htmlbars-inline-precompile';
+import sinon from 'sinon';
+
+moduleForComponent('two-step-button', 'Integration | Component | two step button', {
+ integration: true,
+});
+
+const commonProperties = () => ({
+ idleText: 'Idle State Button',
+ cancelText: 'Cancel Action',
+ confirmText: 'Confirm Action',
+ confirmationMessage: 'Are you certain',
+ onConfirm: sinon.spy(),
+ onCancel: sinon.spy(),
+});
+
+const commonTemplate = hbs`
+ {{two-step-button
+ idleText=idleText
+ cancelText=cancelText
+ confirmText=confirmText
+ confirmationMessage=confirmationMessage
+ onConfirm=onConfirm
+ onCancel=onCancel}}
+`;
+
+test('presents as a button in the idle state', function(assert) {
+ const props = commonProperties();
+ this.setProperties(props);
+ this.render(commonTemplate);
+
+ assert.ok(find('[data-test-idle-button]'), 'Idle button is rendered');
+ assert.equal(
+ find('[data-test-idle-button]').textContent.trim(),
+ props.idleText,
+ 'Button is labeled correctly'
+ );
+
+ assert.notOk(find('[data-test-cancel-button]'), 'No cancel button yet');
+ assert.notOk(find('[data-test-confirm-button]'), 'No confirm button yet');
+ assert.notOk(find('[data-test-confirmation-message]'), 'No confirmation message yet');
+});
+
+test('clicking the idle state button transitions into the promptForConfirmation state', function(assert) {
+ const props = commonProperties();
+ this.setProperties(props);
+ this.render(commonTemplate);
+
+ click('[data-test-idle-button]');
+
+ return wait().then(() => {
+ assert.ok(find('[data-test-cancel-button]'), 'Cancel button is rendered');
+ assert.equal(
+ find('[data-test-cancel-button]').textContent.trim(),
+ props.cancelText,
+ 'Button is labeled correctly'
+ );
+
+ assert.ok(find('[data-test-confirm-button]'), 'Confirm button is rendered');
+ assert.equal(
+ find('[data-test-confirm-button]').textContent.trim(),
+ props.confirmText,
+ 'Button is labeled correctly'
+ );
+
+ assert.equal(
+ find('[data-test-confirmation-message]').textContent.trim(),
+ props.confirmationMessage,
+ 'Confirmation message is shown'
+ );
+
+ assert.notOk(find('[data-test-idle-button]'), 'No more idle button');
+ });
+});
+
+test('canceling in the promptForConfirmation state calls the onCancel hook and resets to the idle state', function(assert) {
+ const props = commonProperties();
+ this.setProperties(props);
+ this.render(commonTemplate);
+
+ click('[data-test-idle-button]');
+
+ return wait().then(() => {
+ click('[data-test-cancel-button]');
+
+ return wait().then(() => {
+ assert.ok(props.onCancel.calledOnce, 'The onCancel hook fired');
+ assert.ok(find('[data-test-idle-button]'), 'Idle button is back');
+ });
+ });
+});
+
+test('confirming the promptForConfirmation state calls the onConfirm hook and resets to the idle state', function(assert) {
+ const props = commonProperties();
+ this.setProperties(props);
+ this.render(commonTemplate);
+
+ click('[data-test-idle-button]');
+
+ return wait().then(() => {
+ click('[data-test-confirm-button]');
+
+ return wait().then(() => {
+ assert.ok(props.onConfirm.calledOnce, 'The onConfirm hook fired');
+ assert.ok(find('[data-test-idle-button]'), 'Idle button is back');
+ });
+ });
+});