Commit fc0d6e93 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch...

Merge branch '232577-populate-the-milestone-dropdown-combobox-on-the-release-edit-new-page-with-group-milestones' into 'master'

Resolve "Populate the milestone dropdown combobox on the Release edit/new page with Group milestones"

See merge request gitlab-org/gitlab!46027
parents 023b7230 49ae16b0
......@@ -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
......@@ -17388,6 +17388,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