/**
* 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 (
);
}
if (presentation === "list") return content;
let presented = closing ? lastVisible.current!.content : content;
let evidenceActive = (current === "open" || current === "reopened")
&& !!value.thread
&& (meta?.status === "open" || meta?.status === "reopened")
&& !!evidence;
return (
{presented}
);
}
type Pointing = {
places?: { [question: string]: number };
relations?: { [question: string]: Relation };
pendingRelation?: string;
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 (