From 751b6e2fd678b9a05953bdb608c422f6837e0512 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 14 Aug 2018 12:46:55 -0700 Subject: [PATCH 01/19] Enforce a min-height for the code editor component --- ui/app/styles/components/codemirror.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/app/styles/components/codemirror.scss b/ui/app/styles/components/codemirror.scss index 59f4d755b32..16b54f23b4b 100644 --- a/ui/app/styles/components/codemirror.scss +++ b/ui/app/styles/components/codemirror.scss @@ -4,6 +4,10 @@ $dark-bright: lighten($dark, 15%); height: auto; } +.CodeMirror-scroll { + min-height: 500px; +} + .cm-s-hashi, .cm-s-hashi-read-only { &.CodeMirror { From 53f2ca31279470b78c4b01fbb033f83be8fcf56f Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 14 Aug 2018 12:47:28 -0700 Subject: [PATCH 02/19] New layout helper for associating two elements vertically By default, blocks have a margin of 1.5em to create a consistent vertical rhythm. However, sometimes elements need to be associated with the element above them. In this cases, the gap between elements needs to be tighter. There are many ways to do this, but this approach asks the latter content to be marked as associative. The implication is that the association is with the previous block. --- ui/app/styles/core.scss | 51 +++++++++++++++++---------------- ui/app/styles/utils/layout.scss | 3 ++ 2 files changed, 29 insertions(+), 25 deletions(-) create mode 100644 ui/app/styles/utils/layout.scss diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index e4391415b27..a92c17adea1 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -1,34 +1,35 @@ // Utils -@import "./utils/reset.scss"; -@import "./utils/z-indices"; -@import "./utils/product-colors"; -@import "./utils/bumper"; +@import './utils/reset.scss'; +@import './utils/z-indices'; +@import './utils/product-colors'; +@import './utils/bumper'; +@import './utils/layout'; // Start with Bulma variables as a foundation -@import "bulma/sass/utilities/initial-variables"; +@import 'bulma/sass/utilities/initial-variables'; // Override variables where appropriate -@import "./core/variables.scss"; +@import './core/variables.scss'; // Bring in the rest of Bulma -@import "bulma/bulma"; +@import 'bulma/bulma'; // Override Bulma details where appropriate -@import "./core/buttons"; -@import "./core/breadcrumb"; -@import "./core/columns"; -@import "./core/forms"; -@import "./core/icon"; -@import "./core/level"; -@import "./core/menu"; -@import "./core/message"; -@import "./core/navbar"; -@import "./core/notification"; -@import "./core/pagination"; -@import "./core/progress"; -@import "./core/section"; -@import "./core/table"; -@import "./core/tabs"; -@import "./core/tag"; -@import "./core/title"; -@import "./core/typography"; +@import './core/buttons'; +@import './core/breadcrumb'; +@import './core/columns'; +@import './core/forms'; +@import './core/icon'; +@import './core/level'; +@import './core/menu'; +@import './core/message'; +@import './core/navbar'; +@import './core/notification'; +@import './core/pagination'; +@import './core/progress'; +@import './core/section'; +@import './core/table'; +@import './core/tabs'; +@import './core/tag'; +@import './core/title'; +@import './core/typography'; diff --git a/ui/app/styles/utils/layout.scss b/ui/app/styles/utils/layout.scss new file mode 100644 index 00000000000..6504a68cfc2 --- /dev/null +++ b/ui/app/styles/utils/layout.scss @@ -0,0 +1,3 @@ +.is-associative { + margin-top: -0.75em; +} From 33956a69dae92ea6925c879ecf62f2b55a550b7c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 14 Aug 2018 12:54:54 -0700 Subject: [PATCH 03/19] New job run page and navigation to get there. --- ui/app/router.js | 1 + ui/app/templates/jobs/index.hbs | 13 +++++++++---- ui/app/templates/jobs/run.hbs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 ui/app/templates/jobs/run.hbs diff --git a/ui/app/router.js b/ui/app/router.js index 39446ffa958..7a4015c28e2 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -8,6 +8,7 @@ const Router = EmberRouter.extend({ Router.map(function() { this.route('jobs', function() { + this.route('run'); this.route('job', { path: '/:job_name' }, function() { this.route('task-group', { path: '/:name' }); this.route('definition'); diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index a306f510616..8b792046d33 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -2,11 +2,16 @@ {{#if isForbidden}} {{partial "partials/forbidden-message"}} {{else}} - {{#if filteredJobs.length}} -
-
{{search-box data-test-jobs-search searchTerm=(mut searchTerm) placeholder="Search jobs..."}}
+
+ {{#if filteredJobs.length}} +
+ {{search-box data-test-jobs-search searchTerm=(mut searchTerm) placeholder="Search jobs..."}} +
+ {{/if}} +
+ {{#link-to "jobs.run" class="button is-primary is-pulled-right"}}Run Job{{/link-to}}
- {{/if}} +
{{#list-pagination source=sortedJobs size=pageSize diff --git a/ui/app/templates/jobs/run.hbs b/ui/app/templates/jobs/run.hbs new file mode 100644 index 00000000000..d142248aa68 --- /dev/null +++ b/ui/app/templates/jobs/run.hbs @@ -0,0 +1,31 @@ +
+
+
+
+

Run a Job

+

Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.

+
+
+ +
+
+
+
+
+ Job Definition +
+
+ {{ivy-codemirror + value=jobSpec + options=(hash + mode="javascript" + theme="hashi" + tabSize=2 + lineNumbers=true + )}} +
+
+
+ +
+
From 302401d45c241e6767fa1e22391414e2860040c7 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 14 Aug 2018 13:06:26 -0700 Subject: [PATCH 04/19] Add breadcrumb to the run job page --- ui/app/routes/jobs/run.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 ui/app/routes/jobs/run.js diff --git a/ui/app/routes/jobs/run.js b/ui/app/routes/jobs/run.js new file mode 100644 index 00000000000..ac6c654b110 --- /dev/null +++ b/ui/app/routes/jobs/run.js @@ -0,0 +1,13 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default Route.extend({ + store: service(), + + breadcrumbs: [ + { + label: 'Run', + args: ['jobs.run'], + }, + ], +}); From da1e179704b41cdfa9e969058e3faa3bff3bbb28 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 14 Aug 2018 17:29:51 -0700 Subject: [PATCH 05/19] Parse and Plan API and UI workflows --- ui/app/adapters/job.js | 20 +++++++ ui/app/controllers/jobs/run.js | 36 ++++++++++++ ui/app/models/job.js | 46 +++++++++++++++ ui/app/routes/jobs/run.js | 13 ++++ ui/app/styles/components/codemirror.scss | 2 +- ui/app/templates/jobs/run.hbs | 75 ++++++++++++++++-------- 6 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 ui/app/controllers/jobs/run.js diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index e130478155a..af8b05aafb5 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -59,6 +59,26 @@ export default Watchable.extend({ const url = this.urlForFindRecord(job.get('id'), 'job'); return this.ajax(url, 'DELETE'); }, + + parse(spec) { + const url = addToPath(this.urlForFindAll('job'), '/parse'); + return this.ajax(url, 'POST', { + data: { + JobHCL: spec, + Canonicalize: true, + }, + }); + }, + + plan(job) { + const url = addToPath(this.urlForFindRecord(job.get('id'), 'job'), '/plan'); + return this.ajax(url, 'POST', { + data: { + Job: job.get('_newDefinitionJSON'), + Diff: true, + }, + }); + }, }); function associateNamespace(url, namespace) { diff --git a/ui/app/controllers/jobs/run.js b/ui/app/controllers/jobs/run.js new file mode 100644 index 00000000000..916a0c8e292 --- /dev/null +++ b/ui/app/controllers/jobs/run.js @@ -0,0 +1,36 @@ +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; +import { task } from 'ember-concurrency'; + +export default Controller.extend({ + stage: computed('planOutput', function() { + return this.get('planOutput') ? 'plan' : 'editor'; + }), + + plan: task(function*() { + this.cancel(); + + try { + yield this.get('model').parse(); + } catch (err) { + this.set('parseError', err); + } + + try { + const planOutput = yield this.get('model').plan(); + console.log('Heyo!', planOutput); + this.set('planOutput', planOutput); + } catch (err) { + this.set('planError', err); + console.log('Uhoh', err); + } + }).drop(), + + submit: task(function*() {}), + + cancel() { + this.set('planOutput', null); + this.set('planError', null); + this.set('parseError', null); + }, +}); diff --git a/ui/app/models/job.js b/ui/app/models/job.js index b69040eaaa7..5de6977ae7f 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -4,6 +4,8 @@ import Model from 'ember-data/model'; import attr from 'ember-data/attr'; import { belongsTo, hasMany } from 'ember-data/relationships'; import { fragmentArray } from 'ember-data-model-fragments/attributes'; +import RSVP from 'rsvp'; +import { assert } from '@ember/debug'; const JOB_TYPES = ['service', 'batch', 'system']; @@ -191,6 +193,41 @@ export default Model.extend({ return this.store.adapterFor('job').stop(this); }, + plan() { + assert('A job must be parsed before planned', this.get('_newDefinitionJSON')); + return this.store.adapterFor('job').plan(this); + }, + + parse() { + const definition = this.get('_newDefinition'); + let promise; + + try { + // If the definition is already JSON then it doesn't need to be parsed. + const json = JSON.parse(definition); + this.set('_newDefinitionJSON', definition); + this.setIDByPayload(json); + promise = RSVP.Resolve(definition); + } catch (err) { + // If the definition is invalid JSON, assume it is HCL. If it is invalid + // in anyway, the parse endpoint will throw an error. + promise = this.store + .adapterFor('job') + .parse(this.get('_newDefinition')) + .then(response => { + this.set('_newDefinitionJSON', response); + this.setIDByPayload(response); + }); + } + + return promise; + }, + + setIDByPayload(payload) { + this.set('plainId', payload.Name); + this.set('id', JSON.stringify([payload.Name, payload.Namespace || 'default'])); + }, + statusClass: computed('status', function() { const classMap = { pending: 'is-pending', @@ -206,4 +243,13 @@ export default Model.extend({ // Lazily decode the base64 encoded payload return window.atob(this.get('payload') || ''); }), + + // An arbitrary HCL or JSON string that is used by the serializer to plan + // and run this job. Used for both new job models and saved job models. + _newDefinition: attr('string'), + + // The new definition may be HCL, in which case the API will need to parse the + // spec first. In order to preserve both the original HCL and the parsed response + // that will be submitted to the create job endpoint, another prop is necessary. + _newDefinitionJSON: attr('string'), }); diff --git a/ui/app/routes/jobs/run.js b/ui/app/routes/jobs/run.js index ac6c654b110..121e9b4ce9e 100644 --- a/ui/app/routes/jobs/run.js +++ b/ui/app/routes/jobs/run.js @@ -3,6 +3,7 @@ import { inject as service } from '@ember/service'; export default Route.extend({ store: service(), + system: service(), breadcrumbs: [ { @@ -10,4 +11,16 @@ export default Route.extend({ args: ['jobs.run'], }, ], + + model() { + return this.get('store').createRecord('job', { + namespace: this.get('system.activeNamespace'), + }); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.get('model').deleteRecord(); + } + }, }); diff --git a/ui/app/styles/components/codemirror.scss b/ui/app/styles/components/codemirror.scss index 16b54f23b4b..81c08356e67 100644 --- a/ui/app/styles/components/codemirror.scss +++ b/ui/app/styles/components/codemirror.scss @@ -43,7 +43,7 @@ $dark-bright: lighten($dark, 15%); } span.cm-comment { - color: $grey-light; + color: $grey; } span.cm-string, diff --git a/ui/app/templates/jobs/run.hbs b/ui/app/templates/jobs/run.hbs index d142248aa68..953ea855f97 100644 --- a/ui/app/templates/jobs/run.hbs +++ b/ui/app/templates/jobs/run.hbs @@ -1,31 +1,58 @@
-
-
-
-

Run a Job

-

Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.

+ {{#if (eq stage "editor")}} +
+
+
+

Run a Job

+

Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.

+
+
+ +
-
- +
+
+
+ Job Definition +
+
+ {{ivy-codemirror + value=(or model._newDefinition jobSpec) + valueUpdated=(action (mut model._newDefinition)) + options=(hash + mode="javascript" + theme="hashi" + tabSize=2 + lineNumbers=true + )}} +
+
+
+ +
+ {{/if}} + + {{#if (eq stage "plan")}} +
+
+
+

Job Plan

+

This is the impact running this job will have on your cluster.

+
+
+ +
-
-
-
- Job Definition +
+
Job Plan
+
+ {{job-diff diff=planOutput.Diff}} +
-
- {{ivy-codemirror - value=jobSpec - options=(hash - mode="javascript" - theme="hashi" - tabSize=2 - lineNumbers=true - )}} +
+ +
-
-
- -
+ {{/if}}
From 27f4a59259891a5c76e878b8e5e391a689595a28 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 14 Aug 2018 17:37:15 -0700 Subject: [PATCH 06/19] Fix no allocations error message layout for the recent allocations component --- .../templates/components/job-page/parts/recent-allocations.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/templates/components/job-page/parts/recent-allocations.hbs b/ui/app/templates/components/job-page/parts/recent-allocations.hbs index 4a0c22965b0..062f06cc2f6 100644 --- a/ui/app/templates/components/job-page/parts/recent-allocations.hbs +++ b/ui/app/templates/components/job-page/parts/recent-allocations.hbs @@ -2,7 +2,7 @@
Recent Allocations
-
+
{{#if job.allocations.length}} {{#list-table source=sortedAllocations From 21da150b9308dc5cdee1acf92c90edb2c2a2b76c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 14 Aug 2018 17:37:44 -0700 Subject: [PATCH 07/19] Remove unused solarized theme configuration --- ui/ember-cli-build.js | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/ember-cli-build.js b/ui/ember-cli-build.js index 44706930951..87dfc3f0f7e 100644 --- a/ui/ember-cli-build.js +++ b/ui/ember-cli-build.js @@ -13,7 +13,6 @@ module.exports = function(defaults) { paths: ['public/images/icons'], }, codemirror: { - themes: ['solarized'], modes: ['javascript'], }, funnel: { From f2128872cec003e1c111f29e4ea4e1f056b0bc9b Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 14 Aug 2018 18:26:26 -0700 Subject: [PATCH 08/19] Run job UI and API workflows --- ui/app/adapters/job.js | 10 ++++++++++ ui/app/controllers/jobs/run.js | 18 +++++++++++++++--- ui/app/models/job.js | 17 +++++++++++++++-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index af8b05aafb5..2999988fa1f 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -79,6 +79,16 @@ export default Watchable.extend({ }, }); }, + + // Running a job doesn't follow REST create semantics so it's easier to + // treat it as an action. + run(job) { + return this.ajax(this.urlForCreateRecord('job'), 'POST', { + data: { + Job: job.get('_newDefinitionJSON'), + }, + }); + }, }); function associateNamespace(url, namespace) { diff --git a/ui/app/controllers/jobs/run.js b/ui/app/controllers/jobs/run.js index 916a0c8e292..871f7ed4f0d 100644 --- a/ui/app/controllers/jobs/run.js +++ b/ui/app/controllers/jobs/run.js @@ -1,6 +1,8 @@ import Controller from '@ember/controller'; import { computed } from '@ember/object'; import { task } from 'ember-concurrency'; +import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; +import { next } from '@ember/runloop'; export default Controller.extend({ stage: computed('planOutput', function() { @@ -18,15 +20,25 @@ export default Controller.extend({ try { const planOutput = yield this.get('model').plan(); - console.log('Heyo!', planOutput); this.set('planOutput', planOutput); } catch (err) { this.set('planError', err); - console.log('Uhoh', err); } }).drop(), - submit: task(function*() {}), + submit: task(function*() { + try { + yield this.get('model').run(); + const id = this.get('model.plainId'); + const namespace = this.get('model.namespace.name') || 'default'; + // navigate to the new job page + this.transitionToRoute('jobs.job', id, { + queryParams: { jobNamespace: namespace }, + }); + } catch (err) { + this.set('runError', err); + } + }), cancel() { this.set('planOutput', null); diff --git a/ui/app/models/job.js b/ui/app/models/job.js index 5de6977ae7f..1ccbf305e6a 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -198,6 +198,11 @@ export default Model.extend({ return this.store.adapterFor('job').plan(this); }, + run() { + assert('A job must be parsed before ran', this.get('_newDefinitionJSON')); + return this.store.adapterFor('job').run(this); + }, + parse() { const definition = this.get('_newDefinition'); let promise; @@ -224,8 +229,16 @@ export default Model.extend({ }, setIDByPayload(payload) { - this.set('plainId', payload.Name); - this.set('id', JSON.stringify([payload.Name, payload.Namespace || 'default'])); + const namespace = payload.Namespace || 'default'; + const id = payload.Name; + + this.set('plainId', id); + this.set('id', JSON.stringify([id, namespace])); + + const namespaceRecord = this.store.peekRecord('namespace', namespace); + if (namespaceRecord) { + this.set('namespace', namespaceRecord); + } }, statusClass: computed('status', function() { From f29f4351f1ca7728ab3746e4487d520ddbb36004 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 15 Aug 2018 15:18:38 -0700 Subject: [PATCH 09/19] Error messages for job submit --- ui/app/controllers/jobs/run.js | 29 +++++++--- ui/app/templates/jobs/run.hbs | 61 +++++++++++++++------- ui/app/utils/message-from-adapter-error.js | 6 +++ ui/app/utils/properties/local-storage.js | 19 +++++++ 4 files changed, 89 insertions(+), 26 deletions(-) create mode 100644 ui/app/utils/message-from-adapter-error.js create mode 100644 ui/app/utils/properties/local-storage.js diff --git a/ui/app/controllers/jobs/run.js b/ui/app/controllers/jobs/run.js index 871f7ed4f0d..7e0d01f5e21 100644 --- a/ui/app/controllers/jobs/run.js +++ b/ui/app/controllers/jobs/run.js @@ -1,46 +1,61 @@ import Controller from '@ember/controller'; import { computed } from '@ember/object'; import { task } from 'ember-concurrency'; -import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; -import { next } from '@ember/runloop'; +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; +import localStorageProperty from 'nomad-ui/utils/properties/local-storage'; export default Controller.extend({ + parseError: null, + planError: null, + runError: null, + + showPlanMessage: localStorageProperty('nomadMessageJobPlan', true), + showEditorMessage: localStorageProperty('nomadMessageJobEditor', true), + stage: computed('planOutput', function() { return this.get('planOutput') ? 'plan' : 'editor'; }), plan: task(function*() { - this.cancel(); + this.reset(); try { yield this.get('model').parse(); } catch (err) { - this.set('parseError', err); + const error = messageFromAdapterError(err) || 'Could not parse input'; + this.set('parseError', error); + return; } try { const planOutput = yield this.get('model').plan(); this.set('planOutput', planOutput); } catch (err) { - this.set('planError', err); + const error = messageFromAdapterError(err) || 'Could not plan job'; + this.set('planError', error); } }).drop(), submit: task(function*() { try { yield this.get('model').run(); + const id = this.get('model.plainId'); const namespace = this.get('model.namespace.name') || 'default'; + + this.reset(); + // navigate to the new job page this.transitionToRoute('jobs.job', id, { queryParams: { jobNamespace: namespace }, }); } catch (err) { - this.set('runError', err); + const error = messageFromAdapterError(err) || 'Could not submit job'; + this.set('runError', error); } }), - cancel() { + reset() { this.set('planOutput', null); this.set('planError', null); this.set('parseError', null); diff --git a/ui/app/templates/jobs/run.hbs b/ui/app/templates/jobs/run.hbs index 953ea855f97..d27ee5f07b7 100644 --- a/ui/app/templates/jobs/run.hbs +++ b/ui/app/templates/jobs/run.hbs @@ -1,16 +1,37 @@
+ {{#if parseError}} +
+

Parse Error

+

{{parseError}}

+
+ {{/if}} + {{#if planError}} +
+

Plan Error

+

{{planError}}

+
+ {{/if}} + {{#if runError}} +
+

Run Error

+

{{runError}}

+
+ {{/if}} + {{#if (eq stage "editor")}} -
-
-
-

Run a Job

-

Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.

-
-
- + {{#if showEditorMessage}} +
+
+
+

Run a Job

+

Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.

+
+
+ +
-
+ {{/if}}
Job Definition @@ -33,17 +54,19 @@ {{/if}} {{#if (eq stage "plan")}} -
-
-
-

Job Plan

-

This is the impact running this job will have on your cluster.

-
-
- + {{#if showPlanMessage}} +
+
+
+

Job Plan

+

This is the impact running this job will have on your cluster.

+
+
+ +
-
+ {{/if}}
Job Plan
@@ -52,7 +75,7 @@
- +
{{/if}}
diff --git a/ui/app/utils/message-from-adapter-error.js b/ui/app/utils/message-from-adapter-error.js new file mode 100644 index 00000000000..2b1ce864bac --- /dev/null +++ b/ui/app/utils/message-from-adapter-error.js @@ -0,0 +1,6 @@ +// Returns a single string based on the response the adapter received +export default function messageFromAdapterError(error) { + if (error.errors) { + return error.errors.mapBy('detail').join('\n\n'); + } +} diff --git a/ui/app/utils/properties/local-storage.js b/ui/app/utils/properties/local-storage.js new file mode 100644 index 00000000000..5049ed27ce6 --- /dev/null +++ b/ui/app/utils/properties/local-storage.js @@ -0,0 +1,19 @@ +import { computed } from '@ember/object'; + +// An Ember.Computed property that persists set values in localStorage +// and will attempt to get its initial value from localStorage before +// falling back to a default. +// +// ex. showTutorial: localStorageProperty('nomadTutorial', true), +export default function localStorageProperty(localStorageKey, defaultValue) { + return computed({ + get() { + const persistedValue = window.localStorage.getItem(localStorageKey); + return persistedValue ? JSON.parse(persistedValue) : defaultValue; + }, + set(key, value) { + window.localStorage.setItem(localStorageKey, JSON.stringify(value)); + return value; + }, + }); +} From a9707411980851035fe4083f32057fe42315fbec Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 15 Aug 2018 16:58:54 -0700 Subject: [PATCH 10/19] Move the Diff property read out of the template --- ui/app/controllers/jobs/run.js | 4 +++- ui/app/templates/jobs/run.hbs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/app/controllers/jobs/run.js b/ui/app/controllers/jobs/run.js index 7e0d01f5e21..2c1eca1fec7 100644 --- a/ui/app/controllers/jobs/run.js +++ b/ui/app/controllers/jobs/run.js @@ -9,6 +9,8 @@ export default Controller.extend({ planError: null, runError: null, + planOutput: null, + showPlanMessage: localStorageProperty('nomadMessageJobPlan', true), showEditorMessage: localStorageProperty('nomadMessageJobEditor', true), @@ -29,7 +31,7 @@ export default Controller.extend({ try { const planOutput = yield this.get('model').plan(); - this.set('planOutput', planOutput); + this.set('planOutput', planOutput.Diff); } catch (err) { const error = messageFromAdapterError(err) || 'Could not plan job'; this.set('planError', error); diff --git a/ui/app/templates/jobs/run.hbs b/ui/app/templates/jobs/run.hbs index d27ee5f07b7..2d37df19c47 100644 --- a/ui/app/templates/jobs/run.hbs +++ b/ui/app/templates/jobs/run.hbs @@ -70,7 +70,7 @@
Job Plan
- {{job-diff diff=planOutput.Diff}} + {{job-diff diff=planOutput}}
From 09e6432e3811d8b742ee8f0c6122f00aab16182a Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 15 Aug 2018 16:59:42 -0700 Subject: [PATCH 11/19] Support parse, plan, and run endpoints in mirage --- ui/mirage/config.js | 37 ++++++++++++++++++++++++++++-- ui/mirage/factories/job-version.js | 6 ++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 0311a8d94eb..ad236ae2fc2 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -2,6 +2,7 @@ import Ember from 'ember'; import Response from 'ember-cli-mirage/response'; import { HOSTS } from './common'; import { logFrames, logEncode } from './data/logs'; +import { generateDiff } from './factories/job-version'; const { copy } = Ember; @@ -55,6 +56,33 @@ export default function() { }) ); + this.post('/jobs', function({ jobs }, 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.post('/jobs/parse', function({ jobs }, req) { + const body = JSON.parse(req.requestBody); + + if (!body.JobHCL) + return new Response(400, {}, 'JobHCL is a required field on the request payload'); + if (!body.Canonicalize) return new Response(400, {}, 'Expected Canonicalize to be true'); + + return new Response(200, {}, this.serialize(jobs.first())); + }); + + this.post('/job/:id/plan', function({ jobs }, req) { + const body = JSON.parse(req.requestBody); + + if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); + if (!body.Diff) return new Response(400, {}, 'Expected Diff to be true'); + + return new Response(200, {}, JSON.stringify({ Diff: generateDiff(req.params.id) })); + }); + this.get( '/job/:id', withBlockingSupport(function({ jobs }, { params, queryParams }) { @@ -107,8 +135,7 @@ export default function() { createAllocations: parent.createAllocations, }); - // Return bogus, since the response is normally just eval information - return new Response(200, {}, '{}'); + return okEmpty(); }); this.delete('/job/:id', function(schema, { params }) { @@ -276,3 +303,9 @@ function filterKeys(object, ...keys) { return clone; } + +// An empty response but not a 204 No Content. This is still a valid JSON +// response that represents a payload with no worthwhile data. +function okEmpty() { + return new Response(200, {}, '{}'); +} diff --git a/ui/mirage/factories/job-version.js b/ui/mirage/factories/job-version.js index 3e1db492da4..fe3404450e0 100644 --- a/ui/mirage/factories/job-version.js +++ b/ui/mirage/factories/job-version.js @@ -6,7 +6,7 @@ export default Factory.extend({ stable: faker.random.boolean, submitTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000, diff() { - return generateDiff(this); + return generateDiff(this.jobId); }, jobId: null, @@ -39,10 +39,10 @@ export default Factory.extend({ }, }); -function generateDiff(version) { +export function generateDiff(id) { return { Fields: null, - ID: version.jobId, + ID: id, Objects: null, TaskGroups: [ { From 4b12c069f6227dc0c2ad9906fac73ddf5b6c291b Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 15 Aug 2018 17:00:08 -0700 Subject: [PATCH 12/19] Use the job name as the job id This has bit me more than once. It's best just to make Mirage consistent with the API even if it currently means indeterminate job ids --- ui/mirage/factories/job.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js index e6380106e7f..096c6622884 100644 --- a/ui/mirage/factories/job.js +++ b/ui/mirage/factories/job.js @@ -8,8 +8,12 @@ const JOB_TYPES = ['service', 'batch', 'system']; const JOB_STATUSES = ['pending', 'running', 'dead']; export default Factory.extend({ - id: i => `job-${i}`, - name: i => `${faker.list.random(...JOB_PREFIXES)()}-${faker.hacker.noun().dasherize()}-${i}`, + id: i => + `${faker.list.random(...JOB_PREFIXES)()}-${faker.hacker.noun().dasherize()}-${i}`.toLowerCase(), + + name() { + return this.id; + }, groupsCount: () => faker.random.number({ min: 1, max: 5 }), From 3b8b894ffd1fc31946fcbf51d224adac13d57b3d Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 15 Aug 2018 17:12:18 -0700 Subject: [PATCH 13/19] Acceptance test for the jobs list page --- ui/app/templates/jobs/index.hbs | 2 +- ui/tests/acceptance/jobs-list-test.js | 12 ++++++++++++ ui/tests/pages/jobs/list.js | 2 ++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index 8b792046d33..10a04f4225e 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -9,7 +9,7 @@
{{/if}}
- {{#link-to "jobs.run" class="button is-primary is-pulled-right"}}Run Job{{/link-to}} + {{#link-to "jobs.run" data-test-run-job class="button is-primary is-pulled-right"}}Run Job{{/link-to}}
{{#list-pagination diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index 0437a7d958c..69c9eed7134 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -69,6 +69,18 @@ test('each job row should link to the corresponding job', function(assert) { }); }); +test('the new job button transitions to the new job page', function(assert) { + JobsList.visit(); + + andThen(() => { + JobsList.runJob(); + }); + + andThen(() => { + assert.equal(currentURL(), '/jobs/run'); + }); +}); + test('when there are no jobs, there is an empty message', function(assert) { JobsList.visit(); diff --git a/ui/tests/pages/jobs/list.js b/ui/tests/pages/jobs/list.js index cd59ac7ef67..252b92efc12 100644 --- a/ui/tests/pages/jobs/list.js +++ b/ui/tests/pages/jobs/list.js @@ -16,6 +16,8 @@ export default create({ search: fillable('[data-test-jobs-search] input'), + runJob: clickable('[data-test-run-job]'), + jobs: collection('[data-test-job-row]', { id: attribute('data-test-job-row'), name: text('[data-test-job-name]'), From 875ba9971e11790e0f1b2ad41a63b33ac94047d9 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 16 Aug 2018 10:56:37 -0700 Subject: [PATCH 14/19] New test helper for getting the underlying CodeMirror instance from a dom selector --- ui/tests/.eslintrc.js | 1 + ui/tests/helpers/codemirror.js | 19 +++++++++++++++++++ ui/tests/helpers/start-app.js | 2 ++ 3 files changed, 22 insertions(+) create mode 100644 ui/tests/helpers/codemirror.js diff --git a/ui/tests/.eslintrc.js b/ui/tests/.eslintrc.js index 7cf1e81db44..0d333ae8eb4 100644 --- a/ui/tests/.eslintrc.js +++ b/ui/tests/.eslintrc.js @@ -5,6 +5,7 @@ module.exports = { selectSearch: true, removeMultipleOption: true, clearSelected: true, + getCodeMirrorInstance: true, }, env: { embertest: true, diff --git a/ui/tests/helpers/codemirror.js b/ui/tests/helpers/codemirror.js new file mode 100644 index 00000000000..87db0c8142d --- /dev/null +++ b/ui/tests/helpers/codemirror.js @@ -0,0 +1,19 @@ +import { registerHelper } from '@ember/test'; + +const invariant = (truthy, error) => { + if (!truthy) throw new Error(error); +}; + +export default function registerCodeMirrorHelpers() { + registerHelper('getCodeMirrorInstance', function(app, selector) { + const cmService = app.__container__.lookup('service:code-mirror'); + + const element = document.querySelector(selector); + invariant(element, `Selector ${selector} matched no elements`); + + const cm = cmService.instanceFor(element.id); + invariant(cm, `No registered CodeMirror instance for ${selector}`); + + return cm; + }); +} diff --git a/ui/tests/helpers/start-app.js b/ui/tests/helpers/start-app.js index 304c6e3773a..496f7190a8e 100644 --- a/ui/tests/helpers/start-app.js +++ b/ui/tests/helpers/start-app.js @@ -3,8 +3,10 @@ import { merge } from '@ember/polyfills'; import Application from '../../app'; import config from '../../config/environment'; import registerPowerSelectHelpers from 'ember-power-select/test-support/helpers'; +import registerCodeMirrorHelpers from 'nomad-ui/tests/helpers/codemirror'; registerPowerSelectHelpers(); +registerCodeMirrorHelpers(); export default function startApp(attrs) { let attributes = merge({}, config.APP); From 3b5d96b234809e9ebe83ab5804c797d799a84ba1 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 16 Aug 2018 10:57:13 -0700 Subject: [PATCH 15/19] New PageObject helper for getting and setting CodeMirror values --- ui/tests/pages/helpers/codemirror.js | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 ui/tests/pages/helpers/codemirror.js diff --git a/ui/tests/pages/helpers/codemirror.js b/ui/tests/pages/helpers/codemirror.js new file mode 100644 index 00000000000..8a64b6e5bca --- /dev/null +++ b/ui/tests/pages/helpers/codemirror.js @@ -0,0 +1,32 @@ +// Like fillable, but for the CodeMirror editor +// +// Usage: fillIn: codeFillable('[data-test-editor]') +// Page.fillIn(code); +export function codeFillable(selector) { + return { + isDescriptor: true, + + get() { + return function(code) { + const cm = getCodeMirrorInstance(selector); + cm.setValue(code); + return this; + }; + }, + }; +} + +// Like text, but for the CodeMirror editor +// +// Usage: content: code('[data-test-editor]') +// Page.code(); // some = [ 'string', 'of', 'code' ] +export function code(selector) { + return { + isDescriptor: true, + + get() { + const cm = getCodeMirrorInstance(selector); + return cm.getValue(); + }, + }; +} From c6fa7575d10b4578b61cd6e824bdc8257a1993ba Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 16 Aug 2018 10:57:56 -0700 Subject: [PATCH 16/19] New Page Object component for common error formatting --- ui/tests/pages/components/error.js | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 ui/tests/pages/components/error.js diff --git a/ui/tests/pages/components/error.js b/ui/tests/pages/components/error.js new file mode 100644 index 00000000000..066ef08fc8e --- /dev/null +++ b/ui/tests/pages/components/error.js @@ -0,0 +1,11 @@ +import { clickable, isPresent, text } from 'ember-cli-page-object'; + +export default function(selectorBase = 'data-test-error') { + return { + scope: `[${selectorBase}]`, + isPresent: isPresent(), + title: text(`[${selectorBase}-title]`), + message: text(`[${selectorBase}-message]`), + seekHelp: clickable(`[${selectorBase}-message] a`), + }; +} From 635411f7502878865561597dca3154dd6c6a4def Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 16 Aug 2018 17:21:44 -0700 Subject: [PATCH 17/19] Rework job parse mirage request to get the job ID out of the payload --- ui/mirage/config.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/mirage/config.js b/ui/mirage/config.js index ad236ae2fc2..21e4c07e6f0 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -71,7 +71,17 @@ export default function() { return new Response(400, {}, 'JobHCL is a required field on the request payload'); if (!body.Canonicalize) return new Response(400, {}, 'Expected Canonicalize to be true'); - return new Response(200, {}, this.serialize(jobs.first())); + // Parse the name out of the first real line of HCL to match IDs in the new job record + // Regex expectation: + // in: job "job-name" { + // out: job-name + const nameFromHCLBlock = /.+?"(.+?)"/; + const jobName = body.JobHCL.trim() + .split('\n')[0] + .match(nameFromHCLBlock)[1]; + + const job = server.create('job', { id: jobName }); + return new Response(200, {}, this.serialize(job)); }); this.post('/job/:id/plan', function({ jobs }, req) { From a5d6790eba5555730f17e27fb8e62e3226d193af Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 16 Aug 2018 17:22:58 -0700 Subject: [PATCH 18/19] Acceptance tests for job run page --- ui/app/models/job.js | 2 +- ui/app/templates/jobs/run.hbs | 33 +-- ui/tests/acceptance/job-run-test.js | 315 ++++++++++++++++++++++++++++ ui/tests/pages/jobs/run.js | 38 ++++ 4 files changed, 371 insertions(+), 17 deletions(-) create mode 100644 ui/tests/acceptance/job-run-test.js create mode 100644 ui/tests/pages/jobs/run.js diff --git a/ui/app/models/job.js b/ui/app/models/job.js index 1ccbf305e6a..6bb5959c055 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -212,7 +212,7 @@ export default Model.extend({ const json = JSON.parse(definition); this.set('_newDefinitionJSON', definition); this.setIDByPayload(json); - promise = RSVP.Resolve(definition); + promise = RSVP.resolve(definition); } catch (err) { // If the definition is invalid JSON, assume it is HCL. If it is invalid // in anyway, the parse endpoint will throw an error. diff --git a/ui/app/templates/jobs/run.hbs b/ui/app/templates/jobs/run.hbs index 2d37df19c47..172487f3746 100644 --- a/ui/app/templates/jobs/run.hbs +++ b/ui/app/templates/jobs/run.hbs @@ -1,20 +1,20 @@
{{#if parseError}}
-

Parse Error

-

{{parseError}}

+

Parse Error

+

{{parseError}}

{{/if}} {{#if planError}}
-

Plan Error

-

{{planError}}

+

Plan Error

+

{{planError}}

{{/if}} {{#if runError}}
-

Run Error

-

{{runError}}

+

Run Error

+

{{runError}}

{{/if}} @@ -23,11 +23,11 @@
-

Run a Job

-

Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.

+

Run a Job

+

Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.

- +
@@ -38,6 +38,7 @@
{{ivy-codemirror + data-test-editor value=(or model._newDefinition jobSpec) valueUpdated=(action (mut model._newDefinition)) options=(hash @@ -49,7 +50,7 @@
- +
{{/if}} @@ -58,11 +59,11 @@
-

Job Plan

-

This is the impact running this job will have on your cluster.

+

Job Plan

+

This is the impact running this job will have on your cluster.

- +
@@ -70,12 +71,12 @@
Job Plan
- {{job-diff diff=planOutput}} + {{job-diff data-test-plan-output diff=planOutput}}
- - + +
{{/if}} diff --git a/ui/tests/acceptance/job-run-test.js b/ui/tests/acceptance/job-run-test.js new file mode 100644 index 00000000000..e168f8ec1c2 --- /dev/null +++ b/ui/tests/acceptance/job-run-test.js @@ -0,0 +1,315 @@ +import { assign } from '@ember/polyfills'; +import { currentURL } from 'ember-native-dom-helpers'; +import { test } from 'qunit'; +import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance'; +import JobRun from 'nomad-ui/tests/pages/jobs/run'; + +const newJobName = 'new-job'; + +const jsonJob = overrides => { + return JSON.stringify( + assign( + {}, + { + Name: newJobName, + Namespace: 'default', + Datacenters: ['dc1'], + Priority: 50, + TaskGroups: { + redis: { + Tasks: { + redis: { + Driver: 'docker', + }, + }, + }, + }, + }, + overrides + ), + null, + 2 + ); +}; + +const hclJob = () => ` +job "${newJobName}" { + namespace = "default" + datacenters = ["dc1"] + + task "redis" { + driver = "docker" + } +} +`; + +moduleForAcceptance('Acceptance | job run', { + beforeEach() { + // Required for placing allocations (a result of creating jobs) + server.create('node'); + }, +}); + +test('visiting /jobs/run', function(assert) { + JobRun.visit(); + + andThen(() => { + assert.equal(currentURL(), '/jobs/run'); + }); +}); + +test('the page has an editor and an explanation popup', function(assert) { + JobRun.visit(); + + andThen(() => { + assert.ok(JobRun.editorHelp.isPresent, 'Editor explanation popup is present'); + assert.ok(JobRun.editor.isPresent, 'Editor is present'); + }); +}); + +test('the explanation popup can be dismissed', function(assert) { + JobRun.visit(); + + andThen(() => { + JobRun.editorHelp.dismiss(); + }); + + andThen(() => { + assert.notOk(JobRun.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'; + + JobRun.visit(); + + andThen(() => { + assert.notOk(JobRun.editorHelp.isPresent, 'Editor explanation popup is gone'); + }); +}); + +test('submitting a json job skips the parse endpoint', function(assert) { + const spec = jsonJob(); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.fillIn(spec); + JobRun.plan(); + }); + + andThen(() => { + const requests = 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(); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.fillIn(spec); + JobRun.plan(); + }); + + andThen(() => { + const requests = 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(); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.fillIn(spec); + JobRun.plan(); + }); + + andThen(() => { + assert.ok(JobRun.planOutput, 'The plan is outputted'); + assert.notOk(JobRun.editor.isPresent, 'The editor is replaced with the plan output'); + assert.ok(JobRun.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(); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.fillIn(spec); + JobRun.plan(); + }); + + andThen(() => { + JobRun.cancel(); + }); + + andThen(() => { + assert.ok(JobRun.editor.isPresent, 'The editor is shown again'); + assert.notOk(JobRun.planOutpu, 'The plan is gone'); + assert.equal(JobRun.editor.contents, spec, 'The spec that was planned is still in the editor'); + }); +}); + +test('from the plan screen, the submit button submits the job and redirects to the job overview page', function(assert) { + const spec = hclJob(); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.fillIn(spec); + JobRun.plan(); + }); + + andThen(() => { + JobRun.run(); + }); + + andThen(() => { + assert.equal( + currentURL(), + `/jobs/${newJobName}`, + `Redirected to the job overview page for ${newJobName}` + ); + + const runRequest = server.pretender.handledRequests.find( + req => req.method === 'POST' && req.url === '/v1/jobs' + ); + const planRequest = server.pretender.handledRequests.find( + req => req.method === 'POST' && req.url === '/v1/jobs/parse' + ); + + assert.ok(runRequest, 'A POST request was made to run the new job'); + assert.deepEqual( + JSON.parse(runRequest.requestBody).Job, + JSON.parse(planRequest.responseText), + 'The Job payload parameter is equivalent to the result of the parse request' + ); + }); +}); + +test('when parse fails, the parse error message is shown', function(assert) { + const spec = hclJob(); + + const errorMessage = 'Parse Failed!! :o'; + server.pretender.post('/v1/jobs/parse', () => [400, {}, errorMessage]); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.fillIn(spec); + JobRun.plan(); + }); + + andThen(() => { + assert.notOk(JobRun.planError.isPresent, 'Plan error is not shown'); + assert.notOk(JobRun.runError.isPresent, 'Run error is not shown'); + + assert.ok(JobRun.parseError.isPresent, 'Parse error is shown'); + assert.equal( + JobRun.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 = 'Parse Failed!! :o'; + server.pretender.post(`/v1/job/${newJobName}/plan`, () => [400, {}, errorMessage]); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.fillIn(spec); + JobRun.plan(); + }); + + andThen(() => { + assert.notOk(JobRun.parseError.isPresent, 'Parse error is not shown'); + assert.notOk(JobRun.runError.isPresent, 'Run error is not shown'); + + assert.ok(JobRun.planError.isPresent, 'Plan error is shown'); + assert.equal( + JobRun.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 = 'Parse Failed!! :o'; + server.pretender.post('/v1/jobs', () => [400, {}, errorMessage]); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.fillIn(spec); + JobRun.plan(); + }); + + andThen(() => { + JobRun.run(); + }); + + andThen(() => { + assert.notOk(JobRun.planError.isPresent, 'Plan error is not shown'); + assert.notOk(JobRun.parseError.isPresent, 'Parse error is not shown'); + + assert.ok(JobRun.runError.isPresent, 'Run error is shown'); + assert.equal( + JobRun.runError.message, + errorMessage, + 'The error message from the server is shown in the error in the UI' + ); + }); +}); + +test('when submitting a job to a different namespace, the redirect to the job overview page takes namespace into account', function(assert) { + const newNamespace = 'second-namespace'; + + server.create('namespace', { id: newNamespace }); + const spec = jsonJob({ Namespace: newNamespace }); + + JobRun.visit(); + + andThen(() => { + JobRun.editor.fillIn(spec); + JobRun.plan(); + }); + + andThen(() => { + JobRun.run(); + }); + andThen(() => { + assert.equal( + currentURL(), + `/jobs/${newJobName}?namespace=${newNamespace}`, + `Redirected to the job overview page for ${newJobName} and switched the namespace to ${newNamespace}` + ); + }); +}); diff --git a/ui/tests/pages/jobs/run.js b/ui/tests/pages/jobs/run.js new file mode 100644 index 00000000000..9192b03c108 --- /dev/null +++ b/ui/tests/pages/jobs/run.js @@ -0,0 +1,38 @@ +import { clickable, create, isPresent, text, visitable } 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 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]'), + }, +}); From da8a6e4f2b2a71fc0badddce957dafe4eae880c1 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 17 Aug 2018 18:20:15 -0700 Subject: [PATCH 19/19] Spiff up the form buttons with type and disabled attributes --- ui/app/templates/jobs/run.hbs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/app/templates/jobs/run.hbs b/ui/app/templates/jobs/run.hbs index 172487f3746..e6bd40573c0 100644 --- a/ui/app/templates/jobs/run.hbs +++ b/ui/app/templates/jobs/run.hbs @@ -50,7 +50,7 @@
- +
{{/if}} @@ -75,8 +75,8 @@
- - + +
{{/if}}