Add button to fail running deployments (#9831)

This closes #8744 and #9826.

It necessitated some customisation options for TwoStepButton. One is inlineText, which puts the confirmation text in the same line as the buttons. Also, there was a single-use configuration option named isInfoAction that I removed in favour of passing a set of class configuration options like this:

                @classes={{hash
                  idleButton="is-warning"
                  confirmationMessage="inherit-color"
                  cancelButton="is-danger is-important"
                  confirmButton="is-warning"}}
This commit is contained in:
Buck Doyle
2021-02-10 08:38:37 -06:00
committed by GitHub
parent 06744e0f9d
commit d98265d954
12 changed files with 209 additions and 15 deletions

View File

@@ -1,6 +1,16 @@
import Watchable from './watchable';
export default class DeploymentAdapter extends Watchable {
fail(deployment) {
const id = deployment.get('id');
const url = urlForAction(this.urlForFindRecord(id, 'deployment'), '/fail');
return this.ajax(url, 'POST', {
data: {
DeploymentId: id,
},
});
}
promote(deployment) {
const id = deployment.get('id');
const url = urlForAction(this.urlForFindRecord(id, 'deployment'), '/promote');

View File

@@ -24,4 +24,22 @@ export default class LatestDeployment extends Component {
}
})
promote;
@task(function*() {
try {
yield this.get('job.latestDeployment.content').fail();
} catch (err) {
let message = messageFromAdapterError(err);
if (err instanceof ForbiddenError) {
message = 'Your ACL token does not grant permission to fail deployments.';
}
this.handleError({
title: 'Could Not Fail Deployment',
description: message,
});
}
})
fail;
}

View File

@@ -4,11 +4,12 @@ import { next } from '@ember/runloop';
import { equal } from '@ember/object/computed';
import { task, waitForEvent } from 'ember-concurrency';
import RSVP from 'rsvp';
import { classNames } from '@ember-decorators/component';
import { classNames, classNameBindings } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';
@classic
@classNames('two-step-button')
@classNameBindings('inlineText:has-inline-text')
export default class TwoStepButton extends Component {
idleText = '';
cancelText = '';
@@ -17,7 +18,7 @@ export default class TwoStepButton extends Component {
awaitingConfirmation = false;
disabled = false;
alignRight = false;
isInfoAction = false;
inlineText = false;
onConfirm() {}
onCancel() {}

View File

@@ -69,4 +69,9 @@ export default class Deployment extends Model {
assert('A deployment needs to requirePromotion to be promoted', this.requiresPromotion);
return this.store.adapterFor('deployment').promote(this);
}
fail() {
assert('A deployment must be running to be failed', this.isRunning);
return this.store.adapterFor('deployment').fail(this);
}
}

View File

@@ -5,6 +5,20 @@
font-size: $body-size;
line-height: 1;
&.has-inline-text {
display: inline-flex;
align-items: center;
button {
margin-left: 0.5ch;
}
.confirmation-text {
position: static;
margin-right: 0.5ch;
}
}
.confirmation-text {
position: absolute;
left: 0;

View File

@@ -212,7 +212,11 @@
<TwoStepButton
data-test-force
@alignRight={{true}}
@isInfoAction={{true}}
@classes={{hash
idleButton="is-warning"
confirmationMessage="inherit-color"
cancelButton="is-danger is-important"
confirmButton="is-warning"}}
@idleText="Force Drain"
@cancelText="Cancel"
@confirmText="Yes, Force Drain"

View File

@@ -14,14 +14,32 @@
<span class="tag is-outlined {{this.job.latestDeployment.statusClass}}" data-test-deployment-status="{{this.job.latestDeployment.statusClass}}">
{{this.job.latestDeployment.status}}
</span>
{{#if this.job.latestDeployment.requiresPromotion}}
<button
data-test-promote-canary
type="button"
class="button is-warning is-small pull-right {{if this.promote.isRunning "is-loading"}}"
disabled={{this.promote.isRunning}}
onclick={{perform this.promote}}>Promote Canary</button>
{{/if}}
<div class="pull-right">
{{#if this.job.latestDeployment.isRunning}}
<TwoStepButton
data-test-fail
@classes={{hash
idleButton="is-danger"
confirmationMessage="inherit-color"
confirmButton="is-danger"}}
@idleText="Fail Deployment"
@cancelText="Cancel"
@confirmText="Yes, Fail"
@confirmationMessage="Are you sure?"
@inlineText={{true}}
@awaitingConfirmation={{this.fail.isRunning}}
@disabled={{this.fail.isRunning}}
@onConfirm={{perform this.fail}} />
{{/if}}
{{#if this.job.latestDeployment.requiresPromotion}}
<button
data-test-promote-canary
type="button"
class="button is-warning is-small {{if this.promote.isRunning "is-loading"}}"
disabled={{this.promote.isRunning}}
onclick={{perform this.promote}}>Promote Canary</button>
{{/if}}
</div>
</div>
</div>
<div class="boxed-section-body with-foot">

View File

@@ -2,7 +2,7 @@
<button
data-test-idle-button
type="button"
class="button {{if this.isInfoAction "is-warning" "is-danger is-outlined"}} is-important is-small"
class="button {{if this.classes.idleButton this.classes.idleButton "is-danger is-outlined"}} is-important is-small"
disabled={{this.disabled}}
onclick={{action "promptForConfirmation"}}>
{{this.idleText}}
@@ -10,13 +10,13 @@
{{else if this.isPendingConfirmation}}
<span
data-test-confirmation-message
class="confirmation-text {{if this.isInfoAction "inherit-color"}} {{if this.alignRight "is-right-aligned"}}">
class="confirmation-text {{this.classes.confirmationMessage}} {{if this.alignRight "is-right-aligned"}} {{if this.inlineText "has-text-inline"}}">
{{this.confirmationMessage}}
</span>
<button
data-test-cancel-button
type="button"
class="button {{if this.isInfoAction "is-danger is-important" "is-dark"}} is-outlined is-small"
class="button is-outlined is-small {{if this.classes.cancelButton this.classes.cancelButton "is-dark"}}"
disabled={{this.awaitingConfirmation}}
onclick={{action (queue
(action "setToIdle")
@@ -26,7 +26,7 @@
</button>
<button
data-test-confirm-button
class="button {{if this.isInfoAction "is-warning" "is-danger"}} is-small {{if this.awaitingConfirmation "is-loading"}}"
class="button is-small {{if this.awaitingConfirmation "is-loading"}} {{if this.classes.confirmButton this.classes.confirmButton "is-danger"}}"
disabled={{this.awaitingConfirmation}}
onclick={{action "confirm"}}
type="button">

View File

@@ -191,6 +191,11 @@ export default function() {
});
this.get('/deployment/:id');
this.post('/deployment/fail/:id', function() {
return new Response(204, {}, '');
});
this.post('/deployment/promote/:id', function() {
return new Response(204, {}, '');
});

View File

@@ -19,6 +19,27 @@ export let Standard = () => {
};
};
export let Styled = () => {
return {
template: hbs`
<h5 class="title is-5">Two-Step Button with class overrides</h5>
<br><br>
<TwoStepButton
@idleText="Scary Action"
@cancelText="Nvm"
@confirmText="Yep"
@confirmationMessage="Wait, really? Like...seriously?"
@classes={{hash
idleButton="is-danger is-large"
confirmationMessage="badge is-warning"
confirmButton="is-large"
cancelButton="is-hollow"
}}
/>
`,
};
};
export let InTitle = () => {
return {
template: hbs`
@@ -37,6 +58,32 @@ export let InTitle = () => {
};
};
export let InlineText = () => {
return {
template: hbs`
<h5 class="title is-5">Two-Step Button with inline confirmation message</h5>
<br><br>
<TwoStepButton
@idleText="Scary Action"
@cancelText="Nvm"
@confirmText="Yep"
@confirmationMessage="Really?"
@inlineText={{true}}
/>
<br><br>
<span style="padding-left: 4rem"></span>
<TwoStepButton
@idleText="Scary Action"
@cancelText="Nvm"
@confirmText="Yep"
@confirmationMessage="Really?"
@alignRight={{true}}
@inlineText={{true}}
/>
`,
};
};
export let LoadingState = () => {
return {
template: hbs`

View File

@@ -226,4 +226,62 @@ module('Integration | Component | job-page/service', function(hooks) {
assert.notOk(find('[data-test-job-error-title]'), 'Error message is dismissable');
});
test('Active deployment can be failed', async function(assert) {
this.server.create('node');
const mirageJob = makeMirageJob(this.server, { activeDeployment: true });
await this.store.findAll('job');
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
const deployment = await job.get('latestDeployment');
this.setProperties(commonProperties(job));
await render(commonTemplate);
await click('[data-test-active-deployment] [data-test-idle-button]');
await click('[data-test-active-deployment] [data-test-confirm-button]');
const requests = this.server.pretender.handledRequests;
assert.ok(
requests
.filterBy('method', 'POST')
.findBy('url', `/v1/deployment/fail/${deployment.get('id')}`),
'A fail POST request was made'
);
});
test('When failing the active deployment fails, an error is shown', async function(assert) {
this.server.pretender.post('/v1/deployment/fail/:id', () => [403, {}, '']);
this.server.create('node');
const mirageJob = makeMirageJob(this.server, { activeDeployment: true });
await this.store.findAll('job');
const job = this.store.peekAll('job').findBy('plainId', mirageJob.id);
this.setProperties(commonProperties(job));
await render(commonTemplate);
await click('[data-test-active-deployment] [data-test-idle-button]');
await click('[data-test-active-deployment] [data-test-confirm-button]');
assert.equal(
find('[data-test-job-error-title]').textContent,
'Could Not Fail Deployment',
'Appropriate error is shown'
);
assert.ok(
find('[data-test-job-error-body]').textContent.includes('ACL'),
'The error message mentions ACLs'
);
await componentA11yAudit(this.element, assert);
await click('[data-test-job-error-close]');
assert.notOk(find('[data-test-job-error-title]'), 'Error message is dismissable');
});
});

View File

@@ -42,11 +42,13 @@ module('Unit | Adapter | Deployment', function(hooks) {
{
variation: '',
region: null,
fail: id => `POST /v1/deployment/fail/${id}`,
promote: id => `POST /v1/deployment/promote/${id}`,
},
{
variation: 'with non-default region',
region: 'region-2',
fail: id => `POST /v1/deployment/fail/${id}?region=region-2`,
promote: id => `POST /v1/deployment/promote/${id}?region=region-2`,
},
];
@@ -64,5 +66,17 @@ module('Unit | Adapter | Deployment', function(hooks) {
All: true,
});
});
test(`fail makes the correct API call ${testCase.variation}`, async function(assert) {
const deployment = await this.initialize({ region: testCase.region });
await this.subject().fail(deployment);
const request = this.server.pretender.handledRequests[0];
assert.equal(`${request.method} ${request.url}`, testCase.fail(deployment.id));
assert.deepEqual(JSON.parse(request.requestBody), {
DeploymentId: deployment.id,
});
});
});
});