feat: init
This commit is contained in:
115
apps/web/core/components/gantt-chart/blocks/block-row.tsx
Normal file
115
apps/web/core/components/gantt-chart/blocks/block-row.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
105
apps/web/core/components/gantt-chart/blocks/block.tsx
Normal file
105
apps/web/core/components/gantt-chart/blocks/block.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
78
apps/web/core/components/gantt-chart/chart/header.tsx
Normal file
78
apps/web/core/components/gantt-chart/chart/header.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
4
apps/web/core/components/gantt-chart/chart/index.ts
Normal file
4
apps/web/core/components/gantt-chart/chart/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./views";
|
||||
export * from "./header";
|
||||
export * from "./main-content";
|
||||
export * from "./root";
|
||||
235
apps/web/core/components/gantt-chart/chart/main-content.tsx
Normal file
235
apps/web/core/components/gantt-chart/chart/main-content.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
219
apps/web/core/components/gantt-chart/chart/root.tsx
Normal file
219
apps/web/core/components/gantt-chart/chart/root.tsx
Normal 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;
|
||||
});
|
||||
@@ -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 <></>;
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./month";
|
||||
export * from "./quarter";
|
||||
export * from "./week";
|
||||
105
apps/web/core/components/gantt-chart/chart/views/month.tsx
Normal file
105
apps/web/core/components/gantt-chart/chart/views/month.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
94
apps/web/core/components/gantt-chart/chart/views/quarter.tsx
Normal file
94
apps/web/core/components/gantt-chart/chart/views/quarter.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
93
apps/web/core/components/gantt-chart/chart/views/week.tsx
Normal file
93
apps/web/core/components/gantt-chart/chart/views/week.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
11
apps/web/core/components/gantt-chart/constants.ts
Normal file
11
apps/web/core/components/gantt-chart/constants.ts
Normal 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";
|
||||
16
apps/web/core/components/gantt-chart/contexts/index.tsx
Normal file
16
apps/web/core/components/gantt-chart/contexts/index.tsx
Normal 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;
|
||||
};
|
||||
106
apps/web/core/components/gantt-chart/data/index.ts
Normal file
106
apps/web/core/components/gantt-chart/data/index.ts
Normal 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);
|
||||
102
apps/web/core/components/gantt-chart/helpers/add-block.tsx
Normal file
102
apps/web/core/components/gantt-chart/helpers/add-block.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
71
apps/web/core/components/gantt-chart/helpers/draggable.tsx
Normal file
71
apps/web/core/components/gantt-chart/helpers/draggable.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
2
apps/web/core/components/gantt-chart/helpers/index.ts
Normal file
2
apps/web/core/components/gantt-chart/helpers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./add-block";
|
||||
export * from "./draggable";
|
||||
4
apps/web/core/components/gantt-chart/index.ts
Normal file
4
apps/web/core/components/gantt-chart/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./chart";
|
||||
export * from "./helpers";
|
||||
export * from "./root";
|
||||
export * from "./sidebar";
|
||||
94
apps/web/core/components/gantt-chart/root.tsx
Normal file
94
apps/web/core/components/gantt-chart/root.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
117
apps/web/core/components/gantt-chart/sidebar/gantt-dnd-HOC.tsx
Normal file
117
apps/web/core/components/gantt-chart/sidebar/gantt-dnd-HOC.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
3
apps/web/core/components/gantt-chart/sidebar/index.ts
Normal file
3
apps/web/core/components/gantt-chart/sidebar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./issues";
|
||||
export * from "./modules";
|
||||
export * from "./root";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sidebar";
|
||||
130
apps/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx
Normal file
130
apps/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sidebar";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
103
apps/web/core/components/gantt-chart/sidebar/root.tsx
Normal file
103
apps/web/core/components/gantt-chart/sidebar/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
42
apps/web/core/components/gantt-chart/sidebar/utils.ts
Normal file
42
apps/web/core/components/gantt-chart/sidebar/utils.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
131
apps/web/core/components/gantt-chart/views/helpers.ts
Normal file
131
apps/web/core/components/gantt-chart/views/helpers.ts
Normal 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;
|
||||
};
|
||||
4
apps/web/core/components/gantt-chart/views/index.ts
Normal file
4
apps/web/core/components/gantt-chart/views/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./week-view";
|
||||
export * from "./month-view";
|
||||
export * from "./quarter-view";
|
||||
export * from "./helpers";
|
||||
171
apps/web/core/components/gantt-chart/views/month-view.ts
Normal file
171
apps/web/core/components/gantt-chart/views/month-view.ts
Normal 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,
|
||||
};
|
||||
148
apps/web/core/components/gantt-chart/views/quarter-view.ts
Normal file
148
apps/web/core/components/gantt-chart/views/quarter-view.ts
Normal 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,
|
||||
};
|
||||
214
apps/web/core/components/gantt-chart/views/week-view.ts
Normal file
214
apps/web/core/components/gantt-chart/views/week-view.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user