mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 11:12:04 +00:00
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.
This commit is contained in:
parent
c70587941c
commit
dc178340de
8 changed files with 485 additions and 26 deletions
|
|
@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-label={woNumber ? `Reorder work order ${woNumber}` : "Reorder work order"}
|
||||||
|
className="flex items-center justify-center bg-transparent p-0"
|
||||||
|
style={{
|
||||||
|
cursor: gripCursor(disabled, isDragging),
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (disabled || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onArrowReorder(event.key === "ArrowUp" ? -1 : 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} aria-hidden />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { GripVertical } from "lucide-react";
|
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
|
|
@ -7,6 +6,7 @@ import type {
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
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 { 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 type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||||
|
import { neighborIdForReorder } from "@/domain/work-orders/utils/board-row-order-storage";
|
||||||
import {
|
import {
|
||||||
canApplyWorkOrderTablePatch,
|
canApplyWorkOrderTablePatch,
|
||||||
canMutateWorkOrderFlag,
|
canMutateWorkOrderFlag,
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import { FlagCell } from "./cells/flag-cell";
|
import { FlagCell } from "./cells/flag-cell";
|
||||||
import { WoTableRowActions } from "./wo-table-row-actions";
|
import { WoTableRowActions } from "./wo-table-row-actions";
|
||||||
import { WoTableRowCells } from "./wo-table-row-cells";
|
import { WoTableRowCells } from "./wo-table-row-cells";
|
||||||
|
import { WoTableRowReorderHandle } from "./wo-table-row-reorder-handle";
|
||||||
import { WoTableRowServiceCells } from "./wo-table-row-service-cells";
|
import { WoTableRowServiceCells } from "./wo-table-row-service-cells";
|
||||||
import {
|
import {
|
||||||
MUTATION_CELL_STYLE,
|
MUTATION_CELL_STYLE,
|
||||||
|
|
@ -155,15 +156,16 @@ export function WoTableRow({
|
||||||
style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
|
style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span
|
<WoTableRowReorderHandle
|
||||||
className="flex items-center justify-center"
|
disabled={isCanceled}
|
||||||
style={{
|
isDragging={isDragging}
|
||||||
cursor: isCanceled ? "default" : isDragging ? "grabbing" : "grab",
|
woNumber={row.woNumber}
|
||||||
color: "var(--color-text-muted)",
|
onArrowReorder={(delta) => {
|
||||||
|
const overId = neighborIdForReorder(allRows, row, delta);
|
||||||
|
if (overId == null) return;
|
||||||
|
handlers.onReorder(row.id, overId);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<GripVertical size={14} />
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
|
<td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
|
||||||
<FlagCell
|
<FlagCell
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks
|
||||||
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
||||||
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||||
|
import { getAuthUser } from "@/lib/auth/auth-storage";
|
||||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
|
@ -33,12 +34,15 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
useWorkOrdersBoardSource(filters);
|
useWorkOrdersBoardSource(filters);
|
||||||
|
|
||||||
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
|
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
|
||||||
|
const user = getAuthUser();
|
||||||
|
const ownerId = user?.id == null ? "" : String(user.id);
|
||||||
|
|
||||||
const local = useWorkOrdersTableLocalState({
|
const local = useWorkOrdersTableLocalState({
|
||||||
items: weeklyItems,
|
items: weeklyItems,
|
||||||
weekMonday: filters.weekMonday,
|
weekMonday: filters.weekMonday,
|
||||||
advActive,
|
advActive,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
ownerId,
|
||||||
});
|
});
|
||||||
const { displayRows, ...localUi } = local;
|
const { displayRows, ...localUi } = local;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
import {
|
import {
|
||||||
applyRowOrderByDay,
|
applyRowOrderByDay,
|
||||||
|
boardRowOrderStorageKey,
|
||||||
loadBoardRowOrder,
|
loadBoardRowOrder,
|
||||||
mergeDayOrder,
|
mergeDayOrder,
|
||||||
removeIdFromRowOrder,
|
moveIdToDayOrder,
|
||||||
reorderIdsInList,
|
reorderIdsInList,
|
||||||
saveBoardRowOrder,
|
saveBoardRowOrder,
|
||||||
type BoardRowOrderByDay,
|
type BoardRowOrderByDay,
|
||||||
|
|
@ -23,6 +25,7 @@ export type UseWorkOrdersTableLocalStateParams = {
|
||||||
weekMonday: string;
|
weekMonday: string;
|
||||||
advActive: boolean;
|
advActive: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
|
ownerId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function usePrunePatchesOnFetchSettle(
|
function usePrunePatchesOnFetchSettle(
|
||||||
|
|
@ -44,6 +47,7 @@ export function useWorkOrdersTableLocalState({
|
||||||
weekMonday,
|
weekMonday,
|
||||||
advActive,
|
advActive,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
ownerId = "",
|
||||||
}: UseWorkOrdersTableLocalStateParams) {
|
}: UseWorkOrdersTableLocalStateParams) {
|
||||||
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
||||||
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
||||||
|
|
@ -51,8 +55,19 @@ export function useWorkOrdersTableLocalState({
|
||||||
const ui = useWorkOrdersTableUiState(weekMonday);
|
const ui = useWorkOrdersTableUiState(weekMonday);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRowOrderByDay(loadBoardRowOrder(weekMonday));
|
setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId));
|
||||||
}, [weekMonday]);
|
}, [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(
|
const apiRows = useMemo(
|
||||||
() => items.map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
|
() => items.map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
|
||||||
|
|
@ -104,13 +119,13 @@ export function useWorkOrdersTableLocalState({
|
||||||
if (!previousDayGroup || previousDayGroup === nextDayGroup) return;
|
if (!previousDayGroup || previousDayGroup === nextDayGroup) return;
|
||||||
|
|
||||||
setRowOrderByDay((prev) => {
|
setRowOrderByDay((prev) => {
|
||||||
const next = removeIdFromRowOrder(prev, key);
|
const next = moveIdToDayOrder(prev, key, nextDayGroup);
|
||||||
if (next === prev) return prev;
|
if (next === prev) return prev;
|
||||||
saveBoardRowOrder(weekMonday, next);
|
saveBoardRowOrder(weekMonday, next, ownerId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[advActive, displayRows, weekMonday, localRows],
|
[advActive, displayRows, localRows, ownerId, weekMonday],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reorderRows = useCallback(
|
const reorderRows = useCallback(
|
||||||
|
|
@ -130,11 +145,11 @@ export function useWorkOrdersTableLocalState({
|
||||||
|
|
||||||
setRowOrderByDay((prev) => {
|
setRowOrderByDay((prev) => {
|
||||||
const next = { ...prev, [dayGroup]: mergeDayOrder(prev[dayGroup], reorderedVisible) };
|
const next = { ...prev, [dayGroup]: mergeDayOrder(prev[dayGroup], reorderedVisible) };
|
||||||
if (!advActive) saveBoardRowOrder(weekMonday, next);
|
if (!advActive) saveBoardRowOrder(weekMonday, next, ownerId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[advActive, displayRows, weekMonday],
|
[advActive, displayRows, ownerId, weekMonday],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { triggerFlash } = ui;
|
const { triggerFlash } = ui;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@ export type BoardRowOrderByDay = Record<string, string[]>;
|
||||||
|
|
||||||
const STORAGE_PREFIX = "shoc:wo-board-row-order:v1:";
|
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}`;
|
return `${STORAGE_PREFIX}${weekMonday}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,13 +22,9 @@ function isBoardRowOrderByDay(value: unknown): value is BoardRowOrderByDay {
|
||||||
return Object.values(value).every(isStringArray);
|
return Object.values(value).every(isStringArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
|
function readStoredOrder(key: string): BoardRowOrderByDay {
|
||||||
if (typeof window === "undefined" || !weekMonday) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(boardRowOrderStorageKey(weekMonday));
|
const raw = localStorage.getItem(key);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return {};
|
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) {
|
if (typeof window === "undefined" || !weekMonday) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(boardRowOrderStorageKey(weekMonday), JSON.stringify(orderByDay));
|
localStorage.setItem(boardRowOrderStorageKey(weekMonday, ownerId), JSON.stringify(orderByDay));
|
||||||
} catch {
|
} catch {
|
||||||
// Quota exceeded or storage blocked — keep in-memory order only.
|
// Quota exceeded or storage blocked — keep in-memory order only.
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +122,20 @@ export function applyRowOrderByDay<T extends { id: string | number; dayGroup: st
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same-day neighbor for keyboard reorder; null at list ends or if the row is missing. */
|
||||||
|
export function neighborIdForReorder<T extends { id: string | number; dayGroup: string }>(
|
||||||
|
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 {
|
export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null {
|
||||||
const dragIdx = ids.indexOf(dragId);
|
const dragIdx = ids.indexOf(dragId);
|
||||||
const overIdx = ids.indexOf(overId);
|
const overIdx = ids.indexOf(overId);
|
||||||
|
|
@ -138,6 +169,23 @@ export function removeIdFromRowOrder(
|
||||||
return changed ? next : orderByDay;
|
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,
|
* 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
|
* ids that were stored but not currently visible stay after the visible block (preserving
|
||||||
|
|
|
||||||
|
|
@ -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> = {}): 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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> = {}): 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> = {}): 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(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<WoTableRow
|
||||||
|
row={row}
|
||||||
|
allRows={allRows}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
isSelected={false}
|
||||||
|
isFlashing={false}
|
||||||
|
dragViz={{ dragId: null, overId: null }}
|
||||||
|
handlers={rowHandlers}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
{ 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,10 @@ import {
|
||||||
boardRowOrderStorageKey,
|
boardRowOrderStorageKey,
|
||||||
loadBoardRowOrder,
|
loadBoardRowOrder,
|
||||||
mergeDayOrder,
|
mergeDayOrder,
|
||||||
|
moveIdToDayOrder,
|
||||||
removeIdFromRowOrder,
|
removeIdFromRowOrder,
|
||||||
|
neighborIdForReorder,
|
||||||
|
legacyBoardRowOrderStorageKey,
|
||||||
reorderIdsInList,
|
reorderIdsInList,
|
||||||
saveBoardRowOrder,
|
saveBoardRowOrder,
|
||||||
} from "@/domain/work-orders/utils/board-row-order-storage";
|
} 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", () => {
|
describe("reorderIdsInList", () => {
|
||||||
it("moves drag id to over index", () => {
|
it("moves drag id to over index", () => {
|
||||||
expect(reorderIdsInList(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]);
|
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", () => {
|
describe("loadBoardRowOrder / saveBoardRowOrder", () => {
|
||||||
const week = "2026-07-13";
|
const week = "2026-07-13";
|
||||||
const key = boardRowOrderStorageKey(week);
|
const key = boardRowOrderStorageKey(week);
|
||||||
|
const ownerKey = boardRowOrderStorageKey(week, "u1");
|
||||||
|
const legacyKey = legacyBoardRowOrderStorageKey(week);
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
|
localStorage.removeItem(ownerKey);
|
||||||
|
localStorage.removeItem(legacyKey);
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -99,6 +134,19 @@ describe("loadBoardRowOrder / saveBoardRowOrder", () => {
|
||||||
expect(loadBoardRowOrder(week)).toEqual(order);
|
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", () => {
|
it("returns empty object for invalid JSON payload", () => {
|
||||||
localStorage.setItem(key, "{not-json");
|
localStorage.setItem(key, "{not-json");
|
||||||
expect(loadBoardRowOrder(week)).toEqual({});
|
expect(loadBoardRowOrder(week)).toEqual({});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue