diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 246231d969bf419f3c024d18bcb9f51401cea297..64b55b4d12fa912a1dda2af1efd1e7179b3e23da 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -55,6 +55,7 @@ const Api = { adminStatisticsPath: '/api/:version/application/statistics', pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', pipelinesPath: '/api/:version/projects/:id/pipelines/', + createPipelinePath: '/api/:version/projects/:id/pipeline', environmentsPath: '/api/:version/projects/:id/environments', rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw', issuePath: '/api/:version/projects/:id/issues/:issue_iid', @@ -576,6 +577,16 @@ const Api = { }); }, + createPipeline(id, data) { + const url = Api.buildUrl(this.createPipelinePath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, data, { + headers: { + 'Content-Type': 'application/json', + }, + }); + }, + environments(id) { const url = Api.buildUrl(this.environmentsPath).replace(':id', encodeURIComponent(id)); return axios.get(url); diff --git a/app/assets/javascripts/pages/projects/pipelines/new/index.js b/app/assets/javascripts/pages/projects/pipelines/new/index.js index b0b077a5e4c09d1652668ce34b5435ff3db644ed..d5563143f0c06d03c580c251f6d2572a1e58f333 100644 --- a/app/assets/javascripts/pages/projects/pipelines/new/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/new/index.js @@ -1,12 +1,19 @@ import $ from 'jquery'; import NewBranchForm from '~/new_branch_form'; import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; +import initNewPipeline from '~/pipeline_new/index'; document.addEventListener('DOMContentLoaded', () => { - new NewBranchForm($('.js-new-pipeline-form')); // eslint-disable-line no-new + const el = document.getElementById('js-new-pipeline'); - setupNativeFormVariableList({ - container: $('.js-ci-variable-list-section'), - formField: 'variables_attributes', - }); + if (el) { + initNewPipeline(); + } else { + new NewBranchForm($('.js-new-pipeline-form')); // eslint-disable-line no-new + + setupNativeFormVariableList({ + container: $('.js-ci-variable-list-section'), + formField: 'variables_attributes', + }); + } }); diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue new file mode 100644 index 0000000000000000000000000000000000000000..c2c5e58eedd0e354c9ea195c6c4cb8cfd87b3d4c --- /dev/null +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -0,0 +1,247 @@ + + + diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..b4ab1143f604e902563bce845a9a8cc0e0b3f342 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/constants.js @@ -0,0 +1,2 @@ +export const VARIABLE_TYPE = 'env_var'; +export const FILE_TYPE = 'file'; diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js new file mode 100644 index 0000000000000000000000000000000000000000..1c4812c2e0e98ddd61ed7bd7c54f3f3c5ce69801 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/index.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import PipelineNewForm from './components/pipeline_new_form.vue'; + +export default () => { + const el = document.getElementById('js-new-pipeline'); + const { + projectId, + pipelinesPath, + refParam, + varParam, + fileParam, + refNames, + settingsLink, + } = el?.dataset; + + const variableParams = JSON.parse(varParam); + const fileParams = JSON.parse(fileParam); + const refs = JSON.parse(refNames); + + return new Vue({ + el, + render(createElement) { + return createElement(PipelineNewForm, { + props: { + projectId, + pipelinesPath, + refParam, + variableParams, + fileParams, + refs, + settingsLink, + }, + }); + }, + }); +}; diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index d8e11ddd423200d0f1cae976b0101d3e89917bb3..fde2a7e5d92b6392d411d8793a42b381fff14d21 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(:filter_pipelines_search, project, default_enabled: true) push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true) push_frontend_feature_flag(:pipelines_security_report_summary, project) + push_frontend_feature_flag(:new_pipeline_form, default_enabled: true) 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 a3e46a0939cd7c4ebd008229da362b2c5d385db1..11fdbd3138272c29a067d698755b0680a4009b17 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -6,37 +6,41 @@ = s_('Pipeline|Run Pipeline') %hr -= 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| - = form_errors(@pipeline) - .form-group.row - .col-sm-12 - = f.label :ref, s_('Pipeline|Run for'), class: 'col-form-label' - = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch - = dropdown_tag(params[:ref] || @project.default_branch, - options: { toggle_class: 'js-branch-select wide monospace', - filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: s_("Pipeline|Search branches"), - data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) - .form-text.text-muted - = s_("Pipeline|Existing branch name or tag") +- if Feature.enabled?(:new_pipeline_form, default_enabled: true) + #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) } } - .col-sm-12.prepend-top-10.js-ci-variable-list-section - %label - = s_('Pipeline|Variables') - %ul.ci-variable-list - - if params[:var] - - params[:var].each do |variable| - = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable - - if params[:file_var] - - params[:file_var].each do |variable| - - variable.push("file") - = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable - = render 'ci/variables/variable_row', form_field: 'pipeline', only_key_value: true - .form-text.text-muted - = (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe +- 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| + = form_errors(@pipeline) + .form-group.row + .col-sm-12 + = f.label :ref, s_('Pipeline|Run for'), class: 'col-form-label' + = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch + = dropdown_tag(params[:ref] || @project.default_branch, + options: { toggle_class: 'js-branch-select wide monospace', + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: s_("Pipeline|Search branches"), + data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) + .form-text.text-muted + = s_("Pipeline|Existing branch name or tag") - .form-actions - = f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3 - = link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right' + .col-sm-12.prepend-top-10.js-ci-variable-list-section + %label + = s_('Pipeline|Variables') + %ul.ci-variable-list + - if params[:var] + - params[:var].each do |variable| + = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable + - if params[:file_var] + - params[:file_var].each do |variable| + - variable.push("file") + = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable + = render 'ci/variables/variable_row', form_field: 'pipeline', only_key_value: true + .form-text.text-muted + = (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe --# haml-lint:disable InlineJavaScript -%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe + .form-actions + = f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3 + = link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right' + + -# haml-lint:disable InlineJavaScript + %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5bc105ee9439057bac0f09793c11d3aa2eb3f79c..0615e5edacb9ad64750d31d76e85dd3e2ebf3556 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -17275,6 +17275,9 @@ msgstr "" msgid "Pipeline|Skipped" msgstr "" +msgid "Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default." +msgstr "" + msgid "Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default." msgstr "" @@ -23504,6 +23507,9 @@ msgstr[1] "" msgid "The fork relationship has been removed." msgstr "" +msgid "The form contains the following error:" +msgstr "" + msgid "The global settings require you to enable Two-Factor Authentication for your account." msgstr "" diff --git a/spec/features/populate_new_pipeline_vars_with_params_spec.rb b/spec/features/populate_new_pipeline_vars_with_params_spec.rb index f931e8497fca5da749e8858c663f9807b56a123e..37fea5331a3708353a01d34ac7daddd9deaf746c 100644 --- a/spec/features/populate_new_pipeline_vars_with_params_spec.rb +++ b/spec/features/populate_new_pipeline_vars_with_params_spec.rb @@ -8,6 +8,7 @@ let(:page_path) { new_project_pipeline_path(project) } before do + stub_feature_flags(new_pipeline_form: false) sign_in(user) project.add_maintainer(user) diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 0eb92f3e679662dc7c3d34636106fd30bc7b915b..8747b3ab54c8caaac1e4db43919bf61953069790 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -652,6 +652,7 @@ def create_build(stage, stage_idx, name, status) let(:project) { create(:project, :repository) } before do + stub_feature_flags(new_pipeline_form: false) visit new_project_pipeline_path(project) end @@ -718,6 +719,7 @@ def create_build(stage, stage_idx, name, status) let(:project) { create(:project, :repository) } before do + stub_feature_flags(new_pipeline_form: false) visit new_project_pipeline_path(project) end diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index b4e25867fad6dd559a64b232f533ae17a3b48767..b76cfe6204d54d676f4288747154ec817503ef55 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -891,4 +891,34 @@ describe('Api', () => { }); }); }); + + describe('createPipeline', () => { + it('creates new pipeline', () => { + const redirectUrl = 'ci-project/-/pipelines/95'; + const projectId = 8; + const postData = { + ref: 'tag-1', + variables: [ + { key: 'test_file', value: 'test_file_val', variable_type: 'file' }, + { key: 'test_var', value: 'test_var_val', variable_type: 'env_var' }, + ], + }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipeline`; + + jest.spyOn(axios, 'post'); + + mock.onPost(expectedUrl).replyOnce(200, { + web_url: redirectUrl, + }); + + return Api.createPipeline(projectId, postData).then(({ data }) => { + expect(data.web_url).toBe(redirectUrl); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + }); + }); }); diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5ad1cdbfa511a6e6227a92c462b70cf84342e242 --- /dev/null +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -0,0 +1,108 @@ +import Api from '~/api'; +import { mount, shallowMount } from '@vue/test-utils'; +import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; +import { GlNewDropdown, GlNewDropdownItem, GlForm } from '@gitlab/ui'; +import { mockRefs, mockParams, mockPostParams, mockProjectId } from '../mock_data'; + +describe('Pipeline New Form', () => { + let wrapper; + + const dummySubmitEvent = { + preventDefault() {}, + }; + + const findForm = () => wrapper.find(GlForm); + const findDropdown = () => wrapper.find(GlNewDropdown); + const findDropdownItems = () => wrapper.findAll(GlNewDropdownItem); + 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 createComponent = (term = '', props = {}, method = shallowMount) => { + wrapper = method(PipelineNewForm, { + propsData: { + projectId: mockProjectId, + pipelinesPath: '', + refs: mockRefs, + defaultBranch: 'master', + settingsLink: '', + ...props, + }, + data() { + return { + searchTerm: term, + }; + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'createPipeline').mockResolvedValue({ data: { web_url: '/' } }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('Dropdown with branches and tags', () => { + it('displays dropdown with all branches and tags', () => { + createComponent(); + expect(findDropdownItems().length).toBe(mockRefs.length); + }); + + it('when user enters search term the list is filtered', () => { + createComponent('master'); + + expect(findDropdownItems().length).toBe(1); + expect( + findDropdownItems() + .at(0) + .text(), + ).toBe('master'); + }); + }); + + describe('Form', () => { + beforeEach(() => { + createComponent('', mockParams, mount); + }); + it('displays the correct values for the provided query params', () => { + expect(findDropdown().props('text')).toBe('tag-1'); + + return wrapper.vm.$nextTick().then(() => { + expect(findVariableRows().length).toBe(3); + }); + }); + + it('does not display remove icon for last row', () => { + expect(findRemoveIcons().length).toBe(2); + }); + + it('removes ci variable row on remove icon button click', () => { + findRemoveIcons() + .at(1) + .trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findVariableRows().length).toBe(2); + }); + }); + + it('creates a pipeline on submit', () => { + findForm().vm.$emit('submit', dummySubmitEvent); + + expect(Api.createPipeline).toHaveBeenCalledWith(mockProjectId, mockPostParams); + }); + + it('creates blank variable on input change event', () => { + findKeyInputs() + .at(2) + .trigger('change'); + + return wrapper.vm.$nextTick().then(() => { + expect(findVariableRows().length).toBe(4); + }); + }); + }); +}); diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..55ec1fb5afc554d887f8e06c5bcab70eb6ca6471 --- /dev/null +++ b/spec/frontend/pipeline_new/mock_data.js @@ -0,0 +1,21 @@ +export const mockRefs = ['master', 'branch-1', 'tag-1']; + +export const mockParams = { + refParam: 'tag-1', + variableParams: { + test_var: 'test_var_val', + }, + fileParams: { + test_file: 'test_file_val', + }, +}; + +export const mockProjectId = '21'; + +export const mockPostParams = { + ref: 'tag-1', + variables: [ + { key: 'test_var', value: 'test_var_val', variable_type: 'env_var' }, + { key: 'test_file', value: 'test_file_val', variable_type: 'file' }, + ], +};