feat(board): drop position arithmetic with same-column handling
This commit is contained in:
parent
193fe5bd86
commit
5d3323e765
3 changed files with 124 additions and 3 deletions
16
.env.example
16
.env.example
|
|
@ -1,3 +1,13 @@
|
|||
NEXT_PUBLIC_PB_URL=http://pocketbase-roo2fon0xs50369mmf04udx1.169.239.180.130.sslip.io/
|
||||
PB_SUPERUSER_EMAIL=kanban@apphub.co.za
|
||||
PB_SUPERUSER_PASSWORD=apphub12345678
|
||||
# Copy to .env.local and fill in real values. .env.local is gitignored.
|
||||
#
|
||||
# NEVER put real credentials in this file. It is tracked by git, so anything
|
||||
# written here is published to every clone and lives in history forever.
|
||||
|
||||
# The PocketBase instance. This one is public by design — the browser connects
|
||||
# to it directly — so the URL itself is not a secret.
|
||||
NEXT_PUBLIC_PB_URL=https://pocketbase.example.com
|
||||
|
||||
# Superuser credentials, used server-side only by scripts/setup-remote.ts,
|
||||
# scripts/seed-demo.ts, and the /api/client/* route handlers.
|
||||
PB_SUPERUSER_EMAIL=you@example.com
|
||||
PB_SUPERUSER_PASSWORD=replace-me
|
||||
|
|
|
|||
72
src/lib/board/move.test.ts
Normal file
72
src/lib/board/move.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { computeDropPosition, type MovableCard } from "@/lib/board/move";
|
||||
|
||||
const column: MovableCard[] = [
|
||||
{ id: "a", position: 1000 },
|
||||
{ id: "b", position: 2000 },
|
||||
{ id: "c", position: 3000 },
|
||||
];
|
||||
|
||||
describe("computeDropPosition — moving into a different column", () => {
|
||||
it("drops at the top", () => {
|
||||
expect(computeDropPosition(column, 0, null)).toBe(0);
|
||||
});
|
||||
|
||||
it("drops in the middle", () => {
|
||||
expect(computeDropPosition(column, 1, null)).toBe(1500);
|
||||
});
|
||||
|
||||
it("drops at the bottom", () => {
|
||||
expect(computeDropPosition(column, 3, null)).toBe(4000);
|
||||
});
|
||||
|
||||
it("drops into an empty column", () => {
|
||||
expect(computeDropPosition([], 0, null)).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDropPosition — reordering within the same column", () => {
|
||||
it("excludes the dragged card from its own neighbours", () => {
|
||||
// Moving "a" to the end. If "a" were still counted, the last neighbour
|
||||
// would be "c" at 3000 and the answer 4000 — but "a" leaving the top does
|
||||
// not change that. The real trap is the middle case below.
|
||||
expect(computeDropPosition(column, 3, "a")).toBe(4000);
|
||||
});
|
||||
|
||||
it("computes the midpoint of the remaining cards, not including itself", () => {
|
||||
// Moving "c" between "a" and "b". With "c" removed the neighbours are
|
||||
// 1000 and 2000, so the answer is 1500. If "c" were left in the list the
|
||||
// index would point at the wrong pair and the card would land back where
|
||||
// it started.
|
||||
expect(computeDropPosition(column, 1, "c")).toBe(1500);
|
||||
});
|
||||
|
||||
it("moves the first card to the second slot", () => {
|
||||
// Remaining after removing "a": [b=2000, c=3000]. Index 1 sits between
|
||||
// them, so 2500.
|
||||
expect(computeDropPosition(column, 1, "a")).toBe(2500);
|
||||
});
|
||||
|
||||
it("returns the card's own position when dropped where it already is", () => {
|
||||
expect(computeDropPosition(column, 1, "b")).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDropPosition — guards", () => {
|
||||
it("clamps an index beyond the end", () => {
|
||||
expect(computeDropPosition(column, 99, null)).toBe(4000);
|
||||
});
|
||||
|
||||
it("clamps a negative index", () => {
|
||||
expect(computeDropPosition(column, -5, null)).toBe(0);
|
||||
});
|
||||
|
||||
it("tolerates an unsorted input list", () => {
|
||||
const shuffled: MovableCard[] = [
|
||||
{ id: "c", position: 3000 },
|
||||
{ id: "a", position: 1000 },
|
||||
{ id: "b", position: 2000 },
|
||||
];
|
||||
expect(computeDropPosition(shuffled, 1, null)).toBe(1500);
|
||||
});
|
||||
});
|
||||
39
src/lib/board/move.ts
Normal file
39
src/lib/board/move.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Where a dragged card lands.
|
||||
*
|
||||
* The one thing worth being careful about: when a card is reordered inside
|
||||
* its own column, it must be removed from the list before its neighbours are
|
||||
* read. Leave it in and the index points at the wrong pair, and the card
|
||||
* snaps back to roughly where it started — a bug that looks like the drag
|
||||
* "not working" rather than like arithmetic.
|
||||
*/
|
||||
|
||||
import { positionBetween } from "@/lib/ordering/fractional";
|
||||
|
||||
export interface MovableCard {
|
||||
id: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param destinationCards every card currently in the destination column
|
||||
* @param dropIndex the slot the card is being dropped into, 0-based
|
||||
* @param draggedCardId the dragged card's id when reordering within the
|
||||
* same column; null when it comes from another column
|
||||
*/
|
||||
export function computeDropPosition(
|
||||
destinationCards: MovableCard[],
|
||||
dropIndex: number,
|
||||
draggedCardId: string | null
|
||||
): number {
|
||||
const ordered = [...destinationCards]
|
||||
.filter((card) => card.id !== draggedCardId)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
|
||||
const index = Math.max(0, Math.min(dropIndex, ordered.length));
|
||||
|
||||
const before = index > 0 ? ordered[index - 1].position : null;
|
||||
const after = index < ordered.length ? ordered[index].position : null;
|
||||
|
||||
return positionBetween(before, after);
|
||||
}
|
||||
Loading…
Reference in a new issue