Commit e5826739 authored by Miguel Rincon's avatar Miguel Rincon

Load refs for the pipeline on demand

The pipeline form does not require all the refs in order to be
submitted. This change waits for the user to list them before loading
them, improving the performance of the form.
parent 19afbc58
...@@ -9,10 +9,6 @@ import { ...@@ -9,10 +9,6 @@ import {
GlFormSelect, GlFormSelect,
GlFormTextarea, GlFormTextarea,
GlLink, GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlSprintf, GlSprintf,
GlLoadingIcon, GlLoadingIcon,
GlSafeHtmlDirective as SafeHtml, GlSafeHtmlDirective as SafeHtml,
...@@ -26,19 +22,26 @@ import httpStatusCodes from '~/lib/utils/http_status'; ...@@ -26,19 +22,26 @@ import httpStatusCodes from '~/lib/utils/http_status';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import { s__, __, n__ } from '~/locale'; import { s__, __, n__ } from '~/locale';
import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants'; import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants';
import RefsDropdown from './refs_dropdown.vue';
const i18n = {
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.',
),
defaultError: __('Something went wrong on our end. Please try again.'),
refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'),
submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'),
warningTitle: __('The form contains the following warning:'),
maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
};
export default { export default {
typeOptions: [ typeOptions: [
{ value: VARIABLE_TYPE, text: __('Variable') }, { value: VARIABLE_TYPE, text: __('Variable') },
{ value: FILE_TYPE, text: __('File') }, { value: FILE_TYPE, text: __('File') },
], ],
variablesDescription: s__( i18n,
'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 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0',
errorTitle: __('Pipeline cannot be run.'),
warningTitle: __('The form contains the following warning:'),
maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
// this height value is used inline on the textarea to match the input field height // this height value is used inline on the textarea to match the input field height
// it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used // it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used
textAreaStyle: { height: '32px' }, textAreaStyle: { height: '32px' },
...@@ -52,12 +55,9 @@ export default { ...@@ -52,12 +55,9 @@ export default {
GlFormSelect, GlFormSelect,
GlFormTextarea, GlFormTextarea,
GlLink, GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlSprintf, GlSprintf,
GlLoadingIcon, GlLoadingIcon,
RefsDropdown,
}, },
directives: { SafeHtml }, directives: { SafeHtml },
props: { props: {
...@@ -77,14 +77,6 @@ export default { ...@@ -77,14 +77,6 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
branches: {
type: Array,
required: true,
},
tags: {
type: Array,
required: true,
},
settingsLink: { settingsLink: {
type: String, type: String,
required: true, required: true,
...@@ -111,11 +103,11 @@ export default { ...@@ -111,11 +103,11 @@ export default {
}, },
data() { data() {
return { return {
searchTerm: '',
refValue: { refValue: {
shortName: this.refParam, shortName: this.refParam,
}, },
form: {}, form: {},
errorTitle: null,
error: null, error: null,
warnings: [], warnings: [],
totalWarnings: 0, totalWarnings: 0,
...@@ -125,22 +117,6 @@ export default { ...@@ -125,22 +117,6 @@ export default {
}; };
}, },
computed: { computed: {
lowerCasedSearchTerm() {
return this.searchTerm.toLowerCase();
},
filteredBranches() {
return this.branches.filter((branch) =>
branch.shortName.toLowerCase().includes(this.lowerCasedSearchTerm),
);
},
filteredTags() {
return this.tags.filter((tag) =>
tag.shortName.toLowerCase().includes(this.lowerCasedSearchTerm),
);
},
hasTags() {
return this.tags.length > 0;
},
overMaxWarningsLimit() { overMaxWarningsLimit() {
return this.totalWarnings > this.maxWarnings; return this.totalWarnings > this.maxWarnings;
}, },
...@@ -148,7 +124,7 @@ export default { ...@@ -148,7 +124,7 @@ export default {
return n__('%d warning found:', '%d warnings found:', this.warnings.length); return n__('%d warning found:', '%d warnings found:', this.warnings.length);
}, },
summaryMessage() { summaryMessage() {
return this.overMaxWarningsLimit ? this.$options.maxWarningsSummary : this.warningsSummary; return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary;
}, },
shouldShowWarning() { shouldShowWarning() {
return this.warnings.length > 0 && !this.isWarningDismissed; return this.warnings.length > 0 && !this.isWarningDismissed;
...@@ -166,6 +142,11 @@ export default { ...@@ -166,6 +142,11 @@ export default {
return this.form[this.refFullName]?.descriptions ?? {}; return this.form[this.refFullName]?.descriptions ?? {};
}, },
}, },
watch: {
refValue() {
this.loadConfigVariablesForm();
},
},
created() { created() {
// this is needed until we add support for ref type in url query strings // this is needed until we add support for ref type in url query strings
// ensure default branch is called with full ref on load // ensure default branch is called with full ref on load
...@@ -174,7 +155,7 @@ export default { ...@@ -174,7 +155,7 @@ export default {
this.refValue.fullName = `refs/heads/${this.refValue.shortName}`; this.refValue.fullName = `refs/heads/${this.refValue.shortName}`;
} }
this.setRefSelected(this.refValue); this.loadConfigVariablesForm();
}, },
methods: { methods: {
addEmptyVariable(refValue) { addEmptyVariable(refValue) {
...@@ -213,10 +194,18 @@ export default { ...@@ -213,10 +194,18 @@ export default {
this.setVariable(refValue, type, key, value); this.setVariable(refValue, type, key, value);
}); });
}, },
setRefSelected(refValue) { removeVariable(index) {
this.refValue = refValue; this.variables.splice(index, 1);
},
canRemove(index) {
return index < this.variables.length - 1;
},
loadConfigVariablesForm() {
// Skip when variables already cached in `form`
if (this.form[this.refFullName]) {
return;
}
if (!this.form[this.refFullName]) {
this.fetchConfigVariables(this.refFullName || this.refShortName) this.fetchConfigVariables(this.refFullName || this.refShortName)
.then(({ descriptions, params }) => { .then(({ descriptions, params }) => {
Vue.set(this.form, this.refFullName, { Vue.set(this.form, this.refFullName, {
...@@ -245,16 +234,6 @@ export default { ...@@ -245,16 +234,6 @@ export default {
// Adds empty var at the end of the form // Adds empty var at the end of the form
this.addEmptyVariable(this.refFullName); this.addEmptyVariable(this.refFullName);
}); });
}
},
isSelected(ref) {
return ref.fullName === this.refValue.fullName;
},
removeVariable(index) {
this.variables.splice(index, 1);
},
canRemove(index) {
return index < this.variables.length - 1;
}, },
fetchConfigVariables(refValue) { fetchConfigVariables(refValue) {
this.isLoading = true; this.isLoading = true;
...@@ -330,10 +309,24 @@ export default { ...@@ -330,10 +309,24 @@ export default {
} = err?.response?.data; } = err?.response?.data;
const [error] = errors; const [error] = errors;
this.reportError({
title: i18n.submitErrorTitle,
error,
warnings,
totalWarnings,
});
});
},
onRefsLoadingError(error) {
this.reportError({ title: i18n.refsLoadingErrorTitle });
Sentry.captureException(error);
},
reportError({ title = null, error = i18n.defaultError, warnings = [], totalWarnings = 0 }) {
this.errorTitle = title;
this.error = error; this.error = error;
this.warnings = warnings; this.warnings = warnings;
this.totalWarnings = totalWarnings; this.totalWarnings = totalWarnings;
});
}, },
}, },
}; };
...@@ -343,7 +336,7 @@ export default { ...@@ -343,7 +336,7 @@ export default {
<gl-form @submit.prevent="createPipeline"> <gl-form @submit.prevent="createPipeline">
<gl-alert <gl-alert
v-if="error" v-if="error"
:title="$options.errorTitle" :title="errorTitle"
:dismissible="false" :dismissible="false"
variant="danger" variant="danger"
class="gl-mb-4" class="gl-mb-4"
...@@ -353,7 +346,7 @@ export default { ...@@ -353,7 +346,7 @@ export default {
</gl-alert> </gl-alert>
<gl-alert <gl-alert
v-if="shouldShowWarning" v-if="shouldShowWarning"
:title="$options.warningTitle" :title="$options.i18n.warningTitle"
variant="warning" variant="warning"
class="gl-mb-4" class="gl-mb-4"
data-testid="run-pipeline-warning-alert" data-testid="run-pipeline-warning-alert"
...@@ -380,31 +373,7 @@ export default { ...@@ -380,31 +373,7 @@ export default {
</details> </details>
</gl-alert> </gl-alert>
<gl-form-group :label="s__('Pipeline|Run for branch name or tag')"> <gl-form-group :label="s__('Pipeline|Run for branch name or tag')">
<gl-dropdown :text="refShortName" block> <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" />
<gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search refs')" />
<gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="branch in filteredBranches"
:key="branch.fullName"
class="gl-font-monospace"
is-check-item
:is-checked="isSelected(branch)"
@click="setRefSelected(branch)"
>
{{ branch.shortName }}
</gl-dropdown-item>
<gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="tag in filteredTags"
:key="tag.fullName"
class="gl-font-monospace"
is-check-item
:is-checked="isSelected(tag)"
@click="setRefSelected(tag)"
>
{{ tag.shortName }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group> </gl-form-group>
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" /> <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" />
...@@ -465,7 +434,7 @@ export default { ...@@ -465,7 +434,7 @@ export default {
</div> </div>
<template #description <template #description
><gl-sprintf :message="$options.variablesDescription"> ><gl-sprintf :message="$options.i18n.variablesDescription">
<template #link="{ content }"> <template #link="{ content }">
<gl-link :href="settingsLink">{{ content }}</gl-link> <gl-link :href="settingsLink">{{ content }}</gl-link>
</template> </template>
......
<script>
import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { BRANCH_REF_TYPE, TAG_REF_TYPE, DEBOUNCE_REFS_SEARCH_MS } from '../constants';
import formatRefs from '../utils/format_refs';
export default {
components: {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
},
inject: ['projectRefsEndpoint'],
props: {
value: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
isLoading: false,
searchTerm: '',
branches: [],
tags: [],
};
},
computed: {
lowerCasedSearchTerm() {
return this.searchTerm.toLowerCase();
},
refShortName() {
return this.value.shortName;
},
hasTags() {
return this.tags.length > 0;
},
},
watch: {
searchTerm() {
this.debouncedLoadRefs();
},
},
methods: {
loadRefs() {
this.isLoading = true;
axios
.get(this.projectRefsEndpoint, {
params: {
search: this.lowerCasedSearchTerm,
},
})
.then(({ data }) => {
// Note: These keys are uppercase in API
const { Branches = [], Tags = [] } = data;
this.branches = formatRefs(Branches, BRANCH_REF_TYPE);
this.tags = formatRefs(Tags, TAG_REF_TYPE);
})
.catch((e) => {
this.$emit('loadingError', e);
})
.finally(() => {
this.isLoading = false;
});
},
debouncedLoadRefs: debounce(function debouncedLoadRefs() {
this.loadRefs();
}, DEBOUNCE_REFS_SEARCH_MS),
setRefSelected(ref) {
this.$emit('input', ref);
},
isSelected(ref) {
return ref.fullName === this.value.fullName;
},
},
};
</script>
<template>
<gl-dropdown :text="refShortName" block @show.once="loadRefs">
<gl-search-box-by-type
v-model.trim="searchTerm"
:is-loading="isLoading"
:placeholder="__('Search refs')"
/>
<gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="branch in branches"
:key="branch.fullName"
class="gl-font-monospace"
is-check-item
:is-checked="isSelected(branch)"
@click="setRefSelected(branch)"
>
{{ branch.shortName }}
</gl-dropdown-item>
<gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="tag in tags"
:key="tag.fullName"
class="gl-font-monospace"
is-check-item
:is-checked="isSelected(tag)"
@click="setRefSelected(tag)"
>
{{ tag.shortName }}
</gl-dropdown-item>
</gl-dropdown>
</template>
export const VARIABLE_TYPE = 'env_var'; export const VARIABLE_TYPE = 'env_var';
export const FILE_TYPE = 'file'; export const FILE_TYPE = 'file';
export const DEBOUNCE_REFS_SEARCH_MS = 250;
export const CONFIG_VARIABLES_TIMEOUT = 5000; export const CONFIG_VARIABLES_TIMEOUT = 5000;
export const BRANCH_REF_TYPE = 'branch'; export const BRANCH_REF_TYPE = 'branch';
export const TAG_REF_TYPE = 'tag'; export const TAG_REF_TYPE = 'tag';
import Vue from 'vue'; import Vue from 'vue';
import PipelineNewForm from './components/pipeline_new_form.vue'; import PipelineNewForm from './components/pipeline_new_form.vue';
import formatRefs from './utils/format_refs';
export default () => { export default () => {
const el = document.getElementById('js-new-pipeline'); const el = document.getElementById('js-new-pipeline');
const { const {
// provide/inject
projectRefsEndpoint,
// props
projectId, projectId,
pipelinesPath, pipelinesPath,
configVariablesPath, configVariablesPath,
...@@ -12,19 +15,18 @@ export default () => { ...@@ -12,19 +15,18 @@ export default () => {
refParam, refParam,
varParam, varParam,
fileParam, fileParam,
branchRefs,
tagRefs,
settingsLink, settingsLink,
maxWarnings, maxWarnings,
} = el?.dataset; } = el?.dataset;
const variableParams = JSON.parse(varParam); const variableParams = JSON.parse(varParam);
const fileParams = JSON.parse(fileParam); const fileParams = JSON.parse(fileParam);
const branches = formatRefs(JSON.parse(branchRefs), 'branch');
const tags = formatRefs(JSON.parse(tagRefs), 'tag');
return new Vue({ return new Vue({
el, el,
provide: {
projectRefsEndpoint,
},
render(createElement) { render(createElement) {
return createElement(PipelineNewForm, { return createElement(PipelineNewForm, {
props: { props: {
...@@ -35,8 +37,6 @@ export default () => { ...@@ -35,8 +37,6 @@ export default () => {
refParam, refParam,
variableParams, variableParams,
fileParams, fileParams,
branches,
tags,
settingsLink, settingsLink,
maxWarnings: Number(maxWarnings), maxWarnings: Number(maxWarnings),
}, },
......
...@@ -14,8 +14,7 @@ ...@@ -14,8 +14,7 @@
ref_param: params[:ref] || @project.default_branch, ref_param: params[:ref] || @project.default_branch,
var_param: params[:var].to_json, var_param: params[:var].to_json,
file_param: params[:file_var].to_json, file_param: params[:file_var].to_json,
branch_refs: @project.repository.branch_names.to_json.html_safe, project_refs_endpoint: refs_project_path(@project, sort: 'updated_desc'),
tag_refs: @project.repository.tag_names.to_json.html_safe,
settings_link: project_settings_ci_cd_path(@project), settings_link: project_settings_ci_cd_path(@project),
max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } } max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } }
......
---
title: Improve performance of manual pipeline form by limiting the refs loaded on page load.
merge_request: 55394
author:
type: performance
...@@ -137,10 +137,10 @@ To execute a pipeline manually: ...@@ -137,10 +137,10 @@ To execute a pipeline manually:
1. Navigate to your project's **CI/CD > Pipelines**. 1. Navigate to your project's **CI/CD > Pipelines**.
1. Select the **Run Pipeline** button. 1. Select the **Run Pipeline** button.
1. On the **Run Pipeline** page: 1. On the **Run Pipeline** page:
1. Select the branch to run the pipeline for in the **Create for** field. 1. Select the branch or tag to run the pipeline for in the **Run for branch name or tag** field.
1. Enter any [environment variables](../variables/README.md) required for the pipeline run. 1. Enter any [environment variables](../variables/README.md) required for the pipeline run.
You can set specific variables to have their [values prefilled in the form](#prefill-variables-in-manual-pipelines). You can set specific variables to have their [values prefilled in the form](#prefill-variables-in-manual-pipelines).
1. Click the **Create pipeline** button. 1. Click the **Run pipeline** button.
The pipeline now executes the jobs as configured. The pipeline now executes the jobs as configured.
......
...@@ -21874,9 +21874,6 @@ msgstr "" ...@@ -21874,9 +21874,6 @@ msgstr ""
msgid "Pipeline Schedules" msgid "Pipeline Schedules"
msgstr "" msgstr ""
msgid "Pipeline cannot be run."
msgstr ""
msgid "Pipeline minutes quota" msgid "Pipeline minutes quota"
msgstr "" msgstr ""
...@@ -22138,6 +22135,9 @@ msgstr "" ...@@ -22138,6 +22135,9 @@ msgstr ""
msgid "Pipeline|Branch name" msgid "Pipeline|Branch name"
msgstr "" msgstr ""
msgid "Pipeline|Branches or tags could not be loaded."
msgstr ""
msgid "Pipeline|Canceled" msgid "Pipeline|Canceled"
msgstr "" msgstr ""
...@@ -22198,6 +22198,9 @@ msgstr "" ...@@ -22198,6 +22198,9 @@ msgstr ""
msgid "Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}" msgid "Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}"
msgstr "" msgstr ""
msgid "Pipeline|Pipeline cannot be run."
msgstr ""
msgid "Pipeline|Pipelines" msgid "Pipeline|Pipelines"
msgstr "" msgstr ""
......
import { GlDropdown, GlDropdownItem, GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import { GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
...@@ -6,34 +6,26 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -6,34 +6,26 @@ import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue';
import { import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue';
mockBranches, import { mockQueryParams, mockPostParams, mockProjectId, mockError, mockRefs } from '../mock_data';
mockTags,
mockParams,
mockPostParams,
mockProjectId,
mockError,
} from '../mock_data';
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(), redirectTo: jest.fn(),
})); }));
const projectRefsEndpoint = '/root/project/refs';
const pipelinesPath = '/root/project/-/pipelines'; const pipelinesPath = '/root/project/-/pipelines';
const configVariablesPath = '/root/project/-/pipelines/config_variables'; const configVariablesPath = '/root/project/-/pipelines/config_variables';
const postResponse = { id: 1 }; const newPipelinePostResponse = { id: 1 };
const defaultBranch = 'master';
describe('Pipeline New Form', () => { describe('Pipeline New Form', () => {
let wrapper; let wrapper;
let mock; let mock;
let dummySubmitEvent;
const dummySubmitEvent = {
preventDefault() {},
};
const findForm = () => wrapper.find(GlForm); const findForm = () => wrapper.find(GlForm);
const findDropdown = () => wrapper.find(GlDropdown); const findRefsDropdown = () => wrapper.findComponent(RefsDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findSubmitButton = () => wrapper.find('[data-testid="run_pipeline_button"]'); const findSubmitButton = () => wrapper.find('[data-testid="run_pipeline_button"]');
const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]');
const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]');
...@@ -44,33 +36,42 @@ describe('Pipeline New Form', () => { ...@@ -44,33 +36,42 @@ describe('Pipeline New Form', () => {
const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf); const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf);
const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const getExpectedPostParams = () => JSON.parse(mock.history.post[0].data); const getFormPostParams = () => JSON.parse(mock.history.post[0].data);
const changeRef = (i) => findDropdownItems().at(i).vm.$emit('click');
const selectBranch = (branch) => {
// Select a branch in the dropdown
findRefsDropdown().vm.$emit('input', {
shortName: branch,
fullName: `refs/heads/${branch}`,
});
};
const createComponent = (term = '', props = {}, method = shallowMount) => { const createComponent = (props = {}, method = shallowMount) => {
wrapper = method(PipelineNewForm, { wrapper = method(PipelineNewForm, {
provide: {
projectRefsEndpoint,
},
propsData: { propsData: {
projectId: mockProjectId, projectId: mockProjectId,
pipelinesPath, pipelinesPath,
configVariablesPath, configVariablesPath,
branches: mockBranches, defaultBranch,
tags: mockTags, refParam: defaultBranch,
defaultBranch: 'master',
settingsLink: '', settingsLink: '',
maxWarnings: 25, maxWarnings: 25,
...props, ...props,
}, },
data() {
return {
searchTerm: term,
};
},
}); });
}; };
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {}); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {});
mock.onGet(projectRefsEndpoint).reply(httpStatusCodes.OK, mockRefs);
dummySubmitEvent = {
preventDefault: jest.fn(),
};
}); });
afterEach(() => { afterEach(() => {
...@@ -80,38 +81,17 @@ describe('Pipeline New Form', () => { ...@@ -80,38 +81,17 @@ describe('Pipeline New Form', () => {
mock.restore(); mock.restore();
}); });
describe('Dropdown with branches and tags', () => {
beforeEach(() => {
mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse);
});
it('displays dropdown with all branches and tags', () => {
const refLength = mockBranches.length + mockTags.length;
createComponent();
expect(findDropdownItems()).toHaveLength(refLength);
});
it('when user enters search term the list is filtered', () => {
createComponent('master');
expect(findDropdownItems()).toHaveLength(1);
expect(findDropdownItems().at(0).text()).toBe('master');
});
});
describe('Form', () => { describe('Form', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent('', mockParams, mount); createComponent(mockQueryParams, mount);
mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse);
await waitForPromises(); await waitForPromises();
}); });
it('displays the correct values for the provided query params', async () => { it('displays the correct values for the provided query params', async () => {
expect(findDropdown().props('text')).toBe('tag-1'); expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' });
expect(findVariableRows()).toHaveLength(3); expect(findVariableRows()).toHaveLength(3);
}); });
...@@ -152,11 +132,19 @@ describe('Pipeline New Form', () => { ...@@ -152,11 +132,19 @@ describe('Pipeline New Form', () => {
describe('Pipeline creation', () => { describe('Pipeline creation', () => {
beforeEach(async () => { beforeEach(async () => {
mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse);
await waitForPromises(); await waitForPromises();
}); });
it('does not submit the native HTML form', async () => {
createComponent();
findForm().vm.$emit('submit', dummySubmitEvent);
expect(dummySubmitEvent.preventDefault).toHaveBeenCalled();
});
it('disables the submit button immediately after submitting', async () => { it('disables the submit button immediately after submitting', async () => {
createComponent(); createComponent();
...@@ -171,19 +159,15 @@ describe('Pipeline New Form', () => { ...@@ -171,19 +159,15 @@ describe('Pipeline New Form', () => {
it('creates pipeline with full ref and variables', async () => { it('creates pipeline with full ref and variables', async () => {
createComponent(); createComponent();
changeRef(0);
findForm().vm.$emit('submit', dummySubmitEvent); findForm().vm.$emit('submit', dummySubmitEvent);
await waitForPromises(); await waitForPromises();
expect(getExpectedPostParams().ref).toEqual(wrapper.vm.$data.refValue.fullName); expect(getFormPostParams().ref).toEqual(`refs/heads/${defaultBranch}`);
expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`); expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`);
}); });
it('creates a pipeline with short ref and variables', async () => { it('creates a pipeline with short ref and variables from the query params', async () => {
// query params are used createComponent(mockQueryParams);
createComponent('', mockParams);
await waitForPromises(); await waitForPromises();
...@@ -191,19 +175,19 @@ describe('Pipeline New Form', () => { ...@@ -191,19 +175,19 @@ describe('Pipeline New Form', () => {
await waitForPromises(); await waitForPromises();
expect(getExpectedPostParams()).toEqual(mockPostParams); expect(getFormPostParams()).toEqual(mockPostParams);
expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`); expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`);
}); });
}); });
describe('When the ref has been changed', () => { describe('When the ref has been changed', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent('', {}, mount); createComponent({}, mount);
await waitForPromises(); await waitForPromises();
}); });
it('variables persist between ref changes', async () => { it('variables persist between ref changes', async () => {
changeRef(0); // change to master selectBranch('master');
await waitForPromises(); await waitForPromises();
...@@ -213,7 +197,7 @@ describe('Pipeline New Form', () => { ...@@ -213,7 +197,7 @@ describe('Pipeline New Form', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
changeRef(1); // change to branch-1 selectBranch('branch-1');
await waitForPromises(); await waitForPromises();
...@@ -223,14 +207,14 @@ describe('Pipeline New Form', () => { ...@@ -223,14 +207,14 @@ describe('Pipeline New Form', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
changeRef(0); // change back to master selectBranch('master');
await waitForPromises(); await waitForPromises();
expect(findKeyInputs().at(0).element.value).toBe('build_var'); expect(findKeyInputs().at(0).element.value).toBe('build_var');
expect(findVariableRows().length).toBe(2); expect(findVariableRows().length).toBe(2);
changeRef(1); // change back to branch-1 selectBranch('branch-1');
await waitForPromises(); await waitForPromises();
...@@ -248,7 +232,7 @@ describe('Pipeline New Form', () => { ...@@ -248,7 +232,7 @@ describe('Pipeline New Form', () => {
const mockYmlDesc = 'A var from yml.'; const mockYmlDesc = 'A var from yml.';
it('loading icon is shown when content is requested and hidden when received', async () => { it('loading icon is shown when content is requested and hidden when received', async () => {
createComponent('', mockParams, mount); createComponent(mockQueryParams, mount);
mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {
[mockYmlKey]: { [mockYmlKey]: {
...@@ -265,7 +249,7 @@ describe('Pipeline New Form', () => { ...@@ -265,7 +249,7 @@ describe('Pipeline New Form', () => {
}); });
it('multi-line strings are added to the value field without removing line breaks', async () => { it('multi-line strings are added to the value field without removing line breaks', async () => {
createComponent('', mockParams, mount); createComponent(mockQueryParams, mount);
mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {
[mockYmlKey]: { [mockYmlKey]: {
...@@ -281,7 +265,7 @@ describe('Pipeline New Form', () => { ...@@ -281,7 +265,7 @@ describe('Pipeline New Form', () => {
describe('with description', () => { describe('with description', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent('', mockParams, mount); createComponent(mockQueryParams, mount);
mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {
[mockYmlKey]: { [mockYmlKey]: {
...@@ -323,7 +307,7 @@ describe('Pipeline New Form', () => { ...@@ -323,7 +307,7 @@ describe('Pipeline New Form', () => {
describe('without description', () => { describe('without description', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent('', mockParams, mount); createComponent(mockQueryParams, mount);
mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {
[mockYmlKey]: { [mockYmlKey]: {
...@@ -346,6 +330,21 @@ describe('Pipeline New Form', () => { ...@@ -346,6 +330,21 @@ describe('Pipeline New Form', () => {
createComponent(); createComponent();
}); });
describe('when the refs cannot be loaded', () => {
beforeEach(() => {
mock
.onGet(projectRefsEndpoint, { params: { search: '' } })
.reply(httpStatusCodes.INTERNAL_SERVER_ERROR);
findRefsDropdown().vm.$emit('loadingError');
});
it('shows both an error alert', () => {
expect(findErrorAlert().exists()).toBe(true);
expect(findWarningAlert().exists()).toBe(false);
});
});
describe('when the error response can be handled', () => { describe('when the error response can be handled', () => {
beforeEach(async () => { beforeEach(async () => {
mock.onPost(pipelinesPath).reply(httpStatusCodes.BAD_REQUEST, mockError); mock.onPost(pipelinesPath).reply(httpStatusCodes.BAD_REQUEST, mockError);
......
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue';
import { mockRefs, mockFilteredRefs } from '../mock_data';
const projectRefsEndpoint = '/root/project/refs';
const refShortName = 'master';
const refFullName = 'refs/heads/master';
jest.mock('~/flash');
describe('Pipeline New Form', () => {
let wrapper;
let mock;
const findDropdown = () => wrapper.find(GlDropdown);
const findRefsDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const createComponent = (props = {}, mountFn = shallowMount) => {
wrapper = mountFn(RefsDropdown, {
provide: {
projectRefsEndpoint,
},
propsData: {
value: {
shortName: refShortName,
fullName: refFullName,
},
...props,
},
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(httpStatusCodes.OK, mockRefs);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
mock.restore();
});
beforeEach(() => {
createComponent();
});
it('displays empty dropdown initially', async () => {
await findDropdown().vm.$emit('show');
expect(findRefsDropdownItems()).toHaveLength(0);
});
it('does not make requests immediately', async () => {
expect(mock.history.get).toHaveLength(0);
});
describe('when user opens dropdown', () => {
beforeEach(async () => {
await findDropdown().vm.$emit('show');
await waitForPromises();
});
it('requests unfiltered tags and branches', async () => {
expect(mock.history.get).toHaveLength(1);
expect(mock.history.get[0].url).toBe(projectRefsEndpoint);
expect(mock.history.get[0].params).toEqual({ search: '' });
});
it('displays dropdown with branches and tags', async () => {
const refLength = mockRefs.Tags.length + mockRefs.Branches.length;
expect(findRefsDropdownItems()).toHaveLength(refLength);
});
it('displays the names of refs', () => {
// Branches
expect(findRefsDropdownItems().at(0).text()).toBe(mockRefs.Branches[0]);
// Tags (appear after branches)
const firstTag = mockRefs.Branches.length;
expect(findRefsDropdownItems().at(firstTag).text()).toBe(mockRefs.Tags[0]);
});
it('when user shows dropdown a second time, only one request is done', () => {
expect(mock.history.get).toHaveLength(1);
});
describe('when user selects a value', () => {
const selectedIndex = 1;
beforeEach(async () => {
await findRefsDropdownItems().at(selectedIndex).vm.$emit('click');
});
it('component emits @input', () => {
const inputs = wrapper.emitted('input');
expect(inputs).toHaveLength(1);
expect(inputs[0]).toEqual([{ shortName: 'branch-1', fullName: 'refs/heads/branch-1' }]);
});
});
describe('when user types searches for a tag', () => {
const mockSearchTerm = 'my-search';
beforeEach(async () => {
mock
.onGet(projectRefsEndpoint, { params: { search: mockSearchTerm } })
.reply(httpStatusCodes.OK, mockFilteredRefs);
await findSearchBox().vm.$emit('input', mockSearchTerm);
await waitForPromises();
});
it('requests filtered tags and branches', async () => {
expect(mock.history.get).toHaveLength(2);
expect(mock.history.get[1].params).toEqual({
search: mockSearchTerm,
});
});
it('displays dropdown with branches and tags', async () => {
const filteredRefLength = mockFilteredRefs.Tags.length + mockFilteredRefs.Branches.length;
expect(findRefsDropdownItems()).toHaveLength(filteredRefLength);
});
});
});
describe('when user has selected a value', () => {
const selectedIndex = 1;
const mockShortName = mockRefs.Branches[selectedIndex];
const mockFullName = `refs/heads/${mockShortName}`;
beforeEach(async () => {
mock
.onGet(projectRefsEndpoint, {
params: { ref: mockFullName },
})
.reply(httpStatusCodes.OK, mockRefs);
createComponent({
value: {
shortName: mockShortName,
fullName: mockFullName,
},
});
await findDropdown().vm.$emit('show');
await waitForPromises();
});
it('branch is checked', () => {
expect(findRefsDropdownItems().at(selectedIndex).props('isChecked')).toBe(true);
});
});
describe('when server returns an error', () => {
beforeEach(async () => {
mock
.onGet(projectRefsEndpoint, { params: { search: '' } })
.reply(httpStatusCodes.INTERNAL_SERVER_ERROR);
await findDropdown().vm.$emit('show');
await waitForPromises();
});
it('loading error event is emitted', () => {
expect(wrapper.emitted('loadingError')).toHaveLength(1);
expect(wrapper.emitted('loadingError')[0]).toEqual([expect.any(Error)]);
});
});
});
export const mockBranches = [ export const mockRefs = {
{ shortName: 'master', fullName: 'refs/heads/master' }, Branches: ['master', 'branch-1', 'branch-2'],
{ shortName: 'branch-1', fullName: 'refs/heads/branch-1' }, Tags: ['1.0.0', '1.1.0', '1.2.0'],
{ shortName: 'branch-2', fullName: 'refs/heads/branch-2' }, };
];
export const mockTags = [ export const mockFilteredRefs = {
{ shortName: '1.0.0', fullName: 'refs/tags/1.0.0' }, Branches: ['branch-1'],
{ shortName: '1.1.0', fullName: 'refs/tags/1.1.0' }, Tags: ['1.0.0', '1.1.0'],
{ shortName: '1.2.0', fullName: 'refs/tags/1.2.0' }, };
];
export const mockParams = { export const mockQueryParams = {
refParam: 'tag-1', refParam: 'tag-1',
variableParams: { variableParams: {
test_var: 'test_var_val', test_var: 'test_var_val',
......
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