feat(ui): shared record-scoped useDocumentDraft hook; fix half-wired invoice draft banner; dedupe draft keys
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
105
src/admin/hooks/useDocumentDraft.ts
Normal file
105
src/admin/hooks/useDocumentDraft.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import useDebounce from "./useDebounce";
|
||||
|
||||
/**
|
||||
* Record-scoped localStorage draft envelope.
|
||||
*
|
||||
* - `recordId === null` → the draft belongs to the "new document" form.
|
||||
* - `recordId === N` → the draft belongs to editing record N.
|
||||
*
|
||||
* Scoping the draft to its record kills cross-record leakage: a create-form
|
||||
* draft can never be restored over an existing record (and vice-versa) because
|
||||
* the consumer compares `recordId` before restoring.
|
||||
*/
|
||||
export interface DraftEnvelope<T> {
|
||||
recordId: number | null;
|
||||
savedAt: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read (and validate) a draft envelope from localStorage. Returns `null` when
|
||||
* absent, unparseable, or the wrong shape. Availability-safe (guarded). The
|
||||
* caller is responsible for record-scoping (compare `recordId` itself) so the
|
||||
* list/banner can decide whether the stored draft applies to the "new" form.
|
||||
*/
|
||||
export function readDraft<T>(key: string): DraftEnvelope<T> | null {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<DraftEnvelope<T>>;
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"data" in parsed &&
|
||||
"recordId" in parsed
|
||||
) {
|
||||
return {
|
||||
recordId: parsed.recordId ?? null,
|
||||
savedAt: typeof parsed.savedAt === "number" ? parsed.savedAt : 0,
|
||||
data: parsed.data as T,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to read draft "${key}":`, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Remove a draft envelope from localStorage. Availability-safe. */
|
||||
export function clearDraft(key: string): void {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.removeItem(key);
|
||||
} catch (e) {
|
||||
console.error(`Failed to clear draft "${key}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
interface UseDocumentDraftOptions<T> {
|
||||
/** `null` = the "new" form; a number = editing that record. */
|
||||
recordId: number | null;
|
||||
/** Only autosave while ready/dirty-eligible (e.g. create mode, data loaded). */
|
||||
enabled: boolean;
|
||||
/** Current form data to persist (debounced before writing). */
|
||||
data: T;
|
||||
/** Debounce window in ms before a write. Defaults to Offers' proven 1500ms. */
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generalizes the proven Offers autosave: debounced write of `data` to
|
||||
* `localStorage[key]` (wrapped in a {@link DraftEnvelope} stamped with
|
||||
* `recordId` + `savedAt`) while `enabled`. The form supplies the write side;
|
||||
* the list/banner uses {@link readDraft} for the read side.
|
||||
*/
|
||||
export default function useDocumentDraft<T>(
|
||||
key: string,
|
||||
{ recordId, enabled, data, debounceMs = 1500 }: UseDocumentDraftOptions<T>,
|
||||
): { clear: () => void } {
|
||||
const payload = JSON.stringify(data);
|
||||
const debouncedPayload = useDebounce(payload, debounceMs);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
const envelope: DraftEnvelope<T> = {
|
||||
recordId,
|
||||
savedAt: Date.now(),
|
||||
data: JSON.parse(debouncedPayload) as T,
|
||||
};
|
||||
localStorage.setItem(key, JSON.stringify(envelope));
|
||||
} catch (e) {
|
||||
console.error(`Failed to save draft "${key}":`, e);
|
||||
}
|
||||
// `recordId` is stable for a given mount; intentionally tracking the
|
||||
// debounced payload + enabled flag only (mirrors Offers' original deps).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedPayload, enabled]);
|
||||
|
||||
const clear = useCallback(() => clearDraft(key), [key]);
|
||||
|
||||
return { clear };
|
||||
}
|
||||
Reference in New Issue
Block a user