Commit 6b1974f5 authored by Payton Burdette's avatar Payton Burdette Committed by Andrew Fontaine

Refactor pipeline form

Refactor the new pipeline
form from HAML to Vue.
parent 0d9183ba
......@@ -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);
......
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',
});
}
});
<script>
import Vue from 'vue';
import { s__, __ } from '~/locale';
import Api from '~/api';
import { redirectTo } from '~/lib/utils/url_utility';
import { VARIABLE_TYPE, FILE_TYPE } from '../constants';
import { uniqueId } from 'lodash';
import {
GlAlert,
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlFormSelect,
GlLink,
GlNewDropdown,
GlNewDropdownItem,
GlSearchBoxByType,
GlSprintf,
} from '@gitlab/ui';
export default {
typeOptions: [
{ value: VARIABLE_TYPE, text: __('Variable') },
{ value: FILE_TYPE, text: __('File') },
],
variablesDescription: s__(
'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
),
formElementClasses: 'gl-mr-3 gl-mb-3 table-section section-15',
errorTitle: __('The form contains the following error:'),
components: {
GlAlert,
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlFormSelect,
GlLink,
GlNewDropdown,
GlNewDropdownItem,
GlSearchBoxByType,
GlSprintf,
},
props: {
pipelinesPath: {
type: String,
required: true,
},
projectId: {
type: String,
required: true,
},
refs: {
type: Array,
required: true,
},
settingsLink: {
type: String,
required: true,
},
fileParams: {
type: Object,
required: false,
default: () => ({}),
},
refParam: {
type: String,
required: false,
default: '',
},
variableParams: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
searchTerm: '',
refValue: this.refParam,
variables: {},
error: false,
};
},
computed: {
filteredRefs() {
const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
return this.refs.filter(ref => ref.toLowerCase().includes(lowerCasedSearchTerm));
},
variablesLength() {
return Object.keys(this.variables).length;
},
},
created() {
if (this.variableParams) {
this.setVariableParams(VARIABLE_TYPE, this.variableParams);
}
if (this.fileParams) {
this.setVariableParams(FILE_TYPE, this.fileParams);
}
this.addEmptyVariable();
},
methods: {
addEmptyVariable() {
this.variables[uniqueId('var')] = {
variable_type: VARIABLE_TYPE,
key: '',
value: '',
};
},
setVariableParams(type, paramsObj) {
Object.entries(paramsObj).forEach(([key, value]) => {
this.variables[uniqueId('var')] = {
key,
value,
variable_type: type,
};
});
},
setRefSelected(ref) {
this.refValue = ref;
},
isSelected(ref) {
return ref === this.refValue;
},
insertNewVariable() {
Vue.set(this.variables, uniqueId('var'), {
variable_type: VARIABLE_TYPE,
key: '',
value: '',
});
},
removeVariable(key) {
Vue.delete(this.variables, key);
},
canRemove(index) {
return index < this.variablesLength - 1;
},
createPipeline() {
const filteredVariables = Object.values(this.variables).filter(
({ key, value }) => key !== '' && value !== '',
);
return Api.createPipeline(this.projectId, {
ref: this.refValue,
variables: filteredVariables,
})
.then(({ data }) => redirectTo(data.web_url))
.catch(err => {
this.error = err.response.data.message.base;
});
},
},
};
</script>
<template>
<gl-form @submit.prevent="createPipeline">
<gl-alert
v-if="error"
:title="$options.errorTitle"
:dismissible="false"
variant="danger"
class="gl-mb-4"
>{{ error }}</gl-alert
>
<gl-form-group :label="s__('Pipeline|Run for')">
<gl-new-dropdown :text="refValue" block>
<gl-search-box-by-type
v-model.trim="searchTerm"
:placeholder="__('Search branches and tags')"
class="gl-p-2"
/>
<gl-new-dropdown-item
v-for="(ref, index) in filteredRefs"
:key="index"
class="gl-font-monospace"
is-check-item
:is-checked="isSelected(ref)"
@click="setRefSelected(ref)"
>
{{ ref }}
</gl-new-dropdown-item>
</gl-new-dropdown>
<template #description>
<div>
{{ s__('Pipeline|Existing branch name or tag') }}
</div></template
>
</gl-form-group>
<gl-form-group :label="s__('Pipeline|Variables')">
<div
v-for="(value, key, index) in variables"
:key="key"
class="gl-display-flex gl-align-items-center gl-mb-4 gl-pb-2 gl-border-b-solid gl-border-gray-200 gl-border-b-1 gl-flex-direction-column gl-md-flex-direction-row"
data-testid="ci-variable-row"
>
<gl-form-select
v-model="variables[key].variable_type"
:class="$options.formElementClasses"
:options="$options.typeOptions"
/>
<gl-form-input
v-model="variables[key].key"
:placeholder="s__('CiVariables|Input variable key')"
:class="$options.formElementClasses"
data-testid="pipeline-form-ci-variable-key"
@change.once="insertNewVariable()"
/>
<gl-form-input
v-model="variables[key].value"
:placeholder="s__('CiVariables|Input variable value')"
class="gl-mr-5 gl-mb-3 table-section section-15"
/>
<gl-button
v-if="canRemove(index)"
icon="issue-close"
class="gl-mb-3"
data-testid="remove-ci-variable-row"
@click="removeVariable(key)"
/>
</div>
<template #description
><gl-sprintf :message="$options.variablesDescription">
<template #link="{ content }">
<gl-link :href="settingsLink">{{ content }}</gl-link>
</template>
</gl-sprintf></template
>
</gl-form-group>
<div
class="gl-border-t-solid gl-border-gray-100 gl-border-t-1 gl-p-5 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between"
>
<gl-button type="submit" category="primary" variant="success">{{
s__('Pipeline|Run Pipeline')
}}</gl-button>
<gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button>
</div>
</gl-form>
</template>
export const VARIABLE_TYPE = 'env_var';
export const FILE_TYPE = 'file';
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,
},
});
},
});
};
......@@ -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]
......
......@@ -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
......@@ -17281,6 +17281,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 ""
......@@ -23510,6 +23513,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 ""
......
......@@ -8,6 +8,7 @@ RSpec.describe "Populate new pipeline CI variables with url params", :js do
let(:page_path) { new_project_pipeline_path(project) }
before do
stub_feature_flags(new_pipeline_form: false)
sign_in(user)
project.add_maintainer(user)
......
......@@ -652,6 +652,7 @@ RSpec.describe 'Pipelines', :js do
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 @@ RSpec.describe 'Pipelines', :js do
let(:project) { create(:project, :repository) }
before do
stub_feature_flags(new_pipeline_form: false)
visit new_project_pipeline_path(project)
end
......
......@@ -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',
},
});
});
});
});
});
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);
});
});
});
});
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' },
],
};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment