[WEB-1249] feat: Kanban multi dragndrop (#4479)

* Kanban multi dnd

* complete Kanban multi dnd

* add proper brackets to if conditions
This commit is contained in:
rahulramesha 2024-05-16 17:29:01 +05:30 committed by GitHub
parent bab52a2672
commit 1ad7011aac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 565 additions and 333 deletions

View File

@ -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)",

View File

@ -217,6 +217,8 @@ export interface IGroupByColumn {
name: string;
icon: ReactElement | undefined;
payload: Partial<TIssue>;
isDropDisabled?: boolean;
dropErrorMessage?: string;
}
export interface IIssueMap {

View File

@ -12,7 +12,6 @@ type Props = {
alwaysRender?: boolean;
placeholderChildren?: ReactNode;
pauseHeightUpdateWhileRendering?: boolean;
changingReference?: any;
};
const RenderIfVisible: React.FC<Props> = (props) => {
@ -27,7 +26,6 @@ const RenderIfVisible: React.FC<Props> = (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<boolean>(alwaysRender);
const placeholderHeight = useRef<string>(defaultHeight);
@ -63,7 +61,7 @@ const RenderIfVisible: React.FC<Props> = (props) => {
}
};
}
}, [intersectionRef, children, changingReference, root, verticalOffset, horizontalOffset]);
}, [intersectionRef, children, root, verticalOffset, horizontalOffset]);
//Set height after render
useEffect(() => {

View File

@ -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<IBaseKanBanLayout> = 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<HTMLDivElement | null>(null);
const [isDragOverDelete, setIsDragOverDelete] = useState(false);
@ -143,7 +153,60 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = 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<TIssue>,
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<IBaseKanBanLayout> = 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<IBaseKanBanLayout> = 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,
});

View File

@ -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<HTMLDivElement | null>;
issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization
}
interface IssueDetailsBlockProps {
@ -99,6 +101,8 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
const {
issueId,
groupId,
subGroupId,
issuesMap,
displayProperties,
isDragDisabled,
@ -106,7 +110,6 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
quickActions,
canEditProperties,
scrollableContainerRef,
issueIds,
} = props;
const cardRef = useRef<HTMLAnchorElement | null>(null);
@ -194,7 +197,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
}}
>
<ControlLink
id={`issue-${issue.id}`}
id={getIssueBlockId(issueId, groupId, subGroupId)}
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.id
}`}
@ -214,7 +217,6 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
root={scrollableContainerRef}
defaultHeight="100px"
horizontalOffset={50}
changingReference={issueIds}
>
<KanbanIssueDetailsBlock
cardRef={cardRef}

View File

@ -7,7 +7,7 @@ import { TRenderQuickActions } from "../list/list-view-types";
interface IssueBlocksListProps {
sub_group_id: string;
columnId: string;
groupId: string;
issuesMap: IIssueMap;
issueIds: string[];
displayProperties: IIssueDisplayProperties | undefined;
@ -21,7 +21,7 @@ interface IssueBlocksListProps {
const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
const {
sub_group_id,
columnId,
groupId,
issuesMap,
issueIds,
displayProperties,
@ -40,13 +40,15 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (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 (
<KanbanIssueBlock
key={draggableId}
issueId={issueId}
groupId={groupId}
subGroupId={sub_group_id}
issuesMap={issuesMap}
displayProperties={displayProperties}
updateIssue={updateIssue}
@ -55,7 +57,6 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
isDragDisabled={isDragDisabled}
canEditProperties={canEditProperties}
scrollableContainerRef={scrollableContainerRef}
issueIds={issueIds} //passing to force render for virtualization whenever parent rerenders
/>
);
})}

View File

@ -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<TIssue>;
canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
showEmptyGroup?: boolean;
subGroupIssueHeaderCount?: (listId: string) => number;
}
@ -176,6 +175,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = 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<TIssue>;
canEditProperties: (projectId: string | undefined) => boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
subGroupIssueHeaderCount?: (listId: string) => number;
}

View File

@ -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<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
enableQuickIssueCreate?: boolean;
@ -47,7 +49,7 @@ interface IKanbanGroup {
canEditProperties: (projectId: string | undefined) => boolean;
groupByVisibilityToggle?: boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
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) => {
<div
//column overlay when issues are not sorted by manual
className={cn(
"absolute top-0 left-0 h-full w-full items-center text-sm font-medium text-custom-text-300 rounded",
"absolute top-0 left-0 h-full w-full items-center text-sm font-medium text-custom-text-300 rounded transparent",
{
"flex flex-col bg-custom-background-80 border-[1px] border-custom-border-300 z-[2]": shouldOverlay,
"flex flex-col border-[1px] border-custom-border-300 z-[2]": shouldOverlay,
},
{ hidden: !shouldOverlay },
{ "justify-center": !sub_group_by }
)}
>
{readableOrderBy && <span className="pt-6">The layout is ordered by {readableOrderBy}.</span>}
<div
className={cn(
"p-3 mt-6 flex flex-col border-[1px] rounded items-center",
{
"bg-custom-background-primary border-custom-border-primary text-custom-text-primary": shouldOverlay,
},
{
"bg-custom-background-error border-custom-border-error text-custom-text-error": isDropDisabled,
}
)}
>
{dropErrorMessage ? (
<span>{dropErrorMessage}</span>
) : (
<>
{readableOrderBy && <span>The layout is ordered by {readableOrderBy}.</span>}
<span>Drop here to move the issue.</span>
</>
)}
</div>
</div>
<KanbanIssueBlocksList
sub_group_id={sub_group_id}
columnId={groupId}
groupId={groupId}
issuesMap={issuesMap}
issueIds={(issueIds as TGroupedIssues)?.[groupId] || []}
displayProperties={displayProperties}

View File

@ -16,12 +16,11 @@ import {
// components
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
import { TRenderQuickActions } from "../list/list-view-types";
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
import { getGroupByColumns, isWorkspaceLevel, GroupDropLocation } from "../utils";
import { KanbanStoreType } from "./base-kanban-root";
import { KanBan } from "./default";
import { HeaderGroupByCard } from "./headers/group-by-card";
import { HeaderSubGroupByCard } from "./headers/sub-group-by-card";
import { KanbanDropLocation } from "./utils";
// types
// constants
@ -111,7 +110,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
quickActions: TRenderQuickActions;
kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
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<void>;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
disableIssueCreation?: boolean;
storeType: KanbanStoreType;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;

View File

@ -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<TIssue>) => Promise<void>) | undefined,
groupBy: TIssueGroupByOptions | undefined,
subGroupBy: TIssueGroupByOptions | undefined,
shouldAddIssueAtTop = false
) => {
if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return;
let updatedIssue: Partial<TIssue> = {};
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,
}))
);
}
};

View File

@ -50,7 +50,6 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
defaultHeight="3rem"
root={containerRef}
classNames="relative border-b border-b-custom-border-200 last:border-b-transparent"
changingReference={issueIds}
>
<IssueBlock
issueId={issueId}

View File

@ -49,10 +49,10 @@ export const IssueProperties: React.FC<IIssueProperties> = 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<IIssueProperties> = 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<IIssueProperties> = 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,

View File

@ -25,14 +25,14 @@ export const SpreadsheetCycleColumn: React.FC<Props> = 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<Props> = observer((props) => {
path: router.asPath,
});
},
[workspaceSlug, issue, addIssueToCycle, removeIssueFromCycle, captureIssueEvent, router.asPath]
[workspaceSlug, issue, addCycleToIssue, removeCycleFromIssue, captureIssueEvent, router.asPath]
);
return (

View File

@ -26,7 +26,7 @@ export const SpreadsheetModuleColumn: React.FC<Props> = 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<Props> = 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<Props> = observer((props) => {
path: router.asPath,
});
},
[workspaceSlug, issue, addModulesToIssue, removeModulesFromIssue, captureIssueEvent, router.asPath]
[workspaceSlug, issue, changeModulesInIssue, captureIssueEvent, router.asPath]
);
return (

View File

@ -66,7 +66,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
defaultHeight="calc(2.75rem - 1px)"
root={containerRef}
placeholderChildren={<td colSpan={100} className="border-b-[0.5px] border-custom-border-200" />}
changingReference={issueIds}
>
<IssueRowDetails
issueId={issueId}

View File

@ -1,6 +1,17 @@
import clone from "lodash/clone";
import concat from "lodash/concat";
import pull from "lodash/pull";
import uniq from "lodash/uniq";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import { ContrastIcon } from "lucide-react";
import { GroupByColumnTypes, IGroupByColumn, TCycleGroups } from "@plane/types";
import {
GroupByColumnTypes,
IGroupByColumn,
TCycleGroups,
IPragmaticDropPayload,
TIssue,
TIssueGroupByOptions,
} from "@plane/types";
import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui";
// components
import { ProjectLogo } from "@/components/project";
@ -8,6 +19,7 @@ import { ProjectLogo } from "@/components/project";
import { ISSUE_PRIORITIES, EIssuesStoreType } from "@/constants/issue";
import { STATE_GROUPS } from "@/constants/state";
import { ICycleStore } from "@/store/cycle.store";
import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/issue-helper.store";
import { ILabelStore } from "@/store/label.store";
import { IMemberRootStore } from "@/store/member";
import { IModuleStore } from "@/store/module.store";
@ -20,6 +32,20 @@ import { IStateStore } from "@/store/state.store";
export const HIGHLIGHT_CLASS = "highlight";
export const HIGHLIGHT_WITH_LINE = "highlight-with-line";
export type GroupDropLocation = {
columnId: string;
groupId: string;
subGroupId?: string;
id: string | undefined;
};
export type IssueUpdates = {
[groupKey: string]: {
ADD: string[];
REMOVE: string[];
};
};
export const isWorkspaceLevel = (type: EIssuesStoreType) =>
[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: <CycleGroupIcon cycleGroup={cycleStatus as TCycleGroups} className="h-3.5 w-3.5" />,
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<TIssue>, issueUpdates: IssueUpdates) => void,
groupBy: TIssueGroupByOptions | undefined,
subGroupBy: TIssueGroupByOptions | undefined,
shouldAddIssueAtTop = false
) => {
if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return;
let updatedIssue: Partial<TIssue> = {};
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);
}
};

View File

@ -121,7 +121,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = 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));
};

View File

@ -60,6 +60,7 @@ export interface ICycleIssues {
) => Promise<void>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
removeCycleFromIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>
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(() => {

View File

@ -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);

View File

@ -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;
}

View File

@ -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<void>;
addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>;
removeModulesFromIssue: (
changeModulesInIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleIds: string[]
addModuleIds: string[],
removeModuleIds: string[]
) => Promise<void>;
}
@ -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,66 +368,77 @@ 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));
});
});
throw error;
}
};
removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => {
try {
runInAction(() => {
moduleIds.forEach((moduleId) => {
// 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]));
});
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;
}
};

View File

@ -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)

View File

@ -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)

View File

@ -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);