diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 428bc196899..b8544af8ff8 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -04496625daa52aaa82fe4140d898258e7e6fda22 +a6674b359a02a4bf0549dcaa77ac05b1f4850831 diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 2b0ddbed7b3..90d8f6ebc58 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -3,7 +3,7 @@ import { GlButton } from '@gitlab/ui'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import ListIssue from 'ee_else_ce/boards/models/issue'; import eventHub from '../eventhub'; -import ProjectSelect from './project_select.vue'; +import ProjectSelect from './project_select_deprecated.vue'; import boardsStore from '../stores/boards_store'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; diff --git a/app/assets/javascripts/boards/components/board_new_issue_new.vue b/app/assets/javascripts/boards/components/board_new_issue_new.vue index 674a49e01ef..5a1b8a7672f 100644 --- a/app/assets/javascripts/boards/components/board_new_issue_new.vue +++ b/app/assets/javascripts/boards/components/board_new_issue_new.vue @@ -1,5 +1,5 @@ diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index aecb2125e04..04699d0d3a4 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -1,14 +1,14 @@ + + diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql new file mode 100644 index 00000000000..1afa6e48547 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql @@ -0,0 +1,17 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getGroupProjects($fullPath: ID!, $search: String, $after: String) { + group(fullPath: $fullPath) { + projects(search: $search, after: $after, first: 100) { + nodes { + id + name + fullPath + nameWithNamespace + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 46d551eb50c..0fe6d669f86 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -29,6 +29,7 @@ import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.grap import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql'; import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql'; import issueSetTitleMutation from '../graphql/issue_set_title.mutation.graphql'; +import groupProjectsQuery from '../graphql/group_projects.query.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -498,6 +499,37 @@ export default { }); }, + fetchGroupProjects: ({ commit, state }, { search = '', fetchNext = false }) => { + commit(types.REQUEST_GROUP_PROJECTS, fetchNext); + + const { fullPath } = state; + + const variables = { + fullPath, + search: search !== '' ? search : undefined, + after: fetchNext ? state.groupProjectsFlags.pageInfo.endCursor : undefined, + }; + + return gqlClient + .query({ + query: groupProjectsQuery, + variables, + }) + .then(({ data }) => { + const { projects } = data.group; + commit(types.RECEIVE_GROUP_PROJECTS_SUCCESS, { + projects: projects.nodes, + pageInfo: projects.pageInfo, + fetchNext, + }); + }) + .catch(() => commit(types.RECEIVE_GROUP_PROJECTS_FAILURE)); + }, + + setSelectedProject: ({ commit }, project) => { + commit(types.SET_SELECTED_PROJECT, project); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 2b2c2bee51c..4697f39498a 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -36,3 +36,7 @@ export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID'; export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING'; export const RESET_ISSUES = 'RESET_ISSUES'; +export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS'; +export const RECEIVE_GROUP_PROJECTS_SUCCESS = 'RECEIVE_GROUP_PROJECTS_SUCCESS'; +export const RECEIVE_GROUP_PROJECTS_FAILURE = 'RECEIVE_GROUP_PROJECTS_FAILURE'; +export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 62ac2ca1417..6c79b22d308 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -237,4 +237,25 @@ export default { [mutationTypes.TOGGLE_EMPTY_STATE]: () => { notImplemented(); }, + + [mutationTypes.REQUEST_GROUP_PROJECTS]: (state, fetchNext) => { + Vue.set(state, 'groupProjectsFlags', { + [fetchNext ? 'isLoadingMore' : 'isLoading']: true, + pageInfo: state.groupProjectsFlags.pageInfo, + }); + }, + + [mutationTypes.RECEIVE_GROUP_PROJECTS_SUCCESS]: (state, { projects, pageInfo, fetchNext }) => { + Vue.set(state, 'groupProjects', fetchNext ? [...state.groupProjects, ...projects] : projects); + Vue.set(state, 'groupProjectsFlags', { isLoading: false, isLoadingMore: false, pageInfo }); + }, + + [mutationTypes.RECEIVE_GROUP_PROJECTS_FAILURE]: (state) => { + state.error = s__('Boards|An error occurred while fetching group projects. Please try again.'); + Vue.set(state, 'groupProjectsFlags', { isLoading: false, isLoadingMore: false }); + }, + + [mutationTypes.SET_SELECTED_PROJECT]: (state, project) => { + state.selectedProject = project; + }, }; diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 573e98e56e0..aba7da373cf 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,7 +1,6 @@ import { inactiveId } from '~/boards/constants'; export default () => ({ - endpoints: {}, boardType: null, disabled: false, isShowingLabels: true, @@ -15,6 +14,13 @@ export default () => ({ issues: {}, filterParams: {}, boardConfig: {}, + groupProjects: [], + groupProjectsFlags: { + isLoading: false, + isLoadingMore: false, + pageInfo: {}, + }, + selectedProject: {}, error: undefined, // TODO: remove after ce/ee split of board_content.vue isShowingEpicsSwimlanes: false, diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb index bce685cacdf..8ee449cbfdc 100644 --- a/app/services/packages/maven/find_or_create_package_service.rb +++ b/app/services/packages/maven/find_or_create_package_service.rb @@ -11,7 +11,12 @@ module Packages .execute unless Namespace::PackageSetting.duplicates_allowed?(package) - return ServiceResponse.error(message: 'Duplicate package is not allowed') + files = package&.package_files || [] + current_maven_files = files.map { |file| extname(file.file_name) } + + if current_maven_files.compact.include?(extname(params[:file_name])) + return ServiceResponse.error(message: 'Duplicate package is not allowed') + end end unless package @@ -54,6 +59,14 @@ module Packages ServiceResponse.success(payload: { package: package }) end + + private + + def extname(filename) + return if filename.blank? + + File.extname(filename) + end end end end diff --git a/changelogs/unreleased/276882-maven-dupe-fix.yml b/changelogs/unreleased/276882-maven-dupe-fix.yml new file mode 100644 index 00000000000..1402d7fb710 --- /dev/null +++ b/changelogs/unreleased/276882-maven-dupe-fix.yml @@ -0,0 +1,6 @@ +--- +title: Fix behavior of maven_duplicates_allowed setting so new Maven packages can + be uploaded +merge_request: 51524 +author: +type: fixed diff --git a/config/feature_flags/experiment/null_hypothesis.yml b/config/feature_flags/experiment/null_hypothesis.yml index 716b0711ef1..8ac76809842 100644 --- a/config/feature_flags/experiment/null_hypothesis.yml +++ b/config/feature_flags/experiment/null_hypothesis.yml @@ -2,6 +2,7 @@ name: null_hypothesis introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45840 rollout_issue_url: +milestone: '13.7' type: experiment group: group::adoption default_enabled: false diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f905020dc79..f218030fcf4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4582,6 +4582,9 @@ msgstr "" msgid "Boards|An error occurred while creating the list. Please try again." msgstr "" +msgid "Boards|An error occurred while fetching group projects. Please try again." +msgstr "" + msgid "Boards|An error occurred while fetching issues. Please reload the page." msgstr "" diff --git a/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb b/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb index 6376f9ab5fd..2b94c072c8b 100644 --- a/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb +++ b/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb @@ -55,7 +55,7 @@ RSpec.describe 'User closes/reopens a merge request', :js do end end - describe 'when closed' do + describe 'when closed', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297500' do context 'when clicking the top `Reopen merge request` link', :aggregate_failures do let(:closed_merge_request) { create(:merge_request, source_project: project, target_project: project, state: 'closed') } diff --git a/spec/frontend/boards/components/board_new_issue_new_spec.js b/spec/frontend/boards/components/board_new_issue_new_spec.js index ee1c4f31cf0..3265ef40a2e 100644 --- a/spec/frontend/boards/components/board_new_issue_new_spec.js +++ b/spec/frontend/boards/components/board_new_issue_new_spec.js @@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import BoardNewIssue from '~/boards/components/board_new_issue_new.vue'; import '~/boards/models/list'; -import { mockList } from '../mock_data'; +import { mockList, mockGroupProjects } from '../mock_data'; const localVue = createLocalVue(); @@ -29,7 +29,7 @@ describe('Issue boards new issue form', () => { beforeEach(() => { const store = new Vuex.Store({ - state: {}, + state: { selectedProject: mockGroupProjects[0] }, actions: { addListNewIssue: addListNewIssuesSpy }, getters: {}, }); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 47cc5baff26..a963a92a366 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -365,3 +365,18 @@ export const mockRawGroupProjects = [ path_with_namespace: 'awesome-group/foobar-project', }, ]; + +export const mockGroupProjects = [ + { + id: 0, + name: 'Example Project', + nameWithNamespace: 'Awesome Group / Example Project', + fullPath: 'awesome-group/example-project', + }, + { + id: 1, + name: 'Foobar Project', + nameWithNamespace: 'Awesome Group / Foobar Project', + fullPath: 'awesome-group/foobar-project', + }, +]; diff --git a/spec/frontend/boards/project_select_deprecated_spec.js b/spec/frontend/boards/project_select_deprecated_spec.js new file mode 100644 index 00000000000..e4f8f96bd33 --- /dev/null +++ b/spec/frontend/boards/project_select_deprecated_spec.js @@ -0,0 +1,261 @@ +import { mount } from '@vue/test-utils'; +import axios from 'axios'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import httpStatus from '~/lib/utils/http_status'; +import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; +import { ListType } from '~/boards/constants'; +import eventHub from '~/boards/eventhub'; +import { deprecatedCreateFlash as flash } from '~/flash'; + +import ProjectSelect from '~/boards/components/project_select_deprecated.vue'; + +import { listObj, mockRawGroupProjects } from './mock_data'; + +jest.mock('~/boards/eventhub'); +jest.mock('~/flash'); + +const dummyGon = { + api_version: 'v4', + relative_url_root: '/gitlab', +}; + +const mockGroupId = 1; +const mockProjectsList1 = mockRawGroupProjects.slice(0, 1); +const mockProjectsList2 = mockRawGroupProjects.slice(1); +const mockDefaultFetchOptions = { + with_issues_enabled: true, + with_shared: false, + include_subgroups: true, + order_by: 'similarity', +}; + +const itemsPerPage = 20; + +describe('ProjectSelect component', () => { + let wrapper; + let axiosMock; + + const findLabel = () => wrapper.find("[data-testid='header-label']"); + const findGlDropdown = () => wrapper.find(GlDropdown); + const findGlDropdownLoadingIcon = () => + findGlDropdown().find('button:first-child').find(GlLoadingIcon); + const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType); + const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); + const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']"); + const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']"); + + const mockGetRequest = (data = [], statusCode = httpStatus.OK) => { + axiosMock + .onGet(`/gitlab/api/v4/groups/${mockGroupId}/projects.json`) + .replyOnce(statusCode, data); + }; + + const searchForProject = async (keyword, waitForAll = true) => { + findGlSearchBoxByType().vm.$emit('input', keyword); + + if (waitForAll) { + await axios.waitForAll(); + } + }; + + const createWrapper = async ({ list = listObj } = {}, waitForAll = true) => { + wrapper = mount(ProjectSelect, { + propsData: { + list, + }, + provide: { + groupId: 1, + }, + }); + + if (waitForAll) { + await axios.waitForAll(); + } + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + window.gon = dummyGon; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + axiosMock.restore(); + jest.clearAllMocks(); + }); + + it('displays a header title', async () => { + createWrapper({}); + + expect(findLabel().text()).toBe('Projects'); + }); + + it('renders a default dropdown text', async () => { + createWrapper({}); + + expect(findGlDropdown().exists()).toBe(true); + expect(findGlDropdown().text()).toContain('Select a project'); + }); + + describe('when mounted', () => { + it('displays a loading icon while projects are being fetched', async () => { + mockGetRequest([]); + + createWrapper({}, false); + + expect(findGlDropdownLoadingIcon().exists()).toBe(true); + + await axios.waitForAll(); + + expect(axiosMock.history.get[0].params).toMatchObject({ search: '' }); + expect(axiosMock.history.get[0].url).toBe( + `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, + ); + + expect(findGlDropdownLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when dropdown menu is open', () => { + describe('by default', () => { + beforeEach(async () => { + mockGetRequest(mockProjectsList1); + + await createWrapper(); + }); + + it('shows GlSearchBoxByType with default attributes', () => { + expect(findGlSearchBoxByType().exists()).toBe(true); + expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({ + placeholder: 'Search projects', + debounce: '250', + }); + }); + + it("displays the fetched project's name", () => { + expect(findFirstGlDropdownItem().exists()).toBe(true); + expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name); + }); + + it("doesn't render loading icon in the menu", () => { + expect(findInMenuLoadingIcon().isVisible()).toBe(false); + }); + + it('renders empty search result message', async () => { + await createWrapper(); + + expect(findEmptySearchMessage().exists()).toBe(true); + }); + }); + + describe('when a project is selected', () => { + beforeEach(async () => { + mockGetRequest(mockProjectsList1); + + await createWrapper(); + + await findFirstGlDropdownItem().find('button').trigger('click'); + }); + + it('emits setSelectedProject with correct project metadata', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('setSelectedProject', { + id: mockProjectsList1[0].id, + path: mockProjectsList1[0].path_with_namespace, + name: mockProjectsList1[0].name, + namespacedName: mockProjectsList1[0].name_with_namespace, + }); + }); + + it('renders the name of the selected project', () => { + expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe( + mockProjectsList1[0].name, + ); + }); + }); + + describe('when user searches for a project', () => { + beforeEach(async () => { + mockGetRequest(mockProjectsList1); + + await createWrapper(); + }); + + it('calls API with correct parameters with default fetch options', async () => { + await searchForProject('foobar'); + + const expectedApiParams = { + search: 'foobar', + per_page: itemsPerPage, + ...mockDefaultFetchOptions, + }; + + expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); + expect(axiosMock.history.get[1].url).toBe( + `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, + ); + }); + + describe("when list type is defined and isn't backlog", () => { + it('calls API with an additional fetch option (min_access_level)', async () => { + axiosMock.reset(); + + await createWrapper({ list: { ...listObj, type: ListType.label } }); + + await searchForProject('foobar'); + + const expectedApiParams = { + search: 'foobar', + per_page: itemsPerPage, + ...mockDefaultFetchOptions, + min_access_level: featureAccessLevel.EVERYONE, + }; + + expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); + expect(axiosMock.history.get[1].url).toBe( + `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, + ); + }); + }); + + it('displays and hides gl-loading-icon while and after fetching data', async () => { + await searchForProject('some keyword', false); + + await wrapper.vm.$nextTick(); + + expect(findInMenuLoadingIcon().isVisible()).toBe(true); + + await axios.waitForAll(); + + expect(findInMenuLoadingIcon().isVisible()).toBe(false); + }); + + it('flashes an error message when fetching fails', async () => { + mockGetRequest([], httpStatus.INTERNAL_SERVER_ERROR); + + await searchForProject('foobar'); + + expect(flash).toHaveBeenCalledTimes(1); + expect(flash).toHaveBeenCalledWith('Something went wrong while fetching projects'); + }); + + describe('with non-empty search result', () => { + beforeEach(async () => { + mockGetRequest(mockProjectsList2); + + await searchForProject('foobar'); + }); + + it('displays the retrieved list of projects', async () => { + expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList2[0].name); + }); + + it('does not render empty search result message', async () => { + expect(findEmptySearchMessage().exists()).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js index fd4f84e996a..14ddab3542b 100644 --- a/spec/frontend/boards/project_select_spec.js +++ b/spec/frontend/boards/project_select_spec.js @@ -1,40 +1,31 @@ -import { mount } from '@vue/test-utils'; -import axios from 'axios'; -import AxiosMockAdapter from 'axios-mock-adapter'; +import Vuex from 'vuex'; +import { createLocalVue, mount } from '@vue/test-utils'; import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; -import httpStatus from '~/lib/utils/http_status'; -import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; -import { ListType } from '~/boards/constants'; -import eventHub from '~/boards/eventhub'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import defaultState from '~/boards/stores/state'; import ProjectSelect from '~/boards/components/project_select.vue'; -import { listObj, mockRawGroupProjects } from './mock_data'; +import { mockList, mockGroupProjects } from './mock_data'; -jest.mock('~/boards/eventhub'); -jest.mock('~/flash'); +const localVue = createLocalVue(); +localVue.use(Vuex); -const dummyGon = { - api_version: 'v4', - relative_url_root: '/gitlab', +const actions = { + fetchGroupProjects: jest.fn(), + setSelectedProject: jest.fn(), }; -const mockGroupId = 1; -const mockProjectsList1 = mockRawGroupProjects.slice(0, 1); -const mockProjectsList2 = mockRawGroupProjects.slice(1); -const mockDefaultFetchOptions = { - with_issues_enabled: true, - with_shared: false, - include_subgroups: true, - order_by: 'similarity', +const createStore = (state = defaultState) => { + return new Vuex.Store({ + state, + actions, + }); }; -const itemsPerPage = 20; +const mockProjectsList1 = mockGroupProjects.slice(0, 1); describe('ProjectSelect component', () => { let wrapper; - let axiosMock; const findLabel = () => wrapper.find("[data-testid='header-label']"); const findGlDropdown = () => wrapper.find(GlDropdown); @@ -46,55 +37,43 @@ describe('ProjectSelect component', () => { const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']"); const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']"); - const mockGetRequest = (data = [], statusCode = httpStatus.OK) => { - axiosMock - .onGet(`/gitlab/api/v4/groups/${mockGroupId}/projects.json`) - .replyOnce(statusCode, data); - }; - - const searchForProject = async (keyword, waitForAll = true) => { - findGlSearchBoxByType().vm.$emit('input', keyword); - - if (waitForAll) { - await axios.waitForAll(); - } - }; - - const createWrapper = async ({ list = listObj } = {}, waitForAll = true) => { - wrapper = mount(ProjectSelect, { - propsData: { - list, + const createWrapper = (state = {}) => { + const store = createStore({ + groupProjects: [], + groupProjectsFlags: { + isLoading: false, + pageInfo: { + hasNextPage: false, + }, }, + ...state, + }); + + wrapper = mount(ProjectSelect, { + localVue, + propsData: { + list: mockList, + }, + store, provide: { groupId: 1, }, }); - - if (waitForAll) { - await axios.waitForAll(); - } }; - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - window.gon = dummyGon; - }); - afterEach(() => { wrapper.destroy(); wrapper = null; - axiosMock.restore(); - jest.clearAllMocks(); }); - it('displays a header title', async () => { - createWrapper({}); + it('displays a header title', () => { + createWrapper(); expect(findLabel().text()).toBe('Projects'); }); - it('renders a default dropdown text', async () => { - createWrapper({}); + it('renders a default dropdown text', () => { + createWrapper(); expect(findGlDropdown().exists()).toBe(true); expect(findGlDropdown().text()).toContain('Select a project'); @@ -102,18 +81,11 @@ describe('ProjectSelect component', () => { describe('when mounted', () => { it('displays a loading icon while projects are being fetched', async () => { - mockGetRequest([]); - - createWrapper({}, false); + createWrapper(); expect(findGlDropdownLoadingIcon().exists()).toBe(true); - await axios.waitForAll(); - - expect(axiosMock.history.get[0].params).toMatchObject({ search: '' }); - expect(axiosMock.history.get[0].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); + await wrapper.vm.$nextTick(); expect(findGlDropdownLoadingIcon().exists()).toBe(false); }); @@ -121,10 +93,8 @@ describe('ProjectSelect component', () => { describe('when dropdown menu is open', () => { describe('by default', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); + beforeEach(() => { + createWrapper({ groupProjects: mockGroupProjects }); }); it('shows GlSearchBoxByType with default attributes', () => { @@ -144,29 +114,24 @@ describe('ProjectSelect component', () => { expect(findInMenuLoadingIcon().isVisible()).toBe(false); }); - it('renders empty search result message', async () => { - await createWrapper(); + it('does not render empty search result message', () => { + expect(findEmptySearchMessage().exists()).toBe(false); + }); + }); + + describe('when no projects are being returned', () => { + it('renders empty search result message', () => { + createWrapper(); expect(findEmptySearchMessage().exists()).toBe(true); }); }); describe('when a project is selected', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); + beforeEach(() => { + createWrapper({ groupProjects: mockProjectsList1 }); - await createWrapper(); - - await findFirstGlDropdownItem().find('button').trigger('click'); - }); - - it('emits setSelectedProject with correct project metadata', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('setSelectedProject', { - id: mockProjectsList1[0].id, - path: mockProjectsList1[0].path_with_namespace, - name: mockProjectsList1[0].name, - namespacedName: mockProjectsList1[0].name_with_namespace, - }); + findFirstGlDropdownItem().find('button').trigger('click'); }); it('renders the name of the selected project', () => { @@ -176,85 +141,13 @@ describe('ProjectSelect component', () => { }); }); - describe('when user searches for a project', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); + describe('when projects are loading', () => { + beforeEach(() => { + createWrapper({ groupProjectsFlags: { isLoading: true } }); }); - it('calls API with correct parameters with default fetch options', async () => { - await searchForProject('foobar'); - - const expectedApiParams = { - search: 'foobar', - per_page: itemsPerPage, - ...mockDefaultFetchOptions, - }; - - expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); - expect(axiosMock.history.get[1].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - }); - - describe("when list type is defined and isn't backlog", () => { - it('calls API with an additional fetch option (min_access_level)', async () => { - axiosMock.reset(); - - await createWrapper({ list: { ...listObj, type: ListType.label } }); - - await searchForProject('foobar'); - - const expectedApiParams = { - search: 'foobar', - per_page: itemsPerPage, - ...mockDefaultFetchOptions, - min_access_level: featureAccessLevel.EVERYONE, - }; - - expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); - expect(axiosMock.history.get[1].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - }); - }); - - it('displays and hides gl-loading-icon while and after fetching data', async () => { - await searchForProject('some keyword', false); - - await wrapper.vm.$nextTick(); - + it('displays and hides gl-loading-icon while and after fetching data', () => { expect(findInMenuLoadingIcon().isVisible()).toBe(true); - - await axios.waitForAll(); - - expect(findInMenuLoadingIcon().isVisible()).toBe(false); - }); - - it('flashes an error message when fetching fails', async () => { - mockGetRequest([], httpStatus.INTERNAL_SERVER_ERROR); - - await searchForProject('foobar'); - - expect(flash).toHaveBeenCalledTimes(1); - expect(flash).toHaveBeenCalledWith('Something went wrong while fetching projects'); - }); - - describe('with non-empty search result', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList2); - - await searchForProject('foobar'); - }); - - it('displays the retrieved list of projects', async () => { - expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList2[0].name); - }); - - it('does not render empty search result message', async () => { - expect(findEmptySearchMessage().exists()).toBe(false); - }); }); }); }); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 5a87a4076df..72f81d6db29 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -9,6 +9,7 @@ import { mockMilestone, labels, mockActiveIssue, + mockGroupProjects, } from '../mock_data'; import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; @@ -1037,6 +1038,94 @@ describe('setActiveIssueTitle', () => { }); }); +describe('fetchGroupProjects', () => { + const state = { + fullPath: 'gitlab-org', + }; + + const pageInfo = { + endCursor: '', + hasNextPage: false, + }; + + const queryResponse = { + data: { + group: { + projects: { + nodes: mockGroupProjects, + pageInfo: { + endCursor: '', + hasNextPage: false, + }, + }, + }, + }, + }; + + it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', (done) => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + testAction( + actions.fetchGroupProjects, + {}, + state, + [ + { + type: types.REQUEST_GROUP_PROJECTS, + payload: false, + }, + { + type: types.RECEIVE_GROUP_PROJECTS_SUCCESS, + payload: { projects: mockGroupProjects, pageInfo, fetchNext: false }, + }, + ], + [], + done, + ); + }); + + it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', (done) => { + jest.spyOn(gqlClient, 'query').mockRejectedValue(); + + testAction( + actions.fetchGroupProjects, + {}, + state, + [ + { + type: types.REQUEST_GROUP_PROJECTS, + payload: false, + }, + { + type: types.RECEIVE_GROUP_PROJECTS_FAILURE, + }, + ], + [], + done, + ); + }); +}); + +describe('setSelectedProject', () => { + it('should commit mutation SET_SELECTED_PROJECT', (done) => { + const project = mockGroupProjects[0]; + + testAction( + actions.setSelectedProject, + project, + {}, + [ + { + type: types.SET_SELECTED_PROJECT, + payload: project, + }, + ], + [], + done, + ); + }); +}); + describe('fetchBacklog', () => { expectNotImplemented(actions.fetchBacklog); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index fa5357856dc..c5fe0e22c3c 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -1,7 +1,7 @@ import mutations from '~/boards/stores/mutations'; import * as types from '~/boards/stores/mutation_types'; import defaultState from '~/boards/stores/state'; -import { mockLists, rawIssue, mockIssue, mockIssue2 } from '../mock_data'; +import { mockLists, rawIssue, mockIssue, mockIssue2, mockGroupProjects } from '../mock_data'; const expectNotImplemented = (action) => { it('is not implemented', () => { @@ -529,4 +529,64 @@ describe('Board Store Mutations', () => { describe('TOGGLE_EMPTY_STATE', () => { expectNotImplemented(mutations.TOGGLE_EMPTY_STATE); }); + + describe('REQUEST_GROUP_PROJECTS', () => { + it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is false', () => { + mutations[types.REQUEST_GROUP_PROJECTS](state, false); + + expect(state.groupProjectsFlags.isLoading).toBe(true); + }); + + it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is true', () => { + mutations[types.REQUEST_GROUP_PROJECTS](state, true); + + expect(state.groupProjectsFlags.isLoadingMore).toBe(true); + }); + }); + + describe('RECEIVE_GROUP_PROJECTS_SUCCESS', () => { + it('Should set groupProjects and pageInfo to state and isLoading in groupProjectsFlags to false', () => { + mutations[types.RECEIVE_GROUP_PROJECTS_SUCCESS](state, { + projects: mockGroupProjects, + pageInfo: { hasNextPage: false }, + }); + + expect(state.groupProjects).toEqual(mockGroupProjects); + expect(state.groupProjectsFlags.isLoading).toBe(false); + expect(state.groupProjectsFlags.pageInfo).toEqual({ hasNextPage: false }); + }); + + it('Should merge projects in groupProjects in state when fetchNext is true', () => { + state = { + ...state, + groupProjects: [mockGroupProjects[0]], + }; + + mutations[types.RECEIVE_GROUP_PROJECTS_SUCCESS](state, { + projects: [mockGroupProjects[1]], + fetchNext: true, + }); + + expect(state.groupProjects).toEqual(mockGroupProjects); + }); + }); + + describe('RECEIVE_GROUP_PROJECTS_FAILURE', () => { + it('Should set error in state and isLoading in groupProjectsFlags to false', () => { + mutations[types.RECEIVE_GROUP_PROJECTS_FAILURE](state); + + expect(state.error).toEqual( + 'An error occurred while fetching group projects. Please try again.', + ); + expect(state.groupProjectsFlags.isLoading).toBe(false); + }); + }); + + describe('SET_SELECTED_PROJECT', () => { + it('Should set selectedProject to state', () => { + mutations[types.SET_SELECTED_PROJECT](state, mockGroupProjects[0]); + + expect(state.selectedProject).toEqual(mockGroupProjects[0]); + }); + }); }); diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb index 13d8e0a023a..e5d11fb1218 100644 --- a/spec/requests/api/maven_packages_spec.rb +++ b/spec/requests/api/maven_packages_spec.rb @@ -679,6 +679,15 @@ RSpec.describe API::MavenPackages do package_settings.update!(maven_duplicates_allowed: false) end + shared_examples 'storing the package file' do + it 'stores the file', :aggregate_failures do + expect { upload_file_with_token(params: params) }.to change { package.package_files.count }.by(1) + + expect(response).to have_gitlab_http_status(:ok) + expect(jar_file.file_name).to eq(file_upload.original_filename) + end + end + it 'rejects the request', :aggregate_failures do expect { upload_file_with_token(params: params) }.not_to change { package.package_files.count } @@ -686,17 +695,23 @@ RSpec.describe API::MavenPackages do expect(json_response['message']).to include('Duplicate package is not allowed') end + context 'when uploading different non-duplicate files to the same package' do + let!(:package) { create(:maven_package, project: project, name: project.full_path) } + + before do + package_file = package.package_files.find_by(file_name: 'my-app-1.0-20180724.124855-1.jar') + package_file.destroy! + end + + it_behaves_like 'storing the package file' + end + context 'when the package name matches the exception regex' do before do package_settings.update!(maven_duplicate_exception_regex: '.*') end - it 'stores the package file', :aggregate_failures do - expect { upload_file_with_token(params: params) }.to change { package.package_files.count }.by(1) - - expect(response).to have_gitlab_http_status(:ok) - expect(jar_file.file_name).to eq(file_upload.original_filename) - end + it_behaves_like 'storing the package file' end end diff --git a/spec/services/packages/maven/find_or_create_package_service_spec.rb b/spec/services/packages/maven/find_or_create_package_service_spec.rb index 6b852bcbba0..82dffeefcde 100644 --- a/spec/services/packages/maven/find_or_create_package_service_spec.rb +++ b/spec/services/packages/maven/find_or_create_package_service_spec.rb @@ -111,6 +111,15 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do expect(subject.errors).to include('Duplicate package is not allowed') end + context 'when uploading different non-duplicate files to the same package' do + before do + package_file = existing_package.package_files.find_by(file_name: 'my-app-1.0-20180724.124855-1.jar') + package_file.destroy! + end + + it_behaves_like 'reuse existing package' + end + context 'when the package name matches the exception regex' do before do package_settings.update!(maven_duplicate_exception_regex: '.*')