Commit c05acf6f authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '321790-load-refs-on-demand' into 'master'

Load refs for the "Run Pipeline" form on demand, limit to 100 results per ref type

See merge request gitlab-org/gitlab!55394
parents 592ad96c e5826739
<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 FILE_TYPE = 'file';
export const DEBOUNCE_REFS_SEARCH_MS = 250;
export const CONFIG_VARIABLES_TIMEOUT = 5000;
export const BRANCH_REF_TYPE = 'branch';
export const TAG_REF_TYPE = 'tag';
import Vue from 'vue';
import PipelineNewForm from './components/pipeline_new_form.vue';
import formatRefs from './utils/format_refs';
export default () => {
const el = document.getElementById('js-new-pipeline');
const {
// provide/inject
projectRefsEndpoint,
// props
projectId,
pipelinesPath,
configVariablesPath,
......@@ -12,19 +15,18 @@ export default () => {
refParam,
varParam,
fileParam,
branchRefs,
tagRefs,
settingsLink,
maxWarnings,
} = el?.dataset;
const variableParams = JSON.parse(varParam);
const fileParams = JSON.parse(fileParam);
const branches = formatRefs(JSON.parse(branchRefs), 'branch');
const tags = formatRefs(JSON.parse(tagRefs), 'tag');
return new Vue({
el,
provide: {
projectRefsEndpoint,
},
render(createElement) {
return createElement(PipelineNewForm, {
props: {
......@@ -35,8 +37,6 @@ export default () => {
refParam,
variableParams,
fileParams,
branches,
tags,
settingsLink,
maxWarnings: Number(maxWarnings),
},
......
......@@ -14,8 +14,7 @@
ref_param: params[:ref] || @project.default_branch,
var_param: params[:var].to_json,
file_param: params[:file_var].to_json,
branch_refs: @project.repository.branch_names.to_json.html_safe,
tag_refs: @project.repository.tag_names.to_json.html_safe,
project_refs_endpoint: refs_project_path(@project, sort: 'updated_desc'),
settings_link: project_settings_ci_cd_path(@project),
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:
1. Navigate to your project's **CI/CD > Pipelines**.
1. Select the **Run Pipeline** button.
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.
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.
......
......@@ -22028,9 +22028,6 @@ msgstr ""
msgid "Pipeline Schedules"
msgstr ""
msgid "Pipeline cannot be run."
msgstr ""
msgid "Pipeline minutes quota"
msgstr ""
......@@ -22289,6 +22286,9 @@ msgstr ""
msgid "Pipeline|Branch name"
msgstr ""
msgid "Pipeline|Branches or tags could not be loaded."
msgstr ""
msgid "Pipeline|Canceled"
msgstr ""
......@@ -22349,6 +22349,9 @@ msgstr ""
msgid "Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}"
msgstr ""
msgid "Pipeline|Pipeline cannot be run."
msgstr ""
msgid "Pipeline|Pipelines"
msgstr ""
......
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 = [
{ shortName: 'master', fullName: 'refs/heads/master' },
{ shortName: 'branch-1', fullName: 'refs/heads/branch-1' },
{ shortName: 'branch-2', fullName: 'refs/heads/branch-2' },
];
export const mockRefs = {
Branches: ['master', 'branch-1', 'branch-2'],
Tags: ['1.0.0', '1.1.0', '1.2.0'],
};
export const mockTags = [
{ shortName: '1.0.0', fullName: 'refs/tags/1.0.0' },
{ shortName: '1.1.0', fullName: 'refs/tags/1.1.0' },
{ shortName: '1.2.0', fullName: 'refs/tags/1.2.0' },
];
export const mockFilteredRefs = {
Branches: ['branch-1'],
Tags: ['1.0.0', '1.1.0'],
};
export const mockParams = {
export const mockQueryParams = {
refParam: 'tag-1',
variableParams: {
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