From 1ad7011aac0438ef8e3528832014442a420dc8cb Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Thu, 16 May 2024 17:29:01 +0530 Subject: [PATCH] [WEB-1249] feat: Kanban multi dragndrop (#4479) * Kanban multi dnd * complete Kanban multi dnd * add proper brackets to if conditions --- .../tailwind-config-custom/tailwind.config.js | 14 + packages/types/src/issues.d.ts | 2 + web/components/core/render-if-visible-HOC.tsx | 4 +- .../issue-layouts/kanban/base-kanban-root.tsx | 80 +++++- .../issues/issue-layouts/kanban/block.tsx | 10 +- .../issue-layouts/kanban/blocks-list.tsx | 9 +- .../issues/issue-layouts/kanban/default.tsx | 9 +- .../issue-layouts/kanban/kanban-group.tsx | 47 +++- .../issues/issue-layouts/kanban/swimlanes.tsx | 7 +- .../issues/issue-layouts/kanban/utils.ts | 213 --------------- .../issues/issue-layouts/list/block-root.tsx | 1 - .../properties/all-properties.tsx | 18 +- .../spreadsheet/columns/cycle-column.tsx | 8 +- .../spreadsheet/columns/module-column.tsx | 12 +- .../issue-layouts/spreadsheet/issue-row.tsx | 1 - web/components/issues/issue-layouts/utils.tsx | 254 +++++++++++++++++- web/components/issues/issue-modal/modal.tsx | 2 +- web/store/issue/cycle/issue.store.ts | 39 ++- web/store/issue/issue-details/issue.store.ts | 8 +- web/store/issue/issue_kanban_view.store.ts | 6 +- web/store/issue/module/issue.store.ts | 107 ++++---- web/store/issue/project-views/issue.store.ts | 8 +- web/store/issue/project/issue.store.ts | 8 +- web/styles/globals.css | 31 ++- 24 files changed, 565 insertions(+), 333 deletions(-) delete mode 100644 web/components/issues/issue-layouts/kanban/utils.ts diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 2000f3abf..0206c0e76 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -87,6 +87,8 @@ module.exports = { 800: convertToRGB("--color-background-800"), 900: convertToRGB("--color-background-900"), 1000: "rgb(0, 0, 0)", + primary: convertToRGB(" --color-background-primary"), + error: convertToRGB(" --color-background-error"), DEFAULT: convertToRGB("--color-background-100"), }, text: { @@ -110,6 +112,8 @@ module.exports = { 800: convertToRGB("--color-text-800"), 900: convertToRGB("--color-text-900"), 1000: "rgb(0, 0, 0)", + primary: convertToRGB("--color-text-primary"), + error: convertToRGB("--color-text-error"), DEFAULT: convertToRGB("--color-text-100"), }, border: { @@ -119,8 +123,18 @@ module.exports = { 300: convertToRGB("--color-border-300"), 400: convertToRGB("--color-border-400"), 1000: "rgb(0, 0, 0)", + primary: convertToRGB("--color-border-primary"), + error: convertToRGB("--color-border-error"), DEFAULT: convertToRGB("--color-border-200"), }, + error: { + 10: convertToRGB("--color-error-10"), + 20: convertToRGB("--color-error-20"), + 30: convertToRGB("--color-error-30"), + 100: convertToRGB("--color-error-100"), + 200: convertToRGB("--color-error-200"), + 500: convertToRGB("--color-error-500"), + }, sidebar: { background: { 0: "rgb(255, 255, 255)", diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index ebe537138..0f2bb8af2 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -217,6 +217,8 @@ export interface IGroupByColumn { name: string; icon: ReactElement | undefined; payload: Partial; + isDropDisabled?: boolean; + dropErrorMessage?: string; } export interface IIssueMap { diff --git a/web/components/core/render-if-visible-HOC.tsx b/web/components/core/render-if-visible-HOC.tsx index dcb9b5cf0..18e011b30 100644 --- a/web/components/core/render-if-visible-HOC.tsx +++ b/web/components/core/render-if-visible-HOC.tsx @@ -12,7 +12,6 @@ type Props = { alwaysRender?: boolean; placeholderChildren?: ReactNode; pauseHeightUpdateWhileRendering?: boolean; - changingReference?: any; }; const RenderIfVisible: React.FC = (props) => { @@ -27,7 +26,6 @@ const RenderIfVisible: React.FC = (props) => { alwaysRender = false, //render the children even if it is not visible in root placeholderChildren = null, //placeholder children pauseHeightUpdateWhileRendering = false, //while this is true the height of the blocks are maintained - changingReference, //This is to force render when this reference is changed } = props; const [shouldVisible, setShouldVisible] = useState(alwaysRender); const placeholderHeight = useRef(defaultHeight); @@ -63,7 +61,7 @@ const RenderIfVisible: React.FC = (props) => { } }; } - }, [intersectionRef, children, changingReference, root, verticalOffset, horizontalOffset]); + }, [intersectionRef, children, root, verticalOffset, horizontalOffset]); //Set height after render useEffect(() => { diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 76455c103..1954f8a67 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -4,21 +4,25 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -// hooks +import { TIssue } from "@plane/types"; import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; import { DeleteIssueModal } from "@/components/issues"; import { ISSUE_DELETED } from "@/constants/event-tracker"; import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; +// hooks import { useEventTracker, useIssueDetail, useIssues, useKanbanView, useUser } from "@/hooks/store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; +// store +import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/issue-helper.store"; // ui // types import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types"; //components +import { GroupDropLocation, handleGroupDragDrop, getSourceFromDropPayload } from "../utils"; import { KanBan } from "./default"; import { KanBanSwimLanes } from "./swimlanes"; -import { KanbanDropLocation, handleDragDrop, getSourceFromDropPayload } from "./utils"; + export type KanbanStoreType = | EIssuesStoreType.PROJECT @@ -61,6 +65,12 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas } = useIssueDetail(); const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue, updateFilters } = useIssuesActions(storeType); + const { + issues: { addCycleToIssue, removeCycleFromIssue }, + } = useIssues(EIssuesStoreType.CYCLE); + const { + issues: { changeModulesInIssue }, + } = useIssues(EIssuesStoreType.MODULE); const deleteAreaRef = useRef(null); const [isDragOverDelete, setIsDragOverDelete] = useState(false); @@ -143,7 +153,60 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas ); }, [deleteAreaRef?.current, setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]); - const handleOnDrop = async (source: KanbanDropLocation, destination: KanbanDropLocation) => { + /** + * update Issue on Drop, checks if modules or cycles are changed and then calls appropriate functions + * @param projectId + * @param issueId + * @param data + * @param issueUpdates + */ + const updateIssueOnDrop = async ( + projectId: string, + issueId: string, + data: Partial, + issueUpdates: { + [groupKey: string]: { + ADD: string[]; + REMOVE: string[]; + }; + } + ) => { + + const errorToastProps = { + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Error while updating issue" + } + const moduleKey = ISSUE_FILTER_DEFAULT_DATA["module"]; + const cycleKey = ISSUE_FILTER_DEFAULT_DATA["cycle"]; + + const isModuleChanged = Object.keys(data).includes(moduleKey); + const isCycleChanged = Object.keys(data).includes(cycleKey); + + if (isCycleChanged && workspaceSlug) { + if(data[cycleKey]) { + addCycleToIssue(workspaceSlug.toString(), projectId, data[cycleKey], issueId).catch(() => setToast(errorToastProps)); + } else { + removeCycleFromIssue(workspaceSlug.toString(), projectId, issueId).catch(() => setToast(errorToastProps)) + } + delete data[cycleKey]; + } + + if (isModuleChanged && workspaceSlug && issueUpdates[moduleKey]) { + changeModulesInIssue( + workspaceSlug.toString(), + projectId, + issueId, + issueUpdates[moduleKey].ADD, + issueUpdates[moduleKey].REMOVE + ).catch(() => setToast(errorToastProps)); + delete data[moduleKey]; + } + + updateIssue && updateIssue(projectId, issueId, data).catch(() => setToast(errorToastProps)); + }; + + const handleOnDrop = async (source: GroupDropLocation, destination: GroupDropLocation) => { if ( source.columnId && destination.columnId && @@ -152,12 +215,12 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas ) return; - await handleDragDrop( + await handleGroupDragDrop( source, destination, getIssueById, issues.getIssueIds, - updateIssue, + updateIssueOnDrop, group_by, sub_group_by, orderBy !== "sort_order" @@ -207,8 +270,11 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas const handleKanbanFilters = (toggle: "group_by" | "sub_group_by", value: string) => { if (workspaceSlug && projectId) { let kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; - if (kanbanFilters.includes(value)) kanbanFilters = kanbanFilters.filter((_value) => _value != value); - else kanbanFilters.push(value); + if (kanbanFilters.includes(value)) { + kanbanFilters = kanbanFilters.filter((_value) => _value != value); + } else { + kanbanFilters.push(value); + } updateFilters(projectId.toString(), EIssueFilterType.KANBAN_FILTERS, { [toggle]: kanbanFilters, }); diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 4890787ad..0bdbab112 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -16,12 +16,15 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; import { TRenderQuickActions } from "../list/list-view-types"; import { IssueProperties } from "../properties/all-properties"; import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { getIssueBlockId } from "../utils"; // ui // types // helper interface IssueBlockProps { issueId: string; + groupId: string; + subGroupId: string; issuesMap: IIssueMap; displayProperties: IIssueDisplayProperties | undefined; isDragDisabled: boolean; @@ -30,7 +33,6 @@ interface IssueBlockProps { quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; - issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization } interface IssueDetailsBlockProps { @@ -99,6 +101,8 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop export const KanbanIssueBlock: React.FC = observer((props) => { const { issueId, + groupId, + subGroupId, issuesMap, displayProperties, isDragDisabled, @@ -106,7 +110,6 @@ export const KanbanIssueBlock: React.FC = observer((props) => { quickActions, canEditProperties, scrollableContainerRef, - issueIds, } = props; const cardRef = useRef(null); @@ -194,7 +197,7 @@ export const KanbanIssueBlock: React.FC = observer((props) => { }} > = observer((props) => { root={scrollableContainerRef} defaultHeight="100px" horizontalOffset={50} - changingReference={issueIds} > = (props) => { const { sub_group_id, - columnId, + groupId, issuesMap, issueIds, displayProperties, @@ -40,13 +40,15 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { if (!issueId) return null; let draggableId = issueId; - if (columnId) draggableId = `${draggableId}__${columnId}`; + if (groupId) draggableId = `${draggableId}__${groupId}`; if (sub_group_id) draggableId = `${draggableId}__${sub_group_id}`; return ( = (props) => { isDragDisabled={isDragDisabled} canEditProperties={canEditProperties} scrollableContainerRef={scrollableContainerRef} - issueIds={issueIds} //passing to force render for virtualization whenever parent rerenders /> ); })} diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index b32f6bddba..d0c2839d1 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -19,12 +19,11 @@ import { useCycle, useKanbanView, useLabel, useMember, useModule, useProject, us // types // parent components import { TRenderQuickActions } from "../list/list-view-types"; -import { getGroupByColumns, isWorkspaceLevel } from "../utils"; +import { getGroupByColumns, isWorkspaceLevel, GroupDropLocation } from "../utils"; // components import { KanbanStoreType } from "./base-kanban-root"; import { HeaderGroupByCard } from "./headers/group-by-card"; import { KanbanGroup } from "./kanban-group"; -import { KanbanDropLocation } from "./utils"; export interface IGroupByKanBan { issuesMap: IIssueMap; @@ -52,7 +51,7 @@ export interface IGroupByKanBan { addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; - handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise; + handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise; showEmptyGroup?: boolean; subGroupIssueHeaderCount?: (listId: string) => number; } @@ -176,6 +175,8 @@ const GroupByKanBan: React.FC = observer((props) => { orderBy={orderBy} sub_group_id={sub_group_id} isDragDisabled={isDragDisabled} + isDropDisabled={!!subList.isDropDisabled} + dropErrorMessage={subList.dropErrorMessage} updateIssue={updateIssue} quickActions={quickActions} enableQuickIssueCreate={enableQuickIssueCreate} @@ -220,7 +221,7 @@ export interface IKanBan { addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; - handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise; + handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise; subGroupIssueHeaderCount?: (listId: string) => number; } diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index 87f0c2f3f..8704cc879 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -21,7 +21,7 @@ import { cn } from "@/helpers/common.helper"; import { useProjectState } from "@/hooks/store"; //components import { TRenderQuickActions } from "../list/list-view-types"; -import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils"; +import { GroupDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload, getIssueBlockId } from "../utils"; import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; interface IKanbanGroup { @@ -33,6 +33,8 @@ interface IKanbanGroup { group_by: TIssueGroupByOptions | undefined; sub_group_id: string; isDragDisabled: boolean; + isDropDisabled: boolean; + dropErrorMessage: string | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; enableQuickIssueCreate?: boolean; @@ -47,7 +49,7 @@ interface IKanbanGroup { canEditProperties: (projectId: string | undefined) => boolean; groupByVisibilityToggle?: boolean; scrollableContainerRef?: MutableRefObject; - handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise; + handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise; orderBy: TIssueOrderByOptions | undefined; } @@ -62,6 +64,8 @@ export const KanbanGroup = (props: IKanbanGroup) => { displayProperties, issueIds, isDragDisabled, + isDropDisabled, + dropErrorMessage, updateIssue, quickActions, canEditProperties, @@ -103,18 +107,21 @@ export const KanbanGroup = (props: IKanbanGroup) => { const source = getSourceFromDropPayload(payload); const destination = getDestinationFromDropPayload(payload); - if (!source || !destination) return; + if (!source || !destination || isDropDisabled) return; handleOnDrop(source, destination); - highlightIssueOnDrop(payload.source.element.id, orderBy !== "sort_order"); + highlightIssueOnDrop( + getIssueBlockId(source.id, destination?.groupId, destination?.subGroupId), + orderBy !== "sort_order" + ); }, }), autoScrollForElements({ element, }) ); - }, [columnRef?.current, groupId, sub_group_id, setIsDraggingOverColumn, orderBy]); + }, [columnRef?.current, groupId, sub_group_id, setIsDraggingOverColumn, orderBy, isDropDisabled, handleOnDrop]); const prePopulateQuickAddData = ( groupByKey: string | undefined, @@ -168,7 +175,7 @@ export const KanbanGroup = (props: IKanbanGroup) => { return preloadedData; }; - const shouldOverlay = isDraggingOverColumn && orderBy !== "sort_order"; + const shouldOverlay = isDraggingOverColumn && (orderBy !== "sort_order" || isDropDisabled); const readableOrderBy = ISSUE_ORDER_BY_OPTIONS.find((orderByObj) => orderByObj.key === orderBy)?.title; return ( @@ -184,20 +191,38 @@ export const KanbanGroup = (props: IKanbanGroup) => {
- {readableOrderBy && The layout is ordered by {readableOrderBy}.} - Drop here to move the issue. +
+ {dropErrorMessage ? ( + {dropErrorMessage} + ) : ( + <> + {readableOrderBy && The layout is ordered by {readableOrderBy}.} + Drop here to move the issue. + + )} +
void; - handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise; + handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise; disableIssueCreation?: boolean; storeType: KanbanStoreType; enableQuickIssueCreate: boolean; @@ -244,7 +243,7 @@ export interface IKanBanSwimLanes { kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; showEmptyGroup: boolean; - handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise; + handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise; disableIssueCreation?: boolean; storeType: KanbanStoreType; addIssuesToView?: (issueIds: string[]) => Promise; diff --git a/web/components/issues/issue-layouts/kanban/utils.ts b/web/components/issues/issue-layouts/kanban/utils.ts deleted file mode 100644 index ece8d77a3..000000000 --- a/web/components/issues/issue-layouts/kanban/utils.ts +++ /dev/null @@ -1,213 +0,0 @@ -import pull from "lodash/pull"; -import { IPragmaticDropPayload, TIssue, TIssueGroupByOptions } from "@plane/types"; -import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/issue-helper.store"; - -export type KanbanDropLocation = { - columnId: string; - groupId: string; - subGroupId?: string; - id: string | undefined; -}; - -/** - * get Kanban Source data from Pragmatic Payload - * @param payload - * @returns - */ -export const getSourceFromDropPayload = (payload: IPragmaticDropPayload): KanbanDropLocation | undefined => { - const { location, source: sourceIssue } = payload; - - const sourceIssueData = sourceIssue.data; - let sourceColumData; - - const sourceDropTargets = location?.initial?.dropTargets ?? []; - for (const dropTarget of sourceDropTargets) { - const dropTargetData = dropTarget?.data; - - if (!dropTargetData) continue; - - if (dropTargetData.type === "COLUMN") { - sourceColumData = dropTargetData; - } - } - - if (sourceIssueData?.id === undefined || !sourceColumData?.groupId) return; - - return { - groupId: sourceColumData.groupId as string, - subGroupId: sourceColumData.subGroupId as string, - columnId: sourceColumData.columnId as string, - id: sourceIssueData.id as string, - }; -}; - -/** - * get Destination Source data from Pragmatic Payload - * @param payload - * @returns - */ -export const getDestinationFromDropPayload = (payload: IPragmaticDropPayload): KanbanDropLocation | undefined => { - const { location } = payload; - - let destinationIssueData, destinationColumnData; - - const destDropTargets = location?.current?.dropTargets ?? []; - - for (const dropTarget of destDropTargets) { - const dropTargetData = dropTarget?.data; - - if (!dropTargetData) continue; - - if (dropTargetData.type === "COLUMN" || dropTargetData.type === "DELETE") { - destinationColumnData = dropTargetData; - } - - if (dropTargetData.type === "ISSUE") { - destinationIssueData = dropTargetData; - } - } - - if (!destinationColumnData?.groupId) return; - - return { - groupId: destinationColumnData.groupId as string, - subGroupId: destinationColumnData.subGroupId as string, - columnId: destinationColumnData.columnId as string, - id: destinationIssueData?.id as string | undefined, - }; -}; - -/** - * Returns Sort order of the issue block at the position of drop - * @param destinationIssues - * @param destinationIssueId - * @param getIssueById - * @returns - */ -const handleSortOrder = ( - destinationIssues: string[], - destinationIssueId: string | undefined, - getIssueById: (issueId: string) => TIssue | undefined, - shouldAddIssueAtTop = false -) => { - const sortOrderDefaultValue = 65535; - let currentIssueState = {}; - - const destinationIndex = destinationIssueId - ? destinationIssues.indexOf(destinationIssueId) - : shouldAddIssueAtTop - ? 0 - : destinationIssues.length; - - if (destinationIssues && destinationIssues.length > 0) { - if (destinationIndex === 0) { - const destinationIssueId = destinationIssues[0]; - const destinationIssue = getIssueById(destinationIssueId); - if (!destinationIssue) return currentIssueState; - - currentIssueState = { - ...currentIssueState, - sort_order: destinationIssue.sort_order - sortOrderDefaultValue, - }; - } else if (destinationIndex === destinationIssues.length) { - const destinationIssueId = destinationIssues[destinationIssues.length - 1]; - const destinationIssue = getIssueById(destinationIssueId); - if (!destinationIssue) return currentIssueState; - - currentIssueState = { - ...currentIssueState, - sort_order: destinationIssue.sort_order + sortOrderDefaultValue, - }; - } else { - const destinationTopIssueId = destinationIssues[destinationIndex - 1]; - const destinationBottomIssueId = destinationIssues[destinationIndex]; - - const destinationTopIssue = getIssueById(destinationTopIssueId); - const destinationBottomIssue = getIssueById(destinationBottomIssueId); - if (!destinationTopIssue || !destinationBottomIssue) return currentIssueState; - - currentIssueState = { - ...currentIssueState, - sort_order: (destinationTopIssue.sort_order + destinationBottomIssue.sort_order) / 2, - }; - } - } else { - currentIssueState = { - ...currentIssueState, - sort_order: sortOrderDefaultValue, - }; - } - - return currentIssueState; -}; - -export const handleDragDrop = async ( - source: KanbanDropLocation, - destination: KanbanDropLocation, - getIssueById: (issueId: string) => TIssue | undefined, - getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined, - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined, - groupBy: TIssueGroupByOptions | undefined, - subGroupBy: TIssueGroupByOptions | undefined, - shouldAddIssueAtTop = false -) => { - if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return; - - let updatedIssue: Partial = {}; - const sourceIssues = getIssueIds(source.groupId, source.subGroupId); - const destinationIssues = getIssueIds(destination.groupId, destination.subGroupId); - - const sourceIssue = getIssueById(source.id); - - if (!sourceIssues || !destinationIssues || !sourceIssue) return; - - updatedIssue = { - id: sourceIssue.id, - project_id: sourceIssue.project_id, - }; - - // for both horizontal and vertical dnd - updatedIssue = { - ...updatedIssue, - ...handleSortOrder(destinationIssues, destination.id, getIssueById, shouldAddIssueAtTop), - }; - - if (source.groupId && destination.groupId && source.groupId !== destination.groupId) { - const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy]; - let groupValue = sourceIssue[groupKey]; - - if (Array.isArray(groupValue)) { - pull(groupValue, source.groupId); - groupValue.push(destination.groupId); - } else { - groupValue = destination.groupId; - } - - updatedIssue = { ...updatedIssue, [groupKey]: groupValue }; - } - - if (subGroupBy && source.subGroupId && destination.subGroupId && source.subGroupId !== destination.subGroupId) { - const subGroupKey = ISSUE_FILTER_DEFAULT_DATA[subGroupBy]; - let subGroupValue = sourceIssue[subGroupKey]; - - if (Array.isArray(subGroupValue)) { - pull(subGroupValue, source.subGroupId); - subGroupValue.push(destination.subGroupId); - } else { - subGroupValue = destination.subGroupId; - } - - updatedIssue = { ...updatedIssue, [subGroupKey]: subGroupValue }; - } - - if (updatedIssue) { - return ( - updateIssue && - (await updateIssue(sourceIssue.project_id, sourceIssue.id, { - ...updatedIssue, - id: sourceIssue.id, - project_id: sourceIssue.project_id, - })) - ); - } -}; diff --git a/web/components/issues/issue-layouts/list/block-root.tsx b/web/components/issues/issue-layouts/list/block-root.tsx index 04cce339e..42f1d58e3 100644 --- a/web/components/issues/issue-layouts/list/block-root.tsx +++ b/web/components/issues/issue-layouts/list/block-root.tsx @@ -50,7 +50,6 @@ export const IssueBlockRoot: FC = observer((props) => { defaultHeight="3rem" root={containerRef} classNames="relative border-b border-b-custom-border-200 last:border-b-transparent" - changingReference={issueIds} > = observer((props) => { const { labelMap } = useLabel(); const { captureIssueEvent } = useEventTracker(); const { - issues: { addModulesToIssue, removeModulesFromIssue }, + issues: { changeModulesInIssue }, } = useIssues(EIssuesStoreType.MODULE); const { - issues: { addIssueToCycle, removeIssueFromCycle }, + issues: { addCycleToIssue, removeCycleFromIssue }, } = useIssues(EIssuesStoreType.CYCLE); const { areEstimatesEnabledForCurrentProject } = useEstimate(); const { getStateById } = useProjectState(); @@ -69,22 +69,22 @@ export const IssueProperties: React.FC = observer((props) => { () => ({ addModulesToIssue: async (moduleIds: string[]) => { if (!workspaceSlug || !issue.project_id || !issue.id) return; - await addModulesToIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds); + await changeModulesInIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds, []); }, removeModulesFromIssue: async (moduleIds: string[]) => { if (!workspaceSlug || !issue.project_id || !issue.id) return; - await removeModulesFromIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds); + await changeModulesInIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, [], moduleIds); }, addIssueToCycle: async (cycleId: string) => { if (!workspaceSlug || !issue.project_id || !issue.id) return; - await addIssueToCycle?.(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]); + await addCycleToIssue?.(workspaceSlug.toString(), issue.project_id, cycleId, issue.id); }, - removeIssueFromCycle: async (cycleId: string) => { + removeIssueFromCycle: async () => { if (!workspaceSlug || !issue.project_id || !issue.id) return; - await removeIssueFromCycle?.(workspaceSlug.toString(), issue.project_id, cycleId, issue.id); + await removeCycleFromIssue?.(workspaceSlug.toString(), issue.project_id, issue.id); }, }), - [workspaceSlug, issue, addModulesToIssue, removeModulesFromIssue, addIssueToCycle, removeIssueFromCycle] + [workspaceSlug, issue, changeModulesInIssue, addCycleToIssue, removeCycleFromIssue] ); const handleState = (stateId: string) => { @@ -174,7 +174,7 @@ export const IssueProperties: React.FC = observer((props) => { (cycleId: string | null) => { if (!issue || issue.cycle_id === cycleId) return; if (cycleId) issueOperations.addIssueToCycle?.(cycleId); - else issueOperations.removeIssueFromCycle?.(issue.cycle_id ?? ""); + else issueOperations.removeIssueFromCycle?.(); captureIssueEvent({ eventName: ISSUE_UPDATED, diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx index a412c604f..8cb2f43fb 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx @@ -25,14 +25,14 @@ export const SpreadsheetCycleColumn: React.FC = observer((props) => { // hooks const { captureIssueEvent } = useEventTracker(); const { - issues: { addIssueToCycle, removeIssueFromCycle }, + issues: { addCycleToIssue, removeCycleFromIssue }, } = useIssues(EIssuesStoreType.CYCLE); const handleCycle = useCallback( async (cycleId: string | null) => { if (!workspaceSlug || !issue || issue.cycle_id === cycleId) return; - if (cycleId) await addIssueToCycle(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]); - else await removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, issue.cycle_id ?? "", issue.id); + if (cycleId) await addCycleToIssue(workspaceSlug.toString(), issue.project_id, cycleId, issue.id); + else await removeCycleFromIssue(workspaceSlug.toString(), issue.project_id, issue.id); captureIssueEvent({ eventName: "Issue updated", payload: { @@ -44,7 +44,7 @@ export const SpreadsheetCycleColumn: React.FC = observer((props) => { path: router.asPath, }); }, - [workspaceSlug, issue, addIssueToCycle, removeIssueFromCycle, captureIssueEvent, router.asPath] + [workspaceSlug, issue, addCycleToIssue, removeCycleFromIssue, captureIssueEvent, router.asPath] ); return ( diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx index fb742f703..efae44e84 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx @@ -26,7 +26,7 @@ export const SpreadsheetModuleColumn: React.FC = observer((props) => { // hooks const { captureIssueEvent } = useEventTracker(); const { - issues: { addModulesToIssue, removeModulesFromIssue }, + issues: { changeModulesInIssue }, } = useIssues(EIssuesStoreType.MODULE); const handleModule = useCallback( @@ -36,13 +36,11 @@ export const SpreadsheetModuleColumn: React.FC = observer((props) => { const updatedModuleIds = xor(issue.module_ids, moduleIds); const modulesToAdd: string[] = []; const modulesToRemove: string[] = []; - for (const moduleId of updatedModuleIds) + for (const moduleId of updatedModuleIds) { if (issue.module_ids.includes(moduleId)) modulesToRemove.push(moduleId); else modulesToAdd.push(moduleId); - if (modulesToAdd.length > 0) - addModulesToIssue(workspaceSlug.toString(), issue.project_id, issue.id, modulesToAdd); - if (modulesToRemove.length > 0) - removeModulesFromIssue(workspaceSlug.toString(), issue.project_id, issue.id, modulesToRemove); + } + changeModulesInIssue(workspaceSlug.toString(), issue.project_id, issue.id, modulesToAdd, modulesToRemove); captureIssueEvent({ eventName: "Issue updated", @@ -55,7 +53,7 @@ export const SpreadsheetModuleColumn: React.FC = observer((props) => { path: router.asPath, }); }, - [workspaceSlug, issue, addModulesToIssue, removeModulesFromIssue, captureIssueEvent, router.asPath] + [workspaceSlug, issue, changeModulesInIssue, captureIssueEvent, router.asPath] ); return ( diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index c58b82597..a72dbc955 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -66,7 +66,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => { defaultHeight="calc(2.75rem - 1px)" root={containerRef} placeholderChildren={} - changingReference={issueIds} > [EIssuesStoreType.PROFILE, EIssuesStoreType.GLOBAL].includes(type) ? true : false; @@ -96,11 +122,14 @@ const getCycleColumns = (projectStore: IProjectStore, cycleStore: ICycleStore): const cycle = getCycleById(cycleId); if (cycle) { const cycleStatus = cycle.status ? (cycle.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const isDropDisabled = cycleStatus === "completed"; cycles.push({ id: cycle.id, name: cycle.name, icon: , payload: { cycle_id: cycle.id }, + isDropDisabled, + dropErrorMessage: isDropDisabled ? "Issue cannot be moved to completed cycles" : undefined, }); } }); @@ -263,3 +292,226 @@ export const highlightIssueOnDrop = ( await scrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 }); }, 200); }; + +/** + * get Kanban Source data from Pragmatic Payload + * @param payload + * @returns + */ +export const getSourceFromDropPayload = (payload: IPragmaticDropPayload): GroupDropLocation | undefined => { + const { location, source: sourceIssue } = payload; + + const sourceIssueData = sourceIssue.data; + let sourceColumData; + + const sourceDropTargets = location?.initial?.dropTargets ?? []; + for (const dropTarget of sourceDropTargets) { + const dropTargetData = dropTarget?.data; + + if (!dropTargetData) continue; + + if (dropTargetData.type === "COLUMN") { + sourceColumData = dropTargetData; + } + } + + if (sourceIssueData?.id === undefined || !sourceColumData?.groupId) return; + + return { + groupId: sourceColumData.groupId as string, + subGroupId: sourceColumData.subGroupId as string, + columnId: sourceColumData.columnId as string, + id: sourceIssueData.id as string, + }; +}; + +/** + * get Destination Source data from Pragmatic Payload + * @param payload + * @returns + */ +export const getDestinationFromDropPayload = (payload: IPragmaticDropPayload): GroupDropLocation | undefined => { + const { location } = payload; + + let destinationIssueData, destinationColumnData; + + const destDropTargets = location?.current?.dropTargets ?? []; + + for (const dropTarget of destDropTargets) { + const dropTargetData = dropTarget?.data; + + if (!dropTargetData) continue; + + if (dropTargetData.type === "COLUMN" || dropTargetData.type === "DELETE") { + destinationColumnData = dropTargetData; + } + + if (dropTargetData.type === "ISSUE") { + destinationIssueData = dropTargetData; + } + } + + if (!destinationColumnData?.groupId) return; + + return { + groupId: destinationColumnData.groupId as string, + subGroupId: destinationColumnData.subGroupId as string, + columnId: destinationColumnData.columnId as string, + id: destinationIssueData?.id as string | undefined, + }; +}; + +/** + * Returns Sort order of the issue block at the position of drop + * @param destinationIssues + * @param destinationIssueId + * @param getIssueById + * @returns + */ +const handleSortOrder = ( + destinationIssues: string[], + destinationIssueId: string | undefined, + getIssueById: (issueId: string) => TIssue | undefined, + shouldAddIssueAtTop = false +) => { + const sortOrderDefaultValue = 65535; + let currentIssueState = {}; + + const destinationIndex = destinationIssueId + ? destinationIssues.indexOf(destinationIssueId) + : shouldAddIssueAtTop + ? 0 + : destinationIssues.length; + + if (destinationIssues && destinationIssues.length > 0) { + if (destinationIndex === 0) { + const destinationIssueId = destinationIssues[0]; + const destinationIssue = getIssueById(destinationIssueId); + if (!destinationIssue) return currentIssueState; + + currentIssueState = { + ...currentIssueState, + sort_order: destinationIssue.sort_order - sortOrderDefaultValue, + }; + } else if (destinationIndex === destinationIssues.length) { + const destinationIssueId = destinationIssues[destinationIssues.length - 1]; + const destinationIssue = getIssueById(destinationIssueId); + if (!destinationIssue) return currentIssueState; + + currentIssueState = { + ...currentIssueState, + sort_order: destinationIssue.sort_order + sortOrderDefaultValue, + }; + } else { + const destinationTopIssueId = destinationIssues[destinationIndex - 1]; + const destinationBottomIssueId = destinationIssues[destinationIndex]; + + const destinationTopIssue = getIssueById(destinationTopIssueId); + const destinationBottomIssue = getIssueById(destinationBottomIssueId); + if (!destinationTopIssue || !destinationBottomIssue) return currentIssueState; + + currentIssueState = { + ...currentIssueState, + sort_order: (destinationTopIssue.sort_order + destinationBottomIssue.sort_order) / 2, + }; + } + } else { + currentIssueState = { + ...currentIssueState, + sort_order: sortOrderDefaultValue, + }; + } + + return currentIssueState; +}; + +export const getIssueBlockId = ( + issueId: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined +) => `issue_${issueId}_${groupId}_${subGroupId}`; + +/** + * returns empty Array if groupId is None + * @param groupId + * @returns + */ +const getGroupId = (groupId: string) => { + if (groupId === "None") return []; + return [groupId]; +}; + +export const handleGroupDragDrop = async ( + source: GroupDropLocation, + destination: GroupDropLocation, + getIssueById: (issueId: string) => TIssue | undefined, + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined, + updateIssueOnDrop: (projectId: string, issueId: string, data: Partial, issueUpdates: IssueUpdates) => void, + groupBy: TIssueGroupByOptions | undefined, + subGroupBy: TIssueGroupByOptions | undefined, + shouldAddIssueAtTop = false +) => { + if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return; + + let updatedIssue: Partial = {}; + const issueUpdates: IssueUpdates = {}; + const destinationIssues = getIssueIds(destination.groupId, destination.subGroupId) ?? []; + + const sourceIssue = getIssueById(source.id); + + if (!sourceIssue) return; + + updatedIssue = { + id: sourceIssue.id, + project_id: sourceIssue.project_id, + }; + + // for both horizontal and vertical dnd + updatedIssue = { + ...updatedIssue, + ...handleSortOrder(destinationIssues, destination.id, getIssueById, shouldAddIssueAtTop), + }; + + // update updatedIssue values based on the source and destination groupIds + if (source.groupId && destination.groupId && source.groupId !== destination.groupId) { + const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy]; + let groupValue = clone(sourceIssue[groupKey]); + + // If groupValues is an array, remove source groupId and add destination groupId + if (Array.isArray(groupValue)) { + pull(groupValue, source.groupId); + if (destination.groupId !== "None") groupValue = uniq(concat(groupValue, [destination.groupId])); + } // else just update the groupValue based on destination groupId + else { + groupValue = destination.groupId === "None" ? null : destination.groupId; + } + + // keep track of updates on what was added and what was removed + issueUpdates[groupKey] = { ADD: getGroupId(destination.groupId), REMOVE: getGroupId(source.groupId) }; + updatedIssue = { ...updatedIssue, [groupKey]: groupValue }; + } + + // do the same for subgroup + // update updatedIssue values based on the source and destination subGroupIds + if (subGroupBy && source.subGroupId && destination.subGroupId && source.subGroupId !== destination.subGroupId) { + const subGroupKey = ISSUE_FILTER_DEFAULT_DATA[subGroupBy]; + let subGroupValue = clone(sourceIssue[subGroupKey]); + + // If subGroupValue is an array, remove source subGroupId and add destination subGroupId + if (Array.isArray(subGroupValue)) { + pull(subGroupValue, source.subGroupId); + if (destination.subGroupId !== "None") subGroupValue = uniq(concat(subGroupValue, [destination.subGroupId])); + } // else just update the subGroupValue based on destination subGroupId + else { + subGroupValue = destination.subGroupId === "None" ? null : destination.subGroupId; + } + + // keep track of updates on what was added and what was removed + issueUpdates[subGroupKey] = { ADD: getGroupId(destination.subGroupId), REMOVE: getGroupId(source.subGroupId) }; + updatedIssue = { ...updatedIssue, [subGroupKey]: subGroupValue }; + } + + if (updatedIssue) { + return await updateIssueOnDrop(sourceIssue.project_id, sourceIssue.id, updatedIssue, issueUpdates); + } +}; diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index c799ef74e..a3171bd88 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -121,7 +121,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => { if (!workspaceSlug || !activeProjectId) return; - await moduleIssues.addModulesToIssue(workspaceSlug, activeProjectId, issue.id, moduleIds); + await moduleIssues.changeModulesInIssue(workspaceSlug, activeProjectId, issue.id, moduleIds, []); moduleIds.forEach((moduleId) => fetchModuleDetails(workspaceSlug, activeProjectId, moduleId)); }; diff --git a/web/store/issue/cycle/issue.store.ts b/web/store/issue/cycle/issue.store.ts index 6524311a1..7a264dad0 100644 --- a/web/store/issue/cycle/issue.store.ts +++ b/web/store/issue/cycle/issue.store.ts @@ -60,6 +60,7 @@ export interface ICycleIssues { ) => Promise; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; + removeCycleFromIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise transferIssuesFromCycle: ( workspaceSlug: string, projectId: string, @@ -273,7 +274,13 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { const response = await this.createIssue(workspaceSlug, projectId, data, cycleId); if (data.module_ids && data.module_ids.length > 0) - await this.rootStore.moduleIssues.addModulesToIssue(workspaceSlug, projectId, response.id, data.module_ids); + await this.rootStore.moduleIssues.changeModulesInIssue( + workspaceSlug, + projectId, + response.id, + data.module_ids, + [] + ); this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); @@ -327,6 +334,36 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { } }; + /** + * Remove a cycle from issue + * @param workspaceSlug + * @param projectId + * @param issueId + * @returns + */ + removeCycleFromIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { + const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id; + if(!issueCycleId) return; + try { + // perform optimistic update, update store + runInAction(() => { + pull(this.issues[issueCycleId], issueId); + }); + this.rootStore.issues.updateIssue(issueId, { cycle_id: null }); + + // make API call + await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, issueCycleId, issueId); + this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, issueCycleId); + } catch (error) { + // revert back changes if fails + runInAction(() => { + update(this.issues, issueCycleId, (cycleIssueIds = []) => uniq(concat(cycleIssueIds, [issueId]))); + }); + this.rootStore.issues.updateIssue(issueId, { cycle_id: issueCycleId }); + throw error; + } + }; + removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { try { runInAction(() => { diff --git a/web/store/issue/issue-details/issue.store.ts b/web/store/issue/issue-details/issue.store.ts index 370a34cd2..26408b485 100644 --- a/web/store/issue/issue-details/issue.store.ts +++ b/web/store/issue/issue-details/issue.store.ts @@ -197,11 +197,12 @@ export class IssueStore implements IIssueStore { }; addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { - const currentModule = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.addModulesToIssue( + const currentModule = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.changeModulesInIssue( workspaceSlug, projectId, issueId, - moduleIds + moduleIds, + [] ); if (moduleIds && moduleIds.length > 0) await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); @@ -209,10 +210,11 @@ export class IssueStore implements IIssueStore { }; removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { - const currentModule = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.removeModulesFromIssue( + const currentModule = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.changeModulesInIssue( workspaceSlug, projectId, issueId, + [], moduleIds ); await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); diff --git a/web/store/issue/issue_kanban_view.store.ts b/web/store/issue/issue_kanban_view.store.ts index 12f5306cb..aa4c2b4a3 100644 --- a/web/store/issue/issue_kanban_view.store.ts +++ b/web/store/issue/issue_kanban_view.store.ts @@ -22,6 +22,8 @@ export interface IIssueKanBanViewStore { setIsDragging: (isDragging: boolean) => void; } +const DRAG_ALLOWED_GROUPS: TIssueGroupByOptions[] = ["state", "priority", "assignees", "labels", "module", "cycle"]; + export class IssueKanBanViewStore implements IIssueKanBanViewStore { kanBanToggle: { groupByHeaderMinMax: string[]; @@ -53,9 +55,9 @@ export class IssueKanBanViewStore implements IIssueKanBanViewStore { getCanUserDragDrop = computedFn( (group_by: TIssueGroupByOptions | undefined, sub_group_by: TIssueGroupByOptions | undefined) => { - if (group_by && ["state", "priority"].includes(group_by)) { + if (group_by && DRAG_ALLOWED_GROUPS.includes(group_by)) { if (!sub_group_by) return true; - if (sub_group_by && ["state", "priority"].includes(sub_group_by)) return true; + if (sub_group_by && DRAG_ALLOWED_GROUPS.includes(sub_group_by)) return true; } return false; } diff --git a/web/store/issue/module/issue.store.ts b/web/store/issue/module/issue.store.ts index 02433bcba..e5c7b1cc1 100644 --- a/web/store/issue/module/issue.store.ts +++ b/web/store/issue/module/issue.store.ts @@ -1,5 +1,6 @@ import concat from "lodash/concat"; import pull from "lodash/pull"; +import isEmpty from "lodash/isEmpty"; import set from "lodash/set"; import uniq from "lodash/uniq"; import update from "lodash/update"; @@ -63,12 +64,12 @@ export interface IModuleIssues { moduleId: string, issueIds: string[] ) => Promise; - addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; - removeModulesFromIssue: ( + changeModulesInIssue: ( workspaceSlug: string, projectId: string, issueId: string, - moduleIds: string[] + addModuleIds: string[], + removeModuleIds: string[] ) => Promise; } @@ -104,8 +105,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { quickAddIssue: action, addIssuesToModule: action, removeIssuesFromModule: action, - addModulesToIssue: action, - removeModulesFromIssue: action, + changeModulesInIssue: action, }); this.rootIssueStore = _rootStore; @@ -368,67 +368,78 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { } }; - addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { + /** + * change modules array in issue + * @param workspaceSlug + * @param projectId + * @param issueId + * @param addModuleIds array of modules to be added + * @param removeModuleIds array of modules to be removed + */ + changeModulesInIssue = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + addModuleIds: string[], + removeModuleIds: string[] + ) => { // keep a copy of the original module ids - const originalModuleIds = this.rootStore.issues.issuesMap[issueId]?.module_ids ?? []; + const originalModuleIds = this.rootStore.issues.issuesMap[issueId]?.module_ids + ? [...this.rootStore.issues.issuesMap[issueId].module_ids!] + : []; try { runInAction(() => { - // add the new issue ids to the module issues map - moduleIds.forEach((moduleId) => { + // remove the new issue id to the module issues map + removeModuleIds.forEach((moduleId) => { + update(this.issues, moduleId, (moduleIssueIds = []) => { + if (moduleIssueIds.includes(issueId)) return pull(moduleIssueIds, issueId); + else return moduleIssueIds; + }); + }); + // add the new issue id to the module issues map + addModuleIds.forEach((moduleId) => { update(this.issues, moduleId, (moduleIssueIds = []) => { if (moduleIssueIds.includes(issueId)) return moduleIssueIds; else return uniq(concat(moduleIssueIds, [issueId])); }); }); + }); + if(originalModuleIds){ // update the root issue map with the new module ids - update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => - uniq(concat(issueModuleIds, moduleIds)) - ); - }); + let currentModuleIds = concat([...originalModuleIds], addModuleIds); + currentModuleIds = pull(currentModuleIds, ...removeModuleIds); + this.rootStore.issues.updateIssue(issueId, { module_ids: uniq(currentModuleIds) }); + } - const issueToModule = await this.moduleService.addModulesToIssue(workspaceSlug, projectId, issueId, { - modules: moduleIds, - }); + //Perform API calls + if (!isEmpty(addModuleIds)) { + await this.moduleService.addModulesToIssue(workspaceSlug, projectId, issueId, { + modules: addModuleIds, + }); + } + if (!isEmpty(removeModuleIds)) { + await this.moduleService.removeModulesFromIssueBulk(workspaceSlug, projectId, issueId, removeModuleIds); + } - return issueToModule; } catch (error) { // revert the issue back to its original module ids set(this.rootStore.issues.issuesMap, [issueId, "module_ids"], originalModuleIds); - // remove the new issue ids from the module issues map - moduleIds.forEach((moduleId) => { - runInAction(() => { - update(this.issues, moduleId, (moduleIssueIds = []) => pull(moduleIssueIds, issueId)); + // add the removed issue id to the module issues map + addModuleIds.forEach((moduleId) => { + update(this.issues, moduleId, (moduleIssueIds = []) => { + if (moduleIssueIds.includes(issueId)) return pull(moduleIssueIds, issueId); + else return moduleIssueIds; + }); + }); + // remove the added issue id to the module issues map + removeModuleIds.forEach((moduleId) => { + update(this.issues, moduleId, (moduleIssueIds = []) => { + if (moduleIssueIds.includes(issueId)) return moduleIssueIds; + else return uniq(concat(moduleIssueIds, [issueId])); }); }); throw error; } }; - - removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { - try { - runInAction(() => { - moduleIds.forEach((moduleId) => { - update(this.issues, moduleId, (moduleIssueIds = []) => { - if (moduleIssueIds.includes(issueId)) return pull(moduleIssueIds, issueId); - else return uniq(concat(moduleIssueIds, [issueId])); - }); - update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => - pull(issueModuleIds, moduleId) - ); - }); - }); - - const response = await this.moduleService.removeModulesFromIssueBulk( - workspaceSlug, - projectId, - issueId, - moduleIds - ); - - return response; - } catch (error) { - throw error; - } - }; } diff --git a/web/store/issue/project-views/issue.store.ts b/web/store/issue/project-views/issue.store.ts index d5de0e400..ce2c12654 100644 --- a/web/store/issue/project-views/issue.store.ts +++ b/web/store/issue/project-views/issue.store.ts @@ -243,7 +243,13 @@ export class ProjectViewIssues extends IssueHelperStore implements IProjectViewI await this.rootStore.cycleIssues.addIssueToCycle(workspaceSlug, projectId, data.cycle_id, [response.id]); if (data.module_ids && data.module_ids.length > 0) - await this.rootStore.moduleIssues.addModulesToIssue(workspaceSlug, projectId, response.id, data.module_ids); + await this.rootStore.moduleIssues.changeModulesInIssue( + workspaceSlug, + projectId, + response.id, + data.module_ids, + [] + ); const quickAddIssueIndex = this.issues[viewId].findIndex((_issueId) => _issueId === data.id); if (quickAddIssueIndex >= 0) diff --git a/web/store/issue/project/issue.store.ts b/web/store/issue/project/issue.store.ts index de6129359..6e137b9d8 100644 --- a/web/store/issue/project/issue.store.ts +++ b/web/store/issue/project/issue.store.ts @@ -227,7 +227,13 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { await this.rootStore.cycleIssues.addIssueToCycle(workspaceSlug, projectId, data.cycle_id, [response.id]); if (data.module_ids && data.module_ids.length > 0) - await this.rootStore.moduleIssues.addModulesToIssue(workspaceSlug, projectId, response.id, data.module_ids); + await this.rootStore.moduleIssues.changeModulesInIssue( + workspaceSlug, + projectId, + response.id, + data.module_ids, + [] + ); const quickAddIssueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === data.id); if (quickAddIssueIndex >= 0) diff --git a/web/styles/globals.css b/web/styles/globals.css index 09e3b9c08..8ca8351ad 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -43,15 +43,28 @@ --color-primary-800: 19, 35, 76; --color-primary-900: 13, 24, 51; + --color-error-10: 255 252 252; + --color-error-20: 255 247 247; + --color-error-30: 255, 219, 220; + --color-error-100: 244 170 170; + --color-error-200: 220 62 62; + --color-error-500: 140 51 58; + --color-background-100: 255, 255, 255; /* primary bg */ --color-background-90: 247, 247, 247; /* secondary bg */ --color-background-80: 232, 232, 232; /* tertiary bg */ + --color-background-primary: var(--color-primary-10); + --color-background-error: var(--color-error-20); + --color-text-100: 23, 23, 23; /* primary text */ --color-text-200: 58, 58, 58; /* secondary text */ --color-text-300: 82, 82, 82; /* tertiary text */ --color-text-400: 163, 163, 163; /* placeholder text */ + --color-text-primary: var(--color-primary-100); + --color-text-error: var(--color-error-200); + --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ --color-border-100: 245, 245, 245; /* subtle border= 1 */ @@ -59,6 +72,9 @@ --color-border-300: 212, 212, 212; /* strong border- 1 */ --color-border-400: 185, 185, 185; /* strong border- 2 */ + --color-border-primary: var(--color-primary-40); + --color-border-error: var(--color-error-100); + --color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.14); --color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), @@ -151,7 +167,7 @@ /* toast theme */ --color-toast-success-text: 62, 155, 79; - --color-toast-error-text: 220, 62, 66; + --color-toast-error-text: var(--color-error-200); --color-toast-warning-text: 255, 186, 24; --color-toast-info-text: 51, 88, 212; --color-toast-loading-text: 28, 32, 36; @@ -159,13 +175,13 @@ --color-toast-tertiary-text: 96, 100, 108; --color-toast-success-background: 253, 253, 254; - --color-toast-error-background: 255, 252, 252; + --color-toast-error-background: var(--color-error-10); --color-toast-warning-background: 254, 253, 251; --color-toast-info-background: 253, 253, 254; --color-toast-loading-background: 253, 253, 254; --color-toast-success-border: 218, 241, 219; - --color-toast-error-border: 255, 219, 220; + --color-toast-error-border: var(--color-error-30); --color-toast-warning-border: 255, 247, 194; --color-toast-info-border: 210, 222, 255; --color-toast-loading-border: 224, 225, 230; @@ -193,6 +209,15 @@ --color-background-90: 32, 32, 32; /* secondary bg */ --color-background-80: 44, 44, 44; /* tertiary bg */ + --color-background-primary: var(--color-background-90); + --color-background-error: var(--color-background-90); + + --color-text-primary: var(--color-primary-40); + --color-text-error: var(--color-error-100); + + --color-border-primary: var(--color-primary-200); + --color-border-error: var(--color-error-500); + --color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.5); --color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 2px 4px 0px rgba(0, 0, 0, 0.5); --color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2), 0px 2px 6px 0px rgba(0, 0, 0, 0.5);