← Files DataARCHIVED FILE
templates/data-app/base/src/use-presentation.js
7.35 KB · Sep 30, 2026 · 23:19 UTC
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { editHistoryValue, recordPresentationEdit, mergePresentationChanges, normalizePresentation, validatePresentation, writeLocalPresentation } from "./presentation-state.js";
import { readVerificationReminderDismissed, rememberVerificationReminderDismissed } from "./verification-reminder.js";
function serializePresentation(value) {
if (value !== null && typeof value === "object"
&& [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
value = Object.fromEntries(Object.keys(value).sort().map((key) => [key, value[key]]));
}
return JSON.stringify(value);
}
// Check only on a verification click, not render. This also observes a cookie
// changed by another dashboard or cleared by the browser without polling.
export function useVerificationReminder(environment) {
const [dismissed, setDismissed] = useState(false);
const check = () => {
const current = readVerificationReminderDismissed(environment);
setDismissed(current);
return current;
};
const remember = () => {
const retained = rememberVerificationReminderDismissed(environment);
setDismissed(retained);
return retained;
};
return { dismissed, check, remember };
}
export function usePresentationHistory(presentation, enabled, restore) {
const value = editHistoryValue(presentation);
const [history, setHistory] = useState(() => ({ entries: [value], index: 0 }));
useLayoutEffect(() => {
setHistory((current) => recordPresentationEdit(current, value, enabled));
}, [value, enabled]);
const step = (direction) => {
const index = history.index + direction;
if (!enabled || index < 0 || index >= history.entries.length) return;
setHistory({ ...history, index });
restore(JSON.parse(history.entries[index]));
};
useEffect(() => {
if (!enabled) return undefined;
const onKeyDown = (event) => {
if (event.defaultPrevented || !(event.metaKey || event.ctrlKey) || event.altKey
|| event.target?.closest?.('input, textarea, select, [contenteditable="true"], [role="dialog"]')) return;
const key = event.key.toLowerCase();
if (key !== "z" && key !== "y") return;
event.preventDefault();
step(key === "y" || event.shiftKey ? 1 : -1);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
});
return { canUndo: enabled && history.index > 0, canRedo: enabled && history.index < history.entries.length - 1,
undo: () => step(-1), redo: () => step(1) };
}
export function usePresentationPersistence({
snapshot, hosted, presentation, personalPresentation = {}, initialPresentation,
initialRevision = 0, canEdit = true, endpoint = "/api/presentation", verificationAction = null,
onAcknowledged, onError,
}) {
const [status, setStatus] = useState("idle");
const sharedInitial = Object.fromEntries(Object.entries(normalizePresentation(initialPresentation))
.filter(([field]) => Object.hasOwn(presentation, field)));
const latest = useRef(normalizePresentation({ ...presentation, ...sharedInitial }));
const revision = useRef(initialRevision);
// Keep the authored values until the save boundary can reject invalid edits.
// Normalizing here would silently discard oversized text before validation.
const serialized = serializePresentation(presentation);
const personal = JSON.stringify(normalizePresentation(personalPresentation));
const latestPersonal = useRef(personal);
latestPersonal.current = personal;
const previous = useRef(serialized);
const saveGeneration = useRef(0);
const acknowledgedGeneration = useRef(0);
const inFlight = useRef(new Map());
useEffect(() => {
writeLocalPresentation(snapshot, { ...JSON.parse(serialized), ...JSON.parse(personal) });
}, [personal, serialized, snapshot]);
useEffect(() => {
const unchanged = serialized === previous.current;
const pending = [...inFlight.current]
.filter(([generation, value]) => generation > acknowledgedGeneration.current && value !== serialized)
.map(([, value]) => JSON.parse(value));
if (unchanged && !verificationAction && !pending.length) return undefined;
const baseline = JSON.parse(previous.current);
if (!canEdit) {
previous.current = serialized;
return undefined;
}
const generation = ++saveGeneration.current;
let current;
try {
current = validatePresentation(JSON.parse(serialized));
} catch (error) {
setStatus("error");
onError?.(error instanceof Error ? error.message : "Unable to save Data app changes.", verificationAction);
return undefined;
}
if (!hosted) {
previous.current = serialized;
setStatus("saved");
onAcknowledged?.(current, verificationAction);
return undefined;
}
setStatus("saving");
const save = async () => {
inFlight.current.set(generation, serialized);
try {
let next = mergePresentationChanges(baseline, current, latest.current, pending);
for (let attempt = 0; attempt < 2; attempt += 1) {
const response = await fetch(endpoint, {
method: "PUT", headers: { "content-type": "application/json" },
body: JSON.stringify({
presentation: next,
revision: revision.current,
...(verificationAction ? { verificationAction } : {}),
}),
});
const result = await response.json();
if (generation < acknowledgedGeneration.current) return;
if (response.status === 409 && attempt === 0) {
if (result.revision < revision.current || generation < saveGeneration.current) return;
next = mergePresentationChanges(baseline, current, result.presentation, pending);
revision.current = result.revision;
continue;
}
if (!response.ok) throw new Error(result.error || "Unable to save Data app changes.");
if (result.revision < revision.current) return;
const authoritative = normalizePresentation(result.presentation ?? next);
const acknowledged = JSON.parse(serialized);
if (authoritative.verification) acknowledged.verification = authoritative.verification;
else delete acknowledged.verification;
acknowledgedGeneration.current = generation;
latest.current = authoritative;
revision.current = result.revision;
previous.current = serializePresentation(acknowledged);
// An older success still advances our server baseline, but cannot
// acknowledge a newer draft or clear its pending/failed save status.
if (generation < saveGeneration.current) return;
writeLocalPresentation(snapshot, { ...authoritative, ...JSON.parse(latestPersonal.current) });
setStatus("saved");
onAcknowledged?.(authoritative, verificationAction);
return;
}
} catch (error) {
if (generation < saveGeneration.current) return;
setStatus("error");
onError?.(error instanceof Error ? error.message : "Unable to save Data app changes.", verificationAction);
} finally {
inFlight.current.delete(generation);
}
};
const timer = setTimeout(save, 300);
return () => clearTimeout(timer);
}, [canEdit, endpoint, hosted, onAcknowledged, onError, serialized, snapshot, verificationAction]);
return status;
}
SHA-256: d8f4a92a74807b6cd9a130927423b0361d6f62f118c71e8aee21bb9826d2d544