diff --git a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx index 865c597cf..3f0ed4723 100644 --- a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx +++ b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx @@ -23,13 +23,23 @@ interface SpoolQRCodePrintingDialog { spoolIds: number[]; } +function getConfiguredBaseUrl(settingValue: string | undefined): string | undefined { + if (settingValue === undefined) { + return undefined; + } + + try { + const parsed = JSON.parse(settingValue) as unknown; + return typeof parsed === "string" && parsed.trim() !== "" ? parsed.trim() : undefined; + } catch { + return settingValue.trim() !== "" ? settingValue.trim() : undefined; + } +} + const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { const t = useTranslate(); const baseUrlSetting = useGetSetting("base_url"); - const baseUrlRoot = - baseUrlSetting.data?.value !== undefined && JSON.parse(baseUrlSetting.data?.value) !== "" - ? JSON.parse(baseUrlSetting.data?.value) - : window.location.origin; + const baseUrlRoot = getConfiguredBaseUrl(baseUrlSetting.data?.value) ?? window.location.origin; const [messageApi, contextHolder] = message.useMessage(); const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl", false); @@ -41,7 +51,11 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { .filter((item) => item !== null) as ISpool[]; // Selected preset state - const [selectedPresetState, setSelectedPresetState] = useSavedState("selectedPreset", undefined); + const [selectedPresetState, setSelectedPresetState] = useSavedState( + "selectedPreset", + undefined, + (value): value is string | undefined => value === undefined || typeof value === "string", + ); // Keep a local copy of the settings which is what's actually displayed. Use the remote state only for saving. // This decouples the debounce stuff from the UI diff --git a/client/src/utils/saveload.ts b/client/src/utils/saveload.ts index f3ba0fc6f..ddcb10541 100644 --- a/client/src/utils/saveload.ts +++ b/client/src/utils/saveload.ts @@ -6,6 +6,120 @@ interface Pagination { pageSize: number; } +const DEFAULT_SORTERS: CrudSort[] = [{ field: "id", order: "asc" }]; +const DEFAULT_FILTERS: CrudFilter[] = []; +const DEFAULT_PAGINATION: Pagination = { currentPage: 1, pageSize: 20 }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function matchesDefaultValueShape(value: unknown, defaultValue: T): value is T { + if (defaultValue === undefined || defaultValue === null) { + return value === defaultValue; + } + + if (Array.isArray(defaultValue)) { + return Array.isArray(value); + } + + if (typeof defaultValue === "number") { + return typeof value === "number" && Number.isFinite(value); + } + + if (typeof defaultValue === "object") { + return isRecord(value); + } + + return typeof value === typeof defaultValue; +} + +function parseSavedJSON( + label: string, + value: string | null, + fallback: T, + isValid: (parsed: unknown) => parsed is T, + onError?: () => void, +): T { + if (!value || value === "undefined") { + return fallback; + } + + try { + const parsed = JSON.parse(value) as unknown; + if (isValid(parsed)) { + return parsed; + } + } catch { + // Ignore parse failures below so malformed persisted values are handled the same way + // as wrong-shape values. + } + + console.warn(`Ignoring malformed saved state for ${label}`); + onError?.(); + return fallback; +} + +function isSavedSorter(value: unknown): value is CrudSort { + return isRecord(value) && typeof value.field === "string" && (value.order === "asc" || value.order === "desc"); +} + +function isSavedSorters(value: unknown): value is CrudSort[] { + return Array.isArray(value) && value.every(isSavedSorter); +} + +function isSavedFilter(value: unknown): value is CrudFilter { + return isRecord(value) && typeof value.field === "string" && typeof value.operator === "string" && "value" in value; +} + +function isSavedFilters(value: unknown): value is CrudFilter[] { + return Array.isArray(value) && value.every(isSavedFilter); +} + +function isSavedShowColumns(value: unknown): value is string[] { + return Array.isArray(value) && value.every((column) => typeof column === "string"); +} + +function isSavedPagination(value: unknown): value is Partial & { current?: number } { + return ( + isRecord(value) && + (value.currentPage === undefined || + (typeof value.currentPage === "number" && Number.isFinite(value.currentPage))) && + (value.current === undefined || (typeof value.current === "number" && Number.isFinite(value.current))) && + (value.pageSize === undefined || (typeof value.pageSize === "number" && Number.isFinite(value.pageSize))) + ); +} + +function parseSavedPagination(label: string, value: string | null, onError?: () => void): Pagination { + const parsed = parseSavedJSON & { current?: number }>( + label, + value, + DEFAULT_PAGINATION, + isSavedPagination, + onError, + ); + + // Older persisted state used `current`; normalize it so lists keep loading even when + // localStorage or URL hash values were saved by an older UI shape. + return { + currentPage: parsed.currentPage ?? parsed.current ?? DEFAULT_PAGINATION.currentPage, + pageSize: parsed.pageSize ?? DEFAULT_PAGINATION.pageSize, + }; +} + +function hasSavedFilterValue(filter: CrudFilter): boolean { + if (!("value" in filter)) { + return false; + } + + const value = filter.value; + if (Array.isArray(value) || typeof value === "string") { + return value.length !== 0; + } + + return value !== undefined && value !== null; +} + export interface TableState { sorters: CrudSort[]; filters: CrudFilter[]; @@ -15,27 +129,68 @@ export interface TableState { export function useInitialTableState(tableId: string): TableState { const [initialState] = useState(() => { - const savedSorters = hasHashProperty("sorters") + const hasHashSorters = hasHashProperty("sorters"); + const hasHashFilters = hasHashProperty("filters"); + const hasHashPagination = hasHashProperty("pagination"); + + const savedSorters = hasHashSorters ? getHashProperty("sorters") : isLocalStorageAvailable ? localStorage.getItem(`${tableId}-sorters`) : null; - const savedFilters = hasHashProperty("filters") + const savedFilters = hasHashFilters ? getHashProperty("filters") : isLocalStorageAvailable ? localStorage.getItem(`${tableId}-filters`) : null; - const savedPagination = hasHashProperty("pagination") + const savedPagination = hasHashPagination ? getHashProperty("pagination") : isLocalStorageAvailable ? localStorage.getItem(`${tableId}-pagination`) : null; const savedShowColumns = isLocalStorageAvailable ? localStorage.getItem(`${tableId}-showColumns`) : null; - const sorters = savedSorters ? JSON.parse(savedSorters) : [{ field: "id", order: "asc" }]; - const filters = savedFilters ? JSON.parse(savedFilters) : []; - const pagination = savedPagination ? JSON.parse(savedPagination) : { page: 1, pageSize: 20 }; - const showColumns = savedShowColumns ? JSON.parse(savedShowColumns) : undefined; + const sorters = parseSavedJSON( + hasHashSorters ? "hash#sorters" : `${tableId}-sorters`, + savedSorters, + DEFAULT_SORTERS, + isSavedSorters, + hasHashSorters + ? () => removeURLHash("sorters") + : isLocalStorageAvailable + ? () => localStorage.removeItem(`${tableId}-sorters`) + : undefined, + ); + const filters = parseSavedJSON( + hasHashFilters ? "hash#filters" : `${tableId}-filters`, + savedFilters, + DEFAULT_FILTERS, + isSavedFilters, + hasHashFilters + ? () => removeURLHash("filters") + : isLocalStorageAvailable + ? () => localStorage.removeItem(`${tableId}-filters`) + : undefined, + ); + const pagination = parseSavedPagination( + hasHashPagination ? "hash#pagination" : `${tableId}-pagination`, + savedPagination, + hasHashPagination + ? () => removeURLHash("pagination") + : isLocalStorageAvailable + ? () => localStorage.removeItem(`${tableId}-pagination`) + : undefined, + ); + const showColumns = parseSavedJSON( + `${tableId}-showColumns`, + savedShowColumns, + undefined, + (value): value is string[] | undefined => value === undefined || isSavedShowColumns(value), + isLocalStorageAvailable ? () => localStorage.removeItem(`${tableId}-showColumns`) : undefined, + ); + + // Guard every persisted table-state read so stale localStorage or hand-edited hash values + // cannot throw during initial render and blank the list page. return { sorters, filters, pagination, showColumns }; }); return initialState; @@ -43,7 +198,7 @@ export function useInitialTableState(tableId: string): TableState { export function useStoreInitialState(tableId: string, state: TableState) { useEffect(() => { - if (state.sorters.length > 0 && JSON.stringify(state.sorters) != JSON.stringify([{ field: "id", order: "asc" }])) { + if (state.sorters.length > 0 && JSON.stringify(state.sorters) != JSON.stringify(DEFAULT_SORTERS)) { if (isLocalStorageAvailable) { localStorage.setItem(`${tableId}-sorters`, JSON.stringify(state.sorters)); } @@ -55,7 +210,7 @@ export function useStoreInitialState(tableId: string, state: TableState) { }, [tableId, state.sorters]); useEffect(() => { - const filters = state.filters.filter((f) => f.value.length != 0); + const filters = state.filters.filter(hasSavedFilterValue); if (filters.length > 0) { if (isLocalStorageAvailable) { localStorage.setItem(`${tableId}-filters`, JSON.stringify(filters)); @@ -68,7 +223,7 @@ export function useStoreInitialState(tableId: string, state: TableState) { }, [tableId, state.filters]); useEffect(() => { - if (JSON.stringify(state.pagination) != JSON.stringify({ current: 1, pageSize: 20 })) { + if (JSON.stringify(state.pagination) != JSON.stringify(DEFAULT_PAGINATION)) { if (isLocalStorageAvailable) { localStorage.setItem(`${tableId}-pagination`, JSON.stringify(state.pagination)); } @@ -90,15 +245,28 @@ export function useStoreInitialState(tableId: string, state: TableState) { }, [tableId, state.showColumns]); } -export function useSavedState(id: string, defaultValue: T) { +export function useSavedState(id: string, defaultValue: T, isValidState?: (value: unknown) => value is T) { const [state, setState] = useState(() => { - const savedState = isLocalStorageAvailable ? localStorage.getItem(`savedStates-${id}`) : null; - return savedState ? JSON.parse(savedState) : defaultValue; + const storageKey = `savedStates-${id}`; + const savedState = isLocalStorageAvailable ? localStorage.getItem(storageKey) : null; + return parseSavedJSON( + storageKey, + savedState, + defaultValue, + isValidState ?? ((value): value is T => matchesDefaultValueShape(value, defaultValue)), + isLocalStorageAvailable ? () => localStorage.removeItem(storageKey) : undefined, + ); }); useEffect(() => { if (isLocalStorageAvailable) { - localStorage.setItem(`savedStates-${id}`, JSON.stringify(state)); + const storageKey = `savedStates-${id}`; + const serializedState = JSON.stringify(state); + if (serializedState === undefined) { + localStorage.removeItem(storageKey); + } else { + localStorage.setItem(storageKey, serializedState); + } } }, [id, state]);