dev: gantt chart revamp (#1900)

* style: gantt chart polishing

* chore: sidebar y-axis drag and drop

* chore: remove y-axis drag and drop from the main content

* refactor: drop end function

* refactor: resizing logic

* chore: x-axis block move

* chore: x-axis block move flag

* chore: update scroll end logic

* style: modules gantt chart

* style: block background tint

* refactor: context dispatcher types

* refactor: draggable component

* chore: filters added to gantt chart

* refactor: folder structure

* style: cycle blocks

* chore: move to block arrow

* chore: move to block on the right side arrow

* chore: added proper comments for functions

* refactor: blocks render logic

* fix: x-axis drag and drop

* chore: minor ui fixes

* chore: remove link tag from blocks

---------

Co-authored-by: Aaryan Khandelwal <aaryan610@Aaryans-MacBook-Pro.local>
This commit is contained in:
Aaryan Khandelwal 2023-08-28 13:25:47 +05:30 committed by GitHub
parent a61e8370b5
commit 47abe9db5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 965 additions and 635 deletions

View File

@ -113,7 +113,6 @@ export const IssuesFilterView: React.FC = () => {
))} ))}
</div> </div>
)} )}
{issueView !== "gantt_chart" && (
<SelectFilters <SelectFilters
filters={filters} filters={filters}
onSelect={(option) => { onSelect={(option) => {
@ -149,7 +148,6 @@ export const IssuesFilterView: React.FC = () => {
direction="left" direction="left"
height="rg" height="rg"
/> />
)}
<Popover className="relative"> <Popover className="relative">
{({ open }) => ( {({ open }) => (
<> <>

View File

@ -114,7 +114,10 @@ export const AllViews: React.FC<Props> = ({
)} )}
</StrictModeDroppable> </StrictModeDroppable>
{groupedIssues ? ( {groupedIssues ? (
!isEmpty || issueView === "kanban" || issueView === "calendar" ? ( !isEmpty ||
issueView === "kanban" ||
issueView === "calendar" ||
issueView === "gantt_chart" ? (
<> <>
{issueView === "list" ? ( {issueView === "list" ? (
<AllLists <AllLists

View File

@ -0,0 +1,83 @@
import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// icons
import { ContrastIcon } from "components/icons";
// helpers
import { getDateRangeStatus, renderShortDate } from "helpers/date-time.helper";
// types
import { ICycle } from "types";
export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const cycleStatus = getDateRangeStatus(data?.start_date, data?.end_date);
return (
<div
className="flex items-center relative h-full w-full rounded"
style={{
backgroundColor:
cycleStatus === "current"
? "#09a953"
: cycleStatus === "upcoming"
? "#f7ae59"
: cycleStatus === "completed"
? "#3f76ff"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: "",
}}
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
>
<div className="absolute top-0 left-0 h-full w-full bg-custom-background-100/50" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{data?.name}</h5>
<div>
{renderShortDate(data?.start_date ?? "")} to {renderShortDate(data?.end_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="relative text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{data?.name}
</div>
</Tooltip>
</div>
);
};
export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const cycleStatus = getDateRangeStatus(data?.start_date, data?.end_date);
return (
<div
className="relative w-full flex items-center gap-2 h-full"
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
>
<ContrastIcon
className="h-5 w-5 flex-shrink-0"
color={`${
cycleStatus === "current"
? "#09a953"
: cycleStatus === "upcoming"
? "#f7ae59"
: cycleStatus === "completed"
? "#3f76ff"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
<h6 className="text-sm font-medium flex-grow truncate">{data?.name}</h6>
</div>
);
};

View File

@ -6,11 +6,8 @@ import useUser from "hooks/use-user";
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view"; import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components // components
import { import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
GanttChartRoot, import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
IssueGanttBlock,
renderIssueBlocksStructure,
} from "components/gantt-chart";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
@ -28,29 +25,20 @@ export const CycleIssuesGanttChartView = () => {
cycleId as string cycleId as string
); );
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
return ( return (
<div className="w-full h-full p-3"> <div className="w-full h-full">
<GanttChartRoot <GanttChartRoot
title="Cycles" border={false}
loaderTitle="Cycles" title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null} blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) => blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
} }
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} SidebarBlockRender={IssueGanttSidebarBlock}
blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />} BlockRender={IssueGanttBlock}
enableReorder={orderBy === "sort_order"} enableReorder={orderBy === "sort_order"}
bottomSpacing
/> />
</div> </div>
); );

View File

@ -9,7 +9,8 @@ import cyclesService from "services/cycles.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// components // components
import { CycleGanttBlock, GanttChartRoot, IBlockUpdateData } from "components/gantt-chart"; import { GanttChartRoot, IBlockUpdateData } from "components/gantt-chart";
import { CycleGanttBlock, CycleGanttSidebarBlock } from "components/cycles";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
@ -24,17 +25,6 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) =>
const { user } = useUser(); const { user } = useUser();
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => { const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
if (!workspaceSlug || !user) return; if (!workspaceSlug || !user) return;
@ -88,10 +78,11 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) =>
loaderTitle="Cycles" loaderTitle="Cycles"
blocks={cycles ? blockFormat(cycles) : null} blocks={cycles ? blockFormat(cycles) : null}
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)} blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} SidebarBlockRender={CycleGanttSidebarBlock}
blockRender={(data: any) => <CycleGanttBlock cycle={data as ICycle} />} BlockRender={CycleGanttBlock}
enableLeftDrag={false} enableBlockLeftResize={false}
enableRightDrag={false} enableBlockRightResize={false}
enableBlockMove={false}
/> />
</div> </div>
); );

View File

@ -0,0 +1,3 @@
export * from "./blocks";
export * from "./cycle-issues-layout";
export * from "./cycles-list-layout";

View File

@ -1,11 +1,10 @@
export * from "./cycles-list"; export * from "./cycles-list";
export * from "./active-cycle-details"; export * from "./active-cycle-details";
export * from "./active-cycle-stats"; export * from "./active-cycle-stats";
export * from "./cycles-list-gantt-chart"; export * from "./gantt-chart";
export * from "./cycles-view"; export * from "./cycles-view";
export * from "./delete-cycle-modal"; export * from "./delete-cycle-modal";
export * from "./form"; export * from "./form";
export * from "./gantt-chart";
export * from "./modal"; export * from "./modal";
export * from "./select"; export * from "./select";
export * from "./sidebar"; export * from "./sidebar";

View File

@ -106,6 +106,7 @@ function RadialProgressBar({ progress }: progress) {
</div> </div>
); );
} }
export const SingleCycleList: React.FC<TSingleStatProps> = ({ export const SingleCycleList: React.FC<TSingleStatProps> = ({
cycle, cycle,
handleEditCycle, handleEditCycle,

View File

@ -1,103 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
import { ICycle, IIssue, IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/module";
export const IssueGanttBlock = ({ issue }: { issue: IIssue }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div
className="flex-shrink-0 w-0.5 h-full"
style={{ backgroundColor: issue.state_detail?.color }}
/>
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{issue.name}</h5>
<div>
{renderShortDate(issue.start_date ?? "")} to{" "}
{renderShortDate(issue.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{issue.name}
</div>
</Tooltip>
</a>
</Link>
);
};
export const CycleGanttBlock = ({ cycle }: { cycle: ICycle }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${cycle.project}/cycles/${cycle.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div className="flex-shrink-0 w-0.5 h-full bg-custom-primary-100" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{cycle.name}</h5>
<div>
{renderShortDate(cycle.start_date ?? "")} to {renderShortDate(cycle.end_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{cycle.name}
</div>
</Tooltip>
</a>
</Link>
);
};
export const ModuleGanttBlock = ({ module }: { module: IModule }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div
className="flex-shrink-0 w-0.5 h-full"
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === module.status)?.color }}
/>
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{module.name}</h5>
<div>
{renderShortDate(module.start_date ?? "")} to{" "}
{renderShortDate(module.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{module.name}
</div>
</Tooltip>
</a>
</Link>
);
};

View File

@ -1,8 +1,7 @@
import { FC } from "react"; import { FC } from "react";
// react-beautiful-dnd // hooks
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd"; import { useChart } from "../hooks";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// helpers // helpers
import { ChartDraggable } from "../helpers/draggable"; import { ChartDraggable } from "../helpers/draggable";
import { renderDateFormat } from "helpers/date-time.helper"; import { renderDateFormat } from "helpers/date-time.helper";
@ -12,90 +11,59 @@ import { IBlockUpdateData, IGanttBlock } from "../types";
export const GanttChartBlocks: FC<{ export const GanttChartBlocks: FC<{
itemsContainerWidth: number; itemsContainerWidth: number;
blocks: IGanttBlock[] | null; blocks: IGanttBlock[] | null;
sidebarBlockRender: FC; BlockRender: React.FC<any>;
blockRender: FC;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableLeftDrag: boolean; enableBlockLeftResize: boolean;
enableRightDrag: boolean; enableBlockRightResize: boolean;
enableReorder: boolean; enableBlockMove: boolean;
}> = ({ }> = ({
itemsContainerWidth, itemsContainerWidth,
blocks, blocks,
sidebarBlockRender, BlockRender,
blockRender,
blockUpdateHandler, blockUpdateHandler,
enableLeftDrag, enableBlockLeftResize,
enableRightDrag, enableBlockRightResize,
enableReorder, enableBlockMove,
}) => { }) => {
const handleChartBlockPosition = ( const { activeBlock, dispatch } = useChart();
block: IGanttBlock,
totalBlockShifts: number,
dragDirection: "left" | "right"
) => {
let updatedDate = new Date();
if (dragDirection === "left") { // update the active block on hover
const originalDate = new Date(block.start_date); const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
const currentDay = originalDate.getDate(); type: "PARTIAL_UPDATE",
updatedDate = new Date(originalDate); payload: {
activeBlock: block,
updatedDate.setDate(currentDay - totalBlockShifts); },
} else {
const originalDate = new Date(block.target_date);
const currentDay = originalDate.getDate();
updatedDate = new Date(originalDate);
updatedDate.setDate(currentDay + totalBlockShifts);
}
blockUpdateHandler(block.data, {
[dragDirection === "left" ? "start_date" : "target_date"]: renderDateFormat(updatedDate),
}); });
}; };
const handleOrderChange = (result: DropResult) => { const handleChartBlockPosition = (
if (!blocks) return; block: IGanttBlock,
totalBlockShifts: number,
dragDirection: "left" | "right" | "move"
) => {
const originalStartDate = new Date(block.start_date);
const updatedStartDate = new Date(originalStartDate);
const { source, destination, draggableId } = result; const originalTargetDate = new Date(block.target_date);
const updatedTargetDate = new Date(originalTargetDate);
if (!destination) return; // update the start date on left resize
if (dragDirection === "left")
if (source.index === destination.index && document) { updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts);
// const draggedBlock = document.querySelector(`#${draggableId}`) as HTMLElement; // update the target date on right resize
// const blockStyles = window.getComputedStyle(draggedBlock); else if (dragDirection === "right")
updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
// console.log(blockStyles.marginLeft); // update both the dates on x-axis move
else if (dragDirection === "move") {
return; updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts);
updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
} }
let updatedSortOrder = blocks[source.index].sort_order; // call the block update handler with the updated dates
blockUpdateHandler(block.data, {
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; start_date: renderDateFormat(updatedStartDate),
else if (destination.index === blocks.length - 1) target_date: renderDateFormat(updatedTargetDate),
updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
}); });
}; };
@ -104,75 +72,29 @@ export const GanttChartBlocks: FC<{
className="relative z-[5] mt-[72px] h-full overflow-hidden overflow-y-auto" className="relative z-[5] mt-[72px] h-full overflow-hidden overflow-y-auto"
style={{ width: `${itemsContainerWidth}px` }} style={{ width: `${itemsContainerWidth}px` }}
> >
<DragDropContext onDragEnd={handleOrderChange}>
<StrictModeDroppable droppableId="gantt">
{(droppableProvided, droppableSnapshot) => (
<div
className="w-full space-y-2"
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
<>
{blocks && {blocks &&
blocks.length > 0 && blocks.length > 0 &&
blocks.map( blocks.map(
(block, index: number) => (block) =>
block.start_date && block.start_date &&
block.target_date && ( block.target_date && (
<Draggable
key={`block-${block.id}`}
draggableId={`block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided) => (
<div <div
className={ key={`block-${block.id}`}
droppableSnapshot.isDraggingOver ? "bg-custom-border-100/10" : "" className={`h-11 ${activeBlock?.id === block.id ? "bg-custom-background-80" : ""}`}
} onMouseEnter={() => updateActiveBlock(block)}
ref={provided.innerRef} onMouseLeave={() => updateActiveBlock(null)}
{...provided.draggableProps}
> >
<ChartDraggable <ChartDraggable
block={block} block={block}
BlockRender={BlockRender}
handleBlock={(...args) => handleChartBlockPosition(block, ...args)} handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
enableLeftDrag={enableLeftDrag} enableBlockLeftResize={enableBlockLeftResize}
enableRightDrag={enableRightDrag} enableBlockRightResize={enableBlockRightResize}
provided={provided} enableBlockMove={enableBlockMove}
> />
<div
className="rounded shadow-sm bg-custom-background-80 overflow-hidden h-9 flex items-center transition-all"
style={{
width: `${block.position?.width}px`,
}}
>
{blockRender({
...block.data,
})}
</div> </div>
</ChartDraggable>
</div>
)}
</Draggable>
) )
)} )}
{droppableProvided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
</DragDropContext>
{/* sidebar */}
{/* <div className="fixed top-0 bottom-0 w-[300px] flex-shrink-0 divide-y divide-custom-border-200 border-r border-custom-border-200 overflow-y-auto">
{blocks &&
blocks.length > 0 &&
blocks.map((block: any, _idx: number) => (
<div className="relative h-[40px] bg-custom-background-100" key={`sidebar-blocks-${_idx}`}>
{sidebarBlockRender(block?.data)}
</div>
))}
</div> */}
</div> </div>
); );
}; };

View File

@ -1,2 +1 @@
export * from "./block";
export * from "./blocks-display"; export * from "./blocks-display";

View File

@ -3,6 +3,7 @@ import { FC, useEffect, useState } from "react";
import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid"; import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
// components // components
import { GanttChartBlocks } from "components/gantt-chart"; import { GanttChartBlocks } from "components/gantt-chart";
import { GanttSidebar } from "../sidebar";
// import { HourChartView } from "./hours"; // import { HourChartView } from "./hours";
// import { DayChartView } from "./day"; // import { DayChartView } from "./day";
// import { WeekChartView } from "./week"; // import { WeekChartView } from "./week";
@ -25,7 +26,7 @@ import {
getMonthChartItemPositionWidthInMonth, getMonthChartItemPositionWidthInMonth,
} from "../views"; } from "../views";
// types // types
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types"; import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types";
// data // data
import { currentViewDataWithView } from "../data"; import { currentViewDataWithView } from "../data";
// context // context
@ -33,15 +34,17 @@ import { useChart } from "../hooks";
type ChartViewRootProps = { type ChartViewRootProps = {
border: boolean; border: boolean;
title: null | string; title: string;
loaderTitle: string; loaderTitle: string;
blocks: IGanttBlock[] | null; blocks: IGanttBlock[] | null;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
sidebarBlockRender: FC<any>; SidebarBlockRender: React.FC<any>;
blockRender: FC<any>; BlockRender: React.FC<any>;
enableLeftDrag: boolean; enableBlockLeftResize: boolean;
enableRightDrag: boolean; enableBlockRightResize: boolean;
enableBlockMove: boolean;
enableReorder: boolean; enableReorder: boolean;
bottomSpacing: boolean;
}; };
export const ChartViewRoot: FC<ChartViewRootProps> = ({ export const ChartViewRoot: FC<ChartViewRootProps> = ({
@ -50,22 +53,24 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
blocks = null, blocks = null,
loaderTitle, loaderTitle,
blockUpdateHandler, blockUpdateHandler,
sidebarBlockRender, SidebarBlockRender,
blockRender, BlockRender,
enableLeftDrag, enableBlockLeftResize,
enableRightDrag, enableBlockRightResize,
enableBlockMove,
enableReorder, enableReorder,
bottomSpacing,
}) => { }) => {
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
const [itemsContainerWidth, setItemsContainerWidth] = useState<number>(0); const [itemsContainerWidth, setItemsContainerWidth] = useState<number>(0);
const [fullScreenMode, setFullScreenMode] = useState<boolean>(false); const [fullScreenMode, setFullScreenMode] = useState<boolean>(false);
const [blocksSidebarView, setBlocksSidebarView] = useState<boolean>(false);
// blocks state management starts // blocks state management starts
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null); const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
const renderBlockStructure = (view: any, blocks: IGanttBlock[]) => const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } =
useChart();
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
blocks && blocks.length > 0 blocks && blocks.length > 0
? blocks.map((block: any) => ({ ? blocks.map((block: any) => ({
...block, ...block,
@ -74,16 +79,16 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
: []; : [];
useEffect(() => { useEffect(() => {
if (currentViewData && blocks && blocks.length > 0) if (currentViewData && blocks)
setChartBlocks(() => renderBlockStructure(currentViewData, blocks)); setChartBlocks(() => renderBlockStructure(currentViewData, blocks));
}, [currentViewData, blocks]); }, [currentViewData, blocks]);
// blocks state management ends // blocks state management ends
const handleChartView = (key: string) => updateCurrentViewRenderPayload(null, key); const handleChartView = (key: TGanttViews) => updateCurrentViewRenderPayload(null, key);
const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: string) => { const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => {
const selectedCurrentView = view; const selectedCurrentView: TGanttViews = view;
const selectedCurrentViewData: ChartDataType | undefined = const selectedCurrentViewData: ChartDataType | undefined =
selectedCurrentView && selectedCurrentView === currentViewData?.key selectedCurrentView && selectedCurrentView === currentViewData?.key
? currentViewData ? currentViewData
@ -155,6 +160,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const updatingCurrentLeftScrollPosition = (width: number) => { const updatingCurrentLeftScrollPosition = (width: number) => {
const scrollContainer = document.getElementById("scroll-container") as HTMLElement; const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
if (!scrollContainer) return;
scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft; scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft;
setItemsContainerWidth(width + scrollContainer?.scrollLeft); setItemsContainerWidth(width + scrollContainer?.scrollLeft);
}; };
@ -195,6 +203,8 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const clientVisibleWidth: number = scrollContainer?.clientWidth; const clientVisibleWidth: number = scrollContainer?.clientWidth;
const currentScrollPosition: number = scrollContainer?.scrollLeft; const currentScrollPosition: number = scrollContainer?.scrollLeft;
updateScrollLeft(currentScrollPosition);
const approxRangeLeft: number = const approxRangeLeft: number =
scrollWidth >= clientVisibleWidth + 1000 ? 1000 : scrollWidth - clientVisibleWidth; scrollWidth >= clientVisibleWidth + 1000 ? 1000 : scrollWidth - clientVisibleWidth;
const approxRangeRight: number = scrollWidth - (approxRangeLeft + clientVisibleWidth); const approxRangeRight: number = scrollWidth - (approxRangeLeft + clientVisibleWidth);
@ -205,16 +215,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
updateCurrentViewRenderPayload("left", currentView); updateCurrentViewRenderPayload("left", currentView);
}; };
useEffect(() => {
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
scrollContainer.addEventListener("scroll", onScroll);
return () => {
scrollContainer.removeEventListener("scroll", onScroll);
};
}, [renderView]);
return ( return (
<div <div
className={`${ className={`${
@ -225,44 +225,14 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
border ? `border border-custom-border-200` : `` border ? `border border-custom-border-200` : ``
} flex h-full flex-col rounded-sm select-none bg-custom-background-100 shadow`} } flex h-full flex-col rounded-sm select-none bg-custom-background-100 shadow`}
> >
{/* chart title */}
{/* <div className="flex w-full flex-shrink-0 flex-wrap items-center gap-5 gap-y-3 whitespace-nowrap p-2 border-b border-custom-border-200">
{title && (
<div className="text-lg font-medium flex gap-2 items-center">
<div>{title}</div>
<div className="text-xs rounded-full px-2 py-1 font-bold border border-custom-primary/75 bg-custom-primary/5 text-custom-text-100">
Gantt View Beta
</div>
</div>
)}
{blocks === null ? (
<div className="text-sm font-medium ml-auto">Loading...</div>
) : (
<div className="text-sm font-medium ml-auto">
{blocks.length} {loaderTitle}
</div>
)}
</div> */}
{/* chart header */} {/* chart header */}
<div className="flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap p-2"> <div className="flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2">
{/* <div
className="transition-all border border-custom-border-200 w-[30px] h-[30px] flex justify-center items-center cursor-pointer rounded-sm hover:bg-custom-background-80"
onClick={() => setBlocksSidebarView(() => !blocksSidebarView)}
>
{blocksSidebarView ? (
<XMarkIcon className="h-5 w-5" />
) : (
<Bars4Icon className="h-4 w-4" />
)}
</div> */}
{title && ( {title && (
<div className="text-lg font-medium flex gap-2 items-center"> <div className="text-lg font-medium flex gap-2 items-center">
<div>{title}</div> <div>{title}</div>
<div className="text-xs rounded-full px-2 py-1 font-bold border border-custom-primary/75 bg-custom-primary/5 text-custom-text-100"> {/* <div className="text-xs rounded-full px-2 py-1 font-bold border border-custom-primary/75 bg-custom-primary/5 text-custom-text-100">
Gantt View Beta Gantt View Beta
</div> </div> */}
</div> </div>
)} )}
@ -282,7 +252,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
allViews.map((_chatView: any, _idx: any) => ( allViews.map((_chatView: any, _idx: any) => (
<div <div
key={_chatView?.key} key={_chatView?.key}
className={`cursor-pointer rounded-sm border border-custom-border-200 p-1 px-2 text-xs ${ className={`cursor-pointer rounded-sm p-1 px-2 text-xs ${
currentView === _chatView?.key currentView === _chatView?.key
? `bg-custom-background-80` ? `bg-custom-background-80`
: `hover:bg-custom-background-90` : `hover:bg-custom-background-90`
@ -296,7 +266,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div <div
className={`cursor-pointer rounded-sm border border-custom-border-200 p-1 px-2 text-xs hover:bg-custom-background-80`} className="cursor-pointer rounded-sm p-1 px-2 text-xs hover:bg-custom-background-80"
onClick={handleToday} onClick={handleToday}
> >
Today Today
@ -316,26 +286,30 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
</div> </div>
{/* content */} {/* content */}
<div className="relative flex h-full w-full flex-1 overflow-hidden border-t border-custom-border-200">
<div <div
className="relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto" id="gantt-container"
id="scroll-container" className={`relative flex h-full w-full flex-1 overflow-hidden border-t border-custom-border-200 ${
bottomSpacing ? "mb-8" : ""
}`}
> >
{/* blocks components */} <div
{currentView && currentViewData && ( id="gantt-sidebar"
<GanttChartBlocks className="h-full w-1/4 flex flex-col border-r border-custom-border-200 space-y-3"
itemsContainerWidth={itemsContainerWidth} >
blocks={chartBlocks} <div className="h-[60px] border-b border-custom-border-200 box-border flex-shrink-0" />
sidebarBlockRender={sidebarBlockRender} <GanttSidebar
blockRender={blockRender} title={title}
blockUpdateHandler={blockUpdateHandler} blockUpdateHandler={blockUpdateHandler}
enableLeftDrag={enableLeftDrag} blocks={chartBlocks}
enableRightDrag={enableRightDrag} SidebarBlockRender={SidebarBlockRender}
enableReorder={enableReorder} enableReorder={enableReorder}
/> />
)} </div>
<div
{/* chart */} className="relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto horizontal-scroll-enable"
id="scroll-container"
onScroll={onScroll}
>
{/* {currentView && currentView === "hours" && <HourChartView />} */} {/* {currentView && currentView === "hours" && <HourChartView />} */}
{/* {currentView && currentView === "day" && <DayChartView />} */} {/* {currentView && currentView === "day" && <DayChartView />} */}
{/* {currentView && currentView === "week" && <WeekChartView />} */} {/* {currentView && currentView === "week" && <WeekChartView />} */}
@ -343,6 +317,19 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
{currentView && currentView === "month" && <MonthChartView />} {currentView && currentView === "month" && <MonthChartView />}
{/* {currentView && currentView === "quarter" && <QuarterChartView />} */} {/* {currentView && currentView === "quarter" && <QuarterChartView />} */}
{/* {currentView && currentView === "year" && <YearChartView />} */} {/* {currentView && currentView === "year" && <YearChartView />} */}
{/* blocks */}
{currentView && currentViewData && (
<GanttChartBlocks
itemsContainerWidth={itemsContainerWidth}
blocks={chartBlocks}
BlockRender={BlockRender}
blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
/>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,30 +17,50 @@ export const MonthChartView: FC<any> = () => {
monthBlocks.length > 0 && monthBlocks.length > 0 &&
monthBlocks.map((block, _idxRoot) => ( monthBlocks.map((block, _idxRoot) => (
<div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col"> <div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col">
<div className="relative border-b border-custom-border-200"> <div className="h-[60px] w-full">
<div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize"> <div className="relative h-[30px]">
<div className="sticky left-0 inline-flex whitespace-nowrap px-3 py-2 text-xs font-medium capitalize">
{block?.title} {block?.title}
</div> </div>
</div> </div>
<div className="flex w-full h-[30px]">
{block?.children &&
block?.children.length > 0 &&
block?.children.map((monthDay, _idx) => (
<div
key={`sub-title-${_idxRoot}-${_idx}`}
className="flex-shrink-0 border-b py-1 text-center capitalize border-custom-border-200"
style={{ width: `${currentViewData?.data.width}px` }}
>
<div className="text-xs space-x-1">
<span className="text-custom-text-200">
{monthDay.dayData.shortTitle[0]}
</span>{" "}
<span
className={
monthDay.today
? "bg-custom-primary-100 text-white px-1 rounded-full"
: ""
}
>
{monthDay.day}
</span>
</div>
</div>
))}
</div>
</div>
<div className="flex h-full w-full divide-x divide-custom-border-100/50"> <div className="flex h-full w-full divide-x divide-custom-border-100/50">
{block?.children && {block?.children &&
block?.children.length > 0 && block?.children.length > 0 &&
block?.children.map((monthDay, _idx) => ( block?.children.map((monthDay, _idx) => (
<div <div
key={`sub-title-${_idxRoot}-${_idx}`} key={`column-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap" className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData?.data.width}px` }} style={{ width: `${currentViewData?.data.width}px` }}
> >
<div
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
monthDay?.today
? `text-red-500 border-red-500`
: `border-custom-border-200`
}`}
>
<div>{monthDay?.title}</div>
</div>
<div <div
className={`relative h-full w-full flex-1 flex justify-center ${ className={`relative h-full w-full flex-1 flex justify-center ${
["sat", "sun"].includes(monthDay?.dayData?.shortTitle || "") ["sat", "sun"].includes(monthDay?.dayData?.shortTitle || "")
@ -48,9 +68,9 @@ export const MonthChartView: FC<any> = () => {
: `` : ``
}`} }`}
> >
{monthDay?.today && ( {/* {monthDay?.today && (
<div className="absolute top-0 bottom-0 w-[1px] bg-red-500" /> <div className="absolute top-0 bottom-0 w-[1px] bg-red-500" />
)} )} */}
</div> </div>
</div> </div>
))} ))}

View File

@ -32,16 +32,27 @@ export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({
currentViewData: currentViewDataWithView(initialView), currentViewData: currentViewDataWithView(initialView),
renderView: [], renderView: [],
allViews: allViewsWithData, allViews: allViewsWithData,
activeBlock: null,
}); });
const [scrollLeft, setScrollLeft] = useState(0);
const handleDispatch = (action: ChartContextActionPayload): ChartContextData => { const handleDispatch = (action: ChartContextActionPayload): ChartContextData => {
const newState = chartReducer(state, action); const newState = chartReducer(state, action);
dispatch(() => newState); dispatch(() => newState);
return newState; return newState;
}; };
const updateScrollLeft = (scrollLeft: number) => {
setScrollLeft(scrollLeft);
};
return ( return (
<ChartContext.Provider value={{ ...state, dispatch: handleDispatch }}> <ChartContext.Provider
value={{ ...state, scrollLeft, updateScrollLeft, dispatch: handleDispatch }}
>
{children} {children}
</ChartContext.Provider> </ChartContext.Provider>
); );

View File

@ -108,8 +108,8 @@ export const allViewsWithData: ChartDataType[] = [
startDate: new Date(), startDate: new Date(),
currentDate: new Date(), currentDate: new Date(),
endDate: new Date(), endDate: new Date(),
approxFilterRange: 8, approxFilterRange: 6,
width: 80, // it will preview monthly all dates with weekends highlighted with no limitations ex: title (1, 2, 3) width: 55, // it will preview monthly all dates with weekends highlighted with no limitations ex: title (1, 2, 3)
}, },
}, },
// { // {

View File

@ -1,45 +1,57 @@
import React, { useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
// react-beautiful-dnd // icons
import { DraggableProvided } from "react-beautiful-dnd"; import { Icon } from "components/ui";
// hooks
import { useChart } from "../hooks"; import { useChart } from "../hooks";
// types // types
import { IGanttBlock } from "../types"; import { IGanttBlock } from "../types";
type Props = { type Props = {
children: any;
block: IGanttBlock; block: IGanttBlock;
handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right") => void; BlockRender: React.FC<any>;
enableLeftDrag: boolean; handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right" | "move") => void;
enableRightDrag: boolean; enableBlockLeftResize: boolean;
provided: DraggableProvided; enableBlockRightResize: boolean;
enableBlockMove: boolean;
}; };
export const ChartDraggable: React.FC<Props> = ({ export const ChartDraggable: React.FC<Props> = ({
children,
block, block,
BlockRender,
handleBlock, handleBlock,
enableLeftDrag = true, enableBlockLeftResize,
enableRightDrag = true, enableBlockRightResize,
provided, enableBlockMove,
}) => { }) => {
const [isLeftResizing, setIsLeftResizing] = useState(false); const [isLeftResizing, setIsLeftResizing] = useState(false);
const [isRightResizing, setIsRightResizing] = useState(false); const [isRightResizing, setIsRightResizing] = useState(false);
const [isMoving, setIsMoving] = useState(false);
const [posFromLeft, setPosFromLeft] = useState<number | null>(null);
const parentDivRef = useRef<HTMLDivElement>(null);
const resizableRef = useRef<HTMLDivElement>(null); const resizableRef = useRef<HTMLDivElement>(null);
const { currentViewData } = useChart(); const { currentViewData, scrollLeft } = useChart();
// check if cursor reaches either end while resizing/dragging
const checkScrollEnd = (e: MouseEvent): number => { const checkScrollEnd = (e: MouseEvent): number => {
const SCROLL_THRESHOLD = 70;
let delWidth = 0; let delWidth = 0;
const ganttContainer = document.querySelector("#gantt-container") as HTMLElement;
const ganttSidebar = document.querySelector("#gantt-sidebar") as HTMLElement;
const scrollContainer = document.querySelector("#scroll-container") as HTMLElement; const scrollContainer = document.querySelector("#scroll-container") as HTMLElement;
const appSidebar = document.querySelector("#app-sidebar") as HTMLElement;
if (!ganttContainer || !ganttSidebar || !scrollContainer) return 0;
const posFromLeft = e.clientX; const posFromLeft = e.clientX;
// manually scroll to left if reached the left end while dragging // manually scroll to left if reached the left end while dragging
if (posFromLeft - appSidebar.clientWidth <= 70) { if (
posFromLeft - (ganttContainer.getBoundingClientRect().left + ganttSidebar.clientWidth) <=
SCROLL_THRESHOLD
) {
if (e.movementX > 0) return 0; if (e.movementX > 0) return 0;
delWidth = -5; delWidth = -5;
@ -48,8 +60,8 @@ export const ChartDraggable: React.FC<Props> = ({
} else delWidth = e.movementX; } else delWidth = e.movementX;
// manually scroll to right if reached the right end while dragging // manually scroll to right if reached the right end while dragging
const posFromRight = window.innerWidth - e.clientX; const posFromRight = ganttContainer.getBoundingClientRect().right - e.clientX;
if (posFromRight <= 70) { if (posFromRight <= SCROLL_THRESHOLD) {
if (e.movementX < 0) return 0; if (e.movementX < 0) return 0;
delWidth = 5; delWidth = 5;
@ -60,12 +72,11 @@ export const ChartDraggable: React.FC<Props> = ({
return delWidth; return delWidth;
}; };
const handleLeftDrag = () => { // handle block resize from the left end
if (!currentViewData || !resizableRef.current || !parentDivRef.current || !block.position) const handleBlockLeftResize = () => {
return; if (!currentViewData || !resizableRef.current || !block.position) return;
const resizableDiv = resizableRef.current; const resizableDiv = resizableRef.current;
const parentDiv = parentDivRef.current;
const columnWidth = currentViewData.data.width; const columnWidth = currentViewData.data.width;
@ -73,11 +84,9 @@ export const ChartDraggable: React.FC<Props> = ({
resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10); resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10); let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialMarginLeft = parseInt(parentDiv.style.marginLeft); let initialMarginLeft = parseInt(resizableDiv.style.marginLeft);
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!window) return;
let delWidth = 0; let delWidth = 0;
delWidth = checkScrollEnd(e); delWidth = checkScrollEnd(e);
@ -92,7 +101,7 @@ export const ChartDraggable: React.FC<Props> = ({
if (newWidth < columnWidth) return; if (newWidth < columnWidth) return;
resizableDiv.style.width = `${newWidth}px`; resizableDiv.style.width = `${newWidth}px`;
parentDiv.style.marginLeft = `${newMarginLeft}px`; resizableDiv.style.marginLeft = `${newMarginLeft}px`;
if (block.position) { if (block.position) {
block.position.width = newWidth; block.position.width = newWidth;
@ -100,6 +109,7 @@ export const ChartDraggable: React.FC<Props> = ({
} }
}; };
// remove event listeners and call block handler with the updated start date
const handleMouseUp = () => { const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
@ -115,9 +125,9 @@ export const ChartDraggable: React.FC<Props> = ({
document.addEventListener("mouseup", handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
}; };
const handleRightDrag = () => { // handle block resize from the right end
if (!currentViewData || !resizableRef.current || !parentDivRef.current || !block.position) const handleBlockRightResize = () => {
return; if (!currentViewData || !resizableRef.current || !block.position) return;
const resizableDiv = resizableRef.current; const resizableDiv = resizableRef.current;
@ -129,8 +139,6 @@ export const ChartDraggable: React.FC<Props> = ({
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10); let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!window) return;
let delWidth = 0; let delWidth = 0;
delWidth = checkScrollEnd(e); delWidth = checkScrollEnd(e);
@ -145,6 +153,7 @@ export const ChartDraggable: React.FC<Props> = ({
if (block.position) block.position.width = Math.max(newWidth, 80); if (block.position) block.position.width = Math.max(newWidth, 80);
}; };
// remove event listeners and call block handler with the updated target date
const handleMouseUp = () => { const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
@ -160,46 +169,148 @@ export const ChartDraggable: React.FC<Props> = ({
document.addEventListener("mouseup", handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
}; };
// handle block x-axis move
const handleBlockMove = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!enableBlockMove || !currentViewData || !resizableRef.current || !block.position) return;
e.preventDefault();
e.stopPropagation();
setIsMoving(true);
const resizableDiv = resizableRef.current;
const columnWidth = currentViewData.data.width;
const blockInitialMarginLeft = parseInt(resizableDiv.style.marginLeft);
let initialMarginLeft = parseInt(resizableDiv.style.marginLeft);
const handleMouseMove = (e: MouseEvent) => {
let delWidth = 0;
delWidth = checkScrollEnd(e);
// calculate new marginLeft and update the initial marginLeft using -=
const newMarginLeft = Math.round((initialMarginLeft += delWidth) / columnWidth) * columnWidth;
resizableDiv.style.marginLeft = `${newMarginLeft}px`;
if (block.position) block.position.marginLeft = newMarginLeft;
};
// remove event listeners and call block handler with the updated dates
const handleMouseUp = () => {
setIsMoving(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
const totalBlockShifts = Math.ceil(
(parseInt(resizableDiv.style.marginLeft) - blockInitialMarginLeft) / columnWidth
);
handleBlock(totalBlockShifts, "move");
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// scroll to a hidden block
const handleScrollToBlock = () => {
const scrollContainer = document.querySelector("#scroll-container") as HTMLElement;
if (!scrollContainer || !block.position) return;
// update container's scroll position to the block's position
scrollContainer.scrollLeft = block.position.marginLeft - 4;
};
// update block position from viewport's left end on scroll
useEffect(() => {
const block = resizableRef.current;
if (!block) return;
setPosFromLeft(block.getBoundingClientRect().left);
}, [scrollLeft]);
// check if block is hidden on either side
const isBlockHiddenOnLeft =
block.position?.marginLeft &&
block.position?.width &&
scrollLeft > block.position.marginLeft + block.position.width;
const isBlockHiddenOnRight = posFromLeft && window && posFromLeft > window.innerWidth;
return ( return (
<>
{/* move to left side hidden block button */}
{isBlockHiddenOnLeft && (
<div
className="fixed ml-1 mt-1.5 z-[1] h-8 w-8 grid place-items-center border border-custom-border-300 rounded cursor-pointer bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
onClick={handleScrollToBlock}
>
<Icon iconName="arrow_back" />
</div>
)}
{/* move to right side hidden block button */}
{isBlockHiddenOnRight && (
<div
className="fixed right-1 mt-1.5 z-[1] h-8 w-8 grid place-items-center border border-custom-border-300 rounded cursor-pointer bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
onClick={handleScrollToBlock}
>
<Icon iconName="arrow_forward" />
</div>
)}
<div <div
id={`block-${block.id}`} id={`block-${block.id}`}
ref={parentDivRef} ref={resizableRef}
className="relative group inline-flex cursor-pointer items-center font-medium transition-all" className="relative group cursor-pointer font-medium rounded shadow-sm h-full inline-flex items-center transition-all"
style={{ style={{
marginLeft: `${block.position?.marginLeft}px`, marginLeft: `${block.position?.marginLeft}px`,
width: `${block.position?.width}px`,
}} }}
> >
{enableLeftDrag && ( {/* left resize drag handle */}
{enableBlockLeftResize && (
<> <>
<div <div
onMouseDown={handleLeftDrag} onMouseDown={handleBlockLeftResize}
onMouseEnter={() => setIsLeftResizing(true)} onMouseEnter={() => setIsLeftResizing(true)}
onMouseLeave={() => setIsLeftResizing(false)} onMouseLeave={() => setIsLeftResizing(false)}
className="absolute top-1/2 -left-2.5 -translate-y-1/2 z-[1] w-6 h-10 bg-brand-backdrop rounded-md cursor-col-resize" className="absolute top-1/2 -left-2.5 -translate-y-1/2 z-[3] w-6 h-full rounded-md cursor-col-resize"
/> />
<div <div
className={`absolute top-1/2 -translate-y-1/2 w-1 h-4/5 rounded-sm bg-custom-background-80 transition-all duration-300 ${ className={`absolute top-1/2 -translate-y-1/2 w-1 h-7 rounded-sm bg-custom-background-100 transition-all duration-300 ${
isLeftResizing ? "-left-2.5" : "left-1" isLeftResizing ? "-left-2.5" : "left-1"
}`} }`}
/> />
</> </>
)} )}
{React.cloneElement(children, { ref: resizableRef, ...provided.dragHandleProps })} <div
{enableRightDrag && ( className="relative z-[2] rounded h-8 w-full flex items-center"
onMouseDown={handleBlockMove}
>
<BlockRender data={block.data} />
</div>
{/* right resize drag handle */}
{enableBlockRightResize && (
<> <>
<div <div
onMouseDown={handleRightDrag} onMouseDown={handleBlockRightResize}
onMouseEnter={() => setIsRightResizing(true)} onMouseEnter={() => setIsRightResizing(true)}
onMouseLeave={() => setIsRightResizing(false)} onMouseLeave={() => setIsRightResizing(false)}
className="absolute top-1/2 -right-2.5 -translate-y-1/2 z-[1] w-6 h-6 bg-brand-backdrop rounded-md cursor-col-resize" className="absolute top-1/2 -right-2.5 -translate-y-1/2 z-[2] w-6 h-full rounded-md cursor-col-resize"
/> />
<div <div
className={`absolute top-1/2 -translate-y-1/2 w-1 h-4/5 rounded-sm bg-custom-background-80 transition-all duration-300 ${ className={`absolute top-1/2 -translate-y-1/2 w-1 h-7 rounded-sm bg-custom-background-100 transition-all duration-300 ${
isRightResizing ? "-right-2.5" : "right-1" isRightResizing ? "-right-2.5" : "right-1"
}`} }`}
/> />
</> </>
)} )}
</div> </div>
</>
); );
}; };

View File

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

View File

@ -8,28 +8,32 @@ import { IBlockUpdateData, IGanttBlock } from "./types";
type GanttChartRootProps = { type GanttChartRootProps = {
border?: boolean; border?: boolean;
title: null | string; title: string;
loaderTitle: string; loaderTitle: string;
blocks: IGanttBlock[] | null; blocks: IGanttBlock[] | null;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
sidebarBlockRender: FC<any>; SidebarBlockRender: FC<any>;
blockRender: FC<any>; BlockRender: FC<any>;
enableLeftDrag?: boolean; enableBlockLeftResize?: boolean;
enableRightDrag?: boolean; enableBlockRightResize?: boolean;
enableBlockMove?: boolean;
enableReorder?: boolean; enableReorder?: boolean;
bottomSpacing?: boolean;
}; };
export const GanttChartRoot: FC<GanttChartRootProps> = ({ export const GanttChartRoot: FC<GanttChartRootProps> = ({
border = true, border = true,
title = null, title,
blocks, blocks,
loaderTitle = "blocks", loaderTitle = "blocks",
blockUpdateHandler, blockUpdateHandler,
sidebarBlockRender, SidebarBlockRender,
blockRender, BlockRender,
enableLeftDrag = true, enableBlockLeftResize = true,
enableRightDrag = true, enableBlockRightResize = true,
enableBlockMove = true,
enableReorder = true, enableReorder = true,
bottomSpacing = false,
}) => ( }) => (
<ChartContextProvider> <ChartContextProvider>
<ChartViewRoot <ChartViewRoot
@ -38,11 +42,13 @@ export const GanttChartRoot: FC<GanttChartRootProps> = ({
blocks={blocks} blocks={blocks}
loaderTitle={loaderTitle} loaderTitle={loaderTitle}
blockUpdateHandler={blockUpdateHandler} blockUpdateHandler={blockUpdateHandler}
sidebarBlockRender={sidebarBlockRender} SidebarBlockRender={SidebarBlockRender}
blockRender={blockRender} BlockRender={BlockRender}
enableLeftDrag={enableLeftDrag} enableBlockLeftResize={enableBlockLeftResize}
enableRightDrag={enableRightDrag} enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableReorder={enableReorder} enableReorder={enableReorder}
bottomSpacing={bottomSpacing}
/> />
</ChartContextProvider> </ChartContextProvider>
); );

View File

@ -0,0 +1,156 @@
// react-beautiful-dnd
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// hooks
import { useChart } from "./hooks";
// ui
import { Loader } from "components/ui";
// icons
import { EllipsisVerticalIcon } from "@heroicons/react/24/outline";
// types
import { IBlockUpdateData, IGanttBlock } from "./types";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
SidebarBlockRender: React.FC<any>;
enableReorder: boolean;
};
export const GanttSidebar: React.FC<Props> = ({
title,
blockUpdateHandler,
blocks,
SidebarBlockRender,
enableReorder,
}) => {
const { activeBlock, dispatch } = useChart();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1)
updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<StrictModeDroppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div
className="h-full overflow-y-auto pl-2.5"
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
<>
{blocks ? (
blocks.length > 0 ? (
blocks.map((block, index) => (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<div
className={`h-11 ${
snapshot.isDragging ? "bg-custom-background-80 rounded" : ""
}`}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={`group h-full w-full flex items-center gap-2 rounded-l px-2 pr-4 ${
activeBlock?.id === block.id ? "bg-custom-background-80" : ""
}`}
>
{enableReorder && (
<button
type="button"
className="rounded p-0.5 text-custom-sidebar-text-200 flex flex-shrink-0 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<EllipsisVerticalIcon className="h-4" />
<EllipsisVerticalIcon className="h-4 -ml-5" />
</button>
)}
<div className="flex-grow truncate w-full h-full">
<SidebarBlockRender data={block.data} />
</div>
</div>
</div>
)}
</Draggable>
))
) : (
<div className="text-custom-text-200 text-sm text-center mt-8">
No <span className="lowercase">{title}</span> found
</div>
)
) : (
<Loader className="pr-2 space-y-3">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
</DragDropContext>
);
};

View File

@ -27,19 +27,33 @@ export interface IBlockUpdateData {
target_date?: string; target_date?: string;
} }
export type TGanttViews = "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year";
export interface ChartContextData { export interface ChartContextData {
allViews: allViewsType[]; allViews: allViewsType[];
currentView: "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year"; currentView: TGanttViews;
currentViewData: ChartDataType | undefined; currentViewData: ChartDataType | undefined;
renderView: any; renderView: any;
activeBlock: IGanttBlock | null;
} }
export type ChartContextActionPayload = { export type ChartContextActionPayload =
type: "CURRENT_VIEW" | "CURRENT_VIEW_DATA" | "PARTIAL_UPDATE" | "RENDER_VIEW"; | {
payload: any; type: "CURRENT_VIEW";
}; payload: TGanttViews;
}
| {
type: "CURRENT_VIEW_DATA" | "RENDER_VIEW";
payload: ChartDataType | undefined;
}
| {
type: "PARTIAL_UPDATE";
payload: Partial<ChartContextData>;
};
export interface ChartContextReducer extends ChartContextData { export interface ChartContextReducer extends ChartContextData {
scrollLeft: number;
updateScrollLeft: (scrollLeft: number) => void;
dispatch: (action: ChartContextActionPayload) => void; dispatch: (action: ChartContextActionPayload) => void;
} }

View File

@ -21,6 +21,7 @@ export const getStateGroupIcon = (
width={width} width={width}
height={height} height={height}
color={color ?? STATE_GROUP_COLORS["backlog"]} color={color ?? STATE_GROUP_COLORS["backlog"]}
className="flex-shrink-0"
/> />
); );
case "unstarted": case "unstarted":
@ -29,6 +30,7 @@ export const getStateGroupIcon = (
width={width} width={width}
height={height} height={height}
color={color ?? STATE_GROUP_COLORS["unstarted"]} color={color ?? STATE_GROUP_COLORS["unstarted"]}
className="flex-shrink-0"
/> />
); );
case "started": case "started":
@ -37,6 +39,7 @@ export const getStateGroupIcon = (
width={width} width={width}
height={height} height={height}
color={color ?? STATE_GROUP_COLORS["started"]} color={color ?? STATE_GROUP_COLORS["started"]}
className="flex-shrink-0"
/> />
); );
case "completed": case "completed":
@ -45,6 +48,7 @@ export const getStateGroupIcon = (
width={width} width={width}
height={height} height={height}
color={color ?? STATE_GROUP_COLORS["completed"]} color={color ?? STATE_GROUP_COLORS["completed"]}
className="flex-shrink-0"
/> />
); );
case "cancelled": case "cancelled":
@ -53,6 +57,7 @@ export const getStateGroupIcon = (
width={width} width={width}
height={height} height={height}
color={color ?? STATE_GROUP_COLORS["cancelled"]} color={color ?? STATE_GROUP_COLORS["cancelled"]}
className="flex-shrink-0"
/> />
); );
default: default:

View File

@ -0,0 +1,67 @@
import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// icons
import { getStateGroupIcon } from "components/icons";
// helpers
import { findTotalDaysInRange, renderShortDate } from "helpers/date-time.helper";
// types
import { IIssue } from "types";
export const IssueGanttBlock = ({ data }: { data: IIssue }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<div
className="flex items-center relative h-full w-full rounded"
style={{ backgroundColor: data?.state_detail?.color }}
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)}
>
<div className="absolute top-0 left-0 h-full w-full bg-custom-background-100/50" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{data?.name}</h5>
<div>
{renderShortDate(data?.start_date ?? "")} to{" "}
{renderShortDate(data?.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="relative text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{data?.name}
</div>
</Tooltip>
</div>
);
};
// rendering issues on gantt sidebar
export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const duration = findTotalDaysInRange(data?.start_date ?? "", data?.target_date ?? "", true);
return (
<div
className="relative w-full flex items-center gap-2 h-full"
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)}
>
{getStateGroupIcon(data?.state_detail?.group, "14", "14", data?.state_detail?.color)}
<div className="text-xs text-custom-text-300 flex-shrink-0">
{data?.project_detail?.identifier} {data?.sequence_id}
</div>
<div className="flex items-center justify-between gap-2 w-full flex-grow truncate">
<h6 className="text-sm font-medium flex-grow truncate">{data?.name}</h6>
<span className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</span>
</div>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./blocks";
export * from "./layout";

View File

@ -6,11 +6,8 @@ import useUser from "hooks/use-user";
import useGanttChartIssues from "hooks/gantt-chart/issue-view"; import useGanttChartIssues from "hooks/gantt-chart/issue-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components // components
import { import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
GanttChartRoot, import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
IssueGanttBlock,
renderIssueBlocksStructure,
} from "components/gantt-chart";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
@ -27,17 +24,6 @@ export const IssueGanttChartView = () => {
projectId as string projectId as string
); );
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "#rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
return ( return (
<div className="w-full h-full"> <div className="w-full h-full">
<GanttChartRoot <GanttChartRoot
@ -48,9 +34,10 @@ export const IssueGanttChartView = () => {
blockUpdateHandler={(block, payload) => blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
} }
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} BlockRender={IssueGanttBlock}
blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />} SidebarBlockRender={IssueGanttSidebarBlock}
enableReorder={orderBy === "sort_order"} enableReorder={orderBy === "sort_order"}
bottomSpacing
/> />
</div> </div>
); );

View File

@ -0,0 +1,55 @@
import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
import { IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/module";
export const ModuleGanttBlock = ({ data }: { data: IModule }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<div
className="relative flex items-center w-full h-full rounded"
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === data?.status)?.color }}
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/modules/${data?.id}`)}
>
<div className="absolute top-0 left-0 h-full w-full bg-custom-background-100/50" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{data?.name}</h5>
<div>
{renderShortDate(data?.start_date ?? "")} to{" "}
{renderShortDate(data?.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="relative text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{data?.name}
</div>
</Tooltip>
</div>
);
};
export const ModuleGanttSidebarBlock = ({ data }: { data: IModule }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<div
className="relative w-full flex items-center gap-2 h-full"
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/modules/${data.id}`)}
>
<h6 className="text-sm font-medium flex-grow truncate">{data.name}</h6>
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./blocks";
export * from "./module-issues-layout";
export * from "./modules-list-layout";

View File

@ -8,11 +8,8 @@ import useUser from "hooks/use-user";
import useGanttChartModuleIssues from "hooks/gantt-chart/module-issues-view"; import useGanttChartModuleIssues from "hooks/gantt-chart/module-issues-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components // components
import { import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
GanttChartRoot, import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
IssueGanttBlock,
renderIssueBlocksStructure,
} from "components/gantt-chart";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
@ -32,29 +29,20 @@ export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
moduleId as string moduleId as string
); );
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
return ( return (
<div className="w-full h-full p-3"> <div className="w-full h-full">
<GanttChartRoot <GanttChartRoot
title="Modules" border={false}
loaderTitle="Modules" title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null} blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) => blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
} }
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} SidebarBlockRender={IssueGanttSidebarBlock}
blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />} BlockRender={IssueGanttBlock}
enableReorder={orderBy === "sort_order"} enableReorder={orderBy === "sort_order"}
bottomSpacing
/> />
</div> </div>
); );

View File

@ -1,6 +1,7 @@
import { FC } from "react"; import { FC } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link";
import { KeyedMutator } from "swr"; import { KeyedMutator } from "swr";
@ -9,11 +10,10 @@ import modulesService from "services/modules.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// components // components
import { GanttChartRoot, IBlockUpdateData, ModuleGanttBlock } from "components/gantt-chart"; import { GanttChartRoot, IBlockUpdateData } from "components/gantt-chart";
import { ModuleGanttBlock, ModuleGanttSidebarBlock } from "components/modules";
// types // types
import { IModule } from "types"; import { IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/module";
type Props = { type Props = {
modules: IModule[]; modules: IModule[];
@ -26,19 +26,6 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules })
const { user } = useUser(); const { user } = useUser();
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === data.status)?.color,
}}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => { const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => {
if (!workspaceSlug || !user) return; if (!workspaceSlug || !user) return;
@ -98,8 +85,8 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules })
loaderTitle="Modules" loaderTitle="Modules"
blocks={modules ? blockFormat(modules) : null} blocks={modules ? blockFormat(modules) : null}
blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} SidebarBlockRender={ModuleGanttSidebarBlock}
blockRender={(data: any) => <ModuleGanttBlock module={data as IModule} />} BlockRender={ModuleGanttBlock}
/> />
</div> </div>
); );

View File

@ -4,6 +4,5 @@ export * from "./delete-module-modal";
export * from "./form"; export * from "./form";
export * from "./gantt-chart"; export * from "./gantt-chart";
export * from "./modal"; export * from "./modal";
export * from "./modules-list-gantt-chart";
export * from "./sidebar"; export * from "./sidebar";
export * from "./single-module-card"; export * from "./single-module-card";

View File

@ -7,11 +7,8 @@ import useGanttChartViewIssues from "hooks/gantt-chart/view-issues-view";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components // components
import { import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
GanttChartRoot, import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
IssueGanttBlock,
renderIssueBlocksStructure,
} from "components/gantt-chart";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
@ -29,28 +26,18 @@ export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
viewId as string viewId as string
); );
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
return ( return (
<div className="w-full h-full p-3"> <div className="w-full h-full">
<GanttChartRoot <GanttChartRoot
title="Issue Views" border={false}
loaderTitle="Issue Views" title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null} blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={(block, payload) => blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
} }
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} SidebarBlockRender={IssueGanttSidebarBlock}
blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />} BlockRender={IssueGanttBlock}
/> />
</div> </div>
); );

View File

@ -19,6 +19,7 @@ export const ORDER_BY_OPTIONS: Array<{
{ name: "Manual", key: "sort_order" }, { name: "Manual", key: "sort_order" },
{ name: "Last created", key: "-created_at" }, { name: "Last created", key: "-created_at" },
{ name: "Last updated", key: "-updated_at" }, { name: "Last updated", key: "-updated_at" },
{ name: "Start date", key: "start_date" },
{ name: "Priority", key: "priority" }, { name: "Priority", key: "priority" },
]; ];

View File

@ -374,3 +374,32 @@ export const getAllTimeIn30MinutesInterval = (): Array<{
{ label: "11:00", value: "11:00" }, { label: "11:00", value: "11:00" },
{ label: "11:30", value: "11:30" }, { label: "11:30", value: "11:30" },
]; ];
/**
* @returns {number} total number of days in range
* @description Returns total number of days in range
* @param {string} startDate
* @param {string} endDate
* @param {boolean} inclusive
* @example checkIfStringIsDate("2021-01-01", "2021-01-08") // 8
*/
export const findTotalDaysInRange = (
startDate: Date | string,
endDate: Date | string,
inclusive: boolean
): number => {
if (!startDate || !endDate) return 0;
startDate = new Date(startDate);
endDate = new Date(endDate);
// find number of days between startDate and endDate
const diffInTime = endDate.getTime() - startDate.getTime();
const diffInDays = diffInTime / (1000 * 3600 * 24);
// if inclusive is true, add 1 to diffInDays
if (inclusive) return diffInDays + 1;
return diffInDays;
};

View File

@ -18,7 +18,13 @@ const useGanttChartCycleIssues = (
order_by: orderBy, order_by: orderBy,
type: filters?.type ? filters?.type : undefined, type: filters?.type ? filters?.type : undefined,
sub_issue: showSubIssues, sub_issue: showSubIssues,
start_target_date: true, assignees: filters?.assignees ? filters?.assignees.join(",") : undefined,
state: filters?.state ? filters?.state.join(",") : undefined,
priority: filters?.priority ? filters?.priority.join(",") : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
target_date: filters?.target_date ? filters?.target_date.join(",") : undefined,
start_target_date: true, // to fetch only issues with a start and target date
}; };
// all issues under the workspace and project // all issues under the workspace and project

View File

@ -14,7 +14,13 @@ const useGanttChartIssues = (workspaceSlug: string | undefined, projectId: strin
order_by: orderBy, order_by: orderBy,
type: filters?.type ? filters?.type : undefined, type: filters?.type ? filters?.type : undefined,
sub_issue: showSubIssues, sub_issue: showSubIssues,
start_target_date: true, assignees: filters?.assignees ? filters?.assignees.join(",") : undefined,
state: filters?.state ? filters?.state.join(",") : undefined,
priority: filters?.priority ? filters?.priority.join(",") : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
target_date: filters?.target_date ? filters?.target_date.join(",") : undefined,
start_target_date: true, // to fetch only issues with a start and target date
}; };
// all issues under the workspace and project // all issues under the workspace and project

View File

@ -18,7 +18,13 @@ const useGanttChartModuleIssues = (
order_by: orderBy, order_by: orderBy,
type: filters?.type ? filters?.type : undefined, type: filters?.type ? filters?.type : undefined,
sub_issue: showSubIssues, sub_issue: showSubIssues,
start_target_date: true, assignees: filters?.assignees ? filters?.assignees.join(",") : undefined,
state: filters?.state ? filters?.state.join(",") : undefined,
priority: filters?.priority ? filters?.priority.join(",") : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
target_date: filters?.target_date ? filters?.target_date.join(",") : undefined,
start_target_date: true, // to fetch only issues with a start and target date
}; };
// all issues under the workspace and project // all issues under the workspace and project

View File

@ -18,7 +18,7 @@ const useGanttChartViewIssues = (
// all issues under the view // all issues under the view
const { data: ganttIssues, mutate: mutateGanttIssues } = useSWR( const { data: ganttIssues, mutate: mutateGanttIssues } = useSWR(
workspaceSlug && projectId && viewId workspaceSlug && projectId && viewId
? VIEW_ISSUES(viewId.toString(), { ...viewGanttParams, start_target_date: true }) ? VIEW_ISSUES(viewId.toString(), { ...viewGanttParams, order_by, start_target_date: true })
: null, : null,
workspaceSlug && projectId && viewId workspaceSlug && projectId && viewId
? () => ? () =>

View File

@ -18,10 +18,10 @@ import {
SingleModuleCard, SingleModuleCard,
} from "components/modules"; } from "components/modules";
// ui // ui
import { EmptyState, Loader, PrimaryButton } from "components/ui"; import { EmptyState, Icon, Loader, PrimaryButton, Tooltip } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
import { PlusIcon, Squares2X2Icon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
// images // images
import emptyModule from "public/empty-state/module.svg"; import emptyModule from "public/empty-state/module.svg";
// types // types
@ -30,7 +30,18 @@ import type { NextPage } from "next";
// fetch-keys // fetch-keys
import { MODULE_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; import { MODULE_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
// helper // helper
import { truncateText } from "helpers/string.helper"; import { replaceUnderscoreIfSnakeCase, truncateText } from "helpers/string.helper";
const moduleViewOptions: { type: "grid" | "gantt_chart"; icon: any }[] = [
{
type: "gantt_chart",
icon: "view_timeline",
},
{
type: "grid",
icon: "table_rows",
},
];
const ProjectModules: NextPage = () => { const ProjectModules: NextPage = () => {
const [selectedModule, setSelectedModule] = useState<SelectModuleType>(); const [selectedModule, setSelectedModule] = useState<SelectModuleType>();
@ -64,6 +75,7 @@ const ProjectModules: NextPage = () => {
useEffect(() => { useEffect(() => {
if (createUpdateModule) return; if (createUpdateModule) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
setSelectedModule(undefined); setSelectedModule(undefined);
clearTimeout(timer); clearTimeout(timer);
@ -79,6 +91,31 @@ const ProjectModules: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
right={ right={
<div className="flex items-center gap-2">
{moduleViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>
}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
modulesView === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setModulesView(option.type)}
>
<Icon
iconName={option.icon}
className={`!text-base ${option.type === "grid" ? "rotate-90" : ""}`}
/>
</button>
</Tooltip>
))}
<PrimaryButton <PrimaryButton
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={() => { onClick={() => {
@ -89,6 +126,7 @@ const ProjectModules: NextPage = () => {
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add Module Add Module
</PrimaryButton> </PrimaryButton>
</div>
} }
> >
<CreateUpdateModuleModal <CreateUpdateModuleModal
@ -99,34 +137,9 @@ const ProjectModules: NextPage = () => {
/> />
{modules ? ( {modules ? (
modules.length > 0 ? ( modules.length > 0 ? (
<div className="space-y-5 p-8 flex flex-col h-full overflow-hidden"> <>
<div className="flex gap-4 justify-between">
<h3 className="text-2xl font-semibold text-custom-text-100">Modules</h3>
<div className="flex items-center gap-x-1">
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-80 ${
modulesView === "grid" ? "bg-custom-background-80" : ""
}`}
onClick={() => setModulesView("grid")}
>
<Squares2X2Icon className="h-4 w-4 text-custom-text-200" />
</button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded outline-none duration-300 hover:bg-custom-background-80 ${
modulesView === "gantt_chart" ? "bg-custom-background-80" : ""
}`}
onClick={() => setModulesView("gantt_chart")}
>
<span className="material-symbols-rounded text-custom-text-200 text-[18px] rotate-90">
waterfall_chart
</span>
</button>
</div>
</div>
{modulesView === "grid" && ( {modulesView === "grid" && (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto p-8">
<div className="grid grid-cols-1 gap-9 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-9 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((module) => ( {modules.map((module) => (
<SingleModuleCard <SingleModuleCard
@ -142,7 +155,7 @@ const ProjectModules: NextPage = () => {
{modulesView === "gantt_chart" && ( {modulesView === "gantt_chart" && (
<ModulesListGanttChartView modules={modules} mutateModules={mutateModules} /> <ModulesListGanttChartView modules={modules} mutateModules={mutateModules} />
)} )}
</div> </>
) : ( ) : (
<EmptyState <EmptyState
title="Manage your project with modules" title="Manage your project with modules"

View File

@ -251,7 +251,9 @@ export type TIssueOrderByOptions =
| "target_date" | "target_date"
| "-target_date" | "-target_date"
| "estimate__point" | "estimate__point"
| "-estimate__point"; | "-estimate__point"
| "start_date"
| "-start_date";
export interface IIssueViewOptions { export interface IIssueViewOptions {
group_by: TIssueGroupByOptions; group_by: TIssueGroupByOptions;