From 33d4e66de0decee8bdc8225b13ab69d45ac6dcb1 Mon Sep 17 00:00:00 2001 From: Miguel Rincon <mrincon@gitlab.com> Date: Fri, 16 Oct 2020 13:40:32 +0000 Subject: [PATCH] Load configured variables when ref changes This changes loads the variables from the CI file and puts them in the "Run pipeline" form as default variables. --- .../components/pipeline_new_form.vue | 203 ++++++++++++------ app/assets/javascripts/pipeline_new/index.js | 2 + .../projects/pipelines_controller.rb | 1 + app/views/projects/pipelines/new.html.haml | 10 +- .../new_pipeline_form_prefilled_vars.yml | 7 + .../components/pipeline_new_form_spec.js | 165 +++++++++++++- 6 files changed, 313 insertions(+), 75 deletions(-) create mode 100644 config/feature_flags/development/new_pipeline_form_prefilled_vars.yml diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index b05cf080aea6..5841716c8c5f 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -1,4 +1,5 @@ <script> +import Vue from 'vue'; import { uniqueId } from 'lodash'; import { GlAlert, @@ -50,6 +51,10 @@ export default { type: String, required: true, }, + configVariablesPath: { + type: String, + required: true, + }, projectId: { type: String, required: true, @@ -86,7 +91,7 @@ export default { return { searchTerm: '', refValue: this.refParam, - variables: [], + form: {}, error: null, warnings: [], totalWarnings: 0, @@ -110,60 +115,122 @@ export default { shouldShowWarning() { return this.warnings.length > 0 && !this.isWarningDismissed; }, + variables() { + return this.form[this.refValue]?.variables ?? []; + }, + descriptions() { + return this.form[this.refValue]?.descriptions ?? {}; + }, }, created() { - this.addEmptyVariable(); - - if (this.variableParams) { - this.setVariableParams(VARIABLE_TYPE, this.variableParams); - } - - if (this.fileParams) { - this.setVariableParams(FILE_TYPE, this.fileParams); - } + this.setRefSelected(this.refValue); }, methods: { - setVariable(type, key, value) { - const variable = this.variables.find(v => v.key === key); + addEmptyVariable(refValue) { + const { variables } = this.form[refValue]; + + const lastVar = variables[variables.length - 1]; + if (lastVar?.key === '' && lastVar?.value === '') { + return; + } + + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), + variable_type: VARIABLE_TYPE, + key: '', + value: '', + }); + }, + setVariable(refValue, type, key, value) { + const { variables } = this.form[refValue]; + + const variable = variables.find(v => v.key === key); if (variable) { variable.type = type; variable.value = value; } else { - // insert before the empty variable - this.variables.splice(this.variables.length - 1, 0, { - uniqueId: uniqueId('var'), + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), key, value, variable_type: type, }); } }, - setVariableParams(type, paramsObj) { + setVariableParams(refValue, type, paramsObj) { Object.entries(paramsObj).forEach(([key, value]) => { - this.setVariable(type, key, value); + this.setVariable(refValue, type, key, value); }); }, - setRefSelected(ref) { - this.refValue = ref; + setRefSelected(refValue) { + this.refValue = refValue; + + if (!this.form[refValue]) { + this.fetchConfigVariables(refValue) + .then(({ descriptions, params }) => { + Vue.set(this.form, refValue, { + variables: [], + descriptions, + }); + + // Add default variables from yml + this.setVariableParams(refValue, VARIABLE_TYPE, params); + }) + .catch(() => { + Vue.set(this.form, refValue, { + variables: [], + descriptions: {}, + }); + }) + .finally(() => { + // Add/update variables, e.g. from query string + if (this.variableParams) { + this.setVariableParams(refValue, VARIABLE_TYPE, this.variableParams); + } + if (this.fileParams) { + this.setVariableParams(refValue, FILE_TYPE, this.fileParams); + } + + // Adds empty var at the end of the form + this.addEmptyVariable(refValue); + }); + } }, + isSelected(ref) { return ref === this.refValue; }, - addEmptyVariable() { - this.variables.push({ - uniqueId: uniqueId('var'), - variable_type: VARIABLE_TYPE, - key: '', - value: '', - }); - }, removeVariable(index) { this.variables.splice(index, 1); }, - canRemove(index) { return index < this.variables.length - 1; }, + + fetchConfigVariables(refValue) { + if (gon?.features?.newPipelineFormPrefilledVars) { + return axios + .get(this.configVariablesPath, { + params: { + sha: refValue, + }, + }) + .then(({ data }) => { + const params = {}; + const descriptions = {}; + + Object.entries(data).forEach(([key, { value, description }]) => { + if (description !== null) { + params[key] = value; + descriptions[key] = description; + } + }); + + return { params, descriptions }; + }); + } + return Promise.resolve({ params: {}, descriptions: {} }); + }, createPipeline() { const filteredVariables = this.variables .filter(({ key, value }) => key !== '' && value !== '') @@ -261,45 +328,53 @@ export default { <div v-for="(variable, index) in variables" :key="variable.uniqueId" - class="gl-display-flex gl-align-items-stretch gl-align-items-center gl-mb-4 gl-ml-n3 gl-pb-2 gl-border-b-solid gl-border-gray-200 gl-border-b-1 gl-flex-direction-column gl-md-flex-direction-row" + class="gl-mb-3 gl-ml-n3 gl-pb-2" data-testid="ci-variable-row" > - <gl-form-select - v-model="variable.variable_type" - :class="$options.formElementClasses" - :options="$options.typeOptions" - /> - <gl-form-input - v-model="variable.key" - :placeholder="s__('CiVariables|Input variable key')" - :class="$options.formElementClasses" - data-testid="pipeline-form-ci-variable-key" - @change.once="addEmptyVariable()" - /> - <gl-form-input - v-model="variable.value" - :placeholder="s__('CiVariables|Input variable value')" - class="gl-mb-3" - /> - - <template v-if="variables.length > 1"> - <gl-button - v-if="canRemove(index)" - class="gl-md-ml-3 gl-mb-3" - data-testid="remove-ci-variable-row" - variant="danger" - category="secondary" - @click="removeVariable(index)" - > - <gl-icon class="gl-mr-0! gl-display-none gl-display-md-block" name="clear" /> - <span class="gl-display-md-none">{{ s__('CiVariables|Remove variable') }}</span> - </gl-button> - <gl-button - v-else - class="gl-md-ml-3 gl-mb-3 gl-display-none gl-display-md-block gl-visibility-hidden" - icon="clear" + <div + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" + > + <gl-form-select + v-model="variable.variable_type" + :class="$options.formElementClasses" + :options="$options.typeOptions" + /> + <gl-form-input + v-model="variable.key" + :placeholder="s__('CiVariables|Input variable key')" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-key" + @change="addEmptyVariable(refValue)" + /> + <gl-form-input + v-model="variable.value" + :placeholder="s__('CiVariables|Input variable value')" + class="gl-mb-3" + data-testid="pipeline-form-ci-variable-value" /> - </template> + + <template v-if="variables.length > 1"> + <gl-button + v-if="canRemove(index)" + class="gl-md-ml-3 gl-mb-3" + data-testid="remove-ci-variable-row" + variant="danger" + category="secondary" + @click="removeVariable(index)" + > + <gl-icon class="gl-mr-0! gl-display-none gl-display-md-block" name="clear" /> + <span class="gl-display-md-none">{{ s__('CiVariables|Remove variable') }}</span> + </gl-button> + <gl-button + v-else + class="gl-md-ml-3 gl-mb-3 gl-display-none gl-display-md-block gl-visibility-hidden" + icon="clear" + /> + </template> + </div> + <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> + {{ descriptions[variable.key] }} + </div> </div> <template #description diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index f1ea86f8c5fb..ff4f677654e7 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -6,6 +6,7 @@ export default () => { const { projectId, pipelinesPath, + configVariablesPath, refParam, varParam, fileParam, @@ -25,6 +26,7 @@ export default () => { props: { projectId, pipelinesPath, + configVariablesPath, refParam, variableParams, fileParams, diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 39dd7a9899d9..953dce4d63c8 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -17,6 +17,7 @@ class Projects::PipelinesController < Projects::ApplicationController push_frontend_feature_flag(:pipelines_security_report_summary, project) push_frontend_feature_flag(:new_pipeline_form, project) push_frontend_feature_flag(:graphql_pipeline_header, project, type: :development, default_enabled: false) + push_frontend_feature_flag(:new_pipeline_form_prefilled_vars, project, type: :development) end before_action :ensure_pipeline, only: [:show] diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 29e5f2cf5b48..cb5401cd3293 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -7,7 +7,15 @@ %hr - if Feature.enabled?(:new_pipeline_form, @project) - #js-new-pipeline{ data: { project_id: @project.id, pipelines_path: project_pipelines_path(@project), ref_param: params[:ref] || @project.default_branch, var_param: params[:var].to_json, file_param: params[:file_var].to_json, ref_names: @project.repository.ref_names.to_json.html_safe, settings_link: project_settings_ci_cd_path(@project), max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } } + #js-new-pipeline{ data: { project_id: @project.id, + pipelines_path: project_pipelines_path(@project), + config_variables_path: config_variables_namespace_project_pipelines_path(@project.namespace, @project), + ref_param: params[:ref] || @project.default_branch, + var_param: params[:var].to_json, + file_param: params[:file_var].to_json, + ref_names: @project.repository.ref_names.to_json.html_safe, + settings_link: project_settings_ci_cd_path(@project), + max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } } - else = form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "js-new-pipeline-form js-requires-input" } do |f| diff --git a/config/feature_flags/development/new_pipeline_form_prefilled_vars.yml b/config/feature_flags/development/new_pipeline_form_prefilled_vars.yml new file mode 100644 index 000000000000..6b821e7fd9e7 --- /dev/null +++ b/config/feature_flags/development/new_pipeline_form_prefilled_vars.yml @@ -0,0 +1,7 @@ +--- +name: new_pipeline_form_prefilled_vars +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44120 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/263276 +type: development +group: group::continuous integration +default_enabled: false diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 97a92778f1a9..040c0fbecc59 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -2,6 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils'; import { GlDropdown, GlDropdownItem, GlForm, GlSprintf } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; +import httpStatusCodes from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; import { mockRefs, mockParams, mockPostParams, mockProjectId, mockError } from '../mock_data'; @@ -11,7 +12,8 @@ jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), })); -const pipelinesPath = '/root/project/-/pipleines'; +const pipelinesPath = '/root/project/-/pipelines'; +const configVariablesPath = '/root/project/-/pipelines/config_variables'; const postResponse = { id: 1 }; describe('Pipeline New Form', () => { @@ -28,6 +30,7 @@ describe('Pipeline New Form', () => { const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); const findKeyInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-key"]'); + const findValueInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-value"]'); const findErrorAlert = () => wrapper.find('[data-testid="run-pipeline-error-alert"]'); const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]'); const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf); @@ -39,6 +42,7 @@ describe('Pipeline New Form', () => { propsData: { projectId: mockProjectId, pipelinesPath, + configVariablesPath, refs: mockRefs, defaultBranch: 'master', settingsLink: '', @@ -55,6 +59,7 @@ describe('Pipeline New Form', () => { beforeEach(() => { mock = new MockAdapter(axios); + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {}); }); afterEach(() => { @@ -66,7 +71,7 @@ describe('Pipeline New Form', () => { describe('Dropdown with branches and tags', () => { beforeEach(() => { - mock.onPost(pipelinesPath).reply(200, postResponse); + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); }); it('displays dropdown with all branches and tags', () => { @@ -87,17 +92,27 @@ describe('Pipeline New Form', () => { }); describe('Form', () => { - beforeEach(() => { + beforeEach(async () => { createComponent('', mockParams, mount); - mock.onPost(pipelinesPath).reply(200, postResponse); + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); + + await waitForPromises(); }); + it('displays the correct values for the provided query params', async () => { expect(findDropdown().props('text')).toBe('tag-1'); + expect(findVariableRows()).toHaveLength(3); + }); - await wrapper.vm.$nextTick(); + it('displays a variable from provided query params', () => { + expect(findKeyInputs().at(0).element.value).toBe('test_var'); + expect(findValueInputs().at(0).element.value).toBe('test_var_val'); + }); - expect(findVariableRows()).toHaveLength(3); + it('displays an empty variable for the user to fill out', async () => { + expect(findKeyInputs().at(2).element.value).toBe(''); + expect(findValueInputs().at(2).element.value).toBe(''); }); it('does not display remove icon for last row', () => { @@ -124,13 +139,143 @@ describe('Pipeline New Form', () => { }); it('creates blank variable on input change event', async () => { - findKeyInputs() - .at(2) - .trigger('change'); + const input = findKeyInputs().at(2); + input.element.value = 'test_var_2'; + input.trigger('change'); await wrapper.vm.$nextTick(); expect(findVariableRows()).toHaveLength(4); + expect(findKeyInputs().at(3).element.value).toBe(''); + expect(findValueInputs().at(3).element.value).toBe(''); + }); + + describe('when the form has been modified', () => { + const selectRef = i => + findDropdownItems() + .at(i) + .vm.$emit('click'); + + beforeEach(async () => { + const input = findKeyInputs().at(0); + input.element.value = 'test_var_2'; + input.trigger('change'); + + findRemoveIcons() + .at(1) + .trigger('click'); + + await wrapper.vm.$nextTick(); + }); + + it('form values are restored when the ref changes', async () => { + expect(findVariableRows()).toHaveLength(2); + + selectRef(1); + await waitForPromises(); + + expect(findVariableRows()).toHaveLength(3); + expect(findKeyInputs().at(0).element.value).toBe('test_var'); + }); + + it('form values are restored again when the ref is reverted', async () => { + selectRef(1); + await waitForPromises(); + + selectRef(2); + await waitForPromises(); + + expect(findVariableRows()).toHaveLength(2); + expect(findKeyInputs().at(0).element.value).toBe('test_var_2'); + }); + }); + }); + + describe('when feature flag new_pipeline_form_prefilled_vars is enabled', () => { + let origGon; + + const mockYmlKey = 'yml_var'; + const mockYmlValue = 'yml_var_val'; + const mockYmlDesc = 'A var from yml.'; + + beforeAll(() => { + origGon = window.gon; + window.gon = { features: { newPipelineFormPrefilledVars: true } }; + }); + + afterAll(() => { + window.gon = origGon; + }); + + describe('when yml defines a variable with description', () => { + beforeEach(async () => { + createComponent('', mockParams, mount); + + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { + [mockYmlKey]: { + value: mockYmlValue, + description: mockYmlDesc, + }, + }); + + await waitForPromises(); + }); + + it('displays all the variables', async () => { + expect(findVariableRows()).toHaveLength(4); + }); + + it('displays a variable from yml', () => { + expect(findKeyInputs().at(0).element.value).toBe(mockYmlKey); + expect(findValueInputs().at(0).element.value).toBe(mockYmlValue); + }); + + it('displays a variable from provided query params', () => { + expect(findKeyInputs().at(1).element.value).toBe('test_var'); + expect(findValueInputs().at(1).element.value).toBe('test_var_val'); + }); + + it('adds a description to the first variable from yml', () => { + expect( + findVariableRows() + .at(0) + .text(), + ).toContain(mockYmlDesc); + }); + + it('removes the description when a variable key changes', async () => { + findKeyInputs().at(0).element.value = 'yml_var_modified'; + findKeyInputs() + .at(0) + .trigger('change'); + + await wrapper.vm.$nextTick(); + + expect( + findVariableRows() + .at(0) + .text(), + ).not.toContain(mockYmlDesc); + }); + }); + + describe('when yml defines a variable without description', () => { + beforeEach(async () => { + createComponent('', mockParams, mount); + + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { + [mockYmlKey]: { + value: mockYmlValue, + description: null, + }, + }); + + await waitForPromises(); + }); + + it('displays all the variables', async () => { + expect(findVariableRows()).toHaveLength(3); + }); }); }); @@ -138,7 +283,7 @@ describe('Pipeline New Form', () => { beforeEach(() => { createComponent(); - mock.onPost(pipelinesPath).reply(400, mockError); + mock.onPost(pipelinesPath).reply(httpStatusCodes.BAD_REQUEST, mockError); findForm().vm.$emit('submit', dummySubmitEvent); -- GitLab