"use client"; import type { ReactNode } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Button, Stack, Text } from "@mantine/core"; import type { CustomWidgetExtensions } from "../core/extensions-schema"; import { isCustomWidgetPreferenceValue } from "../core/extensions-schema"; import { useOptionalCustomWidgetRuntime } from "./context"; import type { CustomWidgetContentResult } from "./types"; type Value = CustomWidgetContentResult["value"]; interface ContentState extends CustomWidgetContentResult { baseline: Value; dirty: boolean; loading: boolean; saving: boolean; error?: string; } type ContentStates = Record; interface ContentContextValue { definitions: NonNullable; values: Record; status: Record>; setValue(name: string, value: Value): void; save(name: string): Promise; reset(name: string): Promise; } export const WidgetContentContext = createContext(null); export const useWidgetContent = () => useContext(WidgetContentContext); const EMPTY = {}; export function useCustomWidgetContent( definitions: NonNullable = EMPTY, ): ContentContextValue { const runtime = useOptionalCustomWidgetRuntime(); const itemId = runtime?.itemId; const definitionId = runtime?.definitionId; const readContent = runtime?.port.readContent; // Receiving live query data must not reset a dirty content draft through a new host callback identity. const readPort = useRef(readContent); readPort.current = readContent; const canRead = Boolean(readContent); const writeContent = runtime?.port.writeContent; const serializedDefinitions = JSON.stringify(definitions); const declared = useMemo(() => JSON.parse(serializedDefinitions) as typeof definitions, [serializedDefinitions]); const defaults = useMemo( () => Object.fromEntries( Object.entries(declared).map(([name, declaration]) => [ name, { value: declaration.defaultValue, baseline: declaration.defaultValue, revision: 0, dirty: false, loading: Boolean(itemId), saving: false, }, ]), ), [declared, itemId], ); const [states, setStates] = useState(defaults); const current = useRef(states); // Keep action guards and submitted values current when React batches input and button events. const updateStates = useCallback((update: (previous: ContentStates) => ContentStates) => { const next = update(current.current); current.current = next; setStates(next); }, []); const generation = useRef(0); const pendingOperations = useRef(new Map()); const editVersions = useRef>({}); useEffect(() => { const loadContent = readPort.current; const version = ++generation.current; const controller = new AbortController(); pendingOperations.current.clear(); editVersions.current = {}; updateStates(() => defaults); if (!canRead || !itemId || !loadContent || !Object.keys(declared).length) return; void loadContent({ itemId }, controller.signal) .then(({ values }) => { if (version !== generation.current) return; updateStates((previous) => { const next = { ...previous }; for (const [name, declaration] of Object.entries(declared)) { const saved = values[name]; const field = next[name]; if (!field) continue; next[name] = { ...field, loading: false }; if (saved && isCustomWidgetPreferenceValue(declaration.type, saved.value)) { next[name] = { ...field, ...saved, value: field.dirty ? field.value : saved.value, baseline: saved.value, dirty: field.dirty && JSON.stringify(field.value) !== JSON.stringify(saved.value), loading: false, }; } } return next; }); }) .catch((error: unknown) => { if (version !== generation.current || controller.signal.aborted) return; updateStates((previous) => Object.fromEntries( Object.entries(previous).map(([name, field]) => [ name, { ...field, loading: false, error: errorMessage(error) }, ]), ), ); }); return () => { controller.abort(); generation.current += 1; }; }, [canRead, declared, defaults, definitionId, itemId, updateStates]); const setValue = useCallback( (name: string, value: Value) => { if (!declared[name] || !isCustomWidgetPreferenceValue(declared[name].type, value)) return; editVersions.current[name] = (editVersions.current[name] ?? 0) + 1; updateStates((previous) => { const field = previous[name]; if (!field) return previous; return { ...previous, [name]: { ...field, value, dirty: JSON.stringify(value) !== JSON.stringify(field.baseline) }, }; }); }, [declared, updateStates], ); const save = useCallback( async (name: string) => { const field = current.current[name]; if ( !field || !field.dirty || field.saving || field.loading || pendingOperations.current.has(name) || runtime?.isEditMode ) return; const version = generation.current; const operation = Symbol(name); pendingOperations.current.set(name, operation); updateStates((previous) => ({ ...previous, [name]: { ...(previous[name] ?? field), saving: true, error: undefined }, })); try { let result = { name, value: field.value, revision: field.revision + 1 }; if (itemId) { if (!writeContent) throw new Error(runtime?.messages.requestFailed); result = await writeContent({ itemId, name, value: field.value, expectedRevision: field.revision }); } if (version !== generation.current || pendingOperations.current.get(name) !== operation) return; updateStates((previous) => { const latest = previous[name] ?? field; return { ...previous, [name]: { ...latest, baseline: result.value, revision: result.revision, saving: false, dirty: JSON.stringify(latest.value) !== JSON.stringify(result.value), }, }; }); } catch (error) { if (version !== generation.current || pendingOperations.current.get(name) !== operation) return; updateStates((previous) => ({ ...previous, [name]: { ...(previous[name] ?? field), saving: false, error: errorMessage(error) }, })); } finally { if (pendingOperations.current.get(name) === operation) pendingOperations.current.delete(name); } }, [itemId, runtime?.isEditMode, runtime?.messages.requestFailed, writeContent, updateStates], ); const reset = useCallback( async (name: string) => { const field = current.current[name]; const declaration = declared[name]; if (!field || !declaration || field.saving || field.loading || pendingOperations.current.has(name)) return; const version = generation.current; const editVersion = editVersions.current[name] ?? 0; const operation = Symbol(name); pendingOperations.current.set(name, operation); updateStates((previous) => ({ ...previous, [name]: { ...(previous[name] ?? field), loading: true, error: undefined }, })); try { let saved = { value: field.baseline, revision: field.revision }; if (itemId && readContent) saved = (await readContent({ itemId })).values[name] ?? { value: declaration.defaultValue, revision: 0 }; if (version !== generation.current || pendingOperations.current.get(name) !== operation) return; if (!isCustomWidgetPreferenceValue(declaration.type, saved.value)) throw new Error(runtime?.messages.requestFailed); updateStates((previous) => { const latest = previous[name] ?? field; let value = saved.value; if ((editVersions.current[name] ?? 0) !== editVersion) value = latest.value; return { ...previous, [name]: { ...latest, ...saved, value, baseline: saved.value, dirty: JSON.stringify(value) !== JSON.stringify(saved.value), loading: false, error: undefined, }, }; }); } catch (error) { if (version !== generation.current || pendingOperations.current.get(name) !== operation) return; updateStates((previous) => ({ ...previous, [name]: { ...(previous[name] ?? field), loading: false, error: errorMessage(error) }, })); } finally { if (pendingOperations.current.get(name) === operation) pendingOperations.current.delete(name); } }, [declared, itemId, readContent, runtime?.messages.requestFailed, updateStates], ); const values = useMemo( () => Object.fromEntries(Object.entries(states).map(([name, field]) => [name, field.value])), [states], ); const status = useMemo( () => Object.fromEntries( Object.entries(states).map(([name, { loading, saving, dirty, error }]) => [ name, { loading, saving, dirty, error }, ]), ), [states], ); return { definitions: declared, values, status, setValue, save, reset }; } function errorMessage(error: unknown) { if (error instanceof Error) return error.message; return String(error); } export function ContentSaveButton({ name, children }: { name: string; children?: ReactNode }) { const content = useWidgetContent(); const runtime = useOptionalCustomWidgetRuntime(); const state = content?.status[name]; return ( {state?.error && ( {state.error} )} ); } export function ContentResetButton({ name, children }: { name: string; children?: ReactNode }) { const content = useWidgetContent(); const state = content?.status[name]; return ( ); }