feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { ArrowRight } from "lucide-react";
// helpers
import type { IBlockUpdateData, IGanttBlock } from "@plane/types";
import { cn } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import type { TSelectionHelper } from "@/hooks/use-multiple-select";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { BLOCK_HEIGHT, SIDEBAR_WIDTH } from "../constants";
import { ChartAddBlock } from "../helpers";
type Props = {
blockId: string;
showAllBlocks: boolean;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
handleScrollToBlock: (block: IGanttBlock) => void;
enableAddBlock: boolean;
selectionHelpers: TSelectionHelper;
ganttContainerRef: React.RefObject<HTMLDivElement>;
};
export const BlockRow: React.FC<Props> = observer((props) => {
const { blockId, showAllBlocks, blockUpdateHandler, handleScrollToBlock, enableAddBlock, selectionHelpers } = props;
// states
const [isHidden, setIsHidden] = useState(false);
const [isBlockHiddenOnLeft, setIsBlockHiddenOnLeft] = useState(false);
// store hooks
const { getBlockById, updateActiveBlockId, isBlockActive } = useTimeLineChartStore();
const { getIsIssuePeeked } = useIssueDetail();
const block = getBlockById(blockId);
useEffect(() => {
const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement;
const timelineBlock = document.getElementById(`gantt-block-${block?.id}`);
if (!timelineBlock || !intersectionRoot) return;
setIsBlockHiddenOnLeft(
!!block.position?.marginLeft &&
!!block.position?.width &&
intersectionRoot.scrollLeft > block.position.marginLeft + block.position.width
);
// Observe if the block is visible on the chart
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setIsHidden(!entry.isIntersecting);
setIsBlockHiddenOnLeft(entry.boundingClientRect.right < (entry.rootBounds?.left ?? 0));
});
},
{
root: intersectionRoot,
rootMargin: `0px 0px 0px -${SIDEBAR_WIDTH}px`,
}
);
observer.observe(timelineBlock);
return () => {
observer.unobserve(timelineBlock);
};
}, [block]);
// hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!block || !block.data || (!showAllBlocks && !(block.start_date && block.target_date))) return null;
const isBlockVisibleOnChart = block.start_date || block.target_date;
const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id);
const isBlockFocused = selectionHelpers.getIsEntityActive(block.id);
const isBlockHoveredOn = isBlockActive(block.id);
return (
<div
className="relative min-w-full w-max"
onMouseEnter={() => updateActiveBlockId(blockId)}
onMouseLeave={() => updateActiveBlockId(null)}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
<div
className={cn("relative h-full", {
"rounded-l border border-r-0 border-custom-primary-70": getIsIssuePeeked(block.data.id),
"bg-custom-background-90": isBlockHoveredOn,
"bg-custom-primary-100/5 hover:bg-custom-primary-100/10": isBlockSelected,
"bg-custom-primary-100/10": isBlockSelected && isBlockHoveredOn,
"border border-r-0 border-custom-border-400": isBlockFocused,
})}
>
{isBlockVisibleOnChart
? isHidden && (
<button
type="button"
className="sticky z-[5] grid h-8 w-8 translate-y-1.5 cursor-pointer place-items-center rounded border border-custom-border-300 bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
style={{
left: `${SIDEBAR_WIDTH + 4}px`,
}}
onClick={() => handleScrollToBlock(block)}
>
<ArrowRight
className={cn("h-3.5 w-3.5", {
"rotate-180": isBlockHiddenOnLeft,
})}
/>
</button>
)
: enableAddBlock && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />}
</div>
</div>
);
});

View File

@@ -0,0 +1,105 @@
import type { RefObject } from "react";
import { useRef } from "react";
import { observer } from "mobx-react";
// components
import type { IBlockUpdateDependencyData } from "@plane/types";
import { cn } from "@plane/utils";
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
// helpers
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
// constants
import { BLOCK_HEIGHT } from "../constants";
// components
import { ChartDraggable } from "../helpers";
import { useGanttResizable } from "../helpers/blockResizables/use-gantt-resizable";
type Props = {
blockId: string;
showAllBlocks: boolean;
blockToRender: (data: any) => React.ReactNode;
enableBlockLeftResize: boolean;
enableBlockRightResize: boolean;
enableBlockMove: boolean;
enableDependency: boolean;
ganttContainerRef: RefObject<HTMLDivElement>;
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>;
};
export const GanttChartBlock: React.FC<Props> = observer((props) => {
const {
blockId,
showAllBlocks,
blockToRender,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
ganttContainerRef,
enableDependency,
updateBlockDates,
} = props;
// store hooks
const { updateActiveBlockId, getBlockById, getIsCurrentDependencyDragging, currentView } = useTimeLineChartStore();
// refs
const resizableRef = useRef<HTMLDivElement>(null);
const block = getBlockById(blockId);
const isCurrentDependencyDragging = getIsCurrentDependencyDragging(blockId);
const { isMoving, handleBlockDrag } = useGanttResizable(block, resizableRef, ganttContainerRef, updateBlockDates);
const isBlockVisibleOnChart = block?.start_date || block?.target_date;
const isBlockComplete = block?.start_date && block?.target_date;
// hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!block || (!showAllBlocks && !isBlockVisibleOnChart)) return null;
if (!block.data) return null;
return (
<div
className={cn("relative z-[5]", {
"transition-all": !!isMoving && currentView === "week",
"pointer-events-none": !isBlockVisibleOnChart,
})}
id={`gantt-block-${block.id}`}
ref={resizableRef}
style={{
height: `${BLOCK_HEIGHT}px`,
marginLeft: `${block.position?.marginLeft}px`,
width: `${block.position?.width}px`,
}}
>
{isBlockVisibleOnChart && (
<RenderIfVisible
root={ganttContainerRef}
horizontalOffset={100}
verticalOffset={200}
classNames="flex h-full w-full items-center"
placeholderChildren={<div className="h-8 w-full bg-custom-background-80 rounded" />}
shouldRecordHeights={false}
forceRender={isCurrentDependencyDragging}
>
<div
className={cn("relative h-full w-full")}
onMouseEnter={() => updateActiveBlockId(blockId)}
onMouseLeave={() => updateActiveBlockId(null)}
>
<ChartDraggable
block={block}
blockToRender={blockToRender}
handleBlockDrag={handleBlockDrag}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove && !!isBlockComplete}
enableDependency={enableDependency}
isMoving={isMoving}
ganttContainerRef={ganttContainerRef}
/>
</div>
</RenderIfVisible>
)}
</div>
);
});

View File

@@ -0,0 +1,78 @@
import { observer } from "mobx-react";
import { Expand, Shrink } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// plane
import type { TGanttViews } from "@plane/types";
import { Row } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { VIEWS_LIST } from "@/components/gantt-chart/data";
// helpers
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { GANTT_BREADCRUMBS_HEIGHT } from "../constants";
type Props = {
blockIds: string[];
fullScreenMode: boolean;
handleChartView: (view: TGanttViews) => void;
handleToday: () => void;
loaderTitle: string;
toggleFullScreenMode: () => void;
showToday: boolean;
};
export const GanttChartHeader: React.FC<Props> = observer((props) => {
const { t } = useTranslation();
const { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode, showToday } =
props;
// chart hook
const { currentView } = useTimeLineChartStore();
return (
<Row
className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap py-2"
style={{ height: `${GANTT_BREADCRUMBS_HEIGHT}px` }}
>
<div className="ml-auto">
<div className="ml-auto text-sm font-medium">
{blockIds ? `${blockIds.length} ${loaderTitle}` : t("common.loading")}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{VIEWS_LIST.map((chartView: any) => (
<div
key={chartView?.key}
className={cn("cursor-pointer rounded-sm p-1 px-2 text-xs", {
"bg-custom-background-80": currentView === chartView?.key,
"hover:bg-custom-background-90": currentView !== chartView?.key,
})}
onClick={() => handleChartView(chartView?.key)}
>
{t(chartView?.i18n_title)}
</div>
))}
</div>
{showToday && (
<button
type="button"
className="rounded-sm p-1 px-2 text-xs hover:bg-custom-background-80"
onClick={handleToday}
>
{t("common.today")}
</button>
)}
<button
type="button"
className="flex items-center justify-center rounded-sm border border-custom-border-200 p-1 transition-all hover:bg-custom-background-80"
onClick={toggleFullScreenMode}
>
{fullScreenMode ? <Shrink className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
</button>
</Row>
);
});

View File

@@ -0,0 +1,4 @@
export * from "./views";
export * from "./header";
export * from "./main-content";
export * from "./root";

View File

@@ -0,0 +1,235 @@
import { useEffect, useRef } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
import type {
ChartDataType,
IBlockUpdateData,
IBlockUpdateDependencyData,
IGanttBlock,
TGanttViews,
} from "@plane/types";
import { cn, getDate } from "@plane/utils";
// components
import { MultipleSelectGroup } from "@/components/core/multiple-select";
import { GanttChartSidebar, MonthChartView, QuarterChartView, WeekChartView } from "@/components/gantt-chart";
// helpers
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
// plane web components
import { TimelineDependencyPaths, TimelineDraggablePath } from "@/plane-web/components/gantt-chart";
import { GanttChartRowList } from "@/plane-web/components/gantt-chart/blocks/block-row-list";
import { GanttChartBlocksList } from "@/plane-web/components/gantt-chart/blocks/blocks-list";
import { IssueBulkOperationsRoot } from "@/plane-web/components/issues/bulk-operations";
// plane web hooks
import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status";
//
import { DEFAULT_BLOCK_WIDTH, GANTT_SELECT_GROUP, HEADER_HEIGHT } from "../constants";
import { getItemPositionWidth } from "../views";
import { TimelineDragHelper } from "./timeline-drag-helper";
type Props = {
blockIds: string[];
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>;
blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
bottomSpacing: boolean;
enableBlockLeftResize: boolean | ((blockId: string) => boolean);
enableBlockMove: boolean | ((blockId: string) => boolean);
enableBlockRightResize: boolean | ((blockId: string) => boolean);
enableReorder: boolean | ((blockId: string) => boolean);
enableSelection: boolean | ((blockId: string) => boolean);
enableAddBlock: boolean | ((blockId: string) => boolean);
enableDependency: boolean | ((blockId: string) => boolean);
itemsContainerWidth: number;
showAllBlocks: boolean;
sidebarToRender: (props: any) => React.ReactNode;
title: string;
updateCurrentViewRenderPayload: (
direction: "left" | "right",
currentView: TGanttViews,
targetDate?: Date
) => ChartDataType | undefined;
quickAdd?: React.ReactNode | undefined;
isEpic?: boolean;
};
export const GanttChartMainContent: React.FC<Props> = observer((props) => {
const {
blockIds,
loadMoreBlocks,
blockToRender,
blockUpdateHandler,
bottomSpacing,
enableBlockLeftResize,
enableBlockMove,
enableBlockRightResize,
enableReorder,
enableAddBlock,
enableSelection,
enableDependency,
itemsContainerWidth,
showAllBlocks,
sidebarToRender,
title,
canLoadMoreBlocks,
updateCurrentViewRenderPayload,
quickAdd,
updateBlockDates,
isEpic = false,
} = props;
// refs
const ganttContainerRef = useRef<HTMLDivElement>(null);
// chart hook
const { currentView, currentViewData } = useTimeLineChartStore();
// plane web hooks
const isBulkOperationsEnabled = useBulkOperationStatus();
// Enable Auto Scroll for Ganttlist
useEffect(() => {
const element = ganttContainerRef.current;
if (!element) return;
return combine(
autoScrollForElements({
element,
getAllowedAxis: () => "vertical",
canScroll: ({ source }) => source.data.dragInstanceId === "GANTT_REORDER",
})
);
}, [ganttContainerRef?.current]);
// handling scroll functionality
const onScroll = (e: React.UIEvent<HTMLDivElement, UIEvent>) => {
const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget;
const approxRangeLeft = scrollLeft;
const approxRangeRight = scrollWidth - (scrollLeft + clientWidth);
const calculatedRangeRight = itemsContainerWidth - (scrollLeft + clientWidth);
if (approxRangeRight < clientWidth || calculatedRangeRight < clientWidth) {
updateCurrentViewRenderPayload("right", currentView);
}
if (approxRangeLeft < clientWidth) {
updateCurrentViewRenderPayload("left", currentView);
}
};
const handleScrollToBlock = (block: IGanttBlock) => {
const scrollContainer = ganttContainerRef.current as HTMLDivElement;
const scrollToEndDate = !block.start_date && block.target_date;
const scrollToDate = block.start_date ? getDate(block.start_date) : getDate(block.target_date);
let chartData;
if (!scrollContainer || !currentViewData || !scrollToDate) return;
if (scrollToDate.getTime() < currentViewData.data.startDate.getTime()) {
chartData = updateCurrentViewRenderPayload("left", currentView, scrollToDate);
} else if (scrollToDate.getTime() > currentViewData.data.endDate.getTime()) {
chartData = updateCurrentViewRenderPayload("right", currentView, scrollToDate);
}
// update container's scroll position to the block's position
const updatedPosition = getItemPositionWidth(chartData ?? currentViewData, block);
setTimeout(() => {
if (updatedPosition)
scrollContainer.scrollLeft = updatedPosition.marginLeft - 4 - (scrollToEndDate ? DEFAULT_BLOCK_WIDTH : 0);
});
};
const CHART_VIEW_COMPONENTS: {
[key in TGanttViews]: React.FC;
} = {
week: WeekChartView,
month: MonthChartView,
quarter: QuarterChartView,
};
if (!currentView) return null;
const ActiveChartView = CHART_VIEW_COMPONENTS[currentView];
return (
<>
<TimelineDragHelper ganttContainerRef={ganttContainerRef} />
<MultipleSelectGroup
containerRef={ganttContainerRef}
entities={{
[GANTT_SELECT_GROUP]: blockIds ?? [],
}}
disabled={!isBulkOperationsEnabled || isEpic}
>
{(helpers) => (
<>
<div
// DO NOT REMOVE THE ID
id="gantt-container"
className={cn(
"h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200",
{
"mb-8": bottomSpacing,
}
)}
ref={ganttContainerRef}
onScroll={onScroll}
>
<GanttChartSidebar
blockIds={blockIds}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
ganttContainerRef={ganttContainerRef}
blockUpdateHandler={blockUpdateHandler}
enableReorder={enableReorder}
enableSelection={enableSelection}
sidebarToRender={sidebarToRender}
title={title}
quickAdd={quickAdd}
selectionHelpers={helpers}
isEpic={isEpic}
/>
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
<ActiveChartView />
{currentViewData && (
<div
className="relative h-full"
style={{
width: `${itemsContainerWidth}px`,
transform: `translateY(${HEADER_HEIGHT}px)`,
paddingBottom: `${HEADER_HEIGHT}px`,
}}
>
<GanttChartRowList
blockIds={blockIds}
blockUpdateHandler={blockUpdateHandler}
handleScrollToBlock={handleScrollToBlock}
enableAddBlock={enableAddBlock}
showAllBlocks={showAllBlocks}
selectionHelpers={helpers}
ganttContainerRef={ganttContainerRef}
/>
<TimelineDependencyPaths isEpic={isEpic} />
<TimelineDraggablePath />
<GanttChartBlocksList
blockIds={blockIds}
blockToRender={blockToRender}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
ganttContainerRef={ganttContainerRef}
enableDependency={enableDependency}
showAllBlocks={showAllBlocks}
updateBlockDates={updateBlockDates}
/>
</div>
)}
</div>
</div>
<IssueBulkOperationsRoot selectionHelpers={helpers} />
</>
)}
</MultipleSelectGroup>
</>
);
});

View File

@@ -0,0 +1,219 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { createPortal } from "react-dom";
// plane imports
// components
import type { ChartDataType, IBlockUpdateData, IBlockUpdateDependencyData, TGanttViews } from "@plane/types";
import { cn } from "@plane/utils";
import { GanttChartHeader, GanttChartMainContent } from "@/components/gantt-chart";
// helpers
// hooks
import { useUserProfile } from "@/hooks/store/user";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { SIDEBAR_WIDTH } from "../constants";
import { currentViewDataWithView } from "../data";
import type { IMonthBlock, IMonthView, IWeekBlock } from "../views";
import { getNumberOfDaysBetweenTwoDates, monthView, quarterView, weekView } from "../views";
type ChartViewRootProps = {
border: boolean;
title: string;
loaderTitle: string;
blockIds: string[];
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blockToRender: (data: any) => React.ReactNode;
sidebarToRender: (props: any) => React.ReactNode;
enableBlockLeftResize: boolean | ((blockId: string) => boolean);
enableBlockRightResize: boolean | ((blockId: string) => boolean);
enableBlockMove: boolean | ((blockId: string) => boolean);
enableReorder: boolean | ((blockId: string) => boolean);
enableAddBlock: boolean | ((blockId: string) => boolean);
enableSelection: boolean | ((blockId: string) => boolean);
enableDependency: boolean | ((blockId: string) => boolean);
bottomSpacing: boolean;
showAllBlocks: boolean;
loadMoreBlocks?: () => void;
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>;
canLoadMoreBlocks?: boolean;
quickAdd?: React.ReactNode | undefined;
showToday: boolean;
isEpic?: boolean;
};
const timelineViewHelpers = {
week: weekView,
month: monthView,
quarter: quarterView,
};
export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
const {
border,
title,
blockIds,
loadMoreBlocks,
loaderTitle,
blockUpdateHandler,
sidebarToRender,
blockToRender,
canLoadMoreBlocks,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
enableReorder,
enableAddBlock,
enableSelection,
enableDependency,
bottomSpacing,
showAllBlocks,
quickAdd,
showToday,
updateBlockDates,
isEpic = false,
} = props;
// states
const [itemsContainerWidth, setItemsContainerWidth] = useState(0);
const [fullScreenMode, setFullScreenMode] = useState(false);
// hooks
const {
currentView,
currentViewData,
renderView,
updateCurrentView,
updateCurrentViewData,
updateRenderView,
updateAllBlocksOnChartChangeWhileDragging,
} = useTimeLineChartStore();
const { data } = useUserProfile();
const startOfWeek = data?.start_of_the_week;
const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews, targetDate?: Date) => {
const selectedCurrentView: TGanttViews = view;
const selectedCurrentViewData: ChartDataType | undefined =
selectedCurrentView && selectedCurrentView === currentViewData?.key
? currentViewData
: currentViewDataWithView(view);
if (selectedCurrentViewData === undefined) return;
const currentViewHelpers = timelineViewHelpers[selectedCurrentView];
const currentRender = currentViewHelpers.generateChart(selectedCurrentViewData, side, targetDate, startOfWeek);
const mergeRenderPayloads = currentViewHelpers.mergeRenderPayloads as (
a: IWeekBlock[] | IMonthView | IMonthBlock[],
b: IWeekBlock[] | IMonthView | IMonthBlock[]
) => IWeekBlock[] | IMonthView | IMonthBlock[];
// updating the prevData, currentData and nextData
if (currentRender.payload) {
updateCurrentViewData(currentRender.state);
if (side === "left") {
updateCurrentView(selectedCurrentView);
updateRenderView(mergeRenderPayloads(currentRender.payload, renderView));
updateItemsContainerWidth(currentRender.scrollWidth);
if (!targetDate) updateCurrentLeftScrollPosition(currentRender.scrollWidth);
updateAllBlocksOnChartChangeWhileDragging(currentRender.scrollWidth);
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
} else if (side === "right") {
updateCurrentView(view);
updateRenderView(mergeRenderPayloads(renderView, currentRender.payload));
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
} else {
updateCurrentView(view);
updateRenderView(currentRender.payload);
setItemsContainerWidth(currentRender.scrollWidth);
setTimeout(() => {
handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate);
}, 50);
}
}
return currentRender.state;
};
const handleToday = () => updateCurrentViewRenderPayload(null, currentView);
// handling the scroll positioning from left and right
useEffect(() => {
handleToday();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateItemsContainerWidth = (width: number) => {
const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement;
if (!scrollContainer) return;
setItemsContainerWidth(width + scrollContainer?.scrollLeft);
};
const updateCurrentLeftScrollPosition = (width: number) => {
const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement;
if (!scrollContainer) return;
scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft;
};
const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => {
const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement;
if (!scrollContainer) return;
const clientVisibleWidth: number = scrollContainer?.clientWidth;
let scrollWidth: number = 0;
let daysDifference: number = 0;
daysDifference = getNumberOfDaysBetweenTwoDates(currentState.data.startDate, date);
scrollWidth =
Math.abs(daysDifference) * currentState.data.dayWidth -
(clientVisibleWidth / 2 - currentState.data.dayWidth) +
SIDEBAR_WIDTH / 2;
scrollContainer.scrollLeft = scrollWidth;
};
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
const content = (
<div
className={cn("relative flex flex-col h-full select-none rounded-sm bg-custom-background-100 shadow", {
"inset-0 z-[25] bg-custom-background-100": fullScreenMode,
"border-[0.5px] border-custom-border-200": border,
})}
>
<GanttChartHeader
blockIds={blockIds}
fullScreenMode={fullScreenMode}
toggleFullScreenMode={() => setFullScreenMode((prevData) => !prevData)}
handleChartView={(key) => updateCurrentViewRenderPayload(null, key)}
handleToday={handleToday}
loaderTitle={loaderTitle}
showToday={showToday}
/>
<GanttChartMainContent
blockIds={blockIds}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler}
bottomSpacing={bottomSpacing}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockMove={enableBlockMove}
enableBlockRightResize={enableBlockRightResize}
enableReorder={enableReorder}
enableSelection={enableSelection}
enableAddBlock={enableAddBlock}
enableDependency={enableDependency}
itemsContainerWidth={itemsContainerWidth}
showAllBlocks={showAllBlocks}
sidebarToRender={sidebarToRender}
title={title}
updateCurrentViewRenderPayload={updateCurrentViewRenderPayload}
quickAdd={quickAdd}
updateBlockDates={updateBlockDates}
isEpic={isEpic}
/>
</div>
);
return fullScreenMode && portalContainer ? createPortal(content, portalContainer) : content;
});

View File

@@ -0,0 +1,18 @@
import type { RefObject } from "react";
import { observer } from "mobx-react";
// hooks
import { useAutoScroller } from "@/hooks/use-auto-scroller";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants";
type Props = {
ganttContainerRef: RefObject<HTMLDivElement>;
};
export const TimelineDragHelper = observer((props: Props) => {
const { ganttContainerRef } = props;
const { isDragging } = useTimeLineChartStore();
useAutoScroller(ganttContainerRef, isDragging, SIDEBAR_WIDTH, HEADER_HEIGHT);
return <></>;
});

View File

@@ -0,0 +1,3 @@
export * from "./month";
export * from "./quarter";
export * from "./week";

View File

@@ -0,0 +1,105 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// components
import { cn } from "@plane/utils";
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "@/components/gantt-chart/constants";
// helpers
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
// types
import type { IMonthView } from "../../views";
import { getNumberOfDaysBetweenTwoDates } from "../../views/helpers";
export const MonthChartView: FC<any> = observer(() => {
// chart hook
const { currentViewData, renderView } = useTimeLineChartStore();
const monthView: IMonthView = renderView;
if (!monthView) return <></>;
const { months, weeks } = monthView;
const monthsStartDate = new Date(months[0].year, months[0].month, 1);
const weeksStartDate = weeks[0].startDate;
const marginLeftDays = getNumberOfDaysBetweenTwoDates(monthsStartDate, weeksStartDate);
return (
<div className={`absolute top-0 left-0 min-h-full h-max w-max flex`}>
{currentViewData && (
<div className="relative flex flex-col outline-[0.25px] outline outline-custom-border-200">
{/** Header Div */}
<div
className="w-full sticky top-0 z-[5] bg-custom-background-100 flex-shrink-0"
style={{
height: `${HEADER_HEIGHT}px`,
}}
>
{/** Main Month Title */}
<div className="flex h-7" style={{ marginLeft: `${marginLeftDays * currentViewData.data.dayWidth}px` }}>
{months?.map((monthBlock) => (
<div
key={`month-${monthBlock?.month}-${monthBlock?.year}`}
className="flex outline-[0.5px] outline outline-custom-border-200"
style={{ width: `${monthBlock.days * currentViewData?.data.dayWidth}px` }}
>
<div
className="sticky flex items-center font-normal z-[1] m-1 whitespace-nowrap px-3 py-1 text-base capitalize bg-custom-background-100 text-custom-text-200"
style={{
left: `${SIDEBAR_WIDTH}px`,
}}
>
{monthBlock?.title}
{monthBlock.today && (
<span className={cn("rounded ml-2 font-medium bg-custom-primary-100 px-1 text-2xs text-white")}>
Current
</span>
)}
</div>
</div>
))}
</div>
{/** Weeks Sub title */}
<div className="h-5 w-full flex">
{weeks?.map((weekBlock) => (
<div
key={`sub-title-${weekBlock.startDate}-${weekBlock.endDate}`}
className={cn(
"flex flex-shrink-0 py-1 px-2 text-center capitalize justify-between outline-[0.25px] outline outline-custom-border-200",
{
"bg-custom-primary-100/20": weekBlock.today,
}
)}
style={{ width: `${currentViewData?.data.dayWidth * 7}px` }}
>
<div className="space-x-1 text-xs font-medium text-custom-text-400">
<span
className={cn({
"rounded bg-custom-primary-100 px-1 text-white": weekBlock.today,
})}
>
{weekBlock.startDate.getDate()}-{weekBlock.endDate.getDate()}
</span>
</div>
<div className="space-x-1 text-xs font-medium">{weekBlock.weekData.shortTitle}</div>
</div>
))}
</div>
</div>
{/** Week Columns */}
<div className="h-full w-full flex-grow flex">
{weeks?.map((weekBlock) => (
<div
key={`column-${weekBlock.startDate}-${weekBlock.endDate}`}
className={cn("h-full overflow-hidden outline-[0.25px] outline outline-custom-border-100", {
"bg-custom-primary-100/20": weekBlock.today,
})}
style={{ width: `${currentViewData?.data.dayWidth * 7}px` }}
/>
))}
</div>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,94 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../../constants";
import type { IMonthBlock, IQuarterMonthBlock } from "../../views";
import { groupMonthsToQuarters } from "../../views";
export const QuarterChartView: FC<any> = observer(() => {
const { currentViewData, renderView } = useTimeLineChartStore();
const monthBlocks: IMonthBlock[] = renderView;
const quarterBlocks: IQuarterMonthBlock[] = groupMonthsToQuarters(monthBlocks);
return (
<div className={`absolute top-0 left-0 min-h-full h-max w-max flex`}>
{currentViewData &&
quarterBlocks?.map((quarterBlock, rootIndex) => (
<div
key={`month-${quarterBlock.quarterNumber}-${quarterBlock.year}`}
className="relative flex flex-col outline-[0.25px] outline outline-custom-border-200"
>
{/** Header Div */}
<div
className="w-full sticky top-0 z-[5] bg-custom-background-100 flex-shrink-0 outline-[1px] outline outline-custom-border-200"
style={{
height: `${HEADER_HEIGHT}px`,
}}
>
{/** Main Quarter Title */}
<div className="w-full inline-flex h-7 justify-between">
<div
className="sticky flex items-center font-normal z-[1] my-1 whitespace-nowrap px-3 py-1 text-base capitalize bg-custom-background-100 text-custom-text-200"
style={{
left: `${SIDEBAR_WIDTH}px`,
}}
>
{quarterBlock?.title}
{quarterBlock.today && (
<span className={cn("rounded ml-2 font-medium bg-custom-primary-100 px-1 text-2xs text-white")}>
Current
</span>
)}
</div>
<div className="sticky whitespace-nowrap px-3 py-2 text-xs capitalize text-custom-text-400">
{quarterBlock.shortTitle}
</div>
</div>
{/** Months Sub title */}
<div className="h-5 w-full flex">
{quarterBlock?.children?.map((monthBlock, index) => (
<div
key={`sub-title-${rootIndex}-${index}`}
className={cn(
"flex flex-shrink-0 text-center capitalize justify-center outline-[0.25px] outline outline-custom-border-200",
{
"bg-custom-primary-100/20": monthBlock.today,
}
)}
style={{ width: `${currentViewData?.data.dayWidth * monthBlock.days}px` }}
>
<div className="space-x-1 flex items-center justify-center text-xs font-medium h-full">
<span
className={cn({
"rounded-lg bg-custom-primary-100 px-2 text-white": monthBlock.today,
})}
>
{monthBlock.monthData.shortTitle}
</span>
</div>
</div>
))}
</div>
</div>
{/** Month Columns */}
<div className="h-full w-full flex-grow flex">
{quarterBlock?.children?.map((monthBlock, index) => (
<div
key={`column-${rootIndex}-${index}`}
className={cn("h-full overflow-hidden outline-[0.25px] outline outline-custom-border-100", {
"bg-custom-primary-100/20": monthBlock.today,
})}
style={{ width: `${currentViewData?.data.dayWidth * monthBlock.days}px` }}
/>
))}
</div>
</div>
))}
</div>
);
});

View File

@@ -0,0 +1,93 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../../constants";
import type { IWeekBlock } from "../../views";
export const WeekChartView: FC<any> = observer(() => {
const { currentViewData, renderView } = useTimeLineChartStore();
const weekBlocks: IWeekBlock[] = renderView;
return (
<div className={`absolute top-0 left-0 min-h-full h-max w-max flex`}>
{currentViewData &&
weekBlocks?.map((block, rootIndex) => (
<div
key={`month-${block?.startDate}-${block?.endDate}`}
className="relative flex flex-col outline-[0.25px] outline outline-custom-border-200"
>
{/** Header Div */}
<div
className="w-full sticky top-0 z-[5] bg-custom-background-100 flex-shrink-0 outline-[1px] outline outline-custom-border-200"
style={{
height: `${HEADER_HEIGHT}px`,
}}
>
{/** Main Months Title */}
<div className="w-full inline-flex h-7 justify-between">
<div
className="sticky flex items-center font-normal z-[1] m-1 whitespace-nowrap px-3 py-1 text-sm capitalize bg-custom-background-100 text-custom-text-200"
style={{
left: `${SIDEBAR_WIDTH}px`,
}}
>
{block?.title}
</div>
<div className="sticky whitespace-nowrap px-3 py-2 text-xs capitalize text-custom-text-400">
{block?.weekData?.title}
</div>
</div>
{/** Days Sub title */}
<div className="h-5 w-full flex">
{block?.children?.map((weekDay, index) => (
<div
key={`sub-title-${rootIndex}-${index}`}
className={cn(
"flex flex-shrink-0 p-1 text-center capitalize justify-between outline-[0.25px] outline outline-custom-border-200",
{
"bg-custom-primary-100/20": weekDay.today,
}
)}
style={{ width: `${currentViewData?.data.dayWidth}px` }}
>
<div className="space-x-1 text-xs font-medium text-custom-text-400">
{weekDay.dayData.abbreviation}
</div>
<div className="space-x-1 text-xs font-medium">
<span
className={cn({
"rounded bg-custom-primary-100 px-1 text-white": weekDay.today,
})}
>
{weekDay.date.getDate()}
</span>
</div>
</div>
))}
</div>
</div>
{/** Day Columns */}
<div className="h-full w-full flex-grow flex bg-custom-background-100">
{block?.children?.map((weekDay, index) => (
<div
key={`column-${rootIndex}-${index}`}
className={cn("h-full overflow-hidden outline-[0.25px] outline outline-custom-border-100", {
"bg-custom-primary-100/20": weekDay.today,
})}
style={{ width: `${currentViewData?.data.dayWidth}px` }}
>
{["sat", "sun"].includes(weekDay?.dayData?.shortTitle) && (
<div className="h-full bg-custom-background-90 outline-[0.25px] outline outline-custom-border-300" />
)}
</div>
))}
</div>
</div>
))}
</div>
);
});

View File

@@ -0,0 +1,11 @@
export const BLOCK_HEIGHT = 44;
export const HEADER_HEIGHT = 48;
export const GANTT_BREADCRUMBS_HEIGHT = 40;
export const SIDEBAR_WIDTH = 360;
export const DEFAULT_BLOCK_WIDTH = 60;
export const GANTT_SELECT_GROUP = "gantt-issues";

View File

@@ -0,0 +1,16 @@
import { createContext, useContext } from "react";
export enum ETimeLineTypeType {
ISSUE = "ISSUE",
MODULE = "MODULE",
PROJECT = "PROJECT",
GROUPED = "GROUPED",
}
export const TimeLineTypeContext = createContext<ETimeLineTypeType | undefined>(undefined);
export const useTimeLineType = () => {
const timelineType = useContext(TimeLineTypeContext);
return timelineType;
};

View File

@@ -0,0 +1,106 @@
// types
import type { WeekMonthDataType, ChartDataType, TGanttViews } from "@plane/types";
import { EStartOfTheWeek } from "@plane/types";
// constants
export const generateWeeks = (startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY): WeekMonthDataType[] => [
...weeks.slice(startOfWeek),
...weeks.slice(0, startOfWeek),
];
export const weeks: WeekMonthDataType[] = [
{ key: 0, shortTitle: "sun", title: "sunday", abbreviation: "Su" },
{ key: 1, shortTitle: "mon", title: "monday", abbreviation: "M" },
{ key: 2, shortTitle: "tue", title: "tuesday", abbreviation: "T" },
{ key: 3, shortTitle: "wed", title: "wednesday", abbreviation: "W" },
{ key: 4, shortTitle: "thurs", title: "thursday", abbreviation: "Th" },
{ key: 5, shortTitle: "fri", title: "friday", abbreviation: "F" },
{ key: 6, shortTitle: "sat", title: "saturday", abbreviation: "Sa" },
];
export const months: WeekMonthDataType[] = [
{ key: 0, shortTitle: "jan", title: "january", abbreviation: "Jan" },
{ key: 1, shortTitle: "feb", title: "february", abbreviation: "Feb" },
{ key: 2, shortTitle: "mar", title: "march", abbreviation: "Mar" },
{ key: 3, shortTitle: "apr", title: "april", abbreviation: "Apr" },
{ key: 4, shortTitle: "may", title: "may", abbreviation: "May" },
{ key: 5, shortTitle: "jun", title: "june", abbreviation: "Jun" },
{ key: 6, shortTitle: "jul", title: "july", abbreviation: "Jul" },
{ key: 7, shortTitle: "aug", title: "august", abbreviation: "Aug" },
{ key: 8, shortTitle: "sept", title: "september", abbreviation: "Sept" },
{ key: 9, shortTitle: "oct", title: "october", abbreviation: "Oct" },
{ key: 10, shortTitle: "nov", title: "november", abbreviation: "Nov" },
{ key: 11, shortTitle: "dec", title: "december", abbreviation: "Dec" },
];
export const quarters: WeekMonthDataType[] = [
{ key: 0, shortTitle: "Q1", title: "Jan - Mar", abbreviation: "Q1" },
{ key: 1, shortTitle: "Q2", title: "Apr - Jun", abbreviation: "Q2" },
{ key: 2, shortTitle: "Q3", title: "Jul - Sept", abbreviation: "Q3" },
{ key: 3, shortTitle: "Q4", title: "Oct - Dec", abbreviation: "Q4" },
];
export const charCapitalize = (word: string) => `${word.charAt(0).toUpperCase()}${word.substring(1)}`;
export const bindZero = (value: number) => (value > 9 ? `${value}` : `0${value}`);
export const timePreview = (date: Date) => {
let hours = date.getHours();
const amPm = hours >= 12 ? "PM" : "AM";
hours = hours % 12;
hours = hours ? hours : 12;
let minutes: number | string = date.getMinutes();
minutes = bindZero(minutes);
return `${bindZero(hours)}:${minutes} ${amPm}`;
};
export const datePreview = (date: Date, includeTime: boolean = false) => {
const day = date.getDate();
let month: number | WeekMonthDataType = date.getMonth();
month = months[month as number] as WeekMonthDataType;
const year = date.getFullYear();
return `${charCapitalize(month?.shortTitle)} ${day}, ${year}${includeTime ? `, ${timePreview(date)}` : ``}`;
};
// context data
export const VIEWS_LIST: ChartDataType[] = [
{
key: "week",
i18n_title: "common.week",
data: {
startDate: new Date(),
currentDate: new Date(),
endDate: new Date(),
approxFilterRange: 4, // it will preview week dates with weekends highlighted with 1 week limitations ex: title (Wed 1, Thu 2, Fri 3)
dayWidth: 60,
},
},
{
key: "month",
i18n_title: "common.month",
data: {
startDate: new Date(),
currentDate: new Date(),
endDate: new Date(),
approxFilterRange: 6, // it will preview monthly all dates with weekends highlighted with no limitations ex: title (1, 2, 3)
dayWidth: 20,
},
},
{
key: "quarter",
i18n_title: "common.quarter",
data: {
startDate: new Date(),
currentDate: new Date(),
endDate: new Date(),
approxFilterRange: 24, // it will preview week starting dates all months data and there is 3 months limitation for preview ex: title (2, 9, 16, 23, 30)
dayWidth: 5,
},
},
];
export const currentViewDataWithView = (view: TGanttViews = "month") =>
VIEWS_LIST.find((_viewData) => _viewData.key === view);

View File

@@ -0,0 +1,102 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { addDays } from "date-fns";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// ui
import { Tooltip } from "@plane/propel/tooltip";
import type { IBlockUpdateData, IGanttBlock } from "@plane/types";
// helpers
import { renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
type Props = {
block: IGanttBlock;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
};
export const ChartAddBlock: React.FC<Props> = observer((props) => {
const { block, blockUpdateHandler } = props;
// states
const [isButtonVisible, setIsButtonVisible] = useState(false);
const [buttonXPosition, setButtonXPosition] = useState(0);
const [buttonStartDate, setButtonStartDate] = useState<Date | null>(null);
// refs
const containerRef = useRef<HTMLDivElement>(null);
// hooks
const { isMobile } = usePlatformOS();
// chart hook
const { currentViewData, currentView } = useTimeLineChartStore();
const handleButtonClick = () => {
if (!currentViewData) return;
const { startDate: chartStartDate, dayWidth } = currentViewData.data;
const columnNumber = buttonXPosition / dayWidth;
let numberOfDays = 1;
if (currentView === "quarter") numberOfDays = 7;
const startDate = addDays(chartStartDate, columnNumber);
const endDate = addDays(startDate, numberOfDays);
blockUpdateHandler(block.data, {
start_date: renderFormattedPayloadDate(startDate) ?? undefined,
target_date: renderFormattedPayloadDate(endDate) ?? undefined,
meta: block.meta,
});
};
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleMouseMove = (e: MouseEvent) => {
if (!currentViewData) return;
setButtonXPosition(e.offsetX);
const { startDate: chartStartDate, dayWidth } = currentViewData.data;
const columnNumber = buttonXPosition / dayWidth;
const startDate = addDays(chartStartDate, columnNumber);
setButtonStartDate(startDate);
};
container.addEventListener("mousemove", handleMouseMove);
return () => {
container?.removeEventListener("mousemove", handleMouseMove);
};
}, [buttonXPosition, currentViewData]);
return (
<div
className="relative h-full w-full"
onMouseEnter={() => setIsButtonVisible(true)}
onMouseLeave={() => setIsButtonVisible(false)}
>
<div ref={containerRef} className="h-full w-full" />
{isButtonVisible && (
<Tooltip tooltipContent={buttonStartDate && renderFormattedDate(buttonStartDate)} isMobile={isMobile}>
<button
type="button"
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-8 w-8 bg-custom-background-80 p-1.5 rounded border border-custom-border-300 grid place-items-center text-custom-text-200 hover:text-custom-text-100"
style={{
marginLeft: `${buttonXPosition}px`,
}}
onClick={handleButtonClick}
>
<Plus className="h-3.5 w-3.5" />
</button>
</Tooltip>
)}
</div>
);
});

View File

@@ -0,0 +1,61 @@
import { useState } from "react";
import { observer } from "mobx-react";
// plane utils
import { cn, renderFormattedDate } from "@plane/utils";
//helpers
//
//hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
type LeftResizableProps = {
enableBlockLeftResize: boolean;
handleBlockDrag: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, dragDirection: "left" | "right" | "move") => void;
isMoving: "left" | "right" | "move" | undefined;
position?: {
marginLeft: number;
width: number;
};
};
export const LeftResizable = observer((props: LeftResizableProps) => {
const { enableBlockLeftResize, isMoving, handleBlockDrag, position } = props;
const [isHovering, setIsHovering] = useState(false);
const { getDateFromPositionOnGantt } = useTimeLineChartStore();
const date = position ? getDateFromPositionOnGantt(position.marginLeft, 0) : undefined;
const dateString = date ? renderFormattedDate(date) : undefined;
const isLeftResizing = isMoving === "left" || isMoving === "move";
if (!enableBlockLeftResize) return null;
return (
<>
{(isHovering || isLeftResizing) && dateString && (
<div className="absolute flex text-xs font-normal text-custom-text-300 h-full w-32 -left-36 justify-end items-center">
<div className="px-2 py-1 bg-custom-primary-20 rounded">{dateString}</div>
</div>
)}
<div
onMouseDown={(e) => {
handleBlockDrag(e, "left");
}}
onMouseOver={() => {
setIsHovering(true);
}}
onMouseOut={() => {
setIsHovering(false);
}}
className="absolute -left-1.5 top-1/2 -translate-y-1/2 z-[6] h-full w-3 cursor-col-resize rounded-md"
/>
<div
className={cn(
"absolute left-1 top-1/2 -translate-y-1/2 h-7 w-1 z-[5] rounded-sm bg-custom-background-100 transition-all duration-300 opacity-0 group-hover:opacity-100",
{
"-left-1.5 opacity-100": isLeftResizing,
}
)}
/>
</>
);
});

View File

@@ -0,0 +1,59 @@
import { useState } from "react";
import { observer } from "mobx-react";
// plane utils
import { cn, renderFormattedDate } from "@plane/utils";
//helpers
//
//hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
type RightResizableProps = {
enableBlockRightResize: boolean;
handleBlockDrag: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, dragDirection: "left" | "right" | "move") => void;
isMoving: "left" | "right" | "move" | undefined;
position?: {
marginLeft: number;
width: number;
};
};
export const RightResizable = observer((props: RightResizableProps) => {
const { enableBlockRightResize, handleBlockDrag, isMoving, position } = props;
const [isHovering, setIsHovering] = useState(false);
const { getDateFromPositionOnGantt } = useTimeLineChartStore();
const date = position ? getDateFromPositionOnGantt(position.marginLeft + position.width, -1) : undefined;
const dateString = date ? renderFormattedDate(date) : undefined;
const isRightResizing = isMoving === "right" || isMoving === "move";
if (!enableBlockRightResize) return null;
return (
<>
{(isHovering || isRightResizing) && dateString && (
<div className="z-[10] absolute flex text-xs font-normal text-custom-text-300 h-full w-32 -right-36 justify-start items-center">
<div className="px-2 py-1 bg-custom-primary-20 rounded">{dateString}</div>
</div>
)}
<div
onMouseDown={(e) => handleBlockDrag(e, "right")}
onMouseOver={() => {
setIsHovering(true);
}}
onMouseOut={() => {
setIsHovering(false);
}}
className="absolute -right-1.5 top-1/2 -translate-y-1/2 z-[6] h-full w-3 cursor-col-resize rounded-md"
/>
<div
className={cn(
"absolute right-1 top-1/2 -translate-y-1/2 h-7 w-1 z-[5] rounded-sm bg-custom-background-100 transition-all duration-300 opacity-0 group-hover:opacity-100",
{
"-right-1.5 opacity-100": isRightResizing,
}
)}
/>
</>
);
});

View File

@@ -0,0 +1,144 @@
import { useRef, useState } from "react";
// Plane
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IBlockUpdateDependencyData, IGanttBlock } from "@plane/types";
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
//
import { DEFAULT_BLOCK_WIDTH, SIDEBAR_WIDTH } from "../../constants";
export const useGanttResizable = (
block: IGanttBlock,
resizableRef: React.RefObject<HTMLDivElement>,
ganttContainerRef: React.RefObject<HTMLDivElement>,
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>
) => {
// refs
const initialPositionRef = useRef<{ marginLeft: number; width: number; offsetX: number }>({
marginLeft: 0,
width: 0,
offsetX: 0,
});
const ganttContainerDimensions = useRef<DOMRect | undefined>();
const currMouseEvent = useRef<MouseEvent | undefined>();
// states
const { currentViewData, updateBlockPosition, setIsDragging, getUpdatedPositionAfterDrag } = useTimeLineChartStore();
const [isMoving, setIsMoving] = useState<"left" | "right" | "move" | undefined>();
// handle block resize from the left end
const handleBlockDrag = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
dragDirection: "left" | "right" | "move"
) => {
const ganttContainerElement = ganttContainerRef.current;
if (!currentViewData || !resizableRef.current || !block.position || !ganttContainerElement) return;
if (e.button !== 0) return;
const resizableDiv = resizableRef.current;
ganttContainerDimensions.current = ganttContainerElement.getBoundingClientRect();
const dayWidth = currentViewData.data.dayWidth;
const mouseX = e.clientX - ganttContainerDimensions.current.left - SIDEBAR_WIDTH + ganttContainerElement.scrollLeft;
// record position on drag start
initialPositionRef.current = {
width: block.position.width ?? 0,
marginLeft: block.position.marginLeft ?? 0,
offsetX: mouseX - block.position.marginLeft,
};
const handleOnScroll = () => {
if (currMouseEvent.current) handleMouseMove(currMouseEvent.current);
};
const handleMouseMove = (e: MouseEvent) => {
currMouseEvent.current = e;
setIsMoving(dragDirection);
setIsDragging(true);
if (!ganttContainerDimensions.current) return;
const { left: containerLeft } = ganttContainerDimensions.current;
const mouseX = e.clientX - containerLeft - SIDEBAR_WIDTH + ganttContainerElement.scrollLeft;
let width = initialPositionRef.current.width;
let marginLeft = initialPositionRef.current.marginLeft;
if (dragDirection === "left") {
// calculate new marginLeft and update the initial marginLeft to the newly calculated one
marginLeft = Math.round(mouseX / dayWidth) * dayWidth;
// get Dimensions from dom's style
const prevMarginLeft = parseFloat(resizableDiv.style.marginLeft.slice(0, -2));
const prevWidth = parseFloat(resizableDiv.style.width.slice(0, -2));
// calculate new width
const marginDelta = prevMarginLeft - marginLeft;
// If target date does not exist while dragging with left handle the revert to default width
width = block.target_date ? prevWidth + marginDelta : DEFAULT_BLOCK_WIDTH;
} else if (dragDirection === "right") {
// calculate new width and update the initialMarginLeft using +=
width = Math.round(mouseX / dayWidth) * dayWidth - marginLeft;
// If start date does not exist while dragging with right handle the revert to default width and adjust marginLeft accordingly
if (!block.start_date) {
// calculate new right and update the marginLeft to the newly calculated one
const marginRight = Math.round(mouseX / dayWidth) * dayWidth;
marginLeft = marginRight - DEFAULT_BLOCK_WIDTH;
width = DEFAULT_BLOCK_WIDTH;
}
} else if (dragDirection === "move") {
// calculate new marginLeft and update the initial marginLeft using -=
marginLeft = Math.round((mouseX - initialPositionRef.current.offsetX) / dayWidth) * dayWidth;
}
// block needs to be at least 1 dayWidth Wide
if (width < dayWidth) return;
resizableDiv.style.width = `${width}px`;
resizableDiv.style.marginLeft = `${marginLeft}px`;
const deltaLeft = Math.round((marginLeft - (block.position?.marginLeft ?? 0)) / dayWidth) * dayWidth;
const deltaWidth = Math.round((width - (block.position?.width ?? 0)) / dayWidth) * dayWidth;
// call update blockPosition
if (deltaWidth || deltaLeft) updateBlockPosition(block.id, deltaLeft, deltaWidth);
};
// remove event listeners and call updateBlockDates
const handleMouseUp = () => {
setIsMoving(undefined);
document.removeEventListener("mousemove", handleMouseMove);
ganttContainerElement.removeEventListener("scroll", handleOnScroll);
document.removeEventListener("mouseup", handleMouseUp);
// update half blocks only when the missing side of the block is directly dragged
const shouldUpdateHalfBlock =
(dragDirection === "left" && !block.start_date) || (dragDirection === "right" && !block.target_date);
try {
const blockUpdates = getUpdatedPositionAfterDrag(block.id, shouldUpdateHalfBlock);
if (updateBlockDates) updateBlockDates(blockUpdates);
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: "Something went wrong while updating block dates",
});
}
setIsDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
ganttContainerElement.addEventListener("scroll", handleOnScroll);
document.addEventListener("mouseup", handleMouseUp);
};
return {
isMoving,
handleBlockDrag,
};
};

View File

@@ -0,0 +1,71 @@
import type { RefObject } from "react";
import React from "react";
import { observer } from "mobx-react";
// hooks
import type { IGanttBlock } from "@plane/types";
// helpers
import { cn } from "@plane/utils";
// Plane-web
import { LeftDependencyDraggable, RightDependencyDraggable } from "@/plane-web/components/gantt-chart";
//
import { LeftResizable } from "./blockResizables/left-resizable";
import { RightResizable } from "./blockResizables/right-resizable";
type Props = {
block: IGanttBlock;
blockToRender: (data: any) => React.ReactNode;
handleBlockDrag: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, dragDirection: "left" | "right" | "move") => void;
isMoving: "left" | "right" | "move" | undefined;
enableBlockLeftResize: boolean;
enableBlockRightResize: boolean;
enableBlockMove: boolean;
enableDependency: boolean | ((blockId: string) => boolean);
ganttContainerRef: RefObject<HTMLDivElement>;
};
export const ChartDraggable: React.FC<Props> = observer((props) => {
const {
block,
blockToRender,
handleBlockDrag,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
enableDependency,
isMoving,
ganttContainerRef,
} = props;
return (
<div className="group w-full z-[5] relative inline-flex h-full cursor-pointer items-center font-medium transition-all">
{/* left resize drag handle */}
{(typeof enableDependency === "function" ? enableDependency(block.id) : enableDependency) && (
<LeftDependencyDraggable block={block} ganttContainerRef={ganttContainerRef} />
)}
<LeftResizable
enableBlockLeftResize={enableBlockLeftResize}
handleBlockDrag={handleBlockDrag}
isMoving={isMoving}
position={block.position}
/>
<div
className={cn("relative z-[6] flex h-8 w-full items-center rounded", {
"pointer-events-none": isMoving,
})}
onMouseDown={(e) => enableBlockMove && handleBlockDrag(e, "move")}
>
{blockToRender({ ...block.data, meta: block.meta })}
</div>
{/* right resize drag handle */}
<RightResizable
enableBlockRightResize={enableBlockRightResize}
handleBlockDrag={handleBlockDrag}
isMoving={isMoving}
position={block.position}
/>
{(typeof enableDependency === "function" ? enableDependency(block.id) : enableDependency) && (
<RightDependencyDraggable block={block} ganttContainerRef={ganttContainerRef} />
)}
</div>
);
});

View File

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

View File

@@ -0,0 +1,4 @@
export * from "./chart";
export * from "./helpers";
export * from "./root";
export * from "./sidebar";

View File

@@ -0,0 +1,94 @@
import type { FC } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
// components
import type { IBlockUpdateData, IBlockUpdateDependencyData } from "@plane/types";
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
import { ChartViewRoot } from "./chart/root";
type GanttChartRootProps = {
border?: boolean;
title: string;
loaderTitle: string;
blockIds: string[];
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blockToRender: (data: any) => React.ReactNode;
sidebarToRender: (props: any) => React.ReactNode;
quickAdd?: React.ReactNode | undefined;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>;
enableBlockLeftResize?: boolean | ((blockId: string) => boolean);
enableBlockRightResize?: boolean | ((blockId: string) => boolean);
enableBlockMove?: boolean | ((blockId: string) => boolean);
enableReorder?: boolean | ((blockId: string) => boolean);
enableAddBlock?: boolean | ((blockId: string) => boolean);
enableSelection?: boolean | ((blockId: string) => boolean);
enableDependency?: boolean | ((blockId: string) => boolean);
bottomSpacing?: boolean;
showAllBlocks?: boolean;
showToday?: boolean;
isEpic?: boolean;
};
export const GanttChartRoot: FC<GanttChartRootProps> = observer((props) => {
const {
border = true,
title,
blockIds,
loaderTitle = "blocks",
blockUpdateHandler,
sidebarToRender,
blockToRender,
loadMoreBlocks,
canLoadMoreBlocks,
enableBlockLeftResize = false,
enableBlockRightResize = false,
enableBlockMove = false,
enableReorder = false,
enableAddBlock = false,
enableSelection = false,
enableDependency = false,
bottomSpacing = false,
showAllBlocks = false,
showToday = true,
quickAdd,
updateBlockDates,
isEpic = false,
} = props;
const { setBlockIds } = useTimeLineChartStore();
// update the timeline store with updated blockIds
useEffect(() => {
setBlockIds(blockIds);
}, [blockIds]);
return (
<ChartViewRoot
border={border}
title={title}
blockIds={blockIds}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
loaderTitle={loaderTitle}
blockUpdateHandler={blockUpdateHandler}
sidebarToRender={sidebarToRender}
blockToRender={blockToRender}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableReorder={enableReorder}
enableAddBlock={enableAddBlock}
enableSelection={enableSelection}
enableDependency={enableDependency}
bottomSpacing={bottomSpacing}
showAllBlocks={showAllBlocks}
quickAdd={quickAdd}
showToday={showToday}
updateBlockDates={updateBlockDates}
isEpic={isEpic}
/>
);
});

View File

@@ -0,0 +1,117 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import { useOutsideClickDetector } from "@plane/hooks";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { DropIndicator } from "@plane/ui";
import { HIGHLIGHT_WITH_LINE, highlightIssueOnDrop } from "@/components/issues/issue-layouts/utils";
type Props = {
id: string;
isLastChild: boolean;
isDragEnabled: boolean;
children: (isDragging: boolean) => React.ReactNode;
onDrop: (draggingBlockId: string | undefined, droppedBlockId: string | undefined, dropAtEndOfList: boolean) => void;
};
export const GanttDnDHOC = observer((props: Props) => {
const { id, isLastChild, children, onDrop, isDragEnabled } = props;
// states
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined);
// refs
const blockRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = blockRef.current;
if (!element) return;
return combine(
draggable({
element,
canDrag: () => isDragEnabled,
getInitialData: () => ({ id, dragInstanceId: "GANTT_REORDER" }),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) => source?.data?.id !== id && source?.data?.dragInstanceId === "GANTT_REORDER",
getData: ({ input, element }) => {
const data = { id };
// attach instruction for last in list
return attachInstruction(data, {
input,
element,
currentLevel: 0,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
});
},
onDrag: ({ self }) => {
const extractedInstruction = extractInstruction(self?.data)?.type;
// check if the highlight is to be shown above or below
setInstruction(
extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined
);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source }) => {
setInstruction(undefined);
const extractedInstruction = extractInstruction(self?.data)?.type;
const currentInstruction = extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined;
if (!currentInstruction) return;
const sourceId = source?.data?.id as string | undefined;
const destinationId = self?.data?.id as string | undefined;
onDrop(sourceId, destinationId, currentInstruction === "DRAG_BELOW");
highlightIssueOnDrop(source?.element?.id, false, true);
},
})
);
}, [blockRef?.current, isLastChild, onDrop]);
useOutsideClickDetector(blockRef, () => blockRef?.current?.classList?.remove(HIGHLIGHT_WITH_LINE));
return (
<div
id={`draggable-${id}`}
className={"relative"}
ref={blockRef}
onDragStart={() => {
if (!isDragEnabled) {
setToast({
title: "Warning!",
type: TOAST_TYPE.WARNING,
message: "Drag and drop is only enabled when sorted by manual",
});
}
}}
>
<DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} />
{children(isDragging)}
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
</div>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./issues";
export * from "./modules";
export * from "./root";

View File

@@ -0,0 +1,89 @@
import { observer } from "mobx-react";
// plane imports
import type { IGanttBlock } from "@plane/types";
import { Row } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { MultipleSelectEntityAction } from "@/components/core/multiple-select";
import { IssueGanttSidebarBlock } from "@/components/issues/issue-layouts/gantt/blocks";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import type { TSelectionHelper } from "@/hooks/use-multiple-select";
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
// local imports
import { BLOCK_HEIGHT, GANTT_SELECT_GROUP } from "../../constants";
type Props = {
block: IGanttBlock;
enableSelection: boolean;
isDragging: boolean;
selectionHelpers?: TSelectionHelper;
isEpic?: boolean;
};
export const IssuesSidebarBlock = observer((props: Props) => {
const { block, enableSelection, isDragging, selectionHelpers, isEpic = false } = props;
// store hooks
const { updateActiveBlockId, isBlockActive, getNumberOfDaysFromPosition } = useTimeLineChartStore();
const { getIsIssuePeeked } = useIssueDetail();
const isBlockComplete = !!block?.start_date && !!block?.target_date;
const duration = isBlockComplete ? getNumberOfDaysFromPosition(block?.position?.width) : undefined;
if (!block?.data) return null;
const isIssueSelected = selectionHelpers?.getIsEntitySelected(block.id);
const isIssueFocused = selectionHelpers?.getIsEntityActive(block.id);
const isBlockHoveredOn = isBlockActive(block.id);
return (
<div
className={cn("group/list-block", {
"rounded bg-custom-background-80": isDragging,
"rounded-l border border-r-0 border-custom-primary-70": getIsIssuePeeked(block.data.id),
"border border-r-0 border-custom-border-400": isIssueFocused,
})}
onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)}
>
<Row
className={cn("group w-full flex items-center gap-2 pr-4", {
"bg-custom-background-90": isBlockHoveredOn,
"bg-custom-primary-100/5 hover:bg-custom-primary-100/10": isIssueSelected,
"bg-custom-primary-100/10": isIssueSelected && isBlockHoveredOn,
})}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
{enableSelection && selectionHelpers && (
<div className="flex items-center gap-2 absolute left-1">
<MultipleSelectEntityAction
className={cn(
"opacity-0 pointer-events-none group-hover/list-block:opacity-100 group-hover/list-block:pointer-events-auto transition-opacity",
{
"opacity-100 pointer-events-auto": isIssueSelected,
}
)}
groupId={GANTT_SELECT_GROUP}
id={block.id}
selectionHelpers={selectionHelpers}
/>
</div>
)}
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
<div className="flex-grow truncate">
<IssueGanttSidebarBlock issueId={block.data.id} isEpic={isEpic} />
</div>
{duration && (
<div className="flex-shrink-0 text-sm text-custom-text-200">
<span>
{duration} day{duration > 1 ? "s" : ""}
</span>
</div>
)}
</div>
</Row>
</div>
);
});

View File

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

View File

@@ -0,0 +1,130 @@
"use client";
import type { RefObject } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// ui
import type { IBlockUpdateData } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { GanttLayoutListItemLoader } from "@/components/ui/loader/layouts/gantt-layout-loader";
//hooks
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import type { TSelectionHelper } from "@/hooks/use-multiple-select";
// local imports
import { useTimeLineChart } from "../../../../hooks/use-timeline-chart";
import { ETimeLineTypeType } from "../../contexts";
import { GanttDnDHOC } from "../gantt-dnd-HOC";
import { handleOrderChange } from "../utils";
import { IssuesSidebarBlock } from "./block";
type Props = {
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
ganttContainerRef: RefObject<HTMLDivElement>;
blockIds: string[];
enableReorder: boolean;
enableSelection: boolean;
showAllBlocks?: boolean;
selectionHelpers?: TSelectionHelper;
isEpic?: boolean;
};
export const IssueGanttSidebar: React.FC<Props> = observer((props) => {
const {
blockUpdateHandler,
blockIds,
enableReorder,
enableSelection,
loadMoreBlocks,
canLoadMoreBlocks,
ganttContainerRef,
showAllBlocks = false,
selectionHelpers,
isEpic = false,
} = props;
const { getBlockById } = useTimeLineChart(ETimeLineTypeType.ISSUE);
const {
issues: { getIssueLoader },
} = useIssuesStore();
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
const isPaginating = !!getIssueLoader();
useIntersectionObserver(
ganttContainerRef,
isPaginating ? null : intersectionElement,
loadMoreBlocks,
"100% 0% 100% 0%"
);
const handleOnDrop = (
draggingBlockId: string | undefined,
droppedBlockId: string | undefined,
dropAtEndOfList: boolean
) => {
handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler);
};
return (
<div>
{blockIds ? (
<>
{blockIds.map((blockId, index) => {
const block = getBlockById(blockId);
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 (!block || (!showAllBlocks && !isBlockVisibleOnSidebar)) return;
return (
<RenderIfVisible
key={block.id}
root={ganttContainerRef}
horizontalOffset={100}
verticalOffset={200}
shouldRecordHeights={false}
placeholderChildren={<GanttLayoutListItemLoader />}
>
<GanttDnDHOC
id={block.id}
isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder}
onDrop={handleOnDrop}
>
{(isDragging: boolean) => (
<IssuesSidebarBlock
block={block}
enableSelection={enableSelection}
isDragging={isDragging}
selectionHelpers={selectionHelpers}
isEpic={isEpic}
/>
)}
</GanttDnDHOC>
</RenderIfVisible>
);
})}
{canLoadMoreBlocks && (
<div ref={setIntersectionElement} className="p-2">
<div className="flex h-10 md:h-8 w-full items-center justify-between gap-1.5 rounded md:px-1 px-4 py-1.5 bg-custom-background-80 animate-pulse" />
</div>
)}
</>
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
</div>
);
});

View File

@@ -0,0 +1,58 @@
import { observer } from "mobx-react";
// Plane
import { Row } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants";
import { ModuleGanttSidebarBlock } from "@/components/modules";
// helpers
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
type Props = {
blockId: string;
isDragging: boolean;
};
export const ModulesSidebarBlock: React.FC<Props> = observer((props) => {
const { blockId, isDragging } = props;
// store hooks
const { getBlockById, updateActiveBlockId, isBlockActive, getNumberOfDaysFromPosition } = useTimeLineChartStore();
const block = getBlockById(blockId);
if (!block) return <></>;
const isBlockComplete = !!block.start_date && !!block.target_date;
const duration = isBlockComplete ? getNumberOfDaysFromPosition(block?.position?.width) : undefined;
return (
<div
className={cn({
"rounded bg-custom-background-80": isDragging,
})}
onMouseEnter={() => updateActiveBlockId(block.id)}
onMouseLeave={() => updateActiveBlockId(null)}
>
<Row
id={`sidebar-block-${block.id}`}
className={cn("group w-full flex items-center gap-2 pr-4", {
"bg-custom-background-90": isBlockActive(block.id),
})}
style={{
height: `${BLOCK_HEIGHT}px`,
}}
>
<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>
</Row>
</div>
);
});

View File

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

View File

@@ -0,0 +1,61 @@
"use client";
import { observer } from "mobx-react";
// ui
import type { IBlockUpdateData } from "@plane/types";
import { Loader } from "@plane/ui";
// components
// hooks
import { useTimeLineChart } from "@/hooks/use-timeline-chart";
//
import { ETimeLineTypeType } from "../../contexts";
import { GanttDnDHOC } from "../gantt-dnd-HOC";
import { handleOrderChange } from "../utils";
import { ModulesSidebarBlock } from "./block";
// types
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blockIds: string[];
enableReorder: boolean;
};
export const ModuleGanttSidebar: React.FC<Props> = observer((props) => {
const { blockUpdateHandler, blockIds, enableReorder } = props;
const { getBlockById } = useTimeLineChart(ETimeLineTypeType.MODULE);
const handleOnDrop = (
draggingBlockId: string | undefined,
droppedBlockId: string | undefined,
dropAtEndOfList: boolean
) => {
handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler);
};
return (
<div className="h-full">
{blockIds ? (
blockIds.map((blockId, index) => (
<GanttDnDHOC
key={blockId}
id={blockId}
isLastChild={index === blockIds.length - 1}
isDragEnabled={enableReorder}
onDrop={handleOnDrop}
>
{(isDragging: boolean) => <ModulesSidebarBlock blockId={blockId} isDragging={isDragging} />}
</GanttDnDHOC>
))
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
</div>
);
});

View File

@@ -0,0 +1,103 @@
import type { RefObject } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// components
import type { IBlockUpdateData } from "@plane/types";
import { Row, ERowVariant } from "@plane/ui";
import { cn } from "@plane/utils";
import { MultipleSelectGroupAction } from "@/components/core/multiple-select";
// helpers
// hooks
import type { TSelectionHelper } from "@/hooks/use-multiple-select";
// constants
import { GANTT_SELECT_GROUP, HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants";
type Props = {
blockIds: string[];
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
ganttContainerRef: RefObject<HTMLDivElement>;
enableReorder: boolean | ((blockId: string) => boolean);
enableSelection: boolean | ((blockId: string) => boolean);
sidebarToRender: (props: any) => React.ReactNode;
title: string;
quickAdd?: React.ReactNode | undefined;
selectionHelpers: TSelectionHelper;
isEpic?: boolean;
};
export const GanttChartSidebar: React.FC<Props> = observer((props) => {
const { t } = useTranslation();
const {
blockIds,
blockUpdateHandler,
enableReorder,
enableSelection,
sidebarToRender,
loadMoreBlocks,
canLoadMoreBlocks,
ganttContainerRef,
title,
quickAdd,
selectionHelpers,
isEpic = false,
} = props;
const isGroupSelectionEmpty = selectionHelpers.isGroupSelected(GANTT_SELECT_GROUP) === "empty";
return (
<Row
// DO NOT REMOVE THE ID
id="gantt-sidebar"
className="sticky left-0 z-10 min-h-full h-max flex-shrink-0 border-r-[0.5px] border-custom-border-200 bg-custom-background-100"
style={{
width: `${SIDEBAR_WIDTH}px`,
}}
variant={ERowVariant.HUGGING}
>
<Row
className="group/list-header box-border flex-shrink-0 flex items-end justify-between gap-2 border-b-[0.5px] border-custom-border-200 pb-2 pr-4 text-sm font-medium text-custom-text-300 sticky top-0 z-10 bg-custom-background-100"
style={{
height: `${HEADER_HEIGHT}px`,
}}
>
<div className={cn("flex items-center gap-2")}>
{enableSelection && (
<div className="flex-shrink-0 flex items-center w-3.5 absolute left-1">
<MultipleSelectGroupAction
className={cn(
"size-3.5 opacity-0 pointer-events-none group-hover/list-header:opacity-100 group-hover/list-header:pointer-events-auto !outline-none",
{
"opacity-100 pointer-events-auto": !isGroupSelectionEmpty,
}
)}
groupID={GANTT_SELECT_GROUP}
selectionHelpers={selectionHelpers}
/>
</div>
)}
<h6>{title}</h6>
</div>
<h6>{t("common.duration")}</h6>
</Row>
<Row variant={ERowVariant.HUGGING} className="min-h-full h-max bg-custom-background-100">
{sidebarToRender &&
sidebarToRender({
title,
blockUpdateHandler,
blockIds,
enableReorder,
enableSelection,
canLoadMoreBlocks,
ganttContainerRef,
loadMoreBlocks,
selectionHelpers,
isEpic,
})}
</Row>
{quickAdd ? quickAdd : null}
</Row>
);
});

View File

@@ -0,0 +1,42 @@
import type { ChartDataType, IBlockUpdateData, IGanttBlock } from "@plane/types";
export const handleOrderChange = (
draggingBlockId: string | undefined,
droppedBlockId: string | undefined,
dropAtEndOfList: boolean,
blockIds: string[] | null,
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock,
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void
) => {
if (!blockIds || !draggingBlockId || !droppedBlockId) return;
const sourceBlockIndex = blockIds.findIndex((id) => id === draggingBlockId);
const destinationBlockIndex = dropAtEndOfList ? blockIds.length : blockIds.findIndex((id) => id === droppedBlockId);
// return if dropped outside the list
if (sourceBlockIndex === -1 || destinationBlockIndex === -1 || sourceBlockIndex === destinationBlockIndex) return;
let updatedSortOrder = getBlockById(blockIds[sourceBlockIndex])?.sort_order ?? 0;
// update the sort order to the lowest if dropped at the top
if (destinationBlockIndex === 0) updatedSortOrder = (getBlockById(blockIds[0])?.sort_order ?? 0) - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destinationBlockIndex === blockIds.length)
updatedSortOrder = (getBlockById(blockIds[blockIds.length - 1])?.sort_order ?? 0) + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = getBlockById(blockIds[destinationBlockIndex])?.sort_order ?? 0;
const relativeDestinationSortingOrder = getBlockById(blockIds[destinationBlockIndex - 1])?.sort_order ?? 0;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(getBlockById(blockIds[sourceBlockIndex])?.data, {
sort_order: {
destinationIndex: destinationBlockIndex,
newSortOrder: updatedSortOrder,
sourceIndex: sourceBlockIndex,
},
});
};

View File

@@ -0,0 +1,131 @@
import type { ChartDataType, IGanttBlock } from "@plane/types";
import { addDaysToDate, findTotalDaysInRange, getDate } from "@plane/utils";
import { DEFAULT_BLOCK_WIDTH } from "../constants";
/**
* Generates Date by using Day, month and Year
* @param day
* @param month
* @param year
* @returns
*/
export const generateDate = (day: number, month: number, year: number) => new Date(year, month, day);
/**
* Returns number of days in month
* @param month
* @param year
* @returns
*/
export const getNumberOfDaysInMonth = (month: number, year: number) => {
const date = new Date(year, month + 1, 0);
return date.getDate();
};
/**
* Returns week number from date
* @param date
* @returns
*/
export const getWeekNumberByDate = (date: Date) => {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
const daysOffset = firstDayOfYear.getDay();
const firstWeekStart = firstDayOfYear.getTime() - daysOffset * 24 * 60 * 60 * 1000;
const weekStart = new Date(firstWeekStart);
const weekNumber = Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1;
return weekNumber;
};
/**
* Returns number of days between two dates
* @param startDate
* @param endDate
* @returns
*/
export const getNumberOfDaysBetweenTwoDates = (startDate: Date, endDate: Date) => {
let daysDifference: number = 0;
startDate.setHours(0, 0, 0, 0);
endDate.setHours(0, 0, 0, 0);
const timeDifference: number = startDate.getTime() - endDate.getTime();
daysDifference = Math.round(timeDifference / (1000 * 60 * 60 * 24));
return daysDifference;
};
/**
* returns a date corresponding to the position on the timeline chart
* @param position
* @param chartData
* @param offsetDays
* @returns
*/
export const getDateFromPositionOnGantt = (position: number, chartData: ChartDataType, offsetDays = 0) => {
const numberOfDaysSinceStart = Math.round(position / chartData.data.dayWidth) + offsetDays;
const newDate = addDaysToDate(chartData.data.startDate, numberOfDaysSinceStart);
if (!newDate) undefined;
return newDate;
};
/**
* returns the position and width of the block on the timeline chart from startDate and EndDate
* @param chartData
* @param itemData
* @returns
*/
export const getItemPositionWidth = (chartData: ChartDataType, itemData: IGanttBlock) => {
let scrollPosition: number = 0;
let scrollWidth: number = DEFAULT_BLOCK_WIDTH;
const { startDate: chartStartDate } = chartData.data;
const { start_date, target_date } = itemData;
const itemStartDate = getDate(start_date);
const itemTargetDate = getDate(target_date);
chartStartDate.setHours(0, 0, 0, 0);
itemStartDate?.setHours(0, 0, 0, 0);
itemTargetDate?.setHours(0, 0, 0, 0);
if (!itemStartDate && !itemTargetDate) return;
// get scroll position from the number of days and width of each day
scrollPosition = itemStartDate
? getPositionFromDate(chartData, itemStartDate, 0)
: getPositionFromDate(chartData, itemTargetDate!, -1 * DEFAULT_BLOCK_WIDTH + chartData.data.dayWidth);
if (itemStartDate && itemTargetDate) {
// get width of block
const widthTimeDifference: number = itemStartDate.getTime() - itemTargetDate.getTime();
const widthDaysDifference: number = Math.abs(Math.floor(widthTimeDifference / (1000 * 60 * 60 * 24)));
scrollWidth = (widthDaysDifference + 1) * chartData.data.dayWidth;
}
return { marginLeft: scrollPosition, width: scrollWidth };
};
export const getPositionFromDate = (chartData: ChartDataType, date: string | Date, offsetWidth: number) => {
const currDate = getDate(date);
const { startDate: chartStartDate } = chartData.data;
if (!currDate || !chartStartDate) return 0;
chartStartDate.setHours(0, 0, 0, 0);
currDate.setHours(0, 0, 0, 0);
// get number of days from chart start date to block's start date
const positionDaysDifference = Math.round(findTotalDaysInRange(chartStartDate, currDate, false) ?? 0);
if (!positionDaysDifference) return 0;
// get scroll position from the number of days and width of each day
return positionDaysDifference * chartData.data.dayWidth + offsetWidth;
};

View File

@@ -0,0 +1,4 @@
export * from "./week-view";
export * from "./month-view";
export * from "./quarter-view";
export * from "./helpers";

View File

@@ -0,0 +1,171 @@
import { cloneDeep, uniqBy } from "lodash-es";
// plane imports
import type { ChartDataType } from "@plane/types";
// local imports
import { months } from "../data";
import { getNumberOfDaysBetweenTwoDates, getNumberOfDaysInMonth } from "./helpers";
import type { IWeekBlock } from "./week-view";
import { getWeeksBetweenTwoDates } from "./week-view";
export interface IMonthBlock {
today: boolean;
month: number;
days: number;
monthData: {
key: number;
shortTitle: string;
title: string;
};
title: string;
year: number;
}
export interface IMonthView {
months: IMonthBlock[];
weeks: IWeekBlock[];
}
/**
* Generate Month Chart data
* @param monthPayload
* @param side
* @returns
*/
const generateMonthChart = (monthPayload: ChartDataType, side: null | "left" | "right", targetDate?: Date) => {
let renderState = cloneDeep(monthPayload);
const range: number = renderState.data.approxFilterRange || 6;
let filteredDates: IMonthView = { months: [], weeks: [] };
let minusDate: Date = new Date();
let plusDate: Date = new Date();
let startDate = new Date();
let endDate = new Date();
// if side is null generate months on both side of current date
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getMonthsViewBetweenTwoDates(minusDate, plusDate);
startDate = filteredDates.weeks[0]?.startDate;
endDate = filteredDates.weeks[filteredDates.weeks.length - 1]?.endDate;
renderState = {
...renderState,
data: {
...renderState.data,
startDate,
endDate,
},
};
}
// When side is left, generate more months on the left side of the start date
else if (side === "left") {
const chartStartDate = renderState.data.startDate;
const currentDate = targetDate ? targetDate : chartStartDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1);
plusDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1);
if (minusDate && plusDate) filteredDates = getMonthsViewBetweenTwoDates(minusDate, plusDate);
startDate = filteredDates.weeks[0]?.startDate;
endDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1);
renderState = {
...renderState,
data: { ...renderState.data, startDate },
};
}
// When side is right, generate more months on the right side of the end date
else if (side === "right") {
const chartEndDate = renderState.data.endDate;
const currentDate = targetDate ? targetDate : chartEndDate;
minusDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 1);
if (minusDate && plusDate) filteredDates = getMonthsViewBetweenTwoDates(minusDate, plusDate);
startDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1);
endDate = filteredDates.weeks[filteredDates.weeks.length - 1]?.endDate;
renderState = {
...renderState,
data: { ...renderState.data, endDate: filteredDates.weeks[filteredDates.weeks.length - 1]?.endDate },
};
}
const days = Math.abs(getNumberOfDaysBetweenTwoDates(startDate, endDate)) + 1;
const scrollWidth = days * monthPayload.data.dayWidth;
return { state: renderState, payload: filteredDates, scrollWidth: scrollWidth };
};
/**
* Get Month View data between two dates, i.e., Months and Weeks between two dates
* @param startDate
* @param endDate
* @returns
*/
const getMonthsViewBetweenTwoDates = (startDate: Date, endDate: Date): IMonthView => ({
months: getMonthsBetweenTwoDates(startDate, endDate),
weeks: getWeeksBetweenTwoDates(startDate, endDate, false),
});
/**
* generate array of months between two dates
* @param startDate
* @param endDate
* @returns
*/
export const getMonthsBetweenTwoDates = (startDate: Date, endDate: Date): IMonthBlock[] => {
const monthBlocks = [];
const startYear = startDate.getFullYear();
const startMonth = startDate.getMonth();
const today = new Date();
const todayMonth = today.getMonth();
const todayYear = today.getFullYear();
const currentDate = new Date(startYear, startMonth);
while (currentDate <= endDate) {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
monthBlocks.push({
year: currentYear,
month: currentMonth,
monthData: months[currentMonth],
title: `${months[currentMonth].title} ${currentYear}`,
days: getNumberOfDaysInMonth(currentMonth, currentYear),
today: todayMonth === currentMonth && todayYear === currentYear,
});
currentDate.setMonth(currentDate.getMonth() + 1);
}
return monthBlocks;
};
/**
* Merge two MonthView data payloads
* @param a
* @param b
* @returns
*/
const mergeMonthRenderPayloads = (a: IMonthView, b: IMonthView): IMonthView => ({
months: uniqBy([...a.months, ...b.months], (monthBlock) => `${monthBlock.month}_${monthBlock.year}`),
weeks: uniqBy(
[...a.weeks, ...b.weeks],
(weekBlock) => `${weekBlock.startDate.getTime()}_${weekBlock.endDate.getTime()}`
),
});
export const monthView = {
generateChart: generateMonthChart,
mergeRenderPayloads: mergeMonthRenderPayloads,
};

View File

@@ -0,0 +1,148 @@
//
import type { ChartDataType } from "@plane/types";
import { quarters } from "../data";
import { getNumberOfDaysBetweenTwoDates } from "./helpers";
import type { IMonthBlock } from "./month-view";
import { getMonthsBetweenTwoDates } from "./month-view";
export interface IQuarterMonthBlock {
children: IMonthBlock[];
quarterNumber: number;
shortTitle: string;
title: string;
year: number;
today: boolean;
}
/**
* Generate Quarter Chart data, which in turn are months in an array
* @param quarterPayload
* @param side
* @returns
*/
const generateQuarterChart = (quarterPayload: ChartDataType, side: null | "left" | "right", targetDate?: Date) => {
let renderState = quarterPayload;
const range: number = renderState.data.approxFilterRange || 12;
let filteredDates: IMonthBlock[] = [];
let minusDate: Date = new Date();
let plusDate: Date = new Date();
let startDate = new Date();
let endDate = new Date();
// if side is null generate months on both side of current date
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 0);
if (minusDate && plusDate) filteredDates = getMonthsBetweenTwoDates(minusDate, plusDate);
const startMonthBlock = filteredDates[0];
const endMonthBlock = filteredDates[filteredDates.length - 1];
startDate = new Date(startMonthBlock.year, startMonthBlock.month, 1);
endDate = new Date(endMonthBlock.year, endMonthBlock.month + 1, 0);
renderState = {
...renderState,
data: {
...renderState.data,
startDate,
endDate,
},
};
}
// When side is left, generate more months on the left side of the start date
else if (side === "left") {
const chartStartDate = renderState.data.startDate;
const currentDate = targetDate ? targetDate : chartStartDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range / 2, 1);
plusDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth() - 1, 1);
if (minusDate && plusDate) filteredDates = getMonthsBetweenTwoDates(minusDate, plusDate);
const startMonthBlock = filteredDates[0];
startDate = new Date(startMonthBlock.year, startMonthBlock.month, 1);
endDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1);
renderState = {
...renderState,
data: { ...renderState.data, startDate },
};
}
// When side is right, generate more months on the right side of the end date
else if (side === "right") {
const chartEndDate = renderState.data.endDate;
const currentDate = targetDate ? targetDate : chartEndDate;
minusDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth() + 1, 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range / 2, 1);
if (minusDate && plusDate) filteredDates = getMonthsBetweenTwoDates(minusDate, plusDate);
const endMonthBlock = filteredDates[filteredDates.length - 1];
startDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1);
endDate = new Date(endMonthBlock.year, endMonthBlock.month + 1, 0);
renderState = {
...renderState,
data: { ...renderState.data, endDate },
};
}
const days = Math.abs(getNumberOfDaysBetweenTwoDates(startDate, endDate)) + 1;
const scrollWidth = days * quarterPayload.data.dayWidth;
return { state: renderState, payload: filteredDates, scrollWidth: scrollWidth };
};
/**
* Merge two Quarter data payloads
* @param a
* @param b
* @returns
*/
const mergeQuarterRenderPayloads = (a: IMonthBlock[], b: IMonthBlock[]) => [...a, ...b];
/**
* Group array of Months into Quarters, returns an array og Quarters and it's children Months
* @param monthBlocks
* @returns
*/
export const groupMonthsToQuarters = (monthBlocks: IMonthBlock[]): IQuarterMonthBlock[] => {
const quartersMap: { [key: string]: IQuarterMonthBlock } = {};
const today = new Date();
const todayQuarterNumber = Math.floor(today.getMonth() / 3);
const todayYear = today.getFullYear();
for (const monthBlock of monthBlocks) {
const { month, year } = monthBlock;
const quarterNumber = Math.floor(month / 3);
const quarterKey = `Q${quarterNumber}-${year}`;
if (quartersMap[quarterKey]) {
quartersMap[quarterKey].children.push(monthBlock);
} else {
const quarterData = quarters[quarterNumber];
quartersMap[quarterKey] = {
children: [monthBlock],
quarterNumber,
shortTitle: quarterData.shortTitle,
title: `${quarterData.title} ${year}`,
year,
today: todayQuarterNumber === quarterNumber && todayYear === year,
};
}
}
return Object.values(quartersMap);
};
export const quarterView = {
generateChart: generateQuarterChart,
mergeRenderPayloads: mergeQuarterRenderPayloads,
};

View File

@@ -0,0 +1,214 @@
//
import type { ChartDataType } from "@plane/types";
import { EStartOfTheWeek } from "@plane/types";
import { months, generateWeeks } from "../data";
import { getNumberOfDaysBetweenTwoDates, getWeekNumberByDate } from "./helpers";
export interface IDayBlock {
date: Date;
day: number;
dayData: {
key: number;
shortTitle: string;
title: string;
abbreviation: string;
};
title: string;
today: boolean;
}
export interface IWeekBlock {
children?: IDayBlock[];
weekNumber: number;
weekData: {
shortTitle: string;
title: string;
};
title: string;
startDate: Date;
endDate: Date;
startMonth: number;
startYear: number;
endMonth: number;
endYear: number;
today: boolean;
}
/**
* Generate Week Chart data
* @param weekPayload
* @param side
* @returns
*/
const generateWeekChart = (
weekPayload: ChartDataType,
side: null | "left" | "right",
targetDate?: Date,
startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY
) => {
let renderState = weekPayload;
const range: number = renderState.data.approxFilterRange || 6;
let filteredDates: IWeekBlock[] = [];
let minusDate: Date = new Date();
let plusDate: Date = new Date();
let startDate = new Date();
let endDate = new Date();
// if side is null generate weeks on both side of current date
if (side === null) {
const currentDate = renderState.data.currentDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate());
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate());
if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate, true, startOfWeek);
startDate = filteredDates[0].startDate;
endDate = filteredDates[filteredDates.length - 1].endDate;
renderState = {
...renderState,
data: {
...renderState.data,
startDate,
endDate,
},
};
}
// When side is left, generate more weeks on the left side of the start date
else if (side === "left") {
const chartStartDate = renderState.data.startDate;
const currentDate = targetDate ? targetDate : chartStartDate;
minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1);
plusDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1);
if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate, true, startOfWeek);
startDate = filteredDates[0].startDate;
endDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1);
renderState = {
...renderState,
data: { ...renderState.data, startDate },
};
}
// When side is right, generate more weeks on the right side of the end date
else if (side === "right") {
const chartEndDate = renderState.data.endDate;
const currentDate = targetDate ? targetDate : chartEndDate;
minusDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1);
plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 1);
if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate, true, startOfWeek);
startDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1);
endDate = filteredDates[filteredDates.length - 1].endDate;
renderState = {
...renderState,
data: { ...renderState.data, endDate },
};
}
const days = Math.abs(getNumberOfDaysBetweenTwoDates(startDate, endDate)) + 1;
const scrollWidth = days * weekPayload.data.dayWidth;
return { state: renderState, payload: filteredDates, scrollWidth: scrollWidth };
};
/**
* Generate weeks array between two dates
* @param startDate
* @param endDate
* @param shouldPopulateDaysForWeek
* @returns
*/
export const getWeeksBetweenTwoDates = (
startDate: Date,
endDate: Date,
shouldPopulateDaysForWeek: boolean = true,
startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY
): IWeekBlock[] => {
const weeks: IWeekBlock[] = [];
const currentDate = new Date(startDate.getTime());
const today = new Date();
// Adjust the current date to the start of the week
const day = currentDate.getDay();
const diff = (day + 7 - startOfWeek) % 7; // Calculate days to subtract to get to startOfWeek
currentDate.setDate(currentDate.getDate() - diff);
while (currentDate <= endDate) {
const weekStartDate = new Date(currentDate.getTime());
const weekEndDate = new Date(currentDate.getTime() + 6 * 24 * 60 * 60 * 1000);
const monthAtStartOfTheWeek = weekStartDate.getMonth();
const yearAtStartOfTheWeek = weekStartDate.getFullYear();
const monthAtEndOfTheWeek = weekEndDate.getMonth();
const yearAtEndOfTheWeek = weekEndDate.getFullYear();
const weekNumber = getWeekNumberByDate(currentDate);
weeks.push({
children: shouldPopulateDaysForWeek ? populateDaysForWeek(weekStartDate, startOfWeek) : undefined,
weekNumber,
weekData: {
shortTitle: `w${weekNumber}`,
title: `Week ${weekNumber}`,
},
title:
monthAtStartOfTheWeek === monthAtEndOfTheWeek
? `${months[monthAtStartOfTheWeek].abbreviation} ${yearAtStartOfTheWeek}`
: `${months[monthAtStartOfTheWeek].abbreviation} ${yearAtStartOfTheWeek} - ${months[monthAtEndOfTheWeek].abbreviation} ${yearAtEndOfTheWeek}`,
startMonth: monthAtStartOfTheWeek,
startYear: yearAtStartOfTheWeek,
endMonth: monthAtEndOfTheWeek,
endYear: yearAtEndOfTheWeek,
startDate: weekStartDate,
endDate: weekEndDate,
today: today >= weekStartDate && today <= weekEndDate ? true : false,
});
currentDate.setDate(currentDate.getDate() + 7);
}
return weeks;
};
/**
* return back array of 7 days from the date provided
* @param startDate
* @returns
*/
const populateDaysForWeek = (startDate: Date, startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY): IDayBlock[] => {
const currentDate = new Date(startDate);
const days: IDayBlock[] = [];
const today = new Date();
const weekDays = generateWeeks(startOfWeek);
for (let i = 0; i < 7; i++) {
days.push({
date: new Date(currentDate),
day: currentDate.getDay(),
dayData: weekDays[i],
title: `${weekDays[i].abbreviation} ${currentDate.getDate()}`,
today: today.setHours(0, 0, 0, 0) == currentDate.setHours(0, 0, 0, 0),
});
currentDate.setDate(currentDate.getDate() + 1);
}
return days;
};
/**
* Merge two Week data payloads
* @param a
* @param b
* @returns
*/
const mergeWeekRenderPayloads = (a: IWeekBlock[], b: IWeekBlock[]) => [...a, ...b];
export const weekView = {
generateChart: generateWeekChart,
mergeRenderPayloads: mergeWeekRenderPayloads,
};