feat(bases): add resolveCardDrop helper for kanban drag mutations

This commit is contained in:
Philipinho
2026-05-24 13:20:31 +01:00
parent fd6f6a9341
commit ad0e65371b
2 changed files with 196 additions and 0 deletions
@@ -0,0 +1,108 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("fractional-indexing-jittered", () => ({
generateJitteredKeyBetween: (a: string | null, b: string | null) =>
`${a ?? "START"}|${b ?? "END"}`,
}));
import { resolveCardDrop } from "../resolve-card-drop";
import { NO_VALUE_CHOICE_ID } from "@/features/base/types/base.types";
const mkRow = (id: string, position: string) =>
({ id, position, cells: {} }) as any;
describe("resolveCardDrop", () => {
it("returns cells-only when cross-column drop happens with sort active", () => {
const result = resolveCardDrop({
draggedCardId: "r1",
targetCardId: "r2",
edge: "top",
sourceColumnKey: "c1",
targetColumnKey: "c2",
groupByPropertyId: "prop-status",
columnRows: [mkRow("r2", "b")],
sortsActive: true,
});
expect(result.cells).toEqual({ "prop-status": "c2" });
expect(result.position).toBeUndefined();
});
it("writes null cell value when target is the NO_VALUE column", () => {
const result = resolveCardDrop({
draggedCardId: "r1",
targetCardId: "r2",
edge: "top",
sourceColumnKey: "c1",
targetColumnKey: NO_VALUE_CHOICE_ID,
groupByPropertyId: "prop-status",
columnRows: [mkRow("r2", "b")],
sortsActive: false,
});
expect(result.cells).toEqual({ "prop-status": null });
});
it("returns position-only when intra-column drop with no sort", () => {
const result = resolveCardDrop({
draggedCardId: "r1",
targetCardId: "r2",
edge: "bottom",
sourceColumnKey: "c1",
targetColumnKey: "c1",
groupByPropertyId: "prop-status",
columnRows: [mkRow("r2", "a"), mkRow("r3", "c")],
sortsActive: false,
});
expect(result.cells).toBeUndefined();
expect(typeof result.position).toBe("string");
// Between 'a' (r2) and 'c' (r3) → some key, exact value depends on jitter
// but must satisfy 'a' < key < 'c' for typical jitter outputs.
expect(result.position! > "a").toBe(true);
expect(result.position! < "c").toBe(true);
});
it("returns both cells and position for cross-column with slot", () => {
const result = resolveCardDrop({
draggedCardId: "r1",
targetCardId: "r2",
edge: "top",
sourceColumnKey: "c1",
targetColumnKey: "c2",
groupByPropertyId: "prop-status",
columnRows: [mkRow("r2", "b"), mkRow("r3", "d")],
sortsActive: false,
});
expect(result.cells).toEqual({ "prop-status": "c2" });
expect(typeof result.position).toBe("string");
expect(result.position! < "b").toBe(true);
});
it("appends to the end when targetCardId is not in columnRows (empty/below-last)", () => {
const result = resolveCardDrop({
draggedCardId: "r1",
targetCardId: "__column-body__", // sentinel
edge: "bottom",
sourceColumnKey: "c1",
targetColumnKey: "c2",
groupByPropertyId: "prop-status",
columnRows: [mkRow("r5", "z")],
sortsActive: false,
});
expect(result.cells).toEqual({ "prop-status": "c2" });
expect(result.position! > "z").toBe(true);
});
it("returns undefined for both fields when same column and sort active", () => {
const result = resolveCardDrop({
draggedCardId: "r1",
targetCardId: "r2",
edge: "top",
sourceColumnKey: "c1",
targetColumnKey: "c1",
groupByPropertyId: "prop-status",
columnRows: [mkRow("r2", "a")],
sortsActive: true,
});
expect(result.cells).toBeUndefined();
expect(result.position).toBeUndefined();
});
});
@@ -0,0 +1,88 @@
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { IBaseRow, NO_VALUE_CHOICE_ID } from "@/features/base/types/base.types";
type Edge = "top" | "bottom";
export type ResolveCardDropInput = {
draggedCardId: string;
targetCardId: string;
edge: Edge | null;
sourceColumnKey: string;
targetColumnKey: string;
groupByPropertyId: string;
columnRows: IBaseRow[]; // rows currently in the target column, in display order
sortsActive: boolean;
};
export type ResolveCardDropResult = {
cells: Record<string, unknown> | undefined;
position: string | undefined;
};
export function resolveCardDrop(
input: ResolveCardDropInput,
): ResolveCardDropResult {
const {
draggedCardId,
targetCardId,
edge,
sourceColumnKey,
targetColumnKey,
groupByPropertyId,
columnRows,
sortsActive,
} = input;
const sameColumn = sourceColumnKey === targetColumnKey;
// Compute the cells patch first.
const cells = sameColumn
? undefined
: {
[groupByPropertyId]:
targetColumnKey === NO_VALUE_CHOICE_ID ? null : targetColumnKey,
};
// Same column + sort active → block (caller should have stopped the drag
// via canDrop, but we return no-op for safety).
if (sameColumn && sortsActive) {
return { cells: undefined, position: undefined };
}
// Sort active and cross-column → only cells, no position.
if (sortsActive) {
return { cells, position: undefined };
}
// Sort inactive → compute the slot.
// Filter the dragged card out of the column (it may already be there
// when intra-column drag).
const target = columnRows.filter((r) => r.id !== draggedCardId);
const targetIndex = target.findIndex((r) => r.id === targetCardId);
let lower: string | null;
let upper: string | null;
if (targetIndex === -1) {
// Drop on column body / sentinel / below-last → append.
const last = target[target.length - 1];
lower = last?.position ?? null;
upper = null;
} else if (edge === "top") {
lower = target[targetIndex - 1]?.position ?? null;
upper = target[targetIndex].position;
} else {
lower = target[targetIndex].position;
upper = target[targetIndex + 1]?.position ?? null;
}
let position: string;
try {
position = generateJitteredKeyBetween(lower, upper);
} catch {
// Identical keys (rare; happens when two rows briefly share a position
// during a concurrent edit). Fall back to insert-after-lower.
position = generateJitteredKeyBetween(lower, null);
}
return { cells, position };
}