/** * 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 { cardStatus } from "@chopin/dialect"; import { DecisionIcon, MessageForwardIcon } from "@chopin/icons"; import { cardRelation, NOT_LINKED, QuestionView, RelationNote, useQuestionnaire, } from "@chopin/question/react"; import { useCellValue } from "@mdxeditor/gurx"; import { Provenance, SidecarCard } from "../card"; import { useCardMeta } from "../card-meta"; import { ContentSwapLayer } from "../content-swap"; import { EvidenceHover, EvidenceTrigger } from "./evidence-hover"; import { PresenceFaces, withoutSelf } from "../presence-faces"; import { useRelations } from "../questionnaires"; import { widgets$ } from "../widget-options"; import { useTransitionPresence } from "../transition-presence"; import { WriteupStatus } from "./writeup-status"; import type { Question } from "@chopin/protocol"; import type { ReactNode } from "react"; import type { Transport } from "@chopin/question/react"; import type { Answer, Relation } 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.choices?.length ? { question: question.prompt, choices: question.options .filter(option => question.choices!.includes(option.id)) .map(option => option.label), optionIds: question.choices, } : { 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; /** Durable lifecycle state can arrive separately from the document node. */ meta?: Question.CardMeta; presentation?: "inline" | "list"; motionImmediately?: () => boolean; onCardSource?: (questionnaireId: string) => void; /** The viewer's own handle, who is never shown as present on a card. */ self?: string; evidence?: ReactNode | null; 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 }; /** Linked, pending, deliberately empty or orphaned, by question. */ relations?: { [question: string]: Relation }; /** False when no Planner will review where decisions live. */ planner?: boolean; 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 type CardPresentation = "hidden" | "settled-line" | "resolved" | "open"; export function cardPresentation( value: Questionnaire, meta: Question.CardMeta | undefined, where: "inline" | "list", ): CardPresentation { let status = meta?.status ?? cardStatus(value); if (status === "open" || status === "reopened") return "open"; if (where === "list") return "resolved"; if (status === "discarded" || meta?.hasProse) return "hidden"; // A card can resolve before its metadata reaches this client. Its document // projection is still a decided card, and all prose-less decisions settle inline. return "settled-line"; } export function QuestionnaireCard( { canEdit = true, connected = false, evidence, motionImmediately, onCardSource, presentation = "inline", self, onQuestionEnter, onQuestionLeave, onQuestionSelect, motion, meta, places, planner = true, relations, value, wire, }: QuestionnaireCardProps, ) { if (value.status === "expired") return ; let resolved = answers(value); let current = meta?.status ?? cardStatus(value); // A conversation decision's write-up has its own durable job status below. // "Linking…" is anchor review, which needs a Planner. let pendingRelation = meta?.origin === "conversation" && !meta.hasProse ? NOT_LINKED : planner ? "Linking…" : NOT_LINKED; let pointing = { places, relations, pendingRelation, onQuestionEnter, onQuestionLeave, onQuestionSelect, }; let shown = cardPresentation(value, meta, presentation); let immediate = motionImmediately?.() ?? false; let presence = useTransitionPresence(shown === "hidden" ? undefined : value.id, 200, immediate); let lastVisible = useRef<{ id: string; content: ReactNode } | undefined>(undefined); let content = shown === "settled-line" ? ( ) : shown === "resolved" ? ( ) : ( ); if (shown !== "hidden") lastVisible.current = { id: value.id, content }; let closing = shown === "hidden" && presence.phase === "closing" && lastVisible.current?.id === presence.value; if (shown === "hidden" && !closing) { return (