this repo has no description
0
fork

Configure Feed

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

refactor: reduce core form complexity

+1303 -423
+16 -30
components/form-builder-panels.tsx
··· 76 76 getBranchValidationIssueI18n, 77 77 type BranchValidationIssue, 78 78 } from "@/lib/branching"; 79 + import { 80 + createChoiceOptionDraft, 81 + createDefaultBranchRuleDraft, 82 + serializeBranchRuleDrafts, 83 + serializeChoiceOptionDrafts, 84 + type BranchRuleDraft, 85 + type ChoiceOptionDraft, 86 + } from "@/lib/form-builder-drafts"; 79 87 import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 80 88 81 89 export type FormMetadataDraft = { ··· 86 94 completionLinkUrl: string; 87 95 showProgress: boolean; 88 96 slug: string; 89 - }; 90 - 91 - export type ChoiceOptionDraft = { 92 - id: string; 93 - value: string; 94 - }; 95 - 96 - export type BranchRuleDraft = { 97 - id: string; 98 - operator: BranchOperator; 99 - value: string | null; 100 - targetBlockId: string; 101 97 }; 102 98 103 99 export function BuilderHeader({ ··· 773 769 774 770 function syncChoiceOptions(nextOptions: ChoiceOptionDraft[]) { 775 771 setChoiceOptionsDraft(nextOptions); 776 - updateConfig({ options: nextOptions.map((option) => option.value) }); 772 + updateConfig({ options: serializeChoiceOptionDrafts(nextOptions) }); 777 773 } 778 774 779 775 function updateChoiceOption(optionId: string, value: string) { ··· 791 787 792 788 syncChoiceOptions([ 793 789 ...choiceOptionsDraft, 794 - { 795 - id: crypto.randomUUID(), 796 - value: t("builder.option", { number: choiceOptionsDraft.length + 1 }), 797 - }, 790 + createChoiceOptionDraft( 791 + t("builder.option", { number: choiceOptionsDraft.length + 1 }), 792 + ), 798 793 ]); 799 794 } 800 795 ··· 832 827 function syncBranchRules(nextRules: BranchRuleDraft[]) { 833 828 setBranchRulesDraft(nextRules); 834 829 updateConfig({ 835 - branchRules: nextRules.map((rule) => ({ 836 - operator: rule.operator, 837 - value: rule.value, 838 - targetBlockId: rule.targetBlockId, 839 - })), 830 + branchRules: serializeBranchRuleDrafts(nextRules), 840 831 }); 841 832 } 842 833 ··· 862 853 863 854 syncBranchRules([ 864 855 ...branchRulesDraft, 865 - { 866 - id: crypto.randomUUID(), 867 - operator: getVisibleBranchOperators(blockDraft.type)[0] ?? "equals", 868 - value: 869 - blockDraft.type === "AGREEMENT" 870 - ? AGREEMENT_ANSWER_VALUES.AGREED 871 - : null, 856 + createDefaultBranchRuleDraft({ 857 + blockType: blockDraft.type, 872 858 targetBlockId: firstTarget.id, 873 - }, 859 + }), 874 860 ]); 875 861 } 876 862
+8 -19
components/form-builder.tsx
··· 37 37 BuilderHeader, 38 38 EmptyEditorState, 39 39 FormSettingsPanel, 40 - type ChoiceOptionDraft, 41 40 type FormMetadataDraft, 42 41 } from "@/components/form-builder-panels"; 43 42 import { useI18n } from "@/components/i18n-provider"; ··· 50 49 isQuestionBlock, 51 50 blockTypeTranslationKeys, 52 51 getBlockPreview, 53 - type BranchRule, 54 52 type ChoiceBlockConfig, 55 53 } from "@/lib/blocks"; 56 54 import { 57 55 analyzeBranchingGraph, 58 56 getBranchValidationIssueI18n, 59 57 } from "@/lib/branching"; 58 + import { 59 + createBranchRuleDrafts, 60 + createChoiceOptionDrafts, 61 + serializeBranchRuleDrafts, 62 + type BranchRuleDraft, 63 + type ChoiceOptionDraft, 64 + } from "@/lib/form-builder-drafts"; 60 65 import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 61 66 import { cn } from "@/lib/utils"; 62 67 63 68 type BlockType = BuilderBlock["type"]; 64 69 65 - type BranchRuleDraft = BranchRule & { 66 - id: string; 67 - }; 68 - 69 70 type Selection = { kind: "form" } | { kind: "block"; blockId: string }; 70 71 71 72 const blockIcons: Record<BlockType, typeof Type> = { ··· 91 92 "MULTIPLE_CHOICE", 92 93 "AGREEMENT", 93 94 ]; 94 - 95 - function createChoiceOptionDrafts(options: string[]): ChoiceOptionDraft[] { 96 - return options.map((value) => ({ id: crypto.randomUUID(), value })); 97 - } 98 - 99 - function createBranchRuleDrafts(rules: BranchRule[]): BranchRuleDraft[] { 100 - return rules.map((rule) => ({ id: crypto.randomUUID(), ...rule })); 101 - } 102 95 103 96 async function fetchJson<T>(url: string, init?: RequestInit) { 104 97 const response = await fetch(url, { ··· 422 415 } 423 416 424 417 await withTask(`block-${blockDraft.id}`, async () => { 425 - const serializedBranchRules = branchRulesDraft.map((rule) => ({ 426 - operator: rule.operator, 427 - value: rule.value, 428 - targetBlockId: rule.targetBlockId, 429 - })); 418 + const serializedBranchRules = serializeBranchRuleDrafts(branchRulesDraft); 430 419 const payload = await fetchJson<{ block: BuilderBlock }>( 431 420 `/api/forms/${form.id}/blocks/${blockDraft.id}`, 432 421 {
+38 -51
components/public-form-runner.tsx
··· 52 52 validatePublicFormDraft, 53 53 writeStoredPublicFormDraft, 54 54 } from "@/lib/public-form-draft"; 55 + import { 56 + advanceRunnerRoute, 57 + createInitialRunnerRoute, 58 + getCurrentRouteBlockId, 59 + getRouteCursor, 60 + getVisibleRouteTotal, 61 + hasRunnerProgress, 62 + retreatRunnerRoute, 63 + } from "@/lib/public-form-runner-state"; 55 64 import { cn } from "@/lib/utils"; 56 65 57 66 async function submitResponse( ··· 79 88 } 80 89 81 90 export function PublicFormRunner({ form }: { form: PublicForm }) { 82 - const initialHistory = useMemo( 83 - () => (form.blocks[0] ? [form.blocks[0].id] : []), 91 + const initialRoute = useMemo( 92 + () => createInitialRunnerRoute(form.blocks), 84 93 [form.blocks], 85 94 ); 86 95 const storageKey = useMemo( ··· 89 98 ); 90 99 const { t } = useI18n(); 91 100 const [answers, setAnswers] = useState<Record<string, AnswerValue>>({}); 92 - const [route, setRoute] = useState({ history: initialHistory, cursor: 0 }); 101 + const [route, setRoute] = useState(initialRoute); 93 102 const { toasts, showToast, dismissToast } = useLocalToasts(); 94 103 const [isSubmitting, setIsSubmitting] = useState(false); 95 104 const [isComplete, setIsComplete] = useState(false); ··· 101 110 [form.blocks], 102 111 ); 103 112 const history = route.history; 104 - const maxCursor = Math.max(history.length - 1, 0); 105 - const cursor = Math.min(route.cursor, maxCursor); 106 - const currentBlockId = history[cursor] ?? initialHistory[0] ?? null; 113 + const cursor = getRouteCursor(route); 114 + const currentBlockId = getCurrentRouteBlockId(route, form.blocks); 107 115 const currentBlock = currentBlockId 108 116 ? (blocksById.get(currentBlockId) ?? null) 109 117 : null; 110 118 const nextBlockId = currentBlock 111 119 ? resolveNextBlockId(form.blocks, currentBlock.id, answers) 112 120 : null; 113 - const visibleTotal = useMemo(() => { 114 - if (!currentBlockId) { 115 - return 1; 116 - } 117 - 118 - let total = cursor + 1; 119 - let nextId = resolveNextBlockId(form.blocks, currentBlockId, answers); 120 - let safetyCounter = 0; 121 - 122 - while (nextId && safetyCounter < form.blocks.length) { 123 - total += 1; 124 - nextId = resolveNextBlockId(form.blocks, nextId, answers); 125 - safetyCounter += 1; 126 - } 127 - 128 - return Math.max(total, cursor + 1); 129 - }, [answers, currentBlockId, cursor, form.blocks]); 121 + const visibleTotal = useMemo( 122 + () => 123 + getVisibleRouteTotal({ 124 + blocks: form.blocks, 125 + currentBlockId, 126 + answers, 127 + cursor, 128 + }), 129 + [answers, currentBlockId, cursor, form.blocks], 130 + ); 130 131 const progress = useMemo( 131 132 () => ((cursor + 1) / visibleTotal) * 100, 132 133 [cursor, visibleTotal], ··· 151 152 })); 152 153 } 153 154 154 - const hasDraftProgress = useMemo(() => { 155 - const hasAnyAnswer = Object.values(answers).some((value) => 156 - typeof value === "string" ? value.trim().length > 0 : value.length > 0, 157 - ); 158 - 159 - return hasAnyAnswer || cursor > 0 || history.length > initialHistory.length; 160 - }, [answers, cursor, history.length, initialHistory.length]); 155 + const hasDraftProgress = useMemo( 156 + () => 157 + hasRunnerProgress({ 158 + answers, 159 + cursor, 160 + historyLength: history.length, 161 + initialHistoryLength: initialRoute.history.length, 162 + }), 163 + [answers, cursor, history.length, initialRoute.history.length], 164 + ); 161 165 162 166 useLayoutEffect(() => { 163 167 const storedDraft = readStoredPublicFormDraft(storageKey); ··· 333 337 return; 334 338 } 335 339 336 - setRoute((current) => { 337 - const activeBlockId = current.history[current.cursor] ?? null; 338 - 339 - if (activeBlockId !== sourceBlockId) { 340 - return current; 341 - } 342 - 343 - const nextHistory = [ 344 - ...current.history.slice(0, current.cursor + 1), 345 - resolvedNextBlockId, 346 - ]; 347 - 348 - return { 349 - history: nextHistory, 350 - cursor: nextHistory.length - 1, 351 - }; 352 - }); 340 + setRoute((current) => 341 + advanceRunnerRoute(current, sourceBlockId, resolvedNextBlockId), 342 + ); 353 343 }, 354 344 [ 355 345 answers, ··· 364 354 ); 365 355 366 356 const handleBack = useCallback(() => { 367 - setRoute((current) => ({ 368 - ...current, 369 - cursor: Math.max(0, current.cursor - 1), 370 - })); 357 + setRoute((current) => retreatRunnerRoute(current)); 371 358 }, []); 372 359 373 360 function handleAdvanceKeyDown(
+33
lib/block-config-normalization.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + 3 + import { normalizeBlockConfig } from "@/lib/block-config-normalization"; 4 + 5 + describe("normalizeBlockConfig", () => { 6 + test("trims and filters choice options while preserving branch settings", () => { 7 + const config = normalizeBlockConfig("SINGLE_CHOICE", { 8 + options: [" yes ", "", " no ", " "], 9 + branchRules: [ 10 + { operator: "equals", value: "yes", targetBlockId: "follow-up" }, 11 + ], 12 + defaultNextBlockId: "fallback", 13 + }); 14 + 15 + expect("options" in config && JSON.stringify(config.options)).toBe( 16 + JSON.stringify(["yes", "no"]), 17 + ); 18 + expect("branchRules" in config && config.branchRules.length).toBe(1); 19 + expect( 20 + "defaultNextBlockId" in config ? config.defaultNextBlockId : undefined, 21 + ).toBe("fallback"); 22 + }); 23 + 24 + test("falls back to default choice options when fewer than two valid options remain", () => { 25 + const config = normalizeBlockConfig("MULTIPLE_CHOICE", { 26 + options: ["", " only one "], 27 + }); 28 + 29 + expect("options" in config && JSON.stringify(config.options)).toBe( 30 + JSON.stringify(["Option 1", "Option 2"]), 31 + ); 32 + }); 33 + });
+59
lib/block-config-normalization.ts
··· 1 + import type { FormBlockType as PrismaFormBlockType } from "@prisma/client"; 2 + 3 + import { 4 + isChoiceBlock, 5 + parseBlockConfig, 6 + type BlockConfig, 7 + } from "@/lib/blocks"; 8 + 9 + function sanitizeOptions(options: string[]) { 10 + return options 11 + .map((option) => option.trim()) 12 + .filter(Boolean) 13 + .slice(0, 10); 14 + } 15 + 16 + export function normalizeBlockConfig( 17 + type: PrismaFormBlockType, 18 + value: unknown, 19 + ): BlockConfig { 20 + if (isChoiceBlock(type)) { 21 + const rawOptions = 22 + typeof value === "object" && 23 + value && 24 + "options" in value && 25 + Array.isArray(value.options) 26 + ? value.options.filter( 27 + (option): option is string => typeof option === "string", 28 + ) 29 + : []; 30 + const options = sanitizeOptions(rawOptions); 31 + const branchRules = 32 + typeof value === "object" && 33 + value && 34 + "branchRules" in value && 35 + Array.isArray(value.branchRules) 36 + ? value.branchRules 37 + : undefined; 38 + const defaultNextBlockId = 39 + typeof value === "object" && 40 + value && 41 + "defaultNextBlockId" in value && 42 + typeof value.defaultNextBlockId === "string" 43 + ? value.defaultNextBlockId 44 + : value && 45 + typeof value === "object" && 46 + "defaultNextBlockId" in value && 47 + value.defaultNextBlockId === null 48 + ? null 49 + : undefined; 50 + 51 + return parseBlockConfig(type, { 52 + options: options.length >= 2 ? options : ["Option 1", "Option 2"], 53 + branchRules, 54 + defaultNextBlockId, 55 + }); 56 + } 57 + 58 + return parseBlockConfig(type, value); 59 + }
+50
lib/form-builder-drafts.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + 3 + import { 4 + createChoiceOptionDrafts, 5 + createDefaultBranchRuleDraft, 6 + serializeBranchRuleDrafts, 7 + serializeChoiceOptionDrafts, 8 + } from "@/lib/form-builder-drafts"; 9 + 10 + describe("form builder draft helpers", () => { 11 + test("preserves choice option order when drafts are saved back into block config", () => { 12 + const drafts = createChoiceOptionDrafts(["First", "Second", "Third"]); 13 + 14 + expect(JSON.stringify(serializeChoiceOptionDrafts(drafts))).toBe( 15 + JSON.stringify(["First", "Second", "Third"]), 16 + ); 17 + }); 18 + 19 + test("preserves branch rule operator, value, and target when drafts are saved", () => { 20 + const serialized = serializeBranchRuleDrafts([ 21 + { 22 + id: "draft-1", 23 + operator: "contains", 24 + value: "vip", 25 + targetBlockId: "priority-review", 26 + }, 27 + ]); 28 + 29 + expect(JSON.stringify(serialized)).toBe( 30 + JSON.stringify([ 31 + { 32 + operator: "contains", 33 + value: "vip", 34 + targetBlockId: "priority-review", 35 + }, 36 + ]), 37 + ); 38 + }); 39 + 40 + test("starts agreement branch rules with the agreed answer as the default value", () => { 41 + const draft = createDefaultBranchRuleDraft({ 42 + blockType: "AGREEMENT", 43 + targetBlockId: "accepted", 44 + }); 45 + 46 + expect(draft.operator).toBe("equals"); 47 + expect(draft.value).toBe("agreed"); 48 + expect(draft.targetBlockId).toBe("accepted"); 49 + }); 50 + });
+71
lib/form-builder-drafts.ts
··· 1 + import type { FormBlockType as PrismaFormBlockType } from "@prisma/client"; 2 + 3 + import { 4 + AGREEMENT_ANSWER_VALUES, 5 + getVisibleBranchOperators, 6 + type BranchOperator, 7 + type BranchRule, 8 + } from "@/lib/blocks"; 9 + 10 + export type ChoiceOptionDraft = { 11 + id: string; 12 + value: string; 13 + }; 14 + 15 + export type BranchRuleDraft = { 16 + id: string; 17 + operator: BranchOperator; 18 + value: string | null; 19 + targetBlockId: string; 20 + }; 21 + 22 + export function createChoiceOptionDraft(value: string): ChoiceOptionDraft { 23 + return { 24 + id: crypto.randomUUID(), 25 + value, 26 + }; 27 + } 28 + 29 + export function createChoiceOptionDrafts( 30 + options: string[], 31 + ): ChoiceOptionDraft[] { 32 + return options.map((value) => createChoiceOptionDraft(value)); 33 + } 34 + 35 + export function serializeChoiceOptionDrafts(drafts: ChoiceOptionDraft[]) { 36 + return drafts.map((option) => option.value); 37 + } 38 + 39 + export function createBranchRuleDraft(rule: BranchRule): BranchRuleDraft { 40 + return { 41 + id: crypto.randomUUID(), 42 + ...rule, 43 + }; 44 + } 45 + 46 + export function createBranchRuleDrafts(rules: BranchRule[]): BranchRuleDraft[] { 47 + return rules.map((rule) => createBranchRuleDraft(rule)); 48 + } 49 + 50 + export function serializeBranchRuleDrafts( 51 + drafts: BranchRuleDraft[], 52 + ): BranchRule[] { 53 + return drafts.map((rule) => ({ 54 + operator: rule.operator, 55 + value: rule.value, 56 + targetBlockId: rule.targetBlockId, 57 + })); 58 + } 59 + 60 + export function createDefaultBranchRuleDraft(args: { 61 + blockType: PrismaFormBlockType; 62 + targetBlockId: string; 63 + }): BranchRuleDraft { 64 + const { blockType, targetBlockId } = args; 65 + 66 + return createBranchRuleDraft({ 67 + operator: getVisibleBranchOperators(blockType)[0] ?? "equals", 68 + value: blockType === "AGREEMENT" ? AGREEMENT_ANSWER_VALUES.AGREED : null, 69 + targetBlockId, 70 + }); 71 + }
+22 -323
lib/forms.ts
··· 4 4 type FormBlock, 5 5 type FormBlockType as PrismaFormBlockType, 6 6 type Prisma, 7 - type Response as PrismaResponse, 8 7 } from "@prisma/client"; 9 8 import { z } from "zod"; 10 9 11 10 import { 12 - AGREEMENT_ANSWER_VALUES, 13 - blockTypeSchema, 14 11 getDefaultBlockConfig, 15 - isChoiceBlock, 16 12 isQuestionBlock, 17 - parseBlockConfig, 18 13 serializeBlock, 19 - type BlockConfig, 20 - type ChoiceBlockConfig, 21 14 type SerializedBlock, 22 15 } from "@/lib/blocks"; 23 16 import { ··· 34 27 BuilderForm, 35 28 FormListItem, 36 29 PublicForm, 37 - QuestionSummary, 38 - QuestionSummaryAnswer, 39 30 ResponseDetail, 40 - ResponseListItem, 41 31 ResponseReviewData, 42 - ResponseReviewFormSummary, 43 32 WorkspaceReference, 44 33 } from "@/lib/form-types"; 45 34 import { ··· 52 41 import { slugify } from "@/lib/utils"; 53 42 import { DEFAULT_LOCALE, translate, type AppLocale } from "@/lib/i18n"; 54 43 import { getMessages } from "@/lib/i18n-server"; 44 + import { normalizeBlockConfig } from "@/lib/block-config-normalization"; 45 + import { 46 + buildResponseDetailData, 47 + buildResponseReviewData, 48 + } from "@/lib/response-review"; 55 49 import { 56 50 assertOrganizationMember, 57 51 workspaceAccessWhere, ··· 92 86 type BuilderFormRecord = Prisma.FormGetPayload<{ 93 87 include: typeof builderFormInclude; 94 88 }>; 95 - 96 - type ResponseRecord = Prisma.ResponseGetPayload<{ 97 - include: { 98 - form: { 99 - include: { 100 - organization: { 101 - select: { 102 - id: true; 103 - name: true; 104 - }; 105 - }; 106 - blocks: { 107 - orderBy: { 108 - position: "asc"; 109 - }; 110 - }; 111 - }; 112 - }; 113 - }; 114 - }>; 115 - 116 - const snapshotBlockSchema = z.object({ 117 - id: z.string(), 118 - type: blockTypeSchema, 119 - title: z.string(), 120 - description: z.string(), 121 - required: z.boolean(), 122 - position: z.number(), 123 - config: z.unknown(), 124 - }); 125 89 126 90 export type { 127 91 BuilderForm, ··· 332 296 return form; 333 297 } 334 298 335 - function sanitizeOptions(options: string[]) { 336 - return options 337 - .map((option) => option.trim()) 338 - .filter(Boolean) 339 - .slice(0, 10); 340 - } 341 - 342 - function normalizeBlockConfig( 343 - type: PrismaFormBlockType, 344 - value: unknown, 345 - ): BlockConfig { 346 - if (isChoiceBlock(type)) { 347 - const rawOptions = 348 - typeof value === "object" && 349 - value && 350 - "options" in value && 351 - Array.isArray(value.options) 352 - ? value.options.filter( 353 - (option): option is string => typeof option === "string", 354 - ) 355 - : []; 356 - const options = sanitizeOptions(rawOptions); 357 - const branchRules = 358 - typeof value === "object" && 359 - value && 360 - "branchRules" in value && 361 - Array.isArray(value.branchRules) 362 - ? value.branchRules 363 - : undefined; 364 - const defaultNextBlockId = 365 - typeof value === "object" && 366 - value && 367 - "defaultNextBlockId" in value && 368 - typeof value.defaultNextBlockId === "string" 369 - ? value.defaultNextBlockId 370 - : value && 371 - typeof value === "object" && 372 - "defaultNextBlockId" in value && 373 - value.defaultNextBlockId === null 374 - ? null 375 - : undefined; 376 - 377 - return parseBlockConfig(type, { 378 - options: options.length >= 2 ? options : ["Option 1", "Option 2"], 379 - branchRules, 380 - defaultNextBlockId, 381 - }); 382 - } 383 - 384 - return parseBlockConfig(type, value); 385 - } 386 - 387 299 function getQuestionLabel(block: SerializedBlock) { 388 300 return block.title || "this question"; 389 301 } ··· 477 389 } 478 390 } 479 391 480 - function parseSnapshotBlocks(response: ResponseRecord): SerializedBlock[] { 481 - const parsed = z 482 - .array(snapshotBlockSchema) 483 - .safeParse(response.formSnapshotJson); 484 - 485 - if (!parsed.success) { 486 - return response.form.blocks.map(serializeBlock); 487 - } 488 - 489 - return parsed.data 490 - .sort((left, right) => left.position - right.position) 491 - .map((block) => ({ 492 - id: block.id, 493 - type: block.type, 494 - title: block.title, 495 - description: block.description, 496 - required: block.required, 497 - position: block.position, 498 - formId: response.formId, 499 - createdAt: response.submittedAt, 500 - updatedAt: response.submittedAt, 501 - config: normalizeBlockConfig(block.type, block.config), 502 - })); 503 - } 504 - 505 - function parseResponseAnswers( 506 - response: PrismaResponse, 507 - ): Record<string, string | string[]> { 508 - return typeof response.answersJson === "object" && 509 - response.answersJson && 510 - !Array.isArray(response.answersJson) 511 - ? (response.answersJson as Record<string, string | string[]>) 512 - : {}; 513 - } 514 - 515 - function parseVisitedBlockIds( 516 - response: PrismaResponse, 517 - fallbackBlocks: SerializedBlock[], 518 - ) { 519 - const parsed = z 520 - .array(snapshotBlockSchema) 521 - .safeParse(response.formSnapshotJson); 522 - 523 - if (!parsed.success) { 524 - return new Set(fallbackBlocks.map((block) => block.id)); 525 - } 526 - 527 - return new Set(parsed.data.map((block) => block.id)); 528 - } 529 - 530 - function hasRecordedAnswer(answer: string | string[] | undefined) { 531 - if (Array.isArray(answer)) { 532 - return answer.length > 0; 533 - } 534 - 535 - return typeof answer === "string" && answer.trim().length > 0; 536 - } 537 - 538 - function aggregateQuestionSummaries( 539 - blocks: SerializedBlock[], 540 - responses: PrismaResponse[], 541 - ): QuestionSummary[] { 542 - const answerableBlocks = blocks.filter((block) => 543 - isQuestionBlock(block.type), 544 - ); 545 - const responseSummaries = responses.map((response, index) => ({ 546 - id: response.id, 547 - submissionNumber: index + 1, 548 - answers: parseResponseAnswers(response), 549 - visitedBlockIds: parseVisitedBlockIds(response, blocks), 550 - })); 551 - 552 - return answerableBlocks.map((block) => { 553 - const reachedResponses = responseSummaries.filter((response) => 554 - response.visitedBlockIds.has(block.id), 555 - ); 556 - const skippedCount = responseSummaries.length - reachedResponses.length; 557 - const answeredResponses = reachedResponses.filter((response) => 558 - hasRecordedAnswer(response.answers[block.id]), 559 - ); 560 - const answeredCount = answeredResponses.length; 561 - const emptyCount = reachedResponses.length - answeredCount; 562 - 563 - if ( 564 - block.type === FORM_BLOCK_TYPES.SINGLE_CHOICE || 565 - block.type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE || 566 - block.type === FORM_BLOCK_TYPES.AGREEMENT 567 - ) { 568 - const initialOptions = 569 - block.type === FORM_BLOCK_TYPES.AGREEMENT 570 - ? [AGREEMENT_ANSWER_VALUES.AGREED, AGREEMENT_ANSWER_VALUES.NOT_AGREED] 571 - : [...(block.config as ChoiceBlockConfig).options]; 572 - const optionCounts = new Map(initialOptions.map((value) => [value, 0])); 573 - 574 - for (const response of answeredResponses) { 575 - const answer = response.answers[block.id]; 576 - const values = Array.isArray(answer) 577 - ? answer 578 - : typeof answer === "string" 579 - ? [answer] 580 - : []; 581 - 582 - for (const value of values) { 583 - optionCounts.set(value, (optionCounts.get(value) ?? 0) + 1); 584 - } 585 - } 586 - 587 - return { 588 - block, 589 - kind: "choice", 590 - reachedCount: reachedResponses.length, 591 - answeredCount, 592 - emptyCount, 593 - skippedCount, 594 - optionCounts: [...optionCounts.entries()].map(([value, count]) => ({ 595 - value, 596 - count, 597 - })), 598 - } satisfies QuestionSummary; 599 - } 600 - 601 - if (block.type === FORM_BLOCK_TYPES.NUMBER) { 602 - const numericAnswers = answeredResponses 603 - .map((response) => response.answers[block.id]) 604 - .filter((answer): answer is string => typeof answer === "string") 605 - .map((answer) => Number(answer)) 606 - .filter((value) => Number.isFinite(value)); 607 - const average = numericAnswers.length 608 - ? numericAnswers.reduce((sum, value) => sum + value, 0) / 609 - numericAnswers.length 610 - : null; 611 - 612 - return { 613 - block, 614 - kind: "number", 615 - reachedCount: reachedResponses.length, 616 - answeredCount, 617 - emptyCount, 618 - skippedCount, 619 - min: numericAnswers.length ? Math.min(...numericAnswers) : null, 620 - max: numericAnswers.length ? Math.max(...numericAnswers) : null, 621 - average, 622 - } satisfies QuestionSummary; 623 - } 624 - 625 - const answers = answeredResponses 626 - .map((response) => ({ 627 - responseId: response.id, 628 - submissionNumber: response.submissionNumber, 629 - value: response.answers[block.id], 630 - })) 631 - .flatMap((response): QuestionSummaryAnswer[] => { 632 - if (Array.isArray(response.value)) { 633 - return response.value.map((value) => ({ 634 - responseId: response.responseId, 635 - submissionNumber: response.submissionNumber, 636 - value, 637 - })); 638 - } 639 - 640 - if (typeof response.value === "string") { 641 - return [ 642 - { 643 - responseId: response.responseId, 644 - submissionNumber: response.submissionNumber, 645 - value: response.value, 646 - }, 647 - ]; 648 - } 649 - 650 - return []; 651 - }) 652 - .reverse(); 653 - 654 - return { 655 - block, 656 - kind: "freeform", 657 - reachedCount: reachedResponses.length, 658 - answeredCount, 659 - emptyCount, 660 - skippedCount, 661 - answers, 662 - } satisfies QuestionSummary; 663 - }); 664 - } 665 - 666 - function buildResponseReviewData( 667 - form: BuilderFormRecord, 668 - responses: PrismaResponse[], 669 - ): ResponseReviewData { 670 - const serializedBlocks = form.blocks.map(serializeBlock); 671 - 672 - return { 673 - form: { 674 - id: form.id, 675 - title: form.title, 676 - workspace: toWorkspaceReference(form), 677 - } satisfies ResponseReviewFormSummary, 678 - responses: responses 679 - .map((response, index) => ({ 680 - id: response.id, 681 - submittedAt: response.submittedAt.toISOString(), 682 - answerCount: Object.keys(parseResponseAnswers(response)).length, 683 - submissionNumber: index + 1, 684 - })) 685 - .reverse() satisfies ResponseListItem[], 686 - questionSummaries: aggregateQuestionSummaries(serializedBlocks, responses), 687 - }; 688 - } 689 - 690 392 export async function listFormsForWorkspace( 691 393 userId: string, 692 394 workspace: ActiveWorkspace, ··· 1071 773 orderBy: [{ submittedAt: "asc" }, { id: "asc" }], 1072 774 }); 1073 775 1074 - return buildResponseReviewData(form, responses); 776 + return buildResponseReviewData({ 777 + form: { 778 + id: form.id, 779 + title: form.title, 780 + workspace: toWorkspaceReference(form), 781 + blocks: form.blocks.map(serializeBlock), 782 + }, 783 + responses, 784 + }); 1075 785 } 1076 786 1077 787 export async function getOwnedResponseDetail( ··· 1129 839 }, 1130 840 }); 1131 841 1132 - const visitedBlocks = parseSnapshotBlocks(response); 1133 - const visitedBlockIds = new Set(visitedBlocks.map((block) => block.id)); 1134 - const currentFormBlocks = response.form.blocks.map(serializeBlock); 1135 - 1136 - return { 1137 - id: response.id, 1138 - submittedAt: response.submittedAt.toISOString(), 842 + return buildResponseDetailData({ 843 + responseId: response.id, 844 + formId, 845 + submittedAt: response.submittedAt, 1139 846 submissionNumber, 1140 847 formTitle: response.form.title, 1141 848 workspace: toWorkspaceReference(response.form), 1142 - answers: 1143 - typeof response.answersJson === "object" && 1144 - response.answersJson && 1145 - !Array.isArray(response.answersJson) 1146 - ? (response.answersJson as Record<string, string | string[]>) 1147 - : {}, 1148 - blocks: visitedBlocks, 1149 - routeBlocks: currentFormBlocks.map((block) => ({ 1150 - block, 1151 - status: visitedBlockIds.has(block.id) ? "visited" : "skipped", 1152 - })), 1153 - }; 849 + answersJson: response.answersJson, 850 + formSnapshotJson: response.formSnapshotJson, 851 + currentBlocks: response.form.blocks.map(serializeBlock), 852 + }); 1154 853 }
+142
lib/public-form-runner-state.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + 3 + import type { SerializedBlock } from "@/lib/blocks"; 4 + import { 5 + advanceRunnerRoute, 6 + createInitialRunnerRoute, 7 + getCurrentRouteBlockId, 8 + getVisibleRouteTotal, 9 + hasRunnerProgress, 10 + retreatRunnerRoute, 11 + } from "@/lib/public-form-runner-state"; 12 + 13 + function createBlock( 14 + overrides: Partial<SerializedBlock> & Pick<SerializedBlock, "id" | "type">, 15 + ): SerializedBlock { 16 + const now = new Date("2026-04-14T00:00:00.000Z"); 17 + 18 + return { 19 + id: overrides.id, 20 + formId: "form-1", 21 + type: overrides.type, 22 + title: overrides.title ?? overrides.id, 23 + description: overrides.description ?? "", 24 + required: overrides.required ?? false, 25 + position: overrides.position ?? 0, 26 + createdAt: overrides.createdAt ?? now, 27 + updatedAt: overrides.updatedAt ?? now, 28 + config: 29 + overrides.config ?? 30 + (overrides.type === "TEXT" 31 + ? { body: "Intro" } 32 + : overrides.type === "SINGLE_CHOICE" 33 + ? { 34 + options: ["yes", "no"], 35 + branchRules: [], 36 + defaultNextBlockId: null, 37 + } 38 + : { 39 + placeholder: "", 40 + validationRegex: null, 41 + branchRules: [], 42 + defaultNextBlockId: null, 43 + }), 44 + }; 45 + } 46 + 47 + describe("public form runner state helpers", () => { 48 + test("starts the route at the first saved block", () => { 49 + const blocks = [ 50 + createBlock({ id: "intro", type: "TEXT", position: 0 }), 51 + createBlock({ id: "question", type: "SHORT_TEXT", position: 1 }), 52 + ]; 53 + 54 + const route = createInitialRunnerRoute(blocks); 55 + 56 + expect(JSON.stringify(route.history)).toBe(JSON.stringify(["intro"])); 57 + expect(getCurrentRouteBlockId(route, blocks)).toBe("intro"); 58 + }); 59 + 60 + test("replaces future history when the respondent continues from an earlier step", () => { 61 + const originalRoute = { 62 + history: ["intro", "standard", "finish"], 63 + cursor: 1, 64 + }; 65 + 66 + const nextRoute = advanceRunnerRoute(originalRoute, "standard", "priority"); 67 + 68 + expect(JSON.stringify(nextRoute.history)).toBe( 69 + JSON.stringify(["intro", "standard", "priority"]), 70 + ); 71 + expect(nextRoute.cursor).toBe(2); 72 + }); 73 + 74 + test("progress total follows the actual remaining branch route instead of the full saved block list", () => { 75 + const blocks = [ 76 + createBlock({ 77 + id: "path", 78 + type: "SINGLE_CHOICE", 79 + position: 0, 80 + config: { 81 + options: ["yes", "no"], 82 + branchRules: [ 83 + { operator: "equals", value: "yes", targetBlockId: "finish" }, 84 + ], 85 + defaultNextBlockId: null, 86 + }, 87 + }), 88 + createBlock({ id: "skipped", type: "SHORT_TEXT", position: 1 }), 89 + createBlock({ id: "finish", type: "SHORT_TEXT", position: 2 }), 90 + ]; 91 + 92 + const total = getVisibleRouteTotal({ 93 + blocks, 94 + currentBlockId: "path", 95 + answers: { path: "yes" }, 96 + cursor: 0, 97 + }); 98 + 99 + expect(total).toBe(2); 100 + }); 101 + 102 + test("detects saved progress from answers, cursor movement, or history growth", () => { 103 + expect( 104 + hasRunnerProgress({ 105 + answers: {}, 106 + cursor: 0, 107 + historyLength: 1, 108 + initialHistoryLength: 1, 109 + }), 110 + ).toBe(false); 111 + 112 + expect( 113 + hasRunnerProgress({ 114 + answers: { question: "Ada" }, 115 + cursor: 0, 116 + historyLength: 1, 117 + initialHistoryLength: 1, 118 + }), 119 + ).toBe(true); 120 + 121 + expect( 122 + hasRunnerProgress({ 123 + answers: {}, 124 + cursor: 1, 125 + historyLength: 2, 126 + initialHistoryLength: 1, 127 + }), 128 + ).toBe(true); 129 + }); 130 + 131 + test("moves backward without changing visited history", () => { 132 + const route = retreatRunnerRoute({ 133 + history: ["intro", "question", "finish"], 134 + cursor: 2, 135 + }); 136 + 137 + expect(route.cursor).toBe(1); 138 + expect(JSON.stringify(route.history)).toBe( 139 + JSON.stringify(["intro", "question", "finish"]), 140 + ); 141 + }); 142 + });
+99
lib/public-form-runner-state.ts
··· 1 + import type { SerializedBlock } from "@/lib/blocks"; 2 + import { resolveNextBlockId } from "@/lib/branching"; 3 + import type { AnswerValue } from "@/lib/form-types"; 4 + 5 + export type PublicFormRunnerRoute = { 6 + history: string[]; 7 + cursor: number; 8 + }; 9 + 10 + export function createInitialRunnerRoute( 11 + blocks: Pick<SerializedBlock, "id">[], 12 + ): PublicFormRunnerRoute { 13 + const firstBlockId = blocks[0]?.id; 14 + 15 + return { 16 + history: firstBlockId ? [firstBlockId] : [], 17 + cursor: 0, 18 + }; 19 + } 20 + 21 + export function getRouteCursor(route: PublicFormRunnerRoute) { 22 + return Math.min(route.cursor, Math.max(route.history.length - 1, 0)); 23 + } 24 + 25 + export function getCurrentRouteBlockId( 26 + route: PublicFormRunnerRoute, 27 + blocks: Pick<SerializedBlock, "id">[], 28 + ) { 29 + const cursor = getRouteCursor(route); 30 + return route.history[cursor] ?? blocks[0]?.id ?? null; 31 + } 32 + 33 + export function getVisibleRouteTotal(args: { 34 + blocks: SerializedBlock[]; 35 + currentBlockId: string | null; 36 + answers: Record<string, AnswerValue>; 37 + cursor: number; 38 + }) { 39 + const { blocks, currentBlockId, answers, cursor } = args; 40 + 41 + if (!currentBlockId) { 42 + return 1; 43 + } 44 + 45 + let total = cursor + 1; 46 + let nextId = resolveNextBlockId(blocks, currentBlockId, answers); 47 + let safetyCounter = 0; 48 + 49 + while (nextId && safetyCounter < blocks.length) { 50 + total += 1; 51 + nextId = resolveNextBlockId(blocks, nextId, answers); 52 + safetyCounter += 1; 53 + } 54 + 55 + return Math.max(total, cursor + 1); 56 + } 57 + 58 + export function hasRunnerProgress(args: { 59 + answers: Record<string, AnswerValue>; 60 + cursor: number; 61 + historyLength: number; 62 + initialHistoryLength: number; 63 + }) { 64 + const { answers, cursor, historyLength, initialHistoryLength } = args; 65 + const hasAnyAnswer = Object.values(answers).some((value) => 66 + typeof value === "string" ? value.trim().length > 0 : value.length > 0, 67 + ); 68 + 69 + return hasAnyAnswer || cursor > 0 || historyLength > initialHistoryLength; 70 + } 71 + 72 + export function advanceRunnerRoute( 73 + route: PublicFormRunnerRoute, 74 + sourceBlockId: string, 75 + nextBlockId: string, 76 + ): PublicFormRunnerRoute { 77 + const activeBlockId = route.history[route.cursor] ?? null; 78 + 79 + if (activeBlockId !== sourceBlockId) { 80 + return route; 81 + } 82 + 83 + const nextHistory = [ 84 + ...route.history.slice(0, route.cursor + 1), 85 + nextBlockId, 86 + ]; 87 + 88 + return { 89 + history: nextHistory, 90 + cursor: nextHistory.length - 1, 91 + }; 92 + } 93 + 94 + export function retreatRunnerRoute(route: PublicFormRunnerRoute) { 95 + return { 96 + ...route, 97 + cursor: Math.max(0, route.cursor - 1), 98 + }; 99 + }
+278
lib/response-review.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + 3 + import type { SerializedBlock } from "@/lib/blocks"; 4 + import { 5 + buildResponseDetailData, 6 + buildResponseReviewData, 7 + } from "@/lib/response-review"; 8 + 9 + function createBlock( 10 + overrides: Partial<SerializedBlock> & Pick<SerializedBlock, "id" | "type">, 11 + ): SerializedBlock { 12 + const now = new Date("2026-04-14T00:00:00.000Z"); 13 + 14 + return { 15 + id: overrides.id, 16 + formId: "form-1", 17 + type: overrides.type, 18 + title: overrides.title ?? overrides.id, 19 + description: overrides.description ?? "", 20 + required: overrides.required ?? false, 21 + position: overrides.position ?? 0, 22 + createdAt: overrides.createdAt ?? now, 23 + updatedAt: overrides.updatedAt ?? now, 24 + config: 25 + overrides.config ?? 26 + (overrides.type === "TEXT" 27 + ? { body: "Intro" } 28 + : overrides.type === "SINGLE_CHOICE" 29 + ? { 30 + options: ["yes", "no"], 31 + branchRules: [], 32 + defaultNextBlockId: null, 33 + } 34 + : overrides.type === "MULTIPLE_CHOICE" 35 + ? { 36 + options: ["red", "blue", "green"], 37 + branchRules: [], 38 + defaultNextBlockId: null, 39 + } 40 + : overrides.type === "NUMBER" 41 + ? { 42 + placeholder: "", 43 + allowFloat: false, 44 + min: null, 45 + max: null, 46 + branchRules: [], 47 + defaultNextBlockId: null, 48 + } 49 + : overrides.type === "AGREEMENT" 50 + ? { 51 + label: "I agree", 52 + branchRules: [], 53 + defaultNextBlockId: null, 54 + } 55 + : { 56 + placeholder: "", 57 + validationRegex: null, 58 + branchRules: [], 59 + defaultNextBlockId: null, 60 + }), 61 + }; 62 + } 63 + 64 + describe("buildResponseReviewData", () => { 65 + test("counts reached, skipped, choice, and number summaries from visited routes", () => { 66 + const blocks = [ 67 + createBlock({ 68 + id: "path", 69 + type: "SINGLE_CHOICE", 70 + position: 0, 71 + config: { 72 + options: ["yes", "no"], 73 + branchRules: [], 74 + defaultNextBlockId: null, 75 + }, 76 + }), 77 + createBlock({ id: "follow-up", type: "SHORT_TEXT", position: 1 }), 78 + createBlock({ id: "score", type: "NUMBER", position: 2 }), 79 + ]; 80 + 81 + const responseData = buildResponseReviewData({ 82 + form: { 83 + id: "form-1", 84 + title: "Survey", 85 + workspace: { kind: "personal", label: "Personal workspace" }, 86 + blocks, 87 + }, 88 + responses: [ 89 + { 90 + id: "response-1", 91 + submittedAt: new Date("2026-04-14T10:00:00.000Z"), 92 + answersJson: { path: "yes", score: "10" }, 93 + formSnapshotJson: [ 94 + { 95 + id: "path", 96 + type: "SINGLE_CHOICE", 97 + title: "path", 98 + description: "", 99 + required: false, 100 + position: 0, 101 + config: { options: ["yes", "no"] }, 102 + }, 103 + { 104 + id: "score", 105 + type: "NUMBER", 106 + title: "score", 107 + description: "", 108 + required: false, 109 + position: 2, 110 + config: {}, 111 + }, 112 + ], 113 + }, 114 + { 115 + id: "response-2", 116 + submittedAt: new Date("2026-04-14T11:00:00.000Z"), 117 + answersJson: { path: "no", "follow-up": "Ada", score: "20" }, 118 + formSnapshotJson: [ 119 + { 120 + id: "path", 121 + type: "SINGLE_CHOICE", 122 + title: "path", 123 + description: "", 124 + required: false, 125 + position: 0, 126 + config: { options: ["yes", "no"] }, 127 + }, 128 + { 129 + id: "follow-up", 130 + type: "SHORT_TEXT", 131 + title: "follow-up", 132 + description: "", 133 + required: false, 134 + position: 1, 135 + config: {}, 136 + }, 137 + { 138 + id: "score", 139 + type: "NUMBER", 140 + title: "score", 141 + description: "", 142 + required: false, 143 + position: 2, 144 + config: {}, 145 + }, 146 + ], 147 + }, 148 + ], 149 + }); 150 + 151 + expect(responseData.responses.length).toBe(2); 152 + expect(responseData.responses[0]?.id).toBe("response-2"); 153 + 154 + const pathSummary = responseData.questionSummaries.find( 155 + (summary) => summary.block.id === "path", 156 + ); 157 + const followUpSummary = responseData.questionSummaries.find( 158 + (summary) => summary.block.id === "follow-up", 159 + ); 160 + const scoreSummary = responseData.questionSummaries.find( 161 + (summary) => summary.block.id === "score", 162 + ); 163 + 164 + expect(pathSummary?.kind).toBe("choice"); 165 + expect(pathSummary?.reachedCount).toBe(2); 166 + expect(pathSummary?.answeredCount).toBe(2); 167 + expect( 168 + pathSummary?.kind === "choice" 169 + ? pathSummary.optionCounts.find((option) => option.value === "yes") 170 + ?.count 171 + : null, 172 + ).toBe(1); 173 + expect( 174 + pathSummary?.kind === "choice" 175 + ? pathSummary.optionCounts.find((option) => option.value === "no") 176 + ?.count 177 + : null, 178 + ).toBe(1); 179 + 180 + expect(followUpSummary?.reachedCount).toBe(1); 181 + expect(followUpSummary?.answeredCount).toBe(1); 182 + expect(followUpSummary?.skippedCount).toBe(1); 183 + 184 + expect(scoreSummary?.kind).toBe("number"); 185 + expect(scoreSummary?.reachedCount).toBe(2); 186 + expect(scoreSummary?.kind === "number" ? scoreSummary.min : null).toBe(10); 187 + expect(scoreSummary?.kind === "number" ? scoreSummary.max : null).toBe(20); 188 + expect(scoreSummary?.kind === "number" ? scoreSummary.average : null).toBe( 189 + 15, 190 + ); 191 + }); 192 + }); 193 + 194 + describe("buildResponseDetailData", () => { 195 + test("marks current route blocks as visited or skipped while showing visited snapshot blocks", () => { 196 + const currentBlocks = [ 197 + createBlock({ id: "path", type: "SINGLE_CHOICE", position: 0 }), 198 + createBlock({ id: "follow-up", type: "SHORT_TEXT", position: 1 }), 199 + createBlock({ id: "score", type: "NUMBER", position: 2 }), 200 + ]; 201 + 202 + const detail = buildResponseDetailData({ 203 + responseId: "response-1", 204 + formId: "form-1", 205 + submittedAt: new Date("2026-04-14T10:00:00.000Z"), 206 + submissionNumber: 1, 207 + formTitle: "Survey", 208 + workspace: { kind: "personal", label: "Personal workspace" }, 209 + answersJson: { path: "yes", score: "10" }, 210 + formSnapshotJson: [ 211 + { 212 + id: "path", 213 + type: "SINGLE_CHOICE", 214 + title: "Path at submit time", 215 + description: "", 216 + required: false, 217 + position: 0, 218 + config: { options: ["yes", "no"] }, 219 + }, 220 + { 221 + id: "score", 222 + type: "NUMBER", 223 + title: "Score at submit time", 224 + description: "", 225 + required: false, 226 + position: 2, 227 + config: {}, 228 + }, 229 + ], 230 + currentBlocks, 231 + }); 232 + 233 + expect(JSON.stringify(detail.blocks.map((block) => block.id))).toBe( 234 + JSON.stringify(["path", "score"]), 235 + ); 236 + expect(detail.blocks[0]?.title).toBe("Path at submit time"); 237 + expect( 238 + JSON.stringify( 239 + detail.routeBlocks.map((entry) => ({ 240 + id: entry.block.id, 241 + status: entry.status, 242 + })), 243 + ), 244 + ).toBe( 245 + JSON.stringify([ 246 + { id: "path", status: "visited" }, 247 + { id: "follow-up", status: "skipped" }, 248 + { id: "score", status: "visited" }, 249 + ]), 250 + ); 251 + }); 252 + 253 + test("falls back to current blocks when snapshot data is malformed", () => { 254 + const currentBlocks = [ 255 + createBlock({ id: "first", type: "SHORT_TEXT", position: 0 }), 256 + createBlock({ id: "second", type: "SHORT_TEXT", position: 1 }), 257 + ]; 258 + 259 + const detail = buildResponseDetailData({ 260 + responseId: "response-1", 261 + formId: "form-1", 262 + submittedAt: new Date("2026-04-14T10:00:00.000Z"), 263 + submissionNumber: 1, 264 + formTitle: "Survey", 265 + workspace: { kind: "personal", label: "Personal workspace" }, 266 + answersJson: { first: "Ada" }, 267 + formSnapshotJson: { invalid: true }, 268 + currentBlocks, 269 + }); 270 + 271 + expect(JSON.stringify(detail.blocks.map((block) => block.id))).toBe( 272 + JSON.stringify(["first", "second"]), 273 + ); 274 + expect( 275 + detail.routeBlocks.every((entry) => entry.status === "visited"), 276 + ).toBe(true); 277 + }); 278 + });
+292
lib/response-review.ts
··· 1 + import { z } from "zod"; 2 + 3 + import { 4 + AGREEMENT_ANSWER_VALUES, 5 + blockTypeSchema, 6 + isQuestionBlock, 7 + type ChoiceBlockConfig, 8 + type SerializedBlock, 9 + } from "@/lib/blocks"; 10 + import { normalizeBlockConfig } from "@/lib/block-config-normalization"; 11 + import type { 12 + QuestionSummary, 13 + QuestionSummaryAnswer, 14 + ResponseDetail, 15 + ResponseListItem, 16 + ResponseReviewData, 17 + ResponseReviewFormSummary, 18 + WorkspaceReference, 19 + } from "@/lib/form-types"; 20 + 21 + type StoredAnswerValue = string | string[]; 22 + 23 + type StoredResponseLike = { 24 + id: string; 25 + submittedAt: Date; 26 + answersJson: unknown; 27 + formSnapshotJson: unknown; 28 + }; 29 + 30 + const snapshotBlockSchema = z.object({ 31 + id: z.string(), 32 + type: blockTypeSchema, 33 + title: z.string(), 34 + description: z.string(), 35 + required: z.boolean(), 36 + position: z.number(), 37 + config: z.unknown(), 38 + }); 39 + 40 + export function parseStoredAnswers( 41 + answersJson: unknown, 42 + ): Record<string, StoredAnswerValue> { 43 + return typeof answersJson === "object" && 44 + answersJson && 45 + !Array.isArray(answersJson) 46 + ? (answersJson as Record<string, StoredAnswerValue>) 47 + : {}; 48 + } 49 + 50 + export function restoreVisitedBlocksFromSnapshot( 51 + formSnapshotJson: unknown, 52 + fallbackBlocks: SerializedBlock[], 53 + formId: string, 54 + submittedAt: Date, 55 + ) { 56 + const parsed = z.array(snapshotBlockSchema).safeParse(formSnapshotJson); 57 + 58 + if (!parsed.success) { 59 + return fallbackBlocks; 60 + } 61 + 62 + return parsed.data 63 + .sort((left, right) => left.position - right.position) 64 + .map( 65 + (block) => 66 + ({ 67 + id: block.id, 68 + type: block.type, 69 + title: block.title, 70 + description: block.description, 71 + required: block.required, 72 + position: block.position, 73 + formId, 74 + createdAt: submittedAt, 75 + updatedAt: submittedAt, 76 + config: normalizeBlockConfig(block.type, block.config), 77 + }) satisfies SerializedBlock, 78 + ); 79 + } 80 + 81 + export function getVisitedBlockIds( 82 + formSnapshotJson: unknown, 83 + fallbackBlocks: SerializedBlock[], 84 + ) { 85 + const parsed = z.array(snapshotBlockSchema).safeParse(formSnapshotJson); 86 + 87 + if (!parsed.success) { 88 + return new Set(fallbackBlocks.map((block) => block.id)); 89 + } 90 + 91 + return new Set(parsed.data.map((block) => block.id)); 92 + } 93 + 94 + function hasRecordedAnswer(answer: string | string[] | undefined) { 95 + if (Array.isArray(answer)) { 96 + return answer.length > 0; 97 + } 98 + 99 + return typeof answer === "string" && answer.trim().length > 0; 100 + } 101 + 102 + function aggregateQuestionSummaries( 103 + blocks: SerializedBlock[], 104 + responses: StoredResponseLike[], 105 + ): QuestionSummary[] { 106 + const answerableBlocks = blocks.filter((block) => 107 + isQuestionBlock(block.type), 108 + ); 109 + const responseSummaries = responses.map((response, index) => ({ 110 + id: response.id, 111 + submissionNumber: index + 1, 112 + answers: parseStoredAnswers(response.answersJson), 113 + visitedBlockIds: getVisitedBlockIds(response.formSnapshotJson, blocks), 114 + })); 115 + 116 + return answerableBlocks.map((block) => { 117 + const reachedResponses = responseSummaries.filter((response) => 118 + response.visitedBlockIds.has(block.id), 119 + ); 120 + const skippedCount = responseSummaries.length - reachedResponses.length; 121 + const answeredResponses = reachedResponses.filter((response) => 122 + hasRecordedAnswer(response.answers[block.id]), 123 + ); 124 + const answeredCount = answeredResponses.length; 125 + const emptyCount = reachedResponses.length - answeredCount; 126 + 127 + if ( 128 + block.type === "SINGLE_CHOICE" || 129 + block.type === "MULTIPLE_CHOICE" || 130 + block.type === "AGREEMENT" 131 + ) { 132 + const initialOptions = 133 + block.type === "AGREEMENT" 134 + ? [AGREEMENT_ANSWER_VALUES.AGREED, AGREEMENT_ANSWER_VALUES.NOT_AGREED] 135 + : [...(block.config as ChoiceBlockConfig).options]; 136 + const optionCounts = new Map(initialOptions.map((value) => [value, 0])); 137 + 138 + for (const response of answeredResponses) { 139 + const answer = response.answers[block.id]; 140 + const values = Array.isArray(answer) 141 + ? answer 142 + : typeof answer === "string" 143 + ? [answer] 144 + : []; 145 + 146 + for (const value of values) { 147 + optionCounts.set(value, (optionCounts.get(value) ?? 0) + 1); 148 + } 149 + } 150 + 151 + return { 152 + block, 153 + kind: "choice", 154 + reachedCount: reachedResponses.length, 155 + answeredCount, 156 + emptyCount, 157 + skippedCount, 158 + optionCounts: [...optionCounts.entries()].map(([value, count]) => ({ 159 + value, 160 + count, 161 + })), 162 + } satisfies QuestionSummary; 163 + } 164 + 165 + if (block.type === "NUMBER") { 166 + const numericAnswers = answeredResponses 167 + .map((response) => response.answers[block.id]) 168 + .filter((answer): answer is string => typeof answer === "string") 169 + .map((answer) => Number(answer)) 170 + .filter((value) => Number.isFinite(value)); 171 + const average = numericAnswers.length 172 + ? numericAnswers.reduce((sum, value) => sum + value, 0) / 173 + numericAnswers.length 174 + : null; 175 + 176 + return { 177 + block, 178 + kind: "number", 179 + reachedCount: reachedResponses.length, 180 + answeredCount, 181 + emptyCount, 182 + skippedCount, 183 + min: numericAnswers.length ? Math.min(...numericAnswers) : null, 184 + max: numericAnswers.length ? Math.max(...numericAnswers) : null, 185 + average, 186 + } satisfies QuestionSummary; 187 + } 188 + 189 + const answers = answeredResponses 190 + .map((response) => ({ 191 + responseId: response.id, 192 + submissionNumber: response.submissionNumber, 193 + value: response.answers[block.id], 194 + })) 195 + .flatMap((response): QuestionSummaryAnswer[] => { 196 + if (Array.isArray(response.value)) { 197 + return response.value.map((value) => ({ 198 + responseId: response.responseId, 199 + submissionNumber: response.submissionNumber, 200 + value, 201 + })); 202 + } 203 + 204 + if (typeof response.value === "string") { 205 + return [ 206 + { 207 + responseId: response.responseId, 208 + submissionNumber: response.submissionNumber, 209 + value: response.value, 210 + }, 211 + ]; 212 + } 213 + 214 + return []; 215 + }) 216 + .reverse(); 217 + 218 + return { 219 + block, 220 + kind: "freeform", 221 + reachedCount: reachedResponses.length, 222 + answeredCount, 223 + emptyCount, 224 + skippedCount, 225 + answers, 226 + } satisfies QuestionSummary; 227 + }); 228 + } 229 + 230 + export function buildResponseListItems( 231 + responses: StoredResponseLike[], 232 + ): ResponseListItem[] { 233 + return responses 234 + .map((response, index) => ({ 235 + id: response.id, 236 + submittedAt: response.submittedAt.toISOString(), 237 + answerCount: Object.keys(parseStoredAnswers(response.answersJson)).length, 238 + submissionNumber: index + 1, 239 + })) 240 + .reverse(); 241 + } 242 + 243 + export function buildResponseReviewData(input: { 244 + form: ResponseReviewFormSummary & { blocks: SerializedBlock[] }; 245 + responses: StoredResponseLike[]; 246 + }): ResponseReviewData { 247 + const { form, responses } = input; 248 + 249 + return { 250 + form: { 251 + id: form.id, 252 + title: form.title, 253 + workspace: form.workspace, 254 + }, 255 + responses: buildResponseListItems(responses), 256 + questionSummaries: aggregateQuestionSummaries(form.blocks, responses), 257 + }; 258 + } 259 + 260 + export function buildResponseDetailData(input: { 261 + responseId: string; 262 + formId: string; 263 + submittedAt: Date; 264 + submissionNumber: number; 265 + formTitle: string; 266 + workspace: WorkspaceReference; 267 + answersJson: unknown; 268 + formSnapshotJson: unknown; 269 + currentBlocks: SerializedBlock[]; 270 + }): ResponseDetail { 271 + const visitedBlocks = restoreVisitedBlocksFromSnapshot( 272 + input.formSnapshotJson, 273 + input.currentBlocks, 274 + input.formId, 275 + input.submittedAt, 276 + ); 277 + const visitedBlockIds = new Set(visitedBlocks.map((block) => block.id)); 278 + 279 + return { 280 + id: input.responseId, 281 + submittedAt: input.submittedAt.toISOString(), 282 + submissionNumber: input.submissionNumber, 283 + formTitle: input.formTitle, 284 + workspace: input.workspace, 285 + answers: parseStoredAnswers(input.answersJson), 286 + blocks: visitedBlocks, 287 + routeBlocks: input.currentBlocks.map((block) => ({ 288 + block, 289 + status: visitedBlockIds.has(block.id) ? "visited" : "skipped", 290 + })), 291 + }; 292 + }
+2
openspec/changes/archive/2026-04-14-reduce-core-form-complexity/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-14
+91
openspec/changes/archive/2026-04-14-reduce-core-form-complexity/design.md
··· 1 + ## Context 2 + 3 + The project’s most behavior-heavy paths now sit inside a small number of very large files. `lib/forms.ts` mixes persistence, validation, submission, response review shaping, and export-adjacent concerns. The builder is split across two large, state-heavy components. The public runner combines route state, validation, draft persistence, keyboard behavior, submission, and rendering in one component. Test coverage exists for some pure helpers, but coverage is still sparse around these critical surfaces. 4 + 5 + This change is intentionally phased. The goal is to reduce complexity without introducing large behavior shifts or destabilizing the app. The safest path is to add or strengthen behavior-focused tests first, then extract cohesive seams, then simplify the largest UI surfaces around those seams. 6 + 7 + ## Goals / Non-Goals 8 + 9 + **Goals:** 10 + - Reduce responsibility concentration in `lib/forms.ts` by extracting cohesive domain helpers. 11 + - Create smaller, more focused seams inside builder and runner code so future changes do not require editing giant files. 12 + - Add regression coverage around current high-risk form behavior before and during refactors. 13 + - Keep existing user-visible behavior stable while refactoring. 14 + - Sequence work in reviewable phases with clear checkpoints. 15 + 16 + **Non-Goals:** 17 + - Re-designing the builder UX or public runner UX. 18 + - Changing persistence models, response schemas, or public API contracts. 19 + - Replacing the current builder or runner architecture in one large rewrite. 20 + - Solving all UI duplication and infrastructure cleanup in the same change. 21 + 22 + ## Decisions 23 + 24 + ### 1. Use a test-first seam-extraction approach 25 + Start each phase by locking current behavior with focused tests, then extract the underlying logic into smaller modules. 26 + 27 + Why: 28 + - Reduces regression risk while moving logic out of large files. 29 + - Lets the refactor stay product-behavior based rather than implementation-led. 30 + - Makes it easier to stop after each phase with a stable intermediate state. 31 + 32 + Alternatives considered: 33 + - Refactor first and backfill tests later: faster initially, but too risky for these high-behavior surfaces. 34 + - Full rewrite of builder/runner: too disruptive and difficult to review safely. 35 + 36 + ### 2. Start with `lib/forms.ts` before builder and runner splits 37 + Begin by extracting the most cohesive, testable domains out of `lib/forms.ts`, especially response review/response detail shaping and submission-adjacent helpers. 38 + 39 + Why: 40 + - `lib/forms.ts` is the central domain bottleneck and affects multiple downstream surfaces. 41 + - Shared helpers created here can simplify both builder and runner work later. 42 + - This phase is lower-risk than starting with the most stateful client components. 43 + 44 + Alternatives considered: 45 + - Start with builder splitting: valuable, but the builder depends on several domain helpers that are still too concentrated. 46 + - Start with runner splitting: valuable, but runner state changes are harder to test safely before domain seams improve. 47 + 48 + ### 3. Split UI surfaces by stateful behavior boundaries, not purely by JSX size 49 + When refactoring builder and runner, split along meaningful responsibility lines such as route state, submission behavior, draft persistence, editor section behavior, and specialized rendering. 50 + 51 + Why: 52 + - Avoids moving complexity into many shallow presentational files without reducing coupling. 53 + - Produces seams that are easier to test and reuse. 54 + 55 + Alternatives considered: 56 + - Split by arbitrary visual sections only: reduces file size, but often preserves hidden coupling and cross-component state churn. 57 + 58 + ### 4. Prefer pure helper modules for behavior that feeds multiple UI surfaces 59 + If logic is shared across review pages, exports, builder state mapping, or runner state mapping, extract a pure helper module and test it directly. 60 + 61 + Why: 62 + - Shared pure modules give the best payoff for regression coverage. 63 + - Prevents drift between summary/detail/export or between client/server validation paths. 64 + 65 + Alternatives considered: 66 + - Keep logic inline near each caller: simpler short term, but increases drift and maintenance cost. 67 + 68 + ## Risks / Trade-offs 69 + 70 + - **Incremental refactors can leave temporary intermediate seams** → Mitigation: keep phases small and behavior-locked with tests before/after extraction. 71 + - **Additional tests may initially feel slower than direct code cleanup** → Mitigation: focus coverage on high-risk behavior rather than snapshot-heavy or implementation-coupled tests. 72 + - **Large files may still remain large after first-phase extractions** → Mitigation: treat the change as phased and measure progress by responsibility reduction, not only raw line count. 73 + - **Builder and runner client-component tests can become brittle** → Mitigation: favor pure helper extraction first and add UI tests only around stable user contracts. 74 + 75 + ## Migration Plan 76 + 77 + 1. Add or strengthen behavior-focused tests around existing form-domain behavior. 78 + 2. Extract cohesive helpers from `lib/forms.ts` into focused modules with direct tests. 79 + 3. Update callers to use the extracted modules while preserving output shapes. 80 + 4. Apply the same pattern to builder seams, starting with draft/state mapping logic before larger component splits. 81 + 5. Apply the same pattern to public runner seams, starting with route/progress/validation-adjacent helpers where practical. 82 + 6. Keep checks green after each phase (`format`, `lint`, `typecheck`, targeted tests, build when needed). 83 + 84 + Rollback strategy: 85 + - Because the change is phased and intended to preserve behavior, each phase can be reverted independently by restoring the previous module wiring and keeping the added tests as regression references where still applicable. 86 + 87 + ## Open Questions 88 + 89 + - Which builder subdomain should be extracted first after `lib/forms.ts`: metadata editor behavior, choice/branch rule drafting, or mutation orchestration? 90 + - How much of runner behavior should move into pure helpers versus smaller local hooks/components? 91 + - Whether to add lightweight route-level/API tests during this change or defer them until the core extractions are complete.
+25
openspec/changes/archive/2026-04-14-reduce-core-form-complexity/proposal.md
··· 1 + ## Why 2 + 3 + The core form domain is now concentrated in a few oversized modules: `lib/forms.ts`, the builder surfaces, and the public runner. These areas carry most of the product’s behavior but do not yet have matching module boundaries or regression coverage, which makes routine changes riskier and slows down follow-up work. 4 + 5 + ## What Changes 6 + 7 + - Add a phased refactor plan that reduces the size and responsibility spread of `lib/forms.ts`, `components/form-builder.tsx`, `components/form-builder-panels.tsx`, and `components/public-form-runner.tsx`. 8 + - Extract cohesive domain helpers from `lib/forms.ts` into smaller, focused modules with stable interfaces. 9 + - Break builder and runner behavior into smaller units so state, rendering, and side effects are easier to reason about and test. 10 + - Expand automated test coverage around the current high-risk surfaces before and during refactoring so behavior remains stable. 11 + - Preserve existing user-facing behavior while improving maintainability and regression protection. 12 + 13 + ## Capabilities 14 + 15 + ### New Capabilities 16 + - `core-form-maintainability`: Internal quality requirements for module boundaries and regression coverage around the app’s core form flows. 17 + 18 + ### Modified Capabilities 19 + - None. 20 + 21 + ## Impact 22 + 23 + - Affected code: `lib/forms.ts`, `components/form-builder.tsx`, `components/form-builder-panels.tsx`, `components/public-form-runner.tsx`, related response review/export helpers, and new focused helper modules/tests. 24 + - Affected systems: creator builder flow, public runner flow, response review shaping, and automated test suite coverage. 25 + - No intended API, schema, or user-facing behavior changes.
+22
openspec/changes/archive/2026-04-14-reduce-core-form-complexity/specs/core-form-maintainability/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Core form domain logic is isolated into focused modules 4 + The system SHALL organize the app’s core form behavior into focused modules so persistence orchestration, submission behavior, response review shaping, builder state logic, and runner state logic are not all concentrated in a single oversized module or component. 5 + 6 + #### Scenario: Domain extraction preserves behavior while reducing responsibility spread 7 + - **WHEN** a core form surface is refactored 8 + - **THEN** the behavior previously provided by that surface remains available through smaller, focused modules or components with stable inputs and outputs 9 + 10 + ### Requirement: High-risk form flows are protected by behavior-focused automated tests 11 + The system SHALL maintain automated tests around the app’s highest-risk form flows before and during major refactors, especially around form-domain shaping, builder behavior, and public runner behavior. 12 + 13 + #### Scenario: Refactor of a high-risk form surface 14 + - **WHEN** a large core form module or component is being decomposed 15 + - **THEN** behavior-focused automated tests cover the affected contracts closely enough to detect regressions during the refactor 16 + 17 + ### Requirement: Shared answer and route behavior stays consistent across surfaces 18 + The system SHALL keep shared form behavior consistent across the server and UI surfaces that depend on it, including response shaping, route reconstruction, and answer presentation. 19 + 20 + #### Scenario: Shared form behavior is used by multiple surfaces 21 + - **WHEN** review pages, exports, builder helpers, or runner helpers rely on the same form-domain behavior 22 + - **THEN** that behavior is provided through shared tested logic rather than reimplemented separately in each surface
+29
openspec/changes/archive/2026-04-14-reduce-core-form-complexity/tasks.md
··· 1 + ## 1. Lock behavior with tests 2 + 3 + - [x] 1.1 Add focused regression tests around the first `lib/forms.ts` behavior slice selected for extraction 4 + - [x] 1.2 Add or strengthen tests around the next builder seam selected for extraction 5 + - [x] 1.3 Add or strengthen tests around the next public runner seam selected for extraction 6 + 7 + ## 2. Reduce `lib/forms.ts` responsibility spread 8 + 9 + - [x] 2.1 Extract a cohesive response/review or submission helper module out of `lib/forms.ts` 10 + - [x] 2.2 Update existing callers to use the new helper module without changing behavior 11 + - [x] 2.3 Remove obsolete duplication or inline logic from `lib/forms.ts` 12 + 13 + ## 3. Reduce builder complexity 14 + 15 + - [x] 3.1 Extract the first focused builder seam into a smaller helper, hook, or component boundary 16 + - [x] 3.2 Keep builder state and mutation behavior covered by tests while simplifying the parent surfaces 17 + - [x] 3.3 Remove obsolete duplication or inline logic from the builder files 18 + 19 + ## 4. Reduce runner complexity 20 + 21 + - [x] 4.1 Extract the first focused runner seam into a smaller helper, hook, or component boundary 22 + - [x] 4.2 Keep runner route, validation, and submission behavior covered by tests while simplifying the parent surface 23 + - [x] 4.3 Remove obsolete duplication or inline logic from the runner file 24 + 25 + ## 5. Verify stability 26 + 27 + - [x] 5.1 Run targeted tests for the extracted seams 28 + - [x] 5.2 Run format, lint, and typecheck 29 + - [x] 5.3 Run a production build before finalizing the change
+26
openspec/specs/core-form-maintainability/spec.md
··· 1 + # core-form-maintainability Specification 2 + 3 + ## Purpose 4 + TBD - created by archiving change reduce-core-form-complexity. Update Purpose after archive. 5 + ## Requirements 6 + ### Requirement: Core form domain logic is isolated into focused modules 7 + The system SHALL organize the app’s core form behavior into focused modules so persistence orchestration, submission behavior, response review shaping, builder state logic, and runner state logic are not all concentrated in a single oversized module or component. 8 + 9 + #### Scenario: Domain extraction preserves behavior while reducing responsibility spread 10 + - **WHEN** a core form surface is refactored 11 + - **THEN** the behavior previously provided by that surface remains available through smaller, focused modules or components with stable inputs and outputs 12 + 13 + ### Requirement: High-risk form flows are protected by behavior-focused automated tests 14 + The system SHALL maintain automated tests around the app’s highest-risk form flows before and during major refactors, especially around form-domain shaping, builder behavior, and public runner behavior. 15 + 16 + #### Scenario: Refactor of a high-risk form surface 17 + - **WHEN** a large core form module or component is being decomposed 18 + - **THEN** behavior-focused automated tests cover the affected contracts closely enough to detect regressions during the refactor 19 + 20 + ### Requirement: Shared answer and route behavior stays consistent across surfaces 21 + The system SHALL keep shared form behavior consistent across the server and UI surfaces that depend on it, including response shaping, route reconstruction, and answer presentation. 22 + 23 + #### Scenario: Shared form behavior is used by multiple surfaces 24 + - **WHEN** review pages, exports, builder helpers, or runner helpers rely on the same form-domain behavior 25 + - **THEN** that behavior is provided through shared tested logic rather than reimplemented separately in each surface 26 +