Commit d932410f authored by Axel García's avatar Axel García

Add due date to swimlanes board sidebar

It uses gitlab/ui date picker.
parent 24490f47
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlButton, GlDatepicker } from '@gitlab/ui';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import createFlash from '~/flash';
import { __ } from '~/locale';
export default {
components: {
BoardEditableItem,
GlButton,
GlDatepicker,
},
data() {
return {
loading: false,
};
},
computed: {
...mapGetters({ issue: 'getActiveIssue' }),
hasDueDate() {
return this.issue.dueDate != null;
},
parsedDueDate() {
if (!this.hasDueDate) {
return null;
}
return parsePikadayDate(this.issue.dueDate);
},
formattedDueDate() {
if (!this.hasDueDate) {
return '';
}
return dateInWords(this.parsedDueDate, true);
},
projectPath() {
const referencePath = this.issue.referencePath || '';
return referencePath.slice(0, referencePath.indexOf('#'));
},
},
methods: {
...mapActions(['setActiveIssueDueDate']),
async openDatePicker() {
await this.$nextTick();
this.$refs.datePicker.calendar.show();
},
async setDueDate(date) {
this.loading = true;
this.$refs.sidebarItem.collapse();
try {
const dueDate = date ? formatDate(date, 'yyyy-mm-dd') : null;
await this.setActiveIssueDueDate({ dueDate, projectPath: this.projectPath });
} catch (e) {
createFlash({ message: this.$options.i18n.updateDueDateError });
} finally {
this.loading = false;
}
},
},
i18n: {
dueDate: __('Due date'),
removeDueDate: __('remove due date'),
updateDueDateError: __('An error occurred when updating the issue due date'),
},
};
</script>
<template>
<board-editable-item
ref="sidebarItem"
class="board-sidebar-due-date"
:title="$options.i18n.dueDate"
:loading="loading"
@open="openDatePicker"
>
<template v-if="hasDueDate" #collapsed>
<div class="gl-display-flex gl-align-items-center">
<strong class="gl-text-gray-900">{{ formattedDueDate }}</strong>
<span class="gl-mx-2">-</span>
<gl-button
variant="link"
class="gl-text-gray-400!"
data-testid="reset-button"
:disabled="loading"
@click="setDueDate(null)"
>
{{ $options.i18n.removeDueDate }}
</gl-button>
</div>
</template>
<template>
<gl-datepicker
ref="datePicker"
:value="parsedDueDate"
show-clear-button
@input="setDueDate"
@clear="setDueDate(null)"
/>
</template>
</board-editable-item>
</template>
<style>
/*
* This can be removed after closing:
* https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1048
*/
.board-sidebar-due-date .gl-datepicker,
.board-sidebar-due-date .gl-datepicker-input {
width: 100%;
}
</style>
mutation issueSetDueDate($input: UpdateIssueInput!) {
updateIssue(input: $input) {
issue {
dueDate
}
errors
}
}
......@@ -19,6 +19,7 @@ import createBoardListMutation from '../queries/board_list_create.mutation.graph
import updateBoardListMutation from '../queries/board_list_update.mutation.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
......@@ -327,6 +328,30 @@ export default {
});
},
setActiveIssueDueDate: async ({ commit, getters }, input) => {
const activeIssue = getters.getActiveIssue;
const { data } = await gqlClient.mutate({
mutation: issueSetDueDate,
variables: {
input: {
iid: String(activeIssue.iid),
projectPath: input.projectPath,
dueDate: input.dueDate,
},
},
});
if (data.updateIssue?.errors?.length > 0) {
throw new Error(data.updateIssue.errors);
}
commit(types.UPDATE_ISSUE_BY_ID, {
issueId: activeIssue.id,
prop: 'dueDate',
value: data.updateIssue.issue.dueDate,
});
},
fetchBacklog: () => {
notImplemented();
},
......
......@@ -9,6 +9,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardSidebarEpicSelect from './sidebar/board_sidebar_epic_select.vue';
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';
export default {
headerHeight: `${contentTop()}px`,
......@@ -19,6 +20,7 @@ export default {
BoardSidebarEpicSelect,
BoardSidebarWeightInput,
BoardSidebarLabelsSelect,
BoardSidebarDueDate,
},
mixins: [glFeatureFlagsMixin()],
computed: {
......@@ -50,6 +52,7 @@ export default {
<board-sidebar-epic-select />
<board-sidebar-weight-input v-if="glFeatures.issueWeights" />
<board-sidebar-labels-select />
<board-sidebar-due-date />
</template>
</gl-drawer>
</template>
......@@ -21,6 +21,7 @@ describe('ee/BoardContentSidebar', () => {
'board-sidebar-epic-select': '<div></div>',
'board-sidebar-weight-input': '<div></div>',
'board-sidebar-labels-select': '<div></div>',
'board-sidebar-due-date': '<div></div>',
},
});
};
......
......@@ -2861,6 +2861,9 @@ msgstr ""
msgid "An error occurred when toggling the notification subscription"
msgstr ""
msgid "An error occurred when updating the issue due date"
msgstr ""
msgid "An error occurred when updating the issue weight"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlDatepicker } from '@gitlab/ui';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { createStore } from '~/boards/stores';
import createFlash from '~/flash';
const TEST_DUE_DATE = '2020-02-20';
const TEST_FORMATTED_DUE_DATE = 'Feb 20, 2020';
const TEST_PARSED_DATE = new Date(2020, 1, 20);
const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, dueDate: null, referencePath: 'h/b#2' };
jest.mock('~/flash');
describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => {
let wrapper;
let store;
afterEach(() => {
wrapper.destroy();
store = null;
wrapper = null;
});
const createWrapper = ({ dueDate = null } = {}) => {
store = createStore();
store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } };
store.state.activeId = TEST_ISSUE.id;
wrapper = shallowMount(BoardSidebarDueDate, {
store,
provide: {
canUpdate: true,
},
stubs: {
'board-editable-item': BoardEditableItem,
},
});
};
const findDatePicker = () => wrapper.find(GlDatepicker);
const findResetButton = () => wrapper.find('[data-testid="reset-button"]');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
it('renders "None" when no due date is set', () => {
createWrapper();
expect(findCollapsed().text()).toBe('None');
expect(findResetButton().exists()).toBe(false);
});
it('renders formatted due date with reset button when set', () => {
createWrapper({ dueDate: TEST_DUE_DATE });
expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE);
expect(findResetButton().exists()).toBe(true);
});
describe('when due date is submitted', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
store.state.issues[TEST_ISSUE.id].dueDate = TEST_DUE_DATE;
});
findDatePicker().vm.$emit('input', TEST_PARSED_DATE);
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders formatted due date with reset button', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE);
expect(findResetButton().exists()).toBe(true);
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalledWith({
dueDate: TEST_DUE_DATE,
projectPath: 'h/b',
});
});
});
describe('when due date is cleared', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
store.state.issues[TEST_ISSUE.id].dueDate = null;
});
findDatePicker().vm.$emit('clear');
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders "None"', () => {
expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalled();
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe('None');
});
});
describe('when due date is resetted', () => {
beforeEach(async () => {
createWrapper({ dueDate: TEST_DUE_DATE });
jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
store.state.issues[TEST_ISSUE.id].dueDate = null;
});
findResetButton().vm.$emit('click');
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders "None"', () => {
expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalled();
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toBe('None');
});
});
describe('when the mutation fails', () => {
beforeEach(async () => {
createWrapper({ dueDate: TEST_DUE_DATE });
jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
throw new Error(['failed mutation']);
});
findDatePicker().vm.$emit('input', 'Invalid date');
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders former issue due date', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE);
expect(createFlash).toHaveBeenCalled();
});
});
});
......@@ -640,6 +640,57 @@ describe('setActiveIssueLabels', () => {
});
});
describe('setActiveIssueDueDate', () => {
const state = { issues: { [mockIssue.id]: mockIssue } };
const getters = { getActiveIssue: mockIssue };
const testDueDate = '2020-02-20';
const input = {
dueDate: testDueDate,
projectPath: 'h/b',
};
it('should commit due date after setting the issue', done => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
updateIssue: {
issue: {
dueDate: testDueDate,
},
errors: [],
},
},
});
const payload = {
issueId: getters.getActiveIssue.id,
prop: 'dueDate',
value: testDueDate,
};
testAction(
actions.setActiveIssueDueDate,
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.setActiveIssueDueDate({ 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