From 309ca2407ccd3921fa61ee4815114ade9bdf38de Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:39:19 +0100 Subject: [PATCH] Add document card projection primitives --- .../src/plan/card-answer-projection.test.ts | 91 ++++++++++ .../plan/card-lifecycle-projection.test.ts | 80 +++++++++ .../src/plan/card-option-projection.test.ts | 51 ++++++ .../plan/card-projections.test-fixtures.ts | 12 ++ apps/server/src/plan/card-projections.ts | 152 +++++++++++++++++ .../src/plan/card-prompt-projection.test.ts | 37 ++++ apps/server/src/plan/projections.test.ts | 80 +++++++++ apps/server/src/plan/projections.ts | 90 ++++++++++ apps/server/src/plan/questionnaires.ts | 8 +- apps/server/src/plan/room.ts | 160 +++++++++++++----- packages/dialect/src/dialect.test.ts | 19 +++ packages/dialect/src/dialect.ts | 23 ++- packages/dialect/src/index.ts | 3 +- packages/dialect/src/limits.ts | 2 + .../src/nodes/questionnaire-choices.test.ts | 58 +++++++ .../dialect/src/nodes/questionnaire-fields.ts | 108 ++++++++++++ .../src/nodes/questionnaire-history.test.ts | 96 +++++++++++ .../src/nodes/questionnaire-lifecycle.test.ts | 55 ++++++ .../dialect/src/nodes/questionnaire-mdx.ts | 136 +++++++++++++++ .../nodes/questionnaire-validation.test.ts | 95 +++++++++++ .../src/nodes/questionnaire.test-fixtures.ts | 48 ++++++ .../dialect/src/nodes/questionnaire.test.ts | 36 +--- packages/dialect/src/nodes/questionnaire.ts | 152 ++--------------- packages/dialect/src/validate.ts | 119 ++++++++++++- 24 files changed, 1487 insertions(+), 224 deletions(-) create mode 100644 apps/server/src/plan/card-answer-projection.test.ts create mode 100644 apps/server/src/plan/card-lifecycle-projection.test.ts create mode 100644 apps/server/src/plan/card-option-projection.test.ts create mode 100644 apps/server/src/plan/card-projections.test-fixtures.ts create mode 100644 apps/server/src/plan/card-projections.ts create mode 100644 apps/server/src/plan/card-prompt-projection.test.ts create mode 100644 apps/server/src/plan/projections.test.ts create mode 100644 apps/server/src/plan/projections.ts create mode 100644 packages/dialect/src/nodes/questionnaire-choices.test.ts create mode 100644 packages/dialect/src/nodes/questionnaire-fields.ts create mode 100644 packages/dialect/src/nodes/questionnaire-history.test.ts create mode 100644 packages/dialect/src/nodes/questionnaire-lifecycle.test.ts create mode 100644 packages/dialect/src/nodes/questionnaire-mdx.ts create mode 100644 packages/dialect/src/nodes/questionnaire-validation.test.ts create mode 100644 packages/dialect/src/nodes/questionnaire.test-fixtures.ts diff --git a/apps/server/src/plan/card-answer-projection.test.ts b/apps/server/src/plan/card-answer-projection.test.ts new file mode 100644 index 00000000..73711f9d --- /dev/null +++ b/apps/server/src/plan/card-answer-projection.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as room from "./room"; +import { questionnaire } from "./card-projections.test-fixtures"; + +describe("questionnaire answer projection", () => { + it("writes chosen option identifiers beside the answer summary", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "Auth"); + room.insertQuestionnaire(document, card); + let question = card.questions[0]!; + + let mutation = room.projectAnswer( + document, + card.id, + { [question.id]: "Choose this" }, + undefined, + { [question.id]: [question.options[0]!.id] }, + ); + + expect(mutation?.source).toContain( + ` { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "Auth"); + let question = card.questions[0]!; + let previous = { choices: [question.options[0]!.id], by: "ana", at: "2026-09-23" }; + room.insertQuestionnaire(document, { + ...card, + status: "reopened", + questions: [{ ...question, previous }], + }); + let mutation = room.projectAnswer( + document, + card.id, + { [question.id]: "Choose this" }, + { by: "ben", at: "2026-09-25" }, + { [question.id]: [question.options[0]!.id] }, + ); + expect(mutation?.source).toContain('status="decided"'); + expect(mutation?.source).toContain(" { + let document = await room.create(); + expect(() => room.projectAnswer(document, ulid(), {})).toThrow(/Card is not available/); + let card = questionnaire(ulid(), ulid(), ulid(), "First"); + room.insertQuestionnaires(document, [ + { value: card }, + { value: questionnaire(card.id, ulid(), ulid(), "Second") }, + ]); + let before = room.project(document); + expect(() => room.projectAnswer(document, card.id, {})).toThrow(/Card is not available/); + expect(room.project(document)).toBe(before); + }); + + it("refuses an answer whose question differs from the card", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "First"); + room.insertQuestionnaire(document, card); + let before = room.project(document); + expect(() => room.projectAnswer(document, card.id, { [ulid()]: "Wrong card" })) + .toThrow(/Card questions do not match/); + expect(room.project(document)).toBe(before); + }); + + it("refuses duplicate question IDs inside one answer target", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "First"); + let question = card.questions[0]!; + room.insertQuestionnaire(document, { + ...card, + questions: [question, { + ...question, + header: "Second", + options: [{ ...question.options[0]!, id: ulid() }], + }], + }); + let before = room.project(document); + expect(() => room.projectAnswer(document, card.id, { [question.id]: "Once" })) + .toThrow(/Card questions do not match/); + expect(room.project(document)).toBe(before); + }); +}); diff --git a/apps/server/src/plan/card-lifecycle-projection.test.ts b/apps/server/src/plan/card-lifecycle-projection.test.ts new file mode 100644 index 00000000..f3aa6bfe --- /dev/null +++ b/apps/server/src/plan/card-lifecycle-projection.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as room from "./room"; +import { questionnaire } from "./card-projections.test-fixtures"; + +describe("card lifecycle projection", () => { + it("marks a card discarded without removing its answer or provenance", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "Auth"); + let question = card.questions[0]!; + room.insertQuestionnaire(document, card); + room.projectAnswer(document, card.id, { [question.id]: "Choose this" }, { + by: "ana", + at: "2026-09-23T17:30:00.000Z", + }, { [question.id]: [question.options[0]!.id] }); + + let mutation = room.projectCard(document, card.id, { status: "discarded" }); + + expect(mutation?.source).toContain('status="discarded"'); + expect(mutation?.source).toContain('by="ana"'); + expect(mutation?.source).toContain(' { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "Auth"); + room.insertQuestionnaires(document, [ + { value: card }, + { value: questionnaire(card.id, ulid(), ulid(), "Other") }, + ]); + let before = room.project(document); + + expect(() => room.projectCard(document, ulid(), { status: "discarded" })) + .toThrow(/Card is not available/); + expect(() => room.projectCard(document, card.id, { status: "discarded" })) + .toThrow(/Card is not available/); + expect(room.project(document)).toBe(before); + document.doc.destroy(); + }); + + it("can clear an answer and show its previous choice when reopened", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "Auth"); + let question = card.questions[0]!; + let option = question.options[0]!.id; + room.insertQuestionnaire(document, card); + room.projectAnswer(document, card.id, { [question.id]: "Choose this" }, { + by: "ana", + at: "2026-09-23T17:30:00.000Z", + }, { [question.id]: [option] }); + + let mutation = room.projectCard(document, card.id, { + status: "reopened", + clearAnswers: true, + previous: { + [question.id]: { choices: [option], by: "ana", at: "2026-09-23T17:30:00.000Z" }, + }, + }); + + expect(mutation?.source).toContain('status="reopened"'); + expect(mutation?.source).toContain(` { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "First"); + room.insertQuestionnaire(document, card); + let before = room.project(document); + let question = { + id: card.questions[0]!.id, + header: "First", + question: "What should First be?", + multiple: false, + options: [{ id: ulid(), label: "GitHub Apps", description: "" }], + }; + + expect(() => room.projectOptions(document, ulid(), question)).toThrow(); + expect(() => room.projectOptions(document, card.id, { ...question, id: ulid() })).toThrow(); + expect(room.project(document)).toBe(before); + }); +}); diff --git a/apps/server/src/plan/card-projections.test-fixtures.ts b/apps/server/src/plan/card-projections.test-fixtures.ts new file mode 100644 index 00000000..c206a2a3 --- /dev/null +++ b/apps/server/src/plan/card-projections.test-fixtures.ts @@ -0,0 +1,12 @@ +export function questionnaire(id: string, question: string, option: string, header: string) { + return { + id, + questions: [{ + id: question, + header, + prompt: `What should ${header} be?`, + multiple: false, + options: [{ id: option, label: "Choose this" }], + }], + }; +} diff --git a/apps/server/src/plan/card-projections.ts b/apps/server/src/plan/card-projections.ts new file mode 100644 index 00000000..d987d356 --- /dev/null +++ b/apps/server/src/plan/card-projections.ts @@ -0,0 +1,152 @@ +import { $nodesOfType } from "lexical"; +import { QuestionnaireNode } from "@chopin/dialect"; +import type { Previous } from "@chopin/dialect"; +import type * as Question from "@chopin/question"; +import type { Document, Mutation } from "./room"; + +export type CardChange = { + status: "open" | "decided" | "reopened" | "discarded"; + clearAnswers?: boolean; + previous?: { [question: string]: Previous | null }; +}; + +export class QuestionnaireProjectionError extends Error {} + +export function createCardProjections( + mutate: (target: Document, change: () => boolean) => Mutation | undefined, +) { + function projectAnswer( + target: Document, + id: string, + answers: Record, + settled?: { by: string; at: string }, + chosen?: Record, + ): Mutation | undefined { + return mutate(target, () => { + let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id); + if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available"); + let node = matches[0]!; + let value = node.getQuestionnaire(); + let known = new Set(value.questions.map(question => question.id)); + if ( + known.size !== value.questions.length + || known.size !== Object.keys(answers).length + || Object.keys(answers).some(question => !known.has(question)) + || Object.keys(chosen ?? {}).some(question => !known.has(question)) + ) throw new QuestionnaireProjectionError("Card questions do not match its record"); + node.setQuestionnaire({ + ...value, + // Resolution belongs to the questionnaire, not each answer. + ...(settled ? { by: settled.by, at: settled.at, status: "decided" as const } : {}), + questions: value.questions.map(question => { + let answer = answers[question.id]; + if (answer === undefined) return question; + let projected = { ...question, answer }; + let ids = chosen?.[question.id]; + if (ids?.length) projected.choices = ids; + else delete projected.choices; + return projected; + }), + }); + return true; + }); + } + + function projectCard( + target: Document, + id: string, + change: CardChange, + ): Mutation | undefined { + return mutate(target, () => { + let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id); + if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available"); + let node = matches[0]!; + let value = node.getQuestionnaire(); + let known = new Set(value.questions.map(question => question.id)); + if ( + known.size !== value.questions.length + || Object.keys(change.previous ?? {}).some(question => !known.has(question)) + ) throw new QuestionnaireProjectionError("Card questions do not match its record"); + let { by: _by, at: _at, ...unsettled } = value; + node.setQuestionnaire({ + ...(change.clearAnswers ? unsettled : value), + status: change.status, + questions: value.questions.map(question => { + let next = { ...question }; + if (change.clearAnswers) { + delete next.answer; + delete next.choices; + } + let previous = change.previous?.[question.id]; + if (previous === null) delete next.previous; + else if (previous) next.previous = previous; + return next; + }), + }); + return true; + }); + } + + function hasQuestionnaire(target: Document, id: string, questionId: string): boolean { + return target.editor.getEditorState().read(() => { + let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id); + if (matches.length !== 1) return false; + let questions = matches[0]!.getQuestionnaire().questions; + return questions.length === 1 && questions[0]?.id === questionId; + }); + } + + function projectOptions( + target: Document, + id: string, + question: Question.Item, + ): Mutation | undefined { + return mutate(target, () => { + let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id); + if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available"); + let node = matches[0]!; + let value = node.getQuestionnaire(); + if (value.questions.filter(item => item.id === question.id).length !== 1) { + throw new QuestionnaireProjectionError("Card question does not match its record"); + } + node.setQuestionnaire({ + ...value, + questions: value.questions.map(item => + item.id !== question.id ? item : { + ...item, + options: question.options.map(option => ({ + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + })), + } + ), + }); + return true; + }); + } + + function projectPrompt( + target: Document, + id: string, + questionId: string, + prompt: string, + ): Mutation | undefined { + return mutate(target, () => { + let matches = $nodesOfType(QuestionnaireNode).filter(node => node.getId() === id); + if (matches.length !== 1) throw new QuestionnaireProjectionError("Card is not available"); + let node = matches[0]!; + let value = node.getQuestionnaire(); + if (value.questions.length !== 1 || value.questions[0]?.id !== questionId) { + throw new QuestionnaireProjectionError("Card question does not match its record"); + } + node.setQuestionnaire({ + ...value, + questions: [{ ...value.questions[0]!, prompt }], + }); + return true; + }); + } + + return { projectAnswer, projectCard, hasQuestionnaire, projectOptions, projectPrompt }; +} diff --git a/apps/server/src/plan/card-prompt-projection.test.ts b/apps/server/src/plan/card-prompt-projection.test.ts new file mode 100644 index 00000000..a03b1c2b --- /dev/null +++ b/apps/server/src/plan/card-prompt-projection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as room from "./room"; +import { questionnaire } from "./card-projections.test-fixtures"; + +describe("questionnaire prompt projection", () => { + it("rewrites only the matching card question", async () => { + let document = await room.create(); + let first = questionnaire(ulid(), ulid(), ulid(), "First"); + let second = questionnaire(ulid(), ulid(), ulid(), "Second"); + room.insertQuestionnaires(document, [{ value: first }, { value: second }]); + expect(room.hasQuestionnaire(document, first.id, first.questions[0]!.id)).toBe(true); + expect(room.hasQuestionnaire(document, first.id, second.questions[0]!.id)).toBe(false); + let mutation = room.projectPrompt( + document, + first.id, + first.questions[0]!.id, + "Which feature should ship first?", + ); + expect(mutation?.source).toContain('prompt="Which feature should ship first?"'); + expect(mutation?.source).toContain('prompt="What should Second be?"'); + expect(mutation?.source.match(/prompt="Which feature should ship first\?"/g)).toHaveLength(1); + }); + + it("fails closed on a missing card or mismatched question", async () => { + let document = await room.create(); + let card = questionnaire(ulid(), ulid(), ulid(), "First"); + room.insertQuestionnaire(document, card); + let before = room.project(document); + expect(room.hasQuestionnaire(document, ulid(), card.questions[0]!.id)).toBe(false); + expect(() => room.projectPrompt(document, ulid(), card.questions[0]!.id, "Why?")).toThrow( + /available/i, + ); + expect(() => room.projectPrompt(document, card.id, ulid(), "Why?")).toThrow(/match/i); + expect(room.project(document)).toBe(before); + }); +}); diff --git a/apps/server/src/plan/projections.test.ts b/apps/server/src/plan/projections.test.ts new file mode 100644 index 00000000..7451095c --- /dev/null +++ b/apps/server/src/plan/projections.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "bun:test"; +import { parse, serialize } from "@chopin/dialect"; +import { + newResearchProjections, + protectProjections, + removedResearchProjections, +} from "./projections"; + +let nodes = (source: string) => parse(source).children; + +test.each([ + '\n\n\n\n\n\n', + '> \n\n', +])("nested protected identities cannot collide across kinds: %s", source => { + let proposed = nodes(source); + expect(protectProjections([], proposed)).toContain("appears twice"); + expect(protectProjections(proposed, [])).toContain("appears twice"); + expect(newResearchProjections([], proposed)).toEqual([]); + expect(removedResearchProjections(proposed, [])).toEqual([]); +}); + +test.each([ + "\n\n\n\n", + '', + "> ", +])("missing or empty identity fails closed in either snapshot: %s", source => { + let malformed = nodes(source); + expect(protectProjections([], malformed)).toContain("missing its id"); + expect(protectProjections(malformed, [])).toContain("missing its id"); + expect(newResearchProjections([], malformed)).toEqual([]); + expect(removedResearchProjections(malformed, [])).toEqual([]); +}); + +test.each(["Questionnaire", "Decision"])( + "Research exceptions never authorize new or removed %s", + kind => { + let projection = nodes(`<${kind} id="card" />`); + let ids = new Set(["card"]); + expect(protectProjections([], projection, ids, ids)).toContain("cannot be authored"); + expect(protectProjections(projection, [], ids, ids)).toContain("cannot be dropped"); + }, +); + +test.each([ + ['', ''], + [ + 'Original', + 'Changed', + ], + ['', ''], +])("same identity cannot conceal a changed payload or kind: %s", (before, after) => { + let ids = new Set(["card"]); + expect(protectProjections(nodes(before), nodes(after), ids, ids)).toContain("cannot be altered"); +}); + +test("exact nested projections may move while unrelated prose changes", () => { + let card = 'Original choice'; + let before = nodes(`Before.\n\n\n\n${card}\n\n\n\n`); + let after = nodes(`\n\nChanged prose.\n\n${card}`); + let canonical = serialize({ type: "root", children: after }); + expect(canonical).toContain("Changed prose."); + expect(protectProjections(before, after)).toBeUndefined(); + expect(newResearchProjections(before, after)).toEqual([]); + expect(removedResearchProjections(before, after)).toEqual([]); +}); + +test("Research additions and removals require the exact IDs in the correct authority sets", () => { + let before = nodes(''); + let after = nodes(''); + expect(newResearchProjections(before, after)).toEqual(["new"]); + expect(removedResearchProjections(before, after)).toEqual(["old"]); + expect(protectProjections(before, after)).toContain("cannot be dropped"); + expect(protectProjections(before, after, new Set(["new"]))).toContain("cannot be dropped"); + expect(protectProjections(before, after, new Set(), new Set(["old"]))) + .toContain("cannot be authored"); + expect(protectProjections(before, after, new Set(["old"]), new Set(["new"]))) + .toContain("cannot be dropped"); + expect(protectProjections(before, after, new Set(["new"]), new Set(["old"]))) + .toBeUndefined(); +}); diff --git a/apps/server/src/plan/projections.ts b/apps/server/src/plan/projections.ts new file mode 100644 index 00000000..c2ef24a8 --- /dev/null +++ b/apps/server/src/plan/projections.ts @@ -0,0 +1,90 @@ +import { serialize } from "@chopin/dialect/serialize"; + +import type { RootContent } from "mdast"; + +const PROTECTED = new Set(["Questionnaire", "Decision", "Research"]); + +function collect(nodes: RootContent[]): Map | string { + let found = new Map(); + let walk = (node: RootContent): string | undefined => { + if ( + (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") + && node.name && PROTECTED.has(node.name) + ) { + let id = node.attributes.find(attribute => + attribute.type === "mdxJsxAttribute" && attribute.name === "id" + && typeof attribute.value === "string" + ); + if (!id || typeof id.value !== "string" || !id.value) { + return `a ${node.name} projection is missing its id`; + } + if (found.has(id.value)) return `\`${id.value}\` appears twice`; + found.set(id.value, { + type: node.name, + source: serialize({ type: "root", children: [node] }), + }); + } + if ("children" in node && Array.isArray(node.children)) { + for (let child of node.children) { + let failure = walk(child as RootContent); + if (failure) return failure; + } + } + return undefined; + }; + for (let node of nodes) { + let failure = walk(node); + if (failure) return failure; + } + return found; +} + +/** IDs of new atomic references whose records must be checked before accepting a browser batch. */ +export function newResearchProjections(base: RootContent[], next: RootContent[]): string[] { + let current = collect(base); + let proposed = collect(next); + if (typeof current === "string" || typeof proposed === "string") return []; + return [...proposed].flatMap(([id, value]) => + value.type === "Research" && !current.has(id) ? [id] : [] + ); +} + +/** Existing research references removed from a browser batch need terminal request authority. */ +export function removedResearchProjections(base: RootContent[], next: RootContent[]): string[] { + let current = collect(base); + let proposed = collect(next); + if (typeof current === "string" || typeof proposed === "string") return []; + return [...current].flatMap(([id, value]) => + value.type === "Research" && !proposed.has(id) ? [id] : [] + ); +} + +/** Protected components may move, but browser and Planner prose edits cannot change their records. */ +export function protectProjections( + base: RootContent[], + next: RootContent[], + allowedResearch = new Set(), + removableResearch = new Set(), +): string | undefined { + let current = collect(base); + if (typeof current === "string") return current; + let proposed = collect(next); + if (typeof proposed === "string") return proposed; + for (let [id, value] of current) { + let replacement = proposed.get(id); + if (!replacement) { + if (value.type === "Research" && removableResearch.has(id)) continue; + return "Existing Questionnaire, Decision, and Research projections cannot be dropped."; + } + if (replacement.type !== value.type || replacement.source !== value.source) { + return "Existing Questionnaire, Decision, and Research projections cannot be altered."; + } + } + for (let id of proposed.keys()) { + if (!current.has(id)) { + if (proposed.get(id)?.type === "Research" && allowedResearch.has(id)) continue; + return "Questionnaire, Decision, and Research projections cannot be authored by rewriting the plan."; + } + } + return undefined; +} diff --git a/apps/server/src/plan/questionnaires.ts b/apps/server/src/plan/questionnaires.ts index cf7568ed..c394af98 100644 --- a/apps/server/src/plan/questionnaires.ts +++ b/apps/server/src/plan/questionnaires.ts @@ -16,6 +16,8 @@ export type QuestionnairePlacement = { id: string; at: BlockAddress; after?: string; + /** Replace the decorator while moving it so an adjacent prose caret keeps its Yjs type. */ + renew?: boolean; }; type Actions = { @@ -99,8 +101,12 @@ export function place( let previous = placement.after ? questionnaires.get(placement.after) ?? prose : prose; if (questionnaire === previous || questionnaire.getPreviousSibling() === previous) continue; + let moved = placement.renew + ? $createQuestionnaireNode(questionnaire.getQuestionnaire()) + : questionnaire; questionnaire.remove(); - previous.insertAfter(questionnaire); + previous.insertAfter(moved); + questionnaires.set(placement.id, moved); changed = true; } diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index d1673765..de126edb 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -20,19 +20,25 @@ import * as Y from "yjs"; import { $createDecisionNode, $createPlanNodes, - $exportPlan, + $createResearchNode, + $exportPlanTree, $importPlan, + assert, exportPlan, limits, parse, PlanValidationError, QuestionnaireNode, registry as buildRegistry, + ResearchNode, serialize, ulid, } from "@chopin/dialect"; import { $getAnchorAndFocusForUserState } from "@lexical/yjs"; import * as Questionnaires from "./questionnaires"; +import { createCardProjections, QuestionnaireProjectionError } from "./card-projections"; +export { QuestionnaireProjectionError } from "./card-projections"; +export type { CardChange } from "./card-projections"; import { $createParagraphNode, $getNodeByKey, @@ -192,7 +198,13 @@ export function project(target: Document): string { let failure: unknown; target.editor.getEditorState().read(() => { try { - source = $exportPlan({ registry: schema() }); + let tree = $exportPlanTree({ registry: schema() }); + // Empty root paragraphs hold a shared caret but are not addressable MDX blocks. + tree.children = tree.children.filter(node => + node.type !== "paragraph" || node.children.length > 0 + ); + assert(tree); + source = serialize(tree); } catch (err) { failure = err; } @@ -278,8 +290,7 @@ export async function restore( try { Y.applyUpdate(restored.doc, checkpoint, REMOTE); await settle(); - // Checkpoints written before empty paragraphs were dropped from projection - // store their former blank lines; the canonical form is the same document. + // Older checkpoints retain blank lines from empty caret paragraphs. let projected = project(restored); if (projected !== source && projected !== serialize(parse(source))) { throw new Error("stored plan source does not match its Yjs checkpoint"); @@ -426,6 +437,36 @@ function mutate(target: Document, change: () => boolean): Mutation | undefined { export type QuestionnaireInsertion = Questionnaires.QuestionnaireInsertion; +/** Snapshot visible card projections for a startup migration. */ +export function questionnaireProjections(target: Document): Questionnaire[] { + let values: Questionnaire[] = []; + target.editor.getEditorState().read(() => { + values = $nodesOfType(QuestionnaireNode).map(node => structuredClone(node.getQuestionnaire())); + }); + return values; +} + +/** Add thread links to previously unlinked visible cards in one document update. */ +export function linkQuestionnaireThreads( + target: Document, + links: Array<{ id: string; threadId: string }>, +): Mutation | undefined { + return mutate(target, () => { + let nodes = $nodesOfType(QuestionnaireNode); + for (let link of links) { + let matches = nodes.filter(node => node.getId() === link.id); + if (matches.length !== 1 || matches[0]!.getQuestionnaire().thread) { + throw new QuestionnaireProjectionError("Card is not available for linking"); + } + } + for (let link of links) { + let node = nodes.find(node => node.getId() === link.id)!; + node.setQuestionnaire({ ...node.getQuestionnaire(), thread: link.threadId }); + } + return links.length > 0; + }); +} + export function insertQuestionnaires( target: Document, insertions: QuestionnaireInsertion[], @@ -475,31 +516,8 @@ export function insertDecision(target: Document, value: Decision): Mutation | un * on its own. A discrepancy means the projection is stale, never that the * document has decided something different. */ -export function projectAnswer( - target: Document, - id: string, - answers: Record, - settled?: { by: string; at: string }, -): Mutation | undefined { - return mutate(target, () => { - let found = false; - for (let node of $nodesOfType(QuestionnaireNode)) { - if (node.getId() !== id) continue; - found = true; - let value = node.getQuestionnaire(); - node.setQuestionnaire({ - ...value, - // Resolution belongs to the questionnaire, not each answer. - ...(settled ? { by: settled.by, at: settled.at } : {}), - questions: value.questions.map(question => { - let answer = answers[question.id]; - return answer === undefined ? question : { ...question, answer }; - }), - }); - } - return found; - }); -} +export let { projectAnswer, projectCard, hasQuestionnaire, projectOptions, projectPrompt } = + createCardProjections(mutate); /** Append one shared option to a question's projection in the plan. */ export function appendQuestionOption( @@ -568,8 +586,8 @@ export function reconcile( let root = $getRoot(); let all = root.getChildren(); - // An empty paragraph is a caret affordance, not a block the agent can - // address, so it has no place in the mapping and this edit may remove it. + // Empty root paragraphs are shared caret positions. They are not + // addressable source blocks, but must survive reconciliation by identity. let live = all.filter(node => !($isParagraphNode(node) && node.getChildrenSize() === 0)); if (live.length !== before.length) { throw new Error("the plan changed while the edit was being applied"); @@ -588,15 +606,47 @@ export function reconcile( } fresh.forEach((node, index) => nodes.set(node, created[index]!)); - root.splice( - 0, - all.length, - after.map(node => { - let found = nodes.get(node); - if (!found) throw new Error("a plan block has no live node"); - return found; - }), - ); + let ordered = after.map(node => { + let found = nodes.get(node); + if (!found) throw new Error("a plan block has no live node"); + return found; + }); + let surviving = new Set(ordered); + let caretBefore = new Map(); + let groups = new Map(); + let pending: LexicalNode[] = []; + for (let node of all) { + if ($isParagraphNode(node) && node.getChildrenSize() === 0) pending.push(node); + else { + groups.set(node, pending); + pending = []; + } + } + let displaced: LexicalNode[] = []; + let leading: LexicalNode[] = []; + let nextSurvivor: Array = []; + let nearest: LexicalNode | undefined; + for (let index = live.length - 1; index >= 0; index--) { + if (surviving.has(live[index]!)) nearest = live[index]; + nextSurvivor[index] = nearest; + } + for (let [index, node] of live.entries()) { + let carets = groups.get(node) ?? []; + if (!carets.length) continue; + if (index === 0) { + leading.push(...carets); + continue; + } + let recipient = nextSurvivor[index]; + if (recipient) caretBefore.set(recipient, [...caretBefore.get(recipient) ?? [], ...carets]); + else displaced.push(...carets); + } + root.splice(0, all.length, [ + ...leading, + ...ordered.flatMap(node => [...caretBefore.get(node) ?? [], node]), + ...displaced, + ...pending, + ]); return true; }); } @@ -639,6 +689,16 @@ export function hasProse(target: Document): boolean { ); } +export function headingAllowed(target: Document): boolean { + let blocks = parse(project(target)).children; + let placeholder = blocks[0]?.type === "heading" && blocks[0].depth === 1 + && blocks[0].children.length === 0; + return blocks.slice(placeholder ? 1 : 0).every(node => + node.type === "mdxJsxFlowElement" + && (node.name === "Questionnaire" || node.name === "Decision") + ); +} + function anchorForKey(target: Document, key: string | undefined, hash: string): Anchor { let collab = key ? target.binding.collabNodeMap.get(key) : undefined; let type = collab?.getSharedType(); @@ -662,6 +722,18 @@ export function anchorAt(target: Document, index: number, hash: string): Anchor return anchorForKey(target, key, hash); } +/** A questionnaire's index in the canonical block address space. */ +export function questionnaireIndex(target: Document, id: string): number | undefined { + let found: number | undefined; + target.editor.getEditorState().read(() => { + let matches = addressable().flatMap((node, index) => + node instanceof QuestionnaireNode && node.getId() === id ? [index] : [] + ); + if (matches.length === 1) found = matches[0]; + }); + return found; +} + /** * A position at the end of the block at `index`. * @@ -1062,3 +1134,11 @@ export function rebasePassage(target: Document, passage: Passage): Passage { return { ...passage, blocks, drifted: true }; } } + +export function insertResearch(target: Document, id: string): Mutation | undefined { + return mutate(target, () => { + if ($nodesOfType(ResearchNode).some(node => node.getId() === id)) return false; + $getRoot().append($createResearchNode(id)); + return true; + }); +} diff --git a/packages/dialect/src/dialect.test.ts b/packages/dialect/src/dialect.test.ts index 80bd73f6..196a4fbc 100644 --- a/packages/dialect/src/dialect.test.ts +++ b/packages/dialect/src/dialect.test.ts @@ -309,3 +309,22 @@ describe("diagnostics", () => { expect(JSON.stringify(result.issues)).not.toContain(secret); }); }); + +// Exact pending-card callback from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2. +it("accepts a pending conversation question with no options", () => { + let source = `\n` + + `\n` + + ``; + accepts(source); + expect(canonical(source)).toContain("Which system?"); + accepts(source.replace('status="open"', 'status="discarded"')); + expect(codes(source.replace('thread="thread-a" ', ""))).toContain("missing-children"); + expect( + codes( + source.replace( + "", + `\n`, + ), + ), + ).toContain("missing-children"); +}); diff --git a/packages/dialect/src/dialect.ts b/packages/dialect/src/dialect.ts index 83c62c14..6116483e 100644 --- a/packages/dialect/src/dialect.ts +++ b/packages/dialect/src/dialect.ts @@ -99,6 +99,12 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "components", names: ["Question"] }, forbids: ["Questionnaire", "Tabs", "Callout"], attributes: { + thread: { type: "text", required: false, max: limits.MAX_ID }, + status: { + type: "enum", + required: false, + values: ["open", "decided", "reopened", "discarded"], + }, by: { type: "text", required: false, max: limits.MAX_HANDLE }, at: { type: "text", required: false, max: limits.MAX_TIMESTAMP }, }, @@ -107,7 +113,7 @@ export const COMPONENTS: Readonly> = Object.freeze({ Question: component({ name: "Question", kind: "flow", - content: { type: "components", names: ["Option", "Answer"] }, + content: { type: "components", names: ["Option", "Answer", "Previous"] }, parent: ["Questionnaire"], attributes: { header: { type: "text", required: true, max: limits.MAX_QUESTION_HEADER }, @@ -138,6 +144,21 @@ export const COMPONENTS: Readonly> = Object.freeze({ parent: ["Question"], attributes: { value: { type: "text", required: true, max: limits.MAX_CUSTOM_ANSWER }, + choices: { type: "text", required: false, max: limits.MAX_ANSWER_CHOICES }, + }, + }), + + /** Latest decision replaced by a reopened question; the record holds the full history. */ + Previous: plain({ + name: "Previous", + kind: "flow", + content: { type: "empty" }, + parent: ["Question"], + attributes: { + choices: { type: "text", required: false, max: limits.MAX_ANSWER_CHOICES }, + value: { type: "text", required: false, max: limits.MAX_CUSTOM_ANSWER }, + by: { type: "text", required: true, max: limits.MAX_HANDLE }, + at: { type: "text", required: true, max: limits.MAX_TIMESTAMP }, }, }), diff --git a/packages/dialect/src/index.ts b/packages/dialect/src/index.ts index 721b9dfc..d9a87ef5 100644 --- a/packages/dialect/src/index.ts +++ b/packages/dialect/src/index.ts @@ -73,9 +73,10 @@ export type { SerializedResearch } from "./nodes/research"; export { $createQuestionnaireNode, $isQuestionnaireNode, + cardStatus, QuestionnaireNode, } from "./nodes/questionnaire"; -export type { Option, Question, Questionnaire } from "./nodes/questionnaire"; +export type { CardStatus, Option, Previous, Question, Questionnaire } from "./nodes/questionnaire"; export { $createDecisionNode, $isDecisionNode, DecisionNode } from "./nodes/decision"; export type { Decision, Note } from "./nodes/decision"; diff --git a/packages/dialect/src/limits.ts b/packages/dialect/src/limits.ts index ab30a8e9..5d03d763 100644 --- a/packages/dialect/src/limits.ts +++ b/packages/dialect/src/limits.ts @@ -37,6 +37,8 @@ export const MAX_QUESTION_PROMPT = 1_000; export const MAX_OPTION_LABEL = 200; export const MAX_OPTION_DESCRIPTION = 1_000; export const MAX_CUSTOM_ANSWER = 4_000; +/** Up to twenty ULIDs with separators in one answer. */ +export const MAX_ANSWER_CHOICES = 600; /** * Accepted comment threads, projected into the plan as ``. diff --git a/packages/dialect/src/nodes/questionnaire-choices.test.ts b/packages/dialect/src/nodes/questionnaire-choices.test.ts new file mode 100644 index 00000000..7773315a --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire-choices.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from "bun:test"; + +import * as limits from "../limits"; +import { ulid } from "../ulid"; + +import { BLUE, CANARY, ID, OPEN, QUESTION, through } from "./questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +it("round-trips chosen option identifiers", () => { + let source = OPEN.replace( + "", + `\n`, + ); + let out = through(source); + expect(through(out)).toBe(out); + expect(out).toContain(` { + let source = OPEN.replace('multiple="false"', 'multiple="true"').replace( + "", + `\n`, + ); + let out = through(source); + expect(through(out)).toBe(out); + expect(out).toContain(`choices="${CANARY} ${BLUE}"`); +}); + +it("keeps older and custom answers without chosen identifiers", () => { + let source = OPEN.replace( + "", + `\n`, + ); + let out = through(source); + expect(through(out)).toBe(out); + expect(out).toContain(' { + let answer = (choices: string) => + OPEN.replace("", `\n`); + expect(() => through(answer(ID))).toThrow(/Answer choices must name options/); + expect(() => through(answer(`${CANARY} ${CANARY}`))).toThrow(/cannot repeat/); + expect(() => through(answer(`${CANARY} ${BLUE}`))).toThrow(/accepts one choice/); +}); + +it("rejects more chosen identifiers than the legacy option ceiling", () => { + let ids = Array.from({ length: limits.MAX_OPTIONS + 1 }, () => ulid()); + let source = (selected: string[]) => + `\n` + + `\n` + + ids.map(id => `\n\n`; + expect(through(source(ids.slice(0, limits.MAX_OPTIONS)))).toContain(" = new Set(["open", "decided", "reopened", "discarded"]); + +export function cardStatus(value: Questionnaire): CardStatus { + if (value.status !== undefined) { + if (!STATUSES.has(value.status)) throw new Error("invalid questionnaire status"); + return value.status; + } + return value.questions.length > 0 + && value.questions.every(question => question.answer !== undefined) + ? "decided" + : "open"; +} + +export function parse(value: unknown): Questionnaire { + if (!value || typeof value !== "object") return EMPTY; + let raw = value as Partial; + if ( + Object.hasOwn(raw, "thread") + && (typeof raw.thread !== "string" || !raw.thread.trim() || raw.thread.length > limits.MAX_ID) + ) throw new Error("invalid questionnaire thread"); + if (Object.hasOwn(raw, "status") && !STATUSES.has(raw.status as CardStatus)) { + throw new Error("invalid questionnaire status"); + } + let questions = Array.isArray(raw.questions) ? raw.questions : []; + for (let question of questions) { + if (!question || typeof question !== "object" || !Object.hasOwn(question, "previous")) { + continue; + } + let previous = question.previous; + if ( + !previous || typeof previous !== "object" || Array.isArray(previous) + || !Array.isArray(previous.choices) + || previous.choices.length > limits.MAX_OPTIONS + || (previous.choices.length === 0) === (previous.value === undefined) + || (Object.hasOwn(previous, "value") + && (typeof previous.value !== "string" || !previous.value.trim() + || previous.value.length > limits.MAX_CUSTOM_ANSWER)) + || typeof previous.by !== "string" || !previous.by.trim() + || previous.by.length > limits.MAX_HANDLE + || typeof previous.at !== "string" || !previous.at.trim() + || previous.at.length > limits.MAX_TIMESTAMP + || !Array.isArray(question.options) + ) throw new Error("invalid questionnaire previous decision"); + let options = new Set(question.options.map(option => option?.id)); + if ( + previous.choices.some(id => typeof id !== "string" || !options.has(id)) + || new Set(previous.choices).size !== previous.choices.length + || (!question.multiple && previous.choices.length > 1) + ) throw new Error("invalid questionnaire previous choices"); + } + return { + id: typeof raw.id === "string" ? raw.id : "", + questions, + ...(raw.thread === undefined ? {} : { thread: raw.thread }), + ...(raw.status === undefined ? {} : { status: raw.status }), + ...(typeof raw.by === "string" ? { by: raw.by } : {}), + ...(typeof raw.at === "string" ? { at: raw.at } : {}), + }; +} diff --git a/packages/dialect/src/nodes/questionnaire-history.test.ts b/packages/dialect/src/nodes/questionnaire-history.test.ts new file mode 100644 index 00000000..b3884dad --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire-history.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "bun:test"; +import { $getRoot } from "lexical"; +import { exportPlan, importPlan } from "../convert"; +import * as limits from "../limits"; +import { ulid } from "../ulid"; +import { $isQuestionnaireNode } from "./questionnaire"; +import { + BLUE, + CANARY, + editor, + ID, + OPEN, + QUESTION, + REGISTRY, + through, +} from "./questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +it("round-trips a previous decision through MDX and Lexical JSON", () => { + let source = OPEN + .replace( + ``, + ``, + ) + .replace( + "", + `\n`, + ); + let out = through(source); + expect(through(out)).toBe(out); + expect(out).toContain( + ``, + ); + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + let json = JSON.stringify(instance.getEditorState()); + let restored = editor(); + restored.setEditorState(restored.parseEditorState(json)); + expect(exportPlan(restored, { registry: REGISTRY })).toBe(out); + restored.getEditorState().read(() => { + let node = $getRoot().getFirstChild(); + if (!$isQuestionnaireNode(node)) throw new Error("expected questionnaire"); + expect(node.getQuestionnaire().questions[0]!.previous).toEqual({ + choices: [BLUE], + by: "ana", + at: "2026-09-23T17:30:00.000Z", + }); + }); +}); + +it("round-trips a previous answer without inventing an option choice", () => { + let source = OPEN.replace( + "", + `\n`, + ); + let out = through(source); + expect(out).toContain(' { + let previous = (attributes: string) => + OPEN.replace( + "", + `\n`, + ); + expect(() => through(previous(""))).toThrow(); + expect(() => through(previous(`choices="${BLUE}" value="Text"`))).toThrow(); + expect(() => through(previous('value=""'))).toThrow(); + expect(() => through(previous(`value="${"x".repeat(4001)}"`))).toThrow(); +}); + +it("rejects unknown, repeated, and excess previous choice identifiers", () => { + let previous = (choices: string) => + OPEN.replace( + "", + `\n`, + ); + expect(() => through(previous(`${BLUE} nope`))).toThrow(/Previous choices must name options/); + expect(() => through(previous(`${BLUE} ${BLUE}`))).toThrow(/Previous choices cannot repeat/); + expect(() => through(previous(`${CANARY} ${BLUE}`))).toThrow(/accepts one choice/); + let ids = Array.from({ length: limits.MAX_OPTIONS + 1 }, () => ulid()); + let source = `\n` + + `\n` + + ids.map(id => `\n\n`; + expect(() => through(source)).toThrow(/Previous accepts at most 20 choices/); +}); + +it("rejects multiple previous decisions in one question", () => { + let previous = ``; + let source = OPEN.replace("", `${previous}\n${previous}\n`); + expect(() => through(source)).toThrow(/at most one Previous/); +}); diff --git a/packages/dialect/src/nodes/questionnaire-lifecycle.test.ts b/packages/dialect/src/nodes/questionnaire-lifecycle.test.ts new file mode 100644 index 00000000..bc664238 --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire-lifecycle.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "bun:test"; +import { $getRoot } from "lexical"; +import { importPlan } from "../convert"; + +import { $isQuestionnaireNode, cardStatus, fromElement } from "./questionnaire"; +import { editor, ID, OPEN, parsed, REGISTRY, through } from "./questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +it("round-trips thread and status through MDX and Lexical", () => { + let source = OPEN.replace( + ``, + ``, + ); + let out = through(source); + expect(through(out)).toBe(out); + expect(out).toContain('thread="thread:abc" status="discarded"'); + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + instance.getEditorState().read(() => { + let node = $getRoot().getFirstChild(); + if (!$isQuestionnaireNode(node)) throw new Error("expected questionnaire"); + expect(node.getQuestionnaire()).toMatchObject({ + thread: "thread:abc", + status: "discarded", + }); + expect(cardStatus(node.getQuestionnaire())).toBe("discarded"); + }); +}); + +it("does not let a discarded hidden node receive keyboard selection", () => { + let source = OPEN.replace( + ``, + ``, + ); + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + instance.getEditorState().read(() => { + let node = $getRoot().getFirstChild(); + if (!$isQuestionnaireNode(node)) throw new Error("expected questionnaire"); + expect(node.isKeyboardSelectable()).toBe(false); + }); +}); + +it("derives status only when older documents omit it", () => { + let open = fromElement(parsed(OPEN)); + expect(open).not.toHaveProperty("status"); + expect(open).not.toHaveProperty("thread"); + expect(cardStatus(open)).toBe("open"); + let answered = OPEN.replace("", `\n`); + expect(cardStatus(fromElement(parsed(answered)))).toBe("decided"); + for (let status of ["open", "decided", "reopened", "discarded"] as const) { + expect(cardStatus({ ...open, status })).toBe(status); + } + expect(() => cardStatus({ ...open, status: "maybe" as "open" })).toThrow(/status/); +}); diff --git a/packages/dialect/src/nodes/questionnaire-mdx.ts b/packages/dialect/src/nodes/questionnaire-mdx.ts new file mode 100644 index 00000000..43d44720 --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire-mdx.ts @@ -0,0 +1,136 @@ +import { attribute, attributes, identity } from "./shared"; +import type { Jsx } from "./shared"; +import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx"; +import { + type CardStatus, + type Option, + parse, + type Question, + type Questionnaire, +} from "./questionnaire-fields"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 node declarations; import/export wrappers only. + +function elements(node: Jsx, name: string): MdxJsxFlowElement[] { + let out: MdxJsxFlowElement[] = []; + for (let child of node.children) { + if (child.type === "mdxJsxFlowElement" && child.name === name) out.push(child); + } + return out; +} + +export function fromElement(node: Jsx): Questionnaire { + let questions: Question[] = []; + + for (let element of elements(node, "Question")) { + let options: Option[] = []; + for (let source of elements(element, "Option")) { + let option: Option = { + id: attribute(source, "id") ?? "", + label: attribute(source, "label") ?? "", + }; + let description = attribute(source, "description"); + if (description) option.description = description; + options.push(option); + } + + let question: Question = { + id: attribute(element, "id") ?? "", + header: attribute(element, "header") ?? "", + prompt: attribute(element, "prompt") ?? "", + multiple: attribute(element, "multiple") === "true", + options, + }; + + let answer = elements(element, "Answer")[0]; + if (answer) { + question.answer = attribute(answer, "value") ?? ""; + let chosen = (attribute(answer, "choices") ?? "").split(/\s+/).filter(Boolean); + if (chosen.length) question.choices = chosen; + } + + let previousElements = elements(element, "Previous"); + if (previousElements.length > 1) throw new Error("Question accepts at most one Previous"); + let previous = previousElements[0]; + if (previous) { + question.previous = { + choices: (attribute(previous, "choices") ?? "").trim().split(/\s+/).filter(Boolean), + ...(attribute(previous, "value") === undefined + ? {} + : { value: attribute(previous, "value") }), + by: attribute(previous, "by") ?? "", + at: attribute(previous, "at") ?? "", + }; + } + + questions.push(question); + } + + let by = attribute(node, "by"); + let at = attribute(node, "at"); + let thread = attribute(node, "thread"); + let status = attribute(node, "status") as CardStatus | undefined; + + return parse({ + id: attribute(node, "id") ?? "", + questions, + ...(thread === undefined ? {} : { thread }), + ...(status === undefined ? {} : { status }), + ...(by ? { by } : {}), + ...(at ? { at } : {}), + }); +} + +export function toElement(value: Questionnaire): MdxJsxFlowElement { + value = parse(value); + return { + type: "mdxJsxFlowElement", + name: "Questionnaire", + attributes: identity(value.id, { + thread: value.thread, + status: value.status, + by: value.by, + at: value.at, + }), + children: value.questions.map(question => ({ + type: "mdxJsxFlowElement", + name: "Question", + attributes: identity(question.id, { + header: question.header, + prompt: question.prompt, + multiple: String(question.multiple), + }), + children: [ + ...question.options.map(option => ({ + type: "mdxJsxFlowElement" as const, + name: "Option", + attributes: identity(option.id, { label: option.label, description: option.description }), + children: [], + })), + // A projection of sidecar state: addressed through its Question, + // so it carries no identity of its own. + ...(question.answer === undefined ? [] : [{ + type: "mdxJsxFlowElement" as const, + name: "Answer", + attributes: attributes({ value: question.answer, choices: question.choices?.join(" ") }), + children: [], + }]), + ...(question.previous + ? [{ + type: "mdxJsxFlowElement" as const, + name: "Previous", + attributes: attributes({ + choices: question.previous.choices.length + ? question.previous.choices.join(" ") + : undefined, + value: question.previous.value, + by: question.previous.by, + at: question.previous.at, + }), + children: [], + }] + : []), + ], + })), + }; +} diff --git a/packages/dialect/src/nodes/questionnaire-validation.test.ts b/packages/dialect/src/nodes/questionnaire-validation.test.ts new file mode 100644 index 00000000..8b5b85b2 --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire-validation.test.ts @@ -0,0 +1,95 @@ +import { expect, it } from "bun:test"; + +import { importPlan } from "../convert"; + +import { fromElement, toElement } from "./questionnaire"; +import { BLUE, editor, ID, OPEN, parsed, REGISTRY, through } from "./questionnaire.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +it("rejects malformed lifecycle attributes in MDX", () => { + let header = ``; + expect(() => through(OPEN.replace(header, ``))) + .toThrow(/status.*must be one of/); + expect(() => through(OPEN.replace(header, ``))) + .toThrow(/thread.*cannot be empty/); + expect(() => + through(OPEN.replace( + header, + ``, + )) + ).toThrow(/thread.*exceeds 200 characters/); + let previous = ``; + expect(() => + through( + OPEN.replace("", `${previous.replace(`choices="${BLUE}"`, "")}\n`), + ) + ) + .toThrow(/Previous.*requires.*choices/); + expect(() => + through(OPEN.replace("", `${previous.replace('by="ana"', 'by=""')}\n`)) + ) + .toThrow(/Previous.by.*cannot be empty/); +}); + +it("accepts a 200-character thread ID and rejects 201 characters in MDX and Lexical", () => { + let header = ``; + let accepted = "a".repeat(200); + let rejected = "a".repeat(201); + let source = OPEN.replace(header, ``); + expect(through(source)).toContain(`thread="${accepted}"`); + let instance = editor(); + importPlan(instance, OPEN, { registry: REGISTRY }); + let state = JSON.parse(JSON.stringify(instance.getEditorState())) as { + root: { children: { planQuestionnaire: Record }[] }; + }; + let value = state.root.children[0]!.planQuestionnaire; + value.thread = accepted; + expect(() => instance.parseEditorState(JSON.stringify(state))).not.toThrow(); + value.thread = rejected; + expect(() => instance.parseEditorState(JSON.stringify(state))).toThrow(/thread/); +}); + +it("rejects malformed present lifecycle fields in Lexical JSON", () => { + let instance = editor(); + importPlan(instance, OPEN, { registry: REGISTRY }); + let state = JSON.parse(JSON.stringify(instance.getEditorState())) as { + root: { children: { planQuestionnaire: Record }[] }; + }; + let value = state.root.children[0]!.planQuestionnaire; + value.thread = 42; + expect(() => instance.parseEditorState(JSON.stringify(state))).toThrow(/thread/); + delete value.thread; + value.status = "maybe"; + expect(() => instance.parseEditorState(JSON.stringify(state))).toThrow(/status/); + delete value.status; + let questions = value.questions as Record[]; + questions[0]!.previous = { choices: [BLUE, "nope"], by: "ana", at: "2026-09-23" }; + expect(() => instance.parseEditorState(JSON.stringify(state))).toThrow(/previous/i); +}); + +it("rejects malformed present fields at the plain-data conversion boundary", () => { + let invalidStatus = OPEN.replace( + ``, + ``, + ); + expect(() => fromElement(parsed(invalidStatus))).toThrow(/status/); + let emptyThread = OPEN.replace( + ``, + ``, + ); + expect(() => fromElement(parsed(emptyThread))).toThrow(/thread/); + let invalidPrevious = OPEN.replace( + "", + `\n`, + ); + expect(() => fromElement(parsed(invalidPrevious))).toThrow(/previous/i); + let previous = ``; + expect(() => + fromElement(parsed( + OPEN.replace("", `${previous}\n${previous}\n`), + )) + ).toThrow(/at most one Previous/); + let value = fromElement(parsed(OPEN)); + expect(() => toElement({ ...value, thread: "" })).toThrow(/thread/); + expect(() => toElement({ ...value, status: "" as "open" })).toThrow(/status/); +}); diff --git a/packages/dialect/src/nodes/questionnaire.test-fixtures.ts b/packages/dialect/src/nodes/questionnaire.test-fixtures.ts new file mode 100644 index 00000000..a32b83b8 --- /dev/null +++ b/packages/dialect/src/nodes/questionnaire.test-fixtures.ts @@ -0,0 +1,48 @@ +import { createHeadlessEditor } from "@lexical/headless"; +import { exportPlan, importPlan } from "../convert"; +import { parse } from "../parse"; +import { registry } from "../registry"; +import type { LexicalEditor } from "lexical"; +import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, nodes/questionnaire.test.ts helpers/data. +export const REGISTRY = registry(); + +export const ID = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; + +export const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; + +export const CANARY = "01K0N4W3B7P27CBAEC7A8C8WEA"; + +export const BLUE = "01K0N4X2M5R8T3VQ7YB6ZC4DEF"; + +export function editor(): LexicalEditor { + return createHeadlessEditor({ + nodes: REGISTRY.nodes, + onError(err) { + throw err; + }, + }); +} + +export function through(source: string): string { + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + return exportPlan(instance, { registry: REGISTRY }); +} + +export function parsed(source: string): MdxJsxFlowElement { + let node = parse(source).children[0]; + if (node?.type !== "mdxJsxFlowElement" || node.name !== "Questionnaire") { + throw new Error("expected questionnaire element"); + } + return node; +} + +export const OPEN = `\n` + + `\n` + + `\n` + + `\n`; diff --git a/packages/dialect/src/nodes/questionnaire.test.ts b/packages/dialect/src/nodes/questionnaire.test.ts index fa391a97..e865a643 100644 --- a/packages/dialect/src/nodes/questionnaire.test.ts +++ b/packages/dialect/src/nodes/questionnaire.test.ts @@ -1,42 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { createHeadlessEditor } from "@lexical/headless"; import { $getRoot, $isElementNode } from "lexical"; -import { exportPlan, importPlan } from "../convert"; -import { registry } from "../registry"; +import { importPlan } from "../convert"; import { $isQuestionnaireNode } from "./questionnaire"; -import type { LexicalEditor } from "lexical"; - -const REGISTRY = registry(); - -const ID = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; -const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; -const CANARY = "01K0N4W3B7P27CBAEC7A8C8WEA"; -const BLUE = "01K0N4X2M5R8T3VQ7YB6ZC4DEF"; - -function editor(): LexicalEditor { - return createHeadlessEditor({ - nodes: REGISTRY.nodes, - onError(err) { - throw err; - }, - }); -} - -function through(source: string): string { - let instance = editor(); - importPlan(instance, source, { registry: REGISTRY }); - return exportPlan(instance, { registry: REGISTRY }); -} - -const OPEN = `\n` - + `\n` - + `\n` - + `\n`; +import { editor, ID, OPEN, REGISTRY, through } from "./questionnaire.test-fixtures"; describe("questionnaire", () => { it("round-trips an open questionnaire", () => { diff --git a/packages/dialect/src/nodes/questionnaire.ts b/packages/dialect/src/nodes/questionnaire.ts index 814b6e6e..97a15773 100644 --- a/packages/dialect/src/nodes/questionnaire.ts +++ b/packages/dialect/src/nodes/questionnaire.ts @@ -2,10 +2,10 @@ * Durable questionnaires. * * Unlike the other containers this is atomic. A questionnaire's definition is - * fixed once created apart from options the server appends while it is open, - * and its answer is owned by the sidecar record rather than by the document, so - * there is nothing inside it for two people to edit concurrently. Modelling it as a decorator keeps it selectable, movable and - * deletable as one unit while making its contents unwritable by construction. + * fixed except for options the server appends while it is open. Its answer is + * owned by the sidecar record; the document carries only the projection. + * Modelling it as a decorator keeps it selectable, movable and deletable as one + * unit while making its contents unwritable by construction. * * The `` written into source is a projection for readability. The * sidecar stays authoritative; a mismatch is a server-side error, not something @@ -13,10 +13,8 @@ */ import { $applyNodeReplacement, $getState, $setState, createState, DecoratorNode } from "lexical"; - import { render } from "./render"; -import { attribute, attributes, identity, isFlow, PRIORITY } from "./shared"; - +import { isFlow, PRIORITY } from "./shared"; import type { LexicalExportVisitor, MdastImportVisitor } from "@mdxeditor/editor"; import type { ElementNode, @@ -26,51 +24,13 @@ import type { Spread, } from "lexical"; import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx"; -import type { Jsx } from "./shared"; - -export type Option = { - id: string; - label: string; - description?: string; -}; - -export type Question = { - id: string; - header: string; - prompt: string; - multiple: boolean; - options: Option[]; - /** Projection of the resolved answer, when there is one. */ - answer?: string; -}; - -export type Questionnaire = { - id: string; - questions: Question[]; - /** - * Who settled it, and when. - * - * On the questionnaire rather than on each answer: it resolves as a unit, - * so this is one fact about one moment. Absent until it is answered, and - * absent for good on one answered before this was written down. - */ - by?: string; - /** ISO 8601. */ - at?: string; -}; +import { cardStatus, EMPTY, parse, type Questionnaire } from "./questionnaire-fields"; +import { fromElement, toElement } from "./questionnaire-mdx"; +export { cardStatus } from "./questionnaire-fields"; +export type { CardStatus, Option, Previous, Question, Questionnaire } from "./questionnaire-fields"; +export { fromElement, toElement } from "./questionnaire-mdx"; -const EMPTY: Questionnaire = { id: "", questions: [] }; - -function parse(value: unknown): Questionnaire { - if (!value || typeof value !== "object") return EMPTY; - let raw = value as Partial; - return { - id: typeof raw.id === "string" ? raw.id : "", - questions: Array.isArray(raw.questions) ? raw.questions : [], - ...(typeof raw.by === "string" ? { by: raw.by } : {}), - ...(typeof raw.at === "string" ? { at: raw.at } : {}), - }; -} +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 node declarations; import/export wrappers only. export const questionnaireState = createState("plan-questionnaire", { parse, @@ -131,7 +91,7 @@ export class QuestionnaireNode extends DecoratorNode { } override isKeyboardSelectable(): boolean { - return true; + return cardStatus(this.getQuestionnaire()) !== "discarded"; } /** `@chopin/editor` renders the interactive card. */ @@ -150,94 +110,6 @@ export function $isQuestionnaireNode( return node instanceof QuestionnaireNode; } -// -- MDX conversion -------------------------------------------------------- - -function elements(node: Jsx, name: string): MdxJsxFlowElement[] { - let out: MdxJsxFlowElement[] = []; - for (let child of node.children) { - if (child.type === "mdxJsxFlowElement" && child.name === name) out.push(child); - } - return out; -} - -/** Read a validated `` element into plain data. */ -export function fromElement(node: Jsx): Questionnaire { - let questions: Question[] = []; - - for (let element of elements(node, "Question")) { - let options: Option[] = []; - for (let source of elements(element, "Option")) { - let option: Option = { - id: attribute(source, "id") ?? "", - label: attribute(source, "label") ?? "", - }; - let description = attribute(source, "description"); - if (description) option.description = description; - options.push(option); - } - - let question: Question = { - id: attribute(element, "id") ?? "", - header: attribute(element, "header") ?? "", - prompt: attribute(element, "prompt") ?? "", - multiple: attribute(element, "multiple") === "true", - options, - }; - - let answer = elements(element, "Answer")[0]; - if (answer) question.answer = attribute(answer, "value") ?? ""; - - questions.push(question); - } - - let by = attribute(node, "by"); - let at = attribute(node, "at"); - - return { - id: attribute(node, "id") ?? "", - questions, - ...(by ? { by } : {}), - ...(at ? { at } : {}), - }; -} - -/** Write plain data back out as a `` element. */ -export function toElement(value: Questionnaire): MdxJsxFlowElement { - return { - type: "mdxJsxFlowElement", - name: "Questionnaire", - attributes: identity(value.id, { by: value.by, at: value.at }), - children: value.questions.map(question => ({ - type: "mdxJsxFlowElement", - name: "Question", - attributes: identity(question.id, { - header: question.header, - prompt: question.prompt, - multiple: String(question.multiple), - }), - children: [ - ...question.options.map(option => ({ - type: "mdxJsxFlowElement" as const, - name: "Option", - attributes: identity(option.id, { label: option.label, description: option.description }), - children: [], - })), - // A projection of sidecar state: addressed through its Question, - // so it carries no identity of its own. - ...(question.answer === undefined ? [] : [{ - type: "mdxJsxFlowElement" as const, - name: "Answer", - attributes: attributes({ value: question.answer }), - children: [], - }]), - ], - })), - }; -} - -// -- visitors -------------------------------------------------------------- - -/** Import the whole subtree as one atomic node; nothing inside is editable. */ export const MdastQuestionnaireVisitor: MdastImportVisitor = { testNode: isFlow("Questionnaire"), visitNode({ mdastNode, lexicalParent }) { diff --git a/packages/dialect/src/validate.ts b/packages/dialect/src/validate.ts index 7f6857e4..236fd637 100644 --- a/packages/dialect/src/validate.ts +++ b/packages/dialect/src/validate.ts @@ -41,6 +41,15 @@ function isJsx(node: Nodes): node is Jsx { return node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement"; } +function stringAttribute(node: Jsx, name: string): string | undefined { + let found = node.attributes.find(attribute => + attribute.type === "mdxJsxAttribute" && attribute.name === name + ); + return found?.type === "mdxJsxAttribute" && typeof found.value === "string" + ? found.value + : undefined; +} + function children(node: Nodes): Nodes[] { return (node as Parent).children ?? []; } @@ -82,7 +91,7 @@ class Validator { } } - #walk(node: Nodes, path: string, depth: number, ancestors: string[]): void { + #walk(node: Nodes, path: string, depth: number, ancestors: string[], parent?: Jsx): void { if (depth > limits.MAX_DEPTH) { this.add("too-deep", `Content nests deeper than ${limits.MAX_DEPTH} levels`, path, node); return; @@ -99,7 +108,7 @@ class Validator { } if (isJsx(node)) { - this.#component(node, path, depth, ancestors); + this.#component(node, path, depth, ancestors, parent); return; } @@ -131,11 +140,17 @@ class Validator { let name = label(child); let index = counts.get(name) ?? 0; counts.set(name, index + 1); - this.#walk(child, `${path} > ${name}[${index}]`, depth + 1, ancestors); + this.#walk( + child, + `${path} > ${name}[${index}]`, + depth + 1, + ancestors, + isJsx(node) ? node : undefined, + ); } } - #component(node: Jsx, path: string, depth: number, ancestors: string[]): void { + #component(node: Jsx, path: string, depth: number, ancestors: string[], parentNode?: Jsx): void { if (!node.name) { this.add("fragment", "JSX fragments are not allowed in plans", path, node); return; @@ -181,11 +196,93 @@ class Validator { } this.#attributes(node, spec, path); - this.#content(node, spec, path); + this.#content(node, spec, path, parentNode); + if (spec.name === "Question") this.#questionChoices(node, path); this.#descend(node, path, depth, [...ancestors, spec.name]); } + #questionChoices(node: Jsx, path: string): void { + let elements = children(node).filter(isJsx); + let options = new Set( + elements.filter(item => item.name === "Option") + .map(item => stringAttribute(item, "id")), + ); + for (let answer of elements.filter(item => item.name === "Answer")) { + let raw = stringAttribute(answer, "choices"); + if (raw === undefined) continue; + let chosen = raw.trim().split(/\s+/).filter(Boolean); + if (chosen.length > limits.MAX_OPTIONS) { + this.add( + "too-many-answer-choices", + `Answer accepts at most ${limits.MAX_OPTIONS} choices`, + path, + answer, + ); + } + if (chosen.some(id => !options.has(id))) { + this.add( + "unknown-answer-choice", + "Answer choices must name options in this question", + path, + answer, + ); + } + if (new Set(chosen).size !== chosen.length) { + this.add("duplicate-answer-choice", "Answer choices cannot repeat an option", path, answer); + } + if (stringAttribute(node, "multiple") === "false" && chosen.length > 1) { + this.add("too-many-answer-choices", "This question accepts one choice", path, answer); + } + } + + let previous = elements.filter(item => item.name === "Previous"); + if (previous.length > 1) { + this.add("duplicate-previous", "Question accepts at most one Previous", path, node); + } + for (let item of previous) { + let raw = stringAttribute(item, "choices"); + let value = stringAttribute(item, "value"); + if ((raw === undefined) === (value === undefined)) { + this.add( + "invalid-previous-answer", + "Previous requires choices or value, but not both", + path, + item, + ); + } + if (raw === undefined) continue; + let chosen = raw.trim().split(/\s+/).filter(Boolean); + if (chosen.length > limits.MAX_OPTIONS) { + this.add( + "too-many-previous-choices", + `Previous accepts at most ${limits.MAX_OPTIONS} choices`, + path, + item, + ); + } + if (chosen.some(id => !options.has(id))) { + this.add( + "unknown-previous-choice", + "Previous choices must name options in this question", + path, + item, + ); + } + if (new Set(chosen).size !== chosen.length) { + this.add( + "duplicate-previous-choice", + "Previous choices cannot repeat an option", + path, + item, + ); + } + if (stringAttribute(node, "multiple") === "false" && chosen.length > 1) { + this.add("too-many-previous-choices", "This question accepts one choice", path, item); + } + } + } + #attributes(node: Jsx, spec: dialect.Component, path: string): void { let seen = new Map(); @@ -274,7 +371,7 @@ class Validator { } } - #content(node: Jsx, spec: dialect.Component, path: string): void { + #content(node: Jsx, spec: dialect.Component, path: string, parent?: Jsx): void { let kids = children(node); switch (spec.content.type) { @@ -302,7 +399,15 @@ class Validator { } found++; } - if (found === 0) { + let pendingQuestion = spec.name === "Question" && parent?.name === "Questionnaire" + && !!stringAttribute(parent, "thread") + && children(parent).filter(child => isJsx(child) && child.name === "Question").length + === 1 + && ["open", "reopened", "discarded"].includes( + stringAttribute(parent, "status") ?? "", + ) + && stringAttribute(node, "multiple") === "false"; + if (found === 0 && !pendingQuestion) { this.add( "missing-children", `\`${spec.name}\` requires at least one ${allowed.join(" or ")}`,