diff --git a/ui/app/adapters/allocation.js b/ui/app/adapters/allocation.js index 8aa4b662fd5..a2c24a5fff1 100644 --- a/ui/app/adapters/allocation.js +++ b/ui/app/adapters/allocation.js @@ -1,3 +1,21 @@ 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); + }; +} diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index a75da89d301..9bb5a248582 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -1,5 +1,6 @@ import { inject as service } from '@ember/service'; import Watchable from './watchable'; +import addToPath from 'nomad-ui/utils/add-to-path'; export default Watchable.extend({ system: service(), @@ -118,14 +119,3 @@ function associateNamespace(url, namespace) { } return url; } - -function addToPath(url, extension = '') { - const [path, params] = url.split('?'); - let newUrl = `${path}${extension}`; - - if (params) { - newUrl += `?${params}`; - } - - return newUrl; -} diff --git a/ui/app/components/two-step-button.js b/ui/app/components/two-step-button.js index 8e7bc05d57b..1499edbf090 100644 --- a/ui/app/components/two-step-button.js +++ b/ui/app/components/two-step-button.js @@ -1,5 +1,7 @@ import Component from '@ember/component'; +import { next } from '@ember/runloop'; import { equal } from '@ember/object/computed'; +import { task, waitForEvent } from 'ember-concurrency'; import RSVP from 'rsvp'; export default Component.extend({ @@ -10,6 +12,7 @@ export default Component.extend({ confirmText: '', confirmationMessage: '', awaitingConfirmation: false, + disabled: false, onConfirm() {}, onCancel() {}, @@ -17,12 +20,25 @@ export default Component.extend({ isIdle: equal('state', 'idle'), 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: { setToIdle() { this.set('state', 'idle'); + this.cancelOnClickOutside.cancelAll(); }, promptForConfirmation() { this.set('state', 'prompt'); + next(() => { + this.cancelOnClickOutside.perform(); + }); }, confirm() { RSVP.resolve(this.onConfirm()).then(() => { diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index 7ccfd9b91b7..127718221e2 100644 --- a/ui/app/controllers/allocations/allocation/index.js +++ b/ui/app/controllers/allocations/allocation/index.js @@ -1,8 +1,11 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; +import { computed, observer } from '@ember/object'; import { alias } from '@ember/object/computed'; +import { task } from 'ember-concurrency'; import Sortable from 'nomad-ui/mixins/sortable'; import { lazyClick } from 'nomad-ui/helpers/lazy-click'; +import { watchRecord } from 'nomad-ui/utils/properties/watch'; export default Controller.extend(Sortable, { token: service(), @@ -21,6 +24,50 @@ export default Controller.extend(Sortable, { // Set in the route 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: { gotoTask(allocation, task) { this.transitionToRoute('allocations.allocation.task', task); diff --git a/ui/app/controllers/allocations/allocation/task/index.js b/ui/app/controllers/allocations/allocation/task/index.js index c020bc4a2b6..ce1d3f847f1 100644 --- a/ui/app/controllers/allocations/allocation/task/index.js +++ b/ui/app/controllers/allocations/allocation/task/index.js @@ -1,6 +1,7 @@ import Controller from '@ember/controller'; import { computed } from '@ember/object'; import { alias } from '@ember/object/computed'; +import { task } from 'ember-concurrency'; export default Controller.extend({ network: alias('model.resources.networks.firstObject'), @@ -20,4 +21,24 @@ export default Controller.extend({ ) .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.', + }); + } + }), }); diff --git a/ui/app/mixins/with-watchers.js b/ui/app/mixins/with-watchers.js index d96992b74f3..ce89ec526df 100644 --- a/ui/app/mixins/with-watchers.js +++ b/ui/app/mixins/with-watchers.js @@ -31,8 +31,11 @@ export default Mixin.create(WithVisibilityDetection, { }, actions: { - willTransition() { - this.cancelAllWatchers(); + willTransition(transition) { + // Don't cancel watchers if transitioning into a sub-route + if (!transition.intent.name || !transition.intent.name.startsWith(this.routeName)) { + this.cancelAllWatchers(); + } // Bubble the action up to the application route return true; diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js index f93f19175ce..258d1a55879 100644 --- a/ui/app/models/allocation.js +++ b/ui/app/models/allocation.js @@ -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); + }, }); diff --git a/ui/app/models/task-state.js b/ui/app/models/task-state.js index 9ee5f229683..66ef8fbe303 100644 --- a/ui/app/models/task-state.js +++ b/ui/app/models/task-state.js @@ -43,4 +43,8 @@ export default Fragment.extend({ return classMap[this.state] || 'is-dark'; }), + + restart() { + return this.allocation.restart(this.name); + }, }); diff --git a/ui/app/routes/allocations/allocation/index.js b/ui/app/routes/allocations/allocation/index.js index ebe985da28f..323af0e82b2 100644 --- a/ui/app/routes/allocations/allocation/index.js +++ b/ui/app/routes/allocations/allocation/index.js @@ -10,4 +10,10 @@ export default Route.extend({ return this._super(...arguments); }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.watchNext.cancelAll(); + } + }, }); diff --git a/ui/app/styles/core/title.scss b/ui/app/styles/core/title.scss index f6464e8e8d7..40de40ed8bc 100644 --- a/ui/app/styles/core/title.scss +++ b/ui/app/styles/core/title.scss @@ -4,4 +4,8 @@ &.is-6 { margin-bottom: 0.5rem; } + + &.with-headroom { + margin-top: 1rem; + } } diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 09df050e2c4..280744b08bb 100644 --- a/ui/app/templates/allocations/allocation/index.hbs +++ b/ui/app/templates/allocations/allocation/index.hbs @@ -1,8 +1,42 @@
-

+ {{#if error}} +
+
+
+

{{error.title}}

+

{{error.description}}

+
+
+ +
+
+
+ {{/if}} + +

Allocation {{model.name}} {{model.clientStatus}} {{model.id}} + {{#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}}

diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index 518e8e58dc4..2e1d56fb0fb 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -1,8 +1,33 @@ {{partial "allocations/allocation/task/subnav"}}
+ {{#if error}} +
+
+
+

{{error.title}}

+

{{error.description}}

+
+
+ +
+
+
+ {{/if}} +

{{model.name}} {{model.state}} + {{#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}}

diff --git a/ui/app/templates/components/two-step-button.hbs b/ui/app/templates/components/two-step-button.hbs index 1b95184fea5..e29194e9f35 100644 --- a/ui/app/templates/components/two-step-button.hbs +++ b/ui/app/templates/components/two-step-button.hbs @@ -1,5 +1,10 @@ {{#if isIdle}} - {{else if isPendingConfirmation}} diff --git a/ui/app/utils/add-to-path.js b/ui/app/utils/add-to-path.js new file mode 100644 index 00000000000..7b76e263bcb --- /dev/null +++ b/ui/app/utils/add-to-path.js @@ -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; +} diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 5dbd947f201..24ddf66618f 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -200,6 +200,10 @@ export default function() { this.get('/allocation/:id'); + this.post('/allocation/:id/stop', function() { + return new Response(204, {}, ''); + }); + this.get('/namespaces', function({ namespaces }) { const records = namespaces.all(); @@ -301,6 +305,10 @@ export default function() { }; // 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/fs/logs/:allocation_id', clientAllocationLog); diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js index 3e5ca598066..66bc2b71b76 100644 --- a/ui/tests/acceptance/allocation-detail-test.js +++ b/ui/tests/acceptance/allocation-detail-test.js @@ -1,3 +1,4 @@ +import { run } from '@ember/runloop'; import { currentURL } from '@ember/test-helpers'; import { assign } from '@ember/polyfills'; 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.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) { diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index 211f2cea853..a19ce0d89dd 100644 --- a/ui/tests/acceptance/task-detail-test.js +++ b/ui/tests/acceptance/task-detail-test.js @@ -174,6 +174,42 @@ module('Acceptance | task detail', function(hooks) { assert.ok(Task.error.isPresent, 'Error message is shown'); 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) { diff --git a/ui/tests/integration/two-step-button-test.js b/ui/tests/integration/two-step-button-test.js index fb7b30a0b80..370eec266df 100644 --- a/ui/tests/integration/two-step-button-test.js +++ b/ui/tests/integration/two-step-button-test.js @@ -4,6 +4,10 @@ import { setupRenderingTest } from 'ember-qunit'; import { render, settled } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; 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) { setupRenderingTest(hooks); @@ -14,6 +18,7 @@ module('Integration | Component | two step button', function(hooks) { confirmText: 'Confirm Action', confirmationMessage: 'Are you certain', awaitingConfirmation: false, + disabled: false, onConfirm: sinon.spy(), onCancel: sinon.spy(), }); @@ -25,6 +30,7 @@ module('Integration | Component | two step button', function(hooks) { confirmText=confirmText confirmationMessage=confirmationMessage awaitingConfirmation=awaitingConfirmation + disabled=disabled onConfirm=onConfirm onCancel=onCancel}} `; @@ -35,11 +41,7 @@ module('Integration | Component | two step button', function(hooks) { await render(commonTemplate); assert.ok(find('[data-test-idle-button]'), 'Idle button is rendered'); - assert.equal( - find('[data-test-idle-button]').textContent.trim(), - props.idleText, - 'Button is labeled correctly' - ); + assert.equal(TwoStepButton.idleText, props.idleText, 'Button is labeled correctly'); assert.notOk(find('[data-test-cancel-button]'), 'No cancel 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); await render(commonTemplate); - click('[data-test-idle-button]'); + TwoStepButton.idle(); return settled().then(() => { assert.ok(find('[data-test-cancel-button]'), 'Cancel button is rendered'); - assert.equal( - find('[data-test-cancel-button]').textContent.trim(), - props.cancelText, - 'Button is labeled correctly' - ); + assert.equal(TwoStepButton.cancelText, props.cancelText, 'Button is labeled correctly'); assert.ok(find('[data-test-confirm-button]'), 'Confirm button is rendered'); - assert.equal( - find('[data-test-confirm-button]').textContent.trim(), - props.confirmText, - 'Button is labeled correctly' - ); + assert.equal(TwoStepButton.confirmText, props.confirmText, 'Button is labeled correctly'); assert.equal( - find('[data-test-confirmation-message]').textContent.trim(), + TwoStepButton.confirmationMessage, props.confirmationMessage, 'Confirmation message is shown' ); @@ -83,10 +77,10 @@ module('Integration | Component | two step button', function(hooks) { this.setProperties(props); await render(commonTemplate); - click('[data-test-idle-button]'); + TwoStepButton.idle(); return settled().then(() => { - click('[data-test-cancel-button]'); + TwoStepButton.cancel(); return settled().then(() => { assert.ok(props.onCancel.calledOnce, 'The onCancel hook fired'); @@ -100,10 +94,10 @@ module('Integration | Component | two step button', function(hooks) { this.setProperties(props); await render(commonTemplate); - click('[data-test-idle-button]'); + TwoStepButton.idle(); return settled().then(() => { - click('[data-test-confirm-button]'); + TwoStepButton.confirm(); return settled().then(() => { assert.ok(props.onConfirm.calledOnce, 'The onConfirm hook fired'); @@ -118,21 +112,73 @@ module('Integration | Component | two step button', function(hooks) { this.setProperties(props); await render(commonTemplate); - click('[data-test-idle-button]'); + TwoStepButton.idle(); return settled().then(() => { - assert.ok( - find('[data-test-cancel-button]').hasAttribute('disabled'), - 'The cancel button is disabled' - ); - assert.ok( - find('[data-test-confirm-button]').hasAttribute('disabled'), - 'The confirm button is disabled' - ); - assert.ok( - find('[data-test-confirm-button]').classList.contains('is-loading'), - 'The confirm button is in a loading state' - ); + assert.ok(TwoStepButton.cancelIsDisabled, 'The cancel button is disabled'); + assert.ok(TwoStepButton.confirmIsDisabled, 'The confirm button is disabled'); + assert.ok(TwoStepButton.isRunning, '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'); + }); }); diff --git a/ui/tests/pages/allocations/detail.js b/ui/tests/pages/allocations/detail.js index f1e5348f253..82362e84f5c 100644 --- a/ui/tests/pages/allocations/detail.js +++ b/ui/tests/pages/allocations/detail.js @@ -9,12 +9,16 @@ import { } from 'ember-cli-page-object'; import allocations from 'nomad-ui/tests/pages/components/allocations'; +import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button'; export default create({ visit: visitable('/allocations/:id'), title: text('[data-test-title]'), + stop: twoStepButton('[data-test-stop]'), + restart: twoStepButton('[data-test-restart]'), + details: { scope: '[data-test-allocation-details]', @@ -77,4 +81,11 @@ export default create({ message: text('[data-test-error-message]'), 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]'), + }, }); diff --git a/ui/tests/pages/allocations/task/detail.js b/ui/tests/pages/allocations/task/detail.js index 9e5c0a60be1..a7bb60ec736 100644 --- a/ui/tests/pages/allocations/task/detail.js +++ b/ui/tests/pages/allocations/task/detail.js @@ -8,6 +8,8 @@ import { visitable, } from 'ember-cli-page-object'; +import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button'; + export default create({ visit: visitable('/allocations/:id/:name'), @@ -15,6 +17,8 @@ export default create({ state: text('[data-test-state]'), startedAt: text('[data-test-started-at]'), + restart: twoStepButton('[data-test-restart]'), + breadcrumbs: collection('[data-test-breadcrumb]', { id: attribute('data-test-breadcrumb'), text: text(), @@ -51,4 +55,11 @@ export default create({ message: text('[data-test-error-message]'), 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]'), + }, }); diff --git a/ui/tests/pages/components/two-step-button.js b/ui/tests/pages/components/two-step-button.js new file mode 100644 index 00000000000..1c787b9bb7b --- /dev/null +++ b/ui/tests/pages/components/two-step-button.js @@ -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]'), +}); diff --git a/ui/tests/unit/adapters/allocation-test.js b/ui/tests/unit/adapters/allocation-test.js new file mode 100644 index 00000000000..c176e84535d --- /dev/null +++ b/ui/tests/unit/adapters/allocation-test.js @@ -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' + ); + }); +}); diff --git a/ui/tests/unit/utils/add-to-path-test.js b/ui/tests/unit/utils/add-to-path-test.js new file mode 100644 index 00000000000..6317cfad032 --- /dev/null +++ b/ui/tests/unit/utils/add-to-path-test.js @@ -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}` + ); + }); + }); +});