@@ -35,7 +35,7 @@
Job Definition
{{#if cancelable}}
-
+
{{/if}}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index d3b2a9778..d573ed6de 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -116,6 +116,14 @@ export default function() {
})
);
+ this.post('/job/:id', function(schema, req) {
+ const body = JSON.parse(req.requestBody);
+
+ if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload');
+
+ return okEmpty();
+ });
+
this.get(
'/job/:id/summary',
withBlockingSupport(function({ jobSummaries }, { params }) {
diff --git a/ui/tests/integration/job-editor-test.js b/ui/tests/integration/job-editor-test.js
new file mode 100644
index 000000000..9513a650e
--- /dev/null
+++ b/ui/tests/integration/job-editor-test.js
@@ -0,0 +1,492 @@
+import { getOwner } from '@ember/application';
+import { assign } from '@ember/polyfills';
+import { run } from '@ember/runloop';
+import { test, moduleForComponent } from 'ember-qunit';
+import wait from 'ember-test-helpers/wait';
+import hbs from 'htmlbars-inline-precompile';
+import { create } from 'ember-cli-page-object';
+import sinon from 'sinon';
+import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+import { getCodeMirrorInstance } from 'nomad-ui/tests/helpers/codemirror';
+import jobEditor from 'nomad-ui/tests/pages/components/job-editor';
+import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
+
+const Editor = create(jobEditor());
+
+moduleForComponent('job-editor', 'Integration | Component | job-editor', {
+ integration: true,
+ beforeEach() {
+ window.localStorage.clear();
+
+ fragmentSerializerInitializer(getOwner(this));
+
+ // Normally getCodeMirrorInstance is a registered test helper,
+ // but those registered test helpers only work in acceptance tests.
+ window._getCodeMirrorInstance = window.getCodeMirrorInstance;
+ window.getCodeMirrorInstance = getCodeMirrorInstance(getOwner(this));
+
+ this.store = getOwner(this).lookup('service:store');
+ this.server = startMirage();
+
+ // Required for placing allocations (a result of creating jobs)
+ this.server.create('node');
+
+ Editor.setContext(this);
+ },
+ afterEach() {
+ this.server.shutdown();
+ Editor.removeContext();
+ window.getCodeMirrorInstance = window._getCodeMirrorInstance;
+ delete window._getCodeMirrorInstance;
+ },
+});
+
+const newJobName = 'new-job';
+const newJobTaskGroupName = 'redis';
+const jsonJob = overrides => {
+ return JSON.stringify(
+ assign(
+ {},
+ {
+ Name: newJobName,
+ Namespace: 'default',
+ Datacenters: ['dc1'],
+ Priority: 50,
+ TaskGroups: [
+ {
+ Name: newJobTaskGroupName,
+ Tasks: [
+ {
+ Name: 'redis',
+ Driver: 'docker',
+ },
+ ],
+ },
+ ],
+ },
+ overrides
+ ),
+ null,
+ 2
+ );
+};
+
+const hclJob = () => `
+job "${newJobName}" {
+ namespace = "default"
+ datacenters = ["dc1"]
+
+ task "${newJobTaskGroupName}" {
+ driver = "docker"
+ }
+}
+`;
+
+const commonTemplate = hbs`
+ {{job-editor
+ job=job
+ context=context
+ onSubmit=onSubmit}}
+`;
+
+const cancelableTemplate = hbs`
+ {{job-editor
+ job=job
+ context=context
+ cancelable=true
+ onSubmit=onSubmit
+ onCancel=onCancel}}
+`;
+
+const renderNewJob = (component, job) => () => {
+ component.setProperties({ job, onSubmit: sinon.spy(), context: 'new' });
+ component.render(commonTemplate);
+ return wait();
+};
+
+const renderEditJob = (component, job) => () => {
+ component.setProperties({ job, onSubmit: sinon.spy(), onCancel: sinon.spy(), context: 'edit' });
+ component.render(cancelableTemplate);
+};
+
+const planJob = spec => () => {
+ Editor.editor.fillIn(spec);
+ return wait().then(() => {
+ Editor.plan();
+ return wait();
+ });
+};
+
+test('the default state is an editor with an explanation popup', function(assert) {
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(() => {
+ assert.ok(Editor.editorHelp.isPresent, 'Editor explanation popup is present');
+ assert.ok(Editor.editor.isPresent, 'Editor is present');
+ });
+});
+
+test('the explanation popup can be dismissed', function(assert) {
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(() => {
+ Editor.editorHelp.dismiss();
+ return wait();
+ })
+ .then(() => {
+ assert.notOk(Editor.editorHelp.isPresent, 'Editor explanation popup is gone');
+ assert.equal(
+ window.localStorage.nomadMessageJobEditor,
+ 'false',
+ 'Dismissal is persisted in localStorage'
+ );
+ });
+});
+
+test('the explanation popup is not shown once the dismissal state is set in localStorage', function(assert) {
+ window.localStorage.nomadMessageJobEditor = 'false';
+
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(() => {
+ assert.notOk(Editor.editorHelp.isPresent, 'Editor explanation popup is gone');
+ });
+});
+
+test('submitting a json job skips the parse endpoint', function(assert) {
+ const spec = jsonJob();
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ const requests = this.server.pretender.handledRequests.mapBy('url');
+ assert.notOk(requests.includes('/v1/jobs/parse'), 'JSON job spec is not parsed');
+ assert.ok(requests.includes(`/v1/job/${newJobName}/plan`), 'JSON job spec is still planned');
+ });
+});
+
+test('submitting an hcl job requires the parse endpoint', function(assert) {
+ const spec = hclJob();
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ const requests = this.server.pretender.handledRequests.mapBy('url');
+ assert.ok(requests.includes('/v1/jobs/parse'), 'HCL job spec is parsed first');
+ assert.ok(requests.includes(`/v1/job/${newJobName}/plan`), 'HCL job spec is planned');
+ assert.ok(
+ requests.indexOf('/v1/jobs/parse') < requests.indexOf(`/v1/job/${newJobName}/plan`),
+ 'Parse comes before Plan'
+ );
+ });
+});
+
+test('when a job is successfully parsed and planned, the plan is shown to the user', function(assert) {
+ const spec = hclJob();
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ assert.ok(Editor.planOutput, 'The plan is outputted');
+ assert.notOk(Editor.editor.isPresent, 'The editor is replaced with the plan output');
+ assert.ok(Editor.planHelp.isPresent, 'The plan explanation popup is shown');
+ });
+});
+
+test('from the plan screen, the cancel button goes back to the editor with the job still in tact', function(assert) {
+ const spec = hclJob();
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ Editor.cancel();
+ return wait();
+ })
+ .then(() => {
+ assert.ok(Editor.editor.isPresent, 'The editor is shown again');
+ assert.equal(
+ Editor.editor.contents,
+ spec,
+ 'The spec that was planned is still in the editor'
+ );
+ });
+});
+
+test('when parse fails, the parse error message is shown', function(assert) {
+ const spec = hclJob();
+ const errorMessage = 'Parse Failed!! :o';
+
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ this.server.pretender.post('/v1/jobs/parse', () => [400, {}, errorMessage]);
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ assert.notOk(Editor.planError.isPresent, 'Plan error is not shown');
+ assert.notOk(Editor.runError.isPresent, 'Run error is not shown');
+
+ assert.ok(Editor.parseError.isPresent, 'Parse error is shown');
+ assert.equal(
+ Editor.parseError.message,
+ errorMessage,
+ 'The error message from the server is shown in the error in the UI'
+ );
+ });
+});
+
+test('when plan fails, the plan error message is shown', function(assert) {
+ const spec = hclJob();
+ const errorMessage = 'Plan Failed!! :o';
+
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ this.server.pretender.post(`/v1/job/${newJobName}/plan`, () => [400, {}, errorMessage]);
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ assert.notOk(Editor.parseError.isPresent, 'Parse error is not shown');
+ assert.notOk(Editor.runError.isPresent, 'Run error is not shown');
+
+ assert.ok(Editor.planError.isPresent, 'Plan error is shown');
+ assert.equal(
+ Editor.planError.message,
+ errorMessage,
+ 'The error message from the server is shown in the error in the UI'
+ );
+ });
+});
+
+test('when run fails, the run error message is shown', function(assert) {
+ const spec = hclJob();
+ const errorMessage = 'Run Failed!! :o';
+
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ this.server.pretender.post('/v1/jobs', () => [400, {}, errorMessage]);
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ Editor.run();
+ return wait();
+ })
+ .then(() => {
+ assert.notOk(Editor.planError.isPresent, 'Plan error is not shown');
+ assert.notOk(Editor.parseError.isPresent, 'Parse error is not shown');
+
+ assert.ok(Editor.runError.isPresent, 'Run error is shown');
+ assert.equal(
+ Editor.runError.message,
+ errorMessage,
+ 'The error message from the server is shown in the error in the UI'
+ );
+ });
+});
+
+test('when the scheduler dry-run has warnings, the warnings are shown to the user', function(assert) {
+ const spec = jsonJob({ Unschedulable: true });
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ assert.ok(
+ Editor.dryRunMessage.errored,
+ 'The scheduler dry-run message is in the warning state'
+ );
+ assert.notOk(
+ Editor.dryRunMessage.succeeded,
+ 'The success message is not shown in addition to the warning message'
+ );
+ assert.ok(
+ Editor.dryRunMessage.body.includes(newJobTaskGroupName),
+ 'The scheduler dry-run message includes the warning from send back by the API'
+ );
+ });
+});
+
+test('when the scheduler dry-run has no warnings, a success message is shown to the user', function(assert) {
+ const spec = hclJob();
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ assert.ok(
+ Editor.dryRunMessage.succeeded,
+ 'The scheduler dry-run message is in the success state'
+ );
+ assert.notOk(
+ Editor.dryRunMessage.errored,
+ 'The warning message is not shown in addition to the success message'
+ );
+ });
+});
+
+test('when a job is submitted in the edit context, a POST request is made to the update job endpoint', function(assert) {
+ const spec = hclJob();
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderEditJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ Editor.run();
+ })
+ .then(() => {
+ const requests = this.server.pretender.handledRequests
+ .filterBy('method', 'POST')
+ .mapBy('url');
+ assert.ok(requests.includes(`/v1/job/${newJobName}`), 'A request was made to job update');
+ assert.notOk(requests.includes('/v1/jobs'), 'A request was not made to job create');
+ });
+});
+
+test('when a job is submitted in the new context, a POST request is made to the create job endpoint', function(assert) {
+ const spec = hclJob();
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ Editor.run();
+ })
+ .then(() => {
+ const requests = this.server.pretender.handledRequests
+ .filterBy('method', 'POST')
+ .mapBy('url');
+ assert.ok(requests.includes('/v1/jobs'), 'A request was made to job create');
+ assert.notOk(
+ requests.includes(`/v1/job/${newJobName}`),
+ 'A request was not made to job update'
+ );
+ });
+});
+
+test('when a job is successfully submitted, the onSubmit hook is called', function(assert) {
+ const spec = hclJob();
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(planJob(spec))
+ .then(() => {
+ Editor.run();
+ return wait();
+ })
+ .then(() => {
+ assert.ok(
+ this.get('onSubmit').calledWith(newJobName, 'default'),
+ 'The onSubmit hook was called with the correct arguments'
+ );
+ });
+});
+
+test('when the job-editor cancelable flag is false, there is no cancel button in the header', function(assert) {
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderNewJob(this, job))
+ .then(() => {
+ assert.notOk(Editor.cancelEditingIsAvailable, 'No way to cancel editing');
+ });
+});
+
+test('when the job-editor cancelable flag is true, there is a cancel button in the header', function(assert) {
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderEditJob(this, job))
+ .then(() => {
+ assert.ok(Editor.cancelEditingIsAvailable, 'Cancel editing button exists');
+ });
+});
+
+test('when the job-editor cancel button is clicked, the onCancel hook is called', function(assert) {
+ let job;
+ run(() => {
+ job = this.store.createRecord('job');
+ });
+
+ return wait()
+ .then(renderEditJob(this, job))
+ .then(() => {
+ Editor.cancelEditing();
+ })
+ .then(() => {
+ assert.ok(this.get('onCancel').calledOnce, 'The onCancel hook was called');
+ });
+});
diff --git a/ui/tests/pages/components/job-editor.js b/ui/tests/pages/components/job-editor.js
new file mode 100644
index 000000000..c9de367c5
--- /dev/null
+++ b/ui/tests/pages/components/job-editor.js
@@ -0,0 +1,49 @@
+import { clickable, hasClass, isPresent, text } from 'ember-cli-page-object';
+import { codeFillable, code } from 'nomad-ui/tests/pages/helpers/codemirror';
+
+import error from 'nomad-ui/tests/pages/components/error';
+
+export default () => ({
+ scope: '[data-test-job-editor]',
+
+ planError: error('data-test-plan-error'),
+ parseError: error('data-test-parse-error'),
+ runError: error('data-test-run-error'),
+
+ plan: clickable('[data-test-plan]'),
+ cancel: clickable('[data-test-cancel]'),
+ run: clickable('[data-test-run]'),
+
+ cancelEditing: clickable('[data-test-cancel-editing]'),
+ cancelEditingIsAvailable: isPresent('[data-test-cancel-editing]'),
+
+ planOutput: text('[data-test-plan-output]'),
+
+ planHelp: {
+ isPresent: isPresent('[data-test-plan-help-title]'),
+ title: text('[data-test-plan-help-title]'),
+ message: text('[data-test-plan-help-message]'),
+ dismiss: clickable('[data-test-plan-help-dismiss]'),
+ },
+
+ editorHelp: {
+ isPresent: isPresent('[data-test-editor-help-title]'),
+ title: text('[data-test-editor-help-title]'),
+ message: text('[data-test-editor-help-message]'),
+ dismiss: clickable('[data-test-editor-help-dismiss]'),
+ },
+
+ editor: {
+ isPresent: isPresent('[data-test-editor]'),
+ contents: code('[data-test-editor]'),
+ fillIn: codeFillable('[data-test-editor]'),
+ },
+
+ dryRunMessage: {
+ scope: '[data-test-dry-run-message]',
+ title: text('[data-test-dry-run-title]'),
+ body: text('[data-test-dry-run-body]'),
+ errored: hasClass('is-warning'),
+ succeeded: hasClass('is-primary'),
+ },
+});
diff --git a/ui/tests/pages/jobs/run.js b/ui/tests/pages/jobs/run.js
index 08c1bcb7c..03c24ef14 100644
--- a/ui/tests/pages/jobs/run.js
+++ b/ui/tests/pages/jobs/run.js
@@ -1,46 +1,8 @@
-import { clickable, create, hasClass, isPresent, text, visitable } from 'ember-cli-page-object';
-import { codeFillable, code } from 'nomad-ui/tests/pages/helpers/codemirror';
+import { create, visitable } from 'ember-cli-page-object';
-import error from 'nomad-ui/tests/pages/components/error';
+import jobEditor from 'nomad-ui/tests/pages/components/job-editor';
export default create({
visit: visitable('/jobs/run'),
-
- planError: error('data-test-plan-error'),
- parseError: error('data-test-parse-error'),
- runError: error('data-test-run-error'),
-
- plan: clickable('[data-test-plan]'),
- cancel: clickable('[data-test-cancel]'),
- run: clickable('[data-test-run]'),
-
- planOutput: text('[data-test-plan-output]'),
-
- planHelp: {
- isPresent: isPresent('[data-test-plan-help-title]'),
- title: text('[data-test-plan-help-title]'),
- message: text('[data-test-plan-help-message]'),
- dismiss: clickable('[data-test-plan-help-dismiss]'),
- },
-
- editorHelp: {
- isPresent: isPresent('[data-test-editor-help-title]'),
- title: text('[data-test-editor-help-title]'),
- message: text('[data-test-editor-help-message]'),
- dismiss: clickable('[data-test-editor-help-dismiss]'),
- },
-
- editor: {
- isPresent: isPresent('[data-test-editor]'),
- contents: code('[data-test-editor]'),
- fillIn: codeFillable('[data-test-editor]'),
- },
-
- dryRunMessage: {
- scope: '[data-test-dry-run-message]',
- title: text('[data-test-dry-run-title]'),
- body: text('[data-test-dry-run-body]'),
- errored: hasClass('is-warning'),
- succeeded: hasClass('is-primary'),
- },
+ editor: jobEditor(),
});