mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
Merge pull request #164 from Sea-Haven-Industries/feat/SH-168-wo-board-row-order
feat(work-orders): persist board row order locally and support keyboard reorder
This commit is contained in:
commit
fc07371f0d
11 changed files with 791 additions and 108 deletions
|
|
@ -0,0 +1,40 @@
|
|||
import { GripVertical } from "lucide-react";
|
||||
|
||||
type WoTableRowReorderHandleProps = {
|
||||
disabled: boolean;
|
||||
isDragging: boolean;
|
||||
woNumber: string;
|
||||
onArrowReorder: (delta: -1 | 1) => void;
|
||||
};
|
||||
|
||||
export function WoTableRowReorderHandle({
|
||||
disabled,
|
||||
isDragging,
|
||||
woNumber,
|
||||
onArrowReorder,
|
||||
}: WoTableRowReorderHandleProps) {
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={woNumber ? `Reorder work order ${woNumber}` : "Reorder work order"}
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
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 />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,21 @@ export function WoTableRow({
|
|||
style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
cursor: isCanceled ? "default" : isDragging ? "grabbing" : "grab",
|
||||
color: "var(--color-text-muted)",
|
||||
<WoTableRowReorderHandle
|
||||
disabled={isCanceled}
|
||||
isDragging={isDragging}
|
||||
woNumber={row.woNumber}
|
||||
onArrowReorder={(delta) => {
|
||||
const overId = neighborIdForReorder(
|
||||
allRows,
|
||||
row,
|
||||
delta,
|
||||
(item) => !isWorkOrderFullyLocked(item.status),
|
||||
);
|
||||
if (overId == null) return;
|
||||
handlers.onReorder(row.id, overId);
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<FlagCell
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { getAuthUser } from "@/lib/auth/auth-storage";
|
||||
|
||||
export function resolveBoardRowOrderOwnerId(): string {
|
||||
const user = getAuthUser();
|
||||
return user?.id == null ? "" : String(user.id);
|
||||
}
|
||||
|
|
@ -1,25 +1,13 @@
|
|||
import { useMemo } from "react";
|
||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { resolveBoardRowOrderOwnerId } from "@/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id";
|
||||
import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page";
|
||||
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 { useWorkOrdersTableDayView } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-day-view";
|
||||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import {
|
||||
pickBoardPager,
|
||||
pageSelectedDayGroups,
|
||||
type DayPageSize,
|
||||
} from "@/domain/work-orders/utils/day-pagination";
|
||||
import {
|
||||
buildSelectedDayGroup,
|
||||
isDayScopedSearchEmpty,
|
||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import {
|
||||
filterByBarAndSearch,
|
||||
splitScheduledUnscheduled,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { pickBoardPager, type DayPageSize } from "@/domain/work-orders/utils/day-pagination";
|
||||
import { filterByBarAndSearch } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
|
||||
export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||
|
||||
|
|
@ -33,12 +21,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
useWorkOrdersBoardSource(filters);
|
||||
|
||||
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
|
||||
const ownerId = resolveBoardRowOrderOwnerId();
|
||||
|
||||
const local = useWorkOrdersTableLocalState({
|
||||
items: weeklyItems,
|
||||
weekMonday: filters.weekMonday,
|
||||
advActive,
|
||||
isFetching,
|
||||
ownerId,
|
||||
});
|
||||
const { displayRows, ...localUi } = local;
|
||||
|
||||
|
|
@ -54,57 +44,22 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
return displayRows;
|
||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||
|
||||
const { scheduled, unscheduled } = useMemo(() => {
|
||||
if (advActive) {
|
||||
return splitScheduledUnscheduled(advResults);
|
||||
}
|
||||
return splitScheduledUnscheduled(filtered);
|
||||
}, [advActive, advResults, filtered]);
|
||||
|
||||
const dayGroups = useMemo(
|
||||
() =>
|
||||
advActive
|
||||
? []
|
||||
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
|
||||
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
|
||||
);
|
||||
const pagination = useWorkOrdersDayPagination({
|
||||
weekMonday: filters.weekMonday,
|
||||
selectedDayIso: filters.selectedDayIso,
|
||||
const dayView = useWorkOrdersTableDayView({
|
||||
filters,
|
||||
advActive,
|
||||
dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
|
||||
filtered,
|
||||
advResults,
|
||||
onRevealed: local.triggerFlash,
|
||||
});
|
||||
const pagedDay = useMemo(
|
||||
() => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
|
||||
[dayGroups, pagination.page, pagination.pageSize],
|
||||
);
|
||||
|
||||
const searchActive = filters.search.trim() !== "";
|
||||
const displayGroups = pagedDay.groups;
|
||||
const dayTotalCount = pagedDay.totalCount;
|
||||
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
|
||||
const noSearchResults =
|
||||
!advActive &&
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: dayTotalCount,
|
||||
unassignedQueueReady: true,
|
||||
});
|
||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||
|
||||
const todayKey =
|
||||
filters.weekMonday === currentWeekMonday()
|
||||
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
|
||||
: null;
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() => displayRows.find((row) => String(row.id) === String(local.selectedId)) ?? null,
|
||||
[displayRows, local.selectedId],
|
||||
);
|
||||
|
||||
const scopeTotalCount = advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount;
|
||||
const scopeTotalCount = advActive
|
||||
? (data?.totalCount ?? advResults.length)
|
||||
: dayView.dayTotalCount;
|
||||
useSyncOutOfRangeSearchPage({
|
||||
enabled: advActive,
|
||||
ready: !isLoading,
|
||||
|
|
@ -113,11 +68,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
totalCount: scopeTotalCount,
|
||||
setPage: filters.setPage,
|
||||
});
|
||||
const listTotalCount = advActive ? advResults.length : dayVisibleCount;
|
||||
const listTotalCount = advActive ? advResults.length : dayView.dayVisibleCount;
|
||||
const pager = pickBoardPager({
|
||||
advActive,
|
||||
isLoading,
|
||||
noSearchResults,
|
||||
noSearchResults: dayView.noSearchResults,
|
||||
advResultCount: advResults.length,
|
||||
search: {
|
||||
page: filters.page,
|
||||
|
|
@ -125,11 +80,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
totalCount: scopeTotalCount,
|
||||
},
|
||||
day: {
|
||||
pageSize: pagination.pageSize,
|
||||
pageIndex: pagedDay.pageIndex,
|
||||
pageCount: pagedDay.pageCount,
|
||||
itemCount: dayTotalCount,
|
||||
footerLabel: displayGroups[0]?.label ?? "",
|
||||
pageSize: dayView.pagination.pageSize,
|
||||
pageIndex: dayView.pagedDay.pageIndex,
|
||||
pageCount: dayView.pagedDay.pageCount,
|
||||
itemCount: dayView.dayTotalCount,
|
||||
footerLabel: dayView.displayGroups[0]?.label ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -145,12 +100,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
unassignedTotalCount: 0,
|
||||
unassignedError: null,
|
||||
unassignedLoading: false,
|
||||
unscheduled,
|
||||
displayGroups,
|
||||
searchActive,
|
||||
noSearchResults,
|
||||
isEmptyWeek,
|
||||
todayKey,
|
||||
unscheduled: dayView.unscheduled,
|
||||
displayGroups: dayView.displayGroups,
|
||||
searchActive: dayView.searchActive,
|
||||
noSearchResults: dayView.noSearchResults,
|
||||
isEmptyWeek: dayView.isEmptyWeek,
|
||||
todayKey: dayView.todayKey,
|
||||
...localUi,
|
||||
selectedRow,
|
||||
scopeTotalCount,
|
||||
|
|
@ -167,14 +122,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
filters.setPageSize(next);
|
||||
return;
|
||||
}
|
||||
pagination.setPageSize(next);
|
||||
dayView.pagination.setPageSize(next);
|
||||
},
|
||||
goToPrevDayPage: advActive
|
||||
? () => filters.setPage(Math.max(0, pager.pageIndex - 1))
|
||||
: pagination.goToPrevPage,
|
||||
: dayView.pagination.goToPrevPage,
|
||||
goToNextDayPage: advActive
|
||||
? () => filters.setPage(Math.min(pager.pageIndex + 1, Math.max(0, pager.pageCount - 1)))
|
||||
: pagination.goToNextPage,
|
||||
revealScheduledRow: pagination.revealRow,
|
||||
: dayView.pagination.goToNextPage,
|
||||
revealScheduledRow: dayView.pagination.revealRow,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { useMemo } from "react";
|
||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination";
|
||||
import {
|
||||
buildSelectedDayGroup,
|
||||
isDayScopedSearchEmpty,
|
||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import { splitScheduledUnscheduled } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
|
||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||
|
||||
export type UseWorkOrdersTableDayViewParams = {
|
||||
filters: FiltersReturn;
|
||||
advActive: boolean;
|
||||
filtered: WorkOrderTableRow[];
|
||||
advResults: WorkOrderTableRow[];
|
||||
onRevealed: (id: string | number) => void;
|
||||
};
|
||||
|
||||
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
|
||||
|
||||
export function useWorkOrdersTableDayView({
|
||||
filters,
|
||||
advActive,
|
||||
filtered,
|
||||
advResults,
|
||||
onRevealed,
|
||||
}: UseWorkOrdersTableDayViewParams) {
|
||||
const { scheduled, unscheduled } = useMemo(() => {
|
||||
if (advActive) {
|
||||
return splitScheduledUnscheduled(advResults);
|
||||
}
|
||||
return splitScheduledUnscheduled(filtered);
|
||||
}, [advActive, advResults, filtered]);
|
||||
|
||||
const dayGroups = useMemo(
|
||||
() =>
|
||||
advActive
|
||||
? []
|
||||
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
|
||||
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
|
||||
);
|
||||
const pagination = useWorkOrdersDayPagination({
|
||||
weekMonday: filters.weekMonday,
|
||||
selectedDayIso: filters.selectedDayIso,
|
||||
advActive,
|
||||
dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
|
||||
onRevealed,
|
||||
});
|
||||
const pagedDay = useMemo(
|
||||
() => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
|
||||
[dayGroups, pagination.page, pagination.pageSize],
|
||||
);
|
||||
|
||||
const searchActive = filters.search.trim() !== "";
|
||||
const displayGroups = pagedDay.groups;
|
||||
const dayTotalCount = pagedDay.totalCount;
|
||||
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
|
||||
const noSearchResults =
|
||||
!advActive &&
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: dayTotalCount,
|
||||
unassignedQueueReady: true,
|
||||
});
|
||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||
const todayKey =
|
||||
filters.weekMonday === currentWeekMonday()
|
||||
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
|
||||
: null;
|
||||
|
||||
return {
|
||||
scheduled,
|
||||
unscheduled,
|
||||
displayGroups,
|
||||
searchActive,
|
||||
noSearchResults,
|
||||
isEmptyWeek,
|
||||
todayKey,
|
||||
dayTotalCount,
|
||||
dayVisibleCount,
|
||||
pagination,
|
||||
pagedDay,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
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 { applyBoardRowOrderOnScheduleChange } from "@/domain/work-orders/utils/apply-board-row-order-on-schedule-change";
|
||||
import { applyCarryOverDisplayDayFields } from "@/domain/work-orders/utils/carry-over-display";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { isWorkOrderFullyLocked } from "@/domain/work-orders/utils/work-order-lock";
|
||||
import {
|
||||
applyRowOrderByDay,
|
||||
boardRowOrderStorageKey,
|
||||
loadBoardRowOrder,
|
||||
mergeDayOrder,
|
||||
removeIdFromRowOrder,
|
||||
reorderIdsInList,
|
||||
saveBoardRowOrder,
|
||||
type BoardRowOrderByDay,
|
||||
|
|
@ -24,6 +27,7 @@ export type UseWorkOrdersTableLocalStateParams = {
|
|||
weekMonday: string;
|
||||
advActive: boolean;
|
||||
isFetching: boolean;
|
||||
ownerId?: string;
|
||||
};
|
||||
|
||||
function usePrunePatchesOnFetchSettle(
|
||||
|
|
@ -45,6 +49,7 @@ export function useWorkOrdersTableLocalState({
|
|||
weekMonday,
|
||||
advActive,
|
||||
isFetching,
|
||||
ownerId = "",
|
||||
}: UseWorkOrdersTableLocalStateParams) {
|
||||
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
||||
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
||||
|
|
@ -52,8 +57,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,17 +120,17 @@ export function useWorkOrdersTableLocalState({
|
|||
|
||||
if (patch.scheduledOn === undefined || advActive) return;
|
||||
|
||||
const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup;
|
||||
if (!previousDayGroup || previousDayGroup === nextDayGroup) return;
|
||||
|
||||
setRowOrderByDay((prev) => {
|
||||
const next = removeIdFromRowOrder(prev, key);
|
||||
if (next === prev) return prev;
|
||||
saveBoardRowOrder(weekMonday, next);
|
||||
return next;
|
||||
});
|
||||
setRowOrderByDay((prev) =>
|
||||
applyBoardRowOrderOnScheduleChange(prev, {
|
||||
weekMonday,
|
||||
ownerId,
|
||||
id: key,
|
||||
scheduledOn: patch.scheduledOn!,
|
||||
previousDayGroup,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[advActive, displayRows, weekMonday, localRows],
|
||||
[advActive, displayRows, localRows, ownerId, weekMonday],
|
||||
);
|
||||
|
||||
const reorderRows = useCallback(
|
||||
|
|
@ -124,6 +140,7 @@ export function useWorkOrdersTableLocalState({
|
|||
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
|
||||
const overRow = displayRows.find((r) => String(r.id) === overKey);
|
||||
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) return;
|
||||
if (isWorkOrderFullyLocked(dragRow.status) || isWorkOrderFullyLocked(overRow.status)) return;
|
||||
|
||||
const dayGroup = dragRow.dayGroup;
|
||||
const dayIds = displayRows
|
||||
|
|
@ -134,11 +151,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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import type { BoardRowOrderByDay } from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
import {
|
||||
moveIdToDayOrder,
|
||||
removeIdFromRowOrder,
|
||||
saveBoardRowOrder,
|
||||
transferIdToDestinationWeekOrder,
|
||||
} from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
export type ApplyBoardRowOrderOnScheduleChangeInput = {
|
||||
weekMonday: string;
|
||||
ownerId: string;
|
||||
id: string;
|
||||
scheduledOn: string;
|
||||
previousDayGroup?: string;
|
||||
};
|
||||
|
||||
export function applyBoardRowOrderOnScheduleChange(
|
||||
orderByDay: BoardRowOrderByDay,
|
||||
input: ApplyBoardRowOrderOnScheduleChangeInput,
|
||||
): BoardRowOrderByDay {
|
||||
const { weekMonday, ownerId, id, scheduledOn, previousDayGroup } = input;
|
||||
const nextDayGroup = deriveDayFields(scheduledOn).dayGroup;
|
||||
if (!previousDayGroup || previousDayGroup === nextDayGroup) {
|
||||
return orderByDay;
|
||||
}
|
||||
|
||||
const destinationWeekMonday = mondayOf(scheduledOn.slice(0, 10));
|
||||
if (destinationWeekMonday !== weekMonday) {
|
||||
transferIdToDestinationWeekOrder({
|
||||
sourceWeekMonday: weekMonday,
|
||||
destinationWeekMonday,
|
||||
ownerId,
|
||||
id,
|
||||
nextDayGroup,
|
||||
});
|
||||
return removeIdFromRowOrder(orderByDay, id);
|
||||
}
|
||||
|
||||
const next = moveIdToDayOrder(orderByDay, id, nextDayGroup);
|
||||
if (next !== orderByDay) {
|
||||
saveBoardRowOrder(weekMonday, next, ownerId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
|
@ -2,7 +2,12 @@ export type BoardRowOrderByDay = Record<string, string[]>;
|
|||
|
||||
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,27 @@ 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,
|
||||
isReorderTarget: (item: T) => boolean = () => true,
|
||||
): 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;
|
||||
}
|
||||
for (let cursor = index + delta; cursor >= 0 && cursor < dayRows.length; cursor += delta) {
|
||||
const candidate = dayRows[cursor];
|
||||
if (candidate && isReorderTarget(candidate)) {
|
||||
return candidate.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null {
|
||||
const dragIdx = ids.indexOf(dragId);
|
||||
const overIdx = ids.indexOf(overId);
|
||||
|
|
@ -138,6 +176,60 @@ export function removeIdFromRowOrder(
|
|||
return changed ? next : orderByDay;
|
||||
}
|
||||
|
||||
/** Drop the id from every day, then append it to the destination day. */
|
||||
export type TransferIdToDestinationWeekOrderInput = {
|
||||
sourceWeekMonday: string;
|
||||
destinationWeekMonday: string;
|
||||
ownerId: string;
|
||||
id: string;
|
||||
nextDayGroup: string;
|
||||
};
|
||||
|
||||
/** Persist a moved row under the destination week when schedule crosses week boundaries. */
|
||||
export function transferIdToDestinationWeekOrder({
|
||||
sourceWeekMonday,
|
||||
destinationWeekMonday,
|
||||
ownerId,
|
||||
id,
|
||||
nextDayGroup,
|
||||
}: TransferIdToDestinationWeekOrderInput): void {
|
||||
if (!sourceWeekMonday || !destinationWeekMonday || !nextDayGroup) {
|
||||
return;
|
||||
}
|
||||
if (sourceWeekMonday === destinationWeekMonday) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceOrder = loadBoardRowOrder(sourceWeekMonday, ownerId);
|
||||
const sourceWithoutId = removeIdFromRowOrder(sourceOrder, id);
|
||||
if (sourceWithoutId !== sourceOrder) {
|
||||
saveBoardRowOrder(sourceWeekMonday, sourceWithoutId, ownerId);
|
||||
}
|
||||
|
||||
const destinationOrder = loadBoardRowOrder(destinationWeekMonday, ownerId);
|
||||
saveBoardRowOrder(
|
||||
destinationWeekMonday,
|
||||
moveIdToDayOrder(destinationOrder, id, nextDayGroup),
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
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));
|
||||
localStorage.removeItem(boardRowOrderStorageKey("2026-07-20", "u1"));
|
||||
});
|
||||
|
||||
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 persist a reorder onto a canceled row", async () => {
|
||||
const items = [
|
||||
makeRow({ id: "1" }),
|
||||
makeRow({ id: "2", woNumber: "WO-2", status: "Canceled" }),
|
||||
];
|
||||
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(["1", "2"]);
|
||||
expect(loadBoardRowOrder(WEEK, "u1")).toEqual({});
|
||||
});
|
||||
|
||||
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("writes cross-week schedule moves under the destination week key", 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-20" });
|
||||
});
|
||||
|
||||
expect(loadBoardRowOrder(WEEK, "u1")).toEqual({ "07/15": ["2"] });
|
||||
expect(loadBoardRowOrder("2026-07-20", "u1")).toEqual({ "07/20": ["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,147 @@
|
|||
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).toHaveAttribute("aria-disabled", "true");
|
||||
expect(grip).toHaveAttribute("tabindex", "-1");
|
||||
fireEvent.keyDown(grip, { key: "ArrowDown" });
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a canceled neighbor when reordering an active row", () => {
|
||||
const onReorder = vi.fn();
|
||||
const first = makeRow({ id: "1", woNumber: "WO-1" });
|
||||
const canceled = makeRow({ id: "2", woNumber: "WO-2", status: "Canceled" });
|
||||
const third = makeRow({ id: "3", woNumber: "WO-3" });
|
||||
renderRow(first, [first, canceled, third], handlers({ onReorder }));
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Reorder work order WO-1" }), {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
expect(onReorder).toHaveBeenCalledWith("1", "3");
|
||||
});
|
||||
});
|
||||
|
|
@ -5,9 +5,13 @@ import {
|
|||
boardRowOrderStorageKey,
|
||||
loadBoardRowOrder,
|
||||
mergeDayOrder,
|
||||
moveIdToDayOrder,
|
||||
removeIdFromRowOrder,
|
||||
neighborIdForReorder,
|
||||
legacyBoardRowOrderStorageKey,
|
||||
reorderIdsInList,
|
||||
saveBoardRowOrder,
|
||||
transferIdToDestinationWeekOrder,
|
||||
} from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
|
||||
describe("applyDayRowOrder", () => {
|
||||
|
|
@ -47,6 +51,36 @@ 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();
|
||||
});
|
||||
|
||||
it("skips neighbors that fail isReorderTarget", () => {
|
||||
const dayRows = [
|
||||
{ id: "a", dayGroup: "07/14", locked: false },
|
||||
{ id: "b", dayGroup: "07/14", locked: true },
|
||||
{ id: "c", dayGroup: "07/14", locked: false },
|
||||
];
|
||||
expect(neighborIdForReorder(dayRows, dayRows[0]!, 1, (item) => !item.locked)).toBe("c");
|
||||
expect(neighborIdForReorder(dayRows, dayRows[2]!, -1, (item) => !item.locked)).toBe("a");
|
||||
expect(neighborIdForReorder(dayRows, dayRows[0]!, 1, () => false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderIdsInList", () => {
|
||||
it("moves drag id to over index", () => {
|
||||
expect(reorderIdsInList(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]);
|
||||
|
|
@ -84,12 +118,50 @@ 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("transferIdToDestinationWeekOrder", () => {
|
||||
const sourceWeek = "2026-07-13";
|
||||
const destinationWeek = "2026-07-20";
|
||||
const ownerKeySource = boardRowOrderStorageKey(sourceWeek, "u1");
|
||||
const ownerKeyDestination = boardRowOrderStorageKey(destinationWeek, "u1");
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(ownerKeySource);
|
||||
localStorage.removeItem(ownerKeyDestination);
|
||||
});
|
||||
|
||||
it("removes the id from the source week and appends it to the destination week day bucket", () => {
|
||||
saveBoardRowOrder(sourceWeek, { "07/15": ["1", "2"] }, "u1");
|
||||
transferIdToDestinationWeekOrder({
|
||||
sourceWeekMonday: sourceWeek,
|
||||
destinationWeekMonday: destinationWeek,
|
||||
ownerId: "u1",
|
||||
id: "1",
|
||||
nextDayGroup: "07/20",
|
||||
});
|
||||
expect(loadBoardRowOrder(sourceWeek, "u1")).toEqual({ "07/15": ["2"] });
|
||||
expect(loadBoardRowOrder(destinationWeek, "u1")).toEqual({ "07/20": ["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 +171,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({});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue