Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View 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",
},
];

View File

@@ -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 };
};

View File

@@ -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,
};
};

View File

@@ -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>
);

View 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>
);
});

View 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>
);
});

View File

@@ -0,0 +1,68 @@
"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 useExternalMode = externalCollapsedGroups !== undefined && externalOnToggleGroup !== undefined;
const { containerRef, collapsedGroups, onToggleGroup } = useLayoutState(
useExternalMode
? {
mode: "external",
externalCollapsedGroups,
externalOnToggleGroup,
}
: {
mode: "internal",
}
);
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>
);
});

View 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;
};
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>
);
};

View 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>
);

View 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>
);
});

View 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>
);
});

View File

@@ -0,0 +1,68 @@
"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 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-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>
);
});

View File

@@ -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;
}
};