mirror of
https://github.com/docmost/docmost.git
synced 2026-07-25 17:04:42 +10:00
feat(bases): card-to-card kanban drag with edge slotting
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
IBase,
|
IBase,
|
||||||
IBaseRow,
|
IBaseRow,
|
||||||
@@ -7,7 +7,15 @@ import {
|
|||||||
} from "@/features/base/types/base.types";
|
} from "@/features/base/types/base.types";
|
||||||
import { useKanbanGroups } from "@/features/base/hooks/use-kanban-groups";
|
import { useKanbanGroups } from "@/features/base/hooks/use-kanban-groups";
|
||||||
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
|
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
|
||||||
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
|
import {
|
||||||
|
useCreateRowMutation,
|
||||||
|
useReorderRowMutation,
|
||||||
|
useUpdateRowMutation,
|
||||||
|
} from "@/features/base/queries/base-row-query";
|
||||||
|
import { resolveCardDrop } from "@/features/base/hooks/resolve-card-drop";
|
||||||
|
import type { CardDropPayload } from "@/features/base/hooks/use-kanban-card-drag";
|
||||||
|
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
|
||||||
|
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
|
||||||
import { KanbanColumn } from "./kanban-column";
|
import { KanbanColumn } from "./kanban-column";
|
||||||
import { KanbanEmptyState } from "./kanban-empty-state";
|
import { KanbanEmptyState } from "./kanban-empty-state";
|
||||||
import classes from "@/features/base/styles/kanban.module.css";
|
import classes from "@/features/base/styles/kanban.module.css";
|
||||||
@@ -40,6 +48,9 @@ export function BaseKanban({
|
|||||||
const isGroupable = property?.type === "select" || property?.type === "status";
|
const isGroupable = property?.type === "select" || property?.type === "status";
|
||||||
const updateViewMutation = useUpdateViewMutation();
|
const updateViewMutation = useUpdateViewMutation();
|
||||||
const createRowMutation = useCreateRowMutation();
|
const createRowMutation = useCreateRowMutation();
|
||||||
|
const updateRowMutation = useUpdateRowMutation();
|
||||||
|
const reorderRowMutation = useReorderRowMutation();
|
||||||
|
const sortsActive = (effectiveView?.config?.sorts?.length ?? 0) > 0;
|
||||||
|
|
||||||
// Rules of Hooks: call useKanbanGroups unconditionally with `undefined`
|
// Rules of Hooks: call useKanbanGroups unconditionally with `undefined`
|
||||||
// when not groupable; switch the render path on isGroupable below.
|
// when not groupable; switch the render path on isGroupable below.
|
||||||
@@ -59,6 +70,60 @@ export function BaseKanban({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCardDrop = useCallback(
|
||||||
|
(payload: CardDropPayload) => {
|
||||||
|
if (!groupByPropertyId) return;
|
||||||
|
const targetColumn = columns.find((c) => c.key === payload.targetColumnKey);
|
||||||
|
// The drop target restricts allowedEdges to ["top","bottom"], so the
|
||||||
|
// runtime value is always assignable; narrow the broader Edge union.
|
||||||
|
const edge =
|
||||||
|
payload.edge === "top" || payload.edge === "bottom"
|
||||||
|
? payload.edge
|
||||||
|
: null;
|
||||||
|
const result = resolveCardDrop({
|
||||||
|
draggedCardId: payload.draggedCardId,
|
||||||
|
targetCardId: payload.targetCardId,
|
||||||
|
edge,
|
||||||
|
sourceColumnKey: payload.sourceColumnKey,
|
||||||
|
targetColumnKey: payload.targetColumnKey,
|
||||||
|
groupByPropertyId,
|
||||||
|
columnRows: targetColumn?.rows ?? [],
|
||||||
|
sortsActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.cells !== undefined) {
|
||||||
|
updateRowMutation.mutate({
|
||||||
|
rowId: payload.draggedCardId,
|
||||||
|
pageId: base.id,
|
||||||
|
cells: result.cells,
|
||||||
|
...(result.position !== undefined && { position: result.position }),
|
||||||
|
});
|
||||||
|
} else if (result.position !== undefined) {
|
||||||
|
reorderRowMutation.mutate({
|
||||||
|
rowId: payload.draggedCardId,
|
||||||
|
pageId: base.id,
|
||||||
|
position: result.position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// a11y + post-move flash on the dropped card (if still in DOM).
|
||||||
|
const el = document.querySelector(
|
||||||
|
`[data-row-id="${payload.draggedCardId}"]`,
|
||||||
|
);
|
||||||
|
if (el instanceof HTMLElement) triggerPostMoveFlash(el);
|
||||||
|
const colName = targetColumn?.name ?? "column";
|
||||||
|
liveRegion.announce(`Moved card to ${colName}`);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
base.id,
|
||||||
|
columns,
|
||||||
|
groupByPropertyId,
|
||||||
|
reorderRowMutation,
|
||||||
|
sortsActive,
|
||||||
|
updateRowMutation,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const handleAddCard = (columnKey: string) => {
|
const handleAddCard = (columnKey: string) => {
|
||||||
if (!groupByPropertyId) return;
|
if (!groupByPropertyId) return;
|
||||||
const cells =
|
const cells =
|
||||||
@@ -87,6 +152,7 @@ export function BaseKanban({
|
|||||||
primaryProperty={primaryProperty}
|
primaryProperty={primaryProperty}
|
||||||
onCardClick={onCardClick}
|
onCardClick={onCardClick}
|
||||||
onAddCard={handleAddCard}
|
onAddCard={handleAddCard}
|
||||||
|
onCardDrop={handleCardDrop}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,25 +2,46 @@ import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types";
|
|||||||
import classes from "@/features/base/styles/kanban.module.css";
|
import classes from "@/features/base/styles/kanban.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
useKanbanCardDrag,
|
||||||
|
type CardDropPayload,
|
||||||
|
} from "@/features/base/hooks/use-kanban-card-drag";
|
||||||
|
import { BaseDropEdgeIndicator } from "@/features/base/components/grid/base-drop-edge-indicator";
|
||||||
|
|
||||||
type KanbanCardProps = {
|
type KanbanCardProps = {
|
||||||
row: IBaseRow;
|
row: IBaseRow;
|
||||||
|
columnKey: string;
|
||||||
primaryProperty: IBaseProperty | undefined;
|
primaryProperty: IBaseProperty | undefined;
|
||||||
onClick: (rowId: string) => void;
|
onClick: (rowId: string) => void;
|
||||||
|
onDrop: (payload: CardDropPayload) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function KanbanCard({ row, primaryProperty, onClick }: KanbanCardProps) {
|
export function KanbanCard({
|
||||||
|
row,
|
||||||
|
columnKey,
|
||||||
|
primaryProperty,
|
||||||
|
onClick,
|
||||||
|
onDrop,
|
||||||
|
}: KanbanCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const titleValue =
|
const { ref, isDragging, closestEdge } = useKanbanCardDrag({
|
||||||
primaryProperty
|
cardId: row.id,
|
||||||
? ((row.cells ?? {})[primaryProperty.id] as string | undefined)
|
columnKey,
|
||||||
: undefined;
|
onDrop,
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleValue = primaryProperty
|
||||||
|
? ((row.cells ?? {})[primaryProperty.id] as string | undefined)
|
||||||
|
: undefined;
|
||||||
const titleText = titleValue?.trim().length ? titleValue : t("Untitled");
|
const titleText = titleValue?.trim().length ? titleValue : t("Untitled");
|
||||||
const isEmpty = !titleValue?.trim().length;
|
const isEmpty = !titleValue?.trim().length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={ref}
|
||||||
className={classes.card}
|
className={classes.card}
|
||||||
|
data-row-id={row.id}
|
||||||
|
data-dragging={isDragging || undefined}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onClick(row.id)}
|
onClick={() => onClick(row.id)}
|
||||||
@@ -34,6 +55,7 @@ export function KanbanCard({ row, primaryProperty, onClick }: KanbanCardProps) {
|
|||||||
<div className={clsx(classes.cardTitle, isEmpty && classes.cardTitleEmpty)}>
|
<div className={clsx(classes.cardTitle, isEmpty && classes.cardTitleEmpty)}>
|
||||||
{titleText}
|
{titleText}
|
||||||
</div>
|
</div>
|
||||||
|
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { IBaseProperty } from "@/features/base/types/base.types";
|
|||||||
import { KanbanCard } from "./kanban-card";
|
import { KanbanCard } from "./kanban-card";
|
||||||
import { KanbanColumnHeader } from "./kanban-column-header";
|
import { KanbanColumnHeader } from "./kanban-column-header";
|
||||||
import { KanbanAddCardButton } from "./kanban-add-card-button";
|
import { KanbanAddCardButton } from "./kanban-add-card-button";
|
||||||
|
import type { CardDropPayload } from "@/features/base/hooks/use-kanban-card-drag";
|
||||||
import classes from "@/features/base/styles/kanban.module.css";
|
import classes from "@/features/base/styles/kanban.module.css";
|
||||||
|
|
||||||
type KanbanColumnProps = {
|
type KanbanColumnProps = {
|
||||||
@@ -10,6 +11,7 @@ type KanbanColumnProps = {
|
|||||||
primaryProperty: IBaseProperty | undefined;
|
primaryProperty: IBaseProperty | undefined;
|
||||||
onCardClick: (rowId: string) => void;
|
onCardClick: (rowId: string) => void;
|
||||||
onAddCard: (columnKey: string) => void;
|
onAddCard: (columnKey: string) => void;
|
||||||
|
onCardDrop: (payload: CardDropPayload) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function KanbanColumn({
|
export function KanbanColumn({
|
||||||
@@ -17,6 +19,7 @@ export function KanbanColumn({
|
|||||||
primaryProperty,
|
primaryProperty,
|
||||||
onCardClick,
|
onCardClick,
|
||||||
onAddCard,
|
onAddCard,
|
||||||
|
onCardDrop,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.column} data-column-key={column.key}>
|
<div className={classes.column} data-column-key={column.key}>
|
||||||
@@ -30,8 +33,10 @@ export function KanbanColumn({
|
|||||||
<KanbanCard
|
<KanbanCard
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
|
columnKey={column.key}
|
||||||
primaryProperty={primaryProperty}
|
primaryProperty={primaryProperty}
|
||||||
onClick={onCardClick}
|
onClick={onCardClick}
|
||||||
|
onDrop={onCardDrop}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<KanbanAddCardButton onClick={() => onAddCard(column.key)} />
|
<KanbanAddCardButton onClick={() => onAddCard(column.key)} />
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||||
|
import {
|
||||||
|
draggable,
|
||||||
|
dropTargetForElements,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
|
import {
|
||||||
|
attachClosestEdge,
|
||||||
|
extractClosestEdge,
|
||||||
|
type Edge,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||||
|
|
||||||
|
export type CardDragData = {
|
||||||
|
type: "base-kanban-card";
|
||||||
|
cardId: string;
|
||||||
|
columnKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CardDropPayload = {
|
||||||
|
draggedCardId: string;
|
||||||
|
targetCardId: string;
|
||||||
|
edge: Edge;
|
||||||
|
sourceColumnKey: string;
|
||||||
|
targetColumnKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useKanbanCardDrag({
|
||||||
|
cardId,
|
||||||
|
columnKey,
|
||||||
|
onDrop,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
cardId: string;
|
||||||
|
columnKey: string;
|
||||||
|
onDrop: (payload: CardDropPayload) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||||
|
// Keep onDrop fresh without re-registering the effect each render.
|
||||||
|
const onDropRef = useRef(onDrop);
|
||||||
|
onDropRef.current = onDrop;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || disabled) return;
|
||||||
|
const data: CardDragData = {
|
||||||
|
type: "base-kanban-card",
|
||||||
|
cardId,
|
||||||
|
columnKey,
|
||||||
|
};
|
||||||
|
return combine(
|
||||||
|
draggable({
|
||||||
|
element: el,
|
||||||
|
getInitialData: () => data,
|
||||||
|
onDragStart: () => setIsDragging(true),
|
||||||
|
onDrop: () => setIsDragging(false),
|
||||||
|
}),
|
||||||
|
dropTargetForElements({
|
||||||
|
element: el,
|
||||||
|
canDrop: ({ source }) => {
|
||||||
|
if (source.data.type !== "base-kanban-card") return false;
|
||||||
|
return source.data.cardId !== cardId;
|
||||||
|
},
|
||||||
|
getData: ({ input, element }) =>
|
||||||
|
attachClosestEdge(
|
||||||
|
{ type: "base-kanban-card-target", cardId, columnKey },
|
||||||
|
{ input, element, allowedEdges: ["top", "bottom"] },
|
||||||
|
),
|
||||||
|
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||||
|
onDragLeave: () => setClosestEdge(null),
|
||||||
|
onDrop: ({ source, self }) => {
|
||||||
|
setClosestEdge(null);
|
||||||
|
if (source.data.type !== "base-kanban-card") return;
|
||||||
|
const edge = extractClosestEdge(self.data);
|
||||||
|
if (!edge) return;
|
||||||
|
onDropRef.current({
|
||||||
|
draggedCardId: source.data.cardId as string,
|
||||||
|
targetCardId: cardId,
|
||||||
|
edge,
|
||||||
|
sourceColumnKey: source.data.columnKey as string,
|
||||||
|
targetColumnKey: columnKey,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [cardId, columnKey, disabled]);
|
||||||
|
|
||||||
|
return { ref, isDragging, closestEdge };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user