this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add branching workflows and archive completed specs

+2212 -77
+36
app/(creator)/forms/[id]/responses/[responseId]/page.tsx
··· 48 48 </Link> 49 49 </section> 50 50 51 + <Card className="p-6"> 52 + <div className="flex flex-wrap items-center gap-3"> 53 + <Badge>{t("responseDetail.routeContext")}</Badge> 54 + </div> 55 + <div className="mt-5 grid gap-6 lg:grid-cols-2"> 56 + <div> 57 + <h2 className="font-display text-2xl text-[var(--ink)]">{t("responseDetail.visitedPath")}</h2> 58 + <div className="mt-3 flex flex-wrap gap-2"> 59 + {response.routeBlocks 60 + .filter((entry) => entry.status === "visited") 61 + .map((entry) => ( 62 + <Badge key={entry.block.id} className="bg-[var(--accent-soft)] text-[var(--accent-ink)]"> 63 + {entry.block.title || blockTypeLabel(entry.block.type)} 64 + </Badge> 65 + ))} 66 + </div> 67 + </div> 68 + <div> 69 + <h2 className="font-display text-2xl text-[var(--ink)]">{t("responseDetail.skippedPath")}</h2> 70 + <div className="mt-3 flex flex-wrap gap-2"> 71 + {response.routeBlocks.filter((entry) => entry.status === "skipped").length ? ( 72 + response.routeBlocks 73 + .filter((entry) => entry.status === "skipped") 74 + .map((entry) => ( 75 + <Badge key={entry.block.id} className="bg-[var(--surface-strong)] text-[var(--muted)]"> 76 + {entry.block.title || blockTypeLabel(entry.block.type)} 77 + </Badge> 78 + )) 79 + ) : ( 80 + <p className="text-sm text-[var(--muted)]">{t("responseDetail.nothingSkipped")}</p> 81 + )} 82 + </div> 83 + </div> 84 + </div> 85 + </Card> 86 + 51 87 <div className="space-y-4"> 52 88 {response.blocks.map((block) => { 53 89 const answer = response.answers[block.id];
+1 -1
app/api/forms/[formId]/publish/route.ts
··· 14 14 try { 15 15 const payload = await request.json(); 16 16 const { formId } = await context.params; 17 - const form = await setOwnedFormPublished(session.user.id, formId, payload); 17 + const form = await setOwnedFormPublished(session.user.id, formId, payload, session.user.locale); 18 18 19 19 return NextResponse.json({ form }); 20 20 } catch (error) {
+337
components/form-builder-panels.tsx
··· 18 18 import { FormStatusBadge } from "@/components/form-status-badge"; 19 19 import { Button } from "@/components/ui/button"; 20 20 import { Input } from "@/components/ui/input"; 21 + import { 22 + Select, 23 + SelectContent, 24 + SelectItem, 25 + SelectTrigger, 26 + SelectValue, 27 + } from "@/components/ui/select"; 21 28 import { Textarea } from "@/components/ui/textarea"; 22 29 import { 30 + AGREEMENT_ANSWER_VALUES, 31 + branchOperatorNeedsValue, 23 32 blockTypeTranslationKeys, 33 + getVisibleBranchOperators, 24 34 isQuestionBlock, 25 35 type AgreementBlockConfig, 26 36 type BlockConfig, 37 + type BranchOperator, 27 38 type LinkBlockConfig, 28 39 type LongTextBlockConfig, 29 40 type NumberBlockConfig, 30 41 type ShortTextBlockConfig, 31 42 type TextBlockConfig, 32 43 } from "@/lib/blocks"; 44 + import { 45 + getBlockDisplayLabel, 46 + getBranchRuleValueHint, 47 + getBranchTargetBlocks, 48 + getBranchValidationIssueI18n, 49 + type BranchValidationIssue, 50 + } from "@/lib/branching"; 33 51 import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 34 52 35 53 export type FormMetadataDraft = { ··· 44 62 export type ChoiceOptionDraft = { 45 63 id: string; 46 64 value: string; 65 + }; 66 + 67 + export type BranchRuleDraft = { 68 + id: string; 69 + operator: BranchOperator; 70 + value: string | null; 71 + targetBlockId: string; 47 72 }; 48 73 49 74 export function BuilderHeader({ ··· 250 275 ); 251 276 } 252 277 278 + function SortableBranchRuleRow({ 279 + block, 280 + rule, 281 + index, 282 + placeholder, 283 + targetOptions, 284 + onChange, 285 + onRemove, 286 + }: { 287 + block: BuilderBlock; 288 + rule: BranchRuleDraft; 289 + index: number; 290 + placeholder: string; 291 + targetOptions: BuilderBlock[]; 292 + onChange: (ruleId: string, patch: Partial<Pick<BranchRuleDraft, "operator" | "value" | "targetBlockId">>) => void; 293 + onRemove: (ruleId: string) => void; 294 + }) { 295 + const { t } = useI18n(); 296 + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: rule.id }); 297 + const staleTargetMissing = rule.targetBlockId && !targetOptions.some((targetBlock) => targetBlock.id === rule.targetBlockId); 298 + const visibleOperators = getVisibleBranchOperators(block.type); 299 + const supportedOperators = visibleOperators.includes(rule.operator) ? visibleOperators : [rule.operator, ...visibleOperators]; 300 + const showValueInput = branchOperatorNeedsValue(rule.operator) && block.type !== "AGREEMENT"; 301 + const valueInputType = block.type === "NUMBER" ? "number" : block.type === "DATE" ? "date" : "text"; 302 + const discreteOptions = 303 + block.type === "SINGLE_CHOICE" || block.type === "MULTIPLE_CHOICE" 304 + ? (block.config as { options: string[] }).options 305 + : block.type === "AGREEMENT" 306 + ? [AGREEMENT_ANSWER_VALUES.AGREED, AGREEMENT_ANSWER_VALUES.NOT_AGREED] 307 + : null; 308 + 309 + return ( 310 + <div 311 + ref={setNodeRef} 312 + style={{ 313 + transform: CSS.Transform.toString(transform), 314 + transition, 315 + }} 316 + className="grid gap-3 rounded-[18px] border border-[color:var(--line)] bg-[var(--surface)] p-3" 317 + > 318 + <div className="flex items-center gap-2"> 319 + <button 320 + type="button" 321 + className="inline-flex size-10 shrink-0 items-center justify-center rounded-xl border border-[color:var(--line)] text-[var(--muted)] transition hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]" 322 + aria-label={t("builder.dragBranchRuleAria", { number: index + 1 })} 323 + {...attributes} 324 + {...listeners} 325 + > 326 + <GripVertical className="size-4" /> 327 + </button> 328 + <div className="grid min-w-0 flex-1 gap-3 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1fr)_minmax(220px,0.9fr)]"> 329 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 330 + <span className="font-medium text-[var(--ink)]">{t("builder.branchOperator")}</span> 331 + <Select 332 + value={rule.operator} 333 + onValueChange={(value) => 334 + onChange(rule.id, { 335 + operator: value as BranchOperator, 336 + value: 337 + block.type === "AGREEMENT" 338 + ? AGREEMENT_ANSWER_VALUES.AGREED 339 + : branchOperatorNeedsValue(value as BranchOperator) 340 + ? rule.value 341 + : null, 342 + }) 343 + } 344 + > 345 + <SelectTrigger className="h-10 w-full text-sm font-medium"> 346 + <SelectValue /> 347 + </SelectTrigger> 348 + <SelectContent> 349 + {supportedOperators.map((operator) => ( 350 + <SelectItem key={operator} value={operator}> 351 + {block.type === "SINGLE_CHOICE" && (operator === "equals" || operator === "not_equals") 352 + ? t(`builder.branchOperatorLabels.singleChoice.${operator}`) 353 + : block.type === "MULTIPLE_CHOICE" && operator === "contains_any" 354 + ? t("builder.branchOperatorLabels.multipleChoice.contains_any") 355 + : block.type === "AGREEMENT" && (operator === "equals" || operator === "not_equals") 356 + ? t(`builder.branchOperatorLabels.agreement.${operator}`) 357 + : block.type === "DATE" && ["equals", "not_equals", "gt", "gte", "lt", "lte"].includes(operator) 358 + ? t(`builder.branchOperatorLabels.date.${operator}`) 359 + : t(`builder.branchOperators.${operator}`)} 360 + </SelectItem> 361 + ))} 362 + </SelectContent> 363 + </Select> 364 + </label> 365 + 366 + {showValueInput ? ( 367 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 368 + <span className="font-medium text-[var(--ink)]">{t("builder.branchWhenAnswer")}</span> 369 + {discreteOptions ? ( 370 + <Select value={rule.value ?? undefined} onValueChange={(value) => onChange(rule.id, { value })}> 371 + <SelectTrigger className="h-10 w-full text-sm font-medium"> 372 + <SelectValue placeholder={placeholder} /> 373 + </SelectTrigger> 374 + <SelectContent> 375 + {discreteOptions.map((option) => ( 376 + <SelectItem key={option} value={option}> 377 + {block.type === "AGREEMENT" ? t(`builder.branchAgreementValues.${option}`) : option} 378 + </SelectItem> 379 + ))} 380 + </SelectContent> 381 + </Select> 382 + ) : ( 383 + <Input 384 + type={valueInputType} 385 + value={rule.value ?? ""} 386 + placeholder={placeholder} 387 + onChange={(event) => onChange(rule.id, { value: event.target.value })} 388 + /> 389 + )} 390 + </label> 391 + ) : ( 392 + <div className="grid gap-2 text-sm text-[var(--muted)]"> 393 + <span className="font-medium text-[var(--ink)]">{t("builder.branchWhenAnswer")}</span> 394 + <div className="flex h-10 items-center rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] px-3 text-sm text-[var(--muted)]"> 395 + {t("builder.branchNoValueNeeded")} 396 + </div> 397 + </div> 398 + )} 399 + 400 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 401 + <span className="font-medium text-[var(--ink)]">{t("builder.branchGoTo")}</span> 402 + <Select value={rule.targetBlockId} onValueChange={(value) => onChange(rule.id, { targetBlockId: value })}> 403 + <SelectTrigger className="h-10 w-full text-sm font-medium"> 404 + <SelectValue /> 405 + </SelectTrigger> 406 + <SelectContent> 407 + {staleTargetMissing ? ( 408 + <SelectItem value={rule.targetBlockId}>{t("builder.branchMissingTarget")}</SelectItem> 409 + ) : null} 410 + {targetOptions.map((targetBlock) => ( 411 + <SelectItem key={targetBlock.id} value={targetBlock.id}> 412 + {getBlockDisplayLabel(targetBlock)} 413 + </SelectItem> 414 + ))} 415 + </SelectContent> 416 + </Select> 417 + </label> 418 + </div> 419 + <button 420 + type="button" 421 + className="inline-flex size-10 shrink-0 items-center justify-center rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--muted)] transition hover:bg-[var(--danger-hover)] hover:text-[var(--danger-contrast)] hover:border-[var(--danger-hover)]" 422 + aria-label={t("builder.removeBranchRuleAria", { number: index + 1 })} 423 + onClick={() => onRemove(rule.id)} 424 + > 425 + <Trash2 className="size-4" /> 426 + </button> 427 + </div> 428 + </div> 429 + ); 430 + } 431 + 253 432 export function BlockEditorPanel({ 433 + allBlocks, 254 434 blockDraft, 255 435 selectedBlockIcon: SelectedBlockIcon, 256 436 choiceOptionsDraft, 437 + branchRulesDraft, 438 + branchValidationIssues, 257 439 setChoiceOptionsDraft, 440 + setBranchRulesDraft, 258 441 setBlockDraft, 259 442 deleteBlock, 260 443 saveBlock, 261 444 busy, 262 445 }: { 446 + allBlocks: BuilderBlock[]; 263 447 blockDraft: BuilderBlock; 264 448 selectedBlockIcon: LucideIcon | null; 265 449 choiceOptionsDraft: ChoiceOptionDraft[]; 450 + branchRulesDraft: BranchRuleDraft[]; 451 + branchValidationIssues: BranchValidationIssue[]; 266 452 setChoiceOptionsDraft: Dispatch<SetStateAction<ChoiceOptionDraft[]>>; 453 + setBranchRulesDraft: Dispatch<SetStateAction<BranchRuleDraft[]>>; 267 454 setBlockDraft: Dispatch<SetStateAction<BuilderBlock | null>>; 268 455 deleteBlock: (blockId: string) => void; 269 456 saveBlock: () => void; ··· 271 458 }) { 272 459 const { t } = useI18n(); 273 460 const optionSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); 461 + const branchRuleSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); 462 + const branchTargetOptions = getBranchTargetBlocks(allBlocks, blockDraft.id); 463 + const blockBranchIssues = branchValidationIssues.filter((issue) => issue.blockId === blockDraft.id); 464 + const blockBranchBlockers = blockBranchIssues.filter((issue) => issue.severity === "blocker"); 465 + const blockBranchWarnings = blockBranchIssues.filter((issue) => issue.severity === "warning"); 466 + const branchRulePlaceholder = getBranchRuleValueHint(blockDraft); 467 + const defaultNextBlockId = "defaultNextBlockId" in blockDraft.config ? blockDraft.config.defaultNextBlockId : null; 468 + const defaultNextSelectValue = defaultNextBlockId ?? "__linear__"; 469 + const staleDefaultNextMissing = Boolean( 470 + defaultNextBlockId && !branchTargetOptions.some((targetBlock) => targetBlock.id === defaultNextBlockId), 471 + ); 274 472 275 473 function updateConfig(patch: Record<string, unknown>) { 276 474 setBlockDraft((current) => ··· 326 524 } 327 525 328 526 syncChoiceOptions(arrayMove(choiceOptionsDraft, oldIndex, newIndex)); 527 + } 528 + 529 + function syncBranchRules(nextRules: BranchRuleDraft[]) { 530 + setBranchRulesDraft(nextRules); 531 + updateConfig({ 532 + branchRules: nextRules.map((rule) => ({ 533 + operator: rule.operator, 534 + value: rule.value, 535 + targetBlockId: rule.targetBlockId, 536 + })), 537 + }); 538 + } 539 + 540 + function updateBranchRule(ruleId: string, patch: Partial<Pick<BranchRuleDraft, "operator" | "value" | "targetBlockId">>) { 541 + syncBranchRules(branchRulesDraft.map((rule) => (rule.id === ruleId ? { ...rule, ...patch } : rule))); 542 + } 543 + 544 + function addBranchRule() { 545 + const firstTarget = branchTargetOptions[0]; 546 + 547 + if (!firstTarget) { 548 + return; 549 + } 550 + 551 + syncBranchRules([ 552 + ...branchRulesDraft, 553 + { 554 + id: crypto.randomUUID(), 555 + operator: getVisibleBranchOperators(blockDraft.type)[0] ?? "equals", 556 + value: blockDraft.type === "AGREEMENT" ? AGREEMENT_ANSWER_VALUES.AGREED : null, 557 + targetBlockId: firstTarget.id, 558 + }, 559 + ]); 560 + } 561 + 562 + function removeBranchRule(ruleId: string) { 563 + syncBranchRules(branchRulesDraft.filter((rule) => rule.id !== ruleId)); 564 + } 565 + 566 + function handleBranchRuleDragEnd(event: DragEndEvent) { 567 + const { active, over } = event; 568 + 569 + if (!over || active.id === over.id) { 570 + return; 571 + } 572 + 573 + const oldIndex = branchRulesDraft.findIndex((rule) => rule.id === active.id); 574 + const newIndex = branchRulesDraft.findIndex((rule) => rule.id === over.id); 575 + 576 + if (oldIndex < 0 || newIndex < 0) { 577 + return; 578 + } 579 + 580 + syncBranchRules(arrayMove(branchRulesDraft, oldIndex, newIndex)); 329 581 } 330 582 331 583 return ( ··· 488 740 <p className="text-xs text-[var(--muted)]">{t("builder.optionsHelp")}</p> 489 741 </div> 490 742 )} 743 + 744 + {isQuestionBlock(blockDraft.type) ? ( 745 + <div className="grid gap-4 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-4 text-sm text-[var(--muted)]"> 746 + <div className="flex items-center justify-between gap-3"> 747 + <div> 748 + <p className="font-medium text-[var(--ink)]">{t("builder.branchingTitle")}</p> 749 + <p className="mt-1 text-xs text-[var(--muted)]">{t("builder.branchingDescription")}</p> 750 + </div> 751 + <Button variant="secondary" size="sm" onClick={addBranchRule} disabled={branchTargetOptions.length === 0}> 752 + <Plus className="size-4" /> 753 + {t("builder.addBranchRule")} 754 + </Button> 755 + </div> 756 + 757 + {branchRulesDraft.length ? ( 758 + <DndContext sensors={branchRuleSensors} collisionDetection={closestCenter} onDragEnd={handleBranchRuleDragEnd}> 759 + <SortableContext items={branchRulesDraft.map((rule) => rule.id)} strategy={verticalListSortingStrategy}> 760 + <div className="grid gap-3"> 761 + {branchRulesDraft.map((rule, index) => ( 762 + <SortableBranchRuleRow 763 + key={rule.id} 764 + block={blockDraft} 765 + rule={rule} 766 + index={index} 767 + placeholder={branchRulePlaceholder} 768 + targetOptions={branchTargetOptions} 769 + onChange={updateBranchRule} 770 + onRemove={removeBranchRule} 771 + /> 772 + ))} 773 + </div> 774 + </SortableContext> 775 + </DndContext> 776 + ) : null} 777 + 778 + <label className="grid gap-2 text-sm text-[var(--muted)] sm:max-w-md"> 779 + <span className="font-medium text-[var(--ink)]">{t("builder.branchOtherwise")}</span> 780 + <Select value={defaultNextSelectValue} onValueChange={(value) => updateConfig({ defaultNextBlockId: value === "__linear__" ? null : value })}> 781 + <SelectTrigger className="h-10 w-full text-sm font-medium"> 782 + <SelectValue /> 783 + </SelectTrigger> 784 + <SelectContent> 785 + <SelectItem value="__linear__">{t("builder.branchDefaultLinear")}</SelectItem> 786 + {staleDefaultNextMissing && defaultNextBlockId ? ( 787 + <SelectItem value={defaultNextBlockId}>{t("builder.branchMissingTarget")}</SelectItem> 788 + ) : null} 789 + {branchTargetOptions.map((targetBlock) => ( 790 + <SelectItem key={targetBlock.id} value={targetBlock.id}> 791 + {getBlockDisplayLabel(targetBlock)} 792 + </SelectItem> 793 + ))} 794 + </SelectContent> 795 + </Select> 796 + </label> 797 + 798 + {branchTargetOptions.length === 0 ? ( 799 + <p className="text-xs text-[var(--muted)]">{t("builder.branchingNoTargets")}</p> 800 + ) : null} 801 + <p className="text-xs text-[var(--muted)]">{t("builder.branchingHelp", { value: branchRulePlaceholder })}</p> 802 + 803 + {blockBranchBlockers.length ? ( 804 + <div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-3 text-sm text-[var(--ink)]"> 805 + <p className="font-medium">{t("builder.branchingBlockersForBlockTitle")}</p> 806 + <ul className="mt-2 list-disc space-y-1 pl-5 text-[var(--muted)]"> 807 + {blockBranchBlockers.map((issue, index) => { 808 + const issueI18n = getBranchValidationIssueI18n(issue, allBlocks); 809 + return <li key={`${issue.code}-${issue.ruleIndex ?? index}`}>{t(issueI18n.key, issueI18n.values)}</li>; 810 + })} 811 + </ul> 812 + </div> 813 + ) : null} 814 + 815 + {blockBranchWarnings.length ? ( 816 + <div className="rounded-xl border border-sky-500/30 bg-sky-500/10 px-3 py-3 text-sm text-[var(--ink)]"> 817 + <p className="font-medium">{t("builder.branchingWarningsForBlockTitle")}</p> 818 + <ul className="mt-2 list-disc space-y-1 pl-5 text-[var(--muted)]"> 819 + {blockBranchWarnings.map((issue, index) => { 820 + const issueI18n = getBranchValidationIssueI18n(issue, allBlocks); 821 + return <li key={`${issue.code}-${issue.ruleIndex ?? index}`}>{t(issueI18n.key, issueI18n.values)}</li>; 822 + })} 823 + </ul> 824 + </div> 825 + ) : null} 826 + </div> 827 + ) : null} 491 828 492 829 {isQuestionBlock(blockDraft.type) ? ( 493 830 <label className="flex items-center gap-3 rounded-xl bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]">
+80 -5
components/form-builder.tsx
··· 44 44 import { Button } from "@/components/ui/button"; 45 45 import { ConfirmDialog } from "@/components/ui/confirm-dialog"; 46 46 import { ToastViewport, type ToastData } from "@/components/ui/toast"; 47 - import { blockTypeTranslationKeys, getBlockPreview, type ChoiceBlockConfig } from "@/lib/blocks"; 47 + import { 48 + getBranchRules, 49 + isQuestionBlock, 50 + blockTypeTranslationKeys, 51 + getBlockPreview, 52 + type BranchRule, 53 + type ChoiceBlockConfig, 54 + } from "@/lib/blocks"; 55 + import { analyzeBranchingGraph, getBranchValidationIssueI18n } from "@/lib/branching"; 48 56 import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 49 57 import { cn } from "@/lib/utils"; 50 58 51 59 type BlockType = BuilderBlock["type"]; 52 60 61 + type BranchRuleDraft = BranchRule & { 62 + id: string; 63 + }; 64 + 53 65 type Selection = { kind: "form" } | { kind: "block"; blockId: string }; 54 66 55 67 const blockIcons: Record<BlockType, typeof Type> = { ··· 80 92 return options.map((value) => ({ id: crypto.randomUUID(), value })); 81 93 } 82 94 95 + function createBranchRuleDrafts(rules: BranchRule[]): BranchRuleDraft[] { 96 + return rules.map((rule) => ({ id: crypto.randomUUID(), ...rule })); 97 + } 98 + 83 99 async function fetchJson<T>(url: string, init?: RequestInit) { 84 100 const response = await fetch(url, { 85 101 ...init, ··· 144 160 transition, 145 161 }} 146 162 className={cn( 147 - "group flex items-center gap-2 rounded-[14px] px-2.5 py-2 transition", 163 + "group flex items-center gap-2 rounded-[14px] px-2.5 py-2", 148 164 selected 149 165 ? "bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)]" 150 166 : "hover:bg-[var(--surface)]", ··· 184 200 return ( 185 201 <div 186 202 className={cn( 187 - "group flex items-center gap-2 rounded-[14px] px-2.5 py-2 transition", 203 + "group flex items-center gap-2 rounded-[14px] px-2.5 py-2", 188 204 selected 189 205 ? "bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)]" 190 206 : "hover:bg-[var(--surface)]", ··· 239 255 ? createChoiceOptionDrafts((selectedBlock.config as ChoiceBlockConfig).options) 240 256 : [], 241 257 ); 258 + const [branchRulesDraft, setBranchRulesDraft] = useState<BranchRuleDraft[]>( 259 + selectedBlock && isQuestionBlock(selectedBlock.type) 260 + ? createBranchRuleDrafts(getBranchRules(selectedBlock.config)) 261 + : [], 262 + ); 242 263 const SelectedBlockIcon = blockDraft ? blockIcons[blockDraft.type] : null; 243 264 244 265 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); 266 + const branchingAnalysis = useMemo(() => analyzeBranchingGraph(form.blocks), [form.blocks]); 245 267 246 268 useEffect(() => { 247 269 setMetadataDraft({ ··· 259 281 setChoiceOptionsDraft( 260 282 selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE") 261 283 ? createChoiceOptionDrafts((selectedBlock.config as ChoiceBlockConfig).options) 284 + : [], 285 + ); 286 + setBranchRulesDraft( 287 + selectedBlock && isQuestionBlock(selectedBlock.type) 288 + ? createBranchRuleDrafts(getBranchRules(selectedBlock.config)) 262 289 : [], 263 290 ); 264 291 }, [selectedBlock]); ··· 339 366 } 340 367 341 368 await withTask(`block-${blockDraft.id}`, async () => { 369 + const serializedBranchRules = branchRulesDraft.map((rule) => ({ 370 + operator: rule.operator, 371 + value: rule.value, 372 + targetBlockId: rule.targetBlockId, 373 + })); 342 374 const payload = await fetchJson<{ block: BuilderBlock }>(`/api/forms/${form.id}/blocks/${blockDraft.id}`, { 343 375 method: "PATCH", 344 376 body: JSON.stringify({ ··· 347 379 required: blockDraft.required, 348 380 config: 349 381 blockDraft.type === "SINGLE_CHOICE" || blockDraft.type === "MULTIPLE_CHOICE" 350 - ? { options: choiceOptionsDraft.map((option) => option.value) } 351 - : blockDraft.config, 382 + ? { 383 + ...blockDraft.config, 384 + options: choiceOptionsDraft.map((option) => option.value), 385 + branchRules: serializedBranchRules, 386 + } 387 + : isQuestionBlock(blockDraft.type) 388 + ? { 389 + ...blockDraft.config, 390 + branchRules: serializedBranchRules, 391 + } 392 + : blockDraft.config, 352 393 }), 353 394 }); 354 395 ··· 473 514 onTogglePublished={togglePublished} 474 515 /> 475 516 517 + {branchingAnalysis.blockers.length ? ( 518 + <div className="rounded-[18px] border border-amber-500/30 bg-amber-500/10 px-4 py-4 text-sm text-[var(--ink)]"> 519 + <p className="font-medium">{t("builder.branchingBlockersTitle", { count: branchingAnalysis.blockers.length })}</p> 520 + <p className="mt-1 text-[var(--muted)]">{t("builder.branchingBlockersDescription")}</p> 521 + <ul className="mt-3 list-disc space-y-1 pl-5 text-[var(--muted)]"> 522 + {branchingAnalysis.blockers.slice(0, 5).map((issue, index) => { 523 + const issueI18n = getBranchValidationIssueI18n(issue, form.blocks); 524 + return ( 525 + <li key={`${issue.blockId}-${issue.code}-${issue.ruleIndex ?? index}`}>{t(issueI18n.key, issueI18n.values)}</li> 526 + ); 527 + })} 528 + </ul> 529 + </div> 530 + ) : null} 531 + 532 + {branchingAnalysis.warnings.length ? ( 533 + <div className="rounded-[18px] border border-sky-500/30 bg-sky-500/10 px-4 py-4 text-sm text-[var(--ink)]"> 534 + <p className="font-medium">{t("builder.branchingWarningsTitle", { count: branchingAnalysis.warnings.length })}</p> 535 + <p className="mt-1 text-[var(--muted)]">{t("builder.branchingWarningsDescription")}</p> 536 + <ul className="mt-3 list-disc space-y-1 pl-5 text-[var(--muted)]"> 537 + {branchingAnalysis.warnings.slice(0, 5).map((issue, index) => { 538 + const issueI18n = getBranchValidationIssueI18n(issue, form.blocks); 539 + return ( 540 + <li key={`${issue.blockId}-${issue.code}-${issue.ruleIndex ?? index}`}>{t(issueI18n.key, issueI18n.values)}</li> 541 + ); 542 + })} 543 + </ul> 544 + </div> 545 + ) : null} 546 + 476 547 <div className="grid gap-8 lg:min-h-[calc(100vh-14rem)] lg:grid-cols-[300px_minmax(0,1fr)]"> 477 548 <aside className="border-b border-[color:var(--line)] pb-6 lg:sticky lg:top-8 lg:self-start lg:max-h-[calc(100vh-8rem)] lg:border-b-0 lg:border-r lg:pb-0 lg:pr-6"> 478 549 <div className="flex items-center justify-between gap-3"> ··· 554 625 /> 555 626 ) : blockDraft ? ( 556 627 <BlockEditorPanel 628 + allBlocks={form.blocks} 557 629 blockDraft={blockDraft} 558 630 selectedBlockIcon={SelectedBlockIcon} 559 631 choiceOptionsDraft={choiceOptionsDraft} 632 + branchRulesDraft={branchRulesDraft} 633 + branchValidationIssues={[...branchingAnalysis.blockers, ...branchingAnalysis.warnings]} 560 634 setChoiceOptionsDraft={setChoiceOptionsDraft} 635 + setBranchRulesDraft={setBranchRulesDraft} 561 636 setBlockDraft={setBlockDraft} 562 637 deleteBlock={deleteBlock} 563 638 saveBlock={saveBlock}
+28 -14
components/public-form-runner.tsx
··· 25 25 type ShortTextBlockConfig, 26 26 type TextBlockConfig, 27 27 } from "@/lib/blocks"; 28 + import { resolveNextBlockId } from "@/lib/branching"; 28 29 import { isLegacyDefaultCompletionMessage, isLegacyDefaultCompletionTitle } from "@/lib/form-defaults"; 29 30 import type { PublicForm } from "@/lib/form-types"; 30 31 import { cn } from "@/lib/utils"; ··· 48 49 } 49 50 50 51 export function PublicFormRunner({ form }: { form: PublicForm }) { 51 - const [step, setStep] = useState(0); 52 + const initialHistory = useMemo(() => (form.blocks[0] ? [form.blocks[0].id] : []), [form.blocks]); 52 53 const { t } = useI18n(); 53 54 const [answers, setAnswers] = useState<Record<string, string | string[]>>({}); 55 + const [history, setHistory] = useState<string[]>(initialHistory); 56 + const [cursor, setCursor] = useState(0); 54 57 const [toasts, setToasts] = useState<ToastData[]>([]); 55 58 const [isSubmitting, setIsSubmitting] = useState(false); 56 59 const [isComplete, setIsComplete] = useState(false); 57 60 58 - const currentBlock = form.blocks[step]; 59 - const progress = useMemo(() => ((step + 1) / form.blocks.length) * 100, [form.blocks.length, step]); 61 + const blocksById = useMemo(() => new Map(form.blocks.map((block) => [block.id, block])), [form.blocks]); 62 + const currentBlockId = history[cursor] ?? initialHistory[0] ?? null; 63 + const currentBlock = currentBlockId ? blocksById.get(currentBlockId) ?? null : null; 64 + const nextBlockId = currentBlock ? resolveNextBlockId(form.blocks, currentBlock.id, answers) : null; 65 + const visibleTotal = Math.max(cursor + 1, currentBlock && cursor === history.length - 1 && nextBlockId ? history.length + 1 : history.length || 1); 66 + const progress = useMemo(() => ((cursor + 1) / visibleTotal) * 100, [cursor, visibleTotal]); 60 67 const completionTitle = 61 68 !form.completionTitle.trim() || isLegacyDefaultCompletionTitle(form.completionTitle) 62 69 ? t("publicRunner.defaultCompletionTitle") ··· 241 248 }, [answers, currentBlock, showToast, t]); 242 249 243 250 const handleContinue = useCallback(async (answerSet: Record<string, string | string[]> = answers) => { 244 - if (!validateStep(answerSet)) { 251 + if (!validateStep(answerSet) || !currentBlock) { 245 252 return; 246 253 } 247 254 248 - if (step === form.blocks.length - 1) { 255 + const resolvedNextBlockId = resolveNextBlockId(form.blocks, currentBlock.id, answerSet); 256 + 257 + if (!resolvedNextBlockId) { 249 258 try { 250 259 setIsSubmitting(true); 251 260 await submitResponse(form.slug, answerSet); ··· 259 268 return; 260 269 } 261 270 262 - setStep((current) => current + 1); 263 - }, [answers, form.blocks.length, form.slug, showToast, step, t, validateStep]); 271 + setHistory((current) => [...current.slice(0, cursor + 1), resolvedNextBlockId]); 272 + setCursor((current) => current + 1); 273 + }, [answers, currentBlock, cursor, form.blocks, form.slug, showToast, t, validateStep]); 264 274 265 275 const handleBack = useCallback(() => { 266 - setStep((current) => Math.max(0, current - 1)); 276 + setCursor((current) => Math.max(0, current - 1)); 267 277 }, []); 268 278 269 279 function handleAdvanceKeyDown( ··· 286 296 event: ReactKeyboardEvent<HTMLButtonElement>, 287 297 nextAnswer: string | string[], 288 298 ) { 289 - if (event.key !== "Enter" || event.nativeEvent.isComposing || isSubmitting) { 299 + if (event.key !== "Enter" || event.nativeEvent.isComposing || isSubmitting || !currentBlock) { 290 300 return; 291 301 } 292 302 ··· 327 337 return; 328 338 } 329 339 330 - if (event.key === "Escape" && step > 0) { 340 + if (event.key === "Escape" && cursor > 0) { 331 341 event.preventDefault(); 332 342 handleBack(); 333 343 } ··· 335 345 336 346 window.addEventListener("keydown", handleWindowKeyDown); 337 347 return () => window.removeEventListener("keydown", handleWindowKeyDown); 338 - }, [currentBlock, handleBack, handleContinue, isComplete, isSubmitting, step]); 348 + }, [currentBlock, cursor, handleBack, handleContinue, isComplete, isSubmitting]); 339 349 340 350 if (isComplete) { 341 351 return ( ··· 363 373 ); 364 374 } 365 375 376 + if (!currentBlock) { 377 + return null; 378 + } 379 + 366 380 return ( 367 381 <> 368 382 <ToastViewport toasts={toasts} onDismiss={dismissToast} /> ··· 376 390 <div className="w-full max-w-xs shrink-0"> 377 391 <div className="flex items-center justify-between gap-3"> 378 392 <span className="inline-flex items-center rounded-full bg-[var(--accent-soft)] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-ink)]"> 379 - {t("publicRunner.step", { current: step + 1, total: form.blocks.length })} 393 + {t("publicRunner.step", { current: cursor + 1, total: visibleTotal })} 380 394 </span> 381 395 <span className="text-xs font-medium uppercase tracking-[0.2em] text-[var(--muted)]">{Math.round(progress)}%</span> 382 396 </div> ··· 601 615 </AnimatePresence> 602 616 603 617 <div className="mt-6 flex items-center justify-between gap-3 border-t border-[color:var(--line)] pt-4"> 604 - <Button variant="secondary" onClick={handleBack} disabled={step === 0 || isSubmitting}> 618 + <Button variant="secondary" onClick={handleBack} disabled={cursor === 0 || isSubmitting}> 605 619 {t("publicRunner.back")} 606 620 <span className="ml-1 inline-flex h-5 items-center rounded-md border border-current/20 bg-black/10 px-1.5 text-[10px] font-medium opacity-90 dark:bg-white/10"> 607 621 Esc ··· 609 623 </Button> 610 624 <Button onClick={() => void handleContinue()} disabled={isSubmitting}> 611 625 {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null} 612 - {step === form.blocks.length - 1 ? t("publicRunner.submit") : t("publicRunner.continue")} 626 + {!nextBlockId ? t("publicRunner.submit") : t("publicRunner.continue")} 613 627 {!isSubmitting ? ( 614 628 <span className="ml-1 inline-flex h-5 items-center gap-1 rounded-md border border-current/20 bg-black/10 px-1.5 text-[10px] opacity-90 dark:bg-white/10"> 615 629 <CornerDownLeft className="size-3" />
+4 -4
components/ui/select.tsx
··· 23 23 <SelectPrimitive.Trigger 24 24 data-slot="select-trigger" 25 25 className={cn( 26 - "flex h-9 w-full items-center justify-between gap-2 rounded-xl bg-[var(--surface-strong)] pl-3 pr-2 text-xs font-semibold text-[var(--ink)] ring-1 ring-[color:var(--line)] outline-none transition focus:ring-2 focus:ring-[var(--ink)] focus:ring-offset-2 focus:ring-offset-[var(--bg)] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", 26 + "flex h-9 w-full items-center justify-between gap-2 rounded-xl bg-[var(--surface-strong)] pl-3 pr-2 text-left text-xs font-semibold text-[var(--ink)] ring-1 ring-[color:var(--line)] outline-none transition focus:ring-2 focus:ring-[var(--ink)] focus:ring-offset-2 focus:ring-offset-[var(--bg)] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:flex-1 [&>span]:line-clamp-1 [&>span]:text-left", 27 27 className, 28 28 )} 29 29 {...props} ··· 81 81 <SelectPrimitive.Item 82 82 data-slot="select-item" 83 83 className={cn( 84 - "relative flex w-full cursor-default items-center gap-2 rounded-lg py-2 pl-8 pr-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-[var(--accent-soft)] data-[highlighted]:text-[var(--ink)]", 84 + "relative flex w-full cursor-default items-center gap-2 rounded-lg py-2 pl-3 pr-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-[var(--accent-soft)] data-[highlighted]:text-[var(--ink)]", 85 85 className, 86 86 )} 87 87 {...props} 88 88 > 89 - <span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center"> 89 + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> 90 + <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center"> 90 91 <SelectPrimitive.ItemIndicator> 91 92 <Check className="size-4" /> 92 93 </SelectPrimitive.ItemIndicator> 93 94 </span> 94 - <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> 95 95 </SelectPrimitive.Item> 96 96 ); 97 97 }
+149 -29
lib/blocks.ts
··· 144 144 message: "Use a valid number.", 145 145 }); 146 146 147 + export const AGREEMENT_ANSWER_VALUES = { 148 + AGREED: "agreed", 149 + NOT_AGREED: "not_agreed", 150 + } as const; 151 + 152 + export const branchOperatorValues = [ 153 + "equals", 154 + "not_equals", 155 + "is_empty", 156 + "is_not_empty", 157 + "contains", 158 + "contains_any", 159 + "gt", 160 + "gte", 161 + "lt", 162 + "lte", 163 + ] as const; 164 + 165 + export const branchOperatorSchema = z.enum(branchOperatorValues); 166 + 167 + function normalizeBranchRuleOperand(value: unknown) { 168 + if (typeof value !== "string") { 169 + return null; 170 + } 171 + 172 + const trimmed = value.trim(); 173 + return trimmed || null; 174 + } 175 + 176 + const rawBranchRuleSchema = z 177 + .object({ 178 + operator: branchOperatorSchema.optional(), 179 + value: z.unknown().transform(normalizeBranchRuleOperand).optional(), 180 + targetBlockId: z.string().trim().min(1), 181 + }) 182 + .transform((value) => ({ 183 + operator: value.operator ?? "equals", 184 + value: value.value ?? null, 185 + targetBlockId: value.targetBlockId, 186 + })); 187 + 188 + export const branchRuleSchema = rawBranchRuleSchema; 189 + 190 + const branchRulesSchema = z.array(branchRuleSchema).max(50).default([]); 191 + const defaultNextBlockIdSchema = z.string().trim().min(1).nullable().default(null); 192 + 147 193 const textConfigSchema = z.object({ 148 194 body: z.string().max(2400).default(defaultBlockCopyByLocale.en.textBody), 149 195 }); ··· 151 197 const shortTextConfigSchema = z.object({ 152 198 placeholder: z.string().max(120).default(defaultBlockCopyByLocale.en.shortPlaceholder), 153 199 validationRegex: regexPatternSchema.default(null), 200 + branchRules: branchRulesSchema, 201 + defaultNextBlockId: defaultNextBlockIdSchema, 154 202 }); 155 203 156 204 const longTextConfigSchema = z.object({ 157 205 placeholder: z.string().max(240).default(defaultBlockCopyByLocale.en.longPlaceholder), 158 206 validationRegex: regexPatternSchema.default(null), 207 + branchRules: branchRulesSchema, 208 + defaultNextBlockId: defaultNextBlockIdSchema, 159 209 }); 160 210 161 211 const numberConfigSchema = z ··· 164 214 allowFloat: z.boolean().default(false), 165 215 min: numericLimitSchema.default(null), 166 216 max: numericLimitSchema.default(null), 217 + branchRules: branchRulesSchema, 218 + defaultNextBlockId: defaultNextBlockIdSchema, 167 219 }) 168 220 .superRefine((value, context) => { 169 221 if (!value.allowFloat) { ··· 195 247 196 248 const linkConfigSchema = z.object({ 197 249 placeholder: z.string().max(2048).default(defaultBlockCopyByLocale.en.linkPlaceholder), 250 + branchRules: branchRulesSchema, 251 + defaultNextBlockId: defaultNextBlockIdSchema, 198 252 }); 199 253 200 254 const agreementConfigSchema = z.object({ 201 255 label: z.string().trim().max(160).default(defaultBlockCopyByLocale.en.agreementLabel), 256 + branchRules: branchRulesSchema, 257 + defaultNextBlockId: defaultNextBlockIdSchema, 202 258 }); 203 259 204 - const dateConfigSchema = z.object({}); 260 + const dateConfigSchema = z.object({ 261 + branchRules: branchRulesSchema, 262 + defaultNextBlockId: defaultNextBlockIdSchema, 263 + }); 205 264 206 265 const optionListSchema = z.object({ 207 266 options: z.array(z.string().min(1).max(120)).min(2).max(10).default(defaultBlockCopyByLocale.en.options), 267 + branchRules: branchRulesSchema, 268 + defaultNextBlockId: defaultNextBlockIdSchema, 208 269 }); 209 270 210 - export const AGREEMENT_ANSWER_VALUES = { 211 - AGREED: "agreed", 212 - NOT_AGREED: "not_agreed", 213 - } as const; 214 - 215 271 export type AgreementAnswerValue = (typeof AGREEMENT_ANSWER_VALUES)[keyof typeof AGREEMENT_ANSWER_VALUES]; 272 + export type BranchOperator = z.infer<typeof branchOperatorSchema>; 273 + export type BranchRule = z.infer<typeof branchRuleSchema>; 216 274 export type TextBlockConfig = z.infer<typeof textConfigSchema>; 217 275 export type ShortTextBlockConfig = z.infer<typeof shortTextConfigSchema>; 218 276 export type LongTextBlockConfig = z.infer<typeof longTextConfigSchema>; ··· 222 280 export type DateBlockConfig = z.infer<typeof dateConfigSchema>; 223 281 export type ChoiceBlockConfig = z.infer<typeof optionListSchema>; 224 282 export type TextAnswerBlockConfig = ShortTextBlockConfig | LongTextBlockConfig; 225 - export type BlockConfig = 226 - | TextBlockConfig 283 + export type AnswerableBlockConfig = 227 284 | ShortTextBlockConfig 228 285 | LongTextBlockConfig 229 286 | NumberBlockConfig ··· 231 288 | AgreementBlockConfig 232 289 | DateBlockConfig 233 290 | ChoiceBlockConfig; 291 + export type BlockConfig = TextBlockConfig | AnswerableBlockConfig; 234 292 235 293 export type SerializedBlock = Omit<FormBlock, "config"> & { 236 294 config: BlockConfig; ··· 285 343 return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; 286 344 } 287 345 288 - export function getDefaultBlockConfig(type: PrismaFormBlockType, locale: AppLocale = DEFAULT_LOCALE): BlockConfig { 289 - const copy = getDefaultBlockCopy(locale); 346 + export function getSupportedBranchOperators(type: PrismaFormBlockType): BranchOperator[] { 347 + if (type === FORM_BLOCK_TYPES.TEXT) { 348 + return []; 349 + } 350 + 351 + const common: BranchOperator[] = ["equals", "not_equals", "is_empty", "is_not_empty"]; 352 + 353 + if (type === FORM_BLOCK_TYPES.SHORT_TEXT || type === FORM_BLOCK_TYPES.LONG_TEXT || type === FORM_BLOCK_TYPES.LINK) { 354 + return [...common, "contains"]; 355 + } 290 356 357 + if (type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE) { 358 + return [...common, "contains_any"]; 359 + } 360 + 361 + if (type === FORM_BLOCK_TYPES.NUMBER || type === FORM_BLOCK_TYPES.DATE) { 362 + return [...common, "gt", "gte", "lt", "lte"]; 363 + } 364 + 365 + return common; 366 + } 367 + 368 + export function getVisibleBranchOperators(type: PrismaFormBlockType): BranchOperator[] { 369 + if (type === FORM_BLOCK_TYPES.TEXT) { 370 + return []; 371 + } 372 + 373 + if (type === FORM_BLOCK_TYPES.SHORT_TEXT || type === FORM_BLOCK_TYPES.LONG_TEXT || type === FORM_BLOCK_TYPES.LINK) { 374 + return ["is_empty", "is_not_empty", "contains"]; 375 + } 376 + 377 + if (type === FORM_BLOCK_TYPES.SINGLE_CHOICE) { 378 + return ["equals", "not_equals", "is_empty", "is_not_empty"]; 379 + } 380 + 381 + if (type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE) { 382 + return ["contains_any", "is_empty", "is_not_empty"]; 383 + } 384 + 385 + if (type === FORM_BLOCK_TYPES.NUMBER || type === FORM_BLOCK_TYPES.DATE) { 386 + return ["equals", "not_equals", "gt", "gte", "lt", "lte", "is_empty", "is_not_empty"]; 387 + } 388 + 389 + if (type === FORM_BLOCK_TYPES.AGREEMENT) { 390 + return ["equals", "not_equals"]; 391 + } 392 + 393 + return ["equals", "not_equals"]; 394 + } 395 + 396 + export function branchOperatorNeedsValue(operator: BranchOperator) { 397 + return operator !== "is_empty" && operator !== "is_not_empty"; 398 + } 399 + 400 + export function parseBlockConfig(type: PrismaFormBlockType, value: unknown): BlockConfig { 291 401 switch (type) { 292 402 case FORM_BLOCK_TYPES.TEXT: 293 - return textConfigSchema.parse({ body: copy.textBody }); 403 + return textConfigSchema.parse(value ?? {}); 294 404 case FORM_BLOCK_TYPES.SHORT_TEXT: 295 - return shortTextConfigSchema.parse({ placeholder: copy.shortPlaceholder }); 405 + return shortTextConfigSchema.parse(value ?? {}); 296 406 case FORM_BLOCK_TYPES.LONG_TEXT: 297 - return longTextConfigSchema.parse({ placeholder: copy.longPlaceholder }); 407 + return longTextConfigSchema.parse(value ?? {}); 298 408 case FORM_BLOCK_TYPES.SINGLE_CHOICE: 299 409 case FORM_BLOCK_TYPES.MULTIPLE_CHOICE: 300 - return optionListSchema.parse({ options: copy.options }); 410 + return optionListSchema.parse(value ?? {}); 301 411 case FORM_BLOCK_TYPES.NUMBER: 302 - return numberConfigSchema.parse({ placeholder: copy.numberPlaceholder }); 412 + return numberConfigSchema.parse(value ?? {}); 303 413 case FORM_BLOCK_TYPES.LINK: 304 - return linkConfigSchema.parse({ placeholder: copy.linkPlaceholder }); 414 + return linkConfigSchema.parse(value ?? {}); 305 415 case FORM_BLOCK_TYPES.AGREEMENT: 306 - return agreementConfigSchema.parse({ label: copy.agreementLabel }); 416 + return agreementConfigSchema.parse(value ?? {}); 307 417 case FORM_BLOCK_TYPES.DATE: 308 - return dateConfigSchema.parse({}); 418 + return dateConfigSchema.parse(value ?? {}); 309 419 default: 310 - return textConfigSchema.parse({ body: copy.textBody }); 420 + return textConfigSchema.parse(value ?? {}); 311 421 } 312 422 } 313 423 314 - export function parseBlockConfig(type: PrismaFormBlockType, value: unknown): BlockConfig { 424 + export function getDefaultBlockConfig(type: PrismaFormBlockType, locale: AppLocale = DEFAULT_LOCALE): BlockConfig { 425 + const copy = getDefaultBlockCopy(locale); 426 + 315 427 switch (type) { 316 428 case FORM_BLOCK_TYPES.TEXT: 317 - return textConfigSchema.parse(value ?? {}); 429 + return textConfigSchema.parse({ body: copy.textBody }); 318 430 case FORM_BLOCK_TYPES.SHORT_TEXT: 319 - return shortTextConfigSchema.parse(value ?? {}); 431 + return shortTextConfigSchema.parse({ placeholder: copy.shortPlaceholder }); 320 432 case FORM_BLOCK_TYPES.LONG_TEXT: 321 - return longTextConfigSchema.parse(value ?? {}); 433 + return longTextConfigSchema.parse({ placeholder: copy.longPlaceholder }); 322 434 case FORM_BLOCK_TYPES.SINGLE_CHOICE: 323 435 case FORM_BLOCK_TYPES.MULTIPLE_CHOICE: 324 - return optionListSchema.parse(value ?? {}); 436 + return optionListSchema.parse({ options: copy.options }); 325 437 case FORM_BLOCK_TYPES.NUMBER: 326 - return numberConfigSchema.parse(value ?? {}); 438 + return numberConfigSchema.parse({ placeholder: copy.numberPlaceholder }); 327 439 case FORM_BLOCK_TYPES.LINK: 328 - return linkConfigSchema.parse(value ?? {}); 440 + return linkConfigSchema.parse({ placeholder: copy.linkPlaceholder }); 329 441 case FORM_BLOCK_TYPES.AGREEMENT: 330 - return agreementConfigSchema.parse(value ?? {}); 442 + return agreementConfigSchema.parse({ label: copy.agreementLabel }); 331 443 case FORM_BLOCK_TYPES.DATE: 332 - return dateConfigSchema.parse(value ?? {}); 444 + return dateConfigSchema.parse({}); 333 445 default: 334 - return textConfigSchema.parse(value ?? {}); 446 + return textConfigSchema.parse({ body: copy.textBody }); 335 447 } 448 + } 449 + 450 + export function getBranchRules(config: BlockConfig): BranchRule[] { 451 + return "branchRules" in config ? config.branchRules : []; 452 + } 453 + 454 + export function getDefaultNextBlockId(config: BlockConfig) { 455 + return "defaultNextBlockId" in config ? config.defaultNextBlockId : null; 336 456 } 337 457 338 458 export function serializeBlock(block: FormBlock): SerializedBlock {
+740
lib/branching.ts
··· 1 + import { 2 + AGREEMENT_ANSWER_VALUES, 3 + branchOperatorNeedsValue, 4 + getBranchRules, 5 + getDefaultNextBlockId, 6 + getSupportedBranchOperators, 7 + isAgreementAnswerValue, 8 + isQuestionBlock, 9 + isValidDateAnswer, 10 + isValidLinkAnswer, 11 + parseNumericAnswer, 12 + type AgreementBlockConfig, 13 + type BranchOperator, 14 + type BranchRule, 15 + type ChoiceBlockConfig, 16 + type SerializedBlock, 17 + } from "@/lib/blocks"; 18 + 19 + export type BranchValidationIssueSeverity = "blocker" | "warning"; 20 + 21 + export type BranchValidationIssueCode = 22 + | "invalid-target" 23 + | "backward-target" 24 + | "invalid-default-target" 25 + | "backward-default-target" 26 + | "invalid-condition" 27 + | "duplicate-condition" 28 + | "unsupported-operator" 29 + | "unreachable-block" 30 + | "fragile-text-match" 31 + | "overlapping-rule" 32 + | "partial-choice-coverage"; 33 + 34 + export type BranchValidationIssue = { 35 + severity: BranchValidationIssueSeverity; 36 + code: BranchValidationIssueCode; 37 + message: string; 38 + blockId: string; 39 + ruleIndex?: number; 40 + targetBlockId?: string; 41 + value?: string | null; 42 + }; 43 + 44 + export type BranchValidationIssueI18n = { 45 + key: string; 46 + values: Record<string, string | number>; 47 + }; 48 + 49 + export type BranchValidationAnalysis = { 50 + blockers: BranchValidationIssue[]; 51 + warnings: BranchValidationIssue[]; 52 + }; 53 + 54 + function compareDateValues(left: string, right: string) { 55 + return left.localeCompare(right); 56 + } 57 + 58 + export function getBlockDisplayLabel(block: Pick<SerializedBlock, "position" | "title" | "type">) { 59 + const title = block.title.trim(); 60 + return title || `${isQuestionBlock(block.type) ? "Question" : "Block"} ${block.position + 1}`; 61 + } 62 + 63 + export function getBranchValidationIssueI18n(issue: BranchValidationIssue, blocks: SerializedBlock[]): BranchValidationIssueI18n { 64 + const sourceBlock = blocks.find((block) => block.id === issue.blockId); 65 + const targetBlock = issue.targetBlockId ? blocks.find((block) => block.id === issue.targetBlockId) : null; 66 + const blockLabel = sourceBlock ? getBlockDisplayLabel(sourceBlock) : issue.blockId; 67 + const targetLabel = targetBlock ? getBlockDisplayLabel(targetBlock) : ""; 68 + 69 + return { 70 + key: `branching.issueMessages.${issue.code}`, 71 + values: { 72 + blockLabel, 73 + targetLabel, 74 + value: issue.value ?? "", 75 + }, 76 + }; 77 + } 78 + 79 + export function normalizeBranchRuleOperand(block: SerializedBlock, rule: BranchRule) { 80 + if (!branchOperatorNeedsValue(rule.operator)) { 81 + return null; 82 + } 83 + 84 + const trimmed = rule.value?.trim() ?? ""; 85 + 86 + if (!trimmed) { 87 + return null; 88 + } 89 + 90 + if (block.type === "SHORT_TEXT" || block.type === "LONG_TEXT") { 91 + return trimmed; 92 + } 93 + 94 + if (block.type === "SINGLE_CHOICE" || block.type === "MULTIPLE_CHOICE") { 95 + const options = (block.config as ChoiceBlockConfig).options; 96 + return options.includes(trimmed) ? trimmed : null; 97 + } 98 + 99 + if (block.type === "NUMBER") { 100 + const parsed = parseNumericAnswer(trimmed); 101 + return parsed === null ? null : String(parsed); 102 + } 103 + 104 + if (block.type === "LINK") { 105 + return isValidLinkAnswer(trimmed) ? new URL(trimmed).toString() : trimmed; 106 + } 107 + 108 + if (block.type === "DATE") { 109 + return isValidDateAnswer(trimmed) ? trimmed : null; 110 + } 111 + 112 + if (block.type === "AGREEMENT") { 113 + return isAgreementAnswerValue(trimmed) ? trimmed : null; 114 + } 115 + 116 + return null; 117 + } 118 + 119 + function normalizeComparableAnswer(block: SerializedBlock, answer: string | string[] | undefined) { 120 + if (!isQuestionBlock(block.type)) { 121 + return undefined; 122 + } 123 + 124 + if (block.type === "MULTIPLE_CHOICE") { 125 + if (!Array.isArray(answer)) { 126 + return undefined; 127 + } 128 + 129 + const options = (block.config as ChoiceBlockConfig).options; 130 + const normalized = [...new Set(answer.map((value) => value.trim()).filter((value) => options.includes(value)))]; 131 + return normalized.length ? normalized : undefined; 132 + } 133 + 134 + if (typeof answer !== "string") { 135 + return undefined; 136 + } 137 + 138 + const trimmed = answer.trim(); 139 + 140 + if (!trimmed) { 141 + return undefined; 142 + } 143 + 144 + if (block.type === "SHORT_TEXT" || block.type === "LONG_TEXT") { 145 + return trimmed; 146 + } 147 + 148 + if (block.type === "SINGLE_CHOICE") { 149 + return trimmed; 150 + } 151 + 152 + if (block.type === "NUMBER") { 153 + const parsed = parseNumericAnswer(trimmed); 154 + return parsed === null ? undefined : String(parsed); 155 + } 156 + 157 + if (block.type === "LINK") { 158 + return isValidLinkAnswer(trimmed) ? new URL(trimmed).toString() : undefined; 159 + } 160 + 161 + if (block.type === "DATE") { 162 + return isValidDateAnswer(trimmed) ? trimmed : undefined; 163 + } 164 + 165 + if (block.type === "AGREEMENT") { 166 + return isAgreementAnswerValue(trimmed) ? trimmed : undefined; 167 + } 168 + 169 + return undefined; 170 + } 171 + 172 + function isRuleSupportedForBlock(block: SerializedBlock, operator: BranchOperator) { 173 + return getSupportedBranchOperators(block.type).includes(operator); 174 + } 175 + 176 + function isValidForwardTarget(blocks: SerializedBlock[], sourceIndex: number, targetBlockId: string) { 177 + const targetIndex = blocks.findIndex((block) => block.id === targetBlockId); 178 + return targetIndex > sourceIndex; 179 + } 180 + 181 + function compareRuleAgainstAnswer( 182 + block: SerializedBlock, 183 + operator: BranchOperator, 184 + answer: string | string[] | undefined, 185 + operand: string | null, 186 + ) { 187 + const normalizedAnswer = normalizeComparableAnswer(block, answer); 188 + const isEmpty = typeof normalizedAnswer === "undefined" || (Array.isArray(normalizedAnswer) && normalizedAnswer.length === 0); 189 + 190 + switch (operator) { 191 + case "is_empty": 192 + return isEmpty; 193 + case "is_not_empty": 194 + return !isEmpty; 195 + case "equals": 196 + if (!operand || typeof normalizedAnswer === "undefined") { 197 + return false; 198 + } 199 + 200 + return Array.isArray(normalizedAnswer) ? normalizedAnswer.includes(operand) : normalizedAnswer === operand; 201 + case "not_equals": 202 + if (!operand || typeof normalizedAnswer === "undefined") { 203 + return false; 204 + } 205 + 206 + return Array.isArray(normalizedAnswer) ? !normalizedAnswer.includes(operand) : normalizedAnswer !== operand; 207 + case "contains": 208 + return typeof normalizedAnswer === "string" && typeof operand === "string" ? normalizedAnswer.includes(operand) : false; 209 + case "contains_any": 210 + return Array.isArray(normalizedAnswer) && typeof operand === "string" ? normalizedAnswer.includes(operand) : false; 211 + case "gt": 212 + case "gte": 213 + case "lt": 214 + case "lte": { 215 + if (!operand || typeof normalizedAnswer !== "string") { 216 + return false; 217 + } 218 + 219 + if (block.type === "NUMBER") { 220 + const answerNumber = Number(normalizedAnswer); 221 + const operandNumber = Number(operand); 222 + 223 + if (!Number.isFinite(answerNumber) || !Number.isFinite(operandNumber)) { 224 + return false; 225 + } 226 + 227 + if (operator === "gt") return answerNumber > operandNumber; 228 + if (operator === "gte") return answerNumber >= operandNumber; 229 + if (operator === "lt") return answerNumber < operandNumber; 230 + return answerNumber <= operandNumber; 231 + } 232 + 233 + if (block.type === "DATE") { 234 + const comparison = compareDateValues(normalizedAnswer, operand); 235 + 236 + if (operator === "gt") return comparison > 0; 237 + if (operator === "gte") return comparison >= 0; 238 + if (operator === "lt") return comparison < 0; 239 + return comparison <= 0; 240 + } 241 + 242 + return false; 243 + } 244 + default: 245 + return false; 246 + } 247 + } 248 + 249 + export function branchRuleMatches(block: SerializedBlock, answer: string | string[] | undefined, rule: BranchRule) { 250 + const operand = normalizeBranchRuleOperand(block, rule); 251 + return compareRuleAgainstAnswer(block, rule.operator, answer, operand); 252 + } 253 + 254 + export function resolveNextBlockId( 255 + blocks: SerializedBlock[], 256 + currentBlockId: string, 257 + answers: Record<string, string | string[]>, 258 + ) { 259 + const currentIndex = blocks.findIndex((block) => block.id === currentBlockId); 260 + 261 + if (currentIndex < 0) { 262 + return null; 263 + } 264 + 265 + const currentBlock = blocks[currentIndex]; 266 + const linearNextBlockId = blocks[currentIndex + 1]?.id ?? null; 267 + 268 + if (!isQuestionBlock(currentBlock.type)) { 269 + return linearNextBlockId; 270 + } 271 + 272 + for (const rule of getBranchRules(currentBlock.config)) { 273 + if (!isValidForwardTarget(blocks, currentIndex, rule.targetBlockId)) { 274 + continue; 275 + } 276 + 277 + if (!isRuleSupportedForBlock(currentBlock, rule.operator)) { 278 + continue; 279 + } 280 + 281 + if (branchRuleMatches(currentBlock, answers[currentBlock.id], rule)) { 282 + return rule.targetBlockId; 283 + } 284 + } 285 + 286 + const configuredDefaultNextBlockId = getDefaultNextBlockId(currentBlock.config); 287 + 288 + if (configuredDefaultNextBlockId) { 289 + return isValidForwardTarget(blocks, currentIndex, configuredDefaultNextBlockId) 290 + ? configuredDefaultNextBlockId 291 + : linearNextBlockId; 292 + } 293 + 294 + return canUseDefaultPath(currentBlock) ? linearNextBlockId : null; 295 + } 296 + 297 + function canUseDefaultPath(block: SerializedBlock) { 298 + if (!isQuestionBlock(block.type)) { 299 + return true; 300 + } 301 + 302 + const rules = getBranchRules(block.config); 303 + 304 + if (!rules.length) { 305 + return true; 306 + } 307 + 308 + const supportedRules = rules.filter((rule) => isRuleSupportedForBlock(block, rule.operator)); 309 + 310 + if (supportedRules.some((rule) => rule.operator === "is_empty") && supportedRules.some((rule) => rule.operator === "is_not_empty")) { 311 + return false; 312 + } 313 + 314 + if (block.type === "SHORT_TEXT" || block.type === "LONG_TEXT" || block.type === "NUMBER" || block.type === "LINK" || block.type === "DATE") { 315 + return !supportedRules.some((rule) => rule.operator === "is_not_empty"); 316 + } 317 + 318 + if (block.type === "SINGLE_CHOICE") { 319 + const options = (block.config as ChoiceBlockConfig).options; 320 + const explicitEqualsCoverage = new Set( 321 + supportedRules 322 + .filter((rule) => rule.operator === "equals") 323 + .map((rule) => normalizeBranchRuleOperand(block, rule)) 324 + .filter((value): value is string => Boolean(value)), 325 + ); 326 + 327 + return !block.required || explicitEqualsCoverage.size < options.length; 328 + } 329 + 330 + if (block.type === "MULTIPLE_CHOICE") { 331 + return !supportedRules.some((rule) => rule.operator === "is_not_empty"); 332 + } 333 + 334 + if (block.type === "AGREEMENT") { 335 + const explicitEqualsCoverage = new Set( 336 + supportedRules 337 + .filter((rule) => rule.operator === "equals") 338 + .map((rule) => normalizeBranchRuleOperand(block, rule)) 339 + .filter((value): value is string => Boolean(value)), 340 + ); 341 + 342 + return !block.required || !explicitEqualsCoverage.has(AGREEMENT_ANSWER_VALUES.AGREED); 343 + } 344 + 345 + return true; 346 + } 347 + 348 + function getPossibleNextBlockIds(blocks: SerializedBlock[], block: SerializedBlock) { 349 + const blockIndex = blocks.findIndex((candidate) => candidate.id === block.id); 350 + 351 + if (blockIndex < 0) { 352 + return []; 353 + } 354 + 355 + const nextIds = new Set<string>(); 356 + 357 + if (!isQuestionBlock(block.type)) { 358 + const nextLinearBlockId = blocks[blockIndex + 1]?.id; 359 + 360 + if (nextLinearBlockId) { 361 + nextIds.add(nextLinearBlockId); 362 + } 363 + 364 + return [...nextIds]; 365 + } 366 + 367 + for (const rule of getBranchRules(block.config)) { 368 + if (isRuleSupportedForBlock(block, rule.operator) && isValidForwardTarget(blocks, blockIndex, rule.targetBlockId)) { 369 + nextIds.add(rule.targetBlockId); 370 + } 371 + } 372 + 373 + const configuredDefaultNextBlockId = getDefaultNextBlockId(block.config); 374 + 375 + if (configuredDefaultNextBlockId) { 376 + if (isValidForwardTarget(blocks, blockIndex, configuredDefaultNextBlockId)) { 377 + nextIds.add(configuredDefaultNextBlockId); 378 + } 379 + } else if (canUseDefaultPath(block)) { 380 + const nextLinearBlockId = blocks[blockIndex + 1]?.id; 381 + 382 + if (nextLinearBlockId) { 383 + nextIds.add(nextLinearBlockId); 384 + } 385 + } 386 + 387 + return [...nextIds]; 388 + } 389 + 390 + function isRuleConditionValid(block: SerializedBlock, rule: BranchRule) { 391 + if (!isRuleSupportedForBlock(block, rule.operator)) { 392 + return false; 393 + } 394 + 395 + if (!branchOperatorNeedsValue(rule.operator)) { 396 + return true; 397 + } 398 + 399 + const operand = normalizeBranchRuleOperand(block, rule); 400 + 401 + if (!operand) { 402 + return false; 403 + } 404 + 405 + if (rule.operator === "contains" && (block.type === "SHORT_TEXT" || block.type === "LONG_TEXT" || block.type === "LINK")) { 406 + return true; 407 + } 408 + 409 + if (rule.operator === "contains_any" && block.type === "MULTIPLE_CHOICE") { 410 + return true; 411 + } 412 + 413 + if ((rule.operator === "gt" || rule.operator === "gte" || rule.operator === "lt" || rule.operator === "lte") && (block.type === "NUMBER" || block.type === "DATE")) { 414 + return true; 415 + } 416 + 417 + return rule.operator === "equals" || rule.operator === "not_equals"; 418 + } 419 + 420 + function createIssue(issue: Omit<BranchValidationIssue, "severity"> & { severity?: BranchValidationIssueSeverity }): BranchValidationIssue { 421 + return { 422 + severity: issue.severity ?? "blocker", 423 + ...issue, 424 + }; 425 + } 426 + 427 + function validateDefaultTarget(blocks: SerializedBlock[], block: SerializedBlock) { 428 + const issues: BranchValidationIssue[] = []; 429 + const defaultNextBlockId = getDefaultNextBlockId(block.config); 430 + 431 + if (!defaultNextBlockId) { 432 + return issues; 433 + } 434 + 435 + const sourceIndex = blocks.findIndex((candidate) => candidate.id === block.id); 436 + const targetIndex = blocks.findIndex((candidate) => candidate.id === defaultNextBlockId); 437 + 438 + if (targetIndex < 0) { 439 + issues.push( 440 + createIssue({ 441 + code: "invalid-default-target", 442 + blockId: block.id, 443 + targetBlockId: defaultNextBlockId, 444 + message: `Default next step on “${getBlockDisplayLabel(block)}” points to a missing block.`, 445 + }), 446 + ); 447 + return issues; 448 + } 449 + 450 + if (targetIndex <= sourceIndex) { 451 + issues.push( 452 + createIssue({ 453 + code: "backward-default-target", 454 + blockId: block.id, 455 + targetBlockId: defaultNextBlockId, 456 + message: `Default next step on “${getBlockDisplayLabel(block)}” must point to a later block.`, 457 + }), 458 + ); 459 + } 460 + 461 + return issues; 462 + } 463 + 464 + function createRuleIdentity(block: SerializedBlock, rule: BranchRule) { 465 + return `${rule.operator}:${normalizeBranchRuleOperand(block, rule) ?? "<none>"}`; 466 + } 467 + 468 + function collectRuleWarnings(block: SerializedBlock, rules: BranchRule[]) { 469 + const warnings: BranchValidationIssue[] = []; 470 + 471 + rules.forEach((rule, ruleIndex) => { 472 + if ((block.type === "SHORT_TEXT" || block.type === "LONG_TEXT" || block.type === "LINK") && rule.operator === "equals") { 473 + warnings.push( 474 + createIssue({ 475 + severity: "warning", 476 + code: "fragile-text-match", 477 + blockId: block.id, 478 + ruleIndex, 479 + targetBlockId: rule.targetBlockId, 480 + value: rule.value, 481 + message: `Exact text matching on “${getBlockDisplayLabel(block)}” may be fragile if respondents vary punctuation or casing.`, 482 + }), 483 + ); 484 + } 485 + }); 486 + 487 + const firstIsNotEmptyIndex = rules.findIndex((rule) => rule.operator === "is_not_empty"); 488 + 489 + if (firstIsNotEmptyIndex >= 0) { 490 + rules.slice(firstIsNotEmptyIndex + 1).forEach((rule, offset) => { 491 + if (rule.operator !== "is_empty") { 492 + warnings.push( 493 + createIssue({ 494 + severity: "warning", 495 + code: "overlapping-rule", 496 + blockId: block.id, 497 + ruleIndex: firstIsNotEmptyIndex + offset + 1, 498 + targetBlockId: rule.targetBlockId, 499 + value: rule.value, 500 + message: `A later rule on “${getBlockDisplayLabel(block)}” may never run because an earlier “is not empty” rule matches first.`, 501 + }), 502 + ); 503 + } 504 + }); 505 + } 506 + 507 + if ((block.type === "SINGLE_CHOICE" || block.type === "AGREEMENT") && getDefaultNextBlockId(block.config)) { 508 + const explicitCoverage = new Set( 509 + rules 510 + .filter((rule) => rule.operator === "equals") 511 + .map((rule) => normalizeBranchRuleOperand(block, rule)) 512 + .filter((value): value is string => Boolean(value)), 513 + ); 514 + 515 + const expectedValues = 516 + block.type === "SINGLE_CHOICE" 517 + ? (block.config as ChoiceBlockConfig).options 518 + : [AGREEMENT_ANSWER_VALUES.AGREED, AGREEMENT_ANSWER_VALUES.NOT_AGREED]; 519 + 520 + if (explicitCoverage.size > 0 && explicitCoverage.size < expectedValues.length) { 521 + warnings.push( 522 + createIssue({ 523 + severity: "warning", 524 + code: "partial-choice-coverage", 525 + blockId: block.id, 526 + message: `Some answers on “${getBlockDisplayLabel(block)}” fall through to the default route. Double-check that this is intentional.`, 527 + }), 528 + ); 529 + } 530 + } 531 + 532 + return warnings; 533 + } 534 + 535 + function validateBlockRules(blocks: SerializedBlock[], block: SerializedBlock) { 536 + const issues: BranchValidationIssue[] = []; 537 + const sourceIndex = blocks.findIndex((candidate) => candidate.id === block.id); 538 + const seenRuleIdentities = new Set<string>(); 539 + const rules = getBranchRules(block.config); 540 + 541 + for (const [ruleIndex, rule] of rules.entries()) { 542 + if (!isRuleSupportedForBlock(block, rule.operator)) { 543 + issues.push( 544 + createIssue({ 545 + code: "unsupported-operator", 546 + blockId: block.id, 547 + ruleIndex, 548 + targetBlockId: rule.targetBlockId, 549 + value: rule.value, 550 + message: `The operator on a branch rule for “${getBlockDisplayLabel(block)}” is not supported for this block type.`, 551 + }), 552 + ); 553 + continue; 554 + } 555 + 556 + if (!isRuleConditionValid(block, rule)) { 557 + issues.push( 558 + createIssue({ 559 + code: "invalid-condition", 560 + blockId: block.id, 561 + ruleIndex, 562 + targetBlockId: rule.targetBlockId, 563 + value: rule.value, 564 + message: `Branch rule on “${getBlockDisplayLabel(block)}” has an invalid condition value.`, 565 + }), 566 + ); 567 + continue; 568 + } 569 + 570 + const ruleIdentity = createRuleIdentity(block, rule); 571 + 572 + if (seenRuleIdentities.has(ruleIdentity)) { 573 + issues.push( 574 + createIssue({ 575 + code: "duplicate-condition", 576 + blockId: block.id, 577 + ruleIndex, 578 + targetBlockId: rule.targetBlockId, 579 + value: rule.value, 580 + message: `Branch rule on “${getBlockDisplayLabel(block)}” duplicates an earlier condition.`, 581 + }), 582 + ); 583 + } 584 + 585 + seenRuleIdentities.add(ruleIdentity); 586 + 587 + const targetIndex = blocks.findIndex((candidate) => candidate.id === rule.targetBlockId); 588 + 589 + if (targetIndex < 0) { 590 + issues.push( 591 + createIssue({ 592 + code: "invalid-target", 593 + blockId: block.id, 594 + ruleIndex, 595 + targetBlockId: rule.targetBlockId, 596 + value: rule.value, 597 + message: `Branch rule on “${getBlockDisplayLabel(block)}” points to a missing block.`, 598 + }), 599 + ); 600 + continue; 601 + } 602 + 603 + if (targetIndex <= sourceIndex) { 604 + issues.push( 605 + createIssue({ 606 + code: "backward-target", 607 + blockId: block.id, 608 + ruleIndex, 609 + targetBlockId: rule.targetBlockId, 610 + value: rule.value, 611 + message: `Branch rule on “${getBlockDisplayLabel(block)}” must point to a later block.`, 612 + }), 613 + ); 614 + } 615 + } 616 + 617 + return { 618 + blockers: issues, 619 + warnings: collectRuleWarnings(block, rules), 620 + } satisfies BranchValidationAnalysis; 621 + } 622 + 623 + function getReachableBlockIds(blocks: SerializedBlock[]) { 624 + if (!blocks.length) { 625 + return new Set<string>(); 626 + } 627 + 628 + const reachable = new Set<string>(); 629 + const queue = [blocks[0].id]; 630 + 631 + while (queue.length) { 632 + const currentBlockId = queue.shift(); 633 + 634 + if (!currentBlockId || reachable.has(currentBlockId)) { 635 + continue; 636 + } 637 + 638 + reachable.add(currentBlockId); 639 + 640 + const currentBlock = blocks.find((block) => block.id === currentBlockId); 641 + 642 + if (!currentBlock) { 643 + continue; 644 + } 645 + 646 + for (const nextBlockId of getPossibleNextBlockIds(blocks, currentBlock)) { 647 + if (!reachable.has(nextBlockId)) { 648 + queue.push(nextBlockId); 649 + } 650 + } 651 + } 652 + 653 + return reachable; 654 + } 655 + 656 + export function analyzeBranchingGraph(blocks: SerializedBlock[]): BranchValidationAnalysis { 657 + const blockers: BranchValidationIssue[] = []; 658 + const warnings: BranchValidationIssue[] = []; 659 + 660 + for (const block of blocks) { 661 + blockers.push(...validateDefaultTarget(blocks, block)); 662 + const ruleAnalysis = validateBlockRules(blocks, block); 663 + blockers.push(...ruleAnalysis.blockers); 664 + warnings.push(...ruleAnalysis.warnings); 665 + } 666 + 667 + const reachableBlockIds = getReachableBlockIds(blocks); 668 + 669 + for (const block of blocks) { 670 + if (!reachableBlockIds.has(block.id)) { 671 + blockers.push( 672 + createIssue({ 673 + code: "unreachable-block", 674 + blockId: block.id, 675 + message: `“${getBlockDisplayLabel(block)}” is unreachable from the start of the form.`, 676 + }), 677 + ); 678 + } 679 + } 680 + 681 + return { blockers, warnings }; 682 + } 683 + 684 + export function validateBranchingGraph(blocks: SerializedBlock[]) { 685 + return analyzeBranchingGraph(blocks).blockers; 686 + } 687 + 688 + export function resolveVisitedBlockIds(blocks: SerializedBlock[], answers: Record<string, string | string[]>) { 689 + if (!blocks.length) { 690 + return []; 691 + } 692 + 693 + const visitedBlockIds: string[] = []; 694 + let currentBlockId: string | null = blocks[0].id; 695 + let safetyCounter = 0; 696 + 697 + while (currentBlockId && safetyCounter < blocks.length) { 698 + visitedBlockIds.push(currentBlockId); 699 + currentBlockId = resolveNextBlockId(blocks, currentBlockId, answers); 700 + safetyCounter += 1; 701 + } 702 + 703 + return visitedBlockIds; 704 + } 705 + 706 + export function getBranchTargetBlocks(blocks: SerializedBlock[], sourceBlockId: string) { 707 + const sourceBlock = blocks.find((block) => block.id === sourceBlockId); 708 + 709 + if (!sourceBlock) { 710 + return []; 711 + } 712 + 713 + return blocks.filter((block) => block.position > sourceBlock.position); 714 + } 715 + 716 + export function getBranchRuleValueHint(block: SerializedBlock) { 717 + if (block.type === "SINGLE_CHOICE" || block.type === "MULTIPLE_CHOICE") { 718 + const options = (block.config as ChoiceBlockConfig).options; 719 + return options[0] ?? "Option 1"; 720 + } 721 + 722 + if (block.type === "NUMBER") { 723 + return "42"; 724 + } 725 + 726 + if (block.type === "LINK") { 727 + return "https://example.com"; 728 + } 729 + 730 + if (block.type === "DATE") { 731 + return "2026-01-31"; 732 + } 733 + 734 + if (block.type === "AGREEMENT") { 735 + const label = (block.config as AgreementBlockConfig).label.trim(); 736 + return label ? AGREEMENT_ANSWER_VALUES.AGREED : AGREEMENT_ANSWER_VALUES.AGREED; 737 + } 738 + 739 + return "Exact answer value"; 740 + }
+6
lib/form-types.ts
··· 82 82 submissionNumber: number; 83 83 }; 84 84 85 + export type ResponseDetailRouteBlock = { 86 + block: SerializedBlock; 87 + status: "visited" | "skipped"; 88 + }; 89 + 85 90 export type ResponseDetail = { 86 91 id: string; 87 92 submittedAt: string; ··· 90 95 workspace: WorkspaceReference; 91 96 answers: Record<string, AnswerValue>; 92 97 blocks: SerializedBlock[]; 98 + routeBlocks: ResponseDetailRouteBlock[]; 93 99 }; 94 100 95 101 export type ResponseReviewFormSummary = {
+52 -9
lib/forms.ts
··· 26 26 type SerializedBlock, 27 27 type TextAnswerBlockConfig, 28 28 } from "@/lib/blocks"; 29 + import { getBranchValidationIssueI18n, validateBranchingGraph, resolveNextBlockId } from "@/lib/branching"; 29 30 import { db } from "@/lib/db"; 30 31 import { AppError } from "@/lib/errors"; 31 32 import { getLocalizedCompletionDefaults } from "@/lib/form-defaults"; ··· 47 48 reorderBlocksSchema, 48 49 } from "@/lib/validators"; 49 50 import { slugify } from "@/lib/utils"; 50 - import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n"; 51 + import { DEFAULT_LOCALE, translate, type AppLocale } from "@/lib/i18n"; 52 + import { getMessages } from "@/lib/i18n-server"; 51 53 import { assertOrganizationMember, workspaceAccessWhere, workspaceFilterWhere } from "@/lib/workspaces"; 52 54 53 55 const FORM_BLOCK_TYPES = { ··· 318 320 ? value.options.filter((option): option is string => typeof option === "string") 319 321 : []; 320 322 const options = sanitizeOptions(rawOptions); 323 + const branchRules = 324 + typeof value === "object" && value && "branchRules" in value && Array.isArray(value.branchRules) 325 + ? value.branchRules 326 + : undefined; 327 + const defaultNextBlockId = 328 + typeof value === "object" && value && "defaultNextBlockId" in value && typeof value.defaultNextBlockId === "string" 329 + ? value.defaultNextBlockId 330 + : value && typeof value === "object" && "defaultNextBlockId" in value && value.defaultNextBlockId === null 331 + ? null 332 + : undefined; 321 333 322 334 return parseBlockConfig(type, { 323 335 options: options.length >= 2 ? options : ["Option 1", "Option 2"], 336 + branchRules, 337 + defaultNextBlockId, 324 338 }); 325 339 } 326 340 ··· 608 622 }); 609 623 } 610 624 611 - export async function setOwnedFormPublished(userId: string, formId: string, payload: unknown) { 625 + export async function setOwnedFormPublished(userId: string, formId: string, payload: unknown, locale: AppLocale = DEFAULT_LOCALE) { 612 626 const parsed = publishFormSchema.parse(payload); 613 627 const form = await assertAccessibleForm(userId, formId); 614 628 615 629 if (parsed.published && form.blocks.length === 0) { 616 630 throw new AppError("Add at least one block before publishing.", 422); 631 + } 632 + 633 + if (parsed.published) { 634 + const branchValidationIssues = validateBranchingGraph(form.blocks.map(serializeBlock)); 635 + 636 + if (branchValidationIssues.length > 0) { 637 + const messages = await getMessages(locale); 638 + const issueI18n = getBranchValidationIssueI18n(branchValidationIssues[0], form.blocks.map(serializeBlock)); 639 + throw new AppError(translate(messages, issueI18n.key, issueI18n.values), 422); 640 + } 617 641 } 618 642 619 643 const updated = await db.form.update({ ··· 779 803 } 780 804 781 805 const blocks = form.blocks.map(serializeBlock); 782 - const normalizedAnswers = blocks.reduce<Record<string, string | string[]>>((accumulator, block) => { 783 - const answer = normalizeAnswer(block, answersInput[block.id]); 806 + const blocksById = new Map(blocks.map((block) => [block.id, block])); 807 + const normalizedAnswers: Record<string, string | string[]> = {}; 808 + const visitedBlocks: SerializedBlock[] = []; 809 + 810 + let currentBlock: SerializedBlock | undefined = blocks[0]; 811 + let safetyCounter = 0; 812 + 813 + while (currentBlock && safetyCounter < blocks.length) { 814 + visitedBlocks.push(currentBlock); 815 + 816 + const answer = normalizeAnswer(currentBlock, answersInput[currentBlock.id]); 784 817 785 818 if (typeof answer !== "undefined") { 786 - accumulator[block.id] = answer; 819 + normalizedAnswers[currentBlock.id] = answer; 787 820 } 788 821 789 - return accumulator; 790 - }, {}); 822 + const nextBlockId = resolveNextBlockId(blocks, currentBlock.id, normalizedAnswers); 823 + currentBlock = nextBlockId ? blocksById.get(nextBlockId) : undefined; 824 + safetyCounter += 1; 825 + } 791 826 792 827 const response = await db.response.create({ 793 828 data: { 794 829 formId: form.id, 795 830 answersJson: normalizedAnswers, 796 - formSnapshotJson: blocks.map((block) => ({ 831 + formSnapshotJson: visitedBlocks.map((block) => ({ 797 832 id: block.id, 798 833 type: block.type, 799 834 title: block.title, ··· 888 923 }, 889 924 }); 890 925 926 + const visitedBlocks = parseSnapshotBlocks(response); 927 + const visitedBlockIds = new Set(visitedBlocks.map((block) => block.id)); 928 + const currentFormBlocks = response.form.blocks.map(serializeBlock); 929 + 891 930 return { 892 931 id: response.id, 893 932 submittedAt: response.submittedAt.toISOString(), ··· 898 937 typeof response.answersJson === "object" && response.answersJson && !Array.isArray(response.answersJson) 899 938 ? (response.answersJson as Record<string, string | string[]>) 900 939 : {}, 901 - blocks: parseSnapshotBlocks(response), 940 + blocks: visitedBlocks, 941 + routeBlocks: currentFormBlocks.map((block) => ({ 942 + block, 943 + status: visitedBlockIds.has(block.id) ? "visited" : "skipped", 944 + })), 902 945 }; 903 946 }
+67
locales/en.yml
··· 153 153 dragOptionAria: "Drag option {number}" 154 154 removeOptionAria: "Remove option {number}" 155 155 optionsHelp: Drag to reorder, rename in place, and keep between 2 and 10 options. 156 + branchingTitle: Branching 157 + branchingDescription: Send respondents to a later block when this answer matches. 158 + addBranchRule: Add rule 159 + branchOperator: Operator 160 + branchWhenAnswer: Condition value 161 + branchGoTo: Go to block 162 + branchOtherwise: Otherwise continue to 163 + branchDefaultLinear: The next block in saved order 164 + branchNoValueNeeded: No value needed for this operator. 165 + branchingHelp: "Leave a rule unmatched to use the default path. Example value: \"{value}\"." 166 + branchingNoTargets: Add a later block before creating branch rules from this question. 167 + dragBranchRuleAria: "Drag branch rule {number}" 168 + removeBranchRuleAria: "Remove branch rule {number}" 169 + branchMissingTarget: Missing target block 170 + branchingBlockersTitle: "{count} branching blocker(s) must be fixed before publish" 171 + branchingBlockersDescription: Draft edits are preserved, but publishing stays blocked until these routing errors are fixed. 172 + branchingWarningsTitle: "{count} branching warning(s) to review" 173 + branchingWarningsDescription: These warnings do not block draft saves, but they may indicate fragile or confusing routing. 174 + branchingBlockersForBlockTitle: Blocking issues on this block 175 + branchingWarningsForBlockTitle: Warnings on this block 176 + branchOperators: 177 + equals: Equals 178 + not_equals: Not equals 179 + is_empty: Is empty 180 + is_not_empty: Is not empty 181 + contains: Contains 182 + contains_any: Contains any 183 + gt: Greater than 184 + gte: Greater than or equal 185 + lt: Less than 186 + lte: Less than or equal 187 + branchOperatorLabels: 188 + singleChoice: 189 + equals: Is 190 + not_equals: Is not 191 + multipleChoice: 192 + contains_any: Contains option 193 + agreement: 194 + equals: Is agreed 195 + not_equals: Is not agreed 196 + date: 197 + equals: Is on 198 + not_equals: Is not on 199 + gt: Is after 200 + gte: Is on or after 201 + lt: Is before 202 + lte: Is on or before 203 + branchAgreementValues: 204 + agreed: Agree 205 + not_agreed: Do not agree 156 206 requiredToggle: Mark this question as required 157 207 deleteBlock: Delete block 158 208 saveBlock: Save block ··· 183 233 LINK: Link 184 234 AGREEMENT: Agreement 185 235 DATE: Date 236 + branching: 237 + issueMessages: 238 + invalid-target: "A rule on \"{blockLabel}\" points to a missing block." 239 + backward-target: "A rule on \"{blockLabel}\" must point to a later block." 240 + invalid-default-target: "The default next step on \"{blockLabel}\" points to a missing block." 241 + backward-default-target: "The default next step on \"{blockLabel}\" must point to a later block." 242 + invalid-condition: "A rule on \"{blockLabel}\" has an invalid condition value." 243 + duplicate-condition: "A rule on \"{blockLabel}\" duplicates an earlier condition." 244 + unsupported-operator: "A rule on \"{blockLabel}\" uses an operator that does not fit this block type." 245 + unreachable-block: "\"{blockLabel}\" is unreachable from the start of the form." 246 + fragile-text-match: "Exact text matching on \"{blockLabel}\" may be fragile if respondents vary punctuation or casing." 247 + overlapping-rule: "A later rule on \"{blockLabel}\" may never run because an earlier broader rule matches first." 248 + partial-choice-coverage: "Some answers on \"{blockLabel}\" fall through to the default route. Double-check that this is intentional." 186 249 publicRunner: 187 250 responseReceived: Response received 188 251 defaultCompletionTitle: Thanks for taking the time. ··· 226 289 title: "Submission #{number} · {date}" 227 290 description: Review submitted answers in form order. 228 291 backToResponses: Back to responses 292 + routeContext: Route context 293 + visitedPath: Visited path 294 + skippedPath: Skipped sections 295 + nothingSkipped: No sections were skipped in this submission. 229 296 contextBlock: Context block 230 297 contextTitle: Context 231 298 required: Required
+67
locales/ru.yml
··· 153 153 dragOptionAria: "Перетащить вариант {number}" 154 154 removeOptionAria: "Удалить вариант {number}" 155 155 optionsHelp: Перетаскивайте варианты для изменения порядка, переименовывайте их на месте и держите количество в диапазоне от 2 до 10. 156 + branchingTitle: Ветвление 157 + branchingDescription: Отправляйте респондента к более позднему блоку, если ответ совпадает. 158 + addBranchRule: Добавить правило 159 + branchOperator: Оператор 160 + branchWhenAnswer: Значение условия 161 + branchGoTo: Перейти к блоку 162 + branchOtherwise: Иначе продолжить к 163 + branchDefaultLinear: Следующему блоку в сохранённом порядке 164 + branchNoValueNeeded: Для этого оператора значение не требуется. 165 + branchingHelp: "Если ни одно правило не совпало, форма использует маршрут по умолчанию. Пример значения: \"{value}\"." 166 + branchingNoTargets: Добавьте более поздний блок, прежде чем создавать правила ветвления для этого вопроса. 167 + dragBranchRuleAria: "Перетащить правило ветвления {number}" 168 + removeBranchRuleAria: "Удалить правило ветвления {number}" 169 + branchMissingTarget: Целевой блок отсутствует 170 + branchingBlockersTitle: "Проблемы с ветвлением, блокирующие публикацию: {count}" 171 + branchingBlockersDescription: Черновик сохранится, но публикация будет заблокирована, пока вы не исправите ошибки маршрутизации. 172 + branchingWarningsTitle: "Предупреждения по ветвлению: {count}" 173 + branchingWarningsDescription: Эти предупреждения не мешают сохранению черновика, но могут указывать на хрупкую или запутанную маршрутизацию. 174 + branchingBlockersForBlockTitle: Блокирующие ошибки в этом блоке 175 + branchingWarningsForBlockTitle: Предупреждения в этом блоке 176 + branchOperators: 177 + equals: Равно 178 + not_equals: Не равно 179 + is_empty: Пусто 180 + is_not_empty: Не пусто 181 + contains: Содержит 182 + contains_any: Содержит любой из вариантов 183 + gt: Больше чем 184 + gte: Больше или равно 185 + lt: Меньше чем 186 + lte: Меньше или равно 187 + branchOperatorLabels: 188 + singleChoice: 189 + equals: Выбран вариант 190 + not_equals: Выбран не вариант 191 + multipleChoice: 192 + contains_any: Содержит вариант 193 + agreement: 194 + equals: Есть согласие 195 + not_equals: Нет согласия 196 + date: 197 + equals: Дата совпадает с 198 + not_equals: Дата не совпадает с 199 + gt: После 200 + gte: В эту дату или позже 201 + lt: До 202 + lte: В эту дату или раньше 203 + branchAgreementValues: 204 + agreed: Согласен 205 + not_agreed: Не согласен 156 206 requiredToggle: Сделать этот вопрос обязательным 157 207 deleteBlock: Удалить блок 158 208 saveBlock: Сохранить блок ··· 183 233 LINK: Ссылка 184 234 AGREEMENT: Согласие 185 235 DATE: Дата 236 + branching: 237 + issueMessages: 238 + invalid-target: "Правило в \"{blockLabel}\" ссылается на отсутствующий блок." 239 + backward-target: "Правило в \"{blockLabel}\" должно вести только к более позднему блоку." 240 + invalid-default-target: "Маршрут по умолчанию в \"{blockLabel}\" ссылается на отсутствующий блок." 241 + backward-default-target: "Маршрут по умолчанию в \"{blockLabel}\" должен вести только к более позднему блоку." 242 + invalid-condition: "В \"{blockLabel}\" задано некорректное значение условия." 243 + duplicate-condition: "В \"{blockLabel}\" условие дублирует более раннее правило." 244 + unsupported-operator: "В \"{blockLabel}\" используется оператор, который не подходит этому типу блока." 245 + unreachable-block: "Блок \"{blockLabel}\" недостижим от начала формы." 246 + fragile-text-match: "Точное текстовое совпадение в \"{blockLabel}\" может быть хрупким, если респонденты вводят разную пунктуацию или регистр." 247 + overlapping-rule: "Более позднее правило в \"{blockLabel}\" может никогда не сработать, потому что раньше срабатывает более широкое условие." 248 + partial-choice-coverage: "Часть ответов в \"{blockLabel}\" уходит по маршруту по умолчанию. Проверьте, что это сделано намеренно." 186 249 publicRunner: 187 250 responseReceived: Ответ получен 188 251 defaultCompletionTitle: Спасибо, что уделили время. ··· 226 289 title: "Ответ №{number} · {date}" 227 290 description: Просматривайте ответы в порядке формы. 228 291 backToResponses: Назад к ответам 292 + routeContext: Контекст маршрута 293 + visitedPath: Пройденный путь 294 + skippedPath: Пропущенные секции 295 + nothingSkipped: В этой отправке не было пропущенных секций. 229 296 contextBlock: Контекстный блок 230 297 contextTitle: Контекст 231 298 required: Обязательно
+2
openspec/changes/archive/2026-04-13-add-form-branching/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-12
+166
openspec/changes/archive/2026-04-13-add-form-branching/design.md
··· 1 + ## Context 2 + 3 + Lively Forms currently stores form structure as an ordered list of `FormBlock` records, with block-specific settings serialized in each block's `config` JSON. The creator builder edits one block at a time, and the public runner assumes a strictly linear progression by incrementing a numeric step across `form.blocks`. 4 + 5 + Branching is a cross-cutting change because it touches block configuration parsing, builder editing UI, publish validation, runner navigation state, and submission-time validation. The current architecture is a good fit for an incremental implementation because blocks already have stable IDs and a JSON config surface that can absorb routing metadata without introducing a new top-level table. 6 + 7 + Key constraints: 8 + - Public forms are anonymous and already validate answers both client-side and server-side. 9 + - The builder already saves block configuration per block and reuses stable block IDs across reorder operations. 10 + - The current runner and server submission path both assume every block in saved order is part of the respondent journey, which must change for branching to work. 11 + - The requested behavior needs to cover both simple conditional follow-ups and larger divergent paths, but should avoid infinite loops or graph states that are hard to reason about in v1. 12 + 13 + ## Goals / Non-Goals 14 + 15 + **Goals:** 16 + - Let creators define branching from an answerable block to any later block in the form. 17 + - Support simple follow-up questions and multi-branch flows using the same routing model. 18 + - Keep forms without branching working exactly as they do today. 19 + - Enforce deterministic routing in the public runner and on the submission API. 20 + - Block publishing when branch targets are invalid or the graph leaves blocks unreachable from the form start. 21 + - Preserve reliable back navigation when a respondent revisits earlier answers. 22 + 23 + **Non-Goals:** 24 + - Supporting backward jumps, loops, or arbitrary cyclic graphs. 25 + - Adding a visual canvas or node-editor UI in v1. 26 + - Introducing complex condition builders such as numeric ranges, boolean groups, or nested AND/OR logic. 27 + - Changing creator response review beyond what is necessary for branching-safe submissions. 28 + - Versioning published form graphs independently from the draft form model. 29 + 30 + ## Decisions 31 + 32 + ### 1. Store branch rules inside each block's existing `config` JSON 33 + Add a shared branching shape to answerable block configs rather than introducing a separate `FormBranch` table. 34 + 35 + Proposed model: 36 + - Every answerable block may include `branchRules: BranchRule[]` inside `config`. 37 + - Each rule references a target `blockId` and a condition value interpreted according to the source block type. 38 + - Every answerable block may also include `defaultNextBlockId`, which overrides the normal next-in-order fallback when no branch rule matches and also enables unconditional forward jumps after a question. 39 + - When `defaultNextBlockId` is not set, the runner continues to the next block in saved order. 40 + - Text blocks remain non-answerable and do not expose branch editing. 41 + 42 + Why this approach: 43 + - Fits the existing block-centric editing flow and API shape. 44 + - Avoids a database migration for the initial feature. 45 + - Keeps branch rules naturally attached to the question that decides the route. 46 + 47 + Alternatives considered: 48 + - A separate relational edge table: more normalized, but adds migration, joins, and more complex CRUD for an initial release. 49 + - A form-level graph document: more flexible, but less aligned with the current block editor and harder to update incrementally. 50 + 51 + ### 2. Scope v1 routing to forward-only jumps to later blocks 52 + A branch target or configured default next target may point only to a later block in saved order. When no explicit default next target is configured, the implicit fallback remains the next block in saved order. 53 + 54 + Why this approach: 55 + - Still satisfies the requested jump-to-X branching for practical form flows. 56 + - Prevents infinite loops and ambiguous revisit semantics. 57 + - Makes validation and route resolution much simpler. 58 + 59 + Alternatives considered: 60 + - Allow arbitrary targets, including previous blocks: more expressive, but creates loop detection, repeated-answer semantics, and progress complexity that are not needed for v1. 61 + - Restrict branching to only the immediately next question: too limiting for the requested divergent paths. 62 + 63 + ### 3. Use simple block-type-aware match conditions instead of a full rule engine 64 + Branch conditions should match the current block's answer using a small set of predictable operators: 65 + - short text / long text / number / link / date: exact normalized value match 66 + - single choice / agreement: exact selected value match 67 + - multiple choice: contains selected option 68 + 69 + Rules are evaluated in saved order, and the first matching rule wins. 70 + 71 + Why this approach: 72 + - Covers the user request for answer-based branching without building a complex expression language. 73 + - Keeps both builder UI and server validation understandable. 74 + - Gives deterministic behavior for multiple-choice blocks where more than one rule could match. 75 + 76 + Alternatives considered: 77 + - Add full comparator support (`>`, `<`, regex, AND/OR groups): powerful, but too large for the first branching release. 78 + - Allow all matching rules to fire: ambiguous when rules target different blocks. 79 + 80 + ### 4. Validate branch integrity centrally and enforce it at publish time 81 + Introduce a shared graph validation helper used by builder APIs and publish logic. Draft forms may retain invalid branch rules while being edited, but publish MUST fail until issues are resolved. 82 + 83 + Validation should cover: 84 + - branch targets and configured default next targets exist 85 + - every target is later than the source block 86 + - no duplicate or impossible rule shapes for the same block type 87 + - every block remains reachable from the first block through at least one possible route 88 + 89 + Why this approach: 90 + - Lets creators reorder and edit complex forms without destructive auto-fixes. 91 + - Keeps the hard safety boundary at publish time, where invalid graphs would affect respondents. 92 + - Centralizes logic so the client and server do not drift. 93 + 94 + Alternatives considered: 95 + - Reject invalid drafts on every edit: safer but more frustrating during multi-step authoring. 96 + - Auto-delete invalid rules during reorder/delete: simpler operationally, but surprising and lossy. 97 + 98 + ### 5. Change runner state from numeric linear step to visited-route history 99 + Replace the runner's single `step` index over `form.blocks` with route-aware state: 100 + - `history: string[]` of visited block IDs in order 101 + - `cursor` pointing at the current visited block 102 + - `answers` as today 103 + 104 + Advance flow: 105 + 1. validate the current visited block 106 + 2. resolve the next block from the current answer and branch rules 107 + 3. if the respondent had gone back, truncate forward history before appending the new branch result 108 + 4. move to the next visited block or submit if no next block exists 109 + 110 + Why this approach: 111 + - Supports deterministic back navigation across the actual path taken. 112 + - Correctly recalculates future steps when a respondent changes an earlier answer. 113 + - Avoids leaking skipped blocks into the active route. 114 + 115 + Alternatives considered: 116 + - Recompute the entire full route from scratch on every render: possible, but harder to preserve intuitive back behavior. 117 + - Keep a linear numeric index and just skip hidden blocks: brittle once respondents go back and alter branch decisions. 118 + 119 + ### 6. Validate and persist only visited blocks during submission 120 + The submission API should resolve the respondent's visited route using the same branch engine as the client and validate only blocks that were actually visited. Skipped required questions must not block submission. 121 + 122 + Why this approach: 123 + - The current server validation iterates over every block in saved order, which would incorrectly reject branched submissions. 124 + - Server-side route resolution prevents clients from bypassing required questions on the active branch. 125 + 126 + Alternatives considered: 127 + - Trust the client-provided route history: simpler, but weaker from an integrity standpoint. 128 + - Keep validating every block and mark branched ones optional: incorrect and hard to reason about. 129 + 130 + ### 7. Replace full-form percentage with route-relative progress 131 + Because future branches are not always knowable before later answers are given, the runner should present progress relative to the visited route rather than a precise percentage over all saved blocks. 132 + 133 + Why this approach: 134 + - Avoids misleading totals when large sections are skipped. 135 + - Keeps progress honest when a prior answer changes the remaining route. 136 + 137 + Alternatives considered: 138 + - Keep percentage over the full saved block list: inaccurate for heavily branched forms. 139 + - Predict the entire remaining route ahead of time: unreliable when future routing depends on unanswered questions. 140 + 141 + ## Risks / Trade-offs 142 + 143 + - **Branch rules stored in JSON are less queryable than relational edges** → Mitigation: acceptable for v1 because routing is resolved per form load, not through global reporting queries. 144 + - **Reordering blocks can temporarily invalidate existing branch targets** → Mitigation: preserve draft edits, surface validation issues, and enforce correctness at publish time. 145 + - **First-match rule evaluation may surprise creators if multiple rules could match** → Mitigation: make rule order explicit in the builder and document that evaluation is top-to-bottom. 146 + - **Open-input branching based on exact matches may be less powerful than users eventually want** → Mitigation: design the rule shape so richer comparators can be added later without changing the overall graph model. 147 + - **Route-relative progress is less precise than a classic total-step meter** → Mitigation: favor correctness and consistency over a misleading exact percentage. 148 + 149 + ## Migration Plan 150 + 151 + 1. Extend block config parsing/types with shared `branchRules` support for answerable blocks. 152 + 2. Add shared branch matching and graph validation helpers in the server/domain layer. 153 + 3. Update builder APIs and editor UI to create, edit, and validate branch rules against current form blocks. 154 + 4. Update publish validation to reject invalid branch graphs. 155 + 5. Refactor the public runner to use visited-route history and branch-aware progression. 156 + 6. Refactor submission handling to resolve and validate only the visited route. 157 + 7. Verify linear forms still behave identically when no branch rules are configured. 158 + 159 + Rollback strategy: 160 + - If the feature must be rolled back before broad usage, ignore `branchRules` in the runner and hide builder controls. Because the data stays in JSON config, rollback does not require a destructive schema reversal. 161 + 162 + ## Open Questions 163 + 164 + - Should the creator builder eventually show a lightweight route preview or mini-map in addition to per-block rule editing? 165 + - Should response review later distinguish between skipped blocks and unanswered visited blocks more explicitly? 166 + - Do we want richer comparators such as numeric ranges or regex-based routing after the initial exact-match release?
+27
openspec/changes/archive/2026-04-13-add-form-branching/proposal.md
··· 1 + ## Why 2 + 3 + Lively Forms currently runs every published form as a fixed linear sequence, which makes simple follow-up questions and fully divergent form paths impossible. Adding branching now unlocks more practical onboarding, qualification, and decision-tree flows without forcing creators to duplicate forms for each audience. 4 + 5 + ## What Changes 6 + 7 + - Add branching rules and configurable default next targets that let creators send respondents to different later blocks based on the current answer or a saved fallback path. 8 + - Support both simple conditional follow-ups and jump-to-any-block branching across the form graph. 9 + - Keep a safe default path so forms without custom branch rules still run in their saved order. 10 + - Update the public runner to evaluate branch rules after each answer, navigate through the resulting path, and preserve back navigation across visited steps. 11 + - Prevent creators from publishing forms with invalid branch targets or unreachable end states. 12 + 13 + ## Capabilities 14 + 15 + ### New Capabilities 16 + - None. 17 + 18 + ### Modified Capabilities 19 + - `conversational-form-builder`: creators can configure, review, and validate branch destinations between blocks instead of only editing a linear sequence. 20 + - `anonymous-form-runner`: respondents move through a conditional block graph determined by prior answers rather than always following saved block order. 21 + 22 + ## Impact 23 + 24 + - Builder UI for block editing, branch configuration, and publish-time validation 25 + - Form block data model and API payloads for storing branching rules and targets 26 + - Public form runner navigation, progress behavior, and backtracking state 27 + - Submission validation and response snapshots so completed runs reflect the path actually taken
+46
openspec/changes/archive/2026-04-13-add-form-branching/specs/anonymous-form-runner/spec.md
··· 1 + ## MODIFIED Requirements 2 + 3 + ### Requirement: Runner presents blocks in a linear one-at-a-time flow 4 + The system SHALL present form blocks one at a time starting from the first saved block and SHALL resolve each next block by evaluating the current block's saved branch rules against the respondent's current answer. If no branch rule matches, the system SHALL continue to the block's configured default next target when present, or otherwise continue to the next block in saved order. Text blocks SHALL remain part of the flow but SHALL NOT collect an answer. The system SHALL allow backward navigation across the respondent's visited path and SHALL recalculate future visited steps when a prior answer changes. 5 + 6 + #### Scenario: Respondent sees a conditional follow-up question 7 + - **WHEN** a respondent answers a question with a value that matches a saved branch rule pointing to a later follow-up block 8 + - **THEN** the system shows that follow-up block as the next step in the runner 9 + 10 + #### Scenario: Respondent skips a branch-only follow-up 11 + - **WHEN** a respondent answers a question with a value that does not match any saved branch rule 12 + - **THEN** the system continues to the next block in saved order instead of showing the branched follow-up block 13 + 14 + #### Scenario: Respondent changes an earlier branching answer 15 + - **WHEN** a respondent navigates backward, changes an answer that controls branching, and continues again 16 + - **THEN** the system recalculates the subsequent route from that changed answer and replaces the no-longer-valid future steps 17 + 18 + ### Requirement: Runner shows progress across the full flow 19 + The system SHALL display progress based on the respondent's current visited route rather than assuming every saved block will appear, and SHALL update the visible step indicator when branching or backtracking changes that route. 20 + 21 + #### Scenario: Respondent skips a section through branching 22 + - **WHEN** a respondent takes a branch that skips one or more later blocks 23 + - **THEN** the system updates the visible progress so it reflects the route the respondent is actually taking rather than the full saved block list 24 + 25 + #### Scenario: Respondent changes the route by editing a previous answer 26 + - **WHEN** a respondent goes back and changes an answer that changes the remaining route 27 + - **THEN** the system updates the visible progress to match the recalculated route 28 + 29 + ### Requirement: Required question blocks are validated before advancement 30 + The system SHALL prevent a respondent from advancing past a visited required question block until a valid answer is provided for that block type. For required agreement blocks, only an explicit agreed response SHALL satisfy the requirement. Question blocks skipped by branching SHALL NOT be required for advancement or submission. 31 + 32 + #### Scenario: Respondent skips a required short text question 33 + - **WHEN** a respondent attempts to continue from a visited required text question without an answer 34 + - **THEN** the system blocks advancement and indicates that an answer is required 35 + 36 + #### Scenario: Respondent skips a required multiple choice question 37 + - **WHEN** a respondent attempts to continue from a visited required multiple choice block without selecting any options 38 + - **THEN** the system blocks advancement and indicates that an answer is required 39 + 40 + #### Scenario: Respondent does not agree to a required agreement block 41 + - **WHEN** a respondent attempts to continue from a visited required agreement block without selecting agreed 42 + - **THEN** the system blocks advancement and indicates that agreement is required 43 + 44 + #### Scenario: Required block is skipped by a branch 45 + - **WHEN** a respondent takes a branch that bypasses a required question block 46 + - **THEN** the system does not require an answer for that skipped block before allowing completion or submission
+31
openspec/changes/archive/2026-04-13-add-form-branching/specs/conversational-form-builder/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Creator can configure answer-based branch rules on question blocks 4 + The system SHALL allow an authenticated creator to add, edit, reorder, and remove branch rules on answerable blocks in a form located in an accessible workspace. Each branch rule SHALL compare the current block's answer using behavior appropriate to that block type and SHALL send respondents to a later block in the same form. The system SHALL also allow an authenticated creator to configure a default next target on an answerable block so respondents can continue to a chosen later block when no branch rule matches. If no branch rule matches and no default next target is configured, the respondent flow SHALL continue to the next block in saved order. 5 + 6 + #### Scenario: Creator adds a conditional follow-up for one answer 7 + - **WHEN** an authenticated creator configures a single choice question so one option branches to a later follow-up block 8 + - **THEN** the system saves that branch rule on the source question and keeps the unconfigured path flowing to the next saved block 9 + 10 + #### Scenario: Creator defines divergent paths from one question 11 + - **WHEN** an authenticated creator configures different branch rules for different answers on the same question block 12 + - **THEN** the system saves distinct later-block targets for those answers on that source block 13 + 14 + #### Scenario: Creator changes branch priority 15 + - **WHEN** an authenticated creator reorders branch rules on a question block 16 + - **THEN** the system saves the updated evaluation order for that block's branch rules 17 + 18 + ### Requirement: Builder validates branch graphs before publishing 19 + The system SHALL validate saved branch rules before allowing a form to publish. The system SHALL prevent publishing when a branch rule points to a missing, current, or earlier block, or when one or more blocks become unreachable from the first block under the configured branch graph. 20 + 21 + #### Scenario: Creator tries to publish with a backward branch target 22 + - **WHEN** an authenticated creator attempts to publish a form containing a branch rule that points to the source block or an earlier block 23 + - **THEN** the system blocks publishing and indicates that the branch target must move forward in the form 24 + 25 + #### Scenario: Creator tries to publish with an unreachable block 26 + - **WHEN** an authenticated creator attempts to publish a form whose saved branch rules leave a block unreachable from the form start 27 + - **THEN** the system blocks publishing and indicates that the unreachable block must be reconnected or removed 28 + 29 + #### Scenario: Creator saves an invalid draft while editing branching 30 + - **WHEN** an authenticated creator saves draft changes that leave branch validation issues unresolved 31 + - **THEN** the system preserves the draft changes and continues to block publishing until the issues are fixed
+22
openspec/changes/archive/2026-04-13-add-form-branching/tasks.md
··· 1 + ## 1. Branching domain model 2 + 3 + - [x] 1.1 Extend block config types, parsers, and block update validation to support ordered `branchRules` on answerable blocks 4 + - [x] 1.2 Add shared branch matching, next-block resolution, and graph validation helpers for forward-only routing and reachability checks 5 + - [x] 1.3 Update form serialization/helpers so builder and public form payloads include branching data consistently without changing linear forms 6 + 7 + ## 2. Builder authoring and publish validation 8 + 9 + - [x] 2.1 Add branching controls to the block editor for answerable blocks, including rule creation, target selection, and rule reordering 10 + - [x] 2.2 Surface branch validation feedback in the builder while preserving draft edits that are not yet publishable 11 + - [x] 2.3 Enforce publish-time branch validation in the form publish flow and return actionable errors for invalid targets or unreachable blocks 12 + 13 + ## 3. Branch-aware runner and submission 14 + 15 + - [x] 3.1 Refactor the public runner to use visited-route history instead of a linear step index 16 + - [x] 3.2 Update runner progress and back-navigation behavior so route changes after edited answers recalculate future steps correctly 17 + - [x] 3.3 Update anonymous submission handling to resolve the visited route server-side and validate only visited required blocks 18 + 19 + ## 4. Regression checks 20 + 21 + - [x] 4.1 Verify unchanged linear forms still build, publish, and submit exactly as before when no branch rules are configured 22 + - [x] 4.2 Verify branched forms cover simple follow-up, divergent path, skipped required question, and backtracking scenarios end to end
+2
openspec/changes/archive/2026-04-13-improve-branching-logic-review-validation/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-13
+128
openspec/changes/archive/2026-04-13-improve-branching-logic-review-validation/design.md
··· 1 + ## Context 2 + 3 + The initial branching change adds forward-only routing, default next targets, and visited-path runner state, but branch conditions are still intentionally narrow: most blocks only support exact-match semantics. That keeps the first release simple, yet it leaves common real-world routing needs unsolved, such as numeric thresholds, blank-answer handling, or substring matching. 4 + 5 + At the same time, creators currently have limited visibility into what happened after a branched response is submitted. The response review flow can show visited blocks because the submission snapshot already preserves the visited path, but it does not explicitly label the route taken or the sections skipped. Validation is also mostly structural. It blocks obviously invalid graphs, but it does not help creators identify fragile logic, overlapping conditions, or dead-end coverage risks before publishing. 6 + 7 + This follow-up change is cross-cutting because it affects branch rule storage, builder authoring, publish validation, runner evaluation, and response review presentation. 8 + 9 + ## Goals / Non-Goals 10 + 11 + **Goals:** 12 + - Expand branching with a practical v1 operator set that covers common qualification and routing use cases. 13 + - Keep routing deterministic and forward-only. 14 + - Show creators the visited route and skipped sections for a submitted response. 15 + - Strengthen validation with clearer branching warnings and publish-time blockers. 16 + - Preserve compatibility for existing exact-match branch rules. 17 + 18 + **Non-Goals:** 19 + - Adding a full visual graph canvas. 20 + - Supporting arbitrary boolean groups, nested AND/OR logic, or user-defined expressions. 21 + - Adding branching analytics dashboards in this change. 22 + - Introducing backward jumps or loops. 23 + - Reworking the persistence model away from per-block JSON config. 24 + 25 + ## Decisions 26 + 27 + ### 1. Extend branch rules with an explicit operator field 28 + Move branch rules from implicit type-specific matching to an explicit rule shape such as: 29 + - `operator`: enum 30 + - `value`: optional operand when the operator needs one 31 + - `targetBlockId`: later block target 32 + 33 + Initial operator set: 34 + - all answerable blocks: `equals`, `not_equals`, `is_empty`, `is_not_empty` 35 + - short/long text and link: `contains` 36 + - multiple choice: `contains_any` 37 + - number and date: `gt`, `gte`, `lt`, `lte` 38 + 39 + Why this approach: 40 + - Keeps the model evolvable without inventing a second rule format later. 41 + - Lets the builder present operator-appropriate controls and validation. 42 + - Preserves deterministic first-match evaluation. 43 + 44 + Alternatives considered: 45 + - Continue inferring operators from block type alone: too limiting for the requested scope. 46 + - Introduce a generic expression DSL: too complex for the next iteration. 47 + 48 + ### 2. Preserve backward compatibility by upgrading legacy exact-match rules in parsing 49 + Existing branch rules created by the first branching change should continue to work. Parsing should treat legacy exact-match rules as `equals` rules when no operator is present. 50 + 51 + Why this approach: 52 + - Avoids breaking saved draft or published forms. 53 + - Keeps the migration lightweight because branch data lives in JSON config. 54 + 55 + Alternatives considered: 56 + - Require a one-time data migration over all forms: unnecessary complexity for JSON-backed config. 57 + 58 + ### 3. Separate validation into publish blockers and advisory warnings 59 + Validation should continue blocking publish for hard graph errors, while also surfacing non-blocking warnings during editing. Proposed split: 60 + 61 + **Blockers:** 62 + - missing target 63 + - backward/self target 64 + - unreachable block 65 + - invalid operand for the chosen operator 66 + - operator not supported by the source block type 67 + - no valid continuation from a required route branch where the graph dead-ends unexpectedly before remaining reachable required content is satisfiable 68 + 69 + **Warnings:** 70 + - overlapping rules where a broader earlier rule makes a later rule effectively unreachable 71 + - exact-match text/link rules that may be fragile 72 + - choice blocks whose rule coverage is partial and fall through to a default route unexpectedly 73 + - stale branch references caused by reorder/delete operations while still in draft 74 + 75 + Why this approach: 76 + - Keeps publish safety strict without making authoring unbearably rigid. 77 + - Helps creators debug complex flows earlier. 78 + 79 + Alternatives considered: 80 + - Make every warning a blocker: too frustrating for draft authoring. 81 + - Keep only blockers: not enough guidance for advanced branching. 82 + 83 + ### 4. Use the response snapshot plus current form order to show route context 84 + Response review should derive path context from the visited block snapshot already stored on submission and compare it against the full form structure in saved order. The UI can then show: 85 + - visited path in order 86 + - blocks skipped by branching 87 + - the answer shown on each visited decision point 88 + 89 + Why this approach: 90 + - Avoids adding a new response table or separate route log. 91 + - Keeps review faithful to what the respondent actually saw at submission time. 92 + 93 + Alternatives considered: 94 + - Store a separate explicit branch-events log: richer, but unnecessary for the initial review improvement. 95 + 96 + ### 5. Keep runner evaluation server/client shared through the branching domain layer 97 + The same rule-resolution helper should evaluate advanced operators for both client navigation and server-side submission validation. 98 + 99 + Why this approach: 100 + - Prevents drift between what respondents see and what the server accepts. 101 + - Keeps advanced operator semantics consistent across review and validation. 102 + 103 + Alternatives considered: 104 + - Duplicate evaluation logic in the client and server: more fragile and harder to test. 105 + 106 + ## Risks / Trade-offs 107 + 108 + - **More operators increase builder complexity** → Mitigation: scope the operator set tightly and show only operators valid for the selected block type. 109 + - **Advisory warnings can be noisy** → Mitigation: limit warnings to high-signal cases and distinguish them clearly from publish blockers. 110 + - **Legacy exact-match rules could behave unexpectedly if interpreted differently** → Mitigation: map legacy rules only to `equals` semantics and keep normalization deterministic. 111 + - **Skipped/visited route display may confuse creators if the current form has changed since submission** → Mitigation: anchor visited-path display to the stored snapshot and use the current form only for supplemental skipped-block context. 112 + 113 + ## Migration Plan 114 + 115 + 1. Extend branch rule parsing/types with operator-aware schemas and legacy fallback support. 116 + 2. Update shared branch evaluation and validation helpers for the practical v1 operator set. 117 + 3. Expand builder authoring UI to choose operators, operands, and show validation warnings/blockers clearly. 118 + 4. Update response review shaping to expose visited and skipped route context. 119 + 5. Verify existing exact-match branch forms continue to work unchanged. 120 + 121 + Rollback strategy: 122 + - If needed, hide advanced operators in the builder and continue honoring already-saved `equals` behavior. Because the model remains JSON-based, rollback does not require destructive schema reversal. 123 + 124 + ## Open Questions 125 + 126 + - Should response review explicitly show which branch rule fired, or is visited/skipped context enough for the first pass? 127 + - Which warning conditions should remain advisory versus becoming publish blockers after creator feedback? 128 + - Do we want date comparisons to interpret values strictly as local calendar dates or normalized UTC date strings in every surface?
+28
openspec/changes/archive/2026-04-13-improve-branching-logic-review-validation/proposal.md
··· 1 + ## Why 2 + 3 + Branching now supports forward routing and default next targets, but creators still lack the operator range, debugging visibility, and safety checks needed to build and maintain more complex flows confidently. Improving authoring, response review, and validation now will make branching trustworthy enough for production qualification and multi-path forms. 4 + 5 + ## What Changes 6 + 7 + - Expand branching conditions with a practical v1 operator set: `equals`, `not equals`, `is empty`, `is not empty`, text `contains`, multiple-choice `contains any`, and number/date comparison operators. 8 + - Show branching path context in creator response review so creators can see which route a respondent took and which blocks were skipped. 9 + - Strengthen publish-time and builder-time branching validation with clearer issue detection for fragile, overlapping, or incomplete routing setups. 10 + - Improve builder guidance so creators can understand how advanced operators and validation warnings affect route behavior before publishing. 11 + 12 + ## Capabilities 13 + 14 + ### New Capabilities 15 + - None. 16 + 17 + ### Modified Capabilities 18 + - `conversational-form-builder`: branching configuration gains richer operators, stronger validation feedback, and clearer authoring support. 19 + - `anonymous-form-runner`: branching evaluation supports more operator types and preserves route context needed for review. 20 + - `response-review`: creator response inspection exposes the visited branching path and skipped sections for each submission. 21 + 22 + ## Impact 23 + 24 + - Branch rule data model, parsing, and validation logic in the form domain layer 25 + - Builder UI for advanced condition editing and warning presentation 26 + - Public runner route evaluation for richer operator support 27 + - Response detail shaping and creator-facing review pages for path visibility 28 + - Potential translation updates for new branching and review terminology
+20
openspec/changes/archive/2026-04-13-improve-branching-logic-review-validation/specs/anonymous-form-runner/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Runner evaluates practical operator-based branching rules consistently 4 + The system SHALL evaluate saved branching rules using the configured operator semantics for the active block before determining the next step in the respondent path. The runner and submission validation flow SHALL use the same operator behavior. 5 + 6 + #### Scenario: Respondent triggers a numeric comparison rule 7 + - **WHEN** a respondent answers a number or date question with a value that satisfies a saved comparison operator such as greater than or less than 8 + - **THEN** the system routes the respondent to that rule's target block 9 + 10 + #### Scenario: Respondent triggers an empty-answer rule 11 + - **WHEN** a respondent leaves an optional question empty and the current block has a saved `is empty` branch rule 12 + - **THEN** the system routes the respondent using that rule instead of treating the empty answer as unmatched 13 + 14 + #### Scenario: Respondent triggers a multiple-choice contains-any rule 15 + - **WHEN** a respondent selects multiple options and at least one selected option matches a saved `contains any` branch rule 16 + - **THEN** the system routes the respondent to that rule's target block 17 + 18 + #### Scenario: Client and server evaluate the same rule set 19 + - **WHEN** a respondent completes and submits a branched form using supported advanced operators 20 + - **THEN** the submission validation path accepts or rejects the route using the same branching semantics as the public runner
+27
openspec/changes/archive/2026-04-13-improve-branching-logic-review-validation/specs/conversational-form-builder/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Creator can configure operator-based branch conditions on question blocks 4 + The system SHALL allow an authenticated creator to configure branch rules on answerable blocks using operator-aware conditions appropriate to the source block type. The supported operator set SHALL include `equals`, `not equals`, `is empty`, and `is not empty` for answerable blocks; `contains` for text-answer and link blocks; `contains any` for multiple choice blocks; and `greater than`, `greater than or equal`, `less than`, and `less than or equal` for number and date blocks. 5 + 6 + #### Scenario: Creator configures a numeric threshold branch 7 + - **WHEN** an authenticated creator configures a number question with a branch rule using a greater-than or less-than style operator 8 + - **THEN** the system saves that operator and operand as part of the branch rule for that question 9 + 10 + #### Scenario: Creator configures an empty-answer branch 11 + - **WHEN** an authenticated creator configures a branch rule using `is empty` or `is not empty` on an answerable block 12 + - **THEN** the system saves that rule without requiring a comparison value 13 + 14 + #### Scenario: Creator configures a text contains branch 15 + - **WHEN** an authenticated creator configures a text-answer or link question with a `contains` branch rule 16 + - **THEN** the system saves the rule and limits it to a valid operator for that block type 17 + 18 + ### Requirement: Builder distinguishes branching blockers from advisory warnings 19 + The system SHALL surface branching validation feedback while a creator edits a form and SHALL distinguish publish-blocking routing errors from non-blocking advisory warnings. 20 + 21 + #### Scenario: Creator introduces a publish-blocking branching error 22 + - **WHEN** an authenticated creator edits a branch rule so that its target is missing, invalid for that block type, or unreachable in the form graph 23 + - **THEN** the system preserves the draft change, marks the issue as publish-blocking, and prevents publishing until it is fixed 24 + 25 + #### Scenario: Creator introduces a fragile but still valid branch setup 26 + - **WHEN** an authenticated creator creates a branch setup that is structurally valid but likely fragile or overlapping 27 + - **THEN** the system surfaces an advisory warning without preventing the draft from being saved
+12
openspec/changes/archive/2026-04-13-improve-branching-logic-review-validation/specs/response-review/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Creator can inspect branching route context for a submission 4 + The system SHALL allow an authenticated creator to inspect the visited route and skipped sections for an individual submission from a branched form while preserving the submission's saved form-order context. 5 + 6 + #### Scenario: Creator opens a branched submission 7 + - **WHEN** an authenticated creator views a response submitted through a branched form 8 + - **THEN** the system shows which blocks were visited in the submission path and which blocks were skipped by branching 9 + 10 + #### Scenario: Creator reviews a decision point in the visited path 11 + - **WHEN** an authenticated creator inspects a visited branching question in a submission 12 + - **THEN** the system shows the submitted answer in context so the taken route is understandable
+21
openspec/changes/archive/2026-04-13-improve-branching-logic-review-validation/tasks.md
··· 1 + ## 1. Operator-aware branching model 2 + 3 + - [x] 1.1 Extend branch rule schemas and parsers to support explicit operators with legacy exact-match fallback 4 + - [x] 1.2 Update shared branch evaluation helpers for the Practical v1 operator set across builder, runner, and submission validation 5 + - [x] 1.3 Expand branch validation to classify publish blockers and advisory warnings for invalid, overlapping, or fragile rules 6 + 7 + ## 2. Builder authoring and validation UX 8 + 9 + - [x] 2.1 Update branching controls to let creators choose operators and enter operator-specific operands 10 + - [x] 2.2 Show only valid operators for the selected block type and handle operand-less operators such as `is empty` 11 + - [x] 2.3 Surface blocking errors and advisory warnings clearly in the builder without preventing draft saves 12 + 13 + ## 3. Response review path visibility 14 + 15 + - [x] 3.1 Extend response detail shaping to expose visited path and skipped blocks for branched submissions 16 + - [x] 3.2 Update the creator response detail UI to display route context for visited and skipped blocks in submission order 17 + 18 + ## 4. Regression coverage 19 + 20 + - [x] 4.1 Verify existing exact-match branching forms continue to work unchanged after operator support is added 21 + - [x] 4.2 Verify advanced operator routing, warning/blocker behavior, and branched response review context end to end
+46 -15
openspec/specs/anonymous-form-runner/spec.md
··· 12 12 - **THEN** the system does not allow public response collection for that form 13 13 14 14 ### Requirement: Runner presents blocks in a linear one-at-a-time flow 15 - The system SHALL present form blocks in saved order and display exactly one block at a time with forward and backward navigation. Text blocks SHALL appear as part of the flow but SHALL NOT collect an answer. 15 + The system SHALL present form blocks one at a time starting from the first saved block and SHALL resolve each next block by evaluating the current block's saved branch rules against the respondent's current answer. If no branch rule matches, the system SHALL continue to the block's configured default next target when present, or otherwise continue to the next block in saved order. Text blocks SHALL remain part of the flow but SHALL NOT collect an answer. The system SHALL allow backward navigation across the respondent's visited path and SHALL recalculate future visited steps when a prior answer changes. 16 16 17 - #### Scenario: Respondent moves through the form 18 - - **WHEN** a respondent advances through a published form 19 - - **THEN** the system shows each block one at a time in the form's saved order 17 + #### Scenario: Respondent sees a conditional follow-up question 18 + - **WHEN** a respondent answers a question with a value that matches a saved branch rule pointing to a later follow-up block 19 + - **THEN** the system shows that follow-up block as the next step in the runner 20 20 21 - #### Scenario: Respondent returns to a previous block 22 - - **WHEN** a respondent navigates backward in the runner 23 - - **THEN** the system returns them to the previous block without breaking the saved in-progress answers for the session 21 + #### Scenario: Respondent skips a branch-only follow-up 22 + - **WHEN** a respondent answers a question with a value that does not match any saved branch rule 23 + - **THEN** the system continues to the next block in saved order instead of showing the branched follow-up block 24 + 25 + #### Scenario: Respondent changes an earlier branching answer 26 + - **WHEN** a respondent navigates backward, changes an answer that controls branching, and continues again 27 + - **THEN** the system recalculates the subsequent route from that changed answer and replaces the no-longer-valid future steps 24 28 25 29 ### Requirement: Runner shows progress across the full flow 26 - The system SHALL display progress for the respondent based on the linear block sequence, including non-answerable text blocks. 30 + The system SHALL display progress based on the respondent's current visited route rather than assuming every saved block will appear, and SHALL update the visible step indicator when branching or backtracking changes that route. 31 + 32 + #### Scenario: Respondent skips a section through branching 33 + - **WHEN** a respondent takes a branch that skips one or more later blocks 34 + - **THEN** the system updates the visible progress so it reflects the route the respondent is actually taking rather than the full saved block list 27 35 28 - #### Scenario: Respondent views a text block in the flow 29 - - **WHEN** a respondent is on a text block step 30 - - **THEN** the system includes that step in the visible progress through the form 36 + #### Scenario: Respondent changes the route by editing a previous answer 37 + - **WHEN** a respondent goes back and changes an answer that changes the remaining route 38 + - **THEN** the system updates the visible progress to match the recalculated route 31 39 32 40 ### Requirement: Required question blocks are validated before advancement 33 - The system SHALL prevent a respondent from advancing past a required question block until a valid answer is provided for that block type. For required agreement blocks, only an explicit agreed response SHALL satisfy the requirement. 41 + The system SHALL prevent a respondent from advancing past a visited required question block until a valid answer is provided for that block type. For required agreement blocks, only an explicit agreed response SHALL satisfy the requirement. Question blocks skipped by branching SHALL NOT be required for advancement or submission. 34 42 35 43 #### Scenario: Respondent skips a required short text question 36 - - **WHEN** a respondent attempts to continue from a required text question without an answer 44 + - **WHEN** a respondent attempts to continue from a visited required text question without an answer 37 45 - **THEN** the system blocks advancement and indicates that an answer is required 38 46 39 47 #### Scenario: Respondent skips a required multiple choice question 40 - - **WHEN** a respondent attempts to continue from a required multiple choice block without selecting any options 48 + - **WHEN** a respondent attempts to continue from a visited required multiple choice block without selecting any options 41 49 - **THEN** the system blocks advancement and indicates that an answer is required 42 50 43 51 #### Scenario: Respondent does not agree to a required agreement block 44 - - **WHEN** a respondent attempts to continue from a required agreement block without selecting agreed 52 + - **WHEN** a respondent attempts to continue from a visited required agreement block without selecting agreed 45 53 - **THEN** the system blocks advancement and indicates that agreement is required 54 + 55 + #### Scenario: Required block is skipped by a branch 56 + - **WHEN** a respondent takes a branch that bypasses a required question block 57 + - **THEN** the system does not require an answer for that skipped block before allowing completion or submission 46 58 47 59 ### Requirement: Structured question answers are validated by block type 48 60 The system SHALL validate number, link, agreement, and date answers according to the active block kind before allowing a respondent to advance or submit the form. When a text-answer block is configured with custom regex validation, the system SHALL also validate the respondent's text answer against that regex pattern. ··· 100 112 #### Scenario: Form has no custom completion content 101 113 - **WHEN** a respondent submits a published form without customized completion settings 102 114 - **THEN** the system shows the default completion content after submission 115 + 116 + ### Requirement: Runner evaluates practical operator-based branching rules consistently 117 + The system SHALL evaluate saved branching rules using the configured operator semantics for the active block before determining the next step in the respondent path. The runner and submission validation flow SHALL use the same operator behavior. 118 + 119 + #### Scenario: Respondent triggers a numeric comparison rule 120 + - **WHEN** a respondent answers a number or date question with a value that satisfies a saved comparison operator such as greater than or less than 121 + - **THEN** the system routes the respondent to that rule's target block 122 + 123 + #### Scenario: Respondent triggers an empty-answer rule 124 + - **WHEN** a respondent leaves an optional question empty and the current block has a saved `is empty` branch rule 125 + - **THEN** the system routes the respondent using that rule instead of treating the empty answer as unmatched 126 + 127 + #### Scenario: Respondent triggers a multiple-choice contains-any rule 128 + - **WHEN** a respondent selects multiple options and at least one selected option matches a saved `contains any` branch rule 129 + - **THEN** the system routes the respondent to that rule's target block 130 + 131 + #### Scenario: Client and server evaluate the same rule set 132 + - **WHEN** a respondent completes and submits a branched form using supported advanced operators 133 + - **THEN** the submission validation path accepts or rejects the route using the same branching semantics as the public runner
+56
openspec/specs/conversational-form-builder/spec.md
··· 119 119 #### Scenario: Creator opens a form builder in an organization workspace 120 120 - **WHEN** an authenticated creator opens a form that belongs to an organization workspace 121 121 - **THEN** the system shows that organization context in the builder chrome 122 + 123 + ### Requirement: Creator can configure answer-based branch rules on question blocks 124 + The system SHALL allow an authenticated creator to add, edit, reorder, and remove branch rules on answerable blocks in a form located in an accessible workspace. Each branch rule SHALL compare the current block's answer using behavior appropriate to that block type and SHALL send respondents to a later block in the same form. The system SHALL also allow an authenticated creator to configure a default next target on an answerable block so respondents can continue to a chosen later block when no branch rule matches. If no branch rule matches and no default next target is configured, the respondent flow SHALL continue to the next block in saved order. 125 + 126 + #### Scenario: Creator adds a conditional follow-up for one answer 127 + - **WHEN** an authenticated creator configures a single choice question so one option branches to a later follow-up block 128 + - **THEN** the system saves that branch rule on the source question and keeps the unconfigured path flowing to the next saved block 129 + 130 + #### Scenario: Creator defines divergent paths from one question 131 + - **WHEN** an authenticated creator configures different branch rules for different answers on the same question block 132 + - **THEN** the system saves distinct later-block targets for those answers on that source block 133 + 134 + #### Scenario: Creator changes branch priority 135 + - **WHEN** an authenticated creator reorders branch rules on a question block 136 + - **THEN** the system saves the updated evaluation order for that block's branch rules 137 + 138 + ### Requirement: Builder validates branch graphs before publishing 139 + The system SHALL validate saved branch rules before allowing a form to publish. The system SHALL prevent publishing when a branch rule points to a missing, current, or earlier block, or when one or more blocks become unreachable from the first block under the configured branch graph. 140 + 141 + #### Scenario: Creator tries to publish with a backward branch target 142 + - **WHEN** an authenticated creator attempts to publish a form containing a branch rule that points to the source block or an earlier block 143 + - **THEN** the system blocks publishing and indicates that the branch target must move forward in the form 144 + 145 + #### Scenario: Creator tries to publish with an unreachable block 146 + - **WHEN** an authenticated creator attempts to publish a form whose saved branch rules leave a block unreachable from the form start 147 + - **THEN** the system blocks publishing and indicates that the unreachable block must be reconnected or removed 148 + 149 + #### Scenario: Creator saves an invalid draft while editing branching 150 + - **WHEN** an authenticated creator saves draft changes that leave branch validation issues unresolved 151 + - **THEN** the system preserves the draft changes and continues to block publishing until the issues are fixed 152 + 153 + ### Requirement: Creator can configure operator-based branch conditions on question blocks 154 + The system SHALL allow an authenticated creator to configure branch rules on answerable blocks using operator-aware conditions appropriate to the source block type. The supported operator set SHALL include `equals`, `not equals`, `is empty`, and `is not empty` for answerable blocks; `contains` for text-answer and link blocks; `contains any` for multiple choice blocks; and `greater than`, `greater than or equal`, `less than`, and `less than or equal` for number and date blocks. 155 + 156 + #### Scenario: Creator configures a numeric threshold branch 157 + - **WHEN** an authenticated creator configures a number question with a branch rule using a greater-than or less-than style operator 158 + - **THEN** the system saves that operator and operand as part of the branch rule for that question 159 + 160 + #### Scenario: Creator configures an empty-answer branch 161 + - **WHEN** an authenticated creator configures a branch rule using `is empty` or `is not empty` on an answerable block 162 + - **THEN** the system saves that rule without requiring a comparison value 163 + 164 + #### Scenario: Creator configures a text contains branch 165 + - **WHEN** an authenticated creator configures a text-answer or link question with a `contains` branch rule 166 + - **THEN** the system saves the rule and limits it to a valid operator for that block type 167 + 168 + ### Requirement: Builder distinguishes branching blockers from advisory warnings 169 + The system SHALL surface branching validation feedback while a creator edits a form and SHALL distinguish publish-blocking routing errors from non-blocking advisory warnings. 170 + 171 + #### Scenario: Creator introduces a publish-blocking branching error 172 + - **WHEN** an authenticated creator edits a branch rule so that its target is missing, invalid for that block type, or unreachable in the form graph 173 + - **THEN** the system preserves the draft change, marks the issue as publish-blocking, and prevents publishing until it is fixed 174 + 175 + #### Scenario: Creator introduces a fragile but still valid branch setup 176 + - **WHEN** an authenticated creator creates a branch setup that is structurally valid but likely fragile or overlapping 177 + - **THEN** the system surfaces an advisory warning without preventing the draft from being saved
+11
openspec/specs/response-review/spec.md
··· 32 32 #### Scenario: Creator opens responses for an accessible form 33 33 - **WHEN** an authenticated creator opens the responses view for a form in a workspace they can access 34 34 - **THEN** the system displays available export actions for the supported response export formats 35 + 36 + ### Requirement: Creator can inspect branching route context for a submission 37 + The system SHALL allow an authenticated creator to inspect the visited route and skipped sections for an individual submission from a branched form while preserving the submission's saved form-order context. 38 + 39 + #### Scenario: Creator opens a branched submission 40 + - **WHEN** an authenticated creator views a response submitted through a branched form 41 + - **THEN** the system shows which blocks were visited in the submission path and which blocks were skipped by branching 42 + 43 + #### Scenario: Creator reviews a decision point in the visited path 44 + - **WHEN** an authenticated creator inspects a visited branching question in a submission 45 + - **THEN** the system shows the submitted answer in context so the taken route is understandable