Commit 49ae16b0 authored by Jake Burden's avatar Jake Burden Committed by Andrew Fontaine

Populate combobox with group milestones

Fetchs data from group milestones API
Searches with the group search API
Adds group milestone results section
Check if group_milestones_project_releases is available
parent e59dd19e
......@@ -39,6 +39,16 @@ export default {
type: String,
required: true,
},
groupId: {
type: String,
required: false,
default: '',
},
groupMilestonesAvailable: {
type: Boolean,
required: false,
default: false,
},
extraLinks: {
type: Array,
default: () => [],
......@@ -56,12 +66,13 @@ export default {
noMilestone: s__('MilestoneCombobox|No milestone'),
noResultsLabel: s__('MilestoneCombobox|No matching results'),
searchMilestones: s__('MilestoneCombobox|Search Milestones'),
searhErrorMessage: s__('MilestoneCombobox|An error occurred while searching for milestones'),
searchErrorMessage: s__('MilestoneCombobox|An error occurred while searching for milestones'),
projectMilestones: s__('MilestoneCombobox|Project milestones'),
groupMilestones: s__('MilestoneCombobox|Group milestones'),
},
computed: {
...mapState(['matches', 'selectedMilestones']),
...mapGetters(['isLoading']),
...mapGetters(['isLoading', 'groupMilestonesEnabled']),
selectedMilestonesLabel() {
const { selectedMilestones } = this;
const firstMilestoneName = selectedMilestones[0];
......@@ -85,8 +96,14 @@ export default {
this.matches.projectMilestones.totalCount > 0 || this.matches.projectMilestones.error,
);
},
showGroupMilestoneSection() {
return (
this.groupMilestonesEnabled &&
Boolean(this.matches.groupMilestones.totalCount > 0 || this.matches.groupMilestones.error)
);
},
showNoResults() {
return !this.showProjectMilestoneSection;
return !this.showProjectMilestoneSection && !this.showGroupMilestoneSection;
},
},
watch: {
......@@ -115,11 +132,15 @@ export default {
}, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId);
this.setGroupId(this.groupId);
this.setGroupMilestonesAvailable(this.groupMilestonesAvailable);
this.fetchMilestones();
},
methods: {
...mapActions([
'setProjectId',
'setGroupId',
'setGroupMilestonesAvailable',
'setSelectedMilestones',
'clearSelectedMilestones',
'toggleMilestones',
......@@ -194,15 +215,28 @@ export default {
</template>
<template v-else>
<milestone-results-section
v-if="showProjectMilestoneSection"
:section-title="$options.translations.projectMilestones"
:total-count="matches.projectMilestones.totalCount"
:items="matches.projectMilestones.list"
:selected-milestones="selectedMilestones"
:error="matches.projectMilestones.error"
:error-message="$options.translations.searhErrorMessage"
:error-message="$options.translations.searchErrorMessage"
data-testid="project-milestones-section"
@selected="selectMilestone($event)"
/>
<milestone-results-section
v-if="showGroupMilestoneSection"
:section-title="$options.translations.groupMilestones"
:total-count="matches.groupMilestones.totalCount"
:items="matches.groupMilestones.list"
:selected-milestones="selectedMilestones"
:error="matches.groupMilestones.error"
:error-message="$options.translations.searchErrorMessage"
data-testid="group-milestones-section"
@selected="selectMilestone($event)"
/>
</template>
<gl-dropdown-item
v-for="(item, idx) in extraLinks"
......
......@@ -2,6 +2,9 @@ import Api from '~/api';
import * as types from './mutation_types';
export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
export const setGroupId = ({ commit }, groupId) => commit(types.SET_GROUP_ID, groupId);
export const setGroupMilestonesAvailable = ({ commit }, groupMilestonesAvailable) =>
commit(types.SET_GROUP_MILESTONES_AVAILABLE, groupMilestonesAvailable);
export const setSelectedMilestones = ({ commit }, selectedMilestones) =>
commit(types.SET_SELECTED_MILESTONES, selectedMilestones);
......@@ -18,13 +21,23 @@ export const toggleMilestones = ({ commit, state }, selectedMilestone) => {
}
};
export const search = ({ dispatch, commit }, searchQuery) => {
export const search = ({ dispatch, commit, getters }, searchQuery) => {
commit(types.SET_SEARCH_QUERY, searchQuery);
dispatch('searchMilestones');
dispatch('searchProjectMilestones');
if (getters.groupMilestonesEnabled) {
dispatch('searchGroupMilestones');
}
};
export const fetchMilestones = ({ dispatch, getters }) => {
dispatch('fetchProjectMilestones');
if (getters.groupMilestonesEnabled) {
dispatch('fetchGroupMilestones');
}
};
export const fetchMilestones = ({ commit, state }) => {
export const fetchProjectMilestones = ({ commit, state }) => {
commit(types.REQUEST_START);
Api.projectMilestones(state.projectId)
......@@ -39,14 +52,29 @@ export const fetchMilestones = ({ commit, state }) => {
});
};
export const searchMilestones = ({ commit, state }) => {
export const fetchGroupMilestones = ({ commit, state }) => {
commit(types.REQUEST_START);
Api.groupMilestones(state.groupId)
.then(response => {
commit(types.RECEIVE_GROUP_MILESTONES_SUCCESS, response);
})
.catch(error => {
commit(types.RECEIVE_GROUP_MILESTONES_ERROR, error);
})
.finally(() => {
commit(types.REQUEST_FINISH);
});
};
export const searchProjectMilestones = ({ commit, state }) => {
const options = {
search: state.searchQuery,
scope: 'milestones',
};
commit(types.REQUEST_START);
Api.projectSearch(state.projectId, options)
.then(response => {
commit(types.RECEIVE_PROJECT_MILESTONES_SUCCESS, response);
......@@ -58,3 +86,22 @@ export const searchMilestones = ({ commit, state }) => {
commit(types.REQUEST_FINISH);
});
};
export const searchGroupMilestones = ({ commit, state }) => {
const options = {
search: state.searchQuery,
};
commit(types.REQUEST_START);
Api.groupMilestones(state.groupId, options)
.then(response => {
commit(types.RECEIVE_GROUP_MILESTONES_SUCCESS, response);
})
.catch(error => {
commit(types.RECEIVE_GROUP_MILESTONES_ERROR, error);
})
.finally(() => {
commit(types.REQUEST_FINISH);
});
};
/** Returns `true` if there is at least one in-progress request */
export const isLoading = ({ requestCount }) => requestCount > 0;
/** Returns `true` if there is a group ID and group milestones are available */
export const groupMilestonesEnabled = ({ groupId, groupMilestonesAvailable }) =>
Boolean(groupId && groupMilestonesAvailable);
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_GROUP_ID = 'SET_GROUP_ID';
export const SET_GROUP_MILESTONES_AVAILABLE = 'SET_GROUP_MILESTONES_AVAILABLE';
export const SET_SELECTED_MILESTONES = 'SET_SELECTED_MILESTONES';
export const CLEAR_SELECTED_MILESTONES = 'CLEAR_SELECTED_MILESTONES';
......@@ -12,3 +14,6 @@ export const REQUEST_FINISH = 'REQUEST_FINISH';
export const RECEIVE_PROJECT_MILESTONES_SUCCESS = 'RECEIVE_PROJECT_MILESTONES_SUCCESS';
export const RECEIVE_PROJECT_MILESTONES_ERROR = 'RECEIVE_PROJECT_MILESTONES_ERROR';
export const RECEIVE_GROUP_MILESTONES_SUCCESS = 'RECEIVE_GROUP_MILESTONES_SUCCESS';
export const RECEIVE_GROUP_MILESTONES_ERROR = 'RECEIVE_GROUP_MILESTONES_ERROR';
import Vue from 'vue';
import * as types from './mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default {
[types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId;
},
[types.SET_GROUP_ID](state, groupId) {
state.groupId = groupId;
},
[types.SET_GROUP_MILESTONES_AVAILABLE](state, groupMilestonesAvailable) {
state.groupMilestonesAvailable = groupMilestonesAvailable;
},
[types.SET_SELECTED_MILESTONES](state, selectedMilestones) {
Vue.set(state, 'selectedMilestones', selectedMilestones);
},
......@@ -32,7 +37,7 @@ export default {
},
[types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response) {
state.matches.projectMilestones = {
list: convertObjectPropsToCamelCase(response.data).map(({ title }) => ({ title })),
list: response.data.map(({ title }) => ({ title })),
totalCount: parseInt(response.headers['x-total'], 10),
error: null,
};
......@@ -44,4 +49,18 @@ export default {
error,
};
},
[types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response) {
state.matches.groupMilestones = {
list: response.data.map(({ title }) => ({ title })),
totalCount: parseInt(response.headers['x-total'], 10),
error: null,
};
},
[types.RECEIVE_GROUP_MILESTONES_ERROR](state, error) {
state.matches.groupMilestones = {
list: [],
totalCount: 0,
error,
};
},
};
export default () => ({
projectId: null,
groupId: null,
groupMilestonesAvailable: false,
searchQuery: '',
matches: {
projectMilestones: {
......@@ -8,6 +9,11 @@ export default () => ({
totalCount: 0,
error: null,
},
groupMilestones: {
list: [],
totalCount: 0,
error: null,
},
},
selectedMilestones: [],
requestCount: 0,
......
......@@ -34,6 +34,8 @@ export default {
'newMilestonePath',
'manageMilestonesPath',
'projectId',
'groupId',
'groupMilestonesAvailable',
]),
...mapGetters('detail', ['isValid', 'isExistingRelease']),
showForm() {
......@@ -141,6 +143,8 @@ export default {
<milestone-combobox
v-model="releaseMilestones"
:project-id="projectId"
:group-id="groupId"
:group-milestones-available="groupMilestonesAvailable"
:extra-links="milestoneComboboxExtraLinks"
/>
</div>
......
export default ({
projectId,
groupId,
groupMilestonesAvailable = false,
projectPath,
markdownDocsPath,
markdownPreviewPath,
......@@ -13,6 +15,8 @@ export default ({
defaultBranch = null,
}) => ({
projectId,
groupId,
groupMilestonesAvailable: Boolean(groupMilestonesAvailable),
projectPath,
markdownDocsPath,
markdownPreviewPath,
......
......@@ -51,11 +51,17 @@ module ReleasesHelper
)
end
def group_milestone_project_releases_available?(project)
false
end
private
def new_edit_pages_shared_data
{
project_id: @project.id,
group_id: @project.group&.id,
group_milestones_available: group_milestone_project_releases_available?(@project),
project_path: @project.full_path,
markdown_preview_path: preview_markdown_path(@project),
markdown_docs_path: help_page_path('user/markdown'),
......@@ -66,3 +72,5 @@ module ReleasesHelper
}
end
end
ReleasesHelper.prepend_if_ee('EE::ReleasesHelper')
......@@ -130,6 +130,8 @@ In the interface, to add release notes to an existing Git tag:
You can associate a release with one or more [project milestones](../milestones/index.md#project-milestones-and-group-milestones).
[GitLab Premium](https://about.gitlab.com/pricing/) customers can specify [group milestones](../milestones/index.md#project-milestones-and-group-milestones) to associate with a release.
You can do this in the user interface, or by including a `milestones` array in your request to
the [Releases API](../../../api/releases/index.md#create-a-release).
......
# frozen_string_literal: true
module EE
module ReleasesHelper
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
override :group_milestone_project_releases_available?
def group_milestone_project_releases_available?(project)
project.feature_available?(:group_milestone_project_releases).to_s
end
end
end
---
title: Resolve Populate the milestone dropdown combobox on the Release edit/new page
with Group milestones
merge_request: 46027
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ReleasesHelper do
let(:project) { build(:project, namespace: create(:group)) }
let(:release) { create(:release, project: project) }
# rubocop: disable CodeReuse/ActiveRecord
before do
helper.instance_variable_set(:@project, project)
helper.instance_variable_set(:@release, release)
end
# rubocop: enable CodeReuse/ActiveRecord
describe '#group_milestone_project_releases_available?' do
subject { helper.data_for_edit_release_page[:group_milestones_available] }
context 'when group milestones association with project releases is enabled' do
before do
stub_licensed_features(group_milestone_project_releases: true)
end
it { is_expected.to eq("true") }
end
context 'when group milestones association with project releases is disabled' do
before do
stub_licensed_features(group_milestone_project_releases: false)
end
it { is_expected.to eq("false") }
end
end
end
......@@ -17370,6 +17370,9 @@ msgstr ""
msgid "MilestoneCombobox|An error occurred while searching for milestones"
msgstr ""
msgid "MilestoneCombobox|Group milestones"
msgstr ""
msgid "MilestoneCombobox|Milestone"
msgstr ""
......
......@@ -6,7 +6,7 @@ import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import { ENTER_KEY } from '~/lib/utils/keys';
import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import { milestones as projectMilestones } from './mock_data';
import { projectMilestones, groupMilestones } from './mock_data';
import createStore from '~/milestones/stores/';
const extraLinks = [
......@@ -19,16 +19,21 @@ localVue.use(Vuex);
describe('Milestone combobox component', () => {
const projectId = '8';
const groupId = '24';
const groupMilestonesAvailable = true;
const X_TOTAL_HEADER = 'x-total';
let wrapper;
let projectMilestonesApiCallSpy;
let groupMilestonesApiCallSpy;
let searchApiCallSpy;
const createComponent = (props = {}, attrs = {}) => {
wrapper = mount(MilestoneCombobox, {
propsData: {
projectId,
groupId,
groupMilestonesAvailable,
extraLinks,
value: [],
...props,
......@@ -56,6 +61,10 @@ describe('Milestone combobox component', () => {
.fn()
.mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]);
groupMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, groupMilestones, { [X_TOTAL_HEADER]: '6' }]);
searchApiCallSpy = jest
.fn()
.mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]);
......@@ -64,6 +73,10 @@ describe('Milestone combobox component', () => {
.onGet(`/api/v4/projects/${projectId}/milestones`)
.reply(config => projectMilestonesApiCallSpy(config));
mock
.onGet(`/api/v4/groups/${groupId}/milestones`)
.reply(config => groupMilestonesApiCallSpy(config));
mock.onGet(`/api/v4/projects/${projectId}/search`).reply(config => searchApiCallSpy(config));
});
......@@ -89,6 +102,11 @@ describe('Milestone combobox component', () => {
findProjectMilestonesSection().findAll(GlDropdownItem);
const findFirstProjectMilestonesDropdownItem = () => findProjectMilestonesDropdownItems().at(0);
const findGroupMilestonesSection = () => wrapper.find('[data-testid="group-milestones-section"]');
const findGroupMilestonesDropdownItems = () =>
findGroupMilestonesSection().findAll(GlDropdownItem);
const findFirstGroupMilestonesDropdownItem = () => findGroupMilestonesDropdownItems().at(0);
//
// Expecters
//
......@@ -100,6 +118,14 @@ describe('Milestone combobox component', () => {
.includes(s__('MilestoneCombobox|An error occurred while searching for milestones'));
};
const groupMilestoneSectionContainsErrorMessage = () => {
const groupMilestoneSection = findGroupMilestonesSection();
return groupMilestoneSection
.text()
.includes(s__('MilestoneCombobox|An error occurred while searching for milestones'));
};
//
// Convenience methods
//
......@@ -111,19 +137,25 @@ describe('Milestone combobox component', () => {
findFirstProjectMilestonesDropdownItem().vm.$emit('click');
};
const selectFirstGroupMilestone = () => {
findFirstGroupMilestonesDropdownItem().vm.$emit('click');
};
const waitForRequests = ({ andClearMocks } = { andClearMocks: false }) =>
axios.waitForAll().then(() => {
if (andClearMocks) {
projectMilestonesApiCallSpy.mockClear();
groupMilestonesApiCallSpy.mockClear();
}
});
describe('initialization behavior', () => {
beforeEach(createComponent);
it('initializes the dropdown with project milestones when mounted', () => {
it('initializes the dropdown with milestones when mounted', () => {
return waitForRequests().then(() => {
expect(projectMilestonesApiCallSpy).toHaveBeenCalledTimes(1);
expect(groupMilestonesApiCallSpy).toHaveBeenCalledTimes(1);
});
});
......@@ -166,7 +198,7 @@ describe('Milestone combobox component', () => {
return waitForRequests();
});
it('renders the pre-selected project milestones', () => {
it('renders the pre-selected milestones', () => {
expect(findButtonContent().text()).toBe('v0.1 + 5 more');
});
});
......@@ -209,6 +241,8 @@ describe('Milestone combobox component', () => {
.fn()
.mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
groupMilestonesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
createComponent();
return waitForRequests();
......@@ -288,65 +322,195 @@ describe('Milestone combobox component', () => {
expect(projectMilestoneSectionContainsErrorMessage()).toBe(true);
});
});
});
describe('selection', () => {
beforeEach(() => {
createComponent();
describe('selection', () => {
beforeEach(() => {
createComponent();
return waitForRequests();
});
return waitForRequests();
});
it('renders a checkmark by the selected item', async () => {
selectFirstProjectMilestone();
it('renders a checkmark by the selected item', async () => {
selectFirstProjectMilestone();
await localVue.nextTick();
await localVue.nextTick();
expect(
findFirstProjectMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(false);
expect(
findFirstProjectMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(false);
selectFirstProjectMilestone();
selectFirstProjectMilestone();
await localVue.nextTick();
return localVue.nextTick().then(() => {
expect(
findFirstProjectMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(true);
});
describe('when a project milestones is selected', () => {
beforeEach(() => {
createComponent();
projectMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, [{ title: 'v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
return waitForRequests();
});
it("displays the project milestones name in the dropdown's button", async () => {
selectFirstProjectMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone'));
selectFirstProjectMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe('v1.0');
});
it('updates the v-model binding with the project milestone title', () => {
expect(wrapper.vm.value).toEqual([]);
selectFirstProjectMilestone();
expect(wrapper.vm.value).toEqual(['v1.0']);
});
});
});
});
describe('when a project milestones is selected', () => {
describe('group milestones', () => {
describe('when the group milestones search returns results', () => {
beforeEach(() => {
createComponent();
projectMilestonesApiCallSpy = jest
return waitForRequests();
});
it('renders the group milestones section in the dropdown', () => {
expect(findGroupMilestonesSection().exists()).toBe(true);
});
it('renders the "Group milestones" heading with a total number indicator', () => {
expect(
findGroupMilestonesSection()
.find('[data-testid="milestone-results-section-header"]')
.text(),
).toBe('Group milestones 6');
});
it("does not render an error message in the group milestone section's body", () => {
expect(groupMilestoneSectionContainsErrorMessage()).toBe(false);
});
it('renders each group milestones as a selectable item', () => {
const dropdownItems = findGroupMilestonesDropdownItems();
groupMilestones.forEach((milestone, i) => {
expect(dropdownItems.at(i).text()).toBe(milestone.title);
});
});
});
describe('when the group milestones search returns no results', () => {
beforeEach(() => {
groupMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, [{ title: 'v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
.mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
createComponent();
return waitForRequests();
});
it("displays the project milestones name in the dropdown's button", async () => {
selectFirstProjectMilestone();
it('does not render the group milestones section in the dropdown', () => {
expect(findGroupMilestonesSection().exists()).toBe(false);
});
});
describe('when the group milestones search returns an error', () => {
beforeEach(() => {
groupMilestonesApiCallSpy = jest.fn().mockReturnValue([500]);
searchApiCallSpy = jest.fn().mockReturnValue([500]);
createComponent({ value: [] });
return waitForRequests();
});
it('renders the group milestones section in the dropdown', () => {
expect(findGroupMilestonesSection().exists()).toBe(true);
});
it("renders an error message in the group milestones section's body", () => {
expect(groupMilestoneSectionContainsErrorMessage()).toBe(true);
});
});
describe('selection', () => {
beforeEach(() => {
createComponent();
return waitForRequests();
});
it('renders a checkmark by the selected item', async () => {
selectFirstGroupMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone'));
expect(
findFirstGroupMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(false);
selectFirstProjectMilestone();
selectFirstGroupMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe('v1.0');
expect(
findFirstGroupMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(true);
});
it('updates the v-model binding with the project milestone title', () => {
expect(wrapper.vm.value).toEqual([]);
describe('when a group milestones is selected', () => {
beforeEach(() => {
createComponent();
groupMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, [{ title: 'group-v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
selectFirstProjectMilestone();
return waitForRequests();
});
it("displays the group milestones name in the dropdown's button", async () => {
selectFirstGroupMilestone();
await localVue.nextTick();
expect(wrapper.vm.value).toEqual(['v1.0']);
expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone'));
selectFirstGroupMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe('group-v1.0');
});
it('updates the v-model binding with the group milestone title', () => {
expect(wrapper.vm.value).toEqual([]);
selectFirstGroupMilestone();
expect(wrapper.vm.value).toEqual(['group-v1.0']);
});
});
});
});
......
export const milestones = [
export const projectMilestones = [
{
id: 41,
iid: 6,
......@@ -79,4 +79,94 @@ export const milestones = [
},
];
export default milestones;
export const groupMilestones = [
{
id: 141,
iid: 16,
project_id: 8,
group_id: 12,
title: 'group-v0.1',
description: '',
state: 'active',
created_at: '2020-04-04T01:30:40.051Z',
updated_at: '2020-04-04T01:30:40.051Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6',
},
{
id: 140,
iid: 15,
project_id: 8,
group_id: 12,
title: 'group-v4.0',
description: 'Laboriosam nisi sapiente dolores et magnam nobis ad earum.',
state: 'closed',
created_at: '2020-01-13T19:39:15.191Z',
updated_at: '2020-01-13T19:39:15.191Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/5',
},
{
id: 139,
iid: 14,
project_id: 8,
group_id: 12,
title: 'group-v3.0',
description: 'Necessitatibus illo alias et repellat dolorum assumenda ut.',
state: 'closed',
created_at: '2020-01-13T19:39:15.176Z',
updated_at: '2020-01-13T19:39:15.176Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/4',
},
{
id: 138,
iid: 13,
project_id: 8,
group_id: 12,
title: 'group-v2.0',
description: 'Doloribus qui repudiandae iste sit.',
state: 'closed',
created_at: '2020-01-13T19:39:15.161Z',
updated_at: '2020-01-13T19:39:15.161Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/3',
},
{
id: 137,
iid: 12,
project_id: 8,
group_id: 12,
title: 'group-v1.0',
description: 'Illo sint odio officia ea.',
state: 'closed',
created_at: '2020-01-13T19:39:15.146Z',
updated_at: '2020-01-13T19:39:15.146Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/2',
},
{
id: 136,
iid: 11,
project_id: 8,
group_id: 12,
title: 'group-v0.0',
description: 'Sed quae facilis deleniti at delectus assumenda nobis veritatis.',
state: 'active',
created_at: '2020-01-13T19:39:15.127Z',
updated_at: '2020-01-13T19:39:15.127Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/1',
},
];
export default {
projectMilestones,
groupMilestones,
};
......@@ -4,6 +4,7 @@ import * as actions from '~/milestones/stores/actions';
import * as types from '~/milestones/stores/mutation_types';
let mockProjectMilestonesReturnValue;
let mockGroupMilestonesReturnValue;
let mockProjectSearchReturnValue;
jest.mock('~/api', () => ({
......@@ -13,6 +14,7 @@ jest.mock('~/api', () => ({
default: {
projectMilestones: () => mockProjectMilestonesReturnValue,
projectSearch: () => mockProjectSearchReturnValue,
groupMilestones: () => mockGroupMilestonesReturnValue,
},
}));
......@@ -32,6 +34,24 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
describe('setGroupId', () => {
it(`commits ${types.SET_GROUP_ID} with the new group ID`, () => {
const groupId = '123';
testAction(actions.setGroupId, groupId, state, [
{ type: types.SET_GROUP_ID, payload: groupId },
]);
});
});
describe('setGroupMilestonesAvailable', () => {
it(`commits ${types.SET_GROUP_MILESTONES_AVAILABLE} with the boolean indicating if group milestones are available (Premium)`, () => {
state.groupMilestonesAvailable = true;
testAction(actions.setGroupMilestonesAvailable, state.groupMilestonesAvailable, state, [
{ type: types.SET_GROUP_MILESTONES_AVAILABLE, payload: state.groupMilestonesAvailable },
]);
});
});
describe('setSelectedMilestones', () => {
it(`commits ${types.SET_SELECTED_MILESTONES} with the new selected milestones name`, () => {
const selectedMilestones = ['v1.2.3'];
......@@ -66,19 +86,38 @@ describe('Milestone combobox Vuex store actions', () => {
});
describe('search', () => {
it(`commits ${types.SET_SEARCH_QUERY} with the new search query`, () => {
const searchQuery = 'v1.0';
testAction(
actions.search,
searchQuery,
state,
[{ type: types.SET_SEARCH_QUERY, payload: searchQuery }],
[{ type: 'searchMilestones' }],
);
describe('when project has license to add group milestones', () => {
it(`commits ${types.SET_SEARCH_QUERY} with the new search query to search for project and group milestones`, () => {
const getters = {
groupMilestonesEnabled: () => true,
};
const searchQuery = 'v1.0';
testAction(
actions.search,
searchQuery,
{ ...state, ...getters },
[{ type: types.SET_SEARCH_QUERY, payload: searchQuery }],
[{ type: 'searchProjectMilestones' }, { type: 'searchGroupMilestones' }],
);
});
});
describe('when project does not have license to add group milestones', () => {
it(`commits ${types.SET_SEARCH_QUERY} with the new search query to search for project milestones`, () => {
const searchQuery = 'v1.0';
testAction(
actions.search,
searchQuery,
state,
[{ type: types.SET_SEARCH_QUERY, payload: searchQuery }],
[{ type: 'searchProjectMilestones' }],
);
});
});
});
describe('searchMilestones', () => {
describe('searchProjectMilestones', () => {
describe('when the search is successful', () => {
const projectSearchApiResponse = { data: [{ title: 'v1.0' }] };
......@@ -87,7 +126,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchMilestones, undefined, state, [
return testAction(actions.searchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_SUCCESS, payload: projectSearchApiResponse },
{ type: types.REQUEST_FINISH },
......@@ -103,7 +142,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchMilestones, undefined, state, [
return testAction(actions.searchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
......@@ -112,7 +151,71 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
describe('searchGroupMilestones', () => {
describe('when the search is successful', () => {
const groupSearchApiResponse = { data: [{ title: 'group-v1.0' }] };
beforeEach(() => {
mockGroupMilestonesReturnValue = Promise.resolve(groupSearchApiResponse);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchGroupMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_GROUP_MILESTONES_SUCCESS, payload: groupSearchApiResponse },
{ type: types.REQUEST_FINISH },
]);
});
});
describe('when the search fails', () => {
const error = new Error('Something went wrong!');
beforeEach(() => {
mockGroupMilestonesReturnValue = Promise.reject(error);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchGroupMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_GROUP_MILESTONES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
]);
});
});
});
describe('fetchMilestones', () => {
describe('when project has license to add group milestones', () => {
it(`dispatchs fetchProjectMilestones and fetchGroupMilestones`, () => {
const getters = {
groupMilestonesEnabled: () => true,
};
testAction(
actions.fetchMilestones,
undefined,
{ ...state, ...getters },
[],
[{ type: 'fetchProjectMilestones' }, { type: 'fetchGroupMilestones' }],
);
});
});
describe('when project does not have license to add group milestones', () => {
it(`dispatchs fetchProjectMilestones`, () => {
testAction(
actions.fetchMilestones,
undefined,
state,
[],
[{ type: 'fetchProjectMilestones' }],
);
});
});
});
describe('fetchProjectMilestones', () => {
describe('when the fetch is successful', () => {
const projectMilestonesApiResponse = { data: [{ title: 'v1.0' }] };
......@@ -121,7 +224,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.fetchMilestones, undefined, state, [
return testAction(actions.fetchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_SUCCESS, payload: projectMilestonesApiResponse },
{ type: types.REQUEST_FINISH },
......@@ -137,7 +240,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.fetchMilestones, undefined, state, [
return testAction(actions.fetchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
......@@ -145,4 +248,38 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
});
describe('fetchGroupMilestones', () => {
describe('when the fetch is successful', () => {
const groupMilestonesApiResponse = { data: [{ title: 'group-v1.0' }] };
beforeEach(() => {
mockGroupMilestonesReturnValue = Promise.resolve(groupMilestonesApiResponse);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.fetchGroupMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_GROUP_MILESTONES_SUCCESS, payload: groupMilestonesApiResponse },
{ type: types.REQUEST_FINISH },
]);
});
});
describe('when the fetch fails', () => {
const error = new Error('Something went wrong!');
beforeEach(() => {
mockGroupMilestonesReturnValue = Promise.reject(error);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.fetchGroupMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_GROUP_MILESTONES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
]);
});
});
});
});
......@@ -12,4 +12,22 @@ describe('Milestone comboxbox Vuex store getters', () => {
expect(getters.isLoading({ requestCount })).toBe(isLoading);
});
});
describe('groupMilestonesEnabled', () => {
it.each`
groupId | groupMilestonesAvailable | groupMilestonesEnabled
${'1'} | ${true} | ${true}
${'1'} | ${false} | ${false}
${''} | ${true} | ${false}
${''} | ${false} | ${false}
${null} | ${true} | ${false}
`(
'returns true when groupId is a truthy string and groupMilestonesAvailable is true',
({ groupId, groupMilestonesAvailable, groupMilestonesEnabled }) => {
expect(getters.groupMilestonesEnabled({ groupId, groupMilestonesAvailable })).toBe(
groupMilestonesEnabled,
);
},
);
});
});
......@@ -14,6 +14,7 @@ describe('Milestones combobox Vuex store mutations', () => {
expect(state).toEqual({
projectId: null,
groupId: null,
groupMilestonesAvailable: false,
searchQuery: '',
matches: {
projectMilestones: {
......@@ -21,6 +22,11 @@ describe('Milestones combobox Vuex store mutations', () => {
totalCount: 0,
error: null,
},
groupMilestones: {
list: [],
totalCount: 0,
error: null,
},
},
selectedMilestones: [],
requestCount: 0,
......@@ -37,6 +43,24 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
describe(`${types.SET_GROUP_ID}`, () => {
it('updates the group ID', () => {
const newGroupId = '8';
mutations[types.SET_GROUP_ID](state, newGroupId);
expect(state.groupId).toBe(newGroupId);
});
});
describe(`${types.SET_GROUP_MILESTONES_AVAILABLE}`, () => {
it('sets boolean indicating if group milestones are available', () => {
const groupMilestonesAvailable = true;
mutations[types.SET_GROUP_MILESTONES_AVAILABLE](state, groupMilestonesAvailable);
expect(state.groupMilestonesAvailable).toBe(groupMilestonesAvailable);
});
});
describe(`${types.SET_SELECTED_MILESTONES}`, () => {
it('sets the selected milestones', () => {
const selectedMilestones = ['v1.2.3'];
......@@ -60,7 +84,7 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
describe(`${types.ADD_SELECTED_MILESTONESs}`, () => {
describe(`${types.ADD_SELECTED_MILESTONES}`, () => {
it('adds the selected milestones', () => {
const selectedMilestone = 'v1.2.3';
mutations[types.ADD_SELECTED_MILESTONE](state, selectedMilestone);
......@@ -170,4 +194,57 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
});
describe(`${types.RECEIVE_GROUP_MILESTONES_SUCCESS}`, () => {
it('updates state.matches.groupMilestones based on the provided API response', () => {
const response = {
data: [
{
title: 'group-0.1',
},
{
title: 'group-0.2',
},
],
headers: {
'x-total': 2,
},
};
mutations[types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response);
expect(state.matches.groupMilestones).toEqual({
list: [
{
title: 'group-0.1',
},
{
title: 'group-0.2',
},
],
error: null,
totalCount: 2,
});
});
describe(`${types.RECEIVE_GROUP_MILESTONES_ERROR}`, () => {
it('updates state.matches.groupMilestones to an empty state with the error object', () => {
const error = new Error('Something went wrong!');
state.matches.groupMilestones = {
list: [{ title: 'group-0.1' }],
totalCount: 1,
error: null,
};
mutations[types.RECEIVE_GROUP_MILESTONES_ERROR](state, error);
expect(state.matches.groupMilestones).toEqual({
list: [],
totalCount: 0,
error,
});
});
});
});
});
......@@ -27,6 +27,8 @@ describe('Release edit/new component', () => {
updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
releasesPagePath: 'path/to/releases/page',
projectId: '8',
groupId: '42',
groupMilestonesAvailable: true,
};
actions = {
......
......@@ -64,6 +64,8 @@ RSpec.describe ReleasesHelper do
describe '#data_for_edit_release_page' do
it 'has the needed data to display the "edit release" page' do
keys = %i(project_id
group_id
group_milestones_available
project_path
tag_name
markdown_preview_path
......@@ -81,6 +83,8 @@ RSpec.describe ReleasesHelper do
describe '#data_for_new_release_page' do
it 'has the needed data to display the "new release" page' do
keys = %i(project_id
group_id
group_milestones_available
project_path
releases_page_path
markdown_preview_path
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment