Commit 6fc333ec authored by Mireya Andres's avatar Mireya Andres Committed by Olena Horal-Koretska

Fetch and search available branches in pipeline editor

parent 92c70ac9
<script>
import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlIcon } from '@gitlab/ui';
import {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlInfiniteScroll,
GlLoadingIcon,
GlSearchBoxByType,
} from '@gitlab/ui';
import { historyPushState } from '~/lib/utils/common_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { DEFAULT_FAILURE } from '~/pipeline_editor/constants';
import {
BRANCH_PAGINATION_LIMIT,
BRANCH_SEARCH_DEBOUNCE,
DEFAULT_FAILURE,
} from '~/pipeline_editor/constants';
import getAvailableBranches from '~/pipeline_editor/graphql/queries/available_branches.graphql';
import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.graphql';
export default {
i18n: {
dropdownHeader: s__('Switch Branch'),
title: s__('Branches'),
fetchError: s__('Unable to fetch branch list for this project.'),
},
inputDebounce: BRANCH_SEARCH_DEBOUNCE,
components: {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlIcon,
GlInfiniteScroll,
GlLoadingIcon,
GlSearchBoxByType,
},
inject: ['projectFullPath', 'totalBranches'],
props: {
paginationLimit: {
type: Number,
required: false,
default: BRANCH_PAGINATION_LIMIT,
},
},
data() {
return {
branches: [],
page: {
limit: this.paginationLimit,
offset: 0,
searchTerm: '',
},
};
},
inject: ['projectFullPath'],
apollo: {
branches: {
availableBranches: {
query: getAvailableBranches,
variables() {
return {
limit: this.page.limit,
offset: this.page.offset,
projectFullPath: this.projectFullPath,
searchPattern: this.searchPattern,
};
},
update(data) {
return data.project?.repository?.branches || [];
return data.project?.repository?.branchNames || [];
},
result({ data }) {
const newBranches = data.project?.repository?.branchNames || [];
// check that we're not re-concatenating existing fetch results
if (!this.branches.includes(newBranches[0])) {
this.branches = this.branches.concat(newBranches);
}
},
error() {
this.$emit('showError', {
......@@ -42,11 +85,37 @@ export default {
},
},
computed: {
hasBranchList() {
return this.branches?.length > 0;
isBranchesLoading() {
return this.$apollo.queries.availableBranches.loading;
},
showBranchSwitcher() {
return this.branches.length > 0 || this.page.searchTerm.length > 0;
},
searchPattern() {
if (this.page.searchTerm === '') {
return '*';
}
return `*${this.page.searchTerm}*`;
},
},
methods: {
// if there is no searchPattern, paginate by {paginationLimit} branches
fetchNextBranches() {
if (
this.isBranchesLoading ||
this.page.searchTerm.length > 0 ||
this.branches.length === this.totalBranches
) {
return;
}
this.page = {
...this.page,
limit: this.paginationLimit,
offset: this.page.offset + this.paginationLimit,
};
},
async selectBranch(newBranch) {
if (newBranch === this.currentBranch) {
return;
......@@ -62,24 +131,53 @@ export default {
this.$emit('refetchContent');
},
setSearchTerm(newSearchTerm) {
this.branches = [];
this.page = {
limit: newSearchTerm.trim() === '' ? this.paginationLimit : this.totalBranches,
offset: 0,
searchTerm: newSearchTerm.trim(),
};
},
},
};
</script>
<template>
<gl-dropdown v-if="hasBranchList" class="gl-ml-2" :text="currentBranch" icon="branch">
<gl-dropdown
v-if="showBranchSwitcher"
class="gl-ml-2"
:header-text="$options.i18n.dropdownHeader"
:text="currentBranch"
icon="branch"
>
<gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" />
<gl-dropdown-section-header>
{{ this.$options.i18n.title }}
{{ $options.i18n.title }}
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="branch in branches"
:key="branch.name"
:is-checked="currentBranch === branch.name"
:is-check-item="true"
@click="selectBranch(branch.name)"
<gl-infinite-scroll
:fetched-items="branches.length"
:total-items="totalBranches"
:max-list-height="250"
@bottomReached="fetchNextBranches"
>
<gl-icon name="check" class="gl-visibility-hidden" />
{{ branch.name }}
</gl-dropdown-item>
<template #items>
<gl-dropdown-item
v-for="branch in branches"
:key="branch"
:is-checked="currentBranch === branch"
:is-check-item="true"
@click="selectBranch(branch)"
>
{{ branch }}
</gl-dropdown-item>
</template>
<template #default>
<gl-dropdown-item v-if="isBranchesLoading" key="loading">
<gl-loading-icon size="md" />
</gl-dropdown-item>
</template>
</gl-infinite-scroll>
</gl-dropdown>
</template>
......@@ -28,3 +28,6 @@ export const COMMIT_ACTION_CREATE = 'CREATE';
export const COMMIT_ACTION_UPDATE = 'UPDATE';
export const DRAWER_EXPANDED_KEY = 'pipeline_editor_drawer_expanded';
export const BRANCH_PAGINATION_LIMIT = 20;
export const BRANCH_SEARCH_DEBOUNCE = '500';
query getAvailableBranches($projectFullPath: ID!) {
project(fullPath: $projectFullPath) @client {
query getAvailableBranches(
$limit: Int!
$offset: Int!
$projectFullPath: ID!
$searchPattern: String!
) {
project(fullPath: $projectFullPath) {
repository {
branches {
name
}
branchNames(limit: $limit, offset: $offset, searchPattern: $searchPattern)
}
}
}
......@@ -11,22 +11,6 @@ export const resolvers = {
}),
};
},
/* eslint-disable @gitlab/require-i18n-strings */
project() {
return {
__typename: 'Project',
repository: {
__typename: 'Repository',
branches: [
{ __typename: 'Branch', name: 'main' },
{ __typename: 'Branch', name: 'develop' },
{ __typename: 'Branch', name: 'production' },
{ __typename: 'Branch', name: 'test' },
],
},
};
},
/* eslint-enable @gitlab/require-i18n-strings */
},
Mutation: {
lintCI: (_, { endpoint, content, dry_run }) => {
......
......@@ -43,6 +43,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath,
projectNamespace,
runnerHelpPagePath,
totalBranches,
ymlHelpPagePath,
} = el?.dataset;
......@@ -100,6 +101,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath,
projectNamespace,
runnerHelpPagePath,
totalBranches: parseInt(totalBranches, 10),
ymlHelpPagePath,
},
render(h) {
......
......@@ -27,6 +27,7 @@ module Ci
"project-full-path" => project.full_path,
"project-namespace" => project.namespace.full_path,
"runner-help-page-path" => help_page_path('ci/runners/README'),
"total-branches" => project.repository.branches.length,
"yml-help-page-path" => help_page_path('ci/yaml/README')
}
end
......
......@@ -31474,6 +31474,9 @@ msgstr ""
msgid "Survey Response"
msgstr ""
msgid "Switch Branch"
msgstr ""
msgid "Switch branch/tag"
msgstr ""
......
import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import {
GlDropdown,
GlDropdownItem,
GlInfiniteScroll,
GlLoadingIcon,
GlSearchBoxByType,
} from '@gitlab/ui';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue';
import { DEFAULT_FAILURE } from '~/pipeline_editor/constants';
import { mockDefaultBranch, mockProjectBranches, mockProjectFullPath } from '../../mock_data';
import getAvailableBranches from '~/pipeline_editor/graphql/queries/available_branches.graphql';
import {
mockBranchPaginationLimit,
mockDefaultBranch,
mockEmptySearchBranches,
mockProjectBranches,
mockProjectFullPath,
mockSearchBranches,
mockTotalBranches,
mockTotalBranchResults,
mockTotalSearchResults,
} from '../../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
......@@ -15,30 +32,64 @@ describe('Pipeline editor branch switcher', () => {
let mockApollo;
let mockAvailableBranchQuery;
const createComponentWithApollo = () => {
const resolvers = {
Query: {
project: mockAvailableBranchQuery,
const createComponent = (
{ isQueryLoading, mountFn, options } = {
isQueryLoading: false,
mountFn: shallowMount,
options: {},
},
) => {
wrapper = mountFn(BranchSwitcher, {
propsData: {
paginationLimit: mockBranchPaginationLimit,
},
};
mockApollo = createMockApollo([], resolvers);
wrapper = shallowMount(BranchSwitcher, {
localVue,
apolloProvider: mockApollo,
provide: {
projectFullPath: mockProjectFullPath,
totalBranches: mockTotalBranches,
},
mocks: {
$apollo: {
queries: {
availableBranches: {
loading: isQueryLoading,
},
},
},
},
data() {
return {
branches: ['main'],
currentBranch: mockDefaultBranch,
};
},
...options,
});
};
const createComponentWithApollo = (mountFn = shallowMount) => {
const handlers = [[getAvailableBranches, mockAvailableBranchQuery]];
mockApollo = createMockApollo(handlers);
createComponent({
mountFn,
options: {
localVue,
apolloProvider: mockApollo,
mocks: {},
data() {
return {
currentBranch: mockDefaultBranch,
};
},
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const findInfiniteScroll = () => wrapper.findComponent(GlInfiniteScroll);
beforeEach(() => {
mockAvailableBranchQuery = jest.fn();
......@@ -48,7 +99,7 @@ describe('Pipeline editor branch switcher', () => {
wrapper.destroy();
});
describe('while querying', () => {
describe('when querying for the first time', () => {
beforeEach(() => {
createComponentWithApollo();
});
......@@ -61,41 +112,31 @@ describe('Pipeline editor branch switcher', () => {
describe('after querying', () => {
beforeEach(async () => {
mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches);
createComponentWithApollo();
createComponentWithApollo(mount);
await waitForPromises();
});
it('query is called with correct variables', async () => {
expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(1);
expect(mockAvailableBranchQuery).toHaveBeenCalledWith(
expect.anything(),
{
fullPath: mockProjectFullPath,
},
expect.anything(),
expect.anything(),
);
it('renders search box', () => {
expect(findSearchBox().exists()).toBe(true);
});
it('renders list of branches', () => {
expect(findDropdown().exists()).toBe(true);
expect(findDropdownItems()).toHaveLength(mockProjectBranches.repository.branches.length);
expect(findDropdownItems()).toHaveLength(mockTotalBranchResults);
});
it('renders current branch at the top of the list with a check mark', () => {
const firstDropdownItem = findDropdownItems().at(0);
const icon = firstDropdownItem.findComponent(GlIcon);
it('renders current branch with a check mark', () => {
const defaultBranchInDropdown = findDropdownItems().at(0);
expect(firstDropdownItem.text()).toBe(mockDefaultBranch);
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toBe('check');
expect(defaultBranchInDropdown.text()).toBe(mockDefaultBranch);
expect(defaultBranchInDropdown.props('isChecked')).toBe(true);
});
it('does not render check mark for other branches', () => {
const secondDropdownItem = findDropdownItems().at(1);
const icon = secondDropdownItem.findComponent(GlIcon);
const nonDefaultBranch = findDropdownItems().at(1);
expect(icon.classes()).toContain('gl-visibility-hidden');
expect(nonDefaultBranch.text()).not.toBe(mockDefaultBranch);
expect(nonDefaultBranch.props('isChecked')).toBe(false);
});
});
......@@ -125,7 +166,7 @@ describe('Pipeline editor branch switcher', () => {
beforeEach(async () => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches);
createComponentWithApollo();
createComponentWithApollo(mount);
await waitForPromises();
});
......@@ -168,4 +209,138 @@ describe('Pipeline editor branch switcher', () => {
expect(wrapper.emitted('refetchContent')).toBeUndefined();
});
});
describe('when searching', () => {
beforeEach(async () => {
mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches);
createComponentWithApollo(mount);
await waitForPromises();
mockAvailableBranchQuery.mockResolvedValue(mockSearchBranches);
});
describe('with a search term', () => {
it('calls query with correct variables', async () => {
findSearchBox().vm.$emit('input', 'te');
await waitForPromises();
expect(mockAvailableBranchQuery).toHaveBeenCalledWith({
limit: mockTotalBranches, // fetch all branches
offset: 0,
projectFullPath: mockProjectFullPath,
searchPattern: '*te*',
});
});
it('fetches new list of branches', async () => {
expect(findDropdownItems()).toHaveLength(mockTotalBranchResults);
findSearchBox().vm.$emit('input', 'te');
await waitForPromises();
expect(findDropdownItems()).toHaveLength(mockTotalSearchResults);
});
it('does not hide dropdown when search result is empty', async () => {
mockAvailableBranchQuery.mockResolvedValue(mockEmptySearchBranches);
findSearchBox().vm.$emit('input', 'aaaaa');
await waitForPromises();
expect(findDropdown().exists()).toBe(true);
expect(findDropdownItems()).toHaveLength(0);
});
});
describe('without a search term', () => {
beforeEach(async () => {
findSearchBox().vm.$emit('input', 'te');
await waitForPromises();
mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches);
});
it('calls query with correct variables', async () => {
findSearchBox().vm.$emit('input', '');
await waitForPromises();
expect(mockAvailableBranchQuery).toHaveBeenCalledWith({
limit: mockBranchPaginationLimit, // only fetch first n branches first
offset: 0,
projectFullPath: mockProjectFullPath,
searchPattern: '*',
});
});
it('fetches new list of branches', async () => {
expect(findDropdownItems()).toHaveLength(mockTotalSearchResults);
findSearchBox().vm.$emit('input', '');
await waitForPromises();
expect(findDropdownItems()).toHaveLength(mockTotalBranchResults);
});
});
});
describe('loading icon', () => {
test.each`
isQueryLoading | isRendered
${true} | ${true}
${false} | ${false}
`('checks if query is loading before rendering', ({ isQueryLoading, isRendered }) => {
createComponent({ isQueryLoading, mountFn: mount });
expect(findLoadingIcon().exists()).toBe(isRendered);
});
});
describe('when scrolling to the bottom of the list', () => {
beforeEach(async () => {
mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches);
createComponentWithApollo();
await waitForPromises();
});
afterEach(() => {
mockAvailableBranchQuery.mockClear();
});
describe('when search term is empty', () => {
it('fetches more branches', async () => {
expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(1);
findInfiniteScroll().vm.$emit('bottomReached');
await waitForPromises();
expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(2);
});
it('calls the query with the correct variables', async () => {
findInfiniteScroll().vm.$emit('bottomReached');
await waitForPromises();
expect(mockAvailableBranchQuery).toHaveBeenCalledWith({
limit: mockBranchPaginationLimit,
offset: mockBranchPaginationLimit, // offset changed
projectFullPath: mockProjectFullPath,
searchPattern: '*',
});
});
});
describe('when search term exists', () => {
it('does not fetch more branches', async () => {
findSearchBox().vm.$emit('input', 'te');
await waitForPromises();
expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(2);
mockAvailableBranchQuery.mockClear();
findInfiniteScroll().vm.$emit('bottomReached');
await waitForPromises();
expect(mockAvailableBranchQuery).not.toHaveBeenCalled();
});
});
});
});
......@@ -9,7 +9,6 @@ import {
mockDefaultBranch,
mockLintResponse,
mockProjectFullPath,
mockProjectBranches,
} from '../mock_data';
jest.mock('~/api', () => {
......@@ -47,23 +46,6 @@ describe('~/pipeline_editor/graphql/resolvers', () => {
await expect(result.rawData).resolves.toBe(mockCiYml);
});
});
describe('project', () => {
it('resolves project data with type names', async () => {
const result = await resolvers.Query.project();
// eslint-disable-next-line no-underscore-dangle
expect(result.__typename).toBe('Project');
});
it('resolves project with available list of branches', async () => {
const result = await resolvers.Query.project();
expect(result.repository.branches).toHaveLength(
mockProjectBranches.repository.branches.length,
);
});
});
});
describe('Mutation', () => {
......
......@@ -139,18 +139,54 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => {
};
export const mockProjectBranches = {
__typename: 'Project',
repository: {
__typename: 'Repository',
branches: [
{ __typename: 'Branch', name: 'main' },
{ __typename: 'Branch', name: 'develop' },
{ __typename: 'Branch', name: 'production' },
{ __typename: 'Branch', name: 'test' },
],
data: {
project: {
repository: {
branchNames: [
'main',
'develop',
'production',
'test',
'better-feature',
'feature-abc',
'update-ci',
'mock-feature',
'test-merge-request',
'staging',
],
},
},
},
};
export const mockTotalBranchResults =
mockProjectBranches.data.project.repository.branchNames.length;
export const mockSearchBranches = {
data: {
project: {
repository: {
branchNames: ['test', 'better-feature', 'update-ci', 'test-merge-request'],
},
},
},
};
export const mockTotalSearchResults = mockSearchBranches.data.project.repository.branchNames.length;
export const mockEmptySearchBranches = {
data: {
project: {
repository: {
branchNames: [],
},
},
},
};
export const mockBranchPaginationLimit = 10;
export const mockTotalBranches = 20; // must be greater than mockBranchPaginationLimit to test pagination
export const mockProjectPipeline = {
pipeline: {
commitPath: '/-/commit/aabbccdd',
......
......@@ -55,6 +55,7 @@ RSpec.describe Ci::PipelineEditorHelper do
"project-full-path" => project.full_path,
"project-namespace" => project.namespace.full_path,
"runner-help-page-path" => help_page_path('ci/runners/README'),
"total-branches" => project.repository.branches.length,
"yml-help-page-path" => help_page_path('ci/yaml/README')
})
end
......@@ -81,6 +82,7 @@ RSpec.describe Ci::PipelineEditorHelper do
"project-full-path" => project.full_path,
"project-namespace" => project.namespace.full_path,
"runner-help-page-path" => help_page_path('ci/runners/README'),
"total-branches" => 0,
"yml-help-page-path" => help_page_path('ci/yaml/README')
})
end
......
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