Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions client/src/pages/printing/spoolQrCodePrintingDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -41,7 +51,11 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => {
.filter((item) => item !== null) as ISpool[];

// Selected preset state
const [selectedPresetState, setSelectedPresetState] = useSavedState<string | undefined>("selectedPreset", undefined);
const [selectedPresetState, setSelectedPresetState] = useSavedState<string | undefined>(
"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
Expand Down
196 changes: 182 additions & 14 deletions client/src/utils/saveload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function matchesDefaultValueShape<T>(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<T>(
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<Pagination> & { 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<Partial<Pagination> & { 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[];
Expand All @@ -15,35 +129,76 @@ 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<string[] | undefined>(
`${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;
}

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));
}
Expand All @@ -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));
Expand All @@ -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));
}
Expand All @@ -90,15 +245,28 @@ export function useStoreInitialState(tableId: string, state: TableState) {
}, [tableId, state.showColumns]);
}

export function useSavedState<T>(id: string, defaultValue: T) {
export function useSavedState<T>(id: string, defaultValue: T, isValidState?: (value: unknown) => value is T) {
const [state, setState] = useState<T>(() => {
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]);

Expand Down