diff --git a/apps/client/src/features/base/components/views/kanban/base-kanban.tsx b/apps/client/src/features/base/components/views/kanban/base-kanban.tsx index a9d55fb79..a36efb131 100644 --- a/apps/client/src/features/base/components/views/kanban/base-kanban.tsx +++ b/apps/client/src/features/base/components/views/kanban/base-kanban.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { IBase, IBaseRow, @@ -7,7 +7,15 @@ import { } from "@/features/base/types/base.types"; import { useKanbanGroups } from "@/features/base/hooks/use-kanban-groups"; 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 { KanbanEmptyState } from "./kanban-empty-state"; 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 updateViewMutation = useUpdateViewMutation(); 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` // 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) => { if (!groupByPropertyId) return; const cells = @@ -87,6 +152,7 @@ export function BaseKanban({ primaryProperty={primaryProperty} onCardClick={onCardClick} onAddCard={handleAddCard} + onCardDrop={handleCardDrop} /> ))} diff --git a/apps/client/src/features/base/components/views/kanban/kanban-card.tsx b/apps/client/src/features/base/components/views/kanban/kanban-card.tsx index 500cccf31..31b11f5b6 100644 --- a/apps/client/src/features/base/components/views/kanban/kanban-card.tsx +++ b/apps/client/src/features/base/components/views/kanban/kanban-card.tsx @@ -2,25 +2,46 @@ import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types"; import classes from "@/features/base/styles/kanban.module.css"; import { useTranslation } from "react-i18next"; 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 = { row: IBaseRow; + columnKey: string; primaryProperty: IBaseProperty | undefined; 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 titleValue = - primaryProperty - ? ((row.cells ?? {})[primaryProperty.id] as string | undefined) - : undefined; + const { ref, isDragging, closestEdge } = useKanbanCardDrag({ + cardId: row.id, + columnKey, + onDrop, + }); + + const titleValue = primaryProperty + ? ((row.cells ?? {})[primaryProperty.id] as string | undefined) + : undefined; const titleText = titleValue?.trim().length ? titleValue : t("Untitled"); const isEmpty = !titleValue?.trim().length; return (
onClick(row.id)} @@ -34,6 +55,7 @@ export function KanbanCard({ row, primaryProperty, onClick }: KanbanCardProps) {
{titleText}
+ {closestEdge && }
); } diff --git a/apps/client/src/features/base/components/views/kanban/kanban-column.tsx b/apps/client/src/features/base/components/views/kanban/kanban-column.tsx index fe0b3488f..4459d20d0 100644 --- a/apps/client/src/features/base/components/views/kanban/kanban-column.tsx +++ b/apps/client/src/features/base/components/views/kanban/kanban-column.tsx @@ -3,6 +3,7 @@ import { IBaseProperty } from "@/features/base/types/base.types"; import { KanbanCard } from "./kanban-card"; import { KanbanColumnHeader } from "./kanban-column-header"; 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"; type KanbanColumnProps = { @@ -10,6 +11,7 @@ type KanbanColumnProps = { primaryProperty: IBaseProperty | undefined; onCardClick: (rowId: string) => void; onAddCard: (columnKey: string) => void; + onCardDrop: (payload: CardDropPayload) => void; }; export function KanbanColumn({ @@ -17,6 +19,7 @@ export function KanbanColumn({ primaryProperty, onCardClick, onAddCard, + onCardDrop, }: KanbanColumnProps) { return (
@@ -30,8 +33,10 @@ export function KanbanColumn({ ))} onAddCard(column.key)} /> diff --git a/apps/client/src/features/base/hooks/use-kanban-card-drag.ts b/apps/client/src/features/base/hooks/use-kanban-card-drag.ts new file mode 100644 index 000000000..f132bb953 --- /dev/null +++ b/apps/client/src/features/base/hooks/use-kanban-card-drag.ts @@ -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(null); + const [isDragging, setIsDragging] = useState(false); + const [closestEdge, setClosestEdge] = useState(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 }; +}