Commit df35dd1a authored by Illya Klymov's avatar Illya Klymov

Merge branch '37101-sidebar-milestone-should-be-a-vue-component' into 'master'

Add milestone component to swimlanes board sidebar

See merge request gitlab-org/gitlab!44482
parents 1109cbcc d43f2768
......@@ -50,6 +50,13 @@ export default {
}
window.removeEventListener('click', this.collapseWhenOffClick);
},
toggle({ emitEvent = true } = {}) {
if (this.edit) {
this.collapse({ emitEvent });
} else {
this.expand();
}
},
},
};
</script>
......@@ -66,12 +73,12 @@ export default {
variant="link"
class="gl-text-gray-900! js-sidebar-dropdown-toggle"
data-testid="edit-button"
@click="expand"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
</div>
<div v-show="!edit" class="gl-text-gray-400" data-testid="collapsed-content">
<div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content">
......
......@@ -79,7 +79,7 @@ export default {
<span class="gl-mx-2">-</span>
<gl-button
variant="link"
class="gl-text-gray-400!"
class="gl-text-gray-500!"
data-testid="reset-button"
:disabled="loading"
@click="setDueDate(null)"
......
<script>
import { mapGetters, mapActions } from 'vuex';
import {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import groupMilestones from '../../queries/group_milestones.query.graphql';
import createFlash from '~/flash';
import { __, s__ } from '~/locale';
export default {
components: {
BoardEditableItem,
GlDropdown,
GlLoadingIcon,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
GlDropdownDivider,
},
data() {
return {
milestones: [],
searchTitle: '',
loading: false,
edit: false,
};
},
apollo: {
milestones: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: groupMilestones,
debounce: 250,
skip() {
return !this.edit;
},
variables() {
return {
fullPath: this.groupFullPath,
searchTitle: this.searchTitle,
state: 'active',
includeDescendants: true,
};
},
update(data) {
const edges = data?.group?.milestones?.edges ?? [];
return edges.map(item => item.node);
},
error() {
createFlash({ message: this.$options.i18n.fetchMilestonesError });
},
},
},
computed: {
...mapGetters({ issue: 'activeIssue' }),
hasMilestone() {
return this.issue.milestone !== null;
},
groupFullPath() {
const { referencePath = '' } = this.issue;
return referencePath.slice(0, referencePath.indexOf('/'));
},
projectPath() {
const { referencePath = '' } = this.issue;
return referencePath.slice(0, referencePath.indexOf('#'));
},
dropdownText() {
return this.issue.milestone?.title ?? this.$options.i18n.noMilestone;
},
},
mounted() {
this.$root.$on('bv::dropdown::hide', () => {
this.$refs.sidebarItem.collapse();
});
},
methods: {
...mapActions(['setActiveIssueMilestone']),
handleOpen() {
this.edit = true;
this.$refs.dropdown.show();
},
async setMilestone(milestoneId) {
this.loading = true;
this.searchTitle = '';
this.$refs.sidebarItem.collapse();
try {
const input = { milestoneId, projectPath: this.projectPath };
await this.setActiveIssueMilestone(input);
} catch (e) {
createFlash({ message: this.$options.i18n.updateMilestoneError });
} finally {
this.loading = false;
}
},
},
i18n: {
milestone: __('Milestone'),
noMilestone: __('No milestone'),
assignMilestone: __('Assign milestone'),
noMilestonesFound: s__('Milestones|No milestones found'),
fetchMilestonesError: __('There was a problem fetching milestones.'),
updateMilestoneError: __('An error occurred while updating the milestone.'),
},
};
</script>
<template>
<board-editable-item
ref="sidebarItem"
:title="$options.i18n.milestone"
:loading="loading"
@open="handleOpen()"
@close="edit = false"
>
<template v-if="hasMilestone" #collapsed>
<strong class="gl-text-gray-900">{{ issue.milestone.title }}</strong>
</template>
<template>
<gl-dropdown
ref="dropdown"
:text="dropdownText"
:header-text="$options.i18n.assignMilestone"
block
>
<gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" />
<gl-dropdown-item
data-testid="no-milestone-item"
:is-check-item="true"
:is-checked="!issue.milestone"
@click="setMilestone(null)"
>
{{ $options.i18n.noMilestone }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-loading-icon v-if="$apollo.loading" class="gl-py-4" />
<template v-else-if="milestones.length > 0">
<gl-dropdown-item
v-for="milestone in milestones"
:key="milestone.id"
:is-check-item="true"
:is-checked="issue.milestone && milestone.id === issue.milestone.id"
data-testid="milestone-item"
@click="setMilestone(milestone.id)"
>
{{ milestone.title }}
</gl-dropdown-item>
</template>
<gl-dropdown-text v-else data-testid="no-milestones-found">
{{ $options.i18n.noMilestonesFound }}
</gl-dropdown-text>
</gl-dropdown>
</template>
</board-editable-item>
</template>
query groupMilestones(
$fullPath: ID!
$state: MilestoneStateEnum
$includeDescendants: Boolean
$searchTitle: String
) {
group(fullPath: $fullPath) {
milestones(state: $state, includeDescendants: $includeDescendants, searchTitle: $searchTitle) {
edges {
node {
id
title
}
}
}
}
}
......@@ -11,6 +11,10 @@ fragment IssueNode on Issue {
webUrl
subscribed
relativePosition
milestone {
id
title
}
assignees {
nodes {
...User
......
mutation issueSetMilestone($input: UpdateIssueInput!) {
updateIssue(input: $input) {
issue {
milestone {
id
title
description
}
}
errors
}
}
......@@ -25,6 +25,7 @@ import issueCreateMutation from '../queries/issue_create.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql';
import issueSetSubscriptionMutation from '../graphql/mutations/issue_set_subscription.mutation.graphql';
import issueSetMilestone from '../queries/issue_set_milestone.mutation.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
......@@ -337,6 +338,30 @@ export default {
});
},
setActiveIssueMilestone: async ({ commit, getters }, input) => {
const { activeIssue } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetMilestone,
variables: {
input: {
iid: String(activeIssue.iid),
milestoneId: getIdFromGraphQLId(input.milestoneId),
projectPath: input.projectPath,
},
},
});
if (data.updateIssue.errors?.length > 0) {
throw new Error(data.updateIssue.errors);
}
commit(types.UPDATE_ISSUE_BY_ID, {
issueId: activeIssue.id,
prop: 'milestone',
value: data.updateIssue.issue.milestone,
});
},
createNewIssue: ({ commit, state }, issueInput) => {
const input = issueInput;
const { boardType, endpoints } = state;
......
......@@ -12,6 +12,7 @@ import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
export default {
headerHeight: `${contentTop()}px`,
......@@ -25,6 +26,7 @@ export default {
BoardSidebarLabelsSelect,
BoardSidebarDueDate,
BoardSidebarSubscription,
BoardSidebarMilestoneSelect,
},
mixins: [glFeatureFlagsMixin()],
computed: {
......@@ -59,6 +61,7 @@ export default {
<board-sidebar-labels-select />
<board-sidebar-due-date />
<board-sidebar-subscription />
<board-sidebar-milestone-select />
</template>
</gl-drawer>
</template>
......@@ -78,7 +78,7 @@ export default {
<span class="gl-mx-2">-</span>
<gl-button
variant="link"
class="gl-text-gray-400!"
class="gl-text-gray-500!"
data-testid="reset-button"
:disabled="loading"
@click="setWeight(0)"
......
......@@ -21,6 +21,10 @@ fragment IssueNode on Issue {
epic {
id
}
milestone {
id
title
}
assignees {
nodes {
...User
......
......@@ -25,6 +25,7 @@ describe('ee/BoardContentSidebar', () => {
'board-sidebar-labels-select': '<div></div>',
'board-sidebar-due-date': '<div></div>',
'board-sidebar-subscription': '<div></div>',
'board-sidebar-milestone-select': '<div></div>',
},
mocks: {
$apollo: {
......
......@@ -3264,6 +3264,9 @@ msgstr ""
msgid "An error occurred while updating the comment"
msgstr ""
msgid "An error occurred while updating the milestone."
msgstr ""
msgid "An error occurred while validating group path"
msgstr ""
......@@ -17674,6 +17677,9 @@ msgstr ""
msgid "Milestones|Milestone %{milestoneTitle} was not found"
msgstr ""
msgid "Milestones|No milestones found"
msgstr ""
msgid "Milestones|Ongoing Issues (open and assigned)"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import { mockMilestone as TEST_MILESTONE } from 'jest/boards/mock_data';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { createStore } from '~/boards/stores';
import createFlash from '~/flash';
const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, referencePath: 'h/b#2' };
jest.mock('~/flash');
describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => {
let wrapper;
let store;
afterEach(() => {
wrapper.destroy();
store = null;
wrapper = null;
});
const createWrapper = ({ milestone = null } = {}) => {
store = createStore();
store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, milestone } };
store.state.activeId = TEST_ISSUE.id;
wrapper = shallowMount(BoardSidebarMilestoneSelect, {
store,
provide: {
canUpdate: true,
},
data: () => ({
milestones: [TEST_MILESTONE],
}),
stubs: {
'board-editable-item': BoardEditableItem,
},
mocks: {
$apollo: {
loading: false,
},
},
});
};
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findLoader = () => wrapper.find(GlLoadingIcon);
const findDropdownItem = () => wrapper.find('[data-testid="milestone-item"]');
const findUnsetMilestoneItem = () => wrapper.find('[data-testid="no-milestone-item"]');
const findNoMilestonesFoundItem = () => wrapper.find('[data-testid="no-milestones-found"]');
it('renders "None" when no milestone is selected', () => {
createWrapper();
expect(findCollapsed().text()).toBe('None');
});
it('renders milestone title when set', () => {
createWrapper({ milestone: TEST_MILESTONE });
expect(findCollapsed().text()).toContain(TEST_MILESTONE.title);
});
it('shows loader while Apollo is loading', async () => {
createWrapper({ milestone: TEST_MILESTONE });
expect(findLoader().exists()).toBe(false);
wrapper.vm.$apollo.loading = true;
await wrapper.vm.$nextTick();
expect(findLoader().exists()).toBe(true);
});
it('shows message when error or no milestones found', async () => {
createWrapper();
wrapper.setData({ milestones: [] });
await wrapper.vm.$nextTick();
expect(findNoMilestonesFoundItem().text()).toBe('No milestones found');
});
describe('when milestone is selected', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => {
store.state.issues[TEST_ISSUE.id].milestone = TEST_MILESTONE;
});
findDropdownItem().vm.$emit('click');
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders selected milestone', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toContain(TEST_MILESTONE.title);
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueMilestone).toHaveBeenCalledWith({
milestoneId: TEST_MILESTONE.id,
projectPath: 'h/b',
});
});
});
describe('when milestone is set to "None"', () => {
beforeEach(async () => {
createWrapper({ milestone: TEST_MILESTONE });
jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => {
store.state.issues[TEST_ISSUE.id].milestone = null;
});
findUnsetMilestoneItem().vm.$emit('click');
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders "None"', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe('None');
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueMilestone).toHaveBeenCalledWith({
milestoneId: null,
projectPath: 'h/b',
});
});
});
describe('when the mutation fails', () => {
const testMilestone = { id: '1', title: 'Former milestone' };
beforeEach(async () => {
createWrapper({ milestone: testMilestone });
jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => {
throw new Error(['failed mutation']);
});
findDropdownItem().vm.$emit('click');
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders former milestone', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toContain(testMilestone.title);
expect(createFlash).toHaveBeenCalled();
});
});
});
......@@ -8,6 +8,7 @@ import {
mockIssue2WithModel,
rawIssue,
mockIssues,
mockMilestone,
labels,
mockActiveIssue,
} from '../mock_data';
......@@ -885,6 +886,60 @@ describe('setActiveIssueSubscribed', () => {
});
});
describe('setActiveIssueMilestone', () => {
const state = { issues: { [mockIssue.id]: mockIssue } };
const getters = { activeIssue: mockIssue };
const testMilestone = {
...mockMilestone,
id: 'gid://gitlab/Milestone/1',
};
const input = {
milestoneId: testMilestone.id,
projectPath: 'h/b',
};
it('should commit milestone after setting the issue', done => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
updateIssue: {
issue: {
milestone: testMilestone,
},
errors: [],
},
},
});
const payload = {
issueId: getters.activeIssue.id,
prop: 'milestone',
value: testMilestone,
};
testAction(
actions.setActiveIssueMilestone,
input,
{ ...state, ...getters },
[
{
type: types.UPDATE_ISSUE_BY_ID,
payload,
},
],
[],
done,
);
});
it('throws error if fails', async () => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } });
await expect(actions.setActiveIssueMilestone({ getters }, input)).rejects.toThrow(Error);
});
});
describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog);
});
......
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