Commit d43f2768 authored by Axel Garcia's avatar Axel Garcia Committed by Illya Klymov

Add milestones select to swimlanes board sidebar

It uses gitlab/ui and GraphQL to render the
milestones dropdown.
parent 651d6474
...@@ -50,6 +50,13 @@ export default { ...@@ -50,6 +50,13 @@ export default {
} }
window.removeEventListener('click', this.collapseWhenOffClick); window.removeEventListener('click', this.collapseWhenOffClick);
}, },
toggle({ emitEvent = true } = {}) {
if (this.edit) {
this.collapse({ emitEvent });
} else {
this.expand();
}
},
}, },
}; };
</script> </script>
...@@ -66,12 +73,12 @@ export default { ...@@ -66,12 +73,12 @@ export default {
variant="link" variant="link"
class="gl-text-gray-900! js-sidebar-dropdown-toggle" class="gl-text-gray-900! js-sidebar-dropdown-toggle"
data-testid="edit-button" data-testid="edit-button"
@click="expand" @click="toggle"
> >
{{ __('Edit') }} {{ __('Edit') }}
</gl-button> </gl-button>
</div> </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> <slot name="collapsed">{{ __('None') }}</slot>
</div> </div>
<div v-show="edit" data-testid="expanded-content"> <div v-show="edit" data-testid="expanded-content">
......
...@@ -79,7 +79,7 @@ export default { ...@@ -79,7 +79,7 @@ export default {
<span class="gl-mx-2">-</span> <span class="gl-mx-2">-</span>
<gl-button <gl-button
variant="link" variant="link"
class="gl-text-gray-400!" class="gl-text-gray-500!"
data-testid="reset-button" data-testid="reset-button"
:disabled="loading" :disabled="loading"
@click="setDueDate(null)" @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 { ...@@ -11,6 +11,10 @@ fragment IssueNode on Issue {
webUrl webUrl
subscribed subscribed
relativePosition relativePosition
milestone {
id
title
}
assignees { assignees {
nodes { nodes {
...User ...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'; ...@@ -25,6 +25,7 @@ import issueCreateMutation from '../queries/issue_create.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql'; import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql'; import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql';
import issueSetSubscriptionMutation from '../graphql/mutations/issue_set_subscription.mutation.graphql'; import issueSetSubscriptionMutation from '../graphql/mutations/issue_set_subscription.mutation.graphql';
import issueSetMilestone from '../queries/issue_set_milestone.mutation.graphql';
const notImplemented = () => { const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */ /* eslint-disable-next-line @gitlab/require-i18n-strings */
...@@ -337,6 +338,30 @@ export default { ...@@ -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) => { createNewIssue: ({ commit, state }, issueInput) => {
const input = issueInput; const input = issueInput;
const { boardType, endpoints } = state; const { boardType, endpoints } = state;
......
...@@ -12,6 +12,7 @@ import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue'; ...@@ -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 BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
export default { export default {
headerHeight: `${contentTop()}px`, headerHeight: `${contentTop()}px`,
...@@ -25,6 +26,7 @@ export default { ...@@ -25,6 +26,7 @@ export default {
BoardSidebarLabelsSelect, BoardSidebarLabelsSelect,
BoardSidebarDueDate, BoardSidebarDueDate,
BoardSidebarSubscription, BoardSidebarSubscription,
BoardSidebarMilestoneSelect,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
computed: { computed: {
...@@ -59,6 +61,7 @@ export default { ...@@ -59,6 +61,7 @@ export default {
<board-sidebar-labels-select /> <board-sidebar-labels-select />
<board-sidebar-due-date /> <board-sidebar-due-date />
<board-sidebar-subscription /> <board-sidebar-subscription />
<board-sidebar-milestone-select />
</template> </template>
</gl-drawer> </gl-drawer>
</template> </template>
...@@ -78,7 +78,7 @@ export default { ...@@ -78,7 +78,7 @@ export default {
<span class="gl-mx-2">-</span> <span class="gl-mx-2">-</span>
<gl-button <gl-button
variant="link" variant="link"
class="gl-text-gray-400!" class="gl-text-gray-500!"
data-testid="reset-button" data-testid="reset-button"
:disabled="loading" :disabled="loading"
@click="setWeight(0)" @click="setWeight(0)"
......
...@@ -21,6 +21,10 @@ fragment IssueNode on Issue { ...@@ -21,6 +21,10 @@ fragment IssueNode on Issue {
epic { epic {
id id
} }
milestone {
id
title
}
assignees { assignees {
nodes { nodes {
...User ...User
......
...@@ -25,6 +25,7 @@ describe('ee/BoardContentSidebar', () => { ...@@ -25,6 +25,7 @@ describe('ee/BoardContentSidebar', () => {
'board-sidebar-labels-select': '<div></div>', 'board-sidebar-labels-select': '<div></div>',
'board-sidebar-due-date': '<div></div>', 'board-sidebar-due-date': '<div></div>',
'board-sidebar-subscription': '<div></div>', 'board-sidebar-subscription': '<div></div>',
'board-sidebar-milestone-select': '<div></div>',
}, },
}); });
}; };
......
...@@ -3243,6 +3243,9 @@ msgstr "" ...@@ -3243,6 +3243,9 @@ msgstr ""
msgid "An error occurred while updating the comment" msgid "An error occurred while updating the comment"
msgstr "" msgstr ""
msgid "An error occurred while updating the milestone."
msgstr ""
msgid "An error occurred while validating group path" msgid "An error occurred while validating group path"
msgstr "" msgstr ""
...@@ -17599,6 +17602,9 @@ msgstr "" ...@@ -17599,6 +17602,9 @@ msgstr ""
msgid "Milestones|Milestone %{milestoneTitle} was not found" msgid "Milestones|Milestone %{milestoneTitle} was not found"
msgstr "" msgstr ""
msgid "Milestones|No milestones found"
msgstr ""
msgid "Milestones|Ongoing Issues (open and assigned)" msgid "Milestones|Ongoing Issues (open and assigned)"
msgstr "" 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 { ...@@ -8,6 +8,7 @@ import {
mockIssue2WithModel, mockIssue2WithModel,
rawIssue, rawIssue,
mockIssues, mockIssues,
mockMilestone,
labels, labels,
mockActiveIssue, mockActiveIssue,
} from '../mock_data'; } from '../mock_data';
...@@ -885,6 +886,60 @@ describe('setActiveIssueSubscribed', () => { ...@@ -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', () => { describe('fetchBacklog', () => {
expectNotImplemented(actions.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