/** * A questionnaire, as the decisions pane shows it. * * The definition only ever grows by appended options and the answer is owned by * the server's record, so this never writes to the document — an agent rewriting the plan cannot * overwrite a decision. What the plan node carries is a projection, kept so * the source reads correctly on its own. */ import { useEffect, useRef, useState } from "react"; import { QuestionView, useQuestionnaire } from "@chopin/question/react"; import { useCellValue } from "@mdxeditor/gurx"; import { Provenance, SidecarCard } from "../card"; import { ContentSwapLayer } from "../content-swap"; import { PresenceFaces } from "../presence-faces"; import { useRelations } from "../questionnaires"; import { widgets$ } from "../widget-options"; import type { ReactNode } from "react"; import type { Transport } from "@chopin/question/react"; import type { Answer } from "@chopin/question"; import type { Questionnaire, QuestionnaireNode } from "@chopin/dialect"; import type { QuestionStepMotion } from "../widget-options"; /** The plan stores the chosen text; the shared view wants answer records. */ function answers(value: Questionnaire): Answer[] | undefined { if (value.questions.some(question => question.answer === undefined)) return undefined; return value.questions.map(question => ({ question: question.prompt, custom: question.answer ?? "", })); } /** The document calls the question text `prompt`; the domain calls it `question`. */ function definition(value: Questionnaire) { return { questions: value.questions.map(question => ({ id: question.id, header: question.header, question: question.prompt, multiple: question.multiple, options: question.options.map(option => ({ id: option.id, label: option.label, description: option.description ?? "", })), })), }; } export type QuestionnaireCardProps = { value: Questionnaire; wire?: Transport; connected?: boolean; /** Whether this viewer may change or resolve the shared draft. */ canEdit?: boolean; /** How much prose each decision lives in. */ places?: { [question: string]: number }; onQuestionEnter?: (question: string) => void; onQuestionLeave?: (question: string) => void; /** Take the reader to that prose. Without it the shared view's jump is inert. */ onQuestionSelect?: (question: string) => void; motion?: QuestionStepMotion; }; export function QuestionnaireCard( { canEdit = true, connected = false, onQuestionEnter, onQuestionLeave, onQuestionSelect, motion, places, value, wire, }: QuestionnaireCardProps, ) { let resolved = answers(value); let pointing = { places, onQuestionEnter, onQuestionLeave, onQuestionSelect }; return resolved ? : ( ); } type Pointing = { places?: { [question: string]: number }; onQuestionEnter?: (question: string) => void; onQuestionLeave?: (question: string) => void; onQuestionSelect?: (question: string) => void; }; type QuestionStep = { children: ReactNode; question: string }; function QuestionStepSwap( { children, motion, question }: { children: ReactNode; motion: QuestionStepMotion; question: string; }, ) { let current = useRef({ children, question }); let immediately = motion.immediately(); let [presented, setPresented] = useState(question); let [active, setActive] = useState(question); let [outgoing, setOutgoing] = useState(); if (presented !== question) { setOutgoing(current.current); setPresented(question); if (immediately) setActive(question); } current.current = { children, question }; useEffect(() => { if (active !== question) setActive(question); }, [active, question]); return (
{outgoing && ( setOutgoing(step => step?.question === outgoing.question ? undefined : step)} > {outgoing.children} )} {children}
); } function Undecided( { canEdit, connected, motion, value, wire, ...pointing }: & { canEdit: boolean; connected: boolean; motion?: QuestionStepMotion; value: Questionnaire; wire?: Transport; } & Pointing, ) { let state = useQuestionnaire({ id: value.id, bridge: wire, connected, definition: definition(value), }); let answerable = connected && !!state.definition; let editable = canEdit && answerable; return ( person.handle)} />} renderStep={motion ? ({ children, question }) => ( {children} ) : undefined} status="open" submitting={state.submitting} {...pointing} /> ); } function Decided( { resolved, value, ...pointing }: { resolved: Answer[]; value: Questionnaire } & Pointing, ) { return ( } > ); } /** * Whether a resolved decision is carried entirely by a margin marker. * * Only when every question has prose to sit beside. Pending, deliberately * empty and orphaned decisions have nowhere to put a marker, so they keep a * compact card rather than vanishing from the plan. */ export function carriedByMarkers( value: Questionnaire, places: { [question: string]: number } | undefined, ): boolean { return !!places && value.questions.length > 0 && value.questions.every(question => question.answer !== undefined && (places[question.id] ?? 0) > 0 ); } function InlineQuestionnaire({ value }: { value: Questionnaire }) { let options = useCellValue(widgets$); // Re-render when anchors arrive: whether the card collapses depends on them. useRelations(options.questions); let places = options.questions?.counts(value.id); if (carriedByMarkers(value, places)) { return