diff --git a/ee/app/assets/javascripts/vue_shared/components/branches_selector/constants.js b/ee/app/assets/javascripts/vue_shared/components/branches_selector/constants.js
index ad514bd618aa4e8e8ee424cd68924b56f281756f..751b2432ee31751564905acf59a0931d4cd0aec0 100644
--- a/ee/app/assets/javascripts/vue_shared/components/branches_selector/constants.js
+++ b/ee/app/assets/javascripts/vue_shared/components/branches_selector/constants.js
@@ -1,6 +1,7 @@
import { __ } from '~/locale';
export const BRANCH_FETCH_DELAY = 250;
+export const BRANCHES_PER_PAGE = 10;
export const ALL_BRANCHES = {
id: 'ALL_BRANCHES',
name: __('All branches'),
diff --git a/ee/app/assets/javascripts/vue_shared/components/branches_selector/project_branch_selector.vue b/ee/app/assets/javascripts/vue_shared/components/branches_selector/project_branch_selector.vue
new file mode 100644
index 0000000000000000000000000000000000000000..beee6781807f5fd5a084b00513d2ed422217711e
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/components/branches_selector/project_branch_selector.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
diff --git a/ee/spec/frontend/vue_shared/components/branches_selector/project_branch_selector_spec.js b/ee/spec/frontend/vue_shared/components/branches_selector/project_branch_selector_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..94d3391d3e7c9a557ba4b40861f2e10740b53850
--- /dev/null
+++ b/ee/spec/frontend/vue_shared/components/branches_selector/project_branch_selector_spec.js
@@ -0,0 +1,236 @@
+import { nextTick } from 'vue';
+import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import MockAdapter from 'axios-mock-adapter';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ProjectBranchSelector from 'ee/vue_shared/components/branches_selector/project_branch_selector.vue';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+
+const branches = Array.from({ length: 15 }, (_, index) => ({ id: index, name: `test-${index}` }));
+const TEST_BRANCHES = [{ id: 16, name: 'main' }, ...branches];
+
+const MOCKED_LISTBOX_ITEMS = TEST_BRANCHES.map(({ name }) => ({
+ text: name,
+ value: name,
+}));
+
+const TOTAL_BRANCHES = 30;
+
+describe('ProjectBranchSelector', () => {
+ const PROJECT_ID = '1';
+ const MOCKED_BRANCHES_URL = `/api/v4/projects/${PROJECT_ID}/repository/branches`;
+
+ let wrapper;
+ const mockAxios = new MockAdapter(axios);
+
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = shallowMountExtended(ProjectBranchSelector, {
+ propsData: {
+ projectFullPath: PROJECT_ID,
+ ...propsData,
+ },
+ stubs: {
+ GlCollapsibleListbox,
+ },
+ });
+ };
+
+ const findListBox = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem);
+ const findEmptyState = () => wrapper.findByTestId('listbox-no-results-text');
+
+ beforeEach(() => {
+ gon.api_version = 'v4';
+ });
+
+ afterEach(() => {
+ mockAxios.reset();
+ });
+
+ const openDropdown = async () => {
+ findListBox().vm.$emit('shown');
+ await waitForPromises();
+ };
+
+ it('should render custom header and text', () => {
+ const customHeader = 'custom header';
+ const customText = 'custom text';
+
+ createComponent({
+ propsData: {
+ header: customHeader,
+ text: customText,
+ },
+ });
+
+ expect(findListBox().props('headerText')).toBe(customHeader);
+ expect(findListBox().props('toggleText')).toBe(customText);
+ });
+
+ describe('loading state', () => {
+ beforeEach(() => {
+ mockAxios.onGet(MOCKED_BRANCHES_URL).replyOnce(HTTP_STATUS_OK, TEST_BRANCHES, {
+ 'x-total': TOTAL_BRANCHES,
+ });
+ });
+
+ it('should not initially load branches', () => {
+ createComponent();
+
+ expect(findListBox().props('loading')).toBe(false);
+ expect(findListBox().props('items')).toEqual([]);
+ });
+
+ it('should have loading state on first load', async () => {
+ createComponent();
+
+ findListBox().vm.$emit('shown');
+ await nextTick();
+
+ expect(findListBox().props('loading')).toBe(true);
+ });
+ });
+
+ describe('loading successfully', () => {
+ beforeEach(() => {
+ mockAxios.onGet(MOCKED_BRANCHES_URL).reply(HTTP_STATUS_OK, TEST_BRANCHES, {
+ 'x-total': TOTAL_BRANCHES,
+ });
+ });
+
+ it('should render branches in a dropdown', async () => {
+ createComponent();
+
+ await openDropdown();
+
+ expect(findListBox().props('items')).toEqual(MOCKED_LISTBOX_ITEMS);
+ expect(findAllListboxItems()).toHaveLength(MOCKED_LISTBOX_ITEMS.length);
+
+ expect(wrapper.emitted('error')).toEqual([[{ hasErrored: false }]]);
+ expect(findListBox().props('variant')).toEqual('default');
+ expect(findListBox().props('category')).toEqual('primary');
+ });
+
+ it('should select all branches in multiple mode', async () => {
+ createComponent();
+
+ await openDropdown();
+
+ findListBox().vm.$emit('select-all');
+
+ expect(wrapper.emitted('select')).toEqual([[MOCKED_LISTBOX_ITEMS.map(({ value }) => value)]]);
+ });
+
+ it('should reset branches in multiple mode', async () => {
+ createComponent({
+ propsData: {
+ selected: MOCKED_LISTBOX_ITEMS.map(({ value }) => value),
+ },
+ });
+
+ await openDropdown();
+
+ findListBox().vm.$emit('reset');
+
+ expect(wrapper.emitted('select')).toEqual([[[]]]);
+ });
+
+ it('should stop fetching branches when limit is reached', async () => {
+ createComponent();
+ await openDropdown();
+
+ expect(findListBox().props('items')).toHaveLength(TEST_BRANCHES.length);
+
+ findListBox().vm.$emit('bottom-reached');
+ await waitForPromises();
+
+ expect(findListBox().props('items')).toHaveLength(TEST_BRANCHES.length);
+ });
+
+ it.each`
+ selected | expectedSelected | expectedResult
+ ${['main', 'development']} | ${['main', 'development']} | ${['main', 'development'].join(', ')}
+ ${undefined} | ${[]} | ${'Select branches'}
+ `(
+ 'should select saved previously saved branches',
+ async ({ selected, expectedSelected, expectedResult }) => {
+ createComponent({
+ propsData: {
+ selected,
+ },
+ });
+
+ await openDropdown();
+
+ expect(findListBox().props('selected')).toEqual(expectedSelected);
+ expect(findListBox().props('toggleText')).toBe(expectedResult);
+ },
+ );
+ });
+
+ describe('has no protected branches', () => {
+ beforeEach(() => {
+ mockAxios.onGet(MOCKED_BRANCHES_URL).replyOnce(HTTP_STATUS_OK, []);
+ });
+
+ it('should render empty state if no branches exist', async () => {
+ createComponent();
+
+ await openDropdown();
+
+ expect(findAllListboxItems()).toHaveLength(0);
+ expect(findEmptyState().text()).toBe('No results found');
+ });
+ });
+
+ describe('loading failed', () => {
+ beforeEach(() => {
+ mockAxios.onGet(MOCKED_BRANCHES_URL).replyOnce(HTTP_STATUS_BAD_REQUEST);
+ });
+
+ it('should emmit error when loading fails', async () => {
+ const sentrySpy = jest.spyOn(Sentry, 'captureException');
+
+ createComponent();
+
+ await openDropdown();
+
+ expect(wrapper.emitted('error')).toHaveLength(1);
+ expect(findAllListboxItems()).toHaveLength(0);
+ expect(findEmptyState().text()).toBe('No results found');
+ expect(sentrySpy).toHaveBeenCalledWith(new Error('Request failed with status code 400'));
+ });
+
+ it.each`
+ errorMessage | expectedError
+ ${undefined} | ${'Could not retrieve the list of branches. Use the YAML editor mode, or refresh this page later. To view the list of branches, go to %{boldStart}Code - Branches%{boldEnd}'}
+ ${'custom error message'} | ${'custom error message'}
+ `(
+ 'should have error class when hasError and accept custom error message',
+ async ({ errorMessage, expectedError }) => {
+ createComponent({
+ propsData: {
+ hasError: true,
+ errorMessage,
+ },
+ });
+
+ await openDropdown();
+
+ expect(wrapper.emitted('error')).toEqual([
+ [
+ {
+ error: expectedError,
+ hasErrored: true,
+ },
+ ],
+ ]);
+
+ expect(findListBox().props('variant')).toEqual('danger');
+ expect(findListBox().props('category')).toEqual('secondary');
+ },
+ );
+ });
+});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 7afb2795b11f8a07710ef2168553b164eb27f1db..01d7036242e9ffd2218f29b030fb7b055dd6c3a5 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -13164,6 +13164,9 @@ msgstr ""
msgid "Could not restore the group"
msgstr ""
+msgid "Could not retrieve the list of branches. Use the YAML editor mode, or refresh this page later. To view the list of branches, go to %{boldStart}Code - Branches%{boldEnd}"
+msgstr ""
+
msgid "Could not retrieve the list of protected branches. Use the YAML editor mode, or refresh this page later. To view the list of protected branches, go to %{boldStart}Settings - Branches%{boldEnd} and expand %{boldStart}Protected branches%{boldEnd}."
msgstr ""