diff --git a/app/components/pipeline/parameters/component.js b/app/components/pipeline/parameters/component.js new file mode 100644 index 000000000..9a9a2f009 --- /dev/null +++ b/app/components/pipeline/parameters/component.js @@ -0,0 +1,98 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { + extractJobParameters, + extractEventParameters, + getNormalizedParameterGroups +} from 'screwdriver-ui/utils/pipeline/parameters'; + +export default class PipelineParametersComponent extends Component { + @tracked parameters; + + @tracked selectedParameters; + + constructor() { + super(...arguments); + + const { pipelineParameters, jobParameters } = extractEventParameters( + this.args.event + ); + + this.parameters = getNormalizedParameterGroups( + pipelineParameters, + this.args.pipeline.parameters, + jobParameters, + extractJobParameters(this.args.jobs), + this.args.job ? this.args.job.name : null + ); + + this.selectedParameters = { + pipeline: { ...pipelineParameters }, + job: { ...jobParameters } + }; + } + + get title() { + return `${this.args.action} pipeline with parameters`.toUpperCase(); + } + + @action + toggleParameterGroup(groupName) { + const updatedParameters = []; + + this.parameters.forEach(group => { + if (group.paramGroupTitle === groupName) { + updatedParameters.push({ ...group, isOpen: !group.isOpen }); + } else { + updatedParameters.push(group); + } + }); + + this.parameters = updatedParameters; + } + + @action + openSelect(powerSelectObject) { + setTimeout(() => { + const container = document.getElementsByClassName('parameters-container'); + const scrollFrame = container[0]; + const optionsBox = document.getElementById( + `ember-power-select-options-${powerSelectObject.uniqueId}` + ); + + if (optionsBox === null) { + return; + } + + const optionsBoxRect = optionsBox.getBoundingClientRect(); + const scrollFrameRect = scrollFrame.getBoundingClientRect(); + const hiddenAreaHeight = optionsBoxRect.bottom - scrollFrameRect.bottom; + + if (hiddenAreaHeight > 0) { + scrollFrame.scrollBy({ top: hiddenAreaHeight + 10 }); + } + }, 100); + } + + @action + onInput(parameterGroup, parameter, event) { + this.updateParameter(parameterGroup, parameter, event.target.value); + } + + @action + updateParameter(parameterGroup, parameter, value) { + if (parameterGroup) { + const jobParameters = { ...this.selectedParameters.job[parameterGroup] }; + + jobParameters[parameter.name] = { value }; + this.selectedParameters.job[parameterGroup] = jobParameters; + } else { + this.selectedParameters.pipeline[parameter.name] = { value }; + } + + const { pipeline, job } = this.selectedParameters; + + this.args.onUpdateParameters({ ...pipeline, ...job }); + } +} diff --git a/app/components/pipeline/parameters/styles.scss b/app/components/pipeline/parameters/styles.scss new file mode 100644 index 000000000..d6941f70a --- /dev/null +++ b/app/components/pipeline/parameters/styles.scss @@ -0,0 +1,64 @@ +@use 'screwdriver-colors' as colors; +@use 'variables'; + +@mixin styles { + .pipeline-parameters { + .parameter-title { + font-size: 1.2rem; + font-weight: variables.$weight-bold; + color: colors.$sd-light-gray; + } + + .parameters-container { + max-height: 50vh; + overflow-y: scroll; + + .parameter-group { + border-radius: 8px; + border: 1px solid colors.$sd-separator; + margin-top: 1rem; + padding: 0.5rem; + + .group-title { + font-size: 1.5rem; + } + + .parameter-list { + &.collapsed { + display: none; + } + + .parameter { + display: flex; + padding: 0.2rem; + + label { + display: flex; + margin-top: auto; + margin-bottom: auto; + padding-right: 0.5rem; + flex-basis: 25%; + + svg { + margin-top: auto; + margin-bottom: auto; + padding-left: 0.25rem; + } + } + + .dropdown-selection-container { + flex: 1; + } + + > input { + flex: 1; + border-radius: 4px; + border: 1px solid colors.$sd-text-med; + padding-left: 8px; + } + } + } + } + } + } +} diff --git a/app/components/pipeline/parameters/template.hbs b/app/components/pipeline/parameters/template.hbs new file mode 100644 index 000000000..27012ba29 --- /dev/null +++ b/app/components/pipeline/parameters/template.hbs @@ -0,0 +1,42 @@ +
+
+ {{this.title}} +
+
+ {{#each this.parameters as |parameterGroup|}} +
+
+ + {{parameterGroup.paramGroupTitle}} +
+
+ {{#each parameterGroup.parameters as |parameter|}} +
+ + {{#if (is-array parameter.defaultValues)}} + + {{else}} + + {{/if}} +
+ {{/each}} +
+
+ {{/each}} +
+
diff --git a/app/utils/pipeline/parameters.js b/app/utils/pipeline/parameters.js index 220434ab7..9c481246f 100644 --- a/app/utils/pipeline/parameters.js +++ b/app/utils/pipeline/parameters.js @@ -1,3 +1,45 @@ +/** + * Extracts parameters from the API response object of an event + * @param event + * @returns {{pipelineParameters: {}, jobParameters: {}}} + */ +export function extractEventParameters(event) { + const eventParameters = event.meta.parameters || {}; + const pipelineParameters = {}; + const jobParameters = {}; + + // Extract pipeline level and job level parameters + Object.entries(eventParameters).forEach(([propertyName, propertyVal]) => { + const keys = Object.keys(propertyVal); + + if (keys.length === 1 && keys[0] === 'value') { + pipelineParameters[propertyName] = propertyVal; + } else { + jobParameters[propertyName] = propertyVal; + } + }); + + return { + pipelineParameters, + jobParameters + }; +} + +/** + * Extracts job parameters from the API response object of a job + */ +export function extractJobParameters(jobs) { + if (jobs.length === 0) { + return {}; + } + + return jobs.reduce((jobParameters, job) => { + jobParameters[job.name] = job.permutations[0].parameters; + + return jobParameters; + }, {}); +} + /** * normalizeParameters transform given parameters from object into array of objects * this method also backfills with default properties diff --git a/tests/integration/components/pipeline/parameters/component-test.js b/tests/integration/components/pipeline/parameters/component-test.js new file mode 100644 index 000000000..54af81114 --- /dev/null +++ b/tests/integration/components/pipeline/parameters/component-test.js @@ -0,0 +1,281 @@ +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'; +import { selectChoose } from 'ember-power-select/test-support'; +import sinon from 'sinon'; + +module('Integration | Component | pipeline/parameters', function (hooks) { + setupRenderingTest(hooks); + + test('it renders title with correct action', async function (assert) { + this.setProperties({ + event: { + meta: { + parameters: { + foo: { value: 'bar' }, + job1: { p1: { value: 'abc' }, p2: { value: 'xyz' } } + } + } + }, + job: { name: 'job1' }, + pipeline: { parameters: { foo: { value: 'foofoo' } } }, + jobs: [ + { + name: 'job1', + permutations: [{ p1: { value: 'p1' }, p2: { value: 'p2' } }] + } + ] + }); + await render( + hbs`` + ); + + assert.dom('.parameter-title').hasText('START PIPELINE WITH PARAMETERS'); + }); + + test('it renders parameters with shared group expanded', async function (assert) { + this.setProperties({ + event: { + meta: { + parameters: { + bar: { value: 'barzy' }, + foo: { value: 'foozy' }, + job1: { p1: { value: 'abc' }, p2: { value: 'xyz' } } + } + } + }, + pipeline: { + parameters: { + bar: ['barbar', 'bazbaz'], + foo: { value: 'foofoo', description: 'awesome' } + } + }, + jobs: [ + { + name: 'job1', + permutations: [{ p1: { value: 'p1' }, p2: { value: 'p2' } }] + } + ] + }); + + await render( + hbs`` + ); + + assert.dom('.group-title').exists({ count: 2 }); + assert + .dom(this.element.querySelectorAll('.group-title')[0]) + .hasText('Shared'); + assert.dom('.parameter-list.expanded .parameter').exists({ count: 2 }); + + const parameters = this.element.querySelectorAll( + '.parameter-list.expanded .parameter' + ); + + assert.dom(parameters[0].querySelector('label')).hasText('bar'); + assert + .dom(parameters[0].querySelector('.dropdown-selection-container')) + .hasText('barzy'); + assert.dom(parameters[1].querySelector('label')).hasText('foo awesome'); + assert.dom(parameters[1].querySelector('label svg')).exists({ count: 1 }); + assert.dom(parameters[1].querySelector('input')).hasValue('foozy'); + }); + + test('it renders parameters job group expanded', async function (assert) { + this.setProperties({ + event: { + meta: { + parameters: { + bar: { value: 'barzy' }, + foo: { value: 'foozy' }, + job1: { p1: { value: 'abc' }, p2: { value: 'xyz' } } + } + } + }, + pipeline: { + parameters: { + bar: ['barbar', 'bazbaz'], + foo: { value: 'foofoo' } + } + }, + jobs: [ + { + name: 'job1', + permutations: [{ p1: { value: 'p1' }, p2: { value: 'p2' } }] + } + ], + job: { name: 'job1' } + }); + + await render( + hbs`` + ); + + assert.dom('.group-title').exists({ count: 2 }); + assert + .dom(this.element.querySelectorAll('.group-title')[0]) + .hasText('Job: job1'); + assert.dom('.parameter-list.expanded .parameter').exists({ count: 2 }); + + const parameters = this.element.querySelectorAll( + '.parameter-list.expanded .parameter' + ); + + assert.dom(parameters[0].querySelector('label')).hasText('p1'); + assert.dom(parameters[0].querySelector('input')).hasValue('abc'); + assert.dom(parameters[1].querySelector('label')).hasText('p2'); + assert.dom(parameters[1].querySelector('input')).hasValue('xyz'); + }); + + test('it updates parameter value on input', async function (assert) { + const onUpdateParameters = sinon.spy(); + + this.setProperties({ + event: { + meta: { + parameters: { + foo: { value: 'foozy' }, + job1: { p1: { value: 'abc' } } + } + } + }, + pipeline: { + parameters: { + foo: { value: 'foofoo' } + } + }, + jobs: [ + { + name: 'job1', + permutations: [{ p1: { value: 'p1' } }] + } + ], + onUpdateParameters + }); + + await render( + hbs`` + ); + await fillIn('.parameter-list.expanded input', 'foobar'); + + assert.equal(onUpdateParameters.callCount, 1); + assert.true( + onUpdateParameters.calledWith({ + foo: { value: 'foobar' }, + job1: { p1: { value: 'abc' } } + }) + ); + }); + + test('it updates job parameter value on input', async function (assert) { + const onUpdateParameters = sinon.spy(); + + this.setProperties({ + event: { + meta: { + parameters: { + foo: { value: 'foozy' }, + job1: { p1: { value: 'abc' } } + } + } + }, + pipeline: { + parameters: { + foo: { value: 'foofoo' } + } + }, + jobs: [ + { + name: 'job1', + permutations: [{ p1: { value: 'p1' } }] + } + ], + job: { name: 'job1' }, + onUpdateParameters + }); + + await render( + hbs`` + ); + await fillIn('.parameter-list.expanded input', 'job123abc'); + + assert.equal(onUpdateParameters.callCount, 1); + assert.true( + onUpdateParameters.calledWith({ + foo: { value: 'foozy' }, + job1: { p1: { value: 'job123abc' } } + }) + ); + }); + + test('it updates parameter value on selection', async function (assert) { + const onUpdateParameters = sinon.spy(); + + this.setProperties({ + event: { + meta: { + parameters: { + foo: { value: 'foo' } + } + } + }, + pipeline: { + parameters: { + foo: ['foo', 'bar'] + } + }, + jobs: [], + onUpdateParameters + }); + + await render( + hbs`` + ); + + await selectChoose('.dropdown-selection-container', 'bar'); + + assert.equal(onUpdateParameters.callCount, 1); + assert.true( + onUpdateParameters.calledWith({ + foo: { value: 'bar' } + }) + ); + }); +}); diff --git a/tests/unit/utils/pipeline/parameters-test.js b/tests/unit/utils/pipeline/parameters-test.js index 50e6a1997..fd7e41d1a 100644 --- a/tests/unit/utils/pipeline/parameters-test.js +++ b/tests/unit/utils/pipeline/parameters-test.js @@ -1,10 +1,68 @@ import { module, test } from 'qunit'; import { + extractEventParameters, + extractJobParameters, normalizeParameters, getNormalizedParameterGroups } from 'screwdriver-ui/utils/pipeline/parameters'; module('Unit | Utility | Pipeline | parameters', function () { + test('extractEventParameters extracts parameters from event object', function (assert) { + const parameters = extractEventParameters({ + meta: { + parameters: { + foo: { value: 'bar' }, + job1: { p1: { value: 'abc' }, p2: { value: 'xyz' } } + } + } + }); + + assert.deepEqual(parameters.pipelineParameters, { foo: { value: 'bar' } }); + assert.deepEqual(parameters.jobParameters, { + job1: { p1: { value: 'abc' }, p2: { value: 'xyz' } } + }); + }); + + test('extractEventParameters returns empty objects when no parameters are provided', function (assert) { + const parameters = extractEventParameters({ + meta: {} + }); + + assert.deepEqual(parameters.pipelineParameters, {}); + assert.deepEqual(parameters.jobParameters, {}); + }); + + test('extractJobParameters extracts job parameters from jobs response object', function (assert) { + const parameters = extractJobParameters([ + { name: 'job1', permutations: [{ parameters: { a: 1, b: 'abc123' } }] }, + { + name: 'job2', + permutations: [ + { + parameters: { + c: { description: 'cool stuff', value: true }, + d: ['yes', 'no'] + } + } + ] + } + ]); + + assert.deepEqual(parameters, { + job1: { a: 1, b: 'abc123' }, + job2: { + c: { description: 'cool stuff', value: true }, + d: ['yes', 'no'] + } + }); + }); + + test('extractJobParameters returns empty object when no jobs are provided', function (assert) { + const parameters = extractJobParameters([]); + + assert.deepEqual(parameters, {}); + }); + test('normalizeParameters returns normalized parameters', function (assert) { const parameters = normalizeParameters({ param1: true,