feat(bases): reorder kanban columns via drag, persisting choiceOrder

This commit is contained in:
Philipinho
2026-05-24 15:48:51 +01:00
parent 83d52fc324
commit beb7120b00
4 changed files with 124 additions and 1 deletions
@@ -14,6 +14,7 @@ import {
} from "@/features/base/queries/base-row-query"; } from "@/features/base/queries/base-row-query";
import { resolveCardDrop } from "@/features/base/hooks/resolve-card-drop"; import { resolveCardDrop } from "@/features/base/hooks/resolve-card-drop";
import type { CardDropPayload } from "@/features/base/hooks/use-kanban-card-drag"; import type { CardDropPayload } from "@/features/base/hooks/use-kanban-card-drag";
import type { ColumnReorderPayload } from "@/features/base/hooks/use-kanban-column-reorder";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash"; 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 * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { KanbanColumn } from "./kanban-column"; import { KanbanColumn } from "./kanban-column";
@@ -139,6 +140,34 @@ export function BaseKanban({
}); });
}; };
const handleColumnReorder = useCallback(
(payload: ColumnReorderPayload) => {
if (!effectiveView) return;
const current = columns.map((c) => c.key);
const fromIdx = current.indexOf(payload.draggedColumnKey);
const toIdx = current.indexOf(payload.targetColumnKey);
if (fromIdx === -1 || toIdx === -1) return;
const next = current.slice();
next.splice(fromIdx, 1);
const insertAt =
payload.edge === "left"
? toIdx > fromIdx
? toIdx - 1
: toIdx
: toIdx > fromIdx
? toIdx
: toIdx + 1;
next.splice(insertAt, 0, payload.draggedColumnKey);
updateViewMutation.mutate({
viewId: effectiveView.id,
pageId: base.id,
config: { ...effectiveView.config, choiceOrder: next },
});
},
[base.id, columns, effectiveView, updateViewMutation],
);
if (!isGroupable) { if (!isGroupable) {
return <KanbanEmptyState base={base} onPick={handlePickProperty} />; return <KanbanEmptyState base={base} onPick={handlePickProperty} />;
} }
@@ -153,6 +182,7 @@ export function BaseKanban({
onCardClick={onCardClick} onCardClick={onCardClick}
onAddCard={handleAddCard} onAddCard={handleAddCard}
onCardDrop={handleCardDrop} onCardDrop={handleCardDrop}
onColumnReorder={handleColumnReorder}
/> />
))} ))}
</div> </div>
@@ -1,19 +1,36 @@
import { Badge, Text } from "@mantine/core"; import { Badge, Text } from "@mantine/core";
import {
useKanbanColumnReorder,
type ColumnReorderPayload,
} from "@/features/base/hooks/use-kanban-column-reorder";
import { BaseDropEdgeIndicator } from "@/features/base/components/grid/base-drop-edge-indicator";
import classes from "@/features/base/styles/kanban.module.css"; import classes from "@/features/base/styles/kanban.module.css";
type KanbanColumnHeaderProps = { type KanbanColumnHeaderProps = {
columnKey: string;
name: string; name: string;
color: string | null; color: string | null;
count: number; count: number;
onReorderDrop: (payload: ColumnReorderPayload) => void;
}; };
export function KanbanColumnHeader({ export function KanbanColumnHeader({
columnKey,
name, name,
color, color,
count, count,
onReorderDrop,
}: KanbanColumnHeaderProps) { }: KanbanColumnHeaderProps) {
const { ref, isDragging, closestEdge } = useKanbanColumnReorder({
columnKey,
onDrop: onReorderDrop,
});
return ( return (
<div className={classes.columnHeader}> <div
ref={ref}
className={classes.columnHeader}
data-dragging={isDragging || undefined}
>
<div className={classes.columnHeaderLeft}> <div className={classes.columnHeaderLeft}>
{color ? ( {color ? (
<Badge color={color} variant="light" size="sm"> <Badge color={color} variant="light" size="sm">
@@ -26,6 +43,7 @@ export function KanbanColumnHeader({
)} )}
<span className={classes.columnCount}>{count}</span> <span className={classes.columnCount}>{count}</span>
</div> </div>
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div> </div>
); );
} }
@@ -4,6 +4,7 @@ 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 type { CardDropPayload } from "@/features/base/hooks/use-kanban-card-drag";
import type { ColumnReorderPayload } from "@/features/base/hooks/use-kanban-column-reorder";
import { useKanbanColumnDrop } from "@/features/base/hooks/use-kanban-column-drop"; import { useKanbanColumnDrop } from "@/features/base/hooks/use-kanban-column-drop";
import classes from "@/features/base/styles/kanban.module.css"; import classes from "@/features/base/styles/kanban.module.css";
@@ -13,6 +14,7 @@ type KanbanColumnProps = {
onCardClick: (rowId: string) => void; onCardClick: (rowId: string) => void;
onAddCard: (columnKey: string) => void; onAddCard: (columnKey: string) => void;
onCardDrop: (payload: CardDropPayload) => void; onCardDrop: (payload: CardDropPayload) => void;
onColumnReorder: (payload: ColumnReorderPayload) => void;
}; };
export function KanbanColumn({ export function KanbanColumn({
@@ -21,6 +23,7 @@ export function KanbanColumn({
onCardClick, onCardClick,
onAddCard, onAddCard,
onCardDrop, onCardDrop,
onColumnReorder,
}: KanbanColumnProps) { }: KanbanColumnProps) {
const { ref: bodyRef, isOver } = useKanbanColumnDrop({ const { ref: bodyRef, isOver } = useKanbanColumnDrop({
columnKey: column.key, columnKey: column.key,
@@ -30,9 +33,11 @@ export function KanbanColumn({
return ( return (
<div className={classes.column} data-column-key={column.key}> <div className={classes.column} data-column-key={column.key}>
<KanbanColumnHeader <KanbanColumnHeader
columnKey={column.key}
name={column.name} name={column.name}
color={column.color} color={column.color}
count={column.rows.length} count={column.rows.length}
onReorderDrop={onColumnReorder}
/> />
<div <div
ref={bodyRef} ref={bodyRef}
@@ -0,0 +1,70 @@
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 ColumnReorderPayload = {
draggedColumnKey: string;
targetColumnKey: string;
edge: Edge;
};
export function useKanbanColumnReorder({
columnKey,
onDrop,
}: {
columnKey: string;
onDrop: (payload: ColumnReorderPayload) => void;
}) {
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) return;
return combine(
draggable({
element: el,
getInitialData: () => ({ type: "base-kanban-column", columnKey }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: el,
canDrop: ({ source }) =>
source.data.type === "base-kanban-column" &&
source.data.columnKey !== columnKey,
getData: ({ input, element }) =>
attachClosestEdge(
{ type: "base-kanban-column-target", columnKey },
{ input, element, allowedEdges: ["left", "right"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge || source.data.type !== "base-kanban-column") return;
onDropRef.current({
draggedColumnKey: source.data.columnKey as string,
targetColumnKey: columnKey,
edge,
});
},
}),
);
}, [columnKey]);
return { ref, isDragging, closestEdge };
}