Commit c8f23a00 authored by Florie Guibert's avatar Florie Guibert Committed by Natalia Tepluhina

Add To Do sidebar widget

parent 9db9f105
......@@ -54,6 +54,7 @@ export function formatListIssues(listIssues) {
const listIssue = {
...i,
id,
fullId: i.id,
labels: i.labels?.nodes || [],
assignees: i.assignees?.nodes || [],
};
......
......@@ -82,7 +82,9 @@ export default {
class="boards-sidebar gl-absolute"
@close="handleClose"
>
<template #header>{{ __('Issue details') }}</template>
<template #header>
<h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">{{ __('Issue details') }}</h2>
</template>
<template #default>
<board-sidebar-title />
<sidebar-assignees-widget
......
......@@ -60,22 +60,6 @@ export default {
},
},
methods: {
updateGlobalTodoCount(additionalTodoCount) {
const currentCount = parseInt(document.querySelector('.js-todos-count').innerText, 10);
const todoToggleEvent = new CustomEvent('todo:toggle', {
detail: {
count: Math.max(currentCount + additionalTodoCount, 0),
},
});
document.dispatchEvent(todoToggleEvent);
},
incrementGlobalTodoCount() {
this.updateGlobalTodoCount(1);
},
decrementGlobalTodoCount() {
this.updateGlobalTodoCount(-1);
},
createTodo() {
this.todoLoading = true;
return this.$apollo
......@@ -92,9 +76,6 @@ export default {
}
},
})
.then(() => {
this.incrementGlobalTodoCount();
})
.catch((err) => {
this.$emit('error', Error(CREATE_DESIGN_TODO_ERROR));
throw err;
......@@ -130,9 +111,6 @@ export default {
}
},
})
.then(() => {
this.decrementGlobalTodoCount();
})
.catch((err) => {
this.$emit('error', Error(DELETE_DESIGN_TODO_ERROR));
throw err;
......
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { produce } from 'immer';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants';
import TodoButton from '~/vue_shared/components/todo_button.vue';
export default {
components: {
TodoButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
issuableId: {
type: String,
required: true,
},
issuableIid: {
type: String,
required: true,
},
fullPath: {
type: String,
required: true,
},
issuableType: {
required: true,
type: String,
},
},
data() {
return {
loading: false,
};
},
apollo: {
todoId: {
query() {
return todoQueries[this.issuableType].query;
},
variables() {
return {
fullPath: this.fullPath,
iid: String(this.issuableIid),
};
},
update(data) {
return data.workspace?.issuable?.currentUserTodos.nodes[0]?.id;
},
result({ data }) {
const currentUserTodos = data.workspace?.issuable?.currentUserTodos?.nodes ?? [];
this.todoId = currentUserTodos[0]?.id;
this.$emit('todoUpdated', currentUserTodos.length > 0);
},
error() {
createFlash({
message: sprintf(__('Something went wrong while setting %{issuableType} to-do item.'), {
issuableType: this.issuableType,
}),
});
},
},
},
computed: {
todoIdQuery() {
return todoQueries[this.issuableType].query;
},
todoIdQueryVariables() {
return {
fullPath: this.fullPath,
iid: String(this.issuableIid),
};
},
isLoading() {
return this.$apollo.queries?.todoId?.loading || this.loading;
},
hasTodo() {
return Boolean(this.todoId);
},
todoMutationType() {
if (this.hasTodo) {
return TodoMutationTypes.MarkDone;
}
return TodoMutationTypes.Create;
},
},
methods: {
toggleTodo() {
this.loading = true;
this.$apollo
.mutate({
mutation: todoMutations[this.todoMutationType],
variables: {
input: {
targetId: !this.hasTodo ? this.issuableId : undefined,
id: this.hasTodo ? this.todoId : undefined,
},
},
update: (
store,
{
data: {
todoMutation: { todo },
},
},
) => {
const queryProps = {
query: this.todoIdQuery,
variables: this.todoIdQueryVariables,
};
const sourceData = store.readQuery(queryProps);
const data = produce(sourceData, (draftState) => {
draftState.workspace.issuable.currentUserTodos.nodes = this.hasTodo ? [] : [todo];
});
store.writeQuery({
data,
...queryProps,
});
},
})
.then(
({
data: {
todoMutation: { errors },
},
}) => {
if (errors.length) {
createFlash({
message: errors[0],
});
}
},
)
.catch(() => {
createFlash({
message: sprintf(__('Something went wrong while setting %{issuableType} to-do item.'), {
issuableType: this.issuableType,
}),
});
})
.finally(() => {
this.loading = false;
});
},
},
};
</script>
<template>
<div data-testid="sidebar-todo">
<todo-button
:issuable-type="issuableType"
:issuable-id="issuableId"
:is-todo="hasTodo"
:loading="isLoading"
size="small"
@click.stop.prevent="toggleTodo"
/>
</div>
</template>
......@@ -4,6 +4,7 @@ import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql';
import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql';
import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql';
import epicSubscribedQuery from '~/sidebar/queries/epic_subscribed.query.graphql';
import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
......@@ -13,6 +14,8 @@ import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.query.
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql';
import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.query.graphql';
import todoCreateMutation from '~/sidebar/queries/todo_create.mutation.graphql';
import todoMarkDoneMutation from '~/sidebar/queries/todo_mark_done.mutation.graphql';
import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql';
import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql';
......@@ -189,3 +192,19 @@ export const issuableAttributesQueries = {
list: milestonesQueries,
},
};
export const todoQueries = {
[IssuableType.Epic]: {
query: epicTodoQuery,
},
};
export const TodoMutationTypes = {
Create: 'create',
MarkDone: 'mark-done',
};
export const todoMutations = {
[TodoMutationTypes.Create]: todoCreateMutation,
[TodoMutationTypes.MarkDone]: todoMarkDoneMutation,
};
query epicTodos($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
__typename
issuable: epic(iid: $iid) {
__typename
id
currentUserTodos(state: pending) {
nodes {
id
}
}
}
}
}
mutation issuableTodoCreate($input: TodoCreateInput!) {
todoMutation: todoCreate(input: $input) {
__typename
todo {
id
}
errors
}
}
mutation issuableTodoMarkDone($input: TodoMarkDoneInput!) {
todoMutation: todoMarkDone(input: $input) {
__typename
todo {
id
}
errors
}
}
......@@ -18,11 +18,39 @@ export default {
return this.isTodo ? __('Mark as done') : __('Add a to do');
},
},
methods: {
updateGlobalTodoCount(additionalTodoCount) {
const countContainer = document.querySelector('.js-todos-count');
if (countContainer === null) return;
const currentCount = parseInt(countContainer.innerText, 10);
const todoToggleEvent = new CustomEvent('todo:toggle', {
detail: {
count: Math.max(currentCount + additionalTodoCount, 0),
},
});
document.dispatchEvent(todoToggleEvent);
},
incrementGlobalTodoCount() {
this.updateGlobalTodoCount(1);
},
decrementGlobalTodoCount() {
this.updateGlobalTodoCount(-1);
},
onToggle(event) {
if (this.isTodo) {
this.decrementGlobalTodoCount();
} else {
this.incrementGlobalTodoCount();
}
this.$emit('click', event);
},
},
};
</script>
<template>
<gl-button v-bind="$attrs" :aria-label="buttonLabel" @click="$emit('click', $event)">
<gl-button v-bind="$attrs" :aria-label="buttonLabel" @click="onToggle($event)">
{{ buttonLabel }}
</gl-button>
</template>
......@@ -472,6 +472,10 @@
.sidebar-collapsed-icon {
display: none;
}
.gl-drawer-header {
align-items: flex-start;
}
}
.board-header-collapsed-info-icon:hover {
......
......@@ -67,6 +67,7 @@ export function formatListEpics(listEpics) {
const listEpic = {
...i,
id,
fullId: i.id,
labels: i.labels?.nodes || [],
assignees: i.assignees?.nodes || [],
};
......
......@@ -10,10 +10,12 @@ import SidebarConfidentialityWidget from '~/sidebar/components/confidential/side
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
export default {
components: {
GlDrawer,
SidebarTodoWidget,
BoardSidebarLabelsSelect,
BoardSidebarTitle,
SidebarConfidentialityWidget,
......@@ -52,7 +54,15 @@ export default {
:open="isSidebarOpen"
@close="handleClose"
>
<template #header>{{ __('Epic details') }}</template>
<template #header>
<h2 class="gl-mt-0 gl-mb-3 gl-font-size-h2 gl-line-height-24">{{ __('Epic details') }}</h2>
<sidebar-todo-widget
:issuable-id="activeBoardItem.fullId"
:issuable-iid="activeBoardItem.iid"
:full-path="fullPath"
:issuable-type="issuableType"
/>
</template>
<template #default>
<board-sidebar-title data-testid="sidebar-title" />
<sidebar-date-widget
......
......@@ -81,6 +81,33 @@ RSpec.describe 'Epic boards sidebar', :js do
end
end
context 'todo' do
it 'creates todo when clicking button' do
click_card(card)
wait_for_requests
page.within '[data-testid="sidebar-todo"]' do
click_button 'Add a to do'
wait_for_requests
expect(page).to have_content 'Mark as done'
end
end
it 'marks a todo as done' do
click_card(card)
wait_for_requests
page.within '[data-testid="sidebar-todo"]' do
click_button 'Add a to do'
wait_for_requests
click_button 'Mark as done'
wait_for_requests
expect(page).to have_content 'Add a to do'
end
end
end
context 'start date' do
it 'edits fixed start date' do
click_card(card)
......
......@@ -66,6 +66,7 @@ describe('formatListEpics', () => {
1: {
assignees: [],
id: 1,
fullId: 'gid://gitlab/Epic/1',
labels: [mockLabel],
title: 'epic title',
},
......
......@@ -4,7 +4,11 @@ exports[`ee/BoardContentSidebar matches the snapshot 1`] = `
<div
class="boards-sidebar gl-absolute"
>
Issue details
<h2
class="gl-my-0 gl-font-size-h2 gl-line-height-24"
>
Issue details
</h2>
<boardsidebartitle-stub />
<sidebarassigneeswidget-stub
......
......@@ -12,7 +12,8 @@ import SidebarConfidentialityWidget from '~/sidebar/components/confidential/side
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import { mockEpic } from '../mock_data';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import { mockFormattedBoardEpic } from '../mock_data';
describe('EpicBoardContentSidebar', () => {
let wrapper;
......@@ -22,14 +23,14 @@ describe('EpicBoardContentSidebar', () => {
store = new Vuex.Store({
state: {
sidebarType: ISSUABLE,
boardItems: { [mockEpic.id]: mockEpic },
activeId: mockEpic.id,
boardItems: { [mockFormattedBoardEpic.id]: mockFormattedBoardEpic },
activeId: mockFormattedBoardEpic.id,
issuableType: 'epic',
fullPath: 'gitlab-org',
},
getters: {
activeBoardItem: () => {
return mockEpic;
return mockFormattedBoardEpic;
},
isSidebarOpen: () => true,
...mockGetters,
......@@ -86,6 +87,10 @@ describe('EpicBoardContentSidebar', () => {
expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true);
});
it('renders SidebarTodoWidget', () => {
expect(wrapper.findComponent(SidebarTodoWidget).exists()).toBe(true);
});
it('renders BoardSidebarLabelsSelect', () => {
expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true);
});
......@@ -127,7 +132,7 @@ describe('EpicBoardContentSidebar', () => {
expect(toggleBoardItem).toHaveBeenCalledTimes(1);
expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
boardItem: mockEpic,
boardItem: mockFormattedBoardEpic,
sidebarType: ISSUABLE,
});
});
......
......@@ -240,6 +240,22 @@ export const mockEpic = {
labels: [],
};
export const mockFormattedBoardEpic = {
fullId: 'gid://gitlab/Epic/41',
id: 41,
iid: '1',
title: 'Epic title',
state: 'opened',
webUrl: '/groups/gitlab-org/-/epics/1',
group: { fullPath: 'gitlab-org' },
descendantCounts: {
openedIssues: 3,
closedIssues: 2,
},
issues: [mockIssue],
labels: [],
};
export const mockEpics = [
{
id: 'gid://gitlab/Epic/41',
......
......@@ -30339,6 +30339,9 @@ msgstr ""
msgid "Something went wrong while setting %{issuableType} notifications."
msgstr ""
msgid "Something went wrong while setting %{issuableType} to-do item."
msgstr ""
msgid "Something went wrong while stopping this environment. Please try again."
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql';
import TodoButton from '~/vue_shared/components/todo_button.vue';
import { todosResponse, noTodosResponse } from '../../mock_data';
jest.mock('~/flash');
Vue.use(VueApollo);
describe('Sidebar Todo Widget', () => {
let wrapper;
let fakeApollo;
const findTodoButton = () => wrapper.findComponent(TodoButton);
const createComponent = ({
todosQueryHandler = jest.fn().mockResolvedValue(noTodosResponse),
} = {}) => {
fakeApollo = createMockApollo([[epicTodoQuery, todosQueryHandler]]);
wrapper = shallowMount(SidebarTodoWidget, {
apolloProvider: fakeApollo,
provide: {
canUpdate: true,
},
propsData: {
fullPath: 'group',
issuableIid: '1',
issuableId: 'gid://gitlab/Epic/4',
issuableType: 'epic',
},
});
};
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
describe('when user does not have a todo for the issuable', () => {
beforeEach(() => {
createComponent();
return waitForPromises();
});
it('passes false isTodo prop to Todo button component', () => {
expect(findTodoButton().props('isTodo')).toBe(false);
});
it('emits `todoUpdated` event with a `false` payload', () => {
expect(wrapper.emitted('todoUpdated')).toEqual([[false]]);
});
});
describe('when user has a todo for the issuable', () => {
beforeEach(() => {
createComponent({
todosQueryHandler: jest.fn().mockResolvedValue(todosResponse),
});
return waitForPromises();
});
it('passes true isTodo prop to Todo button component', () => {
expect(findTodoButton().props('isTodo')).toBe(true);
});
it('emits `todoUpdated` event with a `true` payload', () => {
expect(wrapper.emitted('todoUpdated')).toEqual([[true]]);
});
});
it('displays a flash message when query is rejected', async () => {
createComponent({
todosQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
});
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
});
});
......@@ -609,4 +609,38 @@ export const issuableTimeTrackingResponse = {
},
};
export const todosResponse = {
data: {
workspace: {
__typename: 'Group',
issuable: {
__typename: 'Epic',
id: 'gid://gitlab/Epic/4',
currentUserTodos: {
nodes: [
{
id: 'gid://gitlab/Todo/433',
},
],
},
},
},
},
};
export const noTodosResponse = {
data: {
workspace: {
__typename: 'Group',
issuable: {
__typename: 'Epic',
id: 'gid://gitlab/Epic/4',
currentUserTodos: {
nodes: [],
},
},
},
},
};
export default mockData;
......@@ -4,6 +4,7 @@ import TodoButton from '~/vue_shared/components/todo_button.vue';
describe('Todo Button', () => {
let wrapper;
let dispatchEventSpy;
const createComponent = (props = {}, mountFn = shallowMount) => {
wrapper = mountFn(TodoButton, {
......@@ -13,8 +14,17 @@ describe('Todo Button', () => {
});
};
beforeEach(() => {
dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
jest.spyOn(document, 'querySelector').mockReturnValue({
innerText: 2,
});
});
afterEach(() => {
wrapper.destroy();
dispatchEventSpy = null;
jest.clearAllMocks();
});
it('renders GlButton', () => {
......@@ -30,6 +40,16 @@ describe('Todo Button', () => {
expect(wrapper.emitted().click).toBeTruthy();
});
it('calls dispatchDocumentEvent to update global To-Do counter correctly', () => {
createComponent({}, mount);
wrapper.find(GlButton).trigger('click');
const dispatchedEvent = dispatchEventSpy.mock.calls[0][0];
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
expect(dispatchedEvent.detail).toEqual({ count: 1 });
expect(dispatchedEvent.type).toBe('todo:toggle');
});
it.each`
label | isTodo
${'Mark as done'} | ${true}
......
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