Merge pull request #4612 from hashicorp/f-ui-job-edit

UI: Edit a job
This commit is contained in:
Michael Lange 2018-08-28 11:26:16 -07:00 committed by GitHub
commit e9454e1b05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 954 additions and 520 deletions

View File

@ -73,32 +73,35 @@ export default RESTAdapter.extend({
//
// This is the original implementation of _buildURL
// without the pluralization of modelName
urlForFindRecord(id, modelName) {
let path;
let url = [];
let host = get(this, 'host');
let prefix = this.urlPrefix();
if (modelName) {
path = modelName.camelize();
if (path) {
url.push(path);
}
}
if (id) {
url.push(encodeURIComponent(id));
}
if (prefix) {
url.unshift(prefix);
}
url = url.join('/');
if (!host && url && url.charAt(0) !== '/') {
url = '/' + url;
}
return url;
},
urlForFindRecord: urlForRecord,
urlForUpdateRecord: urlForRecord,
});
function urlForRecord(id, modelName) {
let path;
let url = [];
let host = get(this, 'host');
let prefix = this.urlPrefix();
if (modelName) {
path = modelName.camelize();
if (path) {
url.push(path);
}
}
if (id) {
url.push(encodeURIComponent(id));
}
if (prefix) {
url.unshift(prefix);
}
url = url.join('/');
if (!host && url && url.charAt(0) !== '/') {
url = '/' + url;
}
return url;
}

View File

@ -33,6 +33,12 @@ export default Watchable.extend({
return associateNamespace(url, namespace);
},
urlForUpdateRecord(id, type, hash) {
const [name, namespace] = JSON.parse(id);
let url = this._super(name, type, hash);
return associateNamespace(url, namespace);
},
xhrKey(url, method, options = {}) {
const plainKey = this._super(...arguments);
const namespace = options.data && options.data.namespace;
@ -71,15 +77,19 @@ export default Watchable.extend({
},
plan(job) {
const url = addToPath(this.urlForFindRecord(job.get('id'), 'job'), '/plan');
const jobId = job.get('id');
const store = this.get('store');
const url = addToPath(this.urlForFindRecord(jobId, 'job'), '/plan');
return this.ajax(url, 'POST', {
data: {
Job: job.get('_newDefinitionJSON'),
Diff: true,
},
}).then(json => {
json.ID = job.get('id');
this.get('store').pushPayload('job-plan', { jobPlans: [json] });
json.ID = jobId;
store.pushPayload('job-plan', { jobPlans: [json] });
return store.peekRecord('job-plan', jobId);
});
},
@ -92,6 +102,14 @@ export default Watchable.extend({
},
});
},
update(job) {
return this.ajax(this.urlForUpdateRecord(job.get('id'), 'job'), 'POST', {
data: {
Job: job.get('_newDefinitionJSON'),
},
});
},
});
function associateNamespace(url, namespace) {

View File

@ -0,0 +1,102 @@
import Component from '@ember/component';
import { assert } from '@ember/debug';
import { inject as service } from '@ember/service';
import { computed } from '@ember/object';
import { task } from 'ember-concurrency';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
export default Component.extend({
store: service(),
config: service(),
'data-test-job-editor': true,
job: null,
onSubmit() {},
context: computed({
get() {
return this.get('_context');
},
set(key, value) {
const allowedValues = ['new', 'edit'];
assert(`context must be one of: ${allowedValues.join(', ')}`, allowedValues.includes(value));
this.set('_context', value);
return value;
},
}),
_context: null,
parseError: null,
planError: null,
runError: null,
planOutput: null,
showPlanMessage: localStorageProperty('nomadMessageJobPlan', true),
showEditorMessage: localStorageProperty('nomadMessageJobEditor', true),
stage: computed('planOutput', function() {
return this.get('planOutput') ? 'plan' : 'editor';
}),
plan: task(function*() {
this.reset();
try {
yield this.get('job').parse();
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not parse input';
this.set('parseError', error);
this.scrollToError();
return;
}
try {
const plan = yield this.get('job').plan();
this.set('planOutput', plan);
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not plan job';
this.set('planError', error);
this.scrollToError();
}
}).drop(),
submit: task(function*() {
try {
if (this.get('context') === 'new') {
yield this.get('job').run();
} else {
yield this.get('job').update();
}
const id = this.get('job.plainId');
const namespace = this.get('job.namespace.name') || 'default';
this.reset();
// Treat the job as ephemeral and only provide ID parts.
this.get('onSubmit')(id, namespace);
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not submit job';
this.set('runError', error);
this.set('planOutput', null);
this.scrollToError();
}
}),
reset() {
this.set('planOutput', null);
this.set('planError', null);
this.set('parseError', null);
this.set('runError', null);
},
scrollToError() {
if (!this.get('config.isTest')) {
window.scrollTo(0, 0);
}
},
});

View File

@ -4,4 +4,22 @@ import { alias } from '@ember/object/computed';
export default Controller.extend(WithNamespaceResetting, {
job: alias('model.job'),
definition: alias('model.definition'),
isEditing: false,
edit() {
this.get('job').set('_newDefinition', JSON.stringify(this.get('definition'), null, 2));
this.set('isEditing', true);
},
onCancel() {
this.set('isEditing', false);
},
onSubmit(id, namespace) {
this.transitionToRoute('jobs.job', id, {
queryParams: { jobNamespace: namespace },
});
},
});

View File

@ -1,69 +1,9 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { computed } from '@ember/object';
import { task } from 'ember-concurrency';
import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error';
import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
export default Controller.extend({
store: service(),
parseError: null,
planError: null,
runError: null,
planOutput: null,
showPlanMessage: localStorageProperty('nomadMessageJobPlan', true),
showEditorMessage: localStorageProperty('nomadMessageJobEditor', true),
stage: computed('planOutput', function() {
return this.get('planOutput') ? 'plan' : 'editor';
}),
plan: task(function*() {
this.reset();
try {
yield this.get('model').parse();
} catch (err) {
const error = messageFromAdapterError(err) || 'Could not parse input';
this.set('parseError', error);
return;
}
try {
yield this.get('model').plan();
const plan = this.get('store').peekRecord('job-plan', this.get('model.id'));
this.set('planOutput', plan);
} catch (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) {
const error = messageFromAdapterError(err) || 'Could not submit job';
this.set('runError', error);
}
}),
reset() {
this.set('planOutput', null);
this.set('planError', null);
this.set('parseError', null);
onSubmit(id, namespace) {
this.transitionToRoute('jobs.job', id, {
queryParams: { jobNamespace: namespace },
});
},
});

View File

@ -203,6 +203,11 @@ export default Model.extend({
return this.store.adapterFor('job').run(this);
},
update() {
assert('A job must be parsed before updated', this.get('_newDefinitionJSON'));
return this.store.adapterFor('job').update(this);
},
parse() {
const definition = this.get('_newDefinition');
let promise;
@ -211,7 +216,12 @@ export default Model.extend({
// If the definition is already JSON then it doesn't need to be parsed.
const json = JSON.parse(definition);
this.set('_newDefinitionJSON', json);
this.setIDByPayload(json);
// You can't set the ID of a record that already exists
if (this.get('isNew')) {
this.setIdByPayload(json);
}
promise = RSVP.resolve(definition);
} catch (err) {
// If the definition is invalid JSON, assume it is HCL. If it is invalid
@ -221,14 +231,14 @@ export default Model.extend({
.parse(this.get('_newDefinition'))
.then(response => {
this.set('_newDefinitionJSON', response);
this.setIDByPayload(response);
this.setIdByPayload(response);
});
}
return promise;
},
setIDByPayload(payload) {
setIdByPayload(payload) {
const namespace = payload.Namespace || 'default';
const id = payload.Name;
@ -241,6 +251,10 @@ export default Model.extend({
}
},
resetId() {
this.set('id', JSON.stringify([this.get('plainId'), this.get('namespace.name') || 'default']));
},
statusClass: computed('status', function() {
const classMap = {
pending: 'is-pending',

View File

@ -16,6 +16,7 @@ Router.map(function() {
this.route('deployments');
this.route('evaluations');
this.route('allocations');
this.route('edit');
});
});

View File

@ -8,4 +8,13 @@ export default Route.extend({
definition,
}));
},
resetController(controller, isExiting) {
if (isExiting) {
const job = controller.get('job');
job.rollbackAttributes();
job.resetId();
controller.set('isEditing', false);
}
},
});

View File

@ -18,6 +18,6 @@ export default Service.extend({
},
setIndexFor(url, value) {
list[url] = value;
list[url] = +value;
},
});

View File

@ -1,5 +1,5 @@
.page-layout {
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;

View File

@ -15,7 +15,7 @@
<div class="chart-tooltip {{if isActive "active" "inactive"}}" style={{tooltipStyle}}>
<ol>
{{#each _data as |datum index|}}
<li class="{{if (eq datum.index activeDatum.index) "active"}}">
<li class="{{if (eq datum.label activeDatum.label) "active"}}">
<span class="label {{if (eq datum.value 0) "is-empty"}}">
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
{{datum.label}}

Before

Width:  |  Height:  |  Size: 801 B

After

Width:  |  Height:  |  Size: 801 B

View File

@ -0,0 +1,95 @@
{{#if parseError}}
<div data-test-parse-error class="notification is-danger">
<h3 class="title is-4" data-test-parse-error-title>Parse Error</h3>
<p data-test-parse-error-message>{{parseError}}</p>
</div>
{{/if}}
{{#if planError}}
<div data-test-plan-error class="notification is-danger">
<h3 class="title is-4" data-test-plan-error-title>Plan Error</h3>
<p data-test-plan-error-message>{{planError}}</p>
</div>
{{/if}}
{{#if runError}}
<div data-test-run-error class="notification is-danger">
<h3 class="title is-4" data-test-run-error-title>Run Error</h3>
<p data-test-run-error-message>{{runError}}</p>
</div>
{{/if}}
{{#if (eq stage "editor")}}
{{#if (and showEditorMessage (eq context "new"))}}
<div class="notification is-info">
<div class="columns">
<div class="column">
<h3 class="title is-4" data-test-editor-help-title>Run a Job</h3>
<p data-test-editor-help-message>Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.</p>
</div>
<div class="column is-centered is-minimum">
<button class="button is-info" onclick={{toggle-action "showEditorMessage" this}} data-test-editor-help-dismiss>Okay</button>
</div>
</div>
</div>
{{/if}}
<div class="boxed-section">
<div class="boxed-section-head">
Job Definition
{{#if cancelable}}
<button class="button is-light is-compact pull-right" onclick={{action onCancel}} data-test-cancel-editing>Cancel</button>
{{/if}}
</div>
<div class="boxed-section-body is-full-bleed">
{{ivy-codemirror
data-test-editor
value=(or job._newDefinition jobSpec)
valueUpdated=(action (mut job._newDefinition))
options=(hash
mode="javascript"
theme="hashi"
tabSize=2
lineNumbers=true
)}}
</div>
</div>
<div class="content is-associative">
<button class="button is-primary {{if plan.isRunning "is-loading"}}" type="button" onclick={{perform plan}} disabled={{or plan.isRunning (not job._newDefinition)}} data-test-plan>Plan</button>
</div>
{{/if}}
{{#if (eq stage "plan")}}
{{#if showPlanMessage}}
<div class="notification is-info">
<div class="columns">
<div class="column">
<h3 class="title is-4" data-test-plan-help-title>Job Plan</h3>
<p data-test-plan-help-message>This is the impact running this job will have on your cluster.</p>
</div>
<div class="column is-centered is-minimum">
<button class="button is-info" onclick={{toggle-action "showPlanMessage" this}} data-test-plan-help-dismiss>Okay</button>
</div>
</div>
</div>
{{/if}}
<div class="boxed-section">
<div class="boxed-section-head">Job Plan</div>
<div class="boxed-section-body is-dark">
{{job-diff data-test-plan-output diff=planOutput.diff verbose=false}}
</div>
</div>
<div class="boxed-section {{if planOutput.failedTGAllocs "is-warning" "is-primary"}}" data-test-dry-run-message>
<div class="boxed-section-head" data-test-dry-run-title>Scheduler dry-run</div>
<div class="boxed-section-body" data-test-dry-run-body>
{{#if planOutput.failedTGAllocs}}
{{#each planOutput.failedTGAllocs as |placementFailure|}}
{{placement-failure failedTGAlloc=placementFailure}}
{{/each}}
{{else}}
All tasks successfully allocated.
{{/if}}
</div>
</div>
<div class="content is-associative">
<button class="button is-primary {{if submit.isRunning "is-loading"}}" type="button" onclick={{perform submit}} disabled={{submit.isRunning}} data-test-run>Run</button>
<button class="button is-light" type="button" onclick={{action reset}} data-test-cancel>Cancel</button>
</div>
{{/if}}

View File

@ -39,7 +39,7 @@
class="split-view" as |chart|}}
<ol data-test-legend class="legend">
{{#each chart.data as |datum index|}}
<li class="{{datum.className}} {{if (eq datum.index chart.activeDatum.index) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<li class="{{datum.className}} {{if (eq datum.label chart.activeDatum.label) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
<span class="value" data-test-legend-value="{{datum.className}}">{{datum.value}}</span>
<span class="label">

View File

@ -1,8 +1,21 @@
{{partial "jobs/job/subnav"}}
<section class="section">
<div class="boxed-section">
<div class="boxed-section-body is-full-bleed">
{{json-viewer data-test-definition-view json=model.definition}}
{{#unless isEditing}}
<div class="boxed-section">
<div class="boxed-section-head">
Job Definition
<button class="button is-light is-compact pull-right" type="button" onclick={{action edit}} data-test-edit-job>Edit</button>
</div>
<div class="boxed-section-body is-full-bleed">
{{json-viewer data-test-definition-view json=definition}}
</div>
</div>
</div>
{{else}}
{{job-editor
job=job
cancelable=true
context="edit"
onCancel=(action onCancel)
onSubmit=(action onSubmit)}}
{{/unless}}
</section>

View File

@ -27,7 +27,7 @@
{{#allocation-status-bar allocationContainer=model.summary class="split-view" as |chart|}}
<ol class="legend">
{{#each chart.data as |datum index|}}
<li class="{{datum.className}} {{if (eq datum.index chart.activeDatum.index) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<li class="{{datum.className}} {{if (eq datum.label chart.activeDatum.label) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
<span class="value">{{datum.value}}</span>
<span class="label">

View File

@ -1,94 +1,6 @@
<section class="section">
{{#if parseError}}
<div data-test-parse-error class="notification is-danger">
<h3 class="title is-4" data-test-parse-error-title>Parse Error</h3>
<p data-test-parse-error-message>{{parseError}}</p>
</div>
{{/if}}
{{#if planError}}
<div data-test-plan-error class="notification is-danger">
<h3 class="title is-4" data-test-plan-error-title>Plan Error</h3>
<p data-test-plan-error-message>{{planError}}</p>
</div>
{{/if}}
{{#if runError}}
<div data-test-run-error class="notification is-danger">
<h3 class="title is-4" data-test-run-error-title>Run Error</h3>
<p data-test-run-error-message>{{runError}}</p>
</div>
{{/if}}
{{#if (eq stage "editor")}}
{{#if showEditorMessage}}
<div class="notification is-info">
<div class="columns">
<div class="column">
<h3 class="title is-4" data-test-editor-help-title>Run a Job</h3>
<p data-test-editor-help-message>Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted.</p>
</div>
<div class="column is-centered is-minimum">
<button class="button is-info" onclick={{toggle-action "showEditorMessage" this}} data-test-editor-help-dismiss>Okay</button>
</div>
</div>
</div>
{{/if}}
<div class="boxed-section">
<div class="boxed-section-head">
Job Definition
</div>
<div class="boxed-section-body is-full-bleed">
{{ivy-codemirror
data-test-editor
value=(or model._newDefinition jobSpec)
valueUpdated=(action (mut model._newDefinition))
options=(hash
mode="javascript"
theme="hashi"
tabSize=2
lineNumbers=true
)}}
</div>
</div>
<div class="content is-associative">
<button class="button is-primary {{if plan.isRunning "is-loading"}}" type="button" onclick={{perform plan}} disabled={{or plan.isRunning (not model._newDefinition)}} data-test-plan>Plan</button>
</div>
{{/if}}
{{#if (eq stage "plan")}}
{{#if showPlanMessage}}
<div class="notification is-info">
<div class="columns">
<div class="column">
<h3 class="title is-4" data-test-plan-help-title>Job Plan</h3>
<p data-test-plan-help-message>This is the impact running this job will have on your cluster.</p>
</div>
<div class="column is-centered is-minimum">
<button class="button is-info" onclick={{toggle-action "showPlanMessage" this}} data-test-plan-help-dismiss>Okay</button>
</div>
</div>
</div>
{{/if}}
<div class="boxed-section">
<div class="boxed-section-head">Job Plan</div>
<div class="boxed-section-body is-dark">
{{job-diff data-test-plan-output diff=planOutput.diff verbose=false}}
</div>
</div>
<div class="boxed-section {{if planOutput.failedTGAllocs "is-warning" "is-primary"}}" data-test-dry-run-message>
<div class="boxed-section-head" data-test-dry-run-title>Scheduler dry-run</div>
<div class="boxed-section-body" data-test-dry-run-body>
{{#if planOutput.failedTGAllocs}}
{{#each planOutput.failedTGAllocs as |placementFailure|}}
{{placement-failure failedTGAlloc=placementFailure}}
{{/each}}
{{else}}
All tasks successfully allocated.
{{/if}}
</div>
</div>
<div class="content is-associative">
<button class="button is-primary {{if submit.isRunning "is-loading"}}" type="button" onclick={{perform submit}} disabled={{submit.isRunning}} data-test-run>Run</button>
<button class="button is-light" type="button" onclick={{action reset}} data-test-cancel>Cancel</button>
</div>
{{/if}}
{{job-editor
job=model
context="new"
onSubmit=(action onSubmit)}}
</section>

View File

@ -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 }) {

View File

@ -29,3 +29,56 @@ test('the job definition page requests the job to display in an unmutated form',
.filter(url => url === jobURL);
assert.ok(jobRequests.length === 2, 'Two requests for the job were made');
});
test('the job definition can be edited', function(assert) {
assert.notOk(Definition.editor.isPresent, 'Editor is not shown on load');
Definition.edit();
andThen(() => {
assert.ok(Definition.editor.isPresent, 'Editor is shown after clicking edit');
assert.notOk(Definition.jsonViewer, 'Editor replaces the JSON viewer');
});
});
test('when in editing mode, the action can be canceled, showing the read-only definition again', function(assert) {
Definition.edit();
andThen(() => {
Definition.editor.cancelEditing();
});
andThen(() => {
assert.ok(Definition.jsonViewer, 'The JSON Viewer is back');
assert.notOk(Definition.editor.isPresent, 'The editor is gone');
});
});
test('when in editing mode, the editor is prepopulated with the job definition', function(assert) {
const requests = server.pretender.handledRequests;
const jobDefinition = requests.findBy('url', `/v1/job/${job.id}`).responseText;
const formattedJobDefinition = JSON.stringify(JSON.parse(jobDefinition), null, 2);
Definition.edit();
andThen(() => {
assert.equal(
Definition.editor.editor.contents,
formattedJobDefinition,
'The editor already has the job definition in it'
);
});
});
test('when changes are submitted, the site redirects to the job overview page', function(assert) {
Definition.edit();
andThen(() => {
Definition.editor.plan();
Definition.editor.run();
});
andThen(() => {
assert.equal(currentURL(), `/jobs/${job.id}`, 'Now on the job overview page');
});
});

View File

@ -35,17 +35,6 @@ const jsonJob = overrides => {
);
};
const hclJob = () => `
job "${newJobName}" {
namespace = "default"
datacenters = ["dc1"]
task "${newJobTaskGroupName}" {
driver = "docker"
}
}
`;
moduleForAcceptance('Acceptance | job run', {
beforeEach() {
// Required for placing allocations (a result of creating jobs)
@ -61,234 +50,25 @@ test('visiting /jobs/run', function(assert) {
});
});
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) {
test('when submitting a job, the site redirects to the new job overview page', function(assert) {
const spec = jsonJob();
JobRun.visit();
andThen(() => {
JobRun.editor.fillIn(spec);
JobRun.plan();
JobRun.editor.editor.fillIn(spec);
JobRun.editor.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');
JobRun.editor.run();
});
});
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'
);
});
});
@ -301,12 +81,12 @@ test('when submitting a job to a different namespace, the redirect to the job ov
JobRun.visit();
andThen(() => {
JobRun.editor.fillIn(spec);
JobRun.plan();
JobRun.editor.editor.fillIn(spec);
JobRun.editor.plan();
});
andThen(() => {
JobRun.run();
JobRun.editor.run();
});
andThen(() => {
assert.equal(
@ -316,52 +96,3 @@ test('when submitting a job to a different namespace, the redirect to the job ov
);
});
});
test('when the scheduler dry-run has warnings, the warnings are shown to the user', function(assert) {
// Unschedulable is a hint to Mirage to respond with warnings from the plan endpoint
const spec = jsonJob({ Unschedulable: true });
JobRun.visit();
andThen(() => {
JobRun.editor.fillIn(spec);
JobRun.plan();
});
andThen(() => {
assert.ok(
JobRun.dryRunMessage.errored,
'The scheduler dry-run message is in the warning state'
);
assert.notOk(
JobRun.dryRunMessage.succeeded,
'The success message is not shown in addition to the warning message'
);
assert.ok(
JobRun.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();
JobRun.visit();
andThen(() => {
JobRun.editor.fillIn(spec);
JobRun.plan();
});
andThen(() => {
assert.ok(
JobRun.dryRunMessage.succeeded,
'The scheduler dry-run message is in the success state'
);
assert.notOk(
JobRun.dryRunMessage.errored,
'The warning message is not shown in addition to the success message'
);
});
});

View File

@ -4,9 +4,9 @@ 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');
export function getCodeMirrorInstance(container) {
return function(selector) {
const cmService = container.lookup('service:code-mirror');
const element = document.querySelector(selector);
invariant(element, `Selector ${selector} matched no elements`);
@ -15,5 +15,12 @@ export default function registerCodeMirrorHelpers() {
invariant(cm, `No registered CodeMirror instance for ${selector}`);
return cm;
};
}
export default function registerCodeMirrorHelpers() {
registerHelper('getCodeMirrorInstance', function(app, selector) {
const helper = getCodeMirrorInstance(app.__container__);
return helper(selector);
});
}

View File

@ -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');
});
});

View File

@ -0,0 +1,51 @@
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]',
isPresent: isPresent(),
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'),
},
});

View File

@ -1,7 +1,12 @@
import { create, isPresent, visitable } from 'ember-cli-page-object';
import { create, isPresent, visitable, clickable } from 'ember-cli-page-object';
import jobEditor from 'nomad-ui/tests/pages/components/job-editor';
export default create({
visit: visitable('/jobs/:id/definition'),
jsonViewer: isPresent('[data-test-definition-view]'),
editor: jobEditor(),
edit: clickable('[data-test-edit-job]'),
});

View File

@ -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(),
});