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 (