Skip to content
Merged
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
29 changes: 24 additions & 5 deletions site/src/controls.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
import type { Choice } from "./dom";
import { button, h, textInput } from "./dom";
import type { ItemSwitch, Reshaped } from "./editorState";
import { nodeSwitch } from "./editorState";
import type { Direction, Json, JsonObject, JsonStep } from "./json";
import { parseJson } from "./json";
import { addListItem, moveListItem, parseNumber, removeListItem, setNode } from "./modelEdit";
import { addListItem, moveListItem, removeListItem, setNode } from "./modelEdit";
import { parseNumber } from "./scalars";
import type { Slots } from "./typeMemory";

/** What the page offers a drawn part of the form for editing the model. */
export interface TreeHost {
/** Applies an edit and draws the form again, returning focus to the field named by `focus` when there is one, and telling a screen reader what happened. */
readonly edit: (change: (model: JsonObject) => JsonObject, options?: EditOptions) => void;
/** Applies an edit that changes no structure, so the form is not drawn again and a field being typed in keeps its caret. */
readonly editQuietly: (change: (model: JsonObject) => JsonObject) => void;
/** Switches an item to another variant of it, as an edit that draws the form again does, and remembers what the item was so a switch back gives it back. */
readonly switchTo: (item: ItemSwitch, options?: SwitchOptions) => void;
}

export interface EditOptions {
export interface SwitchOptions {
readonly focus?: string;
readonly announce?: string;
}

export interface EditOptions extends SwitchOptions {
/** Which paths the edit may have changed the meaning of. Left out, it is all of them, and nothing the editor remembers of the items whose type was switched survives the edit. */
readonly reshapes?: Reshaped;
}

/** The dimension names a reference may pick, and where edits go. */
export interface TreeContext {
readonly names: readonly string[];
Expand Down Expand Up @@ -86,7 +97,7 @@ export function rowButtons(host: TreeHost, row: RowSpec): HTMLElement {
const target = direction === "earlier" ? position - 1 : position + 1;
const keepsMoving = direction === "earlier" ? target > 0 : target < last;
const control = (direction === "earlier") === keepsMoving ? "up" : "down";
host.edit((model) => row.move(model, direction), { focus: fieldId(pathAt(target), control), announce: `Moved ${noun} ${direction === "earlier" ? "up" : "down"}.` });
host.edit((model) => row.move(model, direction), { focus: fieldId(pathAt(target), control), announce: `Moved ${noun} ${direction === "earlier" ? "up" : "down"}.`, reshapes: { inside: listPath } });
};

return h(
Expand All @@ -100,12 +111,20 @@ export function rowButtons(host: TreeHost, row: RowSpec): HTMLElement {
label: "Remove",
small: true,
onClick: () => {
host.edit(row.remove, { focus: fieldId(listPath, "add"), announce: `Removed ${noun}.` });
host.edit(row.remove, { focus: fieldId(listPath, "add"), announce: `Removed ${noun}.`, reshapes: { inside: listPath } });
},
}),
);
}

/**
* Switches the node at a path from one variant to another, naming both as variants in `vocabulary` (see `typeMemory`): the node becomes what it last was in the variant, if it was ever in it, with the children edited since, and otherwise `convert` of what it is now.
*/
export function switchNode(host: TreeHost, spec: { readonly path: readonly JsonStep[]; readonly vocabulary: string; readonly from: string; readonly to: string; readonly convert: (current: Json) => Json; readonly slots: (part: Json) => Slots; readonly announce: string }): void {
const { announce, ...item } = spec;
host.switchTo(nodeSwitch(item), { announce });
}

/** A button that adds an item to the list at a path. */
export function addButton(host: TreeHost, spec: { readonly listPath: readonly JsonStep[]; readonly label: string; readonly ariaLabel: string; readonly item: () => Json }): HTMLButtonElement {
return button({
Expand All @@ -114,7 +133,7 @@ export function addButton(host: TreeHost, spec: { readonly listPath: readonly Js
label: spec.label,
small: true,
onClick: () => {
host.edit((model) => addListItem(model, spec.listPath, spec.item()), { focus: fieldId(spec.listPath, "add"), announce: `${spec.ariaLabel}: added.` });
host.edit((model) => addListItem(model, spec.listPath, spec.item()), { focus: fieldId(spec.listPath, "add"), announce: `${spec.ariaLabel}: added.`, reshapes: "nothing" });
},
});
}
Expand Down
92 changes: 81 additions & 11 deletions site/src/editorState.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { JsonObject } from "./json";
import { isJsonObject, parseJson } from "./json";
import { displayProblem, EMPTY_MODEL } from "./modelEdit";
import type { Json, JsonObject } from "./json";
import { getIn, isJsonObject, parseJson } from "./json";
import { displayProblem, EMPTY_MODEL, setNode } from "./modelEdit";
import type { Memory, Place, Slots } from "./typeMemory";
import { forgetInside, forgetWithin, NO_MEMORY, switchVariant } from "./typeMemory";

/** The spaces a model is indented by when the form writes it. */
const INDENT = 2;
Expand All @@ -13,36 +15,104 @@ export interface TextProblem {

/**
* The model and the JSON text it is shown as. `model` is always one the form can show: while the text has a problem it is the last model that was fine, so the form never goes blank, and `text` is exactly what the user typed, so no text is lost.
*
* `memory` is what the editor remembers of the items whose type the user has switched (see `typeMemory`). It belongs to the loaded model: every way of replacing the model wholesale starts it empty, and it is never part of the model or its text.
*/
export interface EditorState {
readonly text: string;
readonly model: JsonObject;
readonly problem: TextProblem | undefined;
readonly memory: Memory;
}

/** The state after the form edits a model: its text is the model written out. */
export function stateFromModel(model: JsonObject): EditorState {
return { text: JSON.stringify(model, null, INDENT), model, problem: undefined };
/** The state after the form edits a model: its text is the model written out. The memory is the one given, which is empty for a model nobody has switched anything in. */
export function stateFromModel(model: JsonObject, memory: Memory = NO_MEMORY): EditorState {
return { text: JSON.stringify(model, null, INDENT), model, problem: undefined, memory };
}

/** The state for a model nobody has edited yet. */
export function emptyState(): EditorState {
return stateFromModel(EMPTY_MODEL);
}

/** The state after the text is replaced, whether by typing or by loading a model. `previous` is the model to keep showing when the text cannot be shown. */
export function stateFromText(text: string, previous: JsonObject): EditorState {
/** The state after the text is replaced, whether by typing or by loading a model. `previous` is the state to keep showing when the text cannot be shown. A text that is a model the form can show replaces the model, so nothing is remembered of the one before it. */
export function stateFromText(text: string, previous: EditorState): EditorState {
let parsed;
try {
parsed = parseJson(text);
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;

return { text, model: previous, problem: { kind: "syntax", message: error.message } };
return { text, model: previous.model, problem: { kind: "syntax", message: error.message }, memory: previous.memory };
}

const problem = displayProblem(parsed);
if (problem !== undefined || !isJsonObject(parsed)) return { text, model: previous, problem: { kind: "shape", message: problem ?? "the top level is not an object" } };
if (problem !== undefined || !isJsonObject(parsed)) return { text, model: previous.model, problem: { kind: "shape", message: problem ?? "the top level is not an object" }, memory: previous.memory };

return { text, model: parsed, problem: undefined };
return { text, model: parsed, problem: undefined, memory: NO_MEMORY };
}

/**
* Which paths of the model an edit may have changed the meaning of, and so which remembered parts it makes out of date. Leaving it out says "everything", which is always safe.
* - `nothing`: no path that was there names a different item, as when an item is added at the end or a value is typed.
* - `inside`: the items inside the one at the path were moved, removed or renamed; the item itself is where it was.
* - `within`: the item at the path was removed, with everything inside it.
*/
export type Reshaped = "everything" | "nothing" | { readonly inside: Place } | { readonly within: Place };

function memoryAfter(memory: Memory, reshaped: Reshaped): Memory {
if (reshaped === "everything") return NO_MEMORY;
if (reshaped === "nothing") return memory;

return "inside" in reshaped ? forgetInside(memory, reshaped.inside) : forgetWithin(memory, reshaped.within);
}

/** The state after an edit of the model. */
export function stateAfterEdit(state: EditorState, change: (model: JsonObject) => JsonObject, reshaped: Reshaped = "everything"): EditorState {
return stateFromModel(change(state.model), memoryAfter(state.memory, reshaped));
}

/**
* An item switched from one variant to another, as the form asks for it: where the item is, the part of the model that varies with the variant and how to read and write it, where its children are, and how to convert it for a variant the item has not been in. `from` and `to` are names from the vocabulary of variants (see `Switch`).
*/
export interface ItemSwitch {
readonly place: Place;
readonly vocabulary: string;
readonly from: string;
readonly to: string;
readonly read: (model: JsonObject) => Json;
readonly write: (model: JsonObject, part: Json) => JsonObject;
readonly convert: (current: Json) => Json;
readonly slots: (part: Json) => Slots;
readonly inside: "kept" | "dropped";
}

/** The switch of the node at a path: the part that varies is the node. */
export function nodeSwitch(spec: { readonly path: Place; readonly vocabulary: string; readonly from: string; readonly to: string; readonly convert: (current: Json) => Json; readonly slots: (part: Json) => Slots }): ItemSwitch {
const { path } = spec;

return {
place: path,
vocabulary: spec.vocabulary,
from: spec.from,
to: spec.to,
read: (model) => {
const node = getIn(model, path);
if (node === undefined) throw new Error(`no node at ${path.join(".")}`);

return node;
},
write: (model, part) => setNode(model, path, part),
convert: spec.convert,
slots: spec.slots,
inside: "kept",
};
}

/** The state after an item is switched: the model has the part the item takes up, and the memory has the part it left. */
export function stateAfterSwitch(state: EditorState, spec: ItemSwitch): EditorState {
const current = spec.read(state.model);
const switched = switchVariant(state.memory, { place: spec.place, vocabulary: spec.vocabulary, from: spec.from, to: spec.to, current, convert: () => spec.convert(current), slots: spec.slots, inside: spec.inside });

return stateFromModel(spec.write(state.model, switched.part), switched.memory);
}
99 changes: 90 additions & 9 deletions site/src/editorState.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { describe, expect, it } from "vitest";

import { emptyState, stateFromModel, stateFromText } from "./editorState";
import { emptyState, nodeSwitch, stateAfterEdit, stateAfterSwitch, stateFromModel, stateFromText } from "./editorState";
import type { JsonObject } from "./json";
import { NO_MEMORY, noSlots } from "./typeMemory";
import { convertJson } from "./modelEdit";

const PREVIOUS: JsonObject = { dimensions: [{ kind: "choice", name: "kept", values: ["a"] }] };
const BEFORE = stateFromModel(PREVIOUS);

describe("stateFromModel", () => {
it("writes the model out as indented JSON that reads back as the same model", () => {
Expand All @@ -21,13 +24,13 @@ describe("stateFromModel", () => {

describe("stateFromText", () => {
it("shows the model the text holds", () => {
const state = stateFromText('{"dimensions":[],"rules":[]}', PREVIOUS);
const state = stateFromText('{"dimensions":[],"rules":[]}', BEFORE);

expect(state).toEqual({ text: '{"dimensions":[],"rules":[]}', model: { dimensions: [], rules: [] }, problem: undefined });
expect(state).toEqual({ text: '{"dimensions":[],"rules":[]}', model: { dimensions: [], rules: [] }, problem: undefined, memory: NO_MEMORY });
});

it("keeps the last model and exactly the typed text when the text is not JSON", () => {
const state = stateFromText('{"dimensions": [', PREVIOUS);
const state = stateFromText('{"dimensions": [', BEFORE);

expect(state.text).toBe('{"dimensions": [');
expect(state.model).toBe(PREVIOUS);
Expand All @@ -37,7 +40,7 @@ describe("stateFromText", () => {

it("keeps the last model when the JSON is not a model the form can show, and says why", () => {
for (const text of ["[]", "3", "null", "{}", '{"dimensions": 5}', '{"dimensions": [], "rules": {}}']) {
const state = stateFromText(text, PREVIOUS);
const state = stateFromText(text, BEFORE);

expect(state.model).toBe(PREVIOUS);
expect(state.text).toBe(text);
Expand All @@ -48,15 +51,93 @@ describe("stateFromText", () => {
it("shows JSON that is a displayable model even if the library would refuse it", () => {
const text = '{"dimensions":[{"kind":"computed","name":"x"}],"extra":1}';

expect(stateFromText(text, PREVIOUS).problem).toBeUndefined();
expect(stateFromText(text, PREVIOUS).model).toEqual({ dimensions: [{ kind: "computed", name: "x" }], extra: 1 });
expect(stateFromText(text, BEFORE).problem).toBeUndefined();
expect(stateFromText(text, BEFORE).model).toEqual({ dimensions: [{ kind: "computed", name: "x" }], extra: 1 });
});

it("recovers: text that becomes valid shows the new model and clears the problem", () => {
const broken = stateFromText("{", PREVIOUS);
const fixed = stateFromText('{"dimensions":[]}', broken.model);
const broken = stateFromText("{", BEFORE);
const fixed = stateFromText('{"dimensions":[]}', broken);

expect(fixed.problem).toBeUndefined();
expect(fixed.model).toEqual({ dimensions: [] });
});
});

const SWITCH_MODEL: JsonObject = { dimensions: [{ kind: "choice", name: "d", values: ["linux", "macos"] }] };
const FIRST = ["dimensions", 0, "values", 0] as const;
const SECOND = ["dimensions", 0, "values", 1] as const;
const PAST_THE_END = 2;
const OUTSIDE = ["dimensions", 0, "values", PAST_THE_END] as const;

/** The state after the value at a path is switched to another type. */
function switched(state: ReturnType<typeof stateFromModel>, path: readonly (string | number)[], from: string, to: "text" | "number"): ReturnType<typeof stateFromModel> {
return stateAfterSwitch(state, nodeSwitch({ path, vocabulary: "json", from, to, convert: (current) => convertJson(current, to), slots: noSlots }));
}

describe("stateAfterSwitch", () => {
it("changes the model and its text, and remembers what the item was", () => {
const away = switched(stateFromModel(SWITCH_MODEL), FIRST, "text", "number");

expect(away.model).toEqual({ dimensions: [{ kind: "choice", name: "d", values: [0, "macos"] }] });
expect(JSON.parse(away.text)).toEqual(away.model);
expect(away.text).not.toContain("linux");
expect(away.memory.size).toBe(1);
});

it("gives the item back on the switch back", () => {
const back = switched(switched(stateFromModel(SWITCH_MODEL), FIRST, "text", "number"), FIRST, "number", "text");

expect(back.model).toEqual(SWITCH_MODEL);
expect(back.text).toBe(stateFromModel(SWITCH_MODEL).text);
});

it("throws for a path that leads nowhere in the model", () => {
expect(() => switched(stateFromModel(SWITCH_MODEL), OUTSIDE, "text", "number")).toThrow("no node at");
});
});

describe("stateAfterEdit", () => {
const away = switched(stateFromModel(SWITCH_MODEL), FIRST, "text", "number");
const ruleOut = (model: JsonObject): JsonObject => ({ ...model, extra: 1 });

it("forgets everything unless it says which paths it left alone", () => {
expect(stateAfterEdit(away, ruleOut).memory.size).toBe(0);
expect(stateAfterEdit(away, ruleOut, "everything").memory.size).toBe(0);
});

it("keeps the memory for an edit that moves nothing", () => {
expect(stateAfterEdit(away, ruleOut, "nothing").memory).toBe(away.memory);
});

it("forgets what is inside a reshaped list and keeps the rest", () => {
const both = switched(away, SECOND, "text", "number");

expect(stateAfterEdit(both, ruleOut, { inside: ["dimensions", 0, "values"] }).memory.size).toBe(0);
expect(stateAfterEdit(both, ruleOut, { inside: ["dimensions", 1] }).memory.size).toBe(2);
expect(stateAfterEdit(both, ruleOut, { within: SECOND }).memory.size).toBe(1);
});
});

describe("a model replaced by text", () => {
const away = switched(stateFromModel(SWITCH_MODEL), FIRST, "text", "number");

it("starts with nothing remembered", () => {
expect(stateFromText(JSON.stringify(SWITCH_MODEL), away).memory.size).toBe(0);
});

it("keeps what is remembered while the text cannot be shown, because the model is not replaced", () => {
expect(stateFromText("{", away).memory).toBe(away.memory);
expect(stateFromText("[]", away).memory).toBe(away.memory);
});
});

describe("stateFromModel", () => {
it("takes the memory it is given, and has none otherwise", () => {
const away = switched(stateFromModel(SWITCH_MODEL), FIRST, "text", "number");

expect(stateFromModel(away.model, away.memory).memory).toBe(away.memory);
expect(stateFromModel(away.model).memory).toBe(NO_MEMORY);
expect(emptyState().memory).toBe(NO_MEMORY);
});
});
Loading
Loading