This commit is contained in:
171
src/components/ui-tree/UiEditingProvider.jsx
Normal file
171
src/components/ui-tree/UiEditingProvider.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { useUiLayouts } from '@/lib/krowHooks';
|
||||
import { useSkillSections } from '@/components/skills/SkillSurface';
|
||||
import { skillNodesByPlacement } from '@/lib/ui/skillNodes';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { applyOperation } from '@/lib/ui/operations';
|
||||
import { clearOps, emptyPatch, popOp, pushOp } from '@/lib/ui/patch';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
|
||||
/**
|
||||
* Preview, Apply, and the line between them.
|
||||
*
|
||||
* Three states of a UI change live here, and keeping them apart is the whole
|
||||
* job of this module:
|
||||
*
|
||||
* - **Preview** is in React state. It is what the person is trying. It never
|
||||
* reaches the network, and a reload discards it.
|
||||
* - **Saved** is the person's own stored operation list, read from and
|
||||
* written to their account preferences. It survives a reload and a new
|
||||
* session.
|
||||
* - **Source** — the page's registered composition and the Markdown skill
|
||||
* files — is never written by anything here. Not on preview, not on apply.
|
||||
*
|
||||
* A proposed operation is validated *before* it becomes a preview, so a change
|
||||
* that could not be saved is never shown as though it could. Preview and apply
|
||||
* then run the identical merge through `composePage` and the identical
|
||||
* renderer, which is what makes a preview honest: there is no second code path
|
||||
* for the applied state that could disagree with it.
|
||||
*/
|
||||
|
||||
const UiEditingContext = React.createContext(null);
|
||||
|
||||
/** The editing session for the page around it. Null outside a provider. */
|
||||
export const useUiEditing = () => React.useContext(UiEditingContext);
|
||||
|
||||
export function UiEditingProvider({ page, role = null, registry = nodeRegistry, children }) {
|
||||
const { layouts, save, saving } = useUiLayouts();
|
||||
|
||||
/**
|
||||
* What the person is trying, not yet theirs.
|
||||
*
|
||||
* Held per page so that navigating away and back does not carry an
|
||||
* unfinished experiment onto a different surface.
|
||||
*/
|
||||
const [preview, setPreview] = React.useState(() => emptyPatch(page));
|
||||
const [problems, setProblems] = React.useState([]);
|
||||
|
||||
/* A page change is a new editing session. Anything unsaved was about the page
|
||||
that is no longer on screen. */
|
||||
React.useEffect(() => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
}, [page]);
|
||||
|
||||
const saved = layouts[page] || emptyPatch(page);
|
||||
|
||||
/**
|
||||
* What this page's definitions contribute, as nodes.
|
||||
*
|
||||
* Resolved here because it needs the account's custom skills and disabled
|
||||
* list, and handed to `composePage` so that module stays a pure function.
|
||||
* The result is that a Board skill's card is a node in the same tree as the
|
||||
* page's own sections — addressable by the same operations, and hidden or
|
||||
* moved by a patch rather than by editing the definition.
|
||||
*/
|
||||
const sections = useSkillSections(page);
|
||||
const skillNodes = React.useMemo(() => skillNodesByPlacement(sections), [sections]);
|
||||
|
||||
/**
|
||||
* The tree on screen: what the application ships, with what the person saved,
|
||||
* with what they are trying, in that order.
|
||||
*/
|
||||
const composed = React.useMemo(
|
||||
() => composePage(page, { patch: saved, preview, registry, role, skillNodes }),
|
||||
[page, saved, preview, registry, role, skillNodes]
|
||||
);
|
||||
|
||||
/**
|
||||
* Try an operation.
|
||||
*
|
||||
* Validated against the tree as it currently stands — saved changes included
|
||||
* — so an operation is judged against what the person is actually looking at.
|
||||
* A refusal returns the reasons and changes nothing; there is no partially
|
||||
* applied preview.
|
||||
*/
|
||||
const propose = React.useCallback((op) => {
|
||||
const result = applyOperation(composed.tree, op, { registry, role });
|
||||
if (!result.ok) {
|
||||
setProblems(result.problems);
|
||||
return result;
|
||||
}
|
||||
setProblems([]);
|
||||
setPreview((current) => pushOp(current, op));
|
||||
return result;
|
||||
}, [composed.tree, registry, role]);
|
||||
|
||||
/** Throw the experiment away. Nothing was stored, so nothing is undone. */
|
||||
const discard = React.useCallback(() => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
}, [page]);
|
||||
|
||||
/**
|
||||
* Keep it.
|
||||
*
|
||||
* The preview's operations are appended to what was already saved and written
|
||||
* as one list. The preview is only cleared once the write resolves, so a
|
||||
* failed save leaves the person looking at the change they asked for rather
|
||||
* than watching it disappear with an error beside it.
|
||||
*/
|
||||
const apply = React.useCallback(async () => {
|
||||
if (!preview.ops.length) return { ok: true, saved };
|
||||
const next = { ...saved, page, ops: [...saved.ops, ...preview.ops], updatedAt: new Date().toISOString() };
|
||||
const result = await save(page, next);
|
||||
if (result?.persisted === false) {
|
||||
setProblems([{ at: null, message: result.error || 'That change could not be saved.' }]);
|
||||
return { ok: false, saved };
|
||||
}
|
||||
setPreview(emptyPatch(page));
|
||||
return { ok: true, saved: next };
|
||||
}, [preview, saved, page, save]);
|
||||
|
||||
/**
|
||||
* Undo one step.
|
||||
*
|
||||
* The most recent thing first: an unsaved operation if there is one, and only
|
||||
* then a saved one. Undoing a saved change is a write, because the saved list
|
||||
* is the record of what the person chose.
|
||||
*/
|
||||
const undo = React.useCallback(async () => {
|
||||
if (preview.ops.length) {
|
||||
setPreview((current) => popOp(current));
|
||||
return { ok: true };
|
||||
}
|
||||
if (!saved.ops.length) return { ok: true };
|
||||
await save(page, popOp(saved));
|
||||
return { ok: true };
|
||||
}, [preview, saved, page, save]);
|
||||
|
||||
/** Back to the page as the application ships it. Clears both tiers. */
|
||||
const reset = React.useCallback(async () => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
if (saved.ops.length) await save(page, clearOps(saved));
|
||||
return { ok: true };
|
||||
}, [page, saved, save]);
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
page,
|
||||
tree: composed.tree,
|
||||
/* Operations that no longer apply — a saved change naming a section a
|
||||
release has since removed. Surfaced so a page can say so quietly rather
|
||||
than leaving the person wondering why nothing happened. */
|
||||
skipped: composed.skipped,
|
||||
saved,
|
||||
preview,
|
||||
problems,
|
||||
previewing: preview.ops.length > 0,
|
||||
customised: saved.ops.length > 0,
|
||||
saving,
|
||||
propose,
|
||||
discard,
|
||||
apply,
|
||||
undo,
|
||||
reset,
|
||||
}), [
|
||||
page, composed, saved, preview, problems, saving, propose, discard, apply, undo, reset,
|
||||
]);
|
||||
|
||||
return <UiEditingContext.Provider value={value}>{children}</UiEditingContext.Provider>;
|
||||
}
|
||||
Reference in New Issue
Block a user