From dc178340de024a0e40bef20a54bf4bcb4ad5deb6 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 2 Sep 2026 11:07:27 -0300 Subject: [PATCH] feat(work-orders): persist board row order locally and support keyboard reorder Keep same-day WO order across refresh and sign-out in the same browser. Add keyboard reorder on the row grip. --- .../table/wo-table-row-reorder-handle.tsx | 48 ++++++ .../_components/list/table/wo-table-row.tsx | 20 ++- .../_hooks/use-work-orders-table-data.ts | 4 + .../use-work-orders-table-local-state.ts | 31 +++- .../utils/board-row-order-storage.ts | 66 ++++++- ...rk-orders-table-local-state-order.test.tsx | 161 ++++++++++++++++++ .../wo-table-row-reorder-keyboard.test.tsx | 133 +++++++++++++++ .../utils/board-row-order-storage.test.ts | 48 ++++++ 8 files changed, 485 insertions(+), 26 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx create mode 100644 src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx create mode 100644 src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx new file mode 100644 index 00000000..1a934b33 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-reorder-handle.tsx @@ -0,0 +1,48 @@ +import { GripVertical } from "lucide-react"; + +type WoTableRowReorderHandleProps = { + disabled: boolean; + isDragging: boolean; + woNumber: string; + onArrowReorder: (delta: -1 | 1) => void; +}; + +function gripCursor(disabled: boolean, isDragging: boolean): string { + if (disabled) { + return "default"; + } + return isDragging ? "grabbing" : "grab"; +} + +export function WoTableRowReorderHandle({ + disabled, + isDragging, + woNumber, + onArrowReorder, +}: WoTableRowReorderHandleProps) { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index e9ab8450..6e6dcd79 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -1,4 +1,3 @@ -import { GripVertical } from "lucide-react"; import type { LocationOption, VendorDropdownItem, @@ -7,6 +6,7 @@ import type { import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderTablePatchOptions } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data"; +import { neighborIdForReorder } from "@/domain/work-orders/utils/board-row-order-storage"; import { canApplyWorkOrderTablePatch, canMutateWorkOrderFlag, @@ -16,6 +16,7 @@ import { import { FlagCell } from "./cells/flag-cell"; import { WoTableRowActions } from "./wo-table-row-actions"; import { WoTableRowCells } from "./wo-table-row-cells"; +import { WoTableRowReorderHandle } from "./wo-table-row-reorder-handle"; import { WoTableRowServiceCells } from "./wo-table-row-service-cells"; import { MUTATION_CELL_STYLE, @@ -155,15 +156,16 @@ export function WoTableRow({ style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }} onClick={(e) => e.stopPropagation()} > - { + const overId = neighborIdForReorder(allRows, row, delta); + if (overId == null) return; + handlers.onReorder(row.id, overId); }} - > - - + /> e.stopPropagation()}> >>({}); const [localRows, setLocalRows] = useState([]); @@ -51,8 +55,19 @@ export function useWorkOrdersTableLocalState({ const ui = useWorkOrdersTableUiState(weekMonday); useEffect(() => { - setRowOrderByDay(loadBoardRowOrder(weekMonday)); - }, [weekMonday]); + setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId)); + }, [ownerId, weekMonday]); + + useEffect(() => { + const storageKey = boardRowOrderStorageKey(weekMonday, ownerId); + const onStorage = (event: StorageEvent) => { + if (event.key !== storageKey) return; + setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId)); + toast.info("Board order was updated in another session. Refreshed."); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [ownerId, weekMonday]); const apiRows = useMemo( () => items.map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })), @@ -104,13 +119,13 @@ export function useWorkOrdersTableLocalState({ if (!previousDayGroup || previousDayGroup === nextDayGroup) return; setRowOrderByDay((prev) => { - const next = removeIdFromRowOrder(prev, key); + const next = moveIdToDayOrder(prev, key, nextDayGroup); if (next === prev) return prev; - saveBoardRowOrder(weekMonday, next); + saveBoardRowOrder(weekMonday, next, ownerId); return next; }); }, - [advActive, displayRows, weekMonday, localRows], + [advActive, displayRows, localRows, ownerId, weekMonday], ); const reorderRows = useCallback( @@ -130,11 +145,11 @@ export function useWorkOrdersTableLocalState({ setRowOrderByDay((prev) => { const next = { ...prev, [dayGroup]: mergeDayOrder(prev[dayGroup], reorderedVisible) }; - if (!advActive) saveBoardRowOrder(weekMonday, next); + if (!advActive) saveBoardRowOrder(weekMonday, next, ownerId); return next; }); }, - [advActive, displayRows, weekMonday], + [advActive, displayRows, ownerId, weekMonday], ); const { triggerFlash } = ui; diff --git a/src/domain/work-orders/utils/board-row-order-storage.ts b/src/domain/work-orders/utils/board-row-order-storage.ts index 0e9f4307..cac42107 100644 --- a/src/domain/work-orders/utils/board-row-order-storage.ts +++ b/src/domain/work-orders/utils/board-row-order-storage.ts @@ -2,7 +2,12 @@ export type BoardRowOrderByDay = Record; const STORAGE_PREFIX = "shoc:wo-board-row-order:v1:"; -export function boardRowOrderStorageKey(weekMonday: string): string { +export function boardRowOrderStorageKey(weekMonday: string, ownerId = ""): string { + const owner = ownerId.trim() || "local"; + return `${STORAGE_PREFIX}${owner}:${weekMonday}`; +} + +export function legacyBoardRowOrderStorageKey(weekMonday: string): string { return `${STORAGE_PREFIX}${weekMonday}`; } @@ -17,13 +22,9 @@ function isBoardRowOrderByDay(value: unknown): value is BoardRowOrderByDay { return Object.values(value).every(isStringArray); } -export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay { - if (typeof window === "undefined" || !weekMonday) { - return {}; - } - +function readStoredOrder(key: string): BoardRowOrderByDay { try { - const raw = localStorage.getItem(boardRowOrderStorageKey(weekMonday)); + const raw = localStorage.getItem(key); if (!raw) { return {}; } @@ -34,13 +35,29 @@ export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay { } } -export function saveBoardRowOrder(weekMonday: string, orderByDay: BoardRowOrderByDay): void { +export function loadBoardRowOrder(weekMonday: string, ownerId = ""): BoardRowOrderByDay { + if (typeof window === "undefined" || !weekMonday) { + return {}; + } + + const scoped = readStoredOrder(boardRowOrderStorageKey(weekMonday, ownerId)); + if (Object.keys(scoped).length) { + return scoped; + } + return readStoredOrder(legacyBoardRowOrderStorageKey(weekMonday)); +} + +export function saveBoardRowOrder( + weekMonday: string, + orderByDay: BoardRowOrderByDay, + ownerId = "", +): void { if (typeof window === "undefined" || !weekMonday) { return; } try { - localStorage.setItem(boardRowOrderStorageKey(weekMonday), JSON.stringify(orderByDay)); + localStorage.setItem(boardRowOrderStorageKey(weekMonday, ownerId), JSON.stringify(orderByDay)); } catch { // Quota exceeded or storage blocked — keep in-memory order only. } @@ -105,6 +122,20 @@ export function applyRowOrderByDay( + rows: T[], + row: T, + delta: -1 | 1, +): string | number | null { + const dayRows = rows.filter((item) => item.dayGroup === row.dayGroup); + const index = dayRows.findIndex((item) => String(item.id) === String(row.id)); + if (index < 0) { + return null; + } + return dayRows[index + delta]?.id ?? null; +} + export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null { const dragIdx = ids.indexOf(dragId); const overIdx = ids.indexOf(overId); @@ -138,6 +169,23 @@ export function removeIdFromRowOrder( return changed ? next : orderByDay; } +/** Drop the id from every day, then append it to the destination day. */ +export function moveIdToDayOrder( + orderByDay: BoardRowOrderByDay, + id: string, + nextDayGroup: string, +): BoardRowOrderByDay { + if (!nextDayGroup) { + return removeIdFromRowOrder(orderByDay, id); + } + const without = removeIdFromRowOrder(orderByDay, id); + const dest = without[nextDayGroup] ?? []; + if (dest.includes(id)) { + return without; + } + return { ...without, [nextDayGroup]: [...dest, id] }; +} + /** * Merge a visible day's new order into stored order: visible ids take the new sequence, * ids that were stored but not currently visible stay after the visible block (preserving diff --git a/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx b/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx new file mode 100644 index 00000000..5a1bc5e4 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-table-local-state-order.test.tsx @@ -0,0 +1,161 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + boardRowOrderStorageKey, + loadBoardRowOrder, + saveBoardRowOrder, +} from "@/domain/work-orders/utils/board-row-order-storage"; + +const WEEK = "2026-07-13"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "WO-1", + site: "BK5", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-15", + completedDate: "", + targetWeek: "2026-07-13", + dayGroup: "07/15", + dayLabel: "WED · 07/15", + location: "Lobby", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "7", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + isAddOn: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + additionalContacts: [], + ...overrides, + }; +} + +function hookParams( + items: WorkOrderTableRow[], + overrides: Partial<{ advActive: boolean; isFetching: boolean }> = {}, +) { + return { + items, + weekMonday: WEEK, + advActive: false, + isFetching: false, + ownerId: "u1", + ...overrides, + }; +} + +describe("useWorkOrdersTableLocalState row order", () => { + afterEach(() => { + localStorage.removeItem(boardRowOrderStorageKey(WEEK, "u1")); + localStorage.removeItem(boardRowOrderStorageKey(WEEK)); + }); + + it("reorders within a day and persists when advanced search is off", async () => { + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); + + await waitFor(() => { + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["1", "2"]); + }); + + act(() => { + result.current.reorderRows("1", "2"); + }); + + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]); + expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2", "1"] }); + }); + + it("does not write storage while advanced search is active", async () => { + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => + useWorkOrdersTableLocalState(hookParams(items, { advActive: true })), + ); + + await waitFor(() => { + expect(result.current.displayRows).toHaveLength(2); + }); + + act(() => { + result.current.reorderRows("1", "2"); + }); + + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]); + expect(loadBoardRowOrder(WEEK, "u1")).toEqual({}); + }); + + it("applies stored order after load", async () => { + saveBoardRowOrder(WEEK, { "07/15": ["2", "1"] }, "u1"); + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); + + await waitFor(() => { + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]); + }); + }); + + it("drops the id from the old day bucket when scheduledOn changes day", async () => { + saveBoardRowOrder(WEEK, { "07/15": ["1", "2"] }, "u1"); + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); + + await waitFor(() => { + expect(result.current.displayRows).toHaveLength(2); + }); + + act(() => { + result.current.patchRow("1", { scheduledOn: "2026-07-16" }); + }); + + expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2"], "07/16": ["1"] }); + }); + + it("reloads stored order when another session writes the same key", async () => { + const items = [makeRow({ id: "1" }), makeRow({ id: "2", woNumber: "WO-2" })]; + const { result } = renderHook(() => useWorkOrdersTableLocalState(hookParams(items))); + + await waitFor(() => { + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["1", "2"]); + }); + + saveBoardRowOrder(WEEK, { "07/15": ["2", "1"] }, "u1"); + act(() => { + window.dispatchEvent( + new StorageEvent("storage", { key: boardRowOrderStorageKey(WEEK, "u1") }), + ); + }); + + expect(result.current.displayRows.map((row) => String(row.id))).toEqual(["2", "1"]); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx new file mode 100644 index 00000000..3b59148c --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-row-reorder-keyboard.test.tsx @@ -0,0 +1,133 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +function makeRow(overrides: Partial = {}): WorkOrderTableRow { + return { + id: "1", + woNumber: "WO-1", + site: "BK5", + type: "PM", + severity: null, + dispatcherId: "u1", + dispatcherName: "Alex", + scheduledOn: "2026-07-15", + completedDate: "", + targetWeek: "2026-07-13", + dayGroup: "07/15", + dayLabel: "WED · 07/15", + location: "Lobby", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "7", + company: "Acme", + tech: "Sam", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + isAddOn: false, + mediaCount: 0, + title: "", + priority: "", + primaryDispatchId: null, + rowVersion: "v1", + dispatchRowVersion: "", + flagColor: null, + additionalContacts: [], + ...overrides, + }; +} + +function handlers(overrides: Partial = {}): WoTableRowHandlers { + return { + onRowClick: vi.fn(), + onView: vi.fn(), + onEdit: vi.fn(), + onOpenMedia: vi.fn(), + onPatch: vi.fn(), + onScheduleDate: vi.fn(), + onScheduleWeek: vi.fn(), + onOpenSlideOver: vi.fn(), + onReorder: vi.fn(), + onDragStart: vi.fn(), + onDragOver: vi.fn(), + onDrop: vi.fn(), + onDragEnd: vi.fn(), + ...overrides, + }; +} + +function renderRow( + row: WorkOrderTableRow, + allRows: WorkOrderTableRow[], + rowHandlers: WoTableRowHandlers, +) { + return renderWithProviders( + + + + +
, + { withAuth: false }, + ); +} + +describe("WoTableRow keyboard reorder", () => { + it("reorders down and up with arrow keys on the grip", () => { + const onReorder = vi.fn(); + const first = makeRow({ id: "1", woNumber: "WO-1" }); + const second = makeRow({ id: "2", woNumber: "WO-2" }); + renderRow(first, [first, second], handlers({ onReorder })); + + fireEvent.keyDown(screen.getByRole("button", { name: "Reorder work order WO-1" }), { + key: "ArrowDown", + }); + expect(onReorder).toHaveBeenCalledWith("1", "2"); + + onReorder.mockClear(); + fireEvent.keyDown(screen.getByRole("button", { name: "Reorder work order WO-1" }), { + key: "ArrowUp", + }); + expect(onReorder).not.toHaveBeenCalled(); + }); + + it("does not reorder a canceled row from the grip", () => { + const onReorder = vi.fn(); + const row = makeRow({ status: "Canceled" }); + renderRow(row, [row], handlers({ onReorder })); + + const grip = screen.getByRole("button", { name: "Reorder work order WO-1" }); + expect(grip).toBeDisabled(); + fireEvent.keyDown(grip, { key: "ArrowDown" }); + expect(onReorder).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/work-orders/utils/board-row-order-storage.test.ts b/src/test/domain/work-orders/utils/board-row-order-storage.test.ts index 28c326cf..20bab014 100644 --- a/src/test/domain/work-orders/utils/board-row-order-storage.test.ts +++ b/src/test/domain/work-orders/utils/board-row-order-storage.test.ts @@ -5,7 +5,10 @@ import { boardRowOrderStorageKey, loadBoardRowOrder, mergeDayOrder, + moveIdToDayOrder, removeIdFromRowOrder, + neighborIdForReorder, + legacyBoardRowOrderStorageKey, reorderIdsInList, saveBoardRowOrder, } from "@/domain/work-orders/utils/board-row-order-storage"; @@ -47,6 +50,25 @@ describe("applyRowOrderByDay", () => { }); }); +describe("neighborIdForReorder", () => { + const rows = [ + { id: "a", dayGroup: "07/14" }, + { id: "b", dayGroup: "07/14" }, + { id: "c", dayGroup: "07/15" }, + ]; + + it("returns the previous and next id in the same day", () => { + expect(neighborIdForReorder(rows, rows[1]!, -1)).toBe("a"); + expect(neighborIdForReorder(rows, rows[0]!, 1)).toBe("b"); + }); + + it("returns null at the ends of a day or when the row is missing", () => { + expect(neighborIdForReorder(rows, rows[0]!, -1)).toBeNull(); + expect(neighborIdForReorder(rows, rows[2]!, 1)).toBeNull(); + expect(neighborIdForReorder(rows, { id: "x", dayGroup: "07/14" }, 1)).toBeNull(); + }); +}); + describe("reorderIdsInList", () => { it("moves drag id to over index", () => { expect(reorderIdsInList(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]); @@ -84,12 +106,25 @@ describe("removeIdFromRowOrder", () => { }); }); +describe("moveIdToDayOrder", () => { + it("moves an id to the end of the destination day", () => { + expect(moveIdToDayOrder({ "07/15": ["1", "2"], "07/16": ["3"] }, "1", "07/16")).toEqual({ + "07/15": ["2"], + "07/16": ["3", "1"], + }); + }); +}); + describe("loadBoardRowOrder / saveBoardRowOrder", () => { const week = "2026-07-13"; const key = boardRowOrderStorageKey(week); + const ownerKey = boardRowOrderStorageKey(week, "u1"); + const legacyKey = legacyBoardRowOrderStorageKey(week); afterEach(() => { localStorage.removeItem(key); + localStorage.removeItem(ownerKey); + localStorage.removeItem(legacyKey); vi.restoreAllMocks(); }); @@ -99,6 +134,19 @@ describe("loadBoardRowOrder / saveBoardRowOrder", () => { expect(loadBoardRowOrder(week)).toEqual(order); }); + it("reads the legacy unscoped key when the owner key is empty", () => { + const order = { "07/14": ["9"] }; + localStorage.setItem(legacyKey, JSON.stringify(order)); + expect(loadBoardRowOrder(week, "u1")).toEqual(order); + }); + + it("keeps owner-scoped maps isolated", () => { + saveBoardRowOrder(week, { "07/14": ["1"] }, "u1"); + saveBoardRowOrder(week, { "07/14": ["2"] }, "u2"); + expect(loadBoardRowOrder(week, "u1")).toEqual({ "07/14": ["1"] }); + expect(loadBoardRowOrder(week, "u2")).toEqual({ "07/14": ["2"] }); + }); + it("returns empty object for invalid JSON payload", () => { localStorage.setItem(key, "{not-json"); expect(loadBoardRowOrder(week)).toEqual({});