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..4a8046cad
--- /dev/null
+++ b/app/components/pipeline/parameters/styles.scss
@@ -0,0 +1,61 @@
+@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;
+ }
+ }
+ }
+ }
+ }
+ }
+}
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/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' }
+ })
+ );
+ });
+});