[WEB-306] fix: Gantt chart bugs, refactor Gantt context (#3775)

* chore: initialize gantt layout store

* fix: modules being refetched on creation

* fix: scrollLeft calculation logic

* chore: modules list item dropdown position

* refactor: active block logic

* refactor: main content block component

* chore: remove unnecessary conditions for duration
This commit is contained in:
Aaryan Khandelwal 2024-02-23 19:10:45 +05:30 committed by GitHub
parent 46e1d46005
commit ba6479674c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 934 additions and 866 deletions

View File

@ -14,7 +14,7 @@ import {
IssueListItemProps,
} from "components/dashboard/widgets";
// ui
import { getButtonStyling } from "@plane/ui";
import { Loader, getButtonStyling } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
import { getRedirectionFilters } from "helpers/dashboard.helper";
@ -63,7 +63,12 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
<>
<div className="h-full">
{isLoading ? (
<></>
<Loader className="space-y-4 mx-6 mt-7">
<Loader.Item height="25px" />
<Loader.Item height="25px" />
<Loader.Item height="25px" />
<Loader.Item height="25px" />
</Loader>
) : issues.length > 0 ? (
<>
<div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1">

View File

@ -0,0 +1,106 @@
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "../hooks";
import { useIssueDetail } from "hooks/store";
// components
import { ChartAddBlock, ChartDraggable } from "../helpers";
// helpers
import { cn } from "helpers/common.helper";
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "../types";
// constants
import { BLOCK_HEIGHT } from "../constants";
type Props = {
block: IGanttBlock;
blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableBlockLeftResize: boolean;
enableBlockRightResize: boolean;
enableBlockMove: boolean;
enableAddBlock: boolean;
ganttContainerRef: React.RefObject<HTMLDivElement>;
};
export const GanttChartBlock: React.FC<Props> = observer((props) => {
const {
block,
blockToRender,
blockUpdateHandler,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
enableAddBlock,
ganttContainerRef,
} = props;
// store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart();
const { peekIssue } = useIssueDetail();
const isBlockVisibleOnChart = block.start_date && block.target_date;
const handleChartBlockPosition = (
block: IGanttBlock,
totalBlockShifts: number,
dragDirection: "left" | "right" | "move"
) => {
if (!block.start_date || !block.target_date) return;
const originalStartDate = new Date(block.start_date);
const updatedStartDate = new Date(originalStartDate);
const originalTargetDate = new Date(block.target_date);
const updatedTargetDate = new Date(originalTargetDate);
// update the start date on left resize
if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts);
// update the target date on right resize
else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
// update both the dates on x-axis move
else if (dragDirection === "move") {
updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts);
updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
}
// call the block update handler with the updated dates
blockUpdateHandler(block.data, {
start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined,
target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined,
});
};
return (
<div
key={`block-${block.id}`}
className="relative min-w-full w-max"
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
<div
className={cn("relative h-full", {
"bg-custom-background-80": isBlockActive(block.id),
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
peekIssue?.issueId === block.data.id,
})}
onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)}
>
{isBlockVisibleOnChart ? (
<ChartDraggable
block={block}
blockToRender={blockToRender}
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
ganttContainerRef={ganttContainerRef}
/>
) : (
enableAddBlock && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />
)}
</div>
</div>
);
});

View File

@ -1,16 +1,10 @@
import { observer } from "mobx-react";
import { FC } from "react";
// hooks
import { useIssueDetail } from "hooks/store";
import { useChart } from "../hooks";
// helpers
import { ChartAddBlock, ChartDraggable } from "components/gantt-chart";
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { cn } from "helpers/common.helper";
// components
import { GanttChartBlock } from "./block";
// types
import { IBlockUpdateData, IGanttBlock } from "../types";
// constants
import { BLOCK_HEIGHT, HEADER_HEIGHT } from "../constants";
import { HEADER_HEIGHT } from "../constants";
export type GanttChartBlocksProps = {
itemsContainerWidth: number;
@ -21,10 +15,11 @@ export type GanttChartBlocksProps = {
enableBlockRightResize: boolean;
enableBlockMove: boolean;
enableAddBlock: boolean;
ganttContainerRef: React.RefObject<HTMLDivElement>;
showAllBlocks: boolean;
};
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = observer((props) => {
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
const {
itemsContainerWidth,
blocks,
@ -34,52 +29,9 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = observer((props)
enableBlockRightResize,
enableBlockMove,
enableAddBlock,
ganttContainerRef,
showAllBlocks,
} = props;
// store hooks
const { peekIssue } = useIssueDetail();
// chart hook
const { activeBlock, dispatch } = useChart();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleChartBlockPosition = (
block: IGanttBlock,
totalBlockShifts: number,
dragDirection: "left" | "right" | "move"
) => {
if (!block.start_date || !block.target_date) return;
const originalStartDate = new Date(block.start_date);
const updatedStartDate = new Date(originalStartDate);
const originalTargetDate = new Date(block.target_date);
const updatedTargetDate = new Date(originalTargetDate);
// update the start date on left resize
if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts);
// update the target date on right resize
else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
// update both the dates on x-axis move
else if (dragDirection === "move") {
updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts);
updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
}
// call the block update handler with the updated dates
blockUpdateHandler(block.data, {
start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined,
target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined,
});
};
return (
<div
@ -93,41 +45,19 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = observer((props)
// hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!showAllBlocks && !(block.start_date && block.target_date)) return;
const isBlockVisibleOnChart = block.start_date && block.target_date;
return (
<div
key={`block-${block.id}`}
className="relative min-w-full w-max"
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
<div
className={cn("relative h-full", {
"bg-custom-background-80": activeBlock?.id === block.id,
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
peekIssue?.issueId === block.data.id,
})}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
>
{isBlockVisibleOnChart ? (
<ChartDraggable
block={block}
blockToRender={blockToRender}
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
/>
) : (
enableAddBlock && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />
)}
</div>
</div>
<GanttChartBlock
block={block}
blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableAddBlock={enableAddBlock}
ganttContainerRef={ganttContainerRef}
/>
);
})}
</div>
);
});
};

View File

@ -1,10 +1,13 @@
import { Expand, Shrink } from "lucide-react";
// hooks
import { useChart } from "../hooks";
// helpers
import { cn } from "helpers/common.helper";
// types
import { IGanttBlock, TGanttViews } from "../types";
// constants
import { VIEWS_LIST } from "components/gantt-chart/data";
import { useGanttChart } from "../hooks/use-gantt-chart";
import { observer } from "mobx-react";
type Props = {
blocks: IGanttBlock[] | null;
@ -16,10 +19,10 @@ type Props = {
toggleFullScreenMode: () => void;
};
export const GanttChartHeader: React.FC<Props> = (props) => {
export const GanttChartHeader: React.FC<Props> = observer((props) => {
const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, title, toggleFullScreenMode } = props;
// chart hook
const { currentView, allViews } = useChart();
const { currentView } = useGanttChart();
return (
<div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2 z-10">
@ -29,7 +32,7 @@ export const GanttChartHeader: React.FC<Props> = (props) => {
</div>
<div className="flex flex-wrap items-center gap-2">
{allViews?.map((chartView: any) => (
{VIEWS_LIST.map((chartView: any) => (
<div
key={chartView?.key}
className={cn("cursor-pointer rounded-sm p-1 px-2 text-xs", {
@ -56,4 +59,4 @@ export const GanttChartHeader: React.FC<Props> = (props) => {
</button>
</div>
);
};
});

View File

@ -1,3 +1,7 @@
import { useRef } from "react";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "../hooks/use-gantt-chart";
// components
import {
BiWeekChartView,
@ -12,7 +16,6 @@ import {
TGanttViews,
WeekChartView,
YearChartView,
useChart,
} from "components/gantt-chart";
// helpers
import { cn } from "helpers/common.helper";
@ -36,7 +39,7 @@ type Props = {
quickAdd?: React.JSX.Element | undefined;
};
export const GanttChartMainContent: React.FC<Props> = (props) => {
export const GanttChartMainContent: React.FC<Props> = observer((props) => {
const {
blocks,
blockToRender,
@ -55,13 +58,15 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
updateCurrentViewRenderPayload,
quickAdd,
} = props;
// refs
const ganttContainerRef = useRef<HTMLDivElement>(null);
// chart hook
const { currentView, currentViewData, updateScrollLeft } = useChart();
const { currentView, currentViewData } = useGanttChart();
// handling scroll functionality
const onScroll = (e: React.UIEvent<HTMLDivElement, UIEvent>) => {
const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget;
updateScrollLeft(scrollLeft);
// updateScrollLeft(scrollLeft);
const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth;
const approxRangeRight = scrollWidth - (scrollLeft + clientWidth);
@ -95,6 +100,7 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
"mb-8": bottomSpacing,
}
)}
ref={ganttContainerRef}
onScroll={onScroll}
>
<GanttChartSidebar
@ -105,7 +111,7 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
title={title}
quickAdd={quickAdd}
/>
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
<div className="relative h-full flex-shrink-0 flex-grow">
<ActiveChartView />
{currentViewData && (
<GanttChartBlocksList
@ -117,10 +123,11 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableAddBlock={enableAddBlock}
ganttContainerRef={ganttContainerRef}
showAllBlocks={showAllBlocks}
/>
)}
</div>
</div>
);
};
});

View File

@ -1,6 +1,9 @@
import { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "../hooks/use-gantt-chart";
// components
import { GanttChartHeader, useChart, GanttChartMainContent } from "components/gantt-chart";
import { GanttChartHeader, GanttChartMainContent } from "components/gantt-chart";
// views
import {
generateMonthChart,
@ -34,7 +37,7 @@ type ChartViewRootProps = {
quickAdd?: React.JSX.Element | undefined;
};
export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
const {
border,
title,
@ -57,7 +60,8 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
const [fullScreenMode, setFullScreenMode] = useState(false);
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
// hooks
const { currentView, currentViewData, renderView, dispatch } = useChart();
const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } =
useGanttChart();
// rendering the block structure
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
@ -87,36 +91,20 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
// updating the prevData, currentData and nextData
if (currentRender.payload.length > 0) {
updateCurrentViewData(currentRender.state);
if (side === "left") {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
currentView: selectedCurrentView,
currentViewData: currentRender.state,
renderView: [...currentRender.payload, ...renderView],
},
});
updateCurrentView(selectedCurrentView);
updateRenderView([...currentRender.payload, ...renderView]);
updatingCurrentLeftScrollPosition(currentRender.scrollWidth);
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
} else if (side === "right") {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
currentView: view,
currentViewData: currentRender.state,
renderView: [...renderView, ...currentRender.payload],
},
});
updateCurrentView(view);
updateRenderView([...renderView, ...currentRender.payload]);
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
} else {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
currentView: view,
currentViewData: currentRender.state,
renderView: [...currentRender.payload],
},
});
updateCurrentView(view);
updateRenderView(currentRender.payload);
setItemsContainerWidth(currentRender.scrollWidth);
setTimeout(() => {
handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate);
@ -206,4 +194,4 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
/>
</div>
);
};
});

View File

@ -1,10 +1,11 @@
import { FC } from "react";
// context
import { useChart } from "components/gantt-chart";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
export const BiWeekChartView: FC<any> = () => {
export const BiWeekChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
const { currentView, currentViewData, renderView } = useGanttChart();
return (
<>
@ -50,4 +51,4 @@ export const BiWeekChartView: FC<any> = () => {
</div>
</>
);
};
});

View File

@ -1,10 +1,11 @@
import { FC } from "react";
// context
import { useChart } from "../../hooks";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
export const DayChartView: FC<any> = () => {
export const DayChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
const { currentView, currentViewData, renderView } = useGanttChart();
return (
<>
@ -50,4 +51,4 @@ export const DayChartView: FC<any> = () => {
</div>
</>
);
};
});

View File

@ -1,10 +1,11 @@
import { FC } from "react";
// context
import { useChart } from "components/gantt-chart";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
export const HourChartView: FC<any> = () => {
export const HourChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
const { currentView, currentViewData, renderView } = useGanttChart();
return (
<>
@ -50,4 +51,4 @@ export const HourChartView: FC<any> = () => {
</div>
</>
);
};
});

View File

@ -1,6 +1,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useChart } from "components/gantt-chart";
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
// helpers
import { cn } from "helpers/common.helper";
// types
@ -8,9 +9,9 @@ import { IMonthBlock } from "../../views";
// constants
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "components/gantt-chart/constants";
export const MonthChartView: FC<any> = () => {
export const MonthChartView: FC<any> = observer(() => {
// chart hook
const { currentViewData, renderView } = useChart();
const { currentViewData, renderView } = useGanttChart();
const monthBlocks: IMonthBlock[] = renderView;
return (
@ -71,4 +72,4 @@ export const MonthChartView: FC<any> = () => {
))}
</div>
);
};
});

View File

@ -1,10 +1,11 @@
import { FC } from "react";
// context
import { useChart } from "../../hooks";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
export const QuarterChartView: FC<any> = () => {
export const QuarterChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
const { currentView, currentViewData, renderView } = useGanttChart();
return (
<>
@ -46,4 +47,4 @@ export const QuarterChartView: FC<any> = () => {
</div>
</>
);
};
});

View File

@ -1,10 +1,11 @@
import { FC } from "react";
// context
import { useChart } from "../../hooks";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
export const WeekChartView: FC<any> = () => {
export const WeekChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
const { currentView, currentViewData, renderView } = useGanttChart();
return (
<>
@ -50,4 +51,4 @@ export const WeekChartView: FC<any> = () => {
</div>
</>
);
};
});

View File

@ -1,10 +1,11 @@
import { FC } from "react";
// context
import { useChart } from "../../hooks";
import { observer } from "mobx-react";
// hooks
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
export const YearChartView: FC<any> = () => {
export const YearChartView: FC<any> = observer(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
const { currentView, currentViewData, renderView } = useGanttChart();
return (
<>
@ -46,4 +47,4 @@ export const YearChartView: FC<any> = () => {
</div>
</>
);
};
});

View File

@ -1,57 +1,19 @@
import React, { createContext, useState } from "react";
// types
import { ChartContextData, ChartContextActionPayload, ChartContextReducer } from "../types";
// data
import { allViewsWithData, currentViewDataWithView } from "../data";
import { createContext } from "react";
// mobx store
import { GanttStore } from "store/issue/issue_gantt_view.store";
export const ChartContext = createContext<ChartContextReducer | undefined>(undefined);
let ganttViewStore = new GanttStore();
const chartReducer = (state: ChartContextData, action: ChartContextActionPayload): ChartContextData => {
switch (action.type) {
case "CURRENT_VIEW":
return { ...state, currentView: action.payload };
case "CURRENT_VIEW_DATA":
return { ...state, currentViewData: action.payload };
case "RENDER_VIEW":
return { ...state, currentViewData: action.payload };
case "PARTIAL_UPDATE":
return { ...state, ...action.payload };
default:
return state;
}
export const GanttStoreContext = createContext<GanttStore>(ganttViewStore);
const initializeStore = () => {
const _ganttStore = ganttViewStore ?? new GanttStore();
if (typeof window === "undefined") return _ganttStore;
if (!ganttViewStore) ganttViewStore = _ganttStore;
return _ganttStore;
};
const initialView = "month";
export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// states;
const [state, dispatch] = useState<ChartContextData>({
currentView: initialView,
currentViewData: currentViewDataWithView(initialView),
renderView: [],
allViews: allViewsWithData,
activeBlock: null,
});
const [scrollLeft, setScrollLeft] = useState(0);
const handleDispatch = (action: ChartContextActionPayload): ChartContextData => {
const newState = chartReducer(state, action);
dispatch(() => newState);
return newState;
};
const updateScrollLeft = (scrollLeft: number) => setScrollLeft(scrollLeft);
return (
<ChartContext.Provider
value={{
...state,
scrollLeft,
updateScrollLeft,
dispatch: handleDispatch,
}}
>
{children}
</ChartContext.Provider>
);
export const GanttStoreProvider = ({ children }: any) => {
const store = initializeStore();
return <GanttStoreContext.Provider value={store}>{children}</GanttStoreContext.Provider>;
};

View File

@ -1,5 +1,5 @@
// types
import { WeekMonthDataType, ChartDataType } from "../types";
import { WeekMonthDataType, ChartDataType, TGanttViews } from "../types";
// constants
export const weeks: WeekMonthDataType[] = [
@ -53,7 +53,7 @@ export const datePreview = (date: Date, includeTime: boolean = false) => {
};
// context data
export const allViewsWithData: ChartDataType[] = [
export const VIEWS_LIST: ChartDataType[] = [
// {
// key: "hours",
// title: "Hours",
@ -133,7 +133,5 @@ export const allViewsWithData: ChartDataType[] = [
// },
];
export const currentViewDataWithView = (view: string = "month") => {
const currentView: ChartDataType | undefined = allViewsWithData.find((_viewData) => _viewData.key === view);
return currentView;
};
export const currentViewDataWithView = (view: TGanttViews = "month") =>
VIEWS_LIST.find((_viewData) => _viewData.key === view);

View File

@ -1,21 +1,21 @@
import { useEffect, useRef, useState } from "react";
import { addDays } from "date-fns";
import { Plus } from "lucide-react";
// hooks
import { useChart } from "../hooks";
// ui
import { Tooltip } from "@plane/ui";
// helpers
import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "../types";
import { useGanttChart } from "../hooks/use-gantt-chart";
import { observer } from "mobx-react";
type Props = {
block: IGanttBlock;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
};
export const ChartAddBlock: React.FC<Props> = (props) => {
export const ChartAddBlock: React.FC<Props> = observer((props) => {
const { block, blockUpdateHandler } = props;
// states
const [isButtonVisible, setIsButtonVisible] = useState(false);
@ -24,7 +24,7 @@ export const ChartAddBlock: React.FC<Props> = (props) => {
// refs
const containerRef = useRef<HTMLDivElement>(null);
// chart hook
const { currentViewData } = useChart();
const { currentViewData } = useGanttChart();
const handleButtonClick = () => {
if (!currentViewData) return;
@ -88,4 +88,4 @@ export const ChartAddBlock: React.FC<Props> = (props) => {
)}
</div>
);
};
});

View File

@ -1,12 +0,0 @@
// types
import { TIssue } from "@plane/types";
import { IGanttBlock } from "components/gantt-chart";
export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] =>
blocks?.map((block) => ({
data: block,
id: block.id,
sort_order: block.sort_order,
start_date: block.start_date ? new Date(block.start_date) : null,
target_date: block.target_date ? new Date(block.target_date) : null,
}));

View File

@ -1,11 +1,13 @@
import React, { useEffect, useRef, useState } from "react";
import { ArrowRight } from "lucide-react";
// hooks
import { IGanttBlock, useChart } from "components/gantt-chart";
import { IGanttBlock } from "components/gantt-chart";
// helpers
import { cn } from "helpers/common.helper";
// constants
import { SIDEBAR_WIDTH } from "../constants";
import { useGanttChart } from "../hooks/use-gantt-chart";
import { observer } from "mobx-react";
type Props = {
block: IGanttBlock;
@ -14,19 +16,29 @@ type Props = {
enableBlockLeftResize: boolean;
enableBlockRightResize: boolean;
enableBlockMove: boolean;
ganttContainerRef: React.RefObject<HTMLDivElement>;
};
export const ChartDraggable: React.FC<Props> = (props) => {
const { block, blockToRender, handleBlock, enableBlockLeftResize, enableBlockRightResize, enableBlockMove } = props;
export const ChartDraggable: React.FC<Props> = observer((props) => {
const {
block,
blockToRender,
handleBlock,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
ganttContainerRef,
} = props;
// states
const [isLeftResizing, setIsLeftResizing] = useState(false);
const [isRightResizing, setIsRightResizing] = useState(false);
const [isMoving, setIsMoving] = useState(false);
const [isHidden, setIsHidden] = useState(true);
const [scrollLeft, setScrollLeft] = useState(0);
// refs
const resizableRef = useRef<HTMLDivElement>(null);
// chart hook
const { currentViewData, scrollLeft } = useChart();
const { currentViewData } = useGanttChart();
// check if cursor reaches either end while resizing/dragging
const checkScrollEnd = (e: MouseEvent): number => {
const SCROLL_THRESHOLD = 70;
@ -212,6 +224,17 @@ export const ChartDraggable: React.FC<Props> = (props) => {
block.position?.width &&
scrollLeft > block.position.marginLeft + block.position.width;
useEffect(() => {
const ganttContainer = ganttContainerRef.current;
if (!ganttContainer) return;
const handleScroll = () => setScrollLeft(ganttContainer.scrollLeft);
ganttContainer.addEventListener("scroll", handleScroll);
return () => {
ganttContainer.removeEventListener("scroll", handleScroll);
};
}, [ganttContainerRef]);
useEffect(() => {
const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement;
const resizableBlock = resizableRef.current;
@ -234,7 +257,7 @@ export const ChartDraggable: React.FC<Props> = (props) => {
return () => {
observer.unobserve(resizableBlock);
};
}, [block.data.name]);
}, []);
return (
<>
@ -312,4 +335,4 @@ export const ChartDraggable: React.FC<Props> = (props) => {
</div>
</>
);
};
});

View File

@ -1,3 +1,2 @@
export * from "./add-block";
export * from "./block-structure";
export * from "./draggable";

View File

@ -0,0 +1 @@
export * from "./use-gantt-chart";

View File

@ -1,13 +0,0 @@
import { useContext } from "react";
// types
import { ChartContextReducer } from "../types";
// context
import { ChartContext } from "../contexts";
export const useChart = (): ChartContextReducer => {
const context = useContext(ChartContext);
if (!context) throw new Error("useChart must be used within a GanttChart");
return context;
};

View File

@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { GanttStoreContext } from "components/gantt-chart/contexts";
// types
import { IGanttStore } from "store/issue/issue_gantt_view.store";
export const useGanttChart = (): IGanttStore => {
const context = useContext(GanttStoreContext);
if (context === undefined) throw new Error("useGanttChart must be used within GanttStoreProvider");
return context;
};

View File

@ -3,5 +3,5 @@ export * from "./chart";
export * from "./helpers";
export * from "./hooks";
export * from "./root";
export * from "./types";
export * from "./sidebar";
export * from "./types";

View File

@ -2,7 +2,7 @@ import { FC } from "react";
// components
import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
// context
import { ChartContextProvider } from "./contexts";
import { GanttStoreProvider } from "components/gantt-chart/contexts";
type GanttChartRootProps = {
border?: boolean;
@ -42,7 +42,7 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
} = props;
return (
<ChartContextProvider>
<GanttStoreProvider>
<ChartViewRoot
border={border}
title={title}
@ -60,6 +60,6 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
showAllBlocks={showAllBlocks}
quickAdd={quickAdd}
/>
</ChartContextProvider>
</GanttStoreProvider>
);
};

View File

@ -1,158 +0,0 @@
import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd";
import { MoreVertical } from "lucide-react";
// hooks
import { useChart } from "components/gantt-chart/hooks";
// ui
import { Loader } from "@plane/ui";
// components
import { CycleGanttSidebarBlock } from "components/cycles";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
import { cn } from "helpers/common.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
// constants
import { BLOCK_HEIGHT } from "../constants";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
enableReorder: boolean;
};
export const CycleGanttSidebar: React.FC<Props> = (props) => {
const { blockUpdateHandler, blocks, enableReorder } = props;
// chart hook
const { activeBlock, dispatch } = useChart();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<Droppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
<>
{blocks ? (
blocks.map((block, index) => {
const duration = findTotalDaysInRange(block.start_date, block.target_date);
return (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<div
className={cn({
"rounded bg-custom-background-80": snapshot.isDragging,
})}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
"bg-custom-background-80": activeBlock?.id === block.id,
})}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
{enableReorder && (
<button
type="button"
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
</button>
)}
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
<div className="flex-grow truncate">
<CycleGanttSidebarBlock cycleId={block.data.id} />
</div>
{duration && (
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
)}
</div>
</div>
</div>
)}
</Draggable>
);
})
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</Droppable>
</DragDropContext>
);
};

View File

@ -0,0 +1,72 @@
import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
import { observer } from "mobx-react";
import { MoreVertical } from "lucide-react";
// hooks
import { useGanttChart } from "components/gantt-chart/hooks";
// components
import { CycleGanttSidebarBlock } from "components/cycles";
// helpers
import { cn } from "helpers/common.helper";
import { findTotalDaysInRange } from "helpers/date-time.helper";
// types
import { IGanttBlock } from "components/gantt-chart/types";
// constants
import { BLOCK_HEIGHT } from "components/gantt-chart/constants";
type Props = {
block: IGanttBlock;
enableReorder: boolean;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
};
export const CyclesSidebarBlock: React.FC<Props> = observer((props) => {
const { block, enableReorder, provided, snapshot } = props;
// store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart();
const duration = findTotalDaysInRange(block.start_date, block.target_date);
return (
<div
className={cn({
"rounded bg-custom-background-80": snapshot.isDragging,
})}
onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
"bg-custom-background-80": isBlockActive(block.id),
})}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
{enableReorder && (
<button
type="button"
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
</button>
)}
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
<div className="flex-grow truncate">
<CycleGanttSidebarBlock cycleId={block.data.id} />
</div>
{duration && (
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
)}
</div>
</div>
</div>
);
});

View File

@ -0,0 +1 @@
export * from "./sidebar";

View File

@ -0,0 +1,100 @@
import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd";
// ui
import { Loader } from "@plane/ui";
// components
import { CyclesSidebarBlock } from "./block";
// types
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
enableReorder: boolean;
};
export const CycleGanttSidebar: React.FC<Props> = (props) => {
const { blockUpdateHandler, blocks, enableReorder } = props;
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<Droppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
<>
{blocks ? (
blocks.map((block, index) => (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<CyclesSidebarBlock
block={block}
enableReorder={enableReorder}
provided={provided}
snapshot={snapshot}
/>
)}
</Draggable>
))
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</Droppable>
</DragDropContext>
);
};

View File

@ -1,173 +0,0 @@
import { observer } from "mobx-react";
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
import { MoreVertical } from "lucide-react";
// hooks
import { useChart } from "components/gantt-chart/hooks";
import { useIssueDetail } from "hooks/store";
// ui
import { Loader } from "@plane/ui";
// components
import { IssueGanttSidebarBlock } from "components/issues";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
import { cn } from "helpers/common.helper";
// types
import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types";
import { BLOCK_HEIGHT } from "../constants";
type Props = {
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
enableReorder: boolean;
showAllBlocks?: boolean;
};
export const IssueGanttSidebar: React.FC<Props> = observer((props: Props) => {
const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props;
const { activeBlock, dispatch } = useChart();
const { peekIssue } = useIssueDetail();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<>
<DragDropContext onDragEnd={handleOrderChange}>
<Droppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
<>
{blocks ? (
blocks.map((block, index) => {
const isBlockVisibleOnSidebar = block.start_date && block.target_date;
// hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!showAllBlocks && !isBlockVisibleOnSidebar) return;
const duration =
!block.start_date || !block.target_date
? null
: findTotalDaysInRange(block.start_date, block.target_date);
return (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<div
className={cn({
"rounded bg-custom-background-80": snapshot.isDragging,
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
peekIssue?.issueId === block.data.id,
})}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
"bg-custom-background-80": activeBlock?.id === block.id,
})}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
{enableReorder && (
<button
type="button"
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
</button>
)}
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
<div className="flex-grow truncate">
<IssueGanttSidebarBlock issueId={block.data.id} />
</div>
{duration && (
<div className="flex-shrink-0 text-sm text-custom-text-200">
<span>
{duration} day{duration > 1 ? "s" : ""}
</span>
</div>
)}
</div>
</div>
</div>
)}
</Draggable>
);
})
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</Droppable>
</DragDropContext>
</>
);
});

View File

@ -0,0 +1,77 @@
import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
import { observer } from "mobx-react";
import { MoreVertical } from "lucide-react";
// hooks
import { useIssueDetail } from "hooks/store";
import { useGanttChart } from "components/gantt-chart/hooks";
// components
import { IssueGanttSidebarBlock } from "components/issues";
// helpers
import { cn } from "helpers/common.helper";
import { findTotalDaysInRange } from "helpers/date-time.helper";
// types
import { IGanttBlock } from "../../types";
// constants
import { BLOCK_HEIGHT } from "../../constants";
type Props = {
block: IGanttBlock;
enableReorder: boolean;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
};
export const IssuesSidebarBlock: React.FC<Props> = observer((props) => {
const { block, enableReorder, provided, snapshot } = props;
// store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart();
const { peekIssue } = useIssueDetail();
const duration = findTotalDaysInRange(block.start_date, block.target_date);
return (
<div
className={cn({
"rounded bg-custom-background-80": snapshot.isDragging,
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
peekIssue?.issueId === block.data.id,
})}
onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
"bg-custom-background-80": isBlockActive(block.id),
})}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
{enableReorder && (
<button
type="button"
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
</button>
)}
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
<div className="flex-grow truncate">
<IssueGanttSidebarBlock issueId={block.data.id} />
</div>
{duration && (
<div className="flex-shrink-0 text-sm text-custom-text-200">
<span>
{duration} day{duration > 1 ? "s" : ""}
</span>
</div>
)}
</div>
</div>
</div>
);
});

View File

@ -0,0 +1 @@
export * from "./sidebar";

View File

@ -0,0 +1,107 @@
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
// components
import { IssuesSidebarBlock } from "./block";
// ui
import { Loader } from "@plane/ui";
// types
import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types";
type Props = {
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
enableReorder: boolean;
showAllBlocks?: boolean;
};
export const IssueGanttSidebar: React.FC<Props> = (props) => {
const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props;
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<Droppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
<>
{blocks ? (
blocks.map((block, index) => {
const isBlockVisibleOnSidebar = block.start_date && block.target_date;
// hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!showAllBlocks && !isBlockVisibleOnSidebar) return;
return (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<IssuesSidebarBlock
block={block}
enableReorder={enableReorder}
provided={provided}
snapshot={snapshot}
/>
)}
</Draggable>
);
})
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</Droppable>
</DragDropContext>
);
};

View File

@ -1,158 +0,0 @@
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
import { MoreVertical } from "lucide-react";
// hooks
import { useChart } from "components/gantt-chart/hooks";
// ui
import { Loader } from "@plane/ui";
// components
import { ModuleGanttSidebarBlock } from "components/modules";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
import { cn } from "helpers/common.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
// constants
import { BLOCK_HEIGHT } from "../constants";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
enableReorder: boolean;
};
export const ModuleGanttSidebar: React.FC<Props> = (props) => {
const { blockUpdateHandler, blocks, enableReorder } = props;
// chart hook
const { activeBlock, dispatch } = useChart();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<Droppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
<>
{blocks ? (
blocks.map((block, index) => {
const duration = findTotalDaysInRange(block.start_date, block.target_date);
return (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<div
className={cn({
"rounded bg-custom-background-80": snapshot.isDragging,
})}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
"bg-custom-background-80": activeBlock?.id === block.id,
})}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
{enableReorder && (
<button
type="button"
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
</button>
)}
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
<div className="flex-grow truncate">
<ModuleGanttSidebarBlock moduleId={block.data.id} />
</div>
{duration !== undefined && (
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
)}
</div>
</div>
</div>
)}
</Draggable>
);
})
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</Droppable>
</DragDropContext>
);
};

View File

@ -0,0 +1,72 @@
import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
import { observer } from "mobx-react";
import { MoreVertical } from "lucide-react";
// hooks
import { useGanttChart } from "components/gantt-chart/hooks";
// components
import { ModuleGanttSidebarBlock } from "components/modules";
// helpers
import { cn } from "helpers/common.helper";
import { findTotalDaysInRange } from "helpers/date-time.helper";
// types
import { IGanttBlock } from "components/gantt-chart/types";
// constants
import { BLOCK_HEIGHT } from "components/gantt-chart/constants";
type Props = {
block: IGanttBlock;
enableReorder: boolean;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
};
export const ModulesSidebarBlock: React.FC<Props> = observer((props) => {
const { block, enableReorder, provided, snapshot } = props;
// store hooks
const { updateActiveBlockId, isBlockActive } = useGanttChart();
const duration = findTotalDaysInRange(block.start_date, block.target_date);
return (
<div
className={cn({
"rounded bg-custom-background-80": snapshot.isDragging,
})}
onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
"bg-custom-background-80": isBlockActive(block.id),
})}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
{enableReorder && (
<button
type="button"
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
</button>
)}
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
<div className="flex-grow truncate">
<ModuleGanttSidebarBlock moduleId={block.data.id} />
</div>
{duration !== undefined && (
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
)}
</div>
</div>
</div>
);
});

View File

@ -0,0 +1 @@
export * from "./sidebar";

View File

@ -0,0 +1,100 @@
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
// ui
import { Loader } from "@plane/ui";
// components
import { ModulesSidebarBlock } from "./block";
// types
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
enableReorder: boolean;
};
export const ModuleGanttSidebar: React.FC<Props> = (props) => {
const { blockUpdateHandler, blocks, enableReorder } = props;
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<Droppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
<>
{blocks ? (
blocks.map((block, index) => (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<ModulesSidebarBlock
block={block}
enableReorder={enableReorder}
provided={provided}
snapshot={snapshot}
/>
)}
</Draggable>
))
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</Droppable>
</DragDropContext>
);
};

View File

@ -1,17 +1,10 @@
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
import { MoreVertical } from "lucide-react";
// hooks
import { useChart } from "components/gantt-chart/hooks";
// ui
import { Loader } from "@plane/ui";
// components
import { IssueGanttSidebarBlock } from "components/issues";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
import { IssuesSidebarBlock } from "./issues/block";
// types
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
// constants
import { BLOCK_HEIGHT } from "../constants";
type Props = {
title: string;
@ -23,18 +16,6 @@ type Props = {
export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
const { blockUpdateHandler, blocks, enableReorder } = props;
// chart hook
const { activeBlock, dispatch } = useChart();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
@ -89,59 +70,23 @@ export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
>
<>
{blocks ? (
blocks.map((block, index) => {
const duration = findTotalDaysInRange(block.start_date, block.target_date);
return (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<div
className={`${snapshot.isDragging ? "rounded bg-custom-background-80" : ""}`}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={`group flex h-full w-full items-center gap-2 rounded-l px-2 pr-4 ${
activeBlock?.id === block.id ? "bg-custom-background-80" : ""
}`}
>
{enableReorder && (
<button
type="button"
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
</button>
)}
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
<div className="flex-grow truncate">
<IssueGanttSidebarBlock issueId={block.data.id} />
</div>
{duration !== undefined && (
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
)}
</div>
</div>
</div>
)}
</Draggable>
);
})
blocks.map((block, index) => (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<IssuesSidebarBlock
block={block}
enableReorder={enableReorder}
provided={provided}
snapshot={snapshot}
/>
)}
</Draggable>
))
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />

View File

@ -1,10 +1,3 @@
// context types
export type allViewsType = {
key: string;
title: string;
data: Object | null;
};
export interface IGanttBlock {
data: any;
id: string;
@ -29,34 +22,6 @@ export interface IBlockUpdateData {
export type TGanttViews = "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year";
export interface ChartContextData {
allViews: allViewsType[];
currentView: TGanttViews;
currentViewData: ChartDataType | undefined;
renderView: any;
activeBlock: IGanttBlock | null;
}
export type ChartContextActionPayload =
| {
type: "CURRENT_VIEW";
payload: TGanttViews;
}
| {
type: "CURRENT_VIEW_DATA" | "RENDER_VIEW";
payload: ChartDataType | undefined;
}
| {
type: "PARTIAL_UPDATE";
payload: Partial<ChartContextData>;
};
export interface ChartContextReducer extends ChartContextData {
scrollLeft: number;
updateScrollLeft: (scrollLeft: number) => void;
dispatch: (action: ChartContextActionPayload) => void;
}
// chart render types
export interface WeekMonthDataType {
key: number;

View File

@ -5,12 +5,9 @@ import { observer } from "mobx-react-lite";
import { useIssues, useUser } from "hooks/store";
// components
import { GanttQuickAddIssueForm, IssueGanttBlock } from "components/issues";
import {
GanttChartRoot,
IBlockUpdateData,
renderIssueBlocksStructure,
IssueGanttSidebar,
} from "components/gantt-chart";
import { GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "components/gantt-chart";
// helpers
import { renderIssueBlocksStructure } from "helpers/issue.helper";
// types
import { TIssue, TUnGroupedIssues } from "@plane/types";
import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle";

View File

@ -235,7 +235,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
</button>
))}
<CustomMenu verticalEllipsis buttonClassName="z-[1]">
<CustomMenu verticalEllipsis buttonClassName="z-[1]" placement="bottom-end">
{isEditingAllowed && (
<>
<CustomMenu.MenuItem onClick={handleEditModule}>

View File

@ -3,6 +3,7 @@ import { v4 as uuidv4 } from "uuid";
import { orderArrayBy } from "helpers/array.helper";
// types
import { TIssue, TIssueGroupByOptions, TIssueLayouts, TIssueOrderByOptions, TIssueParams } from "@plane/types";
import { IGanttBlock } from "components/gantt-chart";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
@ -132,3 +133,12 @@ export const createIssuePayload: (projectId: string, formData: Partial<TIssue>)
return payload;
};
export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] =>
blocks?.map((block) => ({
data: block,
id: block.id,
sort_order: block.sort_order,
start_date: block.start_date ? new Date(block.start_date) : null,
target_date: block.target_date ? new Date(block.target_date) : null,
}));

View File

@ -0,0 +1,95 @@
import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// helpers
import { currentViewDataWithView } from "components/gantt-chart/data";
// types
import { ChartDataType, TGanttViews } from "components/gantt-chart";
export interface IGanttStore {
// observables
currentView: TGanttViews;
currentViewData: ChartDataType | undefined;
activeBlockId: string | null;
renderView: any;
// computed functions
isBlockActive: (blockId: string) => boolean;
// actions
updateCurrentView: (view: TGanttViews) => void;
updateCurrentViewData: (data: ChartDataType | undefined) => void;
updateActiveBlockId: (blockId: string | null) => void;
updateRenderView: (data: any[]) => void;
}
export class GanttStore implements IGanttStore {
// observables
currentView: TGanttViews = "month";
currentViewData: ChartDataType | undefined = undefined;
activeBlockId: string | null = null;
renderView: any[] = [];
constructor() {
makeObservable(this, {
// observables
currentView: observable.ref,
currentViewData: observable,
activeBlockId: observable.ref,
renderView: observable,
// actions
updateCurrentView: action.bound,
updateCurrentViewData: action.bound,
updateActiveBlockId: action.bound,
updateRenderView: action.bound,
});
this.initGantt();
}
/**
* @description check if block is active
* @param {string} blockId
*/
isBlockActive = computedFn((blockId: string): boolean => this.activeBlockId === blockId);
/**
* @description update current view
* @param {TGanttViews} view
*/
updateCurrentView = (view: TGanttViews) => {
this.currentView = view;
};
/**
* @description update current view data
* @param {ChartDataType | undefined} data
*/
updateCurrentViewData = (data: ChartDataType | undefined) => {
this.currentViewData = data;
};
/**
* @description update active block
* @param {string | null} block
*/
updateActiveBlockId = (blockId: string | null) => {
this.activeBlockId = blockId;
};
/**
* @description update render view
* @param {any[]} data
*/
updateRenderView = (data: any[]) => {
this.renderView = data;
};
/**
* @description initialize gantt chart with month view
*/
initGantt = () => {
const newCurrentViewData = currentViewDataWithView(this.currentView);
runInAction(() => {
this.currentViewData = newCurrentViewData;
});
};
}

View File

@ -194,7 +194,6 @@ export class ModulesStore implements IModuleStore {
runInAction(() => {
set(this.moduleMap, [response?.id], response);
});
this.fetchModules(workspaceSlug, projectId);
return response;
});