diff --git a/apps/client/src/features/base/hooks/__tests__/resolve-card-drop.spec.ts b/apps/client/src/features/base/hooks/__tests__/resolve-card-drop.spec.ts new file mode 100644 index 000000000..f93fee353 --- /dev/null +++ b/apps/client/src/features/base/hooks/__tests__/resolve-card-drop.spec.ts @@ -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(); + }); +}); diff --git a/apps/client/src/features/base/hooks/resolve-card-drop.ts b/apps/client/src/features/base/hooks/resolve-card-drop.ts new file mode 100644 index 000000000..7ea53531a --- /dev/null +++ b/apps/client/src/features/base/hooks/resolve-card-drop.ts @@ -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 | 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 }; +}