+
+
+
+
+
+ {{ project.namespacedName }}
+
+
+
+
+
+ {{ $options.i18n.emptySearchResult }}
+
+
+
+
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: '.*')