snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
26
apps/web/core/components/base-layouts/constants.ts
Normal file
26
apps/web/core/components/base-layouts/constants.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { BoardOutline, ListOutline, TimelineOutline } from "@makeplane/propel/icons";
|
||||
import type { IBaseLayoutConfig } from "@plane/types";
|
||||
|
||||
export const BASE_LAYOUTS: IBaseLayoutConfig[] = [
|
||||
{
|
||||
key: "list",
|
||||
icon: ListOutline,
|
||||
label: "List Layout",
|
||||
},
|
||||
{
|
||||
key: "kanban",
|
||||
icon: BoardOutline,
|
||||
label: "Board Layout",
|
||||
},
|
||||
{
|
||||
key: "gantt",
|
||||
icon: TimelineOutline,
|
||||
label: "Gantt Layout",
|
||||
},
|
||||
];
|
||||
8
apps/web/core/components/base-layouts/gantt/index.ts
Normal file
8
apps/web/core/components/base-layouts/gantt/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export { BaseGanttLayout } from "./layout";
|
||||
export { BaseGanttSidebar } from "./sidebar";
|
||||
146
apps/web/core/components/base-layouts/gantt/layout.tsx
Normal file
146
apps/web/core/components/base-layouts/gantt/layout.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { GANTT_TIMELINE_TYPE } from "@plane/types";
|
||||
import type {
|
||||
IBaseLayoutsGanttItem,
|
||||
IBaseLayoutsGanttProps,
|
||||
TGanttBlockUpdateData,
|
||||
IBlockUpdateDependencyData,
|
||||
} from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
import { TimeLineTypeContext } from "@/components/gantt-chart/contexts";
|
||||
import { GanttChartRoot } from "@/components/gantt-chart/root";
|
||||
import { BaseGanttSidebar } from "./sidebar";
|
||||
|
||||
export const BaseGanttLayout = observer(function BaseGanttLayout<T extends IBaseLayoutsGanttItem>(
|
||||
props: IBaseLayoutsGanttProps<T>
|
||||
) {
|
||||
const {
|
||||
items,
|
||||
groupedItemIds,
|
||||
groups,
|
||||
renderBlock,
|
||||
renderSidebar,
|
||||
onBlockUpdate,
|
||||
onDateUpdate,
|
||||
enableBlockLeftResize = false,
|
||||
enableBlockRightResize = false,
|
||||
enableBlockMove = false,
|
||||
enableReorder = false,
|
||||
enableAddBlock = false,
|
||||
enableSelection = false,
|
||||
enableDependency = false,
|
||||
showAllBlocks = false,
|
||||
showToday = true,
|
||||
border = false,
|
||||
title = "Items",
|
||||
loaderTitle = "items",
|
||||
quickAdd,
|
||||
loadMoreItems,
|
||||
isLoading: _isLoading,
|
||||
className,
|
||||
timelineType: timelineTypeKey = GANTT_TIMELINE_TYPE.ISSUE,
|
||||
} = props;
|
||||
|
||||
// Flatten all grouped item IDs into a single array for gantt
|
||||
// Gantt doesn't typically show groups, it shows all items on a timeline
|
||||
const blockIds = useMemo(() => {
|
||||
const allIds: string[] = [];
|
||||
groups.forEach((group) => {
|
||||
const itemIds = groupedItemIds[group.id] || [];
|
||||
allIds.push(...itemIds);
|
||||
});
|
||||
return allIds;
|
||||
}, [groups, groupedItemIds]);
|
||||
|
||||
// Block update handler - transforms base layout item updates to gantt block updates
|
||||
const handleBlockUpdate = useCallback(
|
||||
(block: T, payload: TGanttBlockUpdateData) => {
|
||||
if (onBlockUpdate) {
|
||||
onBlockUpdate(block, payload);
|
||||
}
|
||||
},
|
||||
[onBlockUpdate]
|
||||
);
|
||||
|
||||
// Block renderer - wraps the user's render function
|
||||
const blockToRender = useCallback((item: T) => renderBlock(item), [renderBlock]);
|
||||
|
||||
// Sidebar renderer - uses custom or default
|
||||
const sidebarToRender = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(sidebarProps: any) => {
|
||||
if (renderSidebar) {
|
||||
// If custom sidebar renderer provided, use it
|
||||
return (
|
||||
<BaseGanttSidebar {...sidebarProps} items={items} renderItem={renderSidebar} loadMoreItems={loadMoreItems} />
|
||||
);
|
||||
}
|
||||
// Otherwise use default sidebar
|
||||
return (
|
||||
<BaseGanttSidebar {...sidebarProps} items={items} renderItem={renderBlock} loadMoreItems={loadMoreItems} />
|
||||
);
|
||||
},
|
||||
[renderSidebar, renderBlock, items, loadMoreItems]
|
||||
);
|
||||
|
||||
const timelineType = GANTT_TIMELINE_TYPE[timelineTypeKey];
|
||||
|
||||
// Date update handler - transforms IBlockUpdateDependencyData to TGanttDateUpdate
|
||||
const handleDateUpdate = useCallback(
|
||||
async (updates: IBlockUpdateDependencyData[]) => {
|
||||
if (onDateUpdate) {
|
||||
// Transform IBlockUpdateDependencyData[] to TGanttDateUpdate[]
|
||||
const transformedUpdates = updates.map((update) => ({
|
||||
id: update.id,
|
||||
start_date: update.start_date,
|
||||
target_date: update.target_date,
|
||||
}));
|
||||
await onDateUpdate(transformedUpdates);
|
||||
}
|
||||
},
|
||||
[onDateUpdate]
|
||||
);
|
||||
|
||||
// Load more handler - wraps loadMoreItems to match expected signature
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (loadMoreItems) {
|
||||
loadMoreItems(""); // Pass empty string as default group ID
|
||||
}
|
||||
}, [loadMoreItems]);
|
||||
|
||||
return (
|
||||
<TimeLineTypeContext.Provider value={timelineType}>
|
||||
<div className={cn("h-full w-full", className)}>
|
||||
<GanttChartRoot
|
||||
border={border}
|
||||
title={title}
|
||||
loaderTitle={loaderTitle}
|
||||
blockIds={blockIds}
|
||||
blockUpdateHandler={handleBlockUpdate}
|
||||
blockToRender={blockToRender}
|
||||
sidebarToRender={sidebarToRender}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableReorder={enableReorder}
|
||||
enableAddBlock={enableAddBlock}
|
||||
enableSelection={enableSelection}
|
||||
enableDependency={enableDependency}
|
||||
showAllBlocks={showAllBlocks}
|
||||
showToday={showToday}
|
||||
quickAdd={quickAdd}
|
||||
updateBlockDates={onDateUpdate ? handleDateUpdate : undefined}
|
||||
loadMoreBlocks={loadMoreItems ? handleLoadMore : undefined}
|
||||
canLoadMoreBlocks={!!loadMoreItems} // Enable pagination if loadMoreItems is provided
|
||||
/>
|
||||
</div>
|
||||
</TimeLineTypeContext.Provider>
|
||||
);
|
||||
});
|
||||
155
apps/web/core/components/base-layouts/gantt/sidebar.tsx
Normal file
155
apps/web/core/components/base-layouts/gantt/sidebar.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { IBaseLayoutsBaseItem, IBlockUpdateData } from "@plane/types";
|
||||
import { Loader, Row } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
|
||||
import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants";
|
||||
import { GanttDnDHOC } from "@/components/gantt-chart/sidebar/gantt-dnd-HOC";
|
||||
import { handleOrderChange } from "@/components/gantt-chart/sidebar/utils";
|
||||
import { GanttLayoutListItemLoader } from "@/components/ui/loader/layouts/gantt-layout-loader";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
|
||||
|
||||
type Props<T extends IBaseLayoutsBaseItem> = {
|
||||
blockUpdateHandler: (block: T, payload: IBlockUpdateData) => void;
|
||||
canLoadMoreBlocks?: boolean;
|
||||
loadMoreItems?: (groupId: string) => void;
|
||||
ganttContainerRef: RefObject<HTMLDivElement | null>;
|
||||
blockIds: string[];
|
||||
enableReorder: boolean;
|
||||
showAllBlocks?: boolean;
|
||||
items: Record<string, T>;
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const BaseGanttSidebar = observer(function BaseGanttSidebar<T extends IBaseLayoutsBaseItem>(props: Props<T>) {
|
||||
const {
|
||||
blockUpdateHandler,
|
||||
blockIds,
|
||||
enableReorder,
|
||||
loadMoreItems,
|
||||
canLoadMoreBlocks,
|
||||
ganttContainerRef,
|
||||
showAllBlocks = false,
|
||||
items,
|
||||
renderItem,
|
||||
} = props;
|
||||
|
||||
const { getBlockById, updateActiveBlockId, isBlockActive, getNumberOfDaysFromPosition } = useTimeLineChartStore();
|
||||
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const isPaginating = false; // TODO: Add proper pagination state
|
||||
|
||||
useIntersectionObserver(
|
||||
ganttContainerRef,
|
||||
isPaginating ? null : intersectionElement,
|
||||
loadMoreItems ? () => loadMoreItems("") : undefined,
|
||||
"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 item = items[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 null;
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<RenderIfVisible
|
||||
key={blockId}
|
||||
root={ganttContainerRef}
|
||||
horizontalOffset={100}
|
||||
verticalOffset={200}
|
||||
shouldRecordHeights={false}
|
||||
placeholderChildren={<GanttLayoutListItemLoader />}
|
||||
>
|
||||
<GanttDnDHOC
|
||||
id={blockId}
|
||||
isLastChild={index === blockIds.length - 1}
|
||||
isDragEnabled={enableReorder}
|
||||
onDrop={handleOnDrop}
|
||||
>
|
||||
{(isDragging: boolean) => {
|
||||
const block = getBlockById(blockId);
|
||||
const isBlockComplete = !!block?.start_date && !!block?.target_date;
|
||||
const duration = isBlockComplete ? getNumberOfDaysFromPosition(block?.position?.width) : undefined;
|
||||
const isBlockHoveredOn = isBlockActive(blockId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("group/list-block", {
|
||||
"rounded-sm bg-layer-1": isDragging,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(blockId)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
>
|
||||
<Row
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 bg-layer-transparent pr-4 hover:bg-layer-transparent-hover",
|
||||
{
|
||||
"bg-layer-transparent-hover": isBlockHoveredOn,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">{renderItem(item)}</div>
|
||||
{duration && (
|
||||
<div className="flex-shrink-0 text-13 text-secondary">
|
||||
<span>
|
||||
{duration} day{duration > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</GanttDnDHOC>
|
||||
</RenderIfVisible>
|
||||
);
|
||||
})}
|
||||
{canLoadMoreBlocks && (
|
||||
<div ref={setIntersectionElement} className="p-2">
|
||||
<div className="flex h-10 w-full animate-pulse items-center justify-between gap-1.5 rounded-sm bg-layer-1 px-4 py-1.5 md:h-8 md:px-1" />
|
||||
</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,61 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
|
||||
interface UseGroupDropTargetProps {
|
||||
groupId: string;
|
||||
enableDragDrop?: boolean;
|
||||
onDrop?: (itemId: string, targetId: string | null, sourceGroupId: string, targetGroupId: string) => void;
|
||||
}
|
||||
|
||||
interface DragSourceData {
|
||||
id: string;
|
||||
groupId: string;
|
||||
type: "ITEM" | "GROUP";
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that turns an element into a valid drop target for group drag-and-drop.
|
||||
*
|
||||
* @returns groupRef (attach to the droppable container) and isDraggingOver (for visual feedback)
|
||||
*/
|
||||
export const useGroupDropTarget = ({ groupId, enableDragDrop = false, onDrop }: UseGroupDropTargetProps) => {
|
||||
const groupRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = groupRef.current;
|
||||
if (!element || !enableDragDrop || !onDrop) return;
|
||||
|
||||
const cleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({ groupId, type: "GROUP" }),
|
||||
|
||||
canDrop: ({ source }) => {
|
||||
const data = (source?.data || {}) as Partial<DragSourceData>;
|
||||
return data.type === "ITEM" && !!data.groupId && data.groupId !== groupId;
|
||||
},
|
||||
|
||||
onDragEnter: () => setIsDraggingOver(true),
|
||||
onDragLeave: () => setIsDraggingOver(false),
|
||||
|
||||
onDrop: ({ source }) => {
|
||||
setIsDraggingOver(false);
|
||||
const data = (source?.data || {}) as Partial<DragSourceData>;
|
||||
if (data.type !== "ITEM" || !data.id || !data.groupId) return;
|
||||
if (data.groupId !== groupId) {
|
||||
onDrop(data.id, null, data.groupId, groupId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [groupId, enableDragDrop, onDrop]);
|
||||
|
||||
return { groupRef, isDraggingOver };
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
|
||||
type UseLayoutStateProps =
|
||||
| {
|
||||
mode: "external";
|
||||
externalCollapsedGroups: string[];
|
||||
externalOnToggleGroup: (groupId: string) => void;
|
||||
enableAutoScroll?: boolean;
|
||||
}
|
||||
| {
|
||||
mode?: "internal";
|
||||
enableAutoScroll?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for managing layout state including:
|
||||
* - Collapsed/expanded group tracking (internal or external)
|
||||
* - Auto-scroll setup for drag-and-drop
|
||||
*/
|
||||
export const useLayoutState = (props: UseLayoutStateProps = { mode: "internal" }) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Internal fallback state
|
||||
const [internalCollapsedGroups, setInternalCollapsedGroups] = useState<string[]>([]);
|
||||
|
||||
// Stable internal toggle function
|
||||
const internalToggleGroup = useCallback((groupId: string) => {
|
||||
setInternalCollapsedGroups((prev) =>
|
||||
prev.includes(groupId) ? prev.filter((id) => id !== groupId) : [...prev, groupId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const useExternal = props.mode === "external";
|
||||
const collapsedGroups = useExternal ? props.externalCollapsedGroups : internalCollapsedGroups;
|
||||
const onToggleGroup = useExternal ? props.externalOnToggleGroup : internalToggleGroup;
|
||||
|
||||
// Enable auto-scroll for DnD
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element || !props.enableAutoScroll) return;
|
||||
|
||||
const cleanup = combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
})
|
||||
);
|
||||
|
||||
return cleanup;
|
||||
}, [props.enableAutoScroll]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
collapsedGroups,
|
||||
onToggleGroup,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { IGroupHeaderProps } from "@plane/types";
|
||||
|
||||
export function GroupHeader({ group, itemCount, onToggleGroup }: IGroupHeaderProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggleGroup(group.id)}
|
||||
className="flex w-full items-center gap-2 text-13 font-medium text-secondary"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{group.icon}
|
||||
<span>{group.name}</span>
|
||||
</div>
|
||||
<span className="text-11 text-tertiary">{itemCount}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
104
apps/web/core/components/base-layouts/kanban/group.tsx
Normal file
104
apps/web/core/components/base-layouts/kanban/group.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IBaseLayoutsKanbanItem, IBaseLayoutsKanbanGroupProps } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useGroupDropTarget } from "../hooks/use-group-drop-target";
|
||||
import { GroupHeader } from "./group-header";
|
||||
import { BaseKanbanItem } from "./item";
|
||||
|
||||
export const BaseKanbanGroup = observer(function BaseKanbanGroup<T extends IBaseLayoutsKanbanItem>(
|
||||
props: IBaseLayoutsKanbanGroupProps<T>
|
||||
) {
|
||||
const {
|
||||
group,
|
||||
itemIds,
|
||||
items,
|
||||
renderItem,
|
||||
renderGroupHeader,
|
||||
isCollapsed,
|
||||
onToggleGroup,
|
||||
enableDragDrop = false,
|
||||
onDrop,
|
||||
canDrag,
|
||||
groupClassName,
|
||||
loadMoreItems: _loadMoreItems,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { groupRef, isDraggingOver } = useGroupDropTarget({
|
||||
groupId: group.id,
|
||||
enableDragDrop,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={groupRef}
|
||||
className={cn(
|
||||
"relative flex max-h-full w-[350px] flex-shrink-0 flex-col overflow-y-auto rounded-md border-[1px] border-transparent bg-layer-1 p-2 pt-0",
|
||||
{
|
||||
"bg-layer-1": isDraggingOver,
|
||||
},
|
||||
groupClassName
|
||||
)}
|
||||
>
|
||||
{/* Group Header */}
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 cursor-pointer px-1 py-2">
|
||||
{renderGroupHeader ? (
|
||||
renderGroupHeader({ group, itemCount: itemIds.length, isCollapsed, onToggleGroup })
|
||||
) : (
|
||||
<GroupHeader
|
||||
group={group}
|
||||
itemCount={itemIds.length}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleGroup={onToggleGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group Items */}
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
{itemIds.map((itemId, index) => {
|
||||
const item = items[itemId];
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<BaseKanbanItem
|
||||
key={itemId}
|
||||
item={item}
|
||||
index={index}
|
||||
groupId={group.id}
|
||||
renderItem={renderItem}
|
||||
enableDragDrop={enableDragDrop}
|
||||
canDrag={canDrag}
|
||||
onDrop={onDrop}
|
||||
isLast={index === itemIds.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{itemIds.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8 text-13 text-tertiary">
|
||||
{t("common.no_items_in_this_group")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggingOver && enableDragDrop && (
|
||||
<div className="absolute top-0 left-0 z-[2] flex h-full w-full items-center justify-center rounded-sm border-[1px] border-strong bg-layer-1/85 text-13 font-medium text-tertiary">
|
||||
<div className="my-8 flex flex-col items-center rounded-sm p-3 text-secondary">
|
||||
{t("common.drop_here_to_move")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
48
apps/web/core/components/base-layouts/kanban/item.tsx
Normal file
48
apps/web/core/components/base-layouts/kanban/item.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { observer } from "mobx-react";
|
||||
import type { IBaseLayoutsKanbanItem, IBaseLayoutsKanbanItemProps } from "@plane/types";
|
||||
|
||||
export const BaseKanbanItem = observer(function BaseKanbanItem<T extends IBaseLayoutsKanbanItem>(
|
||||
props: IBaseLayoutsKanbanItemProps<T>
|
||||
) {
|
||||
const { item, groupId, renderItem, enableDragDrop, canDrag } = props;
|
||||
|
||||
const itemRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const isDragAllowed = canDrag ? canDrag(item) : true;
|
||||
|
||||
// Setup draggable and drop target
|
||||
useEffect(() => {
|
||||
const element = itemRef.current;
|
||||
if (!element || !enableDragDrop) return;
|
||||
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
canDrag: () => isDragAllowed,
|
||||
getInitialData: () => ({ id: item.id, type: "ITEM", groupId }),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({ id: item.id, groupId, type: "ITEM" }),
|
||||
canDrop: ({ source }) => source?.data?.id !== item.id,
|
||||
})
|
||||
);
|
||||
}, [enableDragDrop, isDragAllowed, item.id, groupId]);
|
||||
|
||||
const renderedItem = renderItem(item, groupId);
|
||||
|
||||
return (
|
||||
<div ref={itemRef} className="cursor-pointer">
|
||||
{renderedItem}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
74
apps/web/core/components/base-layouts/kanban/layout.tsx
Normal file
74
apps/web/core/components/base-layouts/kanban/layout.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import type { IBaseLayoutsKanbanItem, IBaseLayoutsKanbanProps } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useLayoutState } from "../hooks/use-layout-state";
|
||||
import { BaseKanbanGroup } from "./group";
|
||||
|
||||
export const BaseKanbanLayout = observer(function BaseKanbanLayout<T extends IBaseLayoutsKanbanItem>(
|
||||
props: IBaseLayoutsKanbanProps<T>
|
||||
) {
|
||||
const {
|
||||
items,
|
||||
groups,
|
||||
groupedItemIds,
|
||||
renderItem,
|
||||
renderGroupHeader,
|
||||
onDrop,
|
||||
canDrag,
|
||||
className,
|
||||
groupClassName,
|
||||
showEmptyGroups = true,
|
||||
enableDragDrop = false,
|
||||
loadMoreItems,
|
||||
collapsedGroups: externalCollapsedGroups,
|
||||
onToggleGroup: externalOnToggleGroup,
|
||||
} = props;
|
||||
|
||||
const useExternalMode = externalCollapsedGroups !== undefined && externalOnToggleGroup !== undefined;
|
||||
const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState(
|
||||
useExternalMode
|
||||
? {
|
||||
mode: "external",
|
||||
externalCollapsedGroups,
|
||||
externalOnToggleGroup,
|
||||
}
|
||||
: {
|
||||
mode: "internal",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative flex h-full w-full gap-2 overflow-x-auto p-3", className)}>
|
||||
{groups.map((group) => {
|
||||
const itemIds = groupedItemIds[group.id] || [];
|
||||
const isCollapsed = collapsedGroups.includes(group.id);
|
||||
|
||||
if (!showEmptyGroups && itemIds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<BaseKanbanGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
itemIds={itemIds}
|
||||
items={items}
|
||||
renderItem={renderItem}
|
||||
renderGroupHeader={renderGroupHeader}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleGroup={onToggleGroup}
|
||||
enableDragDrop={enableDragDrop}
|
||||
onDrop={onDrop}
|
||||
canDrag={canDrag}
|
||||
groupClassName={groupClassName}
|
||||
loadMoreItems={loadMoreItems}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
64
apps/web/core/components/base-layouts/layout-switcher.tsx
Normal file
64
apps/web/core/components/base-layouts/layout-switcher.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
import type { TBaseLayoutType } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { BASE_LAYOUTS } from "./constants";
|
||||
|
||||
type Props = {
|
||||
layouts?: TBaseLayoutType[];
|
||||
onChange: (layout: TBaseLayoutType) => void;
|
||||
selectedLayout: TBaseLayoutType;
|
||||
};
|
||||
|
||||
export function LayoutSwitcher(props: Props) {
|
||||
const { layouts, onChange, selectedLayout } = props;
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOnChange = (layoutKey: TBaseLayoutType) => {
|
||||
if (selectedLayout !== layoutKey) {
|
||||
onChange(layoutKey);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-md bg-layer-3 p-1">
|
||||
{BASE_LAYOUTS.map((layout) => {
|
||||
if (layouts && !layouts.includes(layout.key)) return null;
|
||||
const Icon = layout.icon;
|
||||
return (
|
||||
<Tooltip key={layout.key} label={t(layout.label)} disabled={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(layout.label)}
|
||||
className={cn(
|
||||
"group grid h-5.5 w-7 place-items-center overflow-hidden rounded-sm transition-all hover:bg-layer-transparent-hover",
|
||||
{
|
||||
"bg-layer-transparent-active hover:bg-layer-transparent-active": selectedLayout === layout.key,
|
||||
}
|
||||
)}
|
||||
onClick={() => handleOnChange(layout.key)}
|
||||
>
|
||||
<Icon
|
||||
width={14}
|
||||
height={14}
|
||||
strokeWidth={2}
|
||||
className={cn("size-3.5", {
|
||||
"text-primary": selectedLayout === layout.key,
|
||||
"text-secondary": selectedLayout !== layout.key,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/web/core/components/base-layouts/list/group-header.tsx
Normal file
20
apps/web/core/components/base-layouts/list/group-header.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { IGroupHeaderProps } from "@plane/types";
|
||||
|
||||
export function GroupHeader({ group, itemCount, onToggleGroup }: IGroupHeaderProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggleGroup(group.id)}
|
||||
className="flex w-full items-center gap-2 py-2 text-13 font-medium text-secondary"
|
||||
>
|
||||
{group.icon}
|
||||
<span>{group.name}</span>
|
||||
<span className="text-11 text-tertiary">{itemCount}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
93
apps/web/core/components/base-layouts/list/group.tsx
Normal file
93
apps/web/core/components/base-layouts/list/group.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IBaseLayoutsListItem, IBaseLayoutsListGroupProps } from "@plane/types";
|
||||
import { cn } from "@plane/ui";
|
||||
import { useGroupDropTarget } from "../hooks/use-group-drop-target";
|
||||
import { GroupHeader } from "./group-header";
|
||||
import { BaseListItem } from "./item";
|
||||
|
||||
export const BaseListGroup = observer(function BaseListGroup<T extends IBaseLayoutsListItem>(
|
||||
props: IBaseLayoutsListGroupProps<T>
|
||||
) {
|
||||
const {
|
||||
group,
|
||||
itemIds,
|
||||
items,
|
||||
isCollapsed,
|
||||
onToggleGroup,
|
||||
renderItem,
|
||||
renderGroupHeader,
|
||||
enableDragDrop = false,
|
||||
onDrop,
|
||||
canDrag,
|
||||
loadMoreItems: _loadMoreItems,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { groupRef, isDraggingOver } = useGroupDropTarget({
|
||||
groupId: group.id,
|
||||
enableDragDrop,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={groupRef}
|
||||
className={cn("relative flex flex-shrink-0 flex-col", {
|
||||
"bg-layer-1": isDraggingOver,
|
||||
})}
|
||||
>
|
||||
{/* Group Header */}
|
||||
<div className="sticky top-0 z-10 w-full shrink-0 cursor-pointer border-b border-subtle bg-layer-1 px-6 py-1 hover:bg-layer-1-hover">
|
||||
{renderGroupHeader ? (
|
||||
renderGroupHeader({ group, itemCount: itemIds.length, isCollapsed, onToggleGroup })
|
||||
) : (
|
||||
<GroupHeader
|
||||
group={group}
|
||||
itemCount={itemIds.length}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleGroup={onToggleGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group Items */}
|
||||
{!isCollapsed && (
|
||||
<div className="relative">
|
||||
{itemIds.map((itemId: string, index: number) => {
|
||||
const item = items[itemId];
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<BaseListItem
|
||||
key={itemId}
|
||||
item={item}
|
||||
index={index}
|
||||
groupId={group.id}
|
||||
renderItem={renderItem}
|
||||
enableDragDrop={enableDragDrop}
|
||||
canDrag={canDrag}
|
||||
onDrop={onDrop}
|
||||
isLast={index === itemIds.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggingOver && enableDragDrop && (
|
||||
<div className="absolute top-0 left-0 z-[2] flex h-full w-full items-center justify-center rounded-sm border-[1px] border-strong bg-layer-1/85 text-13 font-medium text-tertiary">
|
||||
<div className="my-8 flex flex-col items-center rounded-sm p-3 text-secondary">
|
||||
{t("common.drop_here_to_move")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
46
apps/web/core/components/base-layouts/list/item.tsx
Normal file
46
apps/web/core/components/base-layouts/list/item.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { observer } from "mobx-react";
|
||||
import type { IBaseLayoutsListItem, IBaseLayoutsListItemProps } from "@plane/types";
|
||||
|
||||
export const BaseListItem = observer(function BaseListItem<T extends IBaseLayoutsListItem>(
|
||||
props: IBaseLayoutsListItemProps<T>
|
||||
) {
|
||||
const { item, groupId, renderItem, enableDragDrop, canDrag, isLast: _isLast, index: _index } = props;
|
||||
const itemRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const isDragAllowed = canDrag ? canDrag(item) : true;
|
||||
|
||||
useEffect(() => {
|
||||
const element = itemRef.current;
|
||||
if (!element || !enableDragDrop) return;
|
||||
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
canDrag: () => isDragAllowed,
|
||||
getInitialData: () => ({ id: item.id, type: "ITEM", groupId }),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({ groupId, type: "ITEM" }),
|
||||
canDrop: ({ source }) => source?.data?.id !== item.id,
|
||||
})
|
||||
);
|
||||
}, [enableDragDrop, isDragAllowed, item.id, groupId]);
|
||||
|
||||
const renderedItem = renderItem(item, groupId);
|
||||
|
||||
return (
|
||||
<div ref={itemRef} className="cursor-pointer">
|
||||
{renderedItem}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
74
apps/web/core/components/base-layouts/list/layout.tsx
Normal file
74
apps/web/core/components/base-layouts/list/layout.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import type { IBaseLayoutsListItem, IBaseLayoutsListProps } from "@plane/types";
|
||||
import { cn } from "@plane/ui";
|
||||
import { useLayoutState } from "../hooks/use-layout-state";
|
||||
import { BaseListGroup } from "./group";
|
||||
|
||||
export const BaseListLayout = observer(function BaseListLayout<T extends IBaseLayoutsListItem>(
|
||||
props: IBaseLayoutsListProps<T>
|
||||
) {
|
||||
const {
|
||||
items,
|
||||
groupedItemIds,
|
||||
groups,
|
||||
renderItem,
|
||||
renderGroupHeader,
|
||||
enableDragDrop = false,
|
||||
onDrop,
|
||||
canDrag,
|
||||
showEmptyGroups = false,
|
||||
collapsedGroups: externalCollapsedGroups,
|
||||
onToggleGroup: externalOnToggleGroup,
|
||||
loadMoreItems,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const useExternalMode = externalCollapsedGroups !== undefined && externalOnToggleGroup !== undefined;
|
||||
const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState(
|
||||
useExternalMode
|
||||
? {
|
||||
mode: "external",
|
||||
externalCollapsedGroups,
|
||||
externalOnToggleGroup,
|
||||
}
|
||||
: {
|
||||
mode: "internal",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative size-full overflow-auto bg-surface-1", className)}>
|
||||
<div className="relative flex size-full flex-col">
|
||||
{groups.map((group) => {
|
||||
const itemIds = groupedItemIds[group.id] || [];
|
||||
const isCollapsed = collapsedGroups.includes(group.id);
|
||||
|
||||
if (!showEmptyGroups && itemIds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<BaseListGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
itemIds={itemIds}
|
||||
items={items}
|
||||
renderItem={renderItem}
|
||||
renderGroupHeader={renderGroupHeader}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleGroup={onToggleGroup}
|
||||
enableDragDrop={enableDragDrop}
|
||||
onDrop={onDrop}
|
||||
canDrag={canDrag}
|
||||
loadMoreItems={loadMoreItems}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { TBaseLayoutType } from "@plane/types";
|
||||
import { KanbanLayoutLoader } from "@/components/ui/loader/layouts/kanban-layout-loader";
|
||||
import { ListLayoutLoader } from "@/components/ui/loader/layouts/list-layout-loader";
|
||||
|
||||
interface GenericLayoutLoaderProps {
|
||||
layout: TBaseLayoutType;
|
||||
/** Optional custom loaders to override defaults */
|
||||
customLoaders?: Partial<Record<TBaseLayoutType, React.ComponentType>>;
|
||||
}
|
||||
|
||||
export function GenericLayoutLoader({ layout, customLoaders }: GenericLayoutLoaderProps) {
|
||||
const CustomLoader = customLoaders?.[layout];
|
||||
if (CustomLoader) return <CustomLoader />;
|
||||
|
||||
switch (layout) {
|
||||
case "list":
|
||||
return <ListLayoutLoader />;
|
||||
case "kanban":
|
||||
return <KanbanLayoutLoader />;
|
||||
default:
|
||||
console.warn(`Unknown layout: ${layout}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user