613 lines
18 KiB
JavaScript
613 lines
18 KiB
JavaScript
import { produce } from 'immer';
|
|
import VueApollo from 'vue-apollo';
|
|
import { isEmpty, map, pick, isEqual } from 'lodash';
|
|
import { apolloProvider } from '~/graphql_shared/issuable_client';
|
|
import { issuesListClient } from '~/issues/list';
|
|
import { TYPENAME_USER } from '~/graphql_shared/constants';
|
|
import { convertToGraphQLId } from '~/graphql_shared/utils';
|
|
import { getBaseURL } from '~/lib/utils/url_utility';
|
|
import { convertEachWordToTitleCase } from '~/lib/utils/text_utility';
|
|
import { getDraft, clearDraft } from '~/lib/utils/autosave';
|
|
import {
|
|
findHierarchyWidgets,
|
|
findHierarchyWidgetChildren,
|
|
isNotesWidget,
|
|
newWorkItemFullPath,
|
|
newWorkItemId,
|
|
getNewWorkItemAutoSaveKey,
|
|
} from '../utils';
|
|
import {
|
|
WIDGET_TYPE_ASSIGNEES,
|
|
WIDGET_TYPE_COLOR,
|
|
WIDGET_TYPE_HIERARCHY,
|
|
WIDGET_TYPE_PARTICIPANTS,
|
|
WIDGET_TYPE_PROGRESS,
|
|
WIDGET_TYPE_START_AND_DUE_DATE,
|
|
WIDGET_TYPE_TIME_TRACKING,
|
|
WIDGET_TYPE_LABELS,
|
|
WIDGET_TYPE_WEIGHT,
|
|
WIDGET_TYPE_MILESTONE,
|
|
WIDGET_TYPE_ITERATION,
|
|
WIDGET_TYPE_HEALTH_STATUS,
|
|
WIDGET_TYPE_DESCRIPTION,
|
|
WIDGET_TYPE_CRM_CONTACTS,
|
|
NEW_WORK_ITEM_IID,
|
|
WIDGET_TYPE_CURRENT_USER_TODOS,
|
|
WIDGET_TYPE_LINKED_ITEMS,
|
|
STATE_CLOSED,
|
|
} from '../constants';
|
|
import workItemByIidQuery from './work_item_by_iid.query.graphql';
|
|
import getWorkItemTreeQuery from './work_item_tree.query.graphql';
|
|
|
|
const getNotesWidgetFromSourceData = (draftData) =>
|
|
draftData?.workspace?.workItem?.widgets.find(isNotesWidget);
|
|
|
|
const updateNotesWidgetDataInDraftData = (draftData, notesWidget) => {
|
|
const noteWidgetIndex = draftData.workspace.workItem.widgets.findIndex(isNotesWidget);
|
|
draftData.workspace.workItem.widgets[noteWidgetIndex] = notesWidget;
|
|
};
|
|
|
|
/**
|
|
* Work Item note create subscription update query callback
|
|
*
|
|
* @param currentNotes
|
|
* @param subscriptionData
|
|
*/
|
|
export const updateCacheAfterCreatingNote = (currentNotes, subscriptionData) => {
|
|
if (!subscriptionData.data?.workItemNoteCreated) {
|
|
return currentNotes;
|
|
}
|
|
const newNote = subscriptionData.data.workItemNoteCreated;
|
|
|
|
return produce(currentNotes, (draftData) => {
|
|
const notesWidget = getNotesWidgetFromSourceData(draftData);
|
|
|
|
if (!notesWidget.discussions) {
|
|
return;
|
|
}
|
|
|
|
const discussion = notesWidget.discussions.nodes.find((d) => d.id === newNote.discussion.id);
|
|
|
|
// handle the case where discussion already exists - we don't need to do anything, update will happen automatically
|
|
if (discussion) {
|
|
return;
|
|
}
|
|
|
|
notesWidget.discussions.nodes.push(newNote.discussion);
|
|
updateNotesWidgetDataInDraftData(draftData, notesWidget);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Work Item note delete subscription update query callback
|
|
*
|
|
* @param currentNotes
|
|
* @param subscriptionData
|
|
*/
|
|
export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) => {
|
|
if (!subscriptionData.data?.workItemNoteDeleted) {
|
|
return currentNotes;
|
|
}
|
|
const deletedNote = subscriptionData.data.workItemNoteDeleted;
|
|
const { id, discussionId, lastDiscussionNote } = deletedNote;
|
|
|
|
return produce(currentNotes, (draftData) => {
|
|
const notesWidget = getNotesWidgetFromSourceData(draftData);
|
|
|
|
if (!notesWidget.discussions) {
|
|
return;
|
|
}
|
|
|
|
const discussionIndex = notesWidget.discussions.nodes.findIndex(
|
|
(discussion) => discussion.id === discussionId,
|
|
);
|
|
|
|
if (discussionIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
if (lastDiscussionNote) {
|
|
notesWidget.discussions.nodes.splice(discussionIndex, 1);
|
|
} else {
|
|
const deletedThreadDiscussion = notesWidget.discussions.nodes[discussionIndex];
|
|
const deletedThreadIndex = deletedThreadDiscussion.notes.nodes.findIndex(
|
|
(note) => note.id === id,
|
|
);
|
|
deletedThreadDiscussion.notes.nodes.splice(deletedThreadIndex, 1);
|
|
notesWidget.discussions.nodes[discussionIndex] = deletedThreadDiscussion;
|
|
}
|
|
|
|
updateNotesWidgetDataInDraftData(draftData, notesWidget);
|
|
});
|
|
};
|
|
|
|
function updateNoteAwardEmojiCache(currentNotes, note, callback) {
|
|
if (!note.awardEmoji) {
|
|
return currentNotes;
|
|
}
|
|
const { awardEmoji } = note;
|
|
|
|
return produce(currentNotes, (draftData) => {
|
|
const notesWidget = getNotesWidgetFromSourceData(draftData);
|
|
|
|
if (!notesWidget.discussions) {
|
|
return;
|
|
}
|
|
|
|
notesWidget.discussions.nodes.forEach((discussion) => {
|
|
discussion.notes.nodes.forEach((n) => {
|
|
if (n.id === note.id) {
|
|
callback(n, awardEmoji);
|
|
}
|
|
});
|
|
});
|
|
|
|
updateNotesWidgetDataInDraftData(draftData, notesWidget);
|
|
});
|
|
}
|
|
|
|
export const updateCacheAfterAddingAwardEmojiToNote = (currentNotes, note) => {
|
|
return updateNoteAwardEmojiCache(currentNotes, note, (n, awardEmoji) => {
|
|
n.awardEmoji.nodes.push(awardEmoji);
|
|
});
|
|
};
|
|
|
|
export const updateCacheAfterRemovingAwardEmojiFromNote = (currentNotes, note) => {
|
|
return updateNoteAwardEmojiCache(currentNotes, note, (n, awardEmoji) => {
|
|
// eslint-disable-next-line no-param-reassign
|
|
n.awardEmoji.nodes = n.awardEmoji.nodes.filter((emoji) => {
|
|
return emoji.name !== awardEmoji.name || emoji.user.id !== awardEmoji.user.id;
|
|
});
|
|
});
|
|
};
|
|
|
|
export const addHierarchyChild = ({ cache, id, workItem, atIndex = null }) => {
|
|
const queryArgs = {
|
|
query: getWorkItemTreeQuery,
|
|
variables: { id },
|
|
};
|
|
const sourceData = cache.readQuery(queryArgs);
|
|
|
|
if (!sourceData) {
|
|
return;
|
|
}
|
|
|
|
cache.writeQuery({
|
|
...queryArgs,
|
|
data: produce(sourceData, (draftState) => {
|
|
const widget = findHierarchyWidgets(draftState?.workItem.widgets);
|
|
widget.hasChildren = true;
|
|
const children = findHierarchyWidgetChildren(draftState?.workItem) || [];
|
|
const existingChild = children.find((child) => child.id === workItem?.id);
|
|
if (!existingChild) {
|
|
if (atIndex !== null) {
|
|
children.splice(atIndex, 0, workItem);
|
|
} else {
|
|
children.unshift(workItem);
|
|
}
|
|
widget.hasChildren = children?.length > 0;
|
|
widget.count = children?.length || 0;
|
|
}
|
|
}),
|
|
});
|
|
};
|
|
|
|
export const addHierarchyChildren = ({ cache, id, workItem, childrenIds }) => {
|
|
const queryArgs = {
|
|
query: getWorkItemTreeQuery,
|
|
variables: {
|
|
id,
|
|
},
|
|
};
|
|
const sourceData = cache.readQuery(queryArgs);
|
|
|
|
if (!sourceData) {
|
|
return;
|
|
}
|
|
|
|
cache.writeQuery({
|
|
...queryArgs,
|
|
data: produce(sourceData, (draftState) => {
|
|
const newChildren = findHierarchyWidgetChildren(workItem);
|
|
|
|
const existingChildren = findHierarchyWidgetChildren(draftState?.workItem);
|
|
|
|
const childrenToAdd = newChildren.filter((item) => {
|
|
return childrenIds.includes(item.id);
|
|
});
|
|
|
|
for (const item of childrenToAdd) {
|
|
if (item.state === STATE_CLOSED) {
|
|
existingChildren.push(item);
|
|
} else {
|
|
existingChildren.unshift(item);
|
|
}
|
|
}
|
|
}),
|
|
});
|
|
};
|
|
|
|
export const removeHierarchyChild = ({ cache, id, workItem }) => {
|
|
const queryArgs = {
|
|
query: getWorkItemTreeQuery,
|
|
variables: { id },
|
|
};
|
|
const sourceData = cache.readQuery(queryArgs);
|
|
|
|
if (!sourceData) {
|
|
return;
|
|
}
|
|
|
|
cache.writeQuery({
|
|
...queryArgs,
|
|
data: produce(sourceData, (draftState) => {
|
|
const widget = findHierarchyWidgets(draftState?.workItem.widgets);
|
|
const children = findHierarchyWidgetChildren(draftState?.workItem);
|
|
const index = children.findIndex((child) => child.id === workItem.id);
|
|
if (index >= 0) children.splice(index, 1);
|
|
widget.hasChildren = children?.length > 0;
|
|
widget.count = children?.length || 0;
|
|
}),
|
|
});
|
|
};
|
|
|
|
export const updateParent = ({ cache, fullPath, iid, workItem }) => {
|
|
const queryArgs = {
|
|
query: workItemByIidQuery,
|
|
variables: { fullPath, iid },
|
|
};
|
|
const sourceData = cache.readQuery(queryArgs);
|
|
|
|
if (!sourceData) {
|
|
return;
|
|
}
|
|
|
|
cache.writeQuery({
|
|
...queryArgs,
|
|
data: produce(sourceData, (draftState) => {
|
|
const children = findHierarchyWidgetChildren(draftState.workspace?.workItem);
|
|
const index = children.findIndex((child) => child.id === workItem.id);
|
|
if (index >= 0) children.splice(index, 1);
|
|
}),
|
|
});
|
|
};
|
|
|
|
export const updateWorkItemCurrentTodosWidget = ({ cache, fullPath, iid, todos }) => {
|
|
const query = {
|
|
query: workItemByIidQuery,
|
|
variables: { fullPath, iid },
|
|
};
|
|
|
|
const sourceData = cache.readQuery(query);
|
|
|
|
if (!sourceData) {
|
|
return;
|
|
}
|
|
|
|
const newData = produce(sourceData, (draftState) => {
|
|
const { widgets } = draftState.workspace.workItem;
|
|
const widgetCurrentUserTodos = widgets.find(
|
|
(widget) => widget.type === WIDGET_TYPE_CURRENT_USER_TODOS,
|
|
);
|
|
|
|
widgetCurrentUserTodos.currentUserTodos.nodes = todos;
|
|
});
|
|
|
|
cache.writeQuery({ ...query, data: newData });
|
|
};
|
|
|
|
export const setNewWorkItemCache = async (
|
|
fullPath,
|
|
widgetDefinitions,
|
|
workItemType,
|
|
workItemTypeId,
|
|
workItemTypeIconName,
|
|
// eslint-disable-next-line max-params
|
|
) => {
|
|
const workItemAttributesWrapperOrder = [
|
|
WIDGET_TYPE_ASSIGNEES,
|
|
WIDGET_TYPE_LABELS,
|
|
WIDGET_TYPE_WEIGHT,
|
|
WIDGET_TYPE_MILESTONE,
|
|
WIDGET_TYPE_ITERATION,
|
|
WIDGET_TYPE_START_AND_DUE_DATE,
|
|
WIDGET_TYPE_PROGRESS,
|
|
WIDGET_TYPE_HEALTH_STATUS,
|
|
WIDGET_TYPE_LINKED_ITEMS,
|
|
WIDGET_TYPE_COLOR,
|
|
WIDGET_TYPE_HIERARCHY,
|
|
WIDGET_TYPE_TIME_TRACKING,
|
|
WIDGET_TYPE_PARTICIPANTS,
|
|
WIDGET_TYPE_CRM_CONTACTS,
|
|
];
|
|
|
|
if (!widgetDefinitions) {
|
|
return;
|
|
}
|
|
|
|
const workItemTitleCase = convertEachWordToTitleCase(workItemType.split('_').join(' '));
|
|
const availableWidgets = widgetDefinitions?.flatMap((i) => i.type) || [];
|
|
const currentUserId = convertToGraphQLId(TYPENAME_USER, gon?.current_user_id);
|
|
const baseURL = getBaseURL();
|
|
|
|
const widgets = [];
|
|
|
|
widgets.push({
|
|
type: WIDGET_TYPE_DESCRIPTION,
|
|
description: null,
|
|
descriptionHtml: '',
|
|
lastEditedAt: null,
|
|
lastEditedBy: null,
|
|
taskCompletionStatus: null,
|
|
__typename: 'WorkItemWidgetDescription',
|
|
});
|
|
|
|
workItemAttributesWrapperOrder.forEach((widgetName) => {
|
|
if (availableWidgets.includes(widgetName)) {
|
|
if (widgetName === WIDGET_TYPE_ASSIGNEES) {
|
|
const assigneesWidgetData = widgetDefinitions.find(
|
|
(definition) => definition.type === WIDGET_TYPE_ASSIGNEES,
|
|
);
|
|
widgets.push({
|
|
type: 'ASSIGNEES',
|
|
allowsMultipleAssignees: assigneesWidgetData.allowsMultipleAssignees || false,
|
|
canInviteMembers: assigneesWidgetData.canInviteMembers || false,
|
|
assignees: {
|
|
nodes: [],
|
|
__typename: 'UserCoreConnection',
|
|
},
|
|
__typename: 'WorkItemWidgetAssignees',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_LINKED_ITEMS) {
|
|
widgets.push({
|
|
type: WIDGET_TYPE_LINKED_ITEMS,
|
|
linkedItems: {
|
|
nodes: [],
|
|
},
|
|
__typename: 'WorkItemWidgetLinkedItems',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_CRM_CONTACTS) {
|
|
widgets.push({
|
|
type: 'CRM_CONTACTS',
|
|
contacts: {
|
|
nodes: [],
|
|
__typename: 'CustomerRelationsContactConnection',
|
|
},
|
|
__typename: 'WorkItemWidgetCrmContacts',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_LABELS) {
|
|
const labelsWidgetData = widgetDefinitions.find(
|
|
(definition) => definition.type === WIDGET_TYPE_LABELS,
|
|
);
|
|
widgets.push({
|
|
type: 'LABELS',
|
|
allowsScopedLabels: labelsWidgetData.allowsScopedLabels,
|
|
labels: {
|
|
nodes: [],
|
|
__typename: 'LabelConnection',
|
|
},
|
|
__typename: 'WorkItemWidgetLabels',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_WEIGHT) {
|
|
const weightWidgetData = widgetDefinitions.find(
|
|
(definition) => definition.type === WIDGET_TYPE_WEIGHT,
|
|
);
|
|
|
|
widgets.push({
|
|
type: 'WEIGHT',
|
|
weight: null,
|
|
rolledUpWeight: 0,
|
|
rolledUpCompletedWeight: 0,
|
|
widgetDefinition: {
|
|
editable: weightWidgetData?.editable,
|
|
rollUp: weightWidgetData?.rollUp,
|
|
},
|
|
__typename: 'WorkItemWidgetWeight',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_MILESTONE) {
|
|
widgets.push({
|
|
type: 'MILESTONE',
|
|
milestone: null,
|
|
__typename: 'WorkItemWidgetMilestone',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_ITERATION) {
|
|
widgets.push({
|
|
iteration: null,
|
|
type: 'ITERATION',
|
|
__typename: 'WorkItemWidgetIteration',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_START_AND_DUE_DATE) {
|
|
widgets.push({
|
|
type: 'START_AND_DUE_DATE',
|
|
dueDate: null,
|
|
startDate: null,
|
|
isFixed: false,
|
|
rollUp: false,
|
|
__typename: 'WorkItemWidgetStartAndDueDate',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_PROGRESS) {
|
|
widgets.push({
|
|
type: 'PROGRESS',
|
|
progress: null,
|
|
updatedAt: null,
|
|
__typename: 'WorkItemWidgetProgress',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_HEALTH_STATUS) {
|
|
widgets.push({
|
|
type: 'HEALTH_STATUS',
|
|
healthStatus: null,
|
|
rolledUpHealthStatus: [],
|
|
__typename: 'WorkItemWidgetHealthStatus',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_COLOR) {
|
|
widgets.push({
|
|
type: 'COLOR',
|
|
color: '#1068bf',
|
|
textColor: '#FFFFFF',
|
|
__typename: 'WorkItemWidgetColor',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_HIERARCHY) {
|
|
widgets.push({
|
|
type: 'HIERARCHY',
|
|
hasChildren: false,
|
|
hasParent: false,
|
|
parent: null,
|
|
depthLimitReachedByType: [],
|
|
rolledUpCountsByType: [],
|
|
children: {
|
|
nodes: [],
|
|
__typename: 'WorkItemConnection',
|
|
},
|
|
__typename: 'WorkItemWidgetHierarchy',
|
|
});
|
|
}
|
|
|
|
if (widgetName === WIDGET_TYPE_TIME_TRACKING) {
|
|
widgets.push({
|
|
type: 'TIME_TRACKING',
|
|
timeEstimate: 0,
|
|
timelogs: {
|
|
nodes: [],
|
|
__typename: 'WorkItemTimelogConnection',
|
|
},
|
|
totalTimeSpent: 0,
|
|
__typename: 'WorkItemWidgetTimeTracking',
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
const issuesListApolloProvider = new VueApollo({
|
|
defaultClient: await issuesListClient(),
|
|
});
|
|
|
|
const cacheProvider = document.querySelector('.js-issues-list-app')
|
|
? issuesListApolloProvider
|
|
: apolloProvider;
|
|
|
|
const newWorkItemPath = newWorkItemFullPath(fullPath, workItemType);
|
|
|
|
const autosaveKey = getNewWorkItemAutoSaveKey(fullPath, workItemType);
|
|
|
|
const getStorageDraftString = getDraft(autosaveKey);
|
|
|
|
const draftData = JSON.parse(getDraft(autosaveKey));
|
|
|
|
// get the widgets stored in draft data
|
|
const draftDataWidgets = map(draftData?.workspace?.workItem?.widgets, pick('type')) || [];
|
|
|
|
// this is to fix errors when we are introducing a new widget and the cache always updates from the old widgets
|
|
// Like if we we introduce a new widget , the user might always see the cached data until hits cancel
|
|
const draftWidgetsAreSameAsCacheDigits = isEqual(
|
|
draftDataWidgets.sort(),
|
|
availableWidgets.sort(),
|
|
);
|
|
|
|
const isValidDraftData =
|
|
draftData?.workspace?.workItem &&
|
|
getStorageDraftString &&
|
|
draftData?.workspace?.workItem &&
|
|
isEmpty(draftWidgetsAreSameAsCacheDigits);
|
|
|
|
/** check in case of someone plays with the localstorage, we need to be sure */
|
|
if (!isValidDraftData) {
|
|
clearDraft(autosaveKey);
|
|
}
|
|
|
|
cacheProvider.clients.defaultClient.cache.writeQuery({
|
|
query: workItemByIidQuery,
|
|
variables: {
|
|
fullPath: newWorkItemPath,
|
|
iid: NEW_WORK_ITEM_IID,
|
|
},
|
|
data: isValidDraftData
|
|
? { ...draftData }
|
|
: {
|
|
workspace: {
|
|
id: newWorkItemPath,
|
|
workItem: {
|
|
id: newWorkItemId(workItemType),
|
|
iid: NEW_WORK_ITEM_IID,
|
|
archived: false,
|
|
title: '',
|
|
state: 'OPEN',
|
|
description: null,
|
|
confidential: false,
|
|
createdAt: null,
|
|
updatedAt: null,
|
|
closedAt: null,
|
|
webUrl: `${baseURL}/groups/gitlab-org/-/work_items/new`,
|
|
reference: '',
|
|
createNoteEmail: null,
|
|
namespace: {
|
|
id: newWorkItemPath,
|
|
fullPath,
|
|
name: newWorkItemPath,
|
|
__typename: 'Namespace',
|
|
},
|
|
author: {
|
|
id: currentUserId,
|
|
avatarUrl: gon?.current_user_avatar_url,
|
|
username: gon?.current_username,
|
|
name: gon?.current_user_fullname,
|
|
webUrl: `${baseURL}/${gon?.current_username}`,
|
|
webPath: `/${gon?.current_username}`,
|
|
__typename: 'UserCore',
|
|
},
|
|
workItemType: {
|
|
id: workItemTypeId || 'mock-work-item-type-id',
|
|
name: workItemTitleCase,
|
|
iconName: workItemTypeIconName,
|
|
__typename: 'WorkItemType',
|
|
},
|
|
userPermissions: {
|
|
deleteWorkItem: true,
|
|
updateWorkItem: true,
|
|
adminParentLink: true,
|
|
setWorkItemMetadata: true,
|
|
createNote: true,
|
|
adminWorkItemLink: true,
|
|
markNoteAsInternal: true,
|
|
__typename: 'WorkItemPermissions',
|
|
},
|
|
widgets,
|
|
__typename: 'WorkItem',
|
|
},
|
|
__typename: 'Namespace',
|
|
},
|
|
},
|
|
});
|
|
};
|
|
|
|
export const optimisticUserPermissions = {
|
|
deleteWorkItem: false,
|
|
updateWorkItem: false,
|
|
adminParentLink: false,
|
|
setWorkItemMetadata: false,
|
|
createNote: false,
|
|
adminWorkItemLink: false,
|
|
__typename: 'WorkItemPermissions',
|
|
};
|