[WEB-4944] feat: add base layouts for kanban and list with drag-and-drop support (#8032)
This commit is contained in:
parent
5247fedd23
commit
73e0e8d529
37 changed files with 796 additions and 0 deletions
15
apps/web/core/components/base-layouts/constants.ts
Normal file
15
apps/web/core/components/base-layouts/constants.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { BoardLayoutIcon, ListLayoutIcon } from "@plane/propel/icons";
|
||||
import type { IBaseLayoutConfig } from "@plane/types";
|
||||
|
||||
export const BASE_LAYOUTS: IBaseLayoutConfig[] = [
|
||||
{
|
||||
key: "list",
|
||||
icon: ListLayoutIcon,
|
||||
label: "List Layout",
|
||||
},
|
||||
{
|
||||
key: "kanban",
|
||||
icon: BoardLayoutIcon,
|
||||
label: "Board Layout",
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
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,58 @@
|
|||
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,14 @@
|
|||
import type { IGroupHeaderProps } from "@plane/types";
|
||||
|
||||
export const GroupHeader = ({ group, itemCount, onToggleGroup }: IGroupHeaderProps) => (
|
||||
<button
|
||||
onClick={() => onToggleGroup(group.id)}
|
||||
className="flex w-full items-center gap-2 text-sm font-medium text-custom-text-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{group.icon}
|
||||
<span>{group.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-custom-text-300">{itemCount}</span>
|
||||
</button>
|
||||
);
|
||||
96
apps/web/core/components/base-layouts/kanban/group.tsx
Normal file
96
apps/web/core/components/base-layouts/kanban/group.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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(<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 flex-shrink-0 flex-col w-[350px] border-[1px] border-transparent p-2 pt-0 max-h-full overflow-y-auto bg-custom-background-90 rounded-md",
|
||||
{
|
||||
"bg-custom-background-80": isDraggingOver,
|
||||
},
|
||||
groupClassName
|
||||
)}
|
||||
>
|
||||
{/* Group Header */}
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-custom-background-90 px-1 py-2 cursor-pointer">
|
||||
{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-sm text-custom-text-300">
|
||||
{t("common.no_items_in_this_group")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggingOver && enableDragDrop && (
|
||||
<div className="absolute top-0 left-0 h-full w-full flex items-center justify-center text-sm font-medium text-custom-text-300 rounded bg-custom-background-80/85 border-[1px] border-custom-border-300 z-[2]">
|
||||
<div className="p-3 my-8 flex flex-col rounded items-center text-custom-text-200">
|
||||
{t("common.drop_here_to_move")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
40
apps/web/core/components/base-layouts/kanban/item.tsx
Normal file
40
apps/web/core/components/base-layouts/kanban/item.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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(<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>
|
||||
);
|
||||
});
|
||||
61
apps/web/core/components/base-layouts/kanban/layout.tsx
Normal file
61
apps/web/core/components/base-layouts/kanban/layout.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
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(<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 { containerRef, collapsedGroups, onToggleGroup } = useLayoutState({
|
||||
mode: "external",
|
||||
externalCollapsedGroups,
|
||||
externalOnToggleGroup,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative w-full flex gap-2 p-3 h-full overflow-x-auto", 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>
|
||||
);
|
||||
});
|
||||
50
apps/web/core/components/base-layouts/layout-switcher.tsx
Normal file
50
apps/web/core/components/base-layouts/layout-switcher.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TBaseLayoutType } from "@plane/types";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { BASE_LAYOUTS } from "./constants";
|
||||
|
||||
type Props = {
|
||||
layouts?: TBaseLayoutType[];
|
||||
onChange: (layout: TBaseLayoutType) => void;
|
||||
selectedLayout: TBaseLayoutType | undefined;
|
||||
};
|
||||
|
||||
export const LayoutSwitcher: React.FC<Props> = (props) => {
|
||||
const { layouts, onChange, selectedLayout } = props;
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const handleOnChange = (layoutKey: TBaseLayoutType) => {
|
||||
if (selectedLayout !== layoutKey) {
|
||||
onChange(layoutKey);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
|
||||
{BASE_LAYOUTS.filter((l) => (layouts ? layouts.includes(l.key) : true)).map((layout) => {
|
||||
const Icon = layout.icon;
|
||||
return (
|
||||
<Tooltip key={layout.key} tooltipContent={layout.label} isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
|
||||
selectedLayout === layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
|
||||
}`}
|
||||
onClick={() => handleOnChange(layout.key)}
|
||||
>
|
||||
<Icon
|
||||
strokeWidth={2}
|
||||
className={`h-3.5 w-3.5 ${
|
||||
selectedLayout === layout.key ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
apps/web/core/components/base-layouts/list/group-header.tsx
Normal file
12
apps/web/core/components/base-layouts/list/group-header.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { IGroupHeaderProps } from "@plane/types";
|
||||
|
||||
export const GroupHeader = ({ group, itemCount, onToggleGroup }: IGroupHeaderProps) => (
|
||||
<button
|
||||
onClick={() => onToggleGroup(group.id)}
|
||||
className="flex w-full items-center gap-2 py-2 text-sm font-medium text-custom-text-200"
|
||||
>
|
||||
{group.icon}
|
||||
<span>{group.name}</span>
|
||||
<span className="text-xs text-custom-text-300">{itemCount}</span>
|
||||
</button>
|
||||
);
|
||||
85
apps/web/core/components/base-layouts/list/group.tsx
Normal file
85
apps/web/core/components/base-layouts/list/group.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IBaseLayoutsListItem, IBaseLayoutsListGroupProps } from "@plane/types";
|
||||
import { cn, Row } from "@plane/ui";
|
||||
import { useGroupDropTarget } from "../hooks/use-group-drop-target";
|
||||
import { GroupHeader } from "./group-header";
|
||||
import { BaseListItem } from "./item";
|
||||
|
||||
export const BaseListGroup = observer(<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 border-[1px] border-transparent", {
|
||||
"bg-custom-background-80": isDraggingOver,
|
||||
})}
|
||||
>
|
||||
{/* Group Header */}
|
||||
<Row className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 py-1">
|
||||
{renderGroupHeader ? (
|
||||
renderGroupHeader({ group, itemCount: itemIds.length, isCollapsed, onToggleGroup })
|
||||
) : (
|
||||
<GroupHeader
|
||||
group={group}
|
||||
itemCount={itemIds.length}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleGroup={onToggleGroup}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{/* 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 h-full w-full flex items-center justify-center text-sm font-medium text-custom-text-300 rounded bg-custom-background-80/85 border-[1px] border-custom-border-300 z-[2]">
|
||||
<div className="p-3 my-8 flex flex-col rounded items-center text-custom-text-200">
|
||||
{t("common.drop_here_to_move")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
38
apps/web/core/components/base-layouts/list/item.tsx
Normal file
38
apps/web/core/components/base-layouts/list/item.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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(<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>
|
||||
);
|
||||
});
|
||||
61
apps/web/core/components/base-layouts/list/layout.tsx
Normal file
61
apps/web/core/components/base-layouts/list/layout.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
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(<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 { containerRef, collapsedGroups, onToggleGroup } = useLayoutState({
|
||||
mode: "external",
|
||||
externalCollapsedGroups,
|
||||
externalOnToggleGroup,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative size-full overflow-auto bg-custom-background-90", className)}>
|
||||
<div className="relative size-full flex 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,24 @@
|
|||
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 const 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -639,6 +639,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Vše",
|
||||
no_items_in_this_group: "V této skupině nejsou žádné položky",
|
||||
drop_here_to_move: "Přetáhněte sem pro přesunutí",
|
||||
states: "Stavy",
|
||||
state: "Stav",
|
||||
state_groups: "Skupiny stavů",
|
||||
|
|
|
|||
|
|
@ -653,6 +653,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Alle",
|
||||
no_items_in_this_group: "Keine Elemente in dieser Gruppe",
|
||||
drop_here_to_move: "Hier ablegen zum Verschieben",
|
||||
states: "Status",
|
||||
state: "Status",
|
||||
state_groups: "Statusgruppen",
|
||||
|
|
|
|||
|
|
@ -478,6 +478,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "All",
|
||||
no_items_in_this_group: "No items in this group",
|
||||
drop_here_to_move: "Drop here to move",
|
||||
states: "States",
|
||||
state: "State",
|
||||
state_groups: "State groups",
|
||||
|
|
|
|||
|
|
@ -654,6 +654,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Todo",
|
||||
no_items_in_this_group: "No hay elementos en este grupo",
|
||||
drop_here_to_move: "Suelta aquí para mover",
|
||||
states: "Estados",
|
||||
state: "Estado",
|
||||
state_groups: "Grupos de estados",
|
||||
|
|
|
|||
|
|
@ -652,6 +652,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Tout",
|
||||
no_items_in_this_group: "Aucun élément dans ce groupe",
|
||||
drop_here_to_move: "Déposer ici pour déplacer",
|
||||
states: "États",
|
||||
state: "État",
|
||||
state_groups: "Groupes d'états",
|
||||
|
|
|
|||
|
|
@ -644,6 +644,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Semua",
|
||||
no_items_in_this_group: "Tidak ada item dalam grup ini",
|
||||
drop_here_to_move: "Letakkan di sini untuk memindahkan",
|
||||
states: "Negara-negara",
|
||||
state: "Negara",
|
||||
state_groups: "Kelompok negara",
|
||||
|
|
|
|||
|
|
@ -647,6 +647,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Tutti",
|
||||
no_items_in_this_group: "Nessun elemento in questo gruppo",
|
||||
drop_here_to_move: "Rilascia qui per spostare",
|
||||
states: "Stati",
|
||||
state: "Stato",
|
||||
state_groups: "Gruppi di stati",
|
||||
|
|
|
|||
|
|
@ -640,6 +640,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "すべて",
|
||||
no_items_in_this_group: "このグループにアイテムはありません",
|
||||
drop_here_to_move: "移動するにはここにドロップ",
|
||||
states: "ステータス",
|
||||
state: "ステータス",
|
||||
state_groups: "ステータスグループ",
|
||||
|
|
|
|||
|
|
@ -634,6 +634,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "모두",
|
||||
no_items_in_this_group: "이 그룹에 항목이 없습니다",
|
||||
drop_here_to_move: "이동하려면 여기에 드롭하세요",
|
||||
states: "상태",
|
||||
state: "상태",
|
||||
state_groups: "상태 그룹",
|
||||
|
|
|
|||
|
|
@ -640,6 +640,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Wszystko",
|
||||
no_items_in_this_group: "Brak elementów w tej grupie",
|
||||
drop_here_to_move: "Upuść tutaj, aby przenieść",
|
||||
states: "Stany",
|
||||
state: "Stan",
|
||||
state_groups: "Grupy stanów",
|
||||
|
|
|
|||
|
|
@ -651,6 +651,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Todos",
|
||||
no_items_in_this_group: "Nenhum item neste grupo",
|
||||
drop_here_to_move: "Solte aqui para mover",
|
||||
states: "Estados",
|
||||
state: "Estado",
|
||||
state_groups: "Grupos de estado",
|
||||
|
|
|
|||
|
|
@ -646,6 +646,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Toate",
|
||||
no_items_in_this_group: "Nu există elemente în acest grup",
|
||||
drop_here_to_move: "Eliberează aici pentru a muta",
|
||||
states: "Stări",
|
||||
state: "Stare",
|
||||
state_groups: "Grupuri de stări",
|
||||
|
|
|
|||
|
|
@ -643,6 +643,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Все",
|
||||
no_items_in_this_group: "В этой группе нет элементов",
|
||||
drop_here_to_move: "Перетащите сюда для перемещения",
|
||||
states: "Статусы",
|
||||
state: "Статус",
|
||||
state_groups: "Группы статусов",
|
||||
|
|
|
|||
|
|
@ -641,6 +641,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Všetko",
|
||||
no_items_in_this_group: "V tejto skupine nie sú žiadne položky",
|
||||
drop_here_to_move: "Presuňte sem na presunutie",
|
||||
states: "Stavy",
|
||||
state: "Stav",
|
||||
state_groups: "Skupiny stavov",
|
||||
|
|
|
|||
|
|
@ -641,6 +641,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Tümü",
|
||||
no_items_in_this_group: "Bu grupta öğe yok",
|
||||
drop_here_to_move: "Taşımak için buraya bırakın",
|
||||
states: "Durumlar",
|
||||
state: "Durum",
|
||||
state_groups: "Durum grupları",
|
||||
|
|
|
|||
|
|
@ -643,6 +643,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Усе",
|
||||
no_items_in_this_group: "У цій групі немає елементів",
|
||||
drop_here_to_move: "Перетягніть сюди для переміщення",
|
||||
states: "Стани",
|
||||
state: "Стан",
|
||||
state_groups: "Групи станів",
|
||||
|
|
|
|||
|
|
@ -648,6 +648,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "Tất cả",
|
||||
no_items_in_this_group: "Không có mục nào trong nhóm này",
|
||||
drop_here_to_move: "Thả vào đây để di chuyển",
|
||||
states: "Trạng thái",
|
||||
state: "Trạng thái",
|
||||
state_groups: "Nhóm trạng thái",
|
||||
|
|
|
|||
|
|
@ -629,6 +629,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "全部",
|
||||
no_items_in_this_group: "此组中没有项目",
|
||||
drop_here_to_move: "拖放到此处以移动",
|
||||
states: "状态",
|
||||
state: "状态",
|
||||
state_groups: "状态组",
|
||||
|
|
|
|||
|
|
@ -628,6 +628,8 @@ export default {
|
|||
},
|
||||
common: {
|
||||
all: "全部",
|
||||
no_items_in_this_group: "此群組中沒有項目",
|
||||
drop_here_to_move: "拖放到此處以移動",
|
||||
states: "狀態",
|
||||
state: "狀態",
|
||||
state_groups: "狀態群組",
|
||||
|
|
|
|||
101
packages/types/src/base-layouts/base.ts
Normal file
101
packages/types/src/base-layouts/base.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
// Base Types
|
||||
|
||||
export interface IBaseLayoutsBaseItem {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IBaseLayoutsBaseGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: ReactNode;
|
||||
payload?: Record<string, unknown>;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
// Drag & Drop Types
|
||||
|
||||
export interface IDragDropHandlers<T extends IBaseLayoutsBaseItem> {
|
||||
enableDragDrop?: boolean;
|
||||
onDrop?: (
|
||||
sourceId: string,
|
||||
destinationId: string | null,
|
||||
sourceGroupId: string,
|
||||
destinationGroupId: string
|
||||
) => Promise<void>;
|
||||
canDrag?: (item: T) => boolean;
|
||||
}
|
||||
|
||||
// Render Props
|
||||
|
||||
export interface IItemRenderProps<T extends IBaseLayoutsBaseItem> {
|
||||
renderItem: (item: T, groupId: string) => ReactNode;
|
||||
}
|
||||
|
||||
export interface IGroupHeaderControls {
|
||||
isCollapsed: boolean;
|
||||
onToggleGroup: (groupId: string) => void;
|
||||
}
|
||||
|
||||
export interface IGroupHeaderProps extends IGroupHeaderControls {
|
||||
group: IBaseLayoutsBaseGroup;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface IGroupRenderProps {
|
||||
renderGroupHeader?: (props: IGroupHeaderProps) => ReactNode;
|
||||
}
|
||||
|
||||
export interface IRenderProps<T extends IBaseLayoutsBaseItem> extends IItemRenderProps<T>, IGroupRenderProps {}
|
||||
|
||||
// Layout Configuration
|
||||
|
||||
export type TBaseLayoutType = "list" | "kanban";
|
||||
|
||||
export interface IBaseLayoutConfig {
|
||||
key: TBaseLayoutType;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Base Layout Props
|
||||
export interface IBaseLayoutsBaseProps<T extends IBaseLayoutsBaseItem> extends IDragDropHandlers<T>, IRenderProps<T> {
|
||||
items: Record<string, T>;
|
||||
groupedItemIds: Record<string, string[]>;
|
||||
groups: IBaseLayoutsBaseGroup[];
|
||||
|
||||
collapsedGroups?: string[];
|
||||
onToggleGroup?: (groupId: string) => void;
|
||||
|
||||
isLoading?: boolean;
|
||||
loadMoreItems?: (groupId: string) => void;
|
||||
|
||||
showEmptyGroups?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Group Props
|
||||
|
||||
export interface IBaseLayoutsBaseGroupProps<T extends IBaseLayoutsBaseItem>
|
||||
extends IDragDropHandlers<T>,
|
||||
IRenderProps<T> {
|
||||
group: IBaseLayoutsBaseGroup;
|
||||
itemIds: string[];
|
||||
items: Record<string, T>;
|
||||
isCollapsed: boolean;
|
||||
onToggleGroup: (groupId: string) => void;
|
||||
loadMoreItems?: (groupId: string) => void;
|
||||
}
|
||||
|
||||
// Item Props
|
||||
|
||||
export interface IBaseLayoutsBaseItemProps<T extends IBaseLayoutsBaseItem>
|
||||
extends IDragDropHandlers<T>,
|
||||
IItemRenderProps<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
groupId: string;
|
||||
isLast: boolean;
|
||||
}
|
||||
3
packages/types/src/base-layouts/index.ts
Normal file
3
packages/types/src/base-layouts/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./base";
|
||||
export * from "./list";
|
||||
export * from "./kanban";
|
||||
24
packages/types/src/base-layouts/kanban.ts
Normal file
24
packages/types/src/base-layouts/kanban.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type {
|
||||
IBaseLayoutsBaseItem,
|
||||
IBaseLayoutsBaseProps,
|
||||
IBaseLayoutsBaseGroupProps,
|
||||
IBaseLayoutsBaseItemProps,
|
||||
} from "./base";
|
||||
|
||||
export type IBaseLayoutsKanbanItem = IBaseLayoutsBaseItem;
|
||||
|
||||
// Main Kanban Layout Props
|
||||
|
||||
export interface IBaseLayoutsKanbanProps<T extends IBaseLayoutsKanbanItem> extends IBaseLayoutsBaseProps<T> {
|
||||
groupClassName?: string;
|
||||
}
|
||||
|
||||
// Kanban Column/Group Props
|
||||
|
||||
export interface IBaseLayoutsKanbanGroupProps<T extends IBaseLayoutsKanbanItem> extends IBaseLayoutsBaseGroupProps<T> {
|
||||
groupClassName?: string;
|
||||
}
|
||||
|
||||
// Kanban Card/Item Props
|
||||
|
||||
export type IBaseLayoutsKanbanItemProps<T extends IBaseLayoutsKanbanItem> = IBaseLayoutsBaseItemProps<T>;
|
||||
20
packages/types/src/base-layouts/list.ts
Normal file
20
packages/types/src/base-layouts/list.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type {
|
||||
IBaseLayoutsBaseItem,
|
||||
IBaseLayoutsBaseProps,
|
||||
IBaseLayoutsBaseGroupProps,
|
||||
IBaseLayoutsBaseItemProps,
|
||||
} from "./base";
|
||||
|
||||
export type IBaseLayoutsListItem = IBaseLayoutsBaseItem;
|
||||
|
||||
// Main List Layout Props
|
||||
|
||||
export type IBaseLayoutsListProps<T extends IBaseLayoutsListItem> = IBaseLayoutsBaseProps<T>;
|
||||
|
||||
// Group component props
|
||||
|
||||
export type IBaseLayoutsListGroupProps<T extends IBaseLayoutsListItem> = IBaseLayoutsBaseGroupProps<T>;
|
||||
|
||||
// Item component props
|
||||
|
||||
export type IBaseLayoutsListItemProps<T extends IBaseLayoutsListItem> = IBaseLayoutsBaseItemProps<T>;
|
||||
|
|
@ -47,3 +47,4 @@ export * from "./workspace";
|
|||
export * from "./workspace-draft-issues/base";
|
||||
export * from "./workspace-notifications";
|
||||
export * from "./workspace-views";
|
||||
export * from "./base-layouts";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue