diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js index c72a913aacd3172dac21748f758df0ef9a5a4d61..35b18c5f56d2cc12e12d810ff528da4387063154 100644 --- a/app/assets/javascripts/api/projects_api.js +++ b/app/assets/javascripts/api/projects_api.js @@ -8,6 +8,7 @@ const PROJECT_ALL_MEMBERS_PATH = '/api/:version/projects/:id/members/all'; const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id'; const PROJECT_REPOSITORY_SIZE_PATH = '/api/:version/projects/:id/repository_size'; const PROJECT_TRANSFER_LOCATIONS_PATH = 'api/:version/projects/:id/transfer_locations'; +const PROJECT_SHARE_LOCATIONS_PATH = 'api/:version/projects/:id/share_locations'; export function getProjects(query, options, callback = () => {}) { const url = buildApiUrl(PROJECTS_PATH); @@ -70,3 +71,10 @@ export const getProjectMembers = (projectId, inherited = false) => { return axios.get(url); }; + +export const getProjectShareLocations = (projectId, params = {}, axiosOptions = {}) => { + const url = buildApiUrl(PROJECT_SHARE_LOCATIONS_PATH).replace(':id', projectId); + const defaultParams = { per_page: DEFAULT_PER_PAGE }; + + return axios.get(url, { params: { ...defaultParams, ...params }, ...axiosOptions }); +}; diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue index 42257127bbce53b976e4155c0096b471620e8749..d8669bff973234688887e7cf78e04f4a68b33f32 100644 --- a/app/assets/javascripts/invite_members/components/group_select.vue +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -3,7 +3,7 @@ import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; import axios from 'axios'; import { debounce } from 'lodash'; import { s__ } from '~/locale'; -import { getGroups, getDescendentGroups } from '~/rest_api'; +import { getGroups, getDescendentGroups, getProjectShareLocations } from '~/rest_api'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import { SEARCH_DELAY, GROUP_FILTERS } from '../constants'; @@ -26,6 +26,14 @@ export default { required: false, default: GROUP_FILTERS.ALL, }, + isProject: { + type: Boolean, + required: true, + }, + sourceId: { + type: String, + required: true, + }, parentGroupId: { type: Number, required: false, @@ -78,7 +86,7 @@ export default { value: group.id, id: group.id, name: group.full_name, - path: group.path, + path: group.full_path, avatarUrl: group.avatar_url, })); @@ -104,15 +112,25 @@ export default { this.activeApiRequestAbortController = new AbortController(); + const axiosConfig = { + signal: this.activeApiRequestAbortController.signal, + }; + + if (this.isProject) { + return this.fetchGroupsNew(axiosConfig); + } + + return this.fetchGroupsLegacy(options, axiosConfig); + }, + fetchGroupsNew(axiosConfig) { + return getProjectShareLocations(this.sourceId, { search: this.searchTerm }, axiosConfig); + }, + fetchGroupsLegacy(options, axiosConfig) { const combinedOptions = { ...this.$options.defaultFetchOptions, ...options, }; - const axiosConfig = { - signal: this.activeApiRequestAbortController.signal, - }; - switch (this.groupsFilter) { case GROUP_FILTERS.DESCENDANT_GROUPS: return getDescendentGroups( diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue index 9893572ae16fbc0364eac5a446ebdd8dddfdb9c9..673c4fa6f00a4ea9e3768b101e302476f2b73554 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -217,9 +217,11 @@ export default { <template #select> <group-select v-model="groupToBeSharedWith" + :source-id="id" :groups-filter="groupSelectFilter" :parent-group-id="groupSelectParentId" :invalid-groups="invalidGroups" + :is-project="isProject" @input="clearValidation" @error="onGroupSelectError" /> diff --git a/spec/frontend/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js index 4ceed885e6eb90f3f295e95ff46b09b0ac6dd2aa..7b8419e036d1d19fc54f17caab6f0029c3c93fca 100644 --- a/spec/frontend/api/projects_api_spec.js +++ b/spec/frontend/api/projects_api_spec.js @@ -146,4 +146,33 @@ describe('~/api/projects_api.js', () => { }); }); }); + + describe('getProjectShareLocations', () => { + it('requests share locations for a project', async () => { + const expectedUrl = `/api/v7/projects/1/share_locations`; + const params = { search: 'foo' }; + const axiosOptions = { mockOption: 'bar' }; + + const response = [ + { + id: 27, + web_url: 'http://127.0.0.1:3000/groups/Commit451', + name: 'Commit451', + avatar_url: null, + full_name: 'Commit451', + full_path: 'Commit451', + }, + ]; + + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, response); + + await expect( + projectsApi.getProjectShareLocations(projectId, params, axiosOptions), + ).resolves.toMatchObject({ + data: response, + }); + expect(mock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE }); + expect(mock.history.get[0].mockOption).toBe(axiosOptions.mockOption); + }); + }); }); diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js index 60501bfbd6a75f36bf814e43e8b44c9da7937c2c..9c22ca9796b954b313524e2c4b97b33acdc5ed32 100644 --- a/spec/frontend/invite_members/components/group_select_spec.js +++ b/spec/frontend/invite_members/components/group_select_spec.js @@ -4,9 +4,11 @@ import { mount } from '@vue/test-utils'; import axios from 'axios'; import waitForPromises from 'helpers/wait_for_promises'; import { getGroups } from '~/api/groups_api'; +import { getProjectShareLocations } from '~/api/projects_api'; import GroupSelect from '~/invite_members/components/group_select.vue'; jest.mock('~/api/groups_api'); +jest.mock('~/api/projects_api'); const group1 = { id: 1, full_name: 'Group One', avatar_url: 'test' }; const group2 = { id: 2, full_name: 'Group Two', avatar_url: 'test' }; @@ -20,23 +22,25 @@ const headers = { 'X-Total-Pages': 2, }; +const defaultProps = { + selectedGroup: {}, + invalidGroups: [], + sourceId: '1', + isProject: false, +}; + describe('GroupSelect', () => { let wrapper; const createComponent = (props = {}) => { wrapper = mount(GroupSelect, { propsData: { - selectedGroup: {}, - invalidGroups: [], + ...defaultProps, ...props, }, }); }; - beforeEach(() => { - getGroups.mockResolvedValueOnce({ data: allGroups, headers }); - }); - const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findListboxToggle = () => findListbox().find('button[aria-haspopup="listbox"]'); const findAvatarByLabel = (text) => @@ -45,178 +49,220 @@ describe('GroupSelect', () => { .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.props('label') === text); describe('when user types in the search input', () => { - beforeEach(async () => { - createComponent(); - await waitForPromises(); - getGroups.mockClear(); - getGroups.mockReturnValueOnce(new Promise(() => {})); - findListbox().vm.$emit('search', group1.full_name); - await nextTick(); - }); + describe('isProject is false', () => { + beforeEach(async () => { + createComponent({ isProject: false }); + await waitForPromises(); - it('calls the API', () => { - expect(getGroups).toHaveBeenCalledWith( - group1.full_name, - { - exclude_internal: true, - active: true, - order_by: 'similarity', - }, - undefined, - { - signal: expect.any(AbortSignal), - }, - ); - }); + getGroups.mockReturnValueOnce(new Promise(() => {})); + findListbox().vm.$emit('search', group1.full_name); + }); - it('displays loading icon while waiting for API call to resolve', () => { - expect(findListbox().props('searching')).toBe(true); - }); - }); + it('displays loading icon while waiting for API call to resolve', () => { + expect(findListbox().props('searching')).toBe(true); + }); - describe('avatar label', () => { - it('includes the correct attributes with name and avatar_url', async () => { - createComponent(); - await waitForPromises(); + it('calls the legacy API', async () => { + await nextTick(); - expect(findAvatarByLabel(group1.full_name).attributes()).toMatchObject({ - src: group1.avatar_url, - 'entity-id': `${group1.id}`, - 'entity-name': group1.full_name, - size: '32', + expect(getGroups).toHaveBeenCalledWith( + group1.full_name, + { + exclude_internal: true, + active: true, + order_by: 'similarity', + }, + undefined, + { + signal: expect.any(AbortSignal), + }, + ); }); }); - describe('when filtering out the group from results', () => { + describe('isProject is true', () => { beforeEach(async () => { - createComponent({ invalidGroups: [group1.id] }); + createComponent({ isProject: true }); await waitForPromises(); - }); - it('does not find an invalid group', () => { - expect(findAvatarByLabel(group1.full_name)).toBe(undefined); + getProjectShareLocations.mockReturnValueOnce(new Promise(() => {})); + findListbox().vm.$emit('search', group1.full_name); }); - it('finds a group that is valid', () => { - expect(findAvatarByLabel(group2.full_name).exists()).toBe(true); + it('displays loading icon while waiting for API call to resolve', () => { + expect(findListbox().props('searching')).toBe(true); }); - }); - }); - describe('when group is selected from the dropdown', () => { - beforeEach(async () => { - createComponent({ - selectedGroup: { - value: group1.id, - id: group1.id, - name: group1.full_name, - path: group1.path, - avatarUrl: group1.avatar_url, - }, - }); - await waitForPromises(); - findListbox().vm.$emit('select', group1.id); - await nextTick(); - }); + it('calls the new API', async () => { + await nextTick(); - it('emits `input` event used by `v-model`', () => { - expect(wrapper.emitted('input')).toMatchObject([ - [ + expect(getProjectShareLocations).toHaveBeenCalledWith( + defaultProps.sourceId, { - value: group1.id, - id: group1.id, - name: group1.full_name, - path: group1.path, - avatarUrl: group1.avatar_url, + search: group1.full_name, }, - ], - ]); - }); - - it('sets dropdown toggle text to selected item', () => { - expect(findListboxToggle().text()).toBe(group1.full_name); + { + signal: expect.any(AbortSignal), + }, + ); + }); }); }); - describe('infinite scroll', () => { - it('sets infinite scroll related props', async () => { - createComponent(); - await waitForPromises(); + describe.each` + isProject + ${true} + ${false} + `('isProject is $isProject', ({ isProject }) => { + const apiAction = isProject ? getProjectShareLocations : getGroups; - expect(findListbox().props()).toMatchObject({ - infiniteScroll: true, - infiniteScrollLoading: false, - totalItems: 40, - }); + beforeEach(() => { + apiAction.mockResolvedValueOnce({ data: allGroups, headers }); }); - describe('when `bottom-reached` event is fired', () => { - it('indicates new groups are loading and adds them to the listbox', async () => { - createComponent(); + describe('avatar label', () => { + it('includes the correct attributes with name and avatar_url', async () => { + createComponent({ isProject }); await waitForPromises(); - const infiniteScrollGroup = { - id: 3, - full_name: 'Infinite scroll group', - avatar_url: 'test', - }; + expect(findAvatarByLabel(group1.full_name).attributes()).toMatchObject({ + src: group1.avatar_url, + 'entity-id': `${group1.id}`, + 'entity-name': group1.full_name, + size: '32', + }); + }); - getGroups.mockResolvedValueOnce({ data: [infiniteScrollGroup], headers }); + describe('when filtering out the group from results', () => { + beforeEach(async () => { + createComponent({ isProject, invalidGroups: [group1.id] }); + await waitForPromises(); + }); - findListbox().vm.$emit('bottom-reached'); + it('does not find an invalid group', () => { + expect(findAvatarByLabel(group1.full_name)).toBe(undefined); + }); + + it('finds a group that is valid', () => { + expect(findAvatarByLabel(group2.full_name).exists()).toBe(true); + }); + }); + }); + + describe('when group is selected from the dropdown', () => { + beforeEach(async () => { + createComponent({ + isProject, + selectedGroup: { + value: group1.id, + id: group1.id, + name: group1.full_name, + path: group1.path, + avatarUrl: group1.avatar_url, + }, + }); + await waitForPromises(); + findListbox().vm.$emit('select', group1.id); await nextTick(); + }); + + it('emits `input` event used by `v-model`', () => { + expect(wrapper.emitted('input')).toMatchObject([ + [ + { + value: group1.id, + id: group1.id, + name: group1.full_name, + path: group1.path, + avatarUrl: group1.avatar_url, + }, + ], + ]); + }); - expect(findListbox().props('infiniteScrollLoading')).toBe(true); + it('sets dropdown toggle text to selected item', () => { + expect(findListboxToggle().text()).toBe(group1.full_name); + }); + }); + describe('infinite scroll', () => { + beforeEach(async () => { + createComponent({ isProject }); await waitForPromises(); + }); - expect(findListbox().props('items')[2]).toMatchObject({ - value: infiniteScrollGroup.id, - id: infiniteScrollGroup.id, - name: infiniteScrollGroup.full_name, - avatarUrl: infiniteScrollGroup.avatar_url, + it('sets infinite scroll related props', () => { + expect(findListbox().props()).toMatchObject({ + infiniteScroll: true, + infiniteScrollLoading: false, + totalItems: 40, }); }); - describe('when API request fails', () => { - it('emits `error` event', async () => { - createComponent(); - await waitForPromises(); + describe('when `bottom-reached` event is fired', () => { + it('indicates new groups are loading and adds them to the listbox', async () => { + const infiniteScrollGroup = { + id: 3, + full_name: 'Infinite scroll group', + avatar_url: 'test', + }; - getGroups.mockRejectedValueOnce(); + apiAction.mockResolvedValueOnce({ data: [infiniteScrollGroup], headers }); findListbox().vm.$emit('bottom-reached'); + await nextTick(); + + expect(findListbox().props('infiniteScrollLoading')).toBe(true); + await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[GroupSelect.i18n.errorMessage]]); + expect(findListbox().props('items')[2]).toMatchObject({ + value: infiniteScrollGroup.id, + id: infiniteScrollGroup.id, + name: infiniteScrollGroup.full_name, + avatarUrl: infiniteScrollGroup.avatar_url, + }); }); - it('does not emit `error` event if error is from request cancellation', async () => { - createComponent(); - await waitForPromises(); + describe('when API request fails', () => { + it('emits `error` event', async () => { + apiAction.mockRejectedValueOnce(); - getGroups.mockRejectedValueOnce(new axios.Cancel()); + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); - findListbox().vm.$emit('bottom-reached'); - await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[GroupSelect.i18n.errorMessage]]); + }); + + it('does not emit `error` event if error is from request cancellation', async () => { + apiAction.mockRejectedValueOnce(new axios.Cancel()); + + findListbox().vm.$emit('bottom-reached'); + await waitForPromises(); - expect(wrapper.emitted('error')).toEqual(undefined); + expect(wrapper.emitted('error')).toEqual(undefined); + }); }); }); }); - }); - describe('when multiple API calls are in-flight', () => { - it('aborts the first API call and resolves second API call', async () => { - const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + describe('when multiple API calls are in-flight', () => { + let abortSpy; - createComponent(); - await waitForPromises(); + beforeEach(async () => { + abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + apiAction.mockResolvedValueOnce({ data: allGroups, headers }); - findListbox().vm.$emit('search', group1.full_name); + createComponent({ isProject }); + await waitForPromises(); + }); - expect(abortSpy).toHaveBeenCalledTimes(1); - expect(wrapper.emitted('error')).toEqual(undefined); + it('aborts the first API call and resolves second API call', () => { + findListbox().vm.$emit('search', group1.full_name); + + expect(abortSpy).toHaveBeenCalledTimes(1); + expect(wrapper.emitted('error')).toEqual(undefined); + }); }); }); }); diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js index 358d70d8117a8ef3ea0570f2407330dd1260e922..09d4fdb12c1de548a78463bf6a8e83bd53efd4f4 100644 --- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js @@ -271,4 +271,17 @@ describe('InviteGroupsModal', () => { expect(wrapper.findComponent(GlAlert).text()).toBe(GroupSelect.i18n.errorMessage); }); }); + + it('renders `GroupSelect` component and passes correct props', () => { + createComponent({ isProject: true }); + + expect(findGroupSelect().props()).toStrictEqual({ + selectedGroup: {}, + groupsFilter: 'all', + isProject: true, + sourceId: '1', + parentGroupId: null, + invalidGroups: [], + }); + }); });