this repo has no description
0
fork

Configure Feed

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

feat(builder): add branching graph workspace

+2232 -211
+70
components/builder/branch-rules-editor.test.tsx
··· 158 158 restoreDom(); 159 159 }); 160 160 161 + test("hides branching controls when terminal mode is enabled", () => { 162 + const restoreDom = installTestDom(); 163 + const blockDraft = createBlock({ 164 + id: "question-1", 165 + type: "SHORT_TEXT", 166 + position: 0, 167 + title: "Last question", 168 + config: { 169 + ...getDefaultBlockConfig("SHORT_TEXT"), 170 + endForm: true, 171 + }, 172 + }); 173 + const nextBlock = createBlock({ 174 + id: "question-2", 175 + type: "SHORT_TEXT", 176 + position: 1, 177 + title: "Next", 178 + }); 179 + 180 + const view = render( 181 + <BranchRulesEditorHarness 182 + allBlocks={[blockDraft, nextBlock]} 183 + blockDraft={blockDraft} 184 + />, 185 + ); 186 + 187 + expect(view.getByText("builder.endFormAfterBlock") !== null).toBe(true); 188 + expect(view.queryByText("builder.branchOtherwise")).toBe(null); 189 + expect( 190 + ( 191 + view.getByRole("button", { 192 + name: "builder.addBranchRule", 193 + }) as HTMLButtonElement 194 + ).disabled, 195 + ).toBe(true); 196 + 197 + cleanup(); 198 + restoreDom(); 199 + }); 200 + 201 + test("shows only the terminal toggle for text blocks", () => { 202 + const restoreDom = installTestDom(); 203 + const blockDraft = createBlock({ 204 + id: "intro", 205 + type: "TEXT", 206 + position: 0, 207 + title: "Intro", 208 + }); 209 + const nextBlock = createBlock({ 210 + id: "question-1", 211 + type: "SHORT_TEXT", 212 + position: 1, 213 + title: "Next", 214 + }); 215 + 216 + const view = render( 217 + <BranchRulesEditorHarness 218 + allBlocks={[blockDraft, nextBlock]} 219 + blockDraft={blockDraft} 220 + />, 221 + ); 222 + 223 + expect(view.getByText("builder.endFormAfterBlock") !== null).toBe(true); 224 + expect(view.queryByText("builder.branchOtherwise")).toBe(null); 225 + expect(view.queryByText("builder.branchingHelp")).toBe(null); 226 + 227 + cleanup(); 228 + restoreDom(); 229 + }); 230 + 161 231 test("renders branch issues only for the selected block", () => { 162 232 const restoreDom = installTestDom(); 163 233 const blockDraft = createBlock({
+56 -37
components/builder/branch-rules-editor.tsx
··· 20 20 21 21 import { useI18n } from "@/components/i18n-provider"; 22 22 import { Button } from "@/components/ui/button"; 23 + import { Checkbox } from "@/components/ui/checkbox"; 23 24 import { Input } from "@/components/ui/input"; 24 25 import { 25 26 Select, ··· 32 33 AGREEMENT_ANSWER_VALUES, 33 34 branchOperatorNeedsValue, 34 35 getVisibleBranchOperators, 36 + isQuestionBlock, 35 37 type BranchOperator, 36 38 } from "@/lib/blocks"; 37 39 import { ··· 276 278 const branchRuleSensors = useSensors( 277 279 useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), 278 280 ); 281 + const canConfigureBranchRules = isQuestionBlock(blockDraft.type); 279 282 const branchTargetOptions = getBranchTargetBlocks(allBlocks, blockDraft.id); 280 283 const blockBranchIssues = branchValidationIssues.filter( 281 284 (issue) => issue.blockId === blockDraft.id, ··· 291 294 "defaultNextBlockId" in blockDraft.config 292 295 ? blockDraft.config.defaultNextBlockId 293 296 : null; 297 + const endForm = 298 + "endForm" in blockDraft.config ? blockDraft.config.endForm : false; 294 299 const defaultNextSelectValue = defaultNextBlockId ?? "__linear__"; 295 300 const staleDefaultNextMissing = Boolean( 296 301 defaultNextBlockId && ··· 373 378 variant="secondary" 374 379 size="sm" 375 380 onClick={addBranchRule} 376 - disabled={branchTargetOptions.length === 0} 381 + disabled={endForm || branchTargetOptions.length === 0} 377 382 > 378 383 <Plus className="size-4" /> 379 384 {t("builder.addBranchRule")} 380 385 </Button> 381 386 </div> 382 387 383 - {branchRulesDraft.length ? ( 388 + {!endForm && canConfigureBranchRules && branchRulesDraft.length ? ( 384 389 <DndContext 385 390 sensors={branchRuleSensors} 386 391 collisionDetection={closestCenter} ··· 408 413 </DndContext> 409 414 ) : null} 410 415 411 - <label className="grid gap-2 text-sm text-[var(--muted)] sm:max-w-md"> 412 - <span className="font-medium text-[var(--ink)]"> 413 - {t("builder.branchOtherwise")} 414 - </span> 415 - <Select 416 - value={defaultNextSelectValue} 417 - onValueChange={(value) => 418 - updateConfig({ 419 - defaultNextBlockId: value === "__linear__" ? null : value, 420 - }) 421 - } 422 - > 423 - <SelectTrigger className="h-10 w-full text-sm font-medium"> 424 - <SelectValue /> 425 - </SelectTrigger> 426 - <SelectContent> 427 - <SelectItem value="__linear__"> 428 - {t("builder.branchDefaultLinear")} 429 - </SelectItem> 430 - {staleDefaultNextMissing && defaultNextBlockId ? ( 431 - <SelectItem value={defaultNextBlockId}> 432 - {t("builder.branchMissingTarget")} 416 + {!endForm && canConfigureBranchRules ? ( 417 + <label className="grid gap-2 text-sm text-[var(--muted)] sm:max-w-md"> 418 + <span className="font-medium text-[var(--ink)]"> 419 + {t("builder.branchOtherwise")} 420 + </span> 421 + <Select 422 + value={defaultNextSelectValue} 423 + onValueChange={(value) => 424 + updateConfig({ 425 + defaultNextBlockId: value === "__linear__" ? null : value, 426 + }) 427 + } 428 + > 429 + <SelectTrigger className="h-10 w-full text-sm font-medium"> 430 + <SelectValue /> 431 + </SelectTrigger> 432 + <SelectContent> 433 + <SelectItem value="__linear__"> 434 + {t("builder.branchDefaultLinear")} 433 435 </SelectItem> 434 - ) : null} 435 - {branchTargetOptions.map((targetBlock) => ( 436 - <SelectItem key={targetBlock.id} value={targetBlock.id}> 437 - {getBlockDisplayLabel(targetBlock)} 438 - </SelectItem> 439 - ))} 440 - </SelectContent> 441 - </Select> 442 - </label> 436 + {staleDefaultNextMissing && defaultNextBlockId ? ( 437 + <SelectItem value={defaultNextBlockId}> 438 + {t("builder.branchMissingTarget")} 439 + </SelectItem> 440 + ) : null} 441 + {branchTargetOptions.map((targetBlock) => ( 442 + <SelectItem key={targetBlock.id} value={targetBlock.id}> 443 + {getBlockDisplayLabel(targetBlock)} 444 + </SelectItem> 445 + ))} 446 + </SelectContent> 447 + </Select> 448 + </label> 449 + ) : null} 443 450 444 - {branchTargetOptions.length === 0 ? ( 451 + {!endForm && 452 + canConfigureBranchRules && 453 + branchTargetOptions.length === 0 ? ( 445 454 <p className="text-xs text-[var(--muted)]"> 446 455 {t("builder.branchingNoTargets")} 447 456 </p> 448 457 ) : null} 449 - <p className="text-xs text-[var(--muted)]"> 450 - {t("builder.branchingHelp", { value: branchRulePlaceholder })} 451 - </p> 458 + <label className="flex items-center gap-3 text-sm text-[var(--muted)]"> 459 + <Checkbox 460 + checked={endForm} 461 + onChange={(event) => 462 + updateConfig({ 463 + endForm: event.target.checked, 464 + }) 465 + } 466 + /> 467 + <span className="font-medium text-[var(--ink)]"> 468 + {t("builder.endFormAfterBlock")} 469 + </span> 470 + </label> 452 471 453 472 {blockBranchBlockers.length ? ( 454 473 <div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-3 text-sm text-[var(--ink)]">
+812
components/builder/branching-graph-workspace.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useMemo, useRef, useState } from "react"; 4 + import { LoaderCircle } from "lucide-react"; 5 + import type { 6 + Dispatch, 7 + PointerEvent as ReactPointerEvent, 8 + SetStateAction, 9 + WheelEvent as ReactWheelEvent, 10 + } from "react"; 11 + 12 + import { BranchRulesEditor } from "@/components/builder/branch-rules-editor"; 13 + import { useI18n } from "@/components/i18n-provider"; 14 + import { AuthoredMarkdown } from "@/components/ui/authored-markdown"; 15 + import { Badge } from "@/components/ui/badge"; 16 + import { Button } from "@/components/ui/button"; 17 + import { Card } from "@/components/ui/card"; 18 + import { 19 + blockTypeTranslationKeys, 20 + isQuestionBlock, 21 + type BranchOperator, 22 + } from "@/lib/blocks"; 23 + import { 24 + getBlockDisplayLabel, 25 + type BranchValidationIssue, 26 + } from "@/lib/branching"; 27 + import { 28 + buildBranchingGraph, 29 + type BranchingGraphEdge, 30 + } from "@/lib/branching-graph"; 31 + import type { BranchRuleDraft } from "@/lib/form-builder-drafts"; 32 + import type { BuilderBlock } from "@/lib/form-types"; 33 + import { cn } from "@/lib/utils"; 34 + 35 + function getEdgePath( 36 + source: { x: number; y: number; width: number; height: number }, 37 + target: { x: number; y: number; width: number; height: number }, 38 + options?: { 39 + minControlOffset?: number; 40 + controlOffsetFactor?: number; 41 + maxControlOffset?: number; 42 + }, 43 + ) { 44 + const startX = source.x + source.width; 45 + const startY = source.y + source.height / 2; 46 + const endX = target.x; 47 + const endY = target.y + target.height / 2; 48 + const minControlOffset = options?.minControlOffset ?? 48; 49 + const controlOffsetFactor = options?.controlOffsetFactor ?? 0.32; 50 + const maxControlOffset = 51 + options?.maxControlOffset ?? Number.POSITIVE_INFINITY; 52 + const controlOffset = Math.min( 53 + maxControlOffset, 54 + Math.max(minControlOffset, Math.abs(endX - startX) * controlOffsetFactor), 55 + ); 56 + 57 + return `M ${startX} ${startY} C ${startX + controlOffset} ${startY}, ${endX - controlOffset} ${endY}, ${endX} ${endY}`; 58 + } 59 + 60 + function getEdgeLabelPosition( 61 + source: { x: number; y: number; width: number; height: number }, 62 + target: { x: number; y: number; width: number; height: number }, 63 + ) { 64 + const startX = source.x + source.width; 65 + const endX = target.x; 66 + 67 + return { 68 + x: startX + (endX - startX) / 2, 69 + y: source.y + source.height / 2 + (target.y - source.y) / 2, 70 + }; 71 + } 72 + 73 + function getBranchOperatorLabel( 74 + t: (key: string, values?: Record<string, string | number>) => string, 75 + blockType: BuilderBlock["type"], 76 + operator: BranchOperator, 77 + ) { 78 + if ( 79 + blockType === "SINGLE_CHOICE" && 80 + (operator === "equals" || operator === "not_equals") 81 + ) { 82 + return t(`builder.branchOperatorLabels.singleChoice.${operator}`); 83 + } 84 + 85 + if (blockType === "MULTIPLE_CHOICE" && operator === "contains_any") { 86 + return t("builder.branchOperatorLabels.multipleChoice.contains_any"); 87 + } 88 + 89 + if ( 90 + blockType === "AGREEMENT" && 91 + (operator === "equals" || operator === "not_equals") 92 + ) { 93 + return t(`builder.branchOperatorLabels.agreement.${operator}`); 94 + } 95 + 96 + if ( 97 + blockType === "DATE" && 98 + ["equals", "not_equals", "gt", "gte", "lt", "lte"].includes(operator) 99 + ) { 100 + return t(`builder.branchOperatorLabels.date.${operator}`); 101 + } 102 + 103 + return t(`builder.branchOperators.${operator}`); 104 + } 105 + 106 + function getGraphEdgeLabel( 107 + t: (key: string, values?: Record<string, string | number>) => string, 108 + block: BuilderBlock, 109 + edge: BranchingGraphEdge, 110 + ) { 111 + if (edge.kind === "linear") { 112 + return null; 113 + } 114 + 115 + if (edge.kind === "default") { 116 + return t("builder.graphDefaultRoute"); 117 + } 118 + 119 + const operatorLabel = getBranchOperatorLabel( 120 + t, 121 + block.type, 122 + edge.operator ?? "equals", 123 + ); 124 + 125 + if (block.type === "AGREEMENT" && edge.value) { 126 + return `${operatorLabel}: ${t(`builder.branchAgreementValues.${edge.value}`)}`; 127 + } 128 + 129 + if (edge.value) { 130 + return `${operatorLabel}: ${edge.value}`; 131 + } 132 + 133 + return operatorLabel; 134 + } 135 + 136 + type GraphPanState = { 137 + pointerId: number; 138 + startX: number; 139 + startY: number; 140 + scrollLeft: number; 141 + scrollTop: number; 142 + }; 143 + 144 + type GraphViewport = { 145 + left: number; 146 + top: number; 147 + width: number; 148 + height: number; 149 + }; 150 + 151 + type GraphNodePositionOverride = { 152 + x: number; 153 + y: number; 154 + }; 155 + 156 + type GraphNodeDragState = { 157 + pointerId: number; 158 + blockId: string; 159 + startClientX: number; 160 + startClientY: number; 161 + startNodeX: number; 162 + startNodeY: number; 163 + moved: boolean; 164 + }; 165 + 166 + export function BranchingGraphWorkspace({ 167 + allBlocks, 168 + selectedBlockId, 169 + selectedGraphEdgeId, 170 + blockDraft, 171 + branchRulesDraft, 172 + branchValidationIssues, 173 + setBlockDraft, 174 + setBranchRulesDraft, 175 + saveBlock, 176 + busy, 177 + onSelectBlock, 178 + onSelectEdge, 179 + }: { 180 + allBlocks: BuilderBlock[]; 181 + selectedBlockId: string | null; 182 + selectedGraphEdgeId: string | null; 183 + blockDraft: BuilderBlock | null; 184 + branchRulesDraft: BranchRuleDraft[]; 185 + branchValidationIssues: BranchValidationIssue[]; 186 + setBlockDraft: Dispatch<SetStateAction<BuilderBlock | null>>; 187 + setBranchRulesDraft: Dispatch<SetStateAction<BranchRuleDraft[]>>; 188 + saveBlock: () => Promise<boolean>; 189 + busy: string | null; 190 + onSelectBlock: (blockId: string) => void; 191 + onSelectEdge: (edgeId: string, sourceBlockId: string) => void; 192 + }) { 193 + const { t } = useI18n(); 194 + const graphScrollRef = useRef<HTMLDivElement | null>(null); 195 + const graphPanStateRef = useRef<GraphPanState | null>(null); 196 + const graphNodeDragStateRef = useRef<GraphNodeDragState | null>(null); 197 + const suppressNodeClickRef = useRef<string | null>(null); 198 + const hasInitializedGraphCameraRef = useRef(false); 199 + const [isPanningGraph, setIsPanningGraph] = useState(false); 200 + const [graphNodePositionOverrides, setGraphNodePositionOverrides] = useState< 201 + Record<string, GraphNodePositionOverride> 202 + >({}); 203 + const [graphViewport, setGraphViewport] = useState<GraphViewport>({ 204 + left: 0, 205 + top: 0, 206 + width: 0, 207 + height: 0, 208 + }); 209 + const graph = useMemo(() => buildBranchingGraph(allBlocks), [allBlocks]); 210 + const positionedNodes = useMemo( 211 + () => 212 + graph.nodes.map((node) => { 213 + const override = graphNodePositionOverrides[node.blockId]; 214 + return override ? { ...node, ...override } : node; 215 + }), 216 + [graph.nodes, graphNodePositionOverrides], 217 + ); 218 + const nodesById = useMemo( 219 + () => new Map(positionedNodes.map((node) => [node.blockId, node])), 220 + [positionedNodes], 221 + ); 222 + const selectedEdge = 223 + graph.edges.find((edge) => edge.id === selectedGraphEdgeId) ?? null; 224 + const selectedEdgeTarget = selectedEdge 225 + ? (allBlocks.find((block) => block.id === selectedEdge.targetBlockId) ?? 226 + null) 227 + : null; 228 + const isSavingSelectedBlock = Boolean( 229 + blockDraft && busy === `block-${blockDraft.id}`, 230 + ); 231 + 232 + function updateConfig(patch: Record<string, unknown>) { 233 + setBlockDraft((current) => 234 + current 235 + ? { 236 + ...current, 237 + config: { 238 + ...(current.config as Record<string, unknown>), 239 + ...patch, 240 + } as BuilderBlock["config"], 241 + } 242 + : current, 243 + ); 244 + } 245 + 246 + function syncGraphViewport() { 247 + const container = graphScrollRef.current; 248 + 249 + if (!container) { 250 + return; 251 + } 252 + 253 + setGraphViewport({ 254 + left: container.scrollLeft, 255 + top: container.scrollTop, 256 + width: container.clientWidth, 257 + height: container.clientHeight, 258 + }); 259 + } 260 + 261 + useEffect(() => { 262 + const container = graphScrollRef.current; 263 + const firstNode = positionedNodes[0]; 264 + 265 + if (!container) { 266 + return; 267 + } 268 + 269 + if (!hasInitializedGraphCameraRef.current && firstNode) { 270 + const nextScrollTop = Math.max( 271 + 0, 272 + firstNode.y + firstNode.height / 2 - container.clientHeight / 2, 273 + ); 274 + container.scrollTop = nextScrollTop; 275 + hasInitializedGraphCameraRef.current = true; 276 + } 277 + 278 + syncGraphViewport(); 279 + }, [graph.width, graph.height, positionedNodes]); 280 + 281 + function handleGraphPointerDown(event: ReactPointerEvent<HTMLDivElement>) { 282 + if (event.button !== 0) { 283 + return; 284 + } 285 + 286 + if ((event.target as HTMLElement).closest("button")) { 287 + return; 288 + } 289 + 290 + const container = graphScrollRef.current; 291 + 292 + if (!container) { 293 + return; 294 + } 295 + 296 + graphPanStateRef.current = { 297 + pointerId: event.pointerId, 298 + startX: event.clientX, 299 + startY: event.clientY, 300 + scrollLeft: container.scrollLeft, 301 + scrollTop: container.scrollTop, 302 + }; 303 + 304 + container.setPointerCapture(event.pointerId); 305 + setIsPanningGraph(true); 306 + } 307 + 308 + function handleGraphPointerMove(event: ReactPointerEvent<HTMLDivElement>) { 309 + const container = graphScrollRef.current; 310 + const panState = graphPanStateRef.current; 311 + 312 + if (!container || !panState || panState.pointerId !== event.pointerId) { 313 + return; 314 + } 315 + 316 + container.scrollLeft = 317 + panState.scrollLeft - (event.clientX - panState.startX); 318 + container.scrollTop = 319 + panState.scrollTop - (event.clientY - panState.startY); 320 + syncGraphViewport(); 321 + } 322 + 323 + function handleGraphPointerEnd(event: ReactPointerEvent<HTMLDivElement>) { 324 + const container = graphScrollRef.current; 325 + const panState = graphPanStateRef.current; 326 + 327 + if (!container || !panState || panState.pointerId !== event.pointerId) { 328 + return; 329 + } 330 + 331 + if (container.hasPointerCapture(event.pointerId)) { 332 + container.releasePointerCapture(event.pointerId); 333 + } 334 + 335 + graphPanStateRef.current = null; 336 + setIsPanningGraph(false); 337 + } 338 + 339 + function handleGraphNodePointerDown( 340 + event: ReactPointerEvent<HTMLButtonElement>, 341 + node: (typeof positionedNodes)[number], 342 + ) { 343 + if (event.button !== 0) { 344 + return; 345 + } 346 + 347 + event.preventDefault(); 348 + event.stopPropagation(); 349 + 350 + graphNodeDragStateRef.current = { 351 + pointerId: event.pointerId, 352 + blockId: node.blockId, 353 + startClientX: event.clientX, 354 + startClientY: event.clientY, 355 + startNodeX: node.x, 356 + startNodeY: node.y, 357 + moved: false, 358 + }; 359 + 360 + event.currentTarget.setPointerCapture(event.pointerId); 361 + } 362 + 363 + function handleGraphNodePointerMove( 364 + event: ReactPointerEvent<HTMLButtonElement>, 365 + ) { 366 + const dragState = graphNodeDragStateRef.current; 367 + 368 + if (!dragState || dragState.pointerId !== event.pointerId) { 369 + return; 370 + } 371 + 372 + const deltaX = event.clientX - dragState.startClientX; 373 + const deltaY = event.clientY - dragState.startClientY; 374 + 375 + if (!dragState.moved && Math.abs(deltaX) + Math.abs(deltaY) > 3) { 376 + dragState.moved = true; 377 + } 378 + 379 + setGraphNodePositionOverrides((current) => ({ 380 + ...current, 381 + [dragState.blockId]: { 382 + x: Math.max(24, dragState.startNodeX + deltaX), 383 + y: Math.max(24, dragState.startNodeY + deltaY), 384 + }, 385 + })); 386 + } 387 + 388 + function handleGraphNodePointerUp( 389 + event: ReactPointerEvent<HTMLButtonElement>, 390 + ) { 391 + const dragState = graphNodeDragStateRef.current; 392 + 393 + if (!dragState || dragState.pointerId !== event.pointerId) { 394 + return; 395 + } 396 + 397 + if (event.currentTarget.hasPointerCapture(event.pointerId)) { 398 + event.currentTarget.releasePointerCapture(event.pointerId); 399 + } 400 + 401 + graphNodeDragStateRef.current = null; 402 + 403 + if (dragState.moved) { 404 + suppressNodeClickRef.current = dragState.blockId; 405 + return; 406 + } 407 + 408 + onSelectBlock(dragState.blockId); 409 + } 410 + 411 + function handleGraphNodeClick(blockId: string) { 412 + if (suppressNodeClickRef.current === blockId) { 413 + suppressNodeClickRef.current = null; 414 + return; 415 + } 416 + 417 + onSelectBlock(blockId); 418 + } 419 + 420 + function handleGraphWheel(event: ReactWheelEvent<HTMLDivElement>) { 421 + const container = graphScrollRef.current; 422 + 423 + if (!container) { 424 + return; 425 + } 426 + 427 + const nextScrollLeft = container.scrollLeft + event.deltaX; 428 + const nextScrollTop = container.scrollTop + event.deltaY; 429 + const canScrollHorizontally = 430 + container.scrollWidth > container.clientWidth && event.deltaX !== 0; 431 + const canScrollVertically = 432 + container.scrollHeight > container.clientHeight && event.deltaY !== 0; 433 + 434 + if (!canScrollHorizontally && !canScrollVertically) { 435 + return; 436 + } 437 + 438 + event.preventDefault(); 439 + 440 + if (canScrollHorizontally) { 441 + container.scrollLeft = nextScrollLeft; 442 + } 443 + 444 + if (canScrollVertically) { 445 + container.scrollTop = nextScrollTop; 446 + } 447 + 448 + syncGraphViewport(); 449 + } 450 + 451 + const minimapWidth = 192; 452 + const minimapHeight = 128; 453 + const minimapScale = Math.min( 454 + minimapWidth / Math.max(graph.width, 1), 455 + minimapHeight / Math.max(graph.height, 1), 456 + ); 457 + const minimapContentWidth = graph.width * minimapScale; 458 + const minimapContentHeight = graph.height * minimapScale; 459 + const minimapOffsetX = (minimapWidth - minimapContentWidth) / 2; 460 + const minimapOffsetY = (minimapHeight - minimapContentHeight) / 2; 461 + 462 + return ( 463 + <div className="space-y-5"> 464 + <Card className="relative overflow-hidden bg-[var(--bg-strong)] p-0"> 465 + {graph.nodes.length ? ( 466 + <div 467 + ref={graphScrollRef} 468 + className="relative overflow-x-auto overflow-y-auto p-4 lg:max-h-[calc(100vh-18rem)]" 469 + onPointerDown={handleGraphPointerDown} 470 + onPointerMove={handleGraphPointerMove} 471 + onPointerUp={handleGraphPointerEnd} 472 + onPointerCancel={handleGraphPointerEnd} 473 + onWheel={handleGraphWheel} 474 + onScroll={syncGraphViewport} 475 + style={{ 476 + cursor: isPanningGraph ? "grabbing" : "grab", 477 + }} 478 + > 479 + <div 480 + className="relative" 481 + style={{ 482 + width: `${graph.width}px`, 483 + height: `${graph.height}px`, 484 + }} 485 + > 486 + <svg 487 + className="absolute inset-0 h-full w-full" 488 + aria-label={t("builder.graphCanvasAria")} 489 + > 490 + {graph.edges.map((edge) => { 491 + const sourceNode = nodesById.get(edge.sourceBlockId); 492 + const targetNode = nodesById.get(edge.targetBlockId); 493 + 494 + if (!sourceNode || !targetNode) { 495 + return null; 496 + } 497 + 498 + const isSelected = selectedGraphEdgeId === edge.id; 499 + const isActiveFromBlock = 500 + !isSelected && selectedBlockId === edge.sourceBlockId; 501 + 502 + return ( 503 + <path 504 + key={edge.id} 505 + d={getEdgePath(sourceNode, targetNode)} 506 + fill="none" 507 + stroke={ 508 + isSelected 509 + ? "var(--accent)" 510 + : isActiveFromBlock 511 + ? "color-mix(in srgb, var(--accent) 65%, var(--line-strong))" 512 + : "color-mix(in srgb, var(--line-strong) 80%, transparent)" 513 + } 514 + strokeWidth={isSelected ? 3 : 2} 515 + strokeDasharray={ 516 + edge.kind === "linear" ? undefined : "8 6" 517 + } 518 + opacity={isSelected || isActiveFromBlock ? 1 : 0.72} 519 + /> 520 + ); 521 + })} 522 + </svg> 523 + 524 + {graph.edges.map((edge) => { 525 + const sourceNode = nodesById.get(edge.sourceBlockId); 526 + const targetNode = nodesById.get(edge.targetBlockId); 527 + const sourceBlock = allBlocks.find( 528 + (block) => block.id === edge.sourceBlockId, 529 + ); 530 + 531 + if (!sourceNode || !targetNode || !sourceBlock) { 532 + return null; 533 + } 534 + 535 + const edgeLabel = getGraphEdgeLabel(t, sourceBlock, edge); 536 + 537 + if (!edgeLabel) { 538 + return null; 539 + } 540 + 541 + const labelPosition = getEdgeLabelPosition( 542 + sourceNode, 543 + targetNode, 544 + ); 545 + const isSelected = selectedGraphEdgeId === edge.id; 546 + 547 + return ( 548 + <button 549 + key={`${edge.id}-label`} 550 + type="button" 551 + className={cn( 552 + "absolute -translate-x-1/2 -translate-y-1/2 rounded-full border px-3 py-1 text-xs font-medium shadow-[var(--shadow-card)] transition", 553 + isSelected 554 + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)]" 555 + : "border-[color:var(--line)] bg-[var(--bg-strong)] text-[var(--muted)] hover:border-[var(--line-strong)] hover:text-[var(--ink)]", 556 + )} 557 + style={{ 558 + left: `${labelPosition.x}px`, 559 + top: `${labelPosition.y}px`, 560 + }} 561 + onClick={() => onSelectEdge(edge.id, edge.sourceBlockId)} 562 + > 563 + {edgeLabel} 564 + </button> 565 + ); 566 + })} 567 + 568 + {positionedNodes.map((node) => { 569 + const isSelected = selectedBlockId === node.blockId; 570 + 571 + return ( 572 + <button 573 + key={node.blockId} 574 + type="button" 575 + className={cn( 576 + "absolute flex items-start justify-start rounded-[24px] border p-4 text-left transition", 577 + isSelected 578 + ? "border-[var(--accent)] bg-[var(--accent-soft)] shadow-[var(--shadow-elevated)]" 579 + : "border-[color:var(--line)] bg-[var(--bg-start)] hover:border-[var(--line-strong)] hover:bg-[var(--bg)]", 580 + )} 581 + style={{ 582 + left: `${node.x}px`, 583 + top: `${node.y}px`, 584 + width: `${node.width}px`, 585 + height: `${node.height}px`, 586 + }} 587 + onPointerDown={(event) => 588 + handleGraphNodePointerDown(event, node) 589 + } 590 + onPointerMove={handleGraphNodePointerMove} 591 + onPointerUp={handleGraphNodePointerUp} 592 + onPointerCancel={handleGraphNodePointerUp} 593 + onClick={() => handleGraphNodeClick(node.blockId)} 594 + > 595 + <div className="w-full self-start"> 596 + <Badge> 597 + {t(blockTypeTranslationKeys[node.block.type])} 598 + </Badge> 599 + <h3 className="mt-4 line-clamp-2 text-base font-semibold text-[var(--ink)]"> 600 + {node.label} 601 + </h3> 602 + </div> 603 + </button> 604 + ); 605 + })} 606 + </div> 607 + </div> 608 + ) : ( 609 + <div className="flex min-h-[420px] items-center justify-center p-8 text-center"> 610 + <div> 611 + <p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--accent)]"> 612 + {t("builder.graphModeEyebrow")} 613 + </p> 614 + <h3 className="mt-4 font-display text-3xl text-[var(--ink)]"> 615 + {t("builder.graphEmptyTitle")} 616 + </h3> 617 + <p className="mt-3 max-w-md text-sm leading-6 text-[var(--muted)]"> 618 + {t("builder.graphEmptyDescription")} 619 + </p> 620 + </div> 621 + </div> 622 + )} 623 + 624 + {graph.nodes.length ? ( 625 + <div className="pointer-events-none absolute bottom-4 right-4 z-10"> 626 + <div className="overflow-hidden rounded-[18px] border border-[color:color-mix(in_srgb,var(--line)_45%,transparent)] shadow-[var(--shadow-surface)] backdrop-blur-sm"> 627 + <svg 628 + aria-hidden="true" 629 + width={minimapWidth} 630 + height={minimapHeight} 631 + viewBox={`0 0 ${minimapWidth} ${minimapHeight}`} 632 + className="block" 633 + > 634 + <rect 635 + x="0" 636 + y="0" 637 + width={minimapWidth} 638 + height={minimapHeight} 639 + rx="14" 640 + fill="color-mix(in srgb, var(--bg-end) 92%, transparent)" 641 + /> 642 + {graph.edges.map((edge) => { 643 + const sourceNode = nodesById.get(edge.sourceBlockId); 644 + const targetNode = nodesById.get(edge.targetBlockId); 645 + 646 + if (!sourceNode || !targetNode) { 647 + return null; 648 + } 649 + 650 + const scaledSource = { 651 + ...sourceNode, 652 + x: minimapOffsetX + sourceNode.x * minimapScale, 653 + y: minimapOffsetY + sourceNode.y * minimapScale, 654 + width: sourceNode.width * minimapScale, 655 + height: sourceNode.height * minimapScale, 656 + }; 657 + const scaledTarget = { 658 + ...targetNode, 659 + x: minimapOffsetX + targetNode.x * minimapScale, 660 + y: minimapOffsetY + targetNode.y * minimapScale, 661 + width: targetNode.width * minimapScale, 662 + height: targetNode.height * minimapScale, 663 + }; 664 + 665 + return ( 666 + <path 667 + key={`minimap-${edge.id}`} 668 + d={getEdgePath(scaledSource, scaledTarget, { 669 + minControlOffset: 6, 670 + controlOffsetFactor: 0.14, 671 + maxControlOffset: 14, 672 + })} 673 + fill="none" 674 + stroke="color-mix(in srgb, var(--line-strong) 75%, transparent)" 675 + strokeWidth={1.25} 676 + opacity={0.8} 677 + /> 678 + ); 679 + })} 680 + {positionedNodes.map((node) => { 681 + const isSelected = selectedBlockId === node.blockId; 682 + 683 + return ( 684 + <rect 685 + key={`minimap-node-${node.blockId}`} 686 + x={minimapOffsetX + node.x * minimapScale} 687 + y={minimapOffsetY + node.y * minimapScale} 688 + width={Math.max(node.width * minimapScale, 8)} 689 + height={Math.max(node.height * minimapScale, 8)} 690 + rx={2.5} 691 + fill={ 692 + isSelected 693 + ? "var(--accent)" 694 + : "color-mix(in srgb, var(--surface-strong) 88%, transparent)" 695 + } 696 + stroke="color-mix(in srgb, var(--line-strong) 80%, transparent)" 697 + strokeWidth={1} 698 + /> 699 + ); 700 + })} 701 + <rect 702 + x={minimapOffsetX + graphViewport.left * minimapScale} 703 + y={minimapOffsetY + graphViewport.top * minimapScale} 704 + width={Math.max(graphViewport.width * minimapScale, 12)} 705 + height={Math.max(graphViewport.height * minimapScale, 12)} 706 + rx={6} 707 + fill="color-mix(in srgb, var(--accent-soft) 22%, transparent)" 708 + stroke="var(--accent)" 709 + strokeWidth={1.5} 710 + /> 711 + </svg> 712 + </div> 713 + </div> 714 + ) : null} 715 + </Card> 716 + 717 + <Card className="bg-[var(--bg-strong)] p-6"> 718 + {!selectedBlockId || !blockDraft ? ( 719 + <div> 720 + <h3 className="font-display text-3xl text-[var(--ink)]"> 721 + {t("builder.graphSelectionTitle")} 722 + </h3> 723 + <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 724 + {t("builder.graphSelectionDescription")} 725 + </p> 726 + </div> 727 + ) : !isQuestionBlock(blockDraft.type) ? ( 728 + <div className="space-y-6"> 729 + <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> 730 + <div> 731 + <h3 className="font-display text-3xl text-[var(--ink)]"> 732 + {getBlockDisplayLabel(blockDraft)} 733 + </h3> 734 + {blockDraft.description ? ( 735 + <AuthoredMarkdown 736 + content={blockDraft.description} 737 + className="mt-4 max-w-2xl text-sm leading-7 text-[var(--muted)]" 738 + /> 739 + ) : null} 740 + <p className="mt-4 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 741 + {t("builder.graphTextBlockDescription")} 742 + </p> 743 + </div> 744 + <Button 745 + onClick={() => void saveBlock()} 746 + disabled={isSavingSelectedBlock} 747 + > 748 + {isSavingSelectedBlock ? ( 749 + <LoaderCircle className="size-4 animate-spin" /> 750 + ) : null} 751 + {t("builder.saveBlock")} 752 + </Button> 753 + </div> 754 + 755 + <BranchRulesEditor 756 + allBlocks={allBlocks} 757 + blockDraft={blockDraft} 758 + branchRulesDraft={branchRulesDraft} 759 + branchValidationIssues={branchValidationIssues} 760 + setBranchRulesDraft={setBranchRulesDraft} 761 + updateConfig={updateConfig} 762 + /> 763 + </div> 764 + ) : ( 765 + <div className="space-y-6"> 766 + <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> 767 + <div> 768 + <h3 className="font-display text-3xl text-[var(--ink)]"> 769 + {getBlockDisplayLabel(blockDraft)} 770 + </h3> 771 + {blockDraft.description ? ( 772 + <AuthoredMarkdown 773 + content={blockDraft.description} 774 + className="mt-4 max-w-2xl text-sm leading-7 text-[var(--muted)]" 775 + /> 776 + ) : null} 777 + {selectedEdge && selectedEdgeTarget ? ( 778 + <p className="mt-4 text-sm leading-6 text-[var(--muted)]"> 779 + {t("builder.graphSelectedRoute", { 780 + route: 781 + getGraphEdgeLabel(t, blockDraft, selectedEdge) ?? 782 + t("builder.graphDefaultRoute"), 783 + target: getBlockDisplayLabel(selectedEdgeTarget), 784 + })} 785 + </p> 786 + ) : null} 787 + </div> 788 + <Button 789 + onClick={() => void saveBlock()} 790 + disabled={isSavingSelectedBlock} 791 + > 792 + {isSavingSelectedBlock ? ( 793 + <LoaderCircle className="size-4 animate-spin" /> 794 + ) : null} 795 + {t("builder.saveBlock")} 796 + </Button> 797 + </div> 798 + 799 + <BranchRulesEditor 800 + allBlocks={allBlocks} 801 + blockDraft={blockDraft} 802 + branchRulesDraft={branchRulesDraft} 803 + branchValidationIssues={branchValidationIssues} 804 + setBranchRulesDraft={setBranchRulesDraft} 805 + updateConfig={updateConfig} 806 + /> 807 + </div> 808 + )} 809 + </Card> 810 + </div> 811 + ); 812 + }
+8 -11
components/form-builder-panels.tsx
··· 50 50 import { Textarea } from "@/components/ui/textarea"; 51 51 import { 52 52 blockTypeTranslationKeys, 53 - isQuestionBlock, 54 53 type AgreementBlockConfig, 55 54 type BlockConfig, 56 55 type LinkBlockConfig, ··· 869 868 </div> 870 869 )} 871 870 872 - {isQuestionBlock(blockDraft.type) ? ( 873 - <BranchRulesEditor 874 - allBlocks={allBlocks} 875 - blockDraft={blockDraft} 876 - branchRulesDraft={branchRulesDraft} 877 - branchValidationIssues={branchValidationIssues} 878 - setBranchRulesDraft={setBranchRulesDraft} 879 - updateConfig={updateConfig} 880 - /> 881 - ) : null} 871 + <BranchRulesEditor 872 + allBlocks={allBlocks} 873 + blockDraft={blockDraft} 874 + branchRulesDraft={branchRulesDraft} 875 + branchValidationIssues={branchValidationIssues} 876 + setBranchRulesDraft={setBranchRulesDraft} 877 + updateConfig={updateConfig} 878 + /> 882 879 </div> 883 880 884 881 <div className="flex flex-wrap items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6">
+159 -11
components/form-builder.test.tsx
··· 70 70 ); 71 71 } 72 72 73 - describe("FormBuilder unsaved block protection", () => { 73 + async function cleanupAndRestore(restoreDom: () => void) { 74 + cleanup(); 75 + await new Promise((resolve) => setTimeout(resolve, 0)); 76 + restoreDom(); 77 + } 78 + 79 + describe("FormBuilder", () => { 74 80 test("switches blocks immediately when there are no unsaved edits", async () => { 75 81 const restoreDom = installTestDom(); 76 82 const firstBlock = createBlock({ ··· 95 101 }); 96 102 expect(view.queryByText("builder.unsavedBlockChangesTitle")).toBe(null); 97 103 98 - cleanup(); 99 - restoreDom(); 104 + await cleanupAndRestore(restoreDom); 100 105 }); 101 106 102 107 test("opens form settings immediately when there are no unsaved edits", async () => { ··· 117 122 }); 118 123 expect(view.queryByText("builder.unsavedBlockChangesTitle")).toBe(null); 119 124 120 - cleanup(); 121 - restoreDom(); 125 + await cleanupAndRestore(restoreDom); 122 126 }); 123 127 124 128 test("lets the creator cancel or discard unsaved changes before opening form settings", async () => { ··· 171 175 expect(view.getByText("builder.settingsTitle") !== null).toBe(true); 172 176 }); 173 177 174 - cleanup(); 175 - restoreDom(); 178 + await cleanupAndRestore(restoreDom); 176 179 }); 177 180 178 181 test("saves the active block before switching when the creator chooses save changes", async () => { ··· 239 242 expect(fetchCalls[0]?.init?.body).toContain('"required":true'); 240 243 241 244 globalThis.fetch = previousFetch; 242 - cleanup(); 243 - restoreDom(); 245 + await cleanupAndRestore(restoreDom); 244 246 }); 245 247 246 248 test("keeps the unsaved-changes dialog open when saving fails", async () => { ··· 294 296 expect(view.queryByDisplayValue("Second question")).toBe(null); 295 297 296 298 globalThis.fetch = previousFetch; 297 - cleanup(); 298 - restoreDom(); 299 + await cleanupAndRestore(restoreDom); 300 + }); 301 + 302 + test("opens graph mode and hides the standard builder sidebar", async () => { 303 + const restoreDom = installTestDom(); 304 + const firstBlock = createBlock({ 305 + id: "block-1", 306 + type: "SHORT_TEXT", 307 + position: 0, 308 + title: "First question", 309 + }); 310 + 311 + const view = renderBuilder(createForm([firstBlock])); 312 + 313 + fireEvent.click( 314 + view.getByRole("button", { name: "builder.openGraphMode" }), 315 + ); 316 + 317 + await waitFor(() => { 318 + expect(view.getByText("Form title") !== null).toBe(true); 319 + expect( 320 + view.getByRole("button", { name: "builder.exitGraphMode" }) !== null, 321 + ).toBe(true); 322 + }); 323 + expect(view.queryByText("builder.blocks")).toBe(null); 324 + 325 + await cleanupAndRestore(restoreDom); 326 + }); 327 + 328 + test("returns to the standard builder layout when graph mode closes", async () => { 329 + const restoreDom = installTestDom(); 330 + const firstBlock = createBlock({ 331 + id: "block-1", 332 + type: "SHORT_TEXT", 333 + position: 0, 334 + title: "First question", 335 + }); 336 + 337 + const view = renderBuilder(createForm([firstBlock])); 338 + 339 + fireEvent.click( 340 + view.getByRole("button", { name: "builder.openGraphMode" }), 341 + ); 342 + 343 + await waitFor(() => { 344 + expect( 345 + view.getByRole("button", { name: "builder.exitGraphMode" }) !== null, 346 + ).toBe(true); 347 + }); 348 + 349 + fireEvent.click( 350 + view.getByRole("button", { name: "builder.exitGraphMode" }), 351 + ); 352 + 353 + await waitFor(() => { 354 + expect(view.getByText("builder.blocks") !== null).toBe(true); 355 + }); 356 + 357 + await cleanupAndRestore(restoreDom); 358 + }); 359 + 360 + test("saves branch edits from graph mode through the existing block save flow", async () => { 361 + const restoreDom = installTestDom(); 362 + const sourceBlock = createBlock({ 363 + id: "block-1", 364 + type: "SINGLE_CHOICE", 365 + position: 0, 366 + title: "Choose one", 367 + config: { 368 + ...getDefaultBlockConfig("SINGLE_CHOICE"), 369 + options: ["Yes", "No"], 370 + }, 371 + }); 372 + const targetBlock = createBlock({ 373 + id: "block-2", 374 + type: "SHORT_TEXT", 375 + position: 1, 376 + title: "Follow-up", 377 + }); 378 + const previousFetch = globalThis.fetch; 379 + const fetchCalls: Array<{ url: string; init?: RequestInit }> = []; 380 + 381 + globalThis.fetch = (async ( 382 + url: string | URL | Request, 383 + init?: RequestInit, 384 + ) => { 385 + fetchCalls.push({ url: String(url), init }); 386 + 387 + return { 388 + ok: true, 389 + json: async () => ({ 390 + block: { 391 + ...sourceBlock, 392 + config: { 393 + ...sourceBlock.config, 394 + branchRules: [ 395 + { 396 + operator: "equals", 397 + value: "Yes", 398 + targetBlockId: "block-2", 399 + }, 400 + ], 401 + }, 402 + }, 403 + }), 404 + } as Response; 405 + }) as typeof fetch; 406 + 407 + const view = renderBuilder(createForm([sourceBlock, targetBlock])); 408 + 409 + fireEvent.click( 410 + view.getByRole("button", { name: "builder.openGraphMode" }), 411 + ); 412 + 413 + await waitFor(() => { 414 + expect( 415 + view.getByRole("button", { name: "builder.exitGraphMode" }) !== null, 416 + ).toBe(true); 417 + }); 418 + 419 + fireEvent.click( 420 + view.getByRole("button", { 421 + name: "blocks.types.SINGLE_CHOICE Choose one", 422 + }), 423 + ); 424 + 425 + await waitFor(() => { 426 + expect( 427 + view.getByRole("button", { name: "builder.addBranchRule" }) !== null, 428 + ).toBe(true); 429 + }); 430 + 431 + fireEvent.click( 432 + view.getByRole("button", { name: "builder.addBranchRule" }), 433 + ); 434 + fireEvent.click(view.getByRole("button", { name: "builder.saveBlock" })); 435 + 436 + await waitFor(() => { 437 + expect(fetchCalls.length).toBe(1); 438 + }); 439 + 440 + expect(fetchCalls[0]?.url).toBe("/api/forms/form-1/blocks/block-1"); 441 + expect(fetchCalls[0]?.init?.method).toBe("PATCH"); 442 + expect(fetchCalls[0]?.init?.body).toContain('"targetBlockId":"block-2"'); 443 + expect(fetchCalls[0]?.init?.body).toContain('"operator":"equals"'); 444 + 445 + globalThis.fetch = previousFetch; 446 + await cleanupAndRestore(restoreDom); 299 447 }); 300 448 });
+287 -141
components/form-builder.tsx
··· 17 17 } from "@dnd-kit/sortable"; 18 18 import { CSS } from "@dnd-kit/utilities"; 19 19 import { 20 + ArrowLeft, 20 21 BadgeCheck, 21 22 CalendarDays, 22 23 GripVertical, ··· 26 27 Plus, 27 28 Radio, 28 29 Rows3, 30 + GitBranch, 29 31 SquareCheck, 30 32 TextCursorInput, 31 33 Type, 32 34 } from "lucide-react"; 33 35 import { useRouter } from "next/navigation"; 34 36 37 + import { BranchingGraphWorkspace } from "@/components/builder/branching-graph-workspace"; 35 38 import { 36 39 BlockEditorPanel, 37 40 BuilderHeader, ··· 43 46 import { useLocalToasts } from "@/components/use-local-toasts"; 44 47 import { Button } from "@/components/ui/button"; 45 48 import { ConfirmDialog } from "@/components/ui/confirm-dialog"; 49 + import { PageHeader } from "@/components/ui/page-header"; 46 50 import { ToastViewport } from "@/components/ui/toast"; 47 51 import { 48 52 getBranchRules, ··· 67 71 import { cn } from "@/lib/utils"; 68 72 69 73 type BlockType = BuilderBlock["type"]; 74 + type BuilderMode = "standard" | "graph"; 70 75 71 76 type Selection = { kind: "form" } | { kind: "block"; blockId: string }; 72 77 type PendingNavigation = 73 - | { kind: "form" } 74 - | { kind: "block"; blockId: string } 75 - | { kind: "add-block"; blockType: BlockType }; 78 + | { kind: "form"; nextBuilderMode?: BuilderMode } 79 + | { 80 + kind: "block"; 81 + blockId: string; 82 + graphEdgeId?: string | null; 83 + nextBuilderMode?: BuilderMode; 84 + activateGraphSelection?: boolean; 85 + } 86 + | { kind: "add-block"; blockType: BlockType; nextBuilderMode?: BuilderMode }; 76 87 77 88 const blockIcons: Record<BlockType, typeof Type> = { 78 89 TEXT: Type, ··· 242 253 const { t } = useI18n(); 243 254 const router = useRouter(); 244 255 const [form, setForm] = useState(initialForm); 256 + const [builderMode, setBuilderMode] = useState<BuilderMode>("standard"); 245 257 const [selection, setSelection] = useState<Selection>(() => 246 258 initialForm.blocks[0] 247 259 ? { kind: "block", blockId: initialForm.blocks[0].id } 248 260 : { kind: "form" }, 261 + ); 262 + const [hasExplicitGraphSelection, setHasExplicitGraphSelection] = 263 + useState(false); 264 + const [selectedGraphEdgeId, setSelectedGraphEdgeId] = useState<string | null>( 265 + null, 249 266 ); 250 267 const { toasts, showToast, dismissToast } = useLocalToasts(); 251 268 const [busy, setBusy] = useState<string | null>(null); ··· 295 312 : [], 296 313 ); 297 314 const SelectedBlockIcon = blockDraft ? blockIcons[blockDraft.type] : null; 315 + const graphSelectedBlockId = 316 + hasExplicitGraphSelection && selection.kind === "block" 317 + ? selection.blockId 318 + : null; 319 + const graphSelectedBlockDraft = hasExplicitGraphSelection ? blockDraft : null; 298 320 const hasUnsavedBlockChanges = useMemo( 299 321 () => 300 322 selection.kind === "block" && ··· 358 380 : [], 359 381 ); 360 382 }, [selectedBlock]); 383 + 384 + useEffect(() => { 385 + if (selection.kind !== "block") { 386 + setSelectedGraphEdgeId(null); 387 + } 388 + }, [selection]); 361 389 362 390 useEffect(() => { 363 391 setIsDndReady(true); ··· 479 507 } 480 508 481 509 async function executePendingNavigation(navigation: PendingNavigation) { 510 + if (navigation.nextBuilderMode) { 511 + setBuilderMode(navigation.nextBuilderMode); 512 + } 513 + 482 514 switch (navigation.kind) { 483 515 case "form": { 484 516 setSelection({ kind: "form" }); 517 + setHasExplicitGraphSelection(false); 518 + setSelectedGraphEdgeId(null); 485 519 break; 486 520 } 487 521 case "block": { 488 522 setSelection({ kind: "block", blockId: navigation.blockId }); 523 + setHasExplicitGraphSelection( 524 + Boolean(navigation.activateGraphSelection), 525 + ); 526 + setSelectedGraphEdgeId(navigation.graphEdgeId ?? null); 489 527 break; 490 528 } 491 529 case "add-block": { 530 + setSelectedGraphEdgeId(null); 492 531 await addBlockNow(navigation.blockType); 493 532 break; 494 533 } ··· 516 555 void executePendingNavigation(navigation); 517 556 } 518 557 558 + function openGraphMode() { 559 + setIsBlockMenuOpen(false); 560 + setBuilderMode("graph"); 561 + setHasExplicitGraphSelection(false); 562 + setSelectedGraphEdgeId(null); 563 + } 564 + 565 + function exitGraphMode() { 566 + setBuilderMode("standard"); 567 + setHasExplicitGraphSelection(false); 568 + setSelectedGraphEdgeId(null); 569 + } 570 + 571 + function openSettings() { 572 + requestPendingNavigation({ 573 + kind: "form", 574 + nextBuilderMode: builderMode === "graph" ? "standard" : undefined, 575 + }); 576 + } 577 + 578 + function selectGraphBlock(blockId: string) { 579 + if (selection.kind === "block" && selection.blockId === blockId) { 580 + setHasExplicitGraphSelection(true); 581 + setSelectedGraphEdgeId(null); 582 + return; 583 + } 584 + 585 + requestPendingNavigation({ 586 + kind: "block", 587 + blockId, 588 + graphEdgeId: null, 589 + activateGraphSelection: true, 590 + }); 591 + } 592 + 593 + function selectGraphEdge(edgeId: string, blockId: string) { 594 + if (selection.kind === "block" && selection.blockId === blockId) { 595 + setHasExplicitGraphSelection(true); 596 + setSelectedGraphEdgeId(edgeId); 597 + return; 598 + } 599 + 600 + requestPendingNavigation({ 601 + kind: "block", 602 + blockId, 603 + graphEdgeId: edgeId, 604 + activateGraphSelection: true, 605 + }); 606 + } 607 + 519 608 async function confirmPendingNavigationWithSave() { 520 609 if (!pendingNavigation) { 521 610 return; ··· 649 738 } 650 739 651 740 const shareHref = `/f/${form.slug}`; 741 + const workspaceLabel = 742 + form.workspace.kind === "personal" 743 + ? t("workspace.personal") 744 + : form.workspace.label; 652 745 653 746 return ( 654 747 <> ··· 703 796 } 704 797 /> 705 798 <div className="space-y-6"> 706 - <BuilderHeader 707 - form={form} 708 - settingsSelected={selection.kind === "form"} 709 - busy={busy} 710 - shareHref={shareHref} 711 - onOpenSettings={() => requestPendingNavigation({ kind: "form" })} 712 - onPublish={() => setPublished(true)} 713 - onUnpublish={() => setPublished(false)} 714 - onCopyShareLink={copyShareLink} 715 - /> 799 + {builderMode === "graph" ? ( 800 + <PageHeader 801 + title={form.title} 802 + metadata={workspaceLabel} 803 + actions={ 804 + <Button variant="secondary" onClick={exitGraphMode}> 805 + <ArrowLeft className="size-4" /> 806 + {t("builder.exitGraphMode")} 807 + </Button> 808 + } 809 + /> 810 + ) : ( 811 + <BuilderHeader 812 + form={form} 813 + settingsSelected={selection.kind === "form"} 814 + busy={busy} 815 + shareHref={shareHref} 816 + onOpenSettings={openSettings} 817 + onPublish={() => setPublished(true)} 818 + onUnpublish={() => setPublished(false)} 819 + onCopyShareLink={copyShareLink} 820 + /> 821 + )} 716 822 717 823 {branchingAnalysis.blockers.length ? ( 718 824 <div className="rounded-[18px] border border-amber-500/30 bg-amber-500/10 px-4 py-4 text-sm text-[var(--ink)]"> ··· 770 876 </div> 771 877 ) : null} 772 878 773 - <div className="grid gap-8 lg:min-h-[calc(100vh-14rem)] lg:grid-cols-[300px_minmax(0,1fr)]"> 774 - <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"> 775 - <div className="flex items-center justify-between gap-3"> 776 - <h2 className="font-display text-3xl text-[var(--ink)]"> 777 - {t("builder.blocks")} 778 - </h2> 779 - <div className="relative" ref={blockMenuRef}> 780 - <Button 781 - size="sm" 782 - onClick={() => setIsBlockMenuOpen((current) => !current)} 783 - aria-expanded={isBlockMenuOpen} 784 - aria-haspopup="menu" 879 + {builderMode === "graph" ? ( 880 + <BranchingGraphWorkspace 881 + allBlocks={form.blocks} 882 + selectedBlockId={graphSelectedBlockId} 883 + selectedGraphEdgeId={selectedGraphEdgeId} 884 + blockDraft={graphSelectedBlockDraft} 885 + branchRulesDraft={branchRulesDraft} 886 + branchValidationIssues={[ 887 + ...branchingAnalysis.blockers, 888 + ...branchingAnalysis.warnings, 889 + ]} 890 + setBlockDraft={setBlockDraft} 891 + setBranchRulesDraft={setBranchRulesDraft} 892 + saveBlock={saveBlock} 893 + busy={busy} 894 + onSelectBlock={selectGraphBlock} 895 + onSelectEdge={selectGraphEdge} 896 + /> 897 + ) : ( 898 + <div className="grid gap-8 lg:min-h-[calc(100vh-14rem)] lg:grid-cols-[300px_minmax(0,1fr)]"> 899 + <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"> 900 + <div className="flex items-center justify-between gap-3"> 901 + <h2 className="font-display text-3xl text-[var(--ink)]"> 902 + {t("builder.blocks")} 903 + </h2> 904 + <div 905 + className="relative flex items-center gap-2" 906 + ref={blockMenuRef} 785 907 > 786 - <Plus className="size-4" /> 787 - {t("builder.newBlock")} 788 - </Button> 789 - {isBlockMenuOpen ? ( 790 - <div className="absolute right-0 top-[calc(100%+0.75rem)] z-20 min-w-[220px] rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] p-2 shadow-[var(--shadow-card)]"> 791 - <div className="grid gap-1"> 792 - {blockCreationOrder.map((type) => { 793 - const Icon = blockIcons[type]; 908 + <Button 909 + size="icon" 910 + variant="secondary" 911 + aria-label={t("builder.openGraphMode")} 912 + title={t("builder.openGraphMode")} 913 + onClick={openGraphMode} 914 + > 915 + <GitBranch 916 + size={18} 917 + color="var(--ink)" 918 + strokeWidth={2.25} 919 + /> 920 + </Button> 921 + <Button 922 + size="icon" 923 + onClick={() => setIsBlockMenuOpen((current) => !current)} 924 + aria-label={t("builder.newBlock")} 925 + title={t("builder.newBlock")} 926 + aria-expanded={isBlockMenuOpen} 927 + aria-haspopup="menu" 928 + > 929 + <Plus size={18} color="var(--bg)" strokeWidth={2.5} /> 930 + </Button> 931 + {isBlockMenuOpen ? ( 932 + <div className="absolute right-0 top-[calc(100%+0.75rem)] z-20 min-w-[220px] rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] p-2 shadow-[var(--shadow-card)]"> 933 + <div className="grid gap-1"> 934 + {blockCreationOrder.map((type) => { 935 + const Icon = blockIcons[type]; 794 936 795 - return ( 796 - <button 797 - key={type} 798 - type="button" 799 - className="flex items-center gap-2 rounded-[12px] px-3 py-2 text-left text-sm font-medium text-[var(--ink)] transition hover:bg-[var(--surface)]" 800 - onClick={() => 801 - requestPendingNavigation({ 802 - kind: "add-block", 803 - blockType: type, 804 - }) 805 - } 806 - > 807 - {busy === `add-${type}` ? ( 808 - <LoaderCircle className="size-4 animate-spin" /> 809 - ) : ( 810 - <Icon className="size-4" /> 811 - )} 812 - {t(blockTypeTranslationKeys[type])} 813 - </button> 814 - ); 815 - })} 937 + return ( 938 + <button 939 + key={type} 940 + type="button" 941 + className="flex items-center gap-2 rounded-[12px] px-3 py-2 text-left text-sm font-medium text-[var(--ink)] transition hover:bg-[var(--surface)]" 942 + onClick={() => 943 + requestPendingNavigation({ 944 + kind: "add-block", 945 + blockType: type, 946 + }) 947 + } 948 + > 949 + {busy === `add-${type}` ? ( 950 + <LoaderCircle className="size-4 animate-spin" /> 951 + ) : ( 952 + <Icon className="size-4" /> 953 + )} 954 + {t(blockTypeTranslationKeys[type])} 955 + </button> 956 + ); 957 + })} 958 + </div> 816 959 </div> 817 - </div> 818 - ) : null} 960 + ) : null} 961 + </div> 819 962 </div> 820 - </div> 821 963 822 - <div className="mt-5 lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto lg:pr-2"> 823 - <div className="space-y-1.5"> 824 - {isDndReady ? ( 825 - <DndContext 826 - sensors={sensors} 827 - collisionDetection={closestCenter} 828 - onDragEnd={handleDragEnd} 829 - > 830 - <SortableContext 831 - items={form.blocks.map((block) => block.id)} 832 - strategy={verticalListSortingStrategy} 964 + <div className="mt-5 lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto lg:pr-2"> 965 + <div className="space-y-1.5"> 966 + {isDndReady ? ( 967 + <DndContext 968 + sensors={sensors} 969 + collisionDetection={closestCenter} 970 + onDragEnd={handleDragEnd} 833 971 > 834 - {form.blocks.map((block) => ( 835 - <SortableBlockRow 836 - key={block.id} 837 - block={block} 838 - selected={ 839 - selection.kind === "block" && 840 - selection.blockId === block.id 841 - } 842 - onSelect={(blockId) => 843 - requestPendingNavigation({ kind: "block", blockId }) 844 - } 845 - /> 846 - ))} 847 - </SortableContext> 848 - </DndContext> 849 - ) : ( 850 - form.blocks.map((block) => ( 851 - <StaticBlockRow 852 - key={block.id} 853 - block={block} 854 - selected={ 855 - selection.kind === "block" && 856 - selection.blockId === block.id 857 - } 858 - onSelect={(blockId) => 859 - requestPendingNavigation({ kind: "block", blockId }) 860 - } 861 - /> 862 - )) 863 - )} 972 + <SortableContext 973 + items={form.blocks.map((block) => block.id)} 974 + strategy={verticalListSortingStrategy} 975 + > 976 + {form.blocks.map((block) => ( 977 + <SortableBlockRow 978 + key={block.id} 979 + block={block} 980 + selected={ 981 + selection.kind === "block" && 982 + selection.blockId === block.id 983 + } 984 + onSelect={(blockId) => 985 + requestPendingNavigation({ 986 + kind: "block", 987 + blockId, 988 + }) 989 + } 990 + /> 991 + ))} 992 + </SortableContext> 993 + </DndContext> 994 + ) : ( 995 + form.blocks.map((block) => ( 996 + <StaticBlockRow 997 + key={block.id} 998 + block={block} 999 + selected={ 1000 + selection.kind === "block" && 1001 + selection.blockId === block.id 1002 + } 1003 + onSelect={(blockId) => 1004 + requestPendingNavigation({ kind: "block", blockId }) 1005 + } 1006 + /> 1007 + )) 1008 + )} 1009 + </div> 864 1010 </div> 865 - </div> 866 - </aside> 1011 + </aside> 867 1012 868 - <section className="min-w-0 border-t border-[color:var(--line)] pt-6 lg:min-h-full lg:border-t-0 lg:pt-0"> 869 - {selection.kind === "form" ? ( 870 - <FormSettingsPanel 871 - metadataDraft={metadataDraft} 872 - setMetadataDraft={setMetadataDraft} 873 - shareHref={shareHref} 874 - copyShareLink={copyShareLink} 875 - deleteForm={deleteForm} 876 - saveMetadata={saveMetadata} 877 - busy={busy} 878 - /> 879 - ) : blockDraft ? ( 880 - <BlockEditorPanel 881 - allBlocks={form.blocks} 882 - blockDraft={blockDraft} 883 - selectedBlockIcon={SelectedBlockIcon} 884 - choiceOptionsDraft={choiceOptionsDraft} 885 - branchRulesDraft={branchRulesDraft} 886 - branchValidationIssues={[ 887 - ...branchingAnalysis.blockers, 888 - ...branchingAnalysis.warnings, 889 - ]} 890 - setChoiceOptionsDraft={setChoiceOptionsDraft} 891 - setBranchRulesDraft={setBranchRulesDraft} 892 - setBlockDraft={setBlockDraft} 893 - deleteBlock={deleteBlock} 894 - saveBlock={saveBlock} 895 - busy={busy} 896 - /> 897 - ) : ( 898 - <EmptyEditorState 899 - onAddShortText={() => 900 - requestPendingNavigation({ 901 - kind: "add-block", 902 - blockType: "SHORT_TEXT", 903 - }) 904 - } 905 - /> 906 - )} 907 - </section> 908 - </div> 1013 + <section className="min-w-0 border-t border-[color:var(--line)] pt-6 lg:min-h-full lg:border-t-0 lg:pt-0"> 1014 + {selection.kind === "form" ? ( 1015 + <FormSettingsPanel 1016 + metadataDraft={metadataDraft} 1017 + setMetadataDraft={setMetadataDraft} 1018 + shareHref={shareHref} 1019 + copyShareLink={copyShareLink} 1020 + deleteForm={deleteForm} 1021 + saveMetadata={saveMetadata} 1022 + busy={busy} 1023 + /> 1024 + ) : blockDraft ? ( 1025 + <BlockEditorPanel 1026 + allBlocks={form.blocks} 1027 + blockDraft={blockDraft} 1028 + selectedBlockIcon={SelectedBlockIcon} 1029 + choiceOptionsDraft={choiceOptionsDraft} 1030 + branchRulesDraft={branchRulesDraft} 1031 + branchValidationIssues={[ 1032 + ...branchingAnalysis.blockers, 1033 + ...branchingAnalysis.warnings, 1034 + ]} 1035 + setChoiceOptionsDraft={setChoiceOptionsDraft} 1036 + setBranchRulesDraft={setBranchRulesDraft} 1037 + setBlockDraft={setBlockDraft} 1038 + deleteBlock={deleteBlock} 1039 + saveBlock={saveBlock} 1040 + busy={busy} 1041 + /> 1042 + ) : ( 1043 + <EmptyEditorState 1044 + onAddShortText={() => 1045 + requestPendingNavigation({ 1046 + kind: "add-block", 1047 + blockType: "SHORT_TEXT", 1048 + }) 1049 + } 1050 + /> 1051 + )} 1052 + </section> 1053 + </div> 1054 + )} 909 1055 </div> 910 1056 </> 911 1057 );
+2 -2
components/ui/button.tsx
··· 9 9 variants: { 10 10 variant: { 11 11 default: 12 - "bg-[var(--ink)] px-5 py-3 text-[var(--bg)] shadow-[var(--shadow-button)] hover:-translate-y-0.5 hover:shadow-[var(--shadow-button-hover)] focus-visible:ring-[var(--ink)]", 12 + "bg-[var(--ink)] px-5 py-3 text-[var(--bg)] shadow-[var(--shadow-button)] hover:brightness-80 hover:shadow-[var(--shadow-button-hover)] focus-visible:ring-[var(--ink)]", 13 13 secondary: 14 14 "bg-[var(--surface-strong)] px-5 py-3 text-[var(--ink)] ring-1 ring-[color:var(--line)] hover:bg-[var(--surface)] focus-visible:ring-[var(--ink)]", 15 15 ghost: ··· 21 21 default: "h-11", 22 22 sm: "h-9 px-4 text-xs", 23 23 lg: "h-12 px-6 text-base", 24 - icon: "h-10 w-10 rounded-xl", 24 + icon: "h-10 w-10 rounded-xl px-0 py-0", 25 25 }, 26 26 }, 27 27 defaultVariants: {
+5
lib/block-config-normalization.ts
··· 47 47 value.defaultNextBlockId === null 48 48 ? null 49 49 : undefined; 50 + const endForm = 51 + typeof value === "object" && value && "endForm" in value 52 + ? Boolean(value.endForm) 53 + : undefined; 50 54 51 55 return parseBlockConfig(type, { 52 56 options: options.length >= 2 ? options : ["Option 1", "Option 2"], 53 57 branchRules, 54 58 defaultNextBlockId, 59 + endForm, 55 60 }); 56 61 } 57 62
+38 -8
lib/blocks.ts
··· 199 199 .min(1) 200 200 .nullable() 201 201 .default(null); 202 + const endFormSchema = z.boolean().default(false); 202 203 203 204 const textConfigSchema = z.object({ 204 205 body: z.string().max(2400).default(defaultBlockCopyByLocale.en.textBody), 206 + endForm: endFormSchema, 205 207 }); 206 208 207 209 const shortTextConfigSchema = z.object({ ··· 212 214 validationRegex: regexPatternSchema.default(null), 213 215 branchRules: branchRulesSchema, 214 216 defaultNextBlockId: defaultNextBlockIdSchema, 217 + endForm: endFormSchema, 215 218 }); 216 219 217 220 const longTextConfigSchema = z.object({ ··· 222 225 validationRegex: regexPatternSchema.default(null), 223 226 branchRules: branchRulesSchema, 224 227 defaultNextBlockId: defaultNextBlockIdSchema, 228 + endForm: endFormSchema, 225 229 }); 226 230 227 231 const numberConfigSchema = z ··· 235 239 max: numericLimitSchema.default(null), 236 240 branchRules: branchRulesSchema, 237 241 defaultNextBlockId: defaultNextBlockIdSchema, 242 + endForm: endFormSchema, 238 243 }) 239 244 .superRefine((value, context) => { 240 245 if (!value.allowFloat) { ··· 271 276 .default(defaultBlockCopyByLocale.en.linkPlaceholder), 272 277 branchRules: branchRulesSchema, 273 278 defaultNextBlockId: defaultNextBlockIdSchema, 279 + endForm: endFormSchema, 274 280 }); 275 281 276 282 const agreementConfigSchema = z.object({ ··· 281 287 .default(defaultBlockCopyByLocale.en.agreementLabel), 282 288 branchRules: branchRulesSchema, 283 289 defaultNextBlockId: defaultNextBlockIdSchema, 290 + endForm: endFormSchema, 284 291 }); 285 292 286 293 const dateConfigSchema = z.object({ 287 294 branchRules: branchRulesSchema, 288 295 defaultNextBlockId: defaultNextBlockIdSchema, 296 + endForm: endFormSchema, 289 297 }); 290 298 291 299 const optionListSchema = z.object({ ··· 296 304 .default(defaultBlockCopyByLocale.en.options), 297 305 branchRules: branchRulesSchema, 298 306 defaultNextBlockId: defaultNextBlockIdSchema, 307 + endForm: endFormSchema, 299 308 }); 300 309 301 310 export type AgreementAnswerValue = 302 311 (typeof AGREEMENT_ANSWER_VALUES)[keyof typeof AGREEMENT_ANSWER_VALUES]; 303 312 export type BranchOperator = z.infer<typeof branchOperatorSchema>; 304 313 export type BranchRule = z.infer<typeof branchRuleSchema>; 305 - export type TextBlockConfig = z.infer<typeof textConfigSchema>; 306 - export type ShortTextBlockConfig = z.infer<typeof shortTextConfigSchema>; 307 - export type LongTextBlockConfig = z.infer<typeof longTextConfigSchema>; 308 - export type NumberBlockConfig = z.infer<typeof numberConfigSchema>; 309 - export type LinkBlockConfig = z.infer<typeof linkConfigSchema>; 310 - export type AgreementBlockConfig = z.infer<typeof agreementConfigSchema>; 311 - export type DateBlockConfig = z.infer<typeof dateConfigSchema>; 312 - export type ChoiceBlockConfig = z.infer<typeof optionListSchema>; 314 + export type TextBlockConfig = WithOptionalEndForm< 315 + z.infer<typeof textConfigSchema> 316 + >; 317 + type WithOptionalEndForm<T> = Omit<T, "endForm"> & { endForm?: boolean }; 318 + export type ShortTextBlockConfig = WithOptionalEndForm< 319 + z.infer<typeof shortTextConfigSchema> 320 + >; 321 + export type LongTextBlockConfig = WithOptionalEndForm< 322 + z.infer<typeof longTextConfigSchema> 323 + >; 324 + export type NumberBlockConfig = WithOptionalEndForm< 325 + z.infer<typeof numberConfigSchema> 326 + >; 327 + export type LinkBlockConfig = WithOptionalEndForm< 328 + z.infer<typeof linkConfigSchema> 329 + >; 330 + export type AgreementBlockConfig = WithOptionalEndForm< 331 + z.infer<typeof agreementConfigSchema> 332 + >; 333 + export type DateBlockConfig = WithOptionalEndForm< 334 + z.infer<typeof dateConfigSchema> 335 + >; 336 + export type ChoiceBlockConfig = WithOptionalEndForm< 337 + z.infer<typeof optionListSchema> 338 + >; 313 339 export type TextAnswerBlockConfig = ShortTextBlockConfig | LongTextBlockConfig; 314 340 export type AnswerableBlockConfig = 315 341 | ShortTextBlockConfig ··· 530 556 531 557 export function getDefaultNextBlockId(config: BlockConfig) { 532 558 return "defaultNextBlockId" in config ? config.defaultNextBlockId : null; 559 + } 560 + 561 + export function getEndForm(config: BlockConfig) { 562 + return "endForm" in config ? config.endForm : false; 533 563 } 534 564 535 565 export function serializeBlock(block: FormBlock): SerializedBlock {
+234
lib/branching-graph.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + 3 + import { getDefaultBlockConfig } from "@/lib/blocks"; 4 + import { buildBranchingGraph } from "@/lib/branching-graph"; 5 + import type { BuilderBlock } from "@/lib/form-types"; 6 + 7 + function createBlock( 8 + overrides: Partial<BuilderBlock> & 9 + Pick<BuilderBlock, "id" | "type" | "position">, 10 + ): BuilderBlock { 11 + const now = new Date("2026-04-14T12:00:00.000Z"); 12 + 13 + return { 14 + formId: "form-1", 15 + title: "", 16 + description: "", 17 + required: false, 18 + createdAt: now, 19 + updatedAt: now, 20 + config: getDefaultBlockConfig(overrides.type), 21 + ...overrides, 22 + } as BuilderBlock; 23 + } 24 + 25 + describe("buildBranchingGraph", () => { 26 + test("lays out linear forms as a left-to-right sequence with linear edges", () => { 27 + const blocks = [ 28 + createBlock({ 29 + id: "block-1", 30 + type: "SHORT_TEXT", 31 + position: 0, 32 + title: "One", 33 + }), 34 + createBlock({ 35 + id: "block-2", 36 + type: "SHORT_TEXT", 37 + position: 1, 38 + title: "Two", 39 + }), 40 + createBlock({ 41 + id: "block-3", 42 + type: "SHORT_TEXT", 43 + position: 2, 44 + title: "Three", 45 + }), 46 + ]; 47 + 48 + const graph = buildBranchingGraph(blocks); 49 + const node1 = graph.nodes.find((node) => node.blockId === "block-1"); 50 + const node2 = graph.nodes.find((node) => node.blockId === "block-2"); 51 + const node3 = graph.nodes.find((node) => node.blockId === "block-3"); 52 + 53 + expect((node1?.x ?? 0) < (node2?.x ?? 0)).toBe(true); 54 + expect((node2?.x ?? 0) < (node3?.x ?? 0)).toBe(true); 55 + expect(node1?.y).toBe(node2?.y); 56 + expect(node2?.y).toBe(node3?.y); 57 + expect( 58 + JSON.stringify( 59 + graph.edges.map((edge) => ({ 60 + kind: edge.kind, 61 + sourceBlockId: edge.sourceBlockId, 62 + targetBlockId: edge.targetBlockId, 63 + })), 64 + ), 65 + ).toBe( 66 + JSON.stringify([ 67 + { 68 + kind: "linear", 69 + sourceBlockId: "block-1", 70 + targetBlockId: "block-2", 71 + }, 72 + { 73 + kind: "linear", 74 + sourceBlockId: "block-2", 75 + targetBlockId: "block-3", 76 + }, 77 + ]), 78 + ); 79 + }); 80 + 81 + test("fans out branch-rule targets while preserving left-to-right block order", () => { 82 + const blocks = [ 83 + createBlock({ 84 + id: "block-1", 85 + type: "SINGLE_CHOICE", 86 + position: 0, 87 + title: "Choose", 88 + config: { 89 + ...getDefaultBlockConfig("SINGLE_CHOICE"), 90 + options: ["Yes", "No"], 91 + branchRules: [ 92 + { 93 + operator: "equals", 94 + value: "Yes", 95 + targetBlockId: "block-3", 96 + }, 97 + ], 98 + defaultNextBlockId: null, 99 + }, 100 + }), 101 + createBlock({ 102 + id: "block-2", 103 + type: "SHORT_TEXT", 104 + position: 1, 105 + title: "Default next", 106 + }), 107 + createBlock({ 108 + id: "block-3", 109 + type: "SHORT_TEXT", 110 + position: 2, 111 + title: "Branch target", 112 + }), 113 + ]; 114 + 115 + const graph = buildBranchingGraph(blocks); 116 + const defaultNode = graph.nodes.find((node) => node.blockId === "block-2"); 117 + const branchNode = graph.nodes.find((node) => node.blockId === "block-3"); 118 + 119 + expect((defaultNode?.x ?? 0) < (branchNode?.x ?? 0)).toBe(true); 120 + expect((branchNode?.y ?? 0) > (defaultNode?.y ?? 0)).toBe(true); 121 + expect( 122 + JSON.stringify( 123 + graph.edges.map((edge) => ({ 124 + kind: edge.kind, 125 + sourceBlockId: edge.sourceBlockId, 126 + targetBlockId: edge.targetBlockId, 127 + })), 128 + ), 129 + ).toBe( 130 + JSON.stringify([ 131 + { 132 + kind: "rule", 133 + sourceBlockId: "block-1", 134 + targetBlockId: "block-3", 135 + }, 136 + { 137 + kind: "linear", 138 + sourceBlockId: "block-1", 139 + targetBlockId: "block-2", 140 + }, 141 + { 142 + kind: "linear", 143 + sourceBlockId: "block-2", 144 + targetBlockId: "block-3", 145 + }, 146 + ]), 147 + ); 148 + }); 149 + 150 + test("does not render outgoing edges from terminal blocks", () => { 151 + const blocks = [ 152 + createBlock({ 153 + id: "block-1", 154 + type: "SHORT_TEXT", 155 + position: 0, 156 + title: "Terminal", 157 + config: { 158 + ...getDefaultBlockConfig("SHORT_TEXT"), 159 + branchRules: [ 160 + { operator: "is_not_empty", value: null, targetBlockId: "block-3" }, 161 + ], 162 + defaultNextBlockId: "block-2", 163 + endForm: true, 164 + }, 165 + }), 166 + createBlock({ 167 + id: "block-2", 168 + type: "SHORT_TEXT", 169 + position: 1, 170 + title: "Next", 171 + }), 172 + createBlock({ 173 + id: "block-3", 174 + type: "SHORT_TEXT", 175 + position: 2, 176 + title: "Branched", 177 + }), 178 + ]; 179 + 180 + const graph = buildBranchingGraph(blocks); 181 + expect(graph.edges.some((edge) => edge.sourceBlockId === "block-1")).toBe( 182 + false, 183 + ); 184 + }); 185 + 186 + test("keeps multi-branch targets distributed to reduce edge overlap", () => { 187 + const blocks = [ 188 + createBlock({ 189 + id: "block-1", 190 + type: "SINGLE_CHOICE", 191 + position: 0, 192 + title: "Choose", 193 + config: { 194 + ...getDefaultBlockConfig("SINGLE_CHOICE"), 195 + options: ["A", "B", "C"], 196 + branchRules: [ 197 + { operator: "equals", value: "A", targetBlockId: "block-3" }, 198 + { operator: "equals", value: "B", targetBlockId: "block-4" }, 199 + ], 200 + defaultNextBlockId: "block-2", 201 + }, 202 + }), 203 + createBlock({ 204 + id: "block-2", 205 + type: "SHORT_TEXT", 206 + position: 1, 207 + title: "Default", 208 + }), 209 + createBlock({ 210 + id: "block-3", 211 + type: "SHORT_TEXT", 212 + position: 2, 213 + title: "Branch A", 214 + }), 215 + createBlock({ 216 + id: "block-4", 217 + type: "SHORT_TEXT", 218 + position: 3, 219 + title: "Branch B", 220 + }), 221 + ]; 222 + 223 + const graph = buildBranchingGraph(blocks); 224 + const node2 = graph.nodes.find((node) => node.blockId === "block-2"); 225 + const node3 = graph.nodes.find((node) => node.blockId === "block-3"); 226 + const node4 = graph.nodes.find((node) => node.blockId === "block-4"); 227 + 228 + expect(new Set([node2?.y, node3?.y, node4?.y]).size > 1).toBe(true); 229 + expect( 230 + (node2?.y ?? 0) !== (node3?.y ?? 0) || 231 + (node2?.y ?? 0) !== (node4?.y ?? 0), 232 + ).toBe(true); 233 + }); 234 + });
+252
lib/branching-graph.ts
··· 1 + import { 2 + getBranchRules, 3 + getDefaultNextBlockId, 4 + getEndForm, 5 + type BranchOperator, 6 + type SerializedBlock, 7 + } from "@/lib/blocks"; 8 + import { getBlockDisplayLabel, getPossibleNextBlockIds } from "@/lib/branching"; 9 + 10 + const GRAPH_PADDING_X = 48; 11 + const GRAPH_PADDING_Y = 48; 12 + export const BRANCHING_GRAPH_NODE_WIDTH = 248; 13 + export const BRANCHING_GRAPH_NODE_HEIGHT = 125; 14 + export const BRANCHING_GRAPH_COLUMN_GAP = 72; 15 + export const BRANCHING_GRAPH_ROW_GAP = 88; 16 + 17 + export type BranchingGraphEdgeKind = "linear" | "default" | "rule"; 18 + 19 + export type BranchingGraphNode = { 20 + blockId: string; 21 + column: number; 22 + row: number; 23 + x: number; 24 + y: number; 25 + width: number; 26 + height: number; 27 + label: string; 28 + block: SerializedBlock; 29 + }; 30 + 31 + export type BranchingGraphEdge = { 32 + id: string; 33 + sourceBlockId: string; 34 + targetBlockId: string; 35 + kind: BranchingGraphEdgeKind; 36 + ruleIndex?: number; 37 + operator?: BranchOperator; 38 + value?: string | null; 39 + }; 40 + 41 + export type BranchingGraphData = { 42 + nodes: BranchingGraphNode[]; 43 + edges: BranchingGraphEdge[]; 44 + width: number; 45 + height: number; 46 + }; 47 + 48 + function createFanOffset(index: number, total: number) { 49 + if (total <= 1) { 50 + return 0; 51 + } 52 + 53 + const centeredIndex = index - (total - 1) / 2; 54 + return centeredIndex * 2; 55 + } 56 + 57 + function getOutgoingEdgeSortWeight(edge: BranchingGraphEdge) { 58 + if (edge.kind === "linear") { 59 + return 0; 60 + } 61 + 62 + if (edge.kind === "default") { 63 + return 1; 64 + } 65 + 66 + return 2; 67 + } 68 + 69 + export function buildBranchingGraph( 70 + blocks: SerializedBlock[], 71 + ): BranchingGraphData { 72 + const blockById = new Map(blocks.map((block) => [block.id, block])); 73 + const edges: BranchingGraphEdge[] = []; 74 + 75 + blocks.forEach((block, blockIndex) => { 76 + const possibleNextBlockIds = new Set( 77 + getPossibleNextBlockIds(blocks, block), 78 + ); 79 + const defaultNextBlockId = getDefaultNextBlockId(block.config); 80 + const linearNextBlockId = blocks[blockIndex + 1]?.id ?? null; 81 + 82 + if (getEndForm(block.config)) { 83 + return; 84 + } 85 + 86 + getBranchRules(block.config).forEach((rule, ruleIndex) => { 87 + const targetBlock = blockById.get(rule.targetBlockId); 88 + 89 + if (!targetBlock || targetBlock.position <= block.position) { 90 + return; 91 + } 92 + 93 + edges.push({ 94 + id: `${block.id}:rule:${ruleIndex}:${rule.targetBlockId}`, 95 + sourceBlockId: block.id, 96 + targetBlockId: rule.targetBlockId, 97 + kind: "rule", 98 + ruleIndex, 99 + operator: rule.operator, 100 + value: rule.value, 101 + }); 102 + }); 103 + 104 + if ( 105 + defaultNextBlockId && 106 + possibleNextBlockIds.has(defaultNextBlockId) && 107 + blockById.has(defaultNextBlockId) 108 + ) { 109 + edges.push({ 110 + id: `${block.id}:default:${defaultNextBlockId}`, 111 + sourceBlockId: block.id, 112 + targetBlockId: defaultNextBlockId, 113 + kind: "default", 114 + }); 115 + } 116 + 117 + if ( 118 + !defaultNextBlockId && 119 + linearNextBlockId && 120 + possibleNextBlockIds.has(linearNextBlockId) && 121 + blockById.has(linearNextBlockId) 122 + ) { 123 + edges.push({ 124 + id: `${block.id}:linear:${linearNextBlockId}`, 125 + sourceBlockId: block.id, 126 + targetBlockId: linearNextBlockId, 127 + kind: "linear", 128 + }); 129 + } 130 + }); 131 + 132 + const edgesBySource = new Map<string, BranchingGraphEdge[]>(); 133 + const edgesByTarget = new Map<string, BranchingGraphEdge[]>(); 134 + 135 + edges.forEach((edge) => { 136 + const outgoing = edgesBySource.get(edge.sourceBlockId) ?? []; 137 + outgoing.push(edge); 138 + edgesBySource.set(edge.sourceBlockId, outgoing); 139 + 140 + const incoming = edgesByTarget.get(edge.targetBlockId) ?? []; 141 + incoming.push(edge); 142 + edgesByTarget.set(edge.targetBlockId, incoming); 143 + }); 144 + 145 + edgesBySource.forEach((outgoingEdges, blockId) => { 146 + outgoingEdges.sort((left, right) => { 147 + const weightDifference = 148 + getOutgoingEdgeSortWeight(left) - getOutgoingEdgeSortWeight(right); 149 + 150 + if (weightDifference !== 0) { 151 + return weightDifference; 152 + } 153 + 154 + const leftTargetPosition = 155 + blockById.get(left.targetBlockId)?.position ?? 0; 156 + const rightTargetPosition = 157 + blockById.get(right.targetBlockId)?.position ?? 0; 158 + 159 + return leftTargetPosition - rightTargetPosition; 160 + }); 161 + edgesBySource.set(blockId, outgoingEdges); 162 + }); 163 + 164 + const rowById = new Map<string, number>(blocks.map((block) => [block.id, 0])); 165 + 166 + for (let pass = 0; pass < 6; pass += 1) { 167 + blocks.forEach((block) => { 168 + const incomingEdges = edgesByTarget.get(block.id) ?? []; 169 + 170 + if (!incomingEdges.length) { 171 + return; 172 + } 173 + 174 + const suggestedRows = incomingEdges.map((edge) => { 175 + const sourceRow = rowById.get(edge.sourceBlockId) ?? 0; 176 + const outgoingEdges = edgesBySource.get(edge.sourceBlockId) ?? []; 177 + const edgeIndex = outgoingEdges.findIndex( 178 + (outgoingEdge) => outgoingEdge.id === edge.id, 179 + ); 180 + 181 + return sourceRow + createFanOffset(edgeIndex, outgoingEdges.length); 182 + }); 183 + 184 + const averageSuggestedRow = 185 + suggestedRows.reduce((sum, row) => sum + row, 0) / suggestedRows.length; 186 + 187 + const existingRow = rowById.get(block.id) ?? 0; 188 + rowById.set(block.id, existingRow * 0.2 + averageSuggestedRow * 0.8); 189 + }); 190 + 191 + [...blocks].reverse().forEach((block) => { 192 + const outgoingEdges = edgesBySource.get(block.id) ?? []; 193 + 194 + if (!outgoingEdges.length) { 195 + return; 196 + } 197 + 198 + const suggestedRows = outgoingEdges.map((edge, index) => { 199 + const targetRow = rowById.get(edge.targetBlockId) ?? 0; 200 + return targetRow - createFanOffset(index, outgoingEdges.length); 201 + }); 202 + 203 + const averageSuggestedRow = 204 + suggestedRows.reduce((sum, row) => sum + row, 0) / suggestedRows.length; 205 + 206 + const existingRow = rowById.get(block.id) ?? 0; 207 + rowById.set(block.id, existingRow * 0.75 + averageSuggestedRow * 0.25); 208 + }); 209 + } 210 + 211 + blocks.forEach((block) => { 212 + rowById.set(block.id, Math.round(rowById.get(block.id) ?? 0)); 213 + }); 214 + 215 + const minRow = Math.min(0, ...Array.from(rowById.values())); 216 + const normalizedRowOffset = Math.abs(minRow); 217 + 218 + const nodes = blocks.map((block) => { 219 + const row = (rowById.get(block.id) ?? 0) + normalizedRowOffset; 220 + const column = block.position; 221 + 222 + return { 223 + blockId: block.id, 224 + column, 225 + row, 226 + x: 227 + GRAPH_PADDING_X + 228 + column * (BRANCHING_GRAPH_NODE_WIDTH + BRANCHING_GRAPH_COLUMN_GAP), 229 + y: 230 + GRAPH_PADDING_Y + 231 + row * (BRANCHING_GRAPH_NODE_HEIGHT + BRANCHING_GRAPH_ROW_GAP), 232 + width: BRANCHING_GRAPH_NODE_WIDTH, 233 + height: BRANCHING_GRAPH_NODE_HEIGHT, 234 + label: getBlockDisplayLabel(block), 235 + block, 236 + } satisfies BranchingGraphNode; 237 + }); 238 + 239 + const width = nodes.length 240 + ? Math.max(...nodes.map((node) => node.x + node.width)) + GRAPH_PADDING_X 241 + : GRAPH_PADDING_X * 2 + BRANCHING_GRAPH_NODE_WIDTH; 242 + const height = nodes.length 243 + ? Math.max(...nodes.map((node) => node.y + node.height)) + GRAPH_PADDING_Y 244 + : GRAPH_PADDING_Y * 2 + BRANCHING_GRAPH_NODE_HEIGHT; 245 + 246 + return { 247 + nodes, 248 + edges, 249 + width, 250 + height, 251 + }; 252 + }
+26
lib/branching.test.ts
··· 164 164 expect(resolveNextBlockId(blocks, "path", { path: "no" })).toBe("fallback"); 165 165 }); 166 166 167 + test("ends immediately when a block is marked as terminal", () => { 168 + const blocks = [ 169 + createBlock({ 170 + id: "terminal-question", 171 + type: "SINGLE_CHOICE", 172 + position: 0, 173 + config: { 174 + options: ["yes", "no"], 175 + branchRules: [ 176 + { operator: "equals", value: "yes", targetBlockId: "special" }, 177 + ], 178 + defaultNextBlockId: "fallback", 179 + endForm: true, 180 + }, 181 + }), 182 + createBlock({ id: "fallback", type: "SHORT_TEXT", position: 1 }), 183 + createBlock({ id: "special", type: "SHORT_TEXT", position: 2 }), 184 + ]; 185 + 186 + expect( 187 + resolveNextBlockId(blocks, "terminal-question", { 188 + "terminal-question": "yes", 189 + }), 190 + ).toBe(null); 191 + }); 192 + 167 193 test("continues in saved order when no rule matches and no default route is configured", () => { 168 194 const blocks = [ 169 195 createBlock({
+10 -1
lib/branching.ts
··· 3 3 branchOperatorNeedsValue, 4 4 getBranchRules, 5 5 getDefaultNextBlockId, 6 + getEndForm, 6 7 getSupportedBranchOperators, 7 8 isAgreementAnswerValue, 8 9 isQuestionBlock, ··· 310 311 const currentBlock = blocks[currentIndex]; 311 312 const linearNextBlockId = blocks[currentIndex + 1]?.id ?? null; 312 313 314 + if (getEndForm(currentBlock.config)) { 315 + return null; 316 + } 317 + 313 318 if (!isQuestionBlock(currentBlock.type)) { 314 319 return linearNextBlockId; 315 320 } ··· 410 415 return true; 411 416 } 412 417 413 - function getPossibleNextBlockIds( 418 + export function getPossibleNextBlockIds( 414 419 blocks: SerializedBlock[], 415 420 block: SerializedBlock, 416 421 ) { ··· 421 426 } 422 427 423 428 const nextIds = new Set<string>(); 429 + 430 + if (getEndForm(block.config)) { 431 + return [...nextIds]; 432 + } 424 433 425 434 if (!isQuestionBlock(block.type)) { 426 435 const nextLinearBlockId = blocks[blockIndex + 1]?.id;
+22
locales/en.yml
··· 127 127 unpublish: Unpublish 128 128 blocks: Blocks 129 129 newBlock: New block 130 + openGraphMode: Open branching graph 131 + graphModeEyebrow: Branching view 132 + graphModeTitle: Branching graph 133 + graphModeDescription: See the full route structure, select a block, and edit how respondents move through the form. 134 + exitGraphMode: Back to editor 135 + graphCanvasAria: Branching graph canvas 136 + graphNodeIndex: "Block {number}" 137 + graphNodeRoutes: "{count} route(s)" 138 + graphNodeNoRoutes: No outgoing routes 139 + graphEmptyTitle: No blocks to map yet 140 + graphEmptyDescription: Add blocks to start laying out the form flow. 141 + graphSelectionEyebrow: Routing 142 + graphSelectionTitle: Select a block to edit its routes 143 + graphSelectionDescription: Click a node or route in the graph to inspect its branching settings. 144 + graphTextBlockDescription: Text blocks appear in the flow, but only question blocks can define branching routes. 145 + graphSelectedRoute: "Selected route: {route} → {target}" 146 + graphEditDescription: Review the selected block's default path and branch rules. 147 + graphLinearRoute: Next 148 + graphDefaultRoute: Default 130 149 settingsTitle: Settings 131 150 titleField: Title 132 151 shareSlug: Public URL address ··· 180 199 branchNoValueNeeded: No value needed for this operator. 181 200 branchingHelp: 'Leave a rule unmatched to use the default path. Example value: "{value}".' 182 201 branchingNoTargets: Add a later block before creating branch rules from this question. 202 + endFormAfterBlock: End form after this block 203 + endFormAfterBlockDescription: Submit the response immediately after this block instead of continuing to another block. 204 + endFormDisablesBranching: While this is enabled, this block ignores its saved branch rules and default path. 183 205 dragBranchRuleAria: "Drag branch rule {number}" 184 206 removeBranchRuleAria: "Remove branch rule {number}" 185 207 branchMissingTarget: Missing target block
+22
locales/ru.yml
··· 127 127 unpublish: Снять с публикации 128 128 blocks: Блоки 129 129 newBlock: Новый блок 130 + openGraphMode: Открыть граф ветвления 131 + graphModeEyebrow: Ветвление 132 + graphModeTitle: Граф ветвления 133 + graphModeDescription: Просматривайте всю структуру переходов, выбирайте блок и редактируйте маршруты, по которым респонденты проходят форму. 134 + exitGraphMode: Назад к редактору 135 + graphCanvasAria: Холст графа ветвления 136 + graphNodeIndex: "Блок {number}" 137 + graphNodeRoutes: "Маршрутов: {count}" 138 + graphNodeNoRoutes: Нет исходящих маршрутов 139 + graphEmptyTitle: Пока нечего раскладывать на графе 140 + graphEmptyDescription: Добавьте блоки, чтобы построить маршрут формы. 141 + graphSelectionEyebrow: Маршрутизация 142 + graphSelectionTitle: Выберите блок, чтобы редактировать его маршруты 143 + graphSelectionDescription: Нажмите на блок или маршрут на графе, чтобы посмотреть и изменить настройки ветвления. 144 + graphTextBlockDescription: Текстовые блоки показываются в потоке, но задавать маршруты могут только блоки с ответом. 145 + graphSelectedRoute: "Выбранный маршрут: {route} → {target}" 146 + graphEditDescription: Проверьте маршрут по умолчанию и правила ветвления для выбранного блока. 147 + graphLinearRoute: Дальше 148 + graphDefaultRoute: По умолчанию 130 149 settingsTitle: Настройки 131 150 titleField: Название 132 151 shareSlug: Адрес публичного URL ··· 180 199 branchNoValueNeeded: Для этого оператора значение не требуется. 181 200 branchingHelp: 'Если ни одно правило не совпало, форма использует маршрут по умолчанию. Пример значения: "{value}".' 182 201 branchingNoTargets: Добавьте более поздний блок, прежде чем создавать правила ветвления для этого вопроса. 202 + endFormAfterBlock: Завершить форму после этого блока 203 + endFormAfterBlockDescription: Сразу отправить ответ после этого блока вместо перехода к следующему. 204 + endFormDisablesBranching: Пока эта опция включена, блок игнорирует сохранённые правила ветвления и маршрут по умолчанию. 183 205 dragBranchRuleAria: "Перетащить правило ветвления {number}" 184 206 removeBranchRuleAria: "Удалить правило ветвления {number}" 185 207 branchMissingTarget: Целевой блок отсутствует
+2
openspec/changes/archive/2026-04-14-add-branching-graph-view/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-14
+88
openspec/changes/archive/2026-04-14-add-branching-graph-view/design.md
··· 1 + ## Context 2 + 3 + Branching is already supported in the conversational form builder, but configuration is centered on the currently selected block and its inspector controls. That works for isolated edits, yet it becomes difficult to reason about the overall route structure when a form has multiple branch paths, shared targets, or unreachable sections. 4 + 5 + The requested change adds a graph-first branching workspace that creators can open from the builder chrome. The graph view must use the full available page width, temporarily hide the usual sidebar-based builder layout, and keep the form readable even when the form is still purely linear. 6 + 7 + The existing branching model, save APIs, publish validation, and block ordering rules already define the source of truth. The graph view should therefore be a new interaction layer over existing block and branch data rather than a parallel persistence model. 8 + 9 + ## Goals / Non-Goals 10 + 11 + **Goals:** 12 + - Give creators a graph-based view of the form's branching structure. 13 + - Add a clear entry point next to the existing create-block action. 14 + - Use a full-width workspace that prioritizes graph readability over the normal sidebar layout. 15 + - Lay out nodes from left to right according to saved block order so linear and branched forms are both understandable. 16 + - Allow creators to inspect and edit branching relationships without introducing a second branching system. 17 + - Reuse existing branch validation and save semantics. 18 + 19 + **Non-Goals:** 20 + - Replacing the standard block editor as the default builder mode. 21 + - Introducing freeform graph editing with arbitrary node placement persistence. 22 + - Changing the meaning of existing branch rules, default-next behavior, or publish validation. 23 + - Adding respondent-facing graph visualizations. 24 + - Redesigning unrelated builder settings outside the branching workflow. 25 + 26 + ## Decisions 27 + 28 + ### 1. Add a dedicated graph mode toggle in the builder toolbar 29 + The builder will expose a compact branching button beside the existing create-block action. Activating it switches the builder into a graph-focused mode; exiting returns the creator to the standard builder layout. 30 + 31 + **Why:** This keeps branching discoverable without making graph mode the default experience for simple forms. 32 + 33 + **Alternatives considered:** 34 + - Put graph mode inside each block's branching editor: rejected because it hides a form-level workflow inside a block-level surface. 35 + - Replace the default builder with a graph view: rejected because most day-to-day edits are still block-content edits. 36 + 37 + ### 2. Build the graph from existing ordered block and branching data 38 + Each node will correspond to a saved block in form order. Edges will be derived from the saved default-next target and each saved branch rule target. The horizontal flow will follow block order from left to right, while branching paths can create vertical separation as needed. 39 + 40 + **Why:** This preserves one source of truth and ensures the graph reflects the same routing model used for saving, validation, and respondent execution. 41 + 42 + **Alternatives considered:** 43 + - Store separate graph nodes and edges: rejected because it would duplicate routing state and create sync risk. 44 + - Use free-drag placement as the canonical layout: rejected because the request is about understanding flow, not maintaining manual diagrams. 45 + 46 + ### 3. Use deterministic auto-layout instead of user-saved node positions 47 + The graph should compute positions from block order and branching relationships each time it renders. Linear forms will therefore appear as a simple left-to-right chain, while branched forms will fan outward only where routing requires it. 48 + 49 + **Why:** Deterministic layout avoids persistence complexity and keeps the graph stable across sessions. 50 + 51 + **Alternatives considered:** 52 + - Persist custom coordinates per block: rejected as unnecessary complexity for the first version. 53 + - Render a simple list with connectors: rejected because it would not provide enough spatial clarity for richer branch graphs. 54 + 55 + ### 4. Keep editing bound to existing branch rule operations 56 + Graph interactions should map back to the current branching model: selecting a node or edge opens graph-mode controls for editing branch rules or default-next behavior using the same rule schema already enforced elsewhere. Saving from graph mode should use the existing block save flows. 57 + 58 + **Why:** This avoids creating different rule semantics between the inspector view and graph view. 59 + 60 + **Alternatives considered:** 61 + - Make graph mode read-only in the first version: rejected because the request is for a branching settings interface, not only visualization. 62 + - Create graph-specific mutation APIs: rejected because existing block update APIs already own branching persistence. 63 + 64 + ### 5. Graph mode temporarily replaces the sidebar layout 65 + When graph mode is active, the normal left block list and standard editor sidebar will be hidden so the graph can take the full page width. Graph-specific actions and context will live in the graph workspace itself. 66 + 67 + **Why:** The graph needs the width that the normal two-pane layout would otherwise consume, and the request explicitly calls for the sidebar to be hidden. 68 + 69 + **Alternatives considered:** 70 + - Keep the sidebar visible beside the graph: rejected because it reduces graph readability and conflicts with the requested layout. 71 + - Open the graph in a modal: rejected because large branching structures need a persistent full-screen workspace. 72 + 73 + ## Risks / Trade-offs 74 + 75 + - **Dense graphs may still become visually busy** → Use deterministic spacing, directional edges, and focus/selection states to keep active paths readable. 76 + - **Dual editing surfaces can drift behaviorally** → Reuse the same branch-rule data model, validation, and save pipeline in both standard and graph modes. 77 + - **Full-width graph mode may hide useful navigation context** → Provide an obvious exit back to the standard builder and preserve the last selected block when returning. 78 + - **Auto-layout may not match every creator's mental model** → Favor stable order-based positioning so the graph stays predictable, even if it is less customizable. 79 + 80 + ## Migration Plan 81 + 82 + - No data migration is required because the graph is derived from existing form blocks and branch configuration. 83 + - Rollback is low risk: disabling graph mode leaves the current builder and saved branching data unchanged. 84 + 85 + ## Open Questions 86 + 87 + - Whether graph mode should support creating new branch rules directly from edge handles, or start with selection-driven editing only. 88 + - Whether graph mode needs viewport controls such as zoom-to-fit and minimap in the first implementation, or whether panning plus fit-on-open is sufficient.
+25
openspec/changes/archive/2026-04-14-add-branching-graph-view/proposal.md
··· 1 + ## Why 2 + 3 + Branching is currently configured through per-block settings, which makes it hard to understand the overall flow of a form once multiple paths are involved. A dedicated graph view would let creators see the full branching structure at once and move between routing relationships more confidently. 4 + 5 + ## What Changes 6 + 7 + - Add a branching graph entry point next to the existing create-block action in the builder toolbar. 8 + - Add a full-width graph view for branching configuration that temporarily replaces the normal sidebar-based builder layout. 9 + - Represent each block as a node laid out from left to right, with edges for saved default-next and branch-rule targets. 10 + - Keep linear forms readable in the graph view by laying blocks out left to right even when no branching is configured. 11 + - Allow creators to inspect and manage branching relationships from the graph-focused interface while preserving existing branching rules and validation behavior. 12 + 13 + ## Capabilities 14 + 15 + ### New Capabilities 16 + - None. 17 + 18 + ### Modified Capabilities 19 + - `conversational-form-builder`: extend builder behavior to include a graph-based branching view and graph-specific navigation for configuring routing relationships. 20 + 21 + ## Impact 22 + 23 + - Affected code: builder layout, branching settings UI, graph rendering components, builder navigation controls, and related interaction tests. 24 + - APIs: no new external API is expected; the feature should continue using the existing block and branching save flows. 25 + - Dependencies/systems: likely frontend-only unless a graph-specific serialization need is discovered during implementation.
+46
openspec/changes/archive/2026-04-14-add-branching-graph-view/specs/conversational-form-builder/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Builder provides a full-page branching graph workspace 4 + The system SHALL allow an authenticated creator to open a graph-based branching workspace from the form builder. The graph workspace SHALL temporarily replace the standard sidebar-based builder layout and use the full available page width to visualize block-to-block routing. 5 + 6 + #### Scenario: Creator opens graph mode from the builder toolbar 7 + - **WHEN** an authenticated creator clicks the branching graph action beside the create-block action in the builder toolbar 8 + - **THEN** the system opens the branching graph workspace for the current form 9 + 10 + #### Scenario: Graph mode replaces the sidebar layout 11 + - **WHEN** an authenticated creator opens the branching graph workspace 12 + - **THEN** the system hides the normal block-list sidebar and standard editor panel while the graph workspace is active 13 + 14 + #### Scenario: Creator exits graph mode 15 + - **WHEN** an authenticated creator leaves the branching graph workspace 16 + - **THEN** the system returns to the standard builder layout for the same form 17 + 18 + ### Requirement: Branching graph workspace visualizes routing from left to right 19 + The system SHALL render each saved form block as a graph node and SHALL derive graph edges from the saved default-next target and saved branch-rule targets for each block. The graph SHALL lay blocks out from left to right in saved block order and SHALL keep linear forms readable even when no branching is configured. 20 + 21 + #### Scenario: Creator views a linear form in graph mode 22 + - **WHEN** an authenticated creator opens graph mode for a form with no branching rules or default-next overrides 23 + - **THEN** the system displays the blocks as a left-to-right sequence of nodes 24 + 25 + #### Scenario: Creator views a branched form in graph mode 26 + - **WHEN** an authenticated creator opens graph mode for a form with saved branch rules or default-next targets 27 + - **THEN** the system displays node connections that represent those possible next-block routes 28 + 29 + #### Scenario: Graph nodes follow saved block order 30 + - **WHEN** an authenticated creator views the branching graph workspace 31 + - **THEN** the system positions nodes so the primary horizontal reading order follows the saved form block order from left to right 32 + 33 + ### Requirement: Creator can manage branching relationships from graph mode 34 + The system SHALL allow an authenticated creator to inspect and update a block's branching relationships from the graph workspace using the same branching rule model and validation rules as the standard builder. 35 + 36 + #### Scenario: Creator inspects a block's routing from graph mode 37 + - **WHEN** an authenticated creator selects a block node in the branching graph workspace 38 + - **THEN** the system shows that block's current default-next target and branch-rule relationships for editing 39 + 40 + #### Scenario: Creator saves branching changes from graph mode 41 + - **WHEN** an authenticated creator updates a block's branching relationships in graph mode and saves 42 + - **THEN** the system persists those changes through the existing block branching save flow 43 + 44 + #### Scenario: Graph mode preserves existing branching validation 45 + - **WHEN** an authenticated creator saves branching changes from graph mode that would violate existing branching validation rules 46 + - **THEN** the system rejects publishing or saving in the same way as the standard branching workflow requires
+23
openspec/changes/archive/2026-04-14-add-branching-graph-view/tasks.md
··· 1 + ## 1. Graph mode entry and layout shell 2 + 3 + - [x] 1.1 Add a graph-mode toggle button with branching icon next to the create-block action in the builder toolbar. 4 + - [x] 1.2 Add builder state for entering and leaving graph mode while preserving the current form and selection context. 5 + - [x] 1.3 Render a full-width graph workspace that hides the standard sidebar and editor layout while graph mode is active. 6 + 7 + ## 2. Graph data and visualization 8 + 9 + - [x] 2.1 Add a graph data helper that converts saved blocks, default-next targets, and branch rules into graph nodes and edges. 10 + - [x] 2.2 Implement deterministic left-to-right layout so linear forms render as a simple sequence and branched forms fan out from saved block order. 11 + - [x] 2.3 Render graph nodes and edges with readable labels and selection states for blocks and routing relationships. 12 + 13 + ## 3. Graph-mode branching editing 14 + 15 + - [x] 3.1 Add graph-mode controls for inspecting the selected block's default-next target and branch rules. 16 + - [x] 3.2 Reuse the existing block branching save flow so graph-mode edits persist through current APIs and draft handling. 17 + - [x] 3.3 Ensure graph-mode editing preserves existing branching validation and surfaces blocking/advisory issues consistently. 18 + 19 + ## 4. Verification 20 + 21 + - [x] 4.1 Add unit coverage for graph data and layout derivation from linear and branched forms. 22 + - [x] 4.2 Add interaction coverage for entering graph mode, leaving graph mode, and editing branching relationships from the graph workspace. 23 + - [x] 4.3 Manually verify graph mode on linear and branched forms, including full-width layout behavior and save/validation flows.
+45
openspec/specs/conversational-form-builder/spec.md
··· 191 191 #### Scenario: Creator introduces a fragile but still valid branch setup 192 192 - **WHEN** an authenticated creator creates a branch setup that is structurally valid but likely fragile or overlapping 193 193 - **THEN** the system surfaces an advisory warning without preventing the draft from being saved 194 + 195 + ### Requirement: Builder provides a full-page branching graph workspace 196 + The system SHALL allow an authenticated creator to open a graph-based branching workspace from the form builder. The graph workspace SHALL temporarily replace the standard sidebar-based builder layout and use the full available page width to visualize block-to-block routing. 197 + 198 + #### Scenario: Creator opens graph mode from the builder toolbar 199 + - **WHEN** an authenticated creator clicks the branching graph action beside the create-block action in the builder toolbar 200 + - **THEN** the system opens the branching graph workspace for the current form 201 + 202 + #### Scenario: Graph mode replaces the sidebar layout 203 + - **WHEN** an authenticated creator opens the branching graph workspace 204 + - **THEN** the system hides the normal block-list sidebar and standard editor panel while the graph workspace is active 205 + 206 + #### Scenario: Creator exits graph mode 207 + - **WHEN** an authenticated creator leaves the branching graph workspace 208 + - **THEN** the system returns to the standard builder layout for the same form 209 + 210 + ### Requirement: Branching graph workspace visualizes routing from left to right 211 + The system SHALL render each saved form block as a graph node and SHALL derive graph edges from the saved default-next target and saved branch-rule targets for each block. The graph SHALL lay blocks out from left to right in saved block order and SHALL keep linear forms readable even when no branching is configured. 212 + 213 + #### Scenario: Creator views a linear form in graph mode 214 + - **WHEN** an authenticated creator opens graph mode for a form with no branching rules or default-next overrides 215 + - **THEN** the system displays the blocks as a left-to-right sequence of nodes 216 + 217 + #### Scenario: Creator views a branched form in graph mode 218 + - **WHEN** an authenticated creator opens graph mode for a form with saved branch rules or default-next targets 219 + - **THEN** the system displays node connections that represent those possible next-block routes 220 + 221 + #### Scenario: Graph nodes follow saved block order 222 + - **WHEN** an authenticated creator views the branching graph workspace 223 + - **THEN** the system positions nodes so the primary horizontal reading order follows the saved form block order from left to right 224 + 225 + ### Requirement: Creator can manage branching relationships from graph mode 226 + The system SHALL allow an authenticated creator to inspect and update a block's branching relationships from the graph workspace using the same branching rule model and validation rules as the standard builder. 227 + 228 + #### Scenario: Creator inspects a block's routing from graph mode 229 + - **WHEN** an authenticated creator selects a block node in the branching graph workspace 230 + - **THEN** the system shows that block's current default-next target and branch-rule relationships for editing 231 + 232 + #### Scenario: Creator saves branching changes from graph mode 233 + - **WHEN** an authenticated creator updates a block's branching relationships in graph mode and saves 234 + - **THEN** the system persists those changes through the existing block branching save flow 235 + 236 + #### Scenario: Graph mode preserves existing branching validation 237 + - **WHEN** an authenticated creator saves branching changes from graph mode that would violate existing branching validation rules 238 + - **THEN** the system rejects publishing or saving in the same way as the standard branching workflow requires