Merge pull request #5734 from hashicorp/f-ui/allocation-lifecycle

UI: Allocation lifecycle
This commit is contained in:
Michael Lange 2019-05-21 11:04:19 -07:00 committed by GitHub
commit dff3abb630
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 543 additions and 50 deletions

View file

@ -1,3 +1,21 @@
import Watchable from './watchable'; import Watchable from './watchable';
import addToPath from 'nomad-ui/utils/add-to-path';
export default Watchable.extend(); export default Watchable.extend({
stop: adapterAction('/stop'),
restart(allocation, taskName) {
const prefix = `${this.host || '/'}${this.urlPrefix()}`;
const url = `${prefix}/client/allocation/${allocation.id}/restart`;
return this.ajax(url, 'PUT', {
data: taskName && { TaskName: taskName },
});
},
});
function adapterAction(path, verb = 'POST') {
return function(allocation) {
const url = addToPath(this.urlForFindRecord(allocation.id, 'allocation'), path);
return this.ajax(url, verb);
};
}

View file

@ -1,5 +1,6 @@
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import Watchable from './watchable'; import Watchable from './watchable';
import addToPath from 'nomad-ui/utils/add-to-path';
export default Watchable.extend({ export default Watchable.extend({
system: service(), system: service(),
@ -118,14 +119,3 @@ function associateNamespace(url, namespace) {
} }
return url; return url;
} }
function addToPath(url, extension = '') {
const [path, params] = url.split('?');
let newUrl = `${path}${extension}`;
if (params) {
newUrl += `?${params}`;
}
return newUrl;
}

View file

@ -1,5 +1,7 @@
import Component from '@ember/component'; import Component from '@ember/component';
import { next } from '@ember/runloop';
import { equal } from '@ember/object/computed'; import { equal } from '@ember/object/computed';
import { task, waitForEvent } from 'ember-concurrency';
import RSVP from 'rsvp'; import RSVP from 'rsvp';
export default Component.extend({ export default Component.extend({
@ -10,6 +12,7 @@ export default Component.extend({
confirmText: '', confirmText: '',
confirmationMessage: '', confirmationMessage: '',
awaitingConfirmation: false, awaitingConfirmation: false,
disabled: false,
onConfirm() {}, onConfirm() {},
onCancel() {}, onCancel() {},
@ -17,12 +20,25 @@ export default Component.extend({
isIdle: equal('state', 'idle'), isIdle: equal('state', 'idle'),
isPendingConfirmation: equal('state', 'prompt'), isPendingConfirmation: equal('state', 'prompt'),
cancelOnClickOutside: task(function*() {
while (true) {
let ev = yield waitForEvent(document.body, 'click');
if (!this.element.contains(ev.target) && !this.awaitingConfirmation) {
this.send('setToIdle');
}
}
}),
actions: { actions: {
setToIdle() { setToIdle() {
this.set('state', 'idle'); this.set('state', 'idle');
this.cancelOnClickOutside.cancelAll();
}, },
promptForConfirmation() { promptForConfirmation() {
this.set('state', 'prompt'); this.set('state', 'prompt');
next(() => {
this.cancelOnClickOutside.perform();
});
}, },
confirm() { confirm() {
RSVP.resolve(this.onConfirm()).then(() => { RSVP.resolve(this.onConfirm()).then(() => {

View file

@ -1,8 +1,11 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { computed, observer } from '@ember/object';
import { alias } from '@ember/object/computed'; import { alias } from '@ember/object/computed';
import { task } from 'ember-concurrency';
import Sortable from 'nomad-ui/mixins/sortable'; import Sortable from 'nomad-ui/mixins/sortable';
import { lazyClick } from 'nomad-ui/helpers/lazy-click'; import { lazyClick } from 'nomad-ui/helpers/lazy-click';
import { watchRecord } from 'nomad-ui/utils/properties/watch';
export default Controller.extend(Sortable, { export default Controller.extend(Sortable, {
token: service(), token: service(),
@ -21,6 +24,50 @@ export default Controller.extend(Sortable, {
// Set in the route // Set in the route
preempter: null, preempter: null,
error: computed(() => {
// { title, description }
return null;
}),
onDismiss() {
this.set('error', null);
},
watchNext: watchRecord('allocation'),
observeWatchNext: observer('model.nextAllocation.clientStatus', function() {
const nextAllocation = this.model.nextAllocation;
if (nextAllocation && nextAllocation.content) {
this.watchNext.perform(nextAllocation);
} else {
this.watchNext.cancelAll();
}
}),
stopAllocation: task(function*() {
try {
yield this.model.stop();
// Eagerly update the allocation clientStatus to avoid flickering
this.model.set('clientStatus', 'complete');
} catch (err) {
this.set('error', {
title: 'Could Not Stop Allocation',
description: 'Your ACL token does not grant allocation lifecycle permissions.',
});
}
}),
restartAllocation: task(function*() {
try {
yield this.model.restart();
} catch (err) {
this.set('error', {
title: 'Could Not Restart Allocation',
description: 'Your ACL token does not grant allocation lifecycle permissions.',
});
}
}),
actions: { actions: {
gotoTask(allocation, task) { gotoTask(allocation, task) {
this.transitionToRoute('allocations.allocation.task', task); this.transitionToRoute('allocations.allocation.task', task);

View file

@ -1,6 +1,7 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import { alias } from '@ember/object/computed'; import { alias } from '@ember/object/computed';
import { task } from 'ember-concurrency';
export default Controller.extend({ export default Controller.extend({
network: alias('model.resources.networks.firstObject'), network: alias('model.resources.networks.firstObject'),
@ -20,4 +21,24 @@ export default Controller.extend({
) )
.sortBy('name'); .sortBy('name');
}), }),
error: computed(() => {
// { title, description }
return null;
}),
onDismiss() {
this.set('error', null);
},
restartTask: task(function*() {
try {
yield this.model.restart();
} catch (err) {
this.set('error', {
title: 'Could Not Restart Task',
description: 'Your ACL token does not grant allocation lifecycle permissions.',
});
}
}),
}); });

View file

@ -31,8 +31,11 @@ export default Mixin.create(WithVisibilityDetection, {
}, },
actions: { actions: {
willTransition() { willTransition(transition) {
// Don't cancel watchers if transitioning into a sub-route
if (!transition.intent.name || !transition.intent.name.startsWith(this.routeName)) {
this.cancelAllWatchers(); this.cancelAllWatchers();
}
// Bubble the action up to the application route // Bubble the action up to the application route
return true; return true;

View file

@ -99,4 +99,12 @@ export default Model.extend({
); );
} }
), ),
stop() {
return this.store.adapterFor('allocation').stop(this);
},
restart(taskName) {
return this.store.adapterFor('allocation').restart(this, taskName);
},
}); });

View file

@ -43,4 +43,8 @@ export default Fragment.extend({
return classMap[this.state] || 'is-dark'; return classMap[this.state] || 'is-dark';
}), }),
restart() {
return this.allocation.restart(this.name);
},
}); });

View file

@ -10,4 +10,10 @@ export default Route.extend({
return this._super(...arguments); return this._super(...arguments);
}, },
resetController(controller, isExiting) {
if (isExiting) {
controller.watchNext.cancelAll();
}
},
}); });

View file

@ -4,4 +4,8 @@
&.is-6 { &.is-6 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
&.with-headroom {
margin-top: 1rem;
}
} }

View file

@ -1,8 +1,42 @@
<section class="section"> <section class="section">
<h1 data-test-title class="title"> {{#if error}}
<div data-test-inline-error class="notification is-danger">
<div class="columns">
<div class="column">
<h3 data-test-inline-error-title class="title is-4">{{error.title}}</h3>
<p data-test-inline-error-body>{{error.description}}</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-inline-error-close class="button is-danger" onclick={{action onDismiss}}>Okay</button>
</div>
</div>
</div>
{{/if}}
<h1 data-test-title class="title with-headroom">
Allocation {{model.name}} Allocation {{model.name}}
<span class="bumper-left tag {{model.statusClass}}">{{model.clientStatus}}</span> <span class="bumper-left tag {{model.statusClass}}">{{model.clientStatus}}</span>
<span class="tag is-hollow is-small no-text-transform">{{model.id}}</span> <span class="tag is-hollow is-small no-text-transform">{{model.id}}</span>
{{#if model.isRunning}}
{{two-step-button
data-test-stop
idleText="Stop"
cancelText="Cancel"
confirmText="Yes, Stop"
confirmationMessage="Are you sure? This will reschedule the allocation on a different client."
awaitingConfirmation=stopAllocation.isRunning
disabled=(or stopAllocation.isRunning restartAllocation.isRunning)
onConfirm=(perform stopAllocation)}}
{{two-step-button
data-test-restart
idleText="Restart"
cancelText="Cancel"
confirmText="Yes, Restart"
confirmationMessage="Are you sure? This will restart the allocation in-place."
awaitingConfirmation=restartAllocation.isRunning
disabled=(or stopAllocation.isRunning restartAllocation.isRunning)
onConfirm=(perform restartAllocation)}}
{{/if}}
</h1> </h1>
<div class="boxed-section is-small"> <div class="boxed-section is-small">

View file

@ -1,8 +1,33 @@
{{partial "allocations/allocation/task/subnav"}} {{partial "allocations/allocation/task/subnav"}}
<section class="section"> <section class="section">
{{#if error}}
<div data-test-inline-error class="notification is-danger">
<div class="columns">
<div class="column">
<h3 data-test-inline-error-title class="title is-4">{{error.title}}</h3>
<p data-test-inline-error-body>{{error.description}}</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-inline-error-close class="button is-danger" onclick={{action onDismiss}}>Okay</button>
</div>
</div>
</div>
{{/if}}
<h1 class="title" data-test-title> <h1 class="title" data-test-title>
{{model.name}} {{model.name}}
<span class="bumper-left tag {{model.stateClass}}" data-test-state>{{model.state}}</span> <span class="bumper-left tag {{model.stateClass}}" data-test-state>{{model.state}}</span>
{{#if model.isRunning}}
{{two-step-button
data-test-restart
idleText="Restart"
cancelText="Cancel"
confirmText="Yes, Restart"
confirmationMessage="Are you sure? This will restart the task in-place."
awaitingConfirmation=restartTask.isRunning
disabled=restartTask.isRunning
onConfirm=(perform restartTask)}}
{{/if}}
</h1> </h1>
<div class="boxed-section is-small"> <div class="boxed-section is-small">

View file

@ -1,5 +1,10 @@
{{#if isIdle}} {{#if isIdle}}
<button data-test-idle-button type="button" class="button is-danger is-outlined is-important is-small" onclick={{action "promptForConfirmation"}}> <button
data-test-idle-button
type="button"
class="button is-danger is-outlined is-important is-small"
disabled={{disabled}}
onclick={{action "promptForConfirmation"}}>
{{idleText}} {{idleText}}
</button> </button>
{{else if isPendingConfirmation}} {{else if isPendingConfirmation}}

View file

@ -0,0 +1,11 @@
// Adds a string to the end of a URL path while being mindful of query params
export default function addToPath(url, extension = '') {
const [path, params] = url.split('?');
let newUrl = `${path}${extension}`;
if (params) {
newUrl += `?${params}`;
}
return newUrl;
}

View file

@ -200,6 +200,10 @@ export default function() {
this.get('/allocation/:id'); this.get('/allocation/:id');
this.post('/allocation/:id/stop', function() {
return new Response(204, {}, '');
});
this.get('/namespaces', function({ namespaces }) { this.get('/namespaces', function({ namespaces }) {
const records = namespaces.all(); const records = namespaces.all();
@ -301,6 +305,10 @@ export default function() {
}; };
// Client requests are available on the server and the client // Client requests are available on the server and the client
this.put('/client/allocation/:id/restart', function() {
return new Response(204, {}, '');
});
this.get('/client/allocation/:id/stats', clientAllocationStatsHandler); this.get('/client/allocation/:id/stats', clientAllocationStatsHandler);
this.get('/client/fs/logs/:allocation_id', clientAllocationLog); this.get('/client/fs/logs/:allocation_id', clientAllocationLog);

View file

@ -1,3 +1,4 @@
import { run } from '@ember/runloop';
import { currentURL } from '@ember/test-helpers'; import { currentURL } from '@ember/test-helpers';
import { assign } from '@ember/polyfills'; import { assign } from '@ember/polyfills';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
@ -155,6 +156,63 @@ module('Acceptance | allocation detail', function(hooks) {
assert.ok(Allocation.error.isShown, 'Error message is shown'); assert.ok(Allocation.error.isShown, 'Error message is shown');
assert.equal(Allocation.error.title, 'Not Found', 'Error message is for 404'); assert.equal(Allocation.error.title, 'Not Found', 'Error message is for 404');
}); });
test('allocation can be stopped', async function(assert) {
await Allocation.stop.idle();
await Allocation.stop.confirm();
assert.equal(
server.pretender.handledRequests.findBy('method', 'POST').url,
`/v1/allocation/${allocation.id}/stop`,
'Stop request is made for the allocation'
);
});
test('allocation can be restarted', async function(assert) {
await Allocation.restart.idle();
await Allocation.restart.confirm();
assert.equal(
server.pretender.handledRequests.findBy('method', 'PUT').url,
`/v1/client/allocation/${allocation.id}/restart`,
'Restart request is made for the allocation'
);
});
test('while an allocation is being restarted, the stop button is disabled', async function(assert) {
server.pretender.post('/v1/allocation/:id/stop', () => [204, {}, ''], true);
await Allocation.stop.idle();
run.later(() => {
assert.ok(Allocation.stop.isRunning, 'Stop is loading');
assert.ok(Allocation.restart.isDisabled, 'Restart is disabled');
server.pretender.resolve(server.pretender.requestReferences[0].request);
}, 500);
await Allocation.stop.confirm();
});
test('if stopping or restarting fails, an error message is shown', async function(assert) {
server.pretender.post('/v1/allocation/:id/stop', () => [403, {}, '']);
await Allocation.stop.idle();
await Allocation.stop.confirm();
assert.ok(Allocation.inlineError.isShown, 'Inline error is shown');
assert.ok(
Allocation.inlineError.title.includes('Could Not Stop Allocation'),
'Title is descriptive'
);
assert.ok(
/ACL token.+?allocation lifecycle/.test(Allocation.inlineError.message),
'Message mentions ACLs and the appropriate permission'
);
await Allocation.inlineError.dismiss();
assert.notOk(Allocation.inlineError.isShown, 'Inline error is no longer shown');
});
}); });
module('Acceptance | allocation detail (rescheduled)', function(hooks) { module('Acceptance | allocation detail (rescheduled)', function(hooks) {

View file

@ -174,6 +174,42 @@ module('Acceptance | task detail', function(hooks) {
assert.ok(Task.error.isPresent, 'Error message is shown'); assert.ok(Task.error.isPresent, 'Error message is shown');
assert.equal(Task.error.title, 'Not Found', 'Error message is for 404'); assert.equal(Task.error.title, 'Not Found', 'Error message is for 404');
}); });
test('task can be restarted', async function(assert) {
await Task.restart.idle();
await Task.restart.confirm();
const request = server.pretender.handledRequests.findBy('method', 'PUT');
assert.equal(
request.url,
`/v1/client/allocation/${allocation.id}/restart`,
'Restart request is made for the allocation'
);
assert.deepEqual(
JSON.parse(request.requestBody),
{ TaskName: task.name },
'Restart request is made for the correct task'
);
});
test('when task restart fails, an error message is shown', async function(assert) {
server.pretender.put('/v1/client/allocation/:id/restart', () => [403, {}, '']);
await Task.restart.idle();
await Task.restart.confirm();
assert.ok(Task.inlineError.isShown, 'Inline error is shown');
assert.ok(Task.inlineError.title.includes('Could Not Restart Task'), 'Title is descriptive');
assert.ok(
/ACL token.+?allocation lifecycle/.test(Task.inlineError.message),
'Message mentions ACLs and the appropriate permission'
);
await Task.inlineError.dismiss();
assert.notOk(Task.inlineError.isShown, 'Inline error is no longer shown');
});
}); });
module('Acceptance | task detail (no addresses)', function(hooks) { module('Acceptance | task detail (no addresses)', function(hooks) {

View file

@ -4,6 +4,10 @@ import { setupRenderingTest } from 'ember-qunit';
import { render, settled } from '@ember/test-helpers'; import { render, settled } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon'; import sinon from 'sinon';
import { create } from 'ember-cli-page-object';
import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
const TwoStepButton = create(twoStepButton());
module('Integration | Component | two step button', function(hooks) { module('Integration | Component | two step button', function(hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
@ -14,6 +18,7 @@ module('Integration | Component | two step button', function(hooks) {
confirmText: 'Confirm Action', confirmText: 'Confirm Action',
confirmationMessage: 'Are you certain', confirmationMessage: 'Are you certain',
awaitingConfirmation: false, awaitingConfirmation: false,
disabled: false,
onConfirm: sinon.spy(), onConfirm: sinon.spy(),
onCancel: sinon.spy(), onCancel: sinon.spy(),
}); });
@ -25,6 +30,7 @@ module('Integration | Component | two step button', function(hooks) {
confirmText=confirmText confirmText=confirmText
confirmationMessage=confirmationMessage confirmationMessage=confirmationMessage
awaitingConfirmation=awaitingConfirmation awaitingConfirmation=awaitingConfirmation
disabled=disabled
onConfirm=onConfirm onConfirm=onConfirm
onCancel=onCancel}} onCancel=onCancel}}
`; `;
@ -35,11 +41,7 @@ module('Integration | Component | two step button', function(hooks) {
await render(commonTemplate); await render(commonTemplate);
assert.ok(find('[data-test-idle-button]'), 'Idle button is rendered'); assert.ok(find('[data-test-idle-button]'), 'Idle button is rendered');
assert.equal( assert.equal(TwoStepButton.idleText, props.idleText, 'Button is labeled correctly');
find('[data-test-idle-button]').textContent.trim(),
props.idleText,
'Button is labeled correctly'
);
assert.notOk(find('[data-test-cancel-button]'), 'No cancel button yet'); assert.notOk(find('[data-test-cancel-button]'), 'No cancel button yet');
assert.notOk(find('[data-test-confirm-button]'), 'No confirm button yet'); assert.notOk(find('[data-test-confirm-button]'), 'No confirm button yet');
@ -51,25 +53,17 @@ module('Integration | Component | two step button', function(hooks) {
this.setProperties(props); this.setProperties(props);
await render(commonTemplate); await render(commonTemplate);
click('[data-test-idle-button]'); TwoStepButton.idle();
return settled().then(() => { return settled().then(() => {
assert.ok(find('[data-test-cancel-button]'), 'Cancel button is rendered'); assert.ok(find('[data-test-cancel-button]'), 'Cancel button is rendered');
assert.equal( assert.equal(TwoStepButton.cancelText, props.cancelText, 'Button is labeled correctly');
find('[data-test-cancel-button]').textContent.trim(),
props.cancelText,
'Button is labeled correctly'
);
assert.ok(find('[data-test-confirm-button]'), 'Confirm button is rendered'); assert.ok(find('[data-test-confirm-button]'), 'Confirm button is rendered');
assert.equal( assert.equal(TwoStepButton.confirmText, props.confirmText, 'Button is labeled correctly');
find('[data-test-confirm-button]').textContent.trim(),
props.confirmText,
'Button is labeled correctly'
);
assert.equal( assert.equal(
find('[data-test-confirmation-message]').textContent.trim(), TwoStepButton.confirmationMessage,
props.confirmationMessage, props.confirmationMessage,
'Confirmation message is shown' 'Confirmation message is shown'
); );
@ -83,10 +77,10 @@ module('Integration | Component | two step button', function(hooks) {
this.setProperties(props); this.setProperties(props);
await render(commonTemplate); await render(commonTemplate);
click('[data-test-idle-button]'); TwoStepButton.idle();
return settled().then(() => { return settled().then(() => {
click('[data-test-cancel-button]'); TwoStepButton.cancel();
return settled().then(() => { return settled().then(() => {
assert.ok(props.onCancel.calledOnce, 'The onCancel hook fired'); assert.ok(props.onCancel.calledOnce, 'The onCancel hook fired');
@ -100,10 +94,10 @@ module('Integration | Component | two step button', function(hooks) {
this.setProperties(props); this.setProperties(props);
await render(commonTemplate); await render(commonTemplate);
click('[data-test-idle-button]'); TwoStepButton.idle();
return settled().then(() => { return settled().then(() => {
click('[data-test-confirm-button]'); TwoStepButton.confirm();
return settled().then(() => { return settled().then(() => {
assert.ok(props.onConfirm.calledOnce, 'The onConfirm hook fired'); assert.ok(props.onConfirm.calledOnce, 'The onConfirm hook fired');
@ -118,21 +112,73 @@ module('Integration | Component | two step button', function(hooks) {
this.setProperties(props); this.setProperties(props);
await render(commonTemplate); await render(commonTemplate);
click('[data-test-idle-button]'); TwoStepButton.idle();
return settled().then(() => { return settled().then(() => {
assert.ok( assert.ok(TwoStepButton.cancelIsDisabled, 'The cancel button is disabled');
find('[data-test-cancel-button]').hasAttribute('disabled'), assert.ok(TwoStepButton.confirmIsDisabled, 'The confirm button is disabled');
'The cancel button is disabled' assert.ok(TwoStepButton.isRunning, 'The confirm button is in a loading state');
);
assert.ok(
find('[data-test-confirm-button]').hasAttribute('disabled'),
'The confirm button is disabled'
);
assert.ok(
find('[data-test-confirm-button]').classList.contains('is-loading'),
'The confirm button is in a loading state'
);
}); });
}); });
test('when in the prompt state, clicking outside will reset state back to idle', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await render(commonTemplate);
TwoStepButton.idle();
await settled();
assert.ok(find('[data-test-cancel-button]'), 'In the prompt state');
click(document.body);
await settled();
assert.ok(find('[data-test-idle-button]'), 'Back in the idle state');
});
test('when in the prompt state, clicking inside will not reset state back to idle', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await render(commonTemplate);
TwoStepButton.idle();
await settled();
assert.ok(find('[data-test-cancel-button]'), 'In the prompt state');
click('[data-test-confirmation-message]');
await settled();
assert.notOk(find('[data-test-idle-button]'), 'Still in the prompt state');
});
test('when awaitingConfirmation is true, clicking outside does nothing', async function(assert) {
const props = commonProperties();
props.awaitingConfirmation = true;
this.setProperties(props);
await render(commonTemplate);
TwoStepButton.idle();
await settled();
assert.ok(find('[data-test-cancel-button]'), 'In the prompt state');
click(document.body);
await settled();
assert.notOk(find('[data-test-idle-button]'), 'Still in the prompt state');
});
test('when disabled is true, the idle button is disabled', async function(assert) {
const props = commonProperties();
props.disabled = true;
this.setProperties(props);
await render(commonTemplate);
assert.ok(TwoStepButton.isDisabled, 'The idle button is disabled');
TwoStepButton.idle();
assert.ok(find('[data-test-idle-button]'), 'Still in the idle state after clicking');
});
}); });

View file

@ -9,12 +9,16 @@ import {
} from 'ember-cli-page-object'; } from 'ember-cli-page-object';
import allocations from 'nomad-ui/tests/pages/components/allocations'; import allocations from 'nomad-ui/tests/pages/components/allocations';
import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
export default create({ export default create({
visit: visitable('/allocations/:id'), visit: visitable('/allocations/:id'),
title: text('[data-test-title]'), title: text('[data-test-title]'),
stop: twoStepButton('[data-test-stop]'),
restart: twoStepButton('[data-test-restart]'),
details: { details: {
scope: '[data-test-allocation-details]', scope: '[data-test-allocation-details]',
@ -77,4 +81,11 @@ export default create({
message: text('[data-test-error-message]'), message: text('[data-test-error-message]'),
seekHelp: clickable('[data-test-error-message] a'), seekHelp: clickable('[data-test-error-message] a'),
}, },
inlineError: {
isShown: isPresent('[data-test-inline-error]'),
title: text('[data-test-inline-error-title]'),
message: text('[data-test-inline-error-body]'),
dismiss: clickable('[data-test-inline-error-close]'),
},
}); });

View file

@ -8,6 +8,8 @@ import {
visitable, visitable,
} from 'ember-cli-page-object'; } from 'ember-cli-page-object';
import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
export default create({ export default create({
visit: visitable('/allocations/:id/:name'), visit: visitable('/allocations/:id/:name'),
@ -15,6 +17,8 @@ export default create({
state: text('[data-test-state]'), state: text('[data-test-state]'),
startedAt: text('[data-test-started-at]'), startedAt: text('[data-test-started-at]'),
restart: twoStepButton('[data-test-restart]'),
breadcrumbs: collection('[data-test-breadcrumb]', { breadcrumbs: collection('[data-test-breadcrumb]', {
id: attribute('data-test-breadcrumb'), id: attribute('data-test-breadcrumb'),
text: text(), text: text(),
@ -51,4 +55,11 @@ export default create({
message: text('[data-test-error-message]'), message: text('[data-test-error-message]'),
seekHelp: clickable('[data-test-error-message] a'), seekHelp: clickable('[data-test-error-message] a'),
}, },
inlineError: {
isShown: isPresent('[data-test-inline-error]'),
title: text('[data-test-inline-error-title]'),
message: text('[data-test-inline-error-body]'),
dismiss: clickable('[data-test-inline-error-close]'),
},
}); });

View file

@ -0,0 +1,22 @@
import { attribute, clickable, hasClass, isPresent, text } from 'ember-cli-page-object';
export default scope => ({
scope,
isPresent: isPresent(),
idle: clickable('[data-test-idle-button]'),
confirm: clickable('[data-test-confirm-button]'),
cancel: clickable('[data-test-cancel-button]'),
isRunning: hasClass('is-loading', '[data-test-confirm-button]'),
isDisabled: attribute('disabled', '[data-test-idle-button]'),
cancelIsDisabled: attribute('disabled', '[data-test-cancel-button]'),
confirmIsDisabled: attribute('disabled', '[data-test-confirm-button]'),
idleText: text('[data-test-idle-button]'),
cancelText: text('[data-test-cancel-button]'),
confirmText: text('[data-test-confirm-button]'),
confirmationMessage: text('[data-test-confirmation-message]'),
});

View file

@ -0,0 +1,77 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
module('Unit | Adapter | Allocation', function(hooks) {
setupTest(hooks);
hooks.beforeEach(async function() {
this.store = this.owner.lookup('service:store');
this.subject = () => this.store.adapterFor('allocation');
this.server = startMirage();
this.server.create('namespace');
this.server.create('node');
this.server.create('job', { createAllocations: false });
this.server.create('allocation', { id: 'alloc-1' });
});
hooks.afterEach(function() {
this.server.shutdown();
});
test('`stop` makes the correct API call', async function(assert) {
const { pretender } = this.server;
const allocationId = 'alloc-1';
const allocation = await this.store.findRecord('allocation', allocationId);
pretender.handledRequests.length = 0;
await this.subject().stop(allocation);
const req = pretender.handledRequests[0];
assert.equal(
`${req.method} ${req.url}`,
`POST /v1/allocation/${allocationId}/stop`,
`POST /v1/allocation/${allocationId}/stop`
);
});
test('`restart` makes the correct API call', async function(assert) {
const { pretender } = this.server;
const allocationId = 'alloc-1';
const allocation = await this.store.findRecord('allocation', allocationId);
pretender.handledRequests.length = 0;
await this.subject().restart(allocation);
const req = pretender.handledRequests[0];
assert.equal(
`${req.method} ${req.url}`,
`PUT /v1/client/allocation/${allocationId}/restart`,
`PUT /v1/client/allocation/${allocationId}/restart`
);
});
test('`restart` takes an optional task name and makes the correct API call', async function(assert) {
const { pretender } = this.server;
const allocationId = 'alloc-1';
const taskName = 'task-name';
const allocation = await this.store.findRecord('allocation', allocationId);
pretender.handledRequests.length = 0;
await this.subject().restart(allocation, taskName);
const req = pretender.handledRequests[0];
assert.equal(
`${req.method} ${req.url}`,
`PUT /v1/client/allocation/${allocationId}/restart`,
`PUT /v1/client/allocation/${allocationId}/restart`
);
assert.deepEqual(
JSON.parse(req.requestBody),
{ TaskName: taskName },
'Request body is correct'
);
});
});

View file

@ -0,0 +1,32 @@
import { module, test } from 'qunit';
import addToPath from 'nomad-ui/utils/add-to-path';
const testCases = [
{
name: 'Only domain',
in: ['https://domain.com', '/path'],
out: 'https://domain.com/path',
},
{
name: 'Deep path',
in: ['https://domain.com/a/path', '/to/nowhere'],
out: 'https://domain.com/a/path/to/nowhere',
},
{
name: 'With Query Params',
in: ['https://domain.com?interesting=development', '/this-is-an'],
out: 'https://domain.com/this-is-an?interesting=development',
},
];
module('Unit | Util | addToPath', function() {
testCases.forEach(testCase => {
test(testCase.name, function(assert) {
assert.equal(
addToPath.apply(null, testCase.in),
testCase.out,
`[${testCase.in.join(', ')}] => ${testCase.out}`
);
});
});
});