diff --git a/ui/app/components/job-page/parts/title.js b/ui/app/components/job-page/parts/title.js
index fb8e3232c007..ba328462726b 100644
--- a/ui/app/components/job-page/parts/title.js
+++ b/ui/app/components/job-page/parts/title.js
@@ -1,4 +1,6 @@
import Component from '@ember/component';
+import { task } from 'ember-concurrency';
+import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
export default Component.extend({
tagName: '',
@@ -8,16 +10,42 @@ export default Component.extend({
handleError() {},
- actions: {
- stopJob() {
- this.get('job')
- .stop()
- .catch(() => {
- this.get('handleError')({
- title: 'Could Not Stop Job',
- description: 'Your ACL token does not grant permission to stop jobs.',
- });
- });
- },
- },
+ stopJob: task(function*() {
+ try {
+ const job = this.get('job');
+ yield job.stop();
+ // Eagerly update the job status to avoid flickering
+ this.job.set('status', 'dead');
+ } catch (err) {
+ this.get('handleError')({
+ title: 'Could Not Stop Job',
+ description: 'Your ACL token does not grant permission to stop jobs.',
+ });
+ }
+ }),
+
+ startJob: task(function*() {
+ const job = this.get('job');
+ const definition = yield job.fetchRawDefinition();
+
+ delete definition.Stop;
+ job.set('_newDefinition', JSON.stringify(definition));
+
+ try {
+ yield job.parse();
+ yield job.update();
+ // Eagerly update the job status to avoid flickering
+ job.set('status', 'running');
+ } catch (err) {
+ let message = messageFromAdapterError(err);
+ if (!message || message === 'Forbidden') {
+ message = 'Your ACL token does not grant permission to stop jobs.';
+ }
+
+ this.get('handleError')({
+ title: 'Could Not Start Job',
+ description: message,
+ });
+ }
+ }),
});
diff --git a/ui/app/components/two-step-button.js b/ui/app/components/two-step-button.js
index 7016e96ec24f..fe40c16f244c 100644
--- a/ui/app/components/two-step-button.js
+++ b/ui/app/components/two-step-button.js
@@ -1,5 +1,6 @@
import Component from '@ember/component';
import { equal } from '@ember/object/computed';
+import RSVP from 'rsvp';
export default Component.extend({
classNames: ['two-step-button'],
@@ -8,6 +9,7 @@ export default Component.extend({
cancelText: '',
confirmText: '',
confirmationMessage: '',
+ awaitingConfirmation: false,
onConfirm() {},
onCancel() {},
@@ -22,5 +24,10 @@ export default Component.extend({
promptForConfirmation() {
this.set('state', 'prompt');
},
+ confirm() {
+ RSVP.resolve(this.get('onConfirm')()).then(() => {
+ this.send('setToIdle');
+ });
+ },
},
});
diff --git a/ui/app/templates/components/freestyle/sg-two-step-button.hbs b/ui/app/templates/components/freestyle/sg-two-step-button.hbs
index f39096b0e1e1..74f43919fbd7 100644
--- a/ui/app/templates/components/freestyle/sg-two-step-button.hbs
+++ b/ui/app/templates/components/freestyle/sg-two-step-button.hbs
@@ -20,3 +20,21 @@
{{/freestyle-usage}}
+
+{{#freestyle-usage "two-step-button-loading" title="Two Step Button Loading State"}}
+
+
+ This is a page title
+ {{two-step-button
+ idleText="Scary Action"
+ cancelText="Nvm"
+ confirmText="Yep"
+ confirmationMessage="Wait, really? Like...seriously?"
+ awaitingConfirmation=true
+ state="prompt"}}
+
+
+{{/freestyle-usage}}
+{{#freestyle-annotation}}
+ Note: the state
property is internal state and only used here to bypass the idle state for demonstration purposes.
+{{/freestyle-annotation}}
diff --git a/ui/app/templates/components/job-page/parts/title.hbs b/ui/app/templates/components/job-page/parts/title.hbs
index 445640d760a5..131b06e56279 100644
--- a/ui/app/templates/components/job-page/parts/title.hbs
+++ b/ui/app/templates/components/job-page/parts/title.hbs
@@ -9,6 +9,16 @@
cancelText="Cancel"
confirmText="Yes, Stop"
confirmationMessage="Are you sure you want to stop this job?"
- onConfirm=(action "stopJob")}}
+ awaitingConfirmation=stopJob.isRunning
+ onConfirm=(perform stopJob)}}
+ {{else}}
+ {{two-step-button
+ data-test-start
+ idleText="Start"
+ cancelText="Cancel"
+ confirmText="Yes, Start"
+ confirmationMessage="Are you sure you want to start this job?"
+ awaitingConfirmation=startJob.isRunning
+ onConfirm=(perform startJob)}}
{{/if}}
diff --git a/ui/app/templates/components/two-step-button.hbs b/ui/app/templates/components/two-step-button.hbs
index e9fe906b59e8..1b95184fea51 100644
--- a/ui/app/templates/components/two-step-button.hbs
+++ b/ui/app/templates/components/two-step-button.hbs
@@ -4,16 +4,22 @@
{{else if isPendingConfirmation}}
{{confirmationMessage}}
-
+
{{cancelText}}
-
+
{{confirmText}}
{{/if}}
diff --git a/ui/tests/integration/job-page/helpers.js b/ui/tests/integration/job-page/helpers.js
index ef64e86af453..21deb51b17e8 100644
--- a/ui/tests/integration/job-page/helpers.js
+++ b/ui/tests/integration/job-page/helpers.js
@@ -19,11 +19,31 @@ export function stopJob() {
});
}
-export function expectStopError(assert) {
+export function startJob() {
+ click('[data-test-start] [data-test-idle-button]');
+ return wait().then(() => {
+ click('[data-test-start] [data-test-confirm-button]');
+ return wait();
+ });
+}
+
+export function expectStartRequest(assert, server, job) {
+ const expectedURL = jobURL(job);
+ const request = server.pretender.handledRequests
+ .filterBy('method', 'POST')
+ .find(req => req.url === expectedURL);
+
+ const requestPayload = JSON.parse(request.requestBody).Job;
+
+ assert.ok(request, 'POST URL was made correctly');
+ assert.ok(requestPayload.Stop == null, 'The Stop signal is not sent in the POST request');
+}
+
+export function expectError(assert, title) {
return () => {
assert.equal(
find('[data-test-job-error-title]').textContent,
- 'Could Not Stop Job',
+ title,
'Appropriate error is shown'
);
assert.ok(
diff --git a/ui/tests/integration/job-page/periodic-test.js b/ui/tests/integration/job-page/periodic-test.js
index ba93d1a51ad4..0908447f9d0a 100644
--- a/ui/tests/integration/job-page/periodic-test.js
+++ b/ui/tests/integration/job-page/periodic-test.js
@@ -4,7 +4,14 @@ 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';
+import {
+ jobURL,
+ stopJob,
+ startJob,
+ expectError,
+ expectDeleteRequest,
+ expectStartRequest,
+} from './helpers';
moduleForComponent('job-page/periodic', 'Integration | Component | job-page/periodic', {
integration: true,
@@ -167,5 +174,51 @@ test('Stopping a job without proper permissions shows an error message', functio
return wait();
})
.then(stopJob)
- .then(expectStopError(assert));
+ .then(expectError(assert, 'Could Not Stop Job'));
+});
+
+test('Starting a job sends a post request for the job using the current definition', function(assert) {
+ let job;
+
+ const mirageJob = this.server.create('job', 'periodic', {
+ childrenCount: 0,
+ createAllocations: false,
+ status: 'dead',
+ });
+ 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(startJob)
+ .then(() => expectStartRequest(assert, this.server, job));
+});
+
+test('Starting a job without proper permissions shows an error message', function(assert) {
+ this.server.pretender.post('/v1/job/:id', () => [403, {}, null]);
+
+ const mirageJob = this.server.create('job', 'periodic', {
+ childrenCount: 0,
+ createAllocations: false,
+ status: 'dead',
+ });
+ 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(startJob)
+ .then(expectError(assert, 'Could Not Start Job'));
});
diff --git a/ui/tests/integration/job-page/service-test.js b/ui/tests/integration/job-page/service-test.js
index aef698a149cf..ff29b3da996d 100644
--- a/ui/tests/integration/job-page/service-test.js
+++ b/ui/tests/integration/job-page/service-test.js
@@ -4,7 +4,7 @@ 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';
+import { startJob, stopJob, expectError, expectDeleteRequest, expectStartRequest } from './helpers';
import Job from 'nomad-ui/tests/pages/jobs/detail';
moduleForComponent('job-page/service', 'Integration | Component | job-page/service', {
@@ -88,7 +88,45 @@ test('Stopping a job without proper permissions shows an error message', functio
return wait();
})
.then(stopJob)
- .then(expectStopError(assert));
+ .then(expectError(assert, 'Could Not Stop Job'));
+});
+
+test('Starting a job sends a post request for the job using the current definition', function(assert) {
+ let job;
+
+ const mirageJob = makeMirageJob(this.server, { status: 'dead' });
+ 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(startJob)
+ .then(() => expectStartRequest(assert, this.server, job));
+});
+
+test('Starting a job without proper permissions shows an error message', function(assert) {
+ this.server.pretender.post('/v1/job/:id', () => [403, {}, null]);
+
+ const mirageJob = makeMirageJob(this.server, { status: 'dead' });
+ 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(startJob)
+ .then(expectError(assert, 'Could Not Start Job'));
});
test('Recent allocations shows allocations in the job context', function(assert) {
diff --git a/ui/tests/integration/two-step-button-test.js b/ui/tests/integration/two-step-button-test.js
index 41eff1e6e4a8..12d23c1d7382 100644
--- a/ui/tests/integration/two-step-button-test.js
+++ b/ui/tests/integration/two-step-button-test.js
@@ -13,6 +13,7 @@ const commonProperties = () => ({
cancelText: 'Cancel Action',
confirmText: 'Confirm Action',
confirmationMessage: 'Are you certain',
+ awaitingConfirmation: false,
onConfirm: sinon.spy(),
onCancel: sinon.spy(),
});
@@ -23,6 +24,7 @@ const commonTemplate = hbs`
cancelText=cancelText
confirmText=confirmText
confirmationMessage=confirmationMessage
+ awaitingConfirmation=awaitingConfirmation
onConfirm=onConfirm
onCancel=onCancel}}
`;
@@ -109,3 +111,27 @@ test('confirming the promptForConfirmation state calls the onConfirm hook and re
});
});
});
+
+test('when awaitingConfirmation is true, the cancel and submit buttons are disabled and the submit button is loading', function(assert) {
+ const props = commonProperties();
+ props.awaitingConfirmation = true;
+ this.setProperties(props);
+ this.render(commonTemplate);
+
+ click('[data-test-idle-button]');
+
+ return wait().then(() => {
+ assert.ok(
+ find('[data-test-cancel-button]').hasAttribute('disabled'),
+ 'The cancel button is disabled'
+ );
+ assert.ok(
+ find('[data-test-confirm-button]').hasAttribute('disabled'),
+ 'The confirm button is disabled'
+ );
+ assert.ok(
+ find('[data-test-confirm-button]').classList.contains('is-loading'),
+ 'The confirm button is in a loading state'
+ );
+ });
+});