diff --git a/app/components/pipeline/modal/confirm-action/component.js b/app/components/pipeline/modal/confirm-action/component.js new file mode 100644 index 000000000..4449e3558 --- /dev/null +++ b/app/components/pipeline/modal/confirm-action/component.js @@ -0,0 +1,124 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import { + buildPostBody, + capitalizeFirstLetter, + initializeParameters, + truncateMessage +} from './util'; + +export default class PipelineModalConfirmActionComponent extends Component { + @service shuttle; + + @service session; + + @tracked isAwaitingResponse = false; + + @tracked wasActionSuccessful = false; + + @tracked reason = ''; + + parameters; + + constructor() { + super(...arguments); + + this.parameters = initializeParameters(this.args.event); + } + + get action() { + return this.args.job.status ? 'restart' : 'start'; + } + + get truncatedMessage() { + return truncateMessage(this.args.event.commit.message); + } + + get isLatestCommitEvent() { + return this.args.event.sha === this.args.latestCommitEvent.sha; + } + + get commitUrl() { + return this.args.event.commit.url; + } + + get truncatedSha() { + return this.args.event.sha.substring(0, 7); + } + + get isLatestNonPrCommitEvent() { + return this.args.event.type === 'pr' ? true : this.isLatestCommitEvent; + } + + get isFrozen() { + return this.args.job.status === 'FROZEN'; + } + + get isParameterized() { + if (this.args.pipeline.parameters) { + return Object.keys(this.args.pipeline.parameters).length > 0; + } + + return false; + } + + get isSubmitButtonDisabled() { + if (this.wasActionSuccessful || this.isAwaitingResponse) { + return true; + } + + if (this.isFrozen) { + return this.reason.length === 0; + } + + return false; + } + + get pendingAction() { + return `${capitalizeFirstLetter(this.action)}ing...`; + } + + get completedAction() { + return this.wasActionSuccessful + ? `${capitalizeFirstLetter(this.action)}ed` + : 'Yes'; + } + + @action + onUpdateParameters(parameters) { + this.parameters = parameters; + } + + @action + async startBuild() { + this.isAwaitingResponse = true; + + const data = buildPostBody( + this.session.data.authenticated.username, + this.args.pipeline.id, + this.args.job, + this.args.event, + this.parameters, + this.isFrozen, + this.reason + ); + + return new Promise((resolve, reject) => { + this.shuttle + .fetchFromApi('POST', '/events', data) + .then(() => { + this.wasActionSuccessful = true; + resolve(); + }) + .catch(err => { + this.wasActionSuccessful = false; + reject(err); + }) + .finally(() => { + this.isAwaitingResponse = false; + }); + }); + } +} diff --git a/app/components/pipeline/modal/confirm-action/styles.scss b/app/components/pipeline/modal/confirm-action/styles.scss new file mode 100644 index 000000000..5704dc467 --- /dev/null +++ b/app/components/pipeline/modal/confirm-action/styles.scss @@ -0,0 +1,73 @@ +@use 'screwdriver-colors' as colors; + +@mixin styles { + #ember-bootstrap-wormhole { + .modal.confirm-action { + .modal-dialog { + max-width: 50%; + + .modal-body { + display: flex; + flex-direction: column; + + .modal-title { + font-size: 1.75rem; + padding-bottom: 0.75rem; + } + + .alert { + margin-bottom: 0.5rem; + } + + .frozen-reason { + display: flex; + justify-content: space-between; + + label { + margin: auto; + padding-right: 0.25rem; + } + + input { + flex: 1; + border-radius: 4px; + border: 1px solid colors.$sd-text-med; + padding-left: 8px; + } + } + + .pipeline-parameters { + padding-top: 2.5rem; + } + } + + .modal-footer { + display: flex; + justify-content: space-between; + + button { + width: 10rem; + + color: colors.$sd-link; + border-color: colors.$sd-link; + + &:hover { + background-color: colors.$sd-info-bg; + } + + &.confirm { + color: colors.$sd-white; + background-color: colors.$sd-running; + border-color: colors.$sd-running; + + &:hover { + background-color: colors.$sd-link; + border-color: colors.$sd-link; + } + } + } + } + } + } + } +} diff --git a/app/components/pipeline/modal/confirm-action/template.hbs b/app/components/pipeline/modal/confirm-action/template.hbs new file mode 100644 index 000000000..8caf20ee5 --- /dev/null +++ b/app/components/pipeline/modal/confirm-action/template.hbs @@ -0,0 +1,66 @@ + + + +
Job: {{@job.name}}
+
+ Commit: {{this.truncatedMessage}} + + #{{this.truncatedSha}} + + {{#unless this.isLatestNonPrCommitEvent}} +
+ + This is NOT the latest commit. +
+ {{/unless}} +
+ + {{#if this.isFrozen}} +
+ + +
+ {{/if}} + + {{#if this.isParameterized}} + + {{/if}} +
+ + + + {{#if this.wasActionSuccessful}} + Close + {{else}} + No + {{/if}} + + +
diff --git a/app/components/pipeline/modal/confirm-action/util.js b/app/components/pipeline/modal/confirm-action/util.js new file mode 100644 index 000000000..a2666c31a --- /dev/null +++ b/app/components/pipeline/modal/confirm-action/util.js @@ -0,0 +1,80 @@ +import { flattenParameters } from 'screwdriver-ui/utils/pipeline/parameters'; + +/** + * Initialize parameters from an event API response object + * @param event + * @returns {undefined|*} + */ +export function initializeParameters(event) { + if (event.meta?.parameters && event.meta.parameters.length > 0) { + return flattenParameters(event.meta.parameters); + } + + return undefined; +} + +/** + * Truncate commit message to 150 characters + * @param commitMessage + * @returns {string|*} + */ +export function truncateMessage(commitMessage) { + const cutOff = 150; + + return commitMessage.length > cutOff + ? `${commitMessage.substring(0, cutOff)}...` + : commitMessage; +} + +/** + * Capitalize first letter of string + * @param string + * @returns {string} + */ +export function capitalizeFirstLetter(string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +/** + * Build post body for starting a job + * @param username + * @param pipeline + * @param job + * @param event + * @param parameters + * @param isFrozen + * @param reason + * @returns {{causeMessage: string, pipelineId}} + */ +export function buildPostBody( + username, + pipelineId, + job, + event, + parameters, + isFrozen, + reason +) { + const data = { + pipelineId, + causeMessage: `Manually started by ${username}` + }; + + if (job.status) { + data.startFrom = job.name; + data.groupEventId = event.groupEventId; + data.parentEventId = event.id; + } else { + data.startFrom = '~commit'; + } + + if (parameters) { + data.meta = { parameters }; + } + + if (isFrozen) { + data.causeMessage = `[force start]${reason}`; + } + + return data; +} diff --git a/app/components/pipeline/styles.scss b/app/components/pipeline/styles.scss index 85af406cd..0976d4cbf 100644 --- a/app/components/pipeline/styles.scss +++ b/app/components/pipeline/styles.scss @@ -1,9 +1,11 @@ @use 'nav/styles' as nav; +@use 'modal/confirm-action/styles' as confirm-action-modal; @use 'modal/toggle-job/styles' as toggle-job; @use 'parameters/styles' as parameters; @mixin styles { @include nav.styles; + @include confirm-action-modal.styles; @include toggle-job.styles; @include parameters.styles; } diff --git a/app/styles/app.scss b/app/styles/app.scss index 34a85ae6a..f5fe5a9a5 100644 --- a/app/styles/app.scss +++ b/app/styles/app.scss @@ -1,4 +1,5 @@ @use 'variables'; +@use 'bootstrap-modal' as bootstrapModal; @use 'components/styles' as components; @use 'v2/pipeline/styles' as pipeline; @@ -204,6 +205,7 @@ html { font-family: Helvetica, Arial, sans-serif; font-weight: variables.$weight-normal; + @include bootstrapModal.styles; @include components.styles; @include pipeline.styles; } diff --git a/app/styles/bootstrap-modal.scss b/app/styles/bootstrap-modal.scss new file mode 100644 index 000000000..5c37f70b5 --- /dev/null +++ b/app/styles/bootstrap-modal.scss @@ -0,0 +1,15 @@ +@use 'screwdriver-colors' as colors; + +@mixin styles { + #ember-bootstrap-wormhole { + .modal-backdrop.fade.show { + background-color: rgba(128, 128, 128, 0.77); + opacity: 1; + } + + .modal-content { + border-radius: 8px; + box-shadow: 0 0 10px colors.$sd-black; + } + } +} diff --git a/tests/integration/components/pipeline/modal/confirm-action/component-test.js b/tests/integration/components/pipeline/modal/confirm-action/component-test.js new file mode 100644 index 000000000..624c460c3 --- /dev/null +++ b/tests/integration/components/pipeline/modal/confirm-action/component-test.js @@ -0,0 +1,261 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'screwdriver-ui/tests/helpers'; +import { fillIn, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module( + 'Integration | Component | pipeline/modal/confirm-action', + function (hooks) { + setupRenderingTest(hooks); + + test('it renders start action', async function (assert) { + this.setProperties({ + pipeline: { parameters: {} }, + event: { + commit: { message: 'commit message', url: 'http://foo.com' }, + sha: 'deadbeef0123456789' + }, + jobs: [], + latestCommitEvent: { sha: 'deadbeef0123456789' }, + job: { name: 'main' }, + closeModal: () => {} + }); + await render( + hbs`` + ); + + assert.dom('.modal-title').hasText('Are you sure you want to start?'); + assert.dom('#confirm-action-job').hasText('Job: main'); + assert + .dom('#confirm-action-commit') + .hasText('Commit: commit message #deadbee'); + assert + .dom('#confirm-action-commit-link') + .hasAttribute('href', 'http://foo.com'); + }); + + test('it renders restart action', async function (assert) { + this.setProperties({ + pipeline: { parameters: {} }, + event: { + commit: { message: 'commit message', url: 'http://foo.com' }, + sha: 'deadbeef0123456789' + }, + jobs: [], + latestCommitEvent: { sha: 'deadbeef0123456789' }, + job: { name: 'main', status: 'SUCCESS' }, + closeModal: () => {} + }); + await render( + hbs`` + ); + + assert.dom('.modal-title').hasText('Are you sure you want to restart?'); + }); + + test('it renders warning message for non-latest commit event', async function (assert) { + this.setProperties({ + pipeline: { parameters: {} }, + event: { + commit: { message: 'commit message', url: 'http://foo.com' }, + sha: '0123456789deadbeef' + }, + jobs: [], + latestCommitEvent: { sha: 'deadbeef0123456789' }, + job: { name: 'main' }, + closeModal: () => {} + }); + await render( + hbs`` + ); + + assert.dom('.alert').hasText('This is NOT the latest commit.'); + }); + + test('it does not render warning message for pr commit event', async function (assert) { + this.setProperties({ + pipeline: { parameters: {} }, + event: { + commit: { message: 'commit message', url: 'http://foo.com' }, + sha: 'deadbeef0123456789', + type: 'pr' + }, + jobs: [], + latestCommitEvent: { sha: 'deadbeef0123456789' }, + job: { name: 'main' }, + closeModal: () => {} + }); + await render( + hbs`` + ); + + assert.dom('.modal-body .alert').doesNotExist(); + + this.setProperties({ + pipeline: { parameters: {} }, + event: { + commit: { message: 'commit message', url: 'http://foo.com' }, + sha: 'abc123', + type: 'pr' + }, + jobs: [], + latestCommitEvent: { sha: 'deadbeef0123456789' }, + job: { name: 'main' }, + closeModal: () => {} + }); + await render( + hbs`` + ); + + assert.dom('.modal-body .alert').doesNotExist(); + }); + + test('it renders reason input for frozen job', async function (assert) { + this.setProperties({ + pipeline: { parameters: {} }, + event: { + commit: { message: 'commit message', url: 'http://foo.com' }, + sha: 'deadbeef0123456789' + }, + jobs: [], + latestCommitEvent: { sha: 'deadbeef0123456789' }, + job: { name: 'main', status: 'FROZEN' }, + closeModal: () => {} + }); + await render( + hbs`` + ); + + assert.dom('.frozen-reason').exists({ count: 1 }); + assert.dom('.frozen-reason label').exists({ count: 1 }); + assert.dom('.frozen-reason input').exists({ count: 1 }); + }); + + test('it renders parameter input for parameterized job', async function (assert) { + this.setProperties({ + pipeline: { parameters: { param1: 'abc' } }, + event: { + commit: { message: 'commit message', url: 'http://foo.com' }, + sha: 'deadbeef0123456789', + meta: { + parameters: { + param1: { value: 'abc' } + } + } + }, + jobs: [], + latestCommitEvent: { sha: 'deadbeef0123456789' }, + job: { name: 'main' }, + closeModal: () => {} + }); + await render( + hbs`` + ); + + assert.dom('.pipeline-parameters').exists({ count: 1 }); + }); + + test('it disables submit button when no reason is provided for frozen job', async function (assert) { + this.setProperties({ + pipeline: { parameters: {} }, + event: { + commit: { message: 'commit message', url: 'http://foo.com' }, + sha: 'deadbeef0123456789' + }, + jobs: [], + latestCommitEvent: { sha: 'deadbeef0123456789' }, + job: { name: 'main', status: 'FROZEN' }, + closeModal: () => {} + }); + await render( + hbs`` + ); + + assert.dom('.modal-footer button.confirm').isDisabled(); + }); + + test('it enables submit button when reason is provided for frozen job', async function (assert) { + this.setProperties({ + pipeline: { parameters: {} }, + event: { + commit: { message: 'commit message', url: 'http://foo.com' }, + sha: 'deadbeef0123456789' + }, + jobs: [], + latestCommitEvent: { sha: 'deadbeef0123456789' }, + job: { name: 'main', status: 'FROZEN' }, + closeModal: () => {} + }); + await render( + hbs`` + ); + + await fillIn('.frozen-reason input', 'Some reason'); + + assert.dom('.modal-footer button.confirm').isEnabled(); + }); + } +); diff --git a/tests/unit/components/pipeline/modal/confirm-action/util-test.js b/tests/unit/components/pipeline/modal/confirm-action/util-test.js new file mode 100644 index 000000000..990ceb91a --- /dev/null +++ b/tests/unit/components/pipeline/modal/confirm-action/util-test.js @@ -0,0 +1,93 @@ +import { module, test } from 'qunit'; +import { + buildPostBody, + capitalizeFirstLetter, + initializeParameters, + truncateMessage +} from 'screwdriver-ui/components/pipeline/modal/confirm-action/util'; + +module('Unit | Component | pipeline/modal/confirm-action/util', function () { + test('initializeParameters initializes correctly', function (assert) { + assert.deepEqual(initializeParameters({ id: 123 }, undefined)); + assert.deepEqual(initializeParameters({ id: 123, meta: {} }, undefined)); + assert.deepEqual( + initializeParameters( + { id: 123, meta: { parameters: { foo: 'bar' } } }, + { foo: 'bar' } + ) + ); + }); + + test('truncateMessage truncates string', function (assert) { + const shortMessage = 'short message'; + + assert.equal(truncateMessage(shortMessage), shortMessage); + + const longMessage = + 'This commit message is really long and will exceed the expected character length; as such, it will be truncated in order to fit within the expected character length'; + + assert.equal( + truncateMessage(longMessage), + 'This commit message is really long and will exceed the expected character length; as such, it will be truncated in order to fit within the expected ch...' + ); + }); + + test('capitalizeFirstLetter capitalizes first letter of string', function (assert) { + assert.equal(capitalizeFirstLetter('foo'), 'Foo'); + }); + + test('buildPostBody sets core values', function (assert) { + assert.deepEqual( + buildPostBody('foobar', 123, {}, null, null, false, null), + { + pipelineId: 123, + causeMessage: 'Manually started by foobar', + startFrom: '~commit' + } + ); + }); + + test('buildPostBody sets correct values for restart', function (assert) { + assert.deepEqual( + buildPostBody( + 'foobar', + 123, + { name: 'main', status: 'SUCCESS' }, + { id: 999, groupEventId: 54321 }, + null, + false, + null + ), + { + pipelineId: 123, + causeMessage: 'Manually started by foobar', + startFrom: 'main', + groupEventId: 54321, + parentEventId: 999 + } + ); + }); + + test('buildPostBody sets parameters', function (assert) { + assert.deepEqual( + buildPostBody('foobar', 123, {}, null, { param: 4 }, false, null), + { + pipelineId: 123, + causeMessage: 'Manually started by foobar', + startFrom: '~commit', + meta: { parameters: { param: 4 } } + } + ); + }); + + test('buildPostBody sets reason if frozen', function (assert) { + assert.deepEqual( + buildPostBody('foobar', 123, {}, null, null, true, 'testing'), + { + pipelineId: 123, + causeMessage: '[force start]testing', + startFrom: '~commit' + } + ); + }); +});