feat: improve form response experience

This commit is contained in:
2026-07-24 22:11:42 +07:00 Unverified
parent 29df784aeb
commit 7a9e67e283
12 changed files with 1279 additions and 349 deletions
+115 -16
View File
@@ -8,10 +8,17 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth";
import { sse } from "@/lib/sse";
import {
decodeCheckboxAnswer,
encodeAnswerValue,
formatStoredAnswer,
type FormAnswerValue,
} from "@/lib/form-answer-values";
import { stripHtml } from "@/lib/utils";
export async function submitForm(
formId: string,
answersList: { questionId: string; value: string }[]
answersList: { questionId: string; value: FormAnswerValue }[]
) {
const session = await getServerSession(authOptions);
if (!session?.user) throw new Error("Not authenticated");
@@ -21,6 +28,96 @@ export async function submitForm(
const form = await requireFormAccess(formId, discordId);
if (!form.isOpen) throw new Error("This form is disabled for now");
const questionsList = await db.query.questions.findMany({
where: (question, { eq }) => eq(question.formId, formId),
orderBy: (question, { asc }) => asc(question.displayOrder),
});
if (questionsList.length === 0) {
throw new Error("แบบฟอร์มนี้ยังไม่มีคำถาม");
}
const questionsById = new Map(questionsList.map((question) => [question.id, question]));
const submittedQuestionIds = new Set<string>();
for (const answer of answersList) {
if (!questionsById.has(answer.questionId) || submittedQuestionIds.has(answer.questionId)) {
throw new Error("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม");
}
submittedQuestionIds.add(answer.questionId);
}
const submittedAnswers = new Map(
answersList.map((answer) => [answer.questionId, answer.value])
);
const normalizedAnswers = questionsList.map((question) => {
const rawValue = submittedAnswers.get(question.id);
if (question.type === "checkbox") {
if (
rawValue !== undefined &&
typeof rawValue !== "string" &&
(!Array.isArray(rawValue) ||
rawValue.some((value) => typeof value !== "string"))
) {
throw new Error(`รูปแบบคำตอบของ “${stripHtml(question.label)}” ไม่ถูกต้อง`);
}
const selectedValues = [...new Set(decodeCheckboxAnswer(rawValue))];
if (
selectedValues.some(
(value) => !value.trim() || value === "__OTHER__"
)
) {
throw new Error(`โปรดระบุคำตอบสำหรับ “${stripHtml(question.label)}”`);
}
if (question.required && selectedValues.length === 0) {
throw new Error(`จำเป็นต้องตอบ “${stripHtml(question.label)}”`);
}
const standardOptions = question.options ?? [];
const customValues = selectedValues.filter(
(value) => !standardOptions.includes(value)
);
if (
(!question.allowOther && customValues.length > 0) ||
customValues.length > 1
) {
throw new Error(`ตัวเลือกของ “${stripHtml(question.label)}” ไม่ถูกต้อง`);
}
return {
questionId: question.id,
value: encodeAnswerValue(selectedValues),
displayValue: selectedValues.join(", "),
};
}
if (rawValue !== undefined && typeof rawValue !== "string") {
throw new Error(`รูปแบบคำตอบของ “${stripHtml(question.label)}” ไม่ถูกต้อง`);
}
const value = rawValue ?? "";
if (value === "__OTHER__") {
throw new Error(`โปรดระบุคำตอบสำหรับ “${stripHtml(question.label)}”`);
}
if (question.required && !value.trim()) {
throw new Error(`จำเป็นต้องตอบ “${stripHtml(question.label)}”`);
}
if (
question.type === "radio" &&
value &&
!(question.options ?? []).includes(value) &&
!question.allowOther
) {
throw new Error(`ตัวเลือกของ “${stripHtml(question.label)}” ไม่ถูกต้อง`);
}
return {
questionId: question.id,
value,
displayValue: value,
};
});
const result = await db.transaction(async (tx) => {
const existingSubmission = await tx.query.submissions.findFirst({
where: (submission, { eq, and }) =>
@@ -69,9 +166,9 @@ export async function submitForm(
submissionId = inserted.id;
}
if (answersList.length > 0) {
if (normalizedAnswers.length > 0) {
await tx.insert(answers).values(
answersList.map((answer) => ({
normalizedAnswers.map((answer) => ({
submissionId,
questionId: answer.questionId,
value: answer.value,
@@ -89,19 +186,15 @@ export async function submitForm(
if (form.discordWebhookUrl && templateToUse) {
try {
const questionsList = await db.query.questions.findMany({
where: (q, { eq }) => eq(q.formId, formId),
orderBy: (q, { asc }) => asc(q.displayOrder)
});
const questionMap = questionsList.reduce((acc, q, index) => {
const questionOrderMap = questionsList.reduce((acc, q, index) => {
acc[q.id] = String(index + 1);
return acc;
}, {} as Record<string, string>);
const answersDict = answersList.reduce((acc, a) => {
const idx = questionMap[a.questionId];
const answersDict = normalizedAnswers.reduce((acc, answer) => {
const idx = questionOrderMap[answer.questionId];
if (idx) {
acc[idx] = a.value;
acc[idx] = answer.displayValue;
}
return acc;
}, {} as Record<string, string>);
@@ -109,11 +202,17 @@ export async function submitForm(
let updatesText = "";
if (result.isUpdate) {
const changed: string[] = [];
for (const ans of answersList) {
const oldVal = result.oldAnswersDict[ans.questionId];
if (oldVal !== ans.value) {
const qTitle = questionsList.find(q => q.id === ans.questionId)?.label || `Question`;
changed.push(`**${qTitle}**: \`${oldVal || "Empty"}\` ➡️ \`${ans.value || "Empty"}\``);
for (const answer of normalizedAnswers) {
const question = questionsById.get(answer.questionId);
const oldValue = result.oldAnswersDict[answer.questionId];
const oldDisplayValue = question
? formatStoredAnswer(oldValue, question.type)
: oldValue;
if (oldDisplayValue !== answer.displayValue) {
const questionTitle = stripHtml(question?.label || "Question");
changed.push(
`**${questionTitle}**: \`${oldDisplayValue || "Empty"}\` ➡️ \`${answer.displayValue || "Empty"}\``
);
}
}
updatesText = changed.length > 0 ? changed.join("\n") : "No changes made.";
@@ -17,6 +17,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import Image from 'next/image';
import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
import { formatStoredAnswer } from "@/lib/form-answer-values";
interface Question {
id: string;
@@ -94,15 +95,16 @@ export default function SubmissionDetailClient({
{/* Answers List */}
<div className="space-y-4">
{questions.map((q) => {
const ansValue = answers[q.id] || "";
const rawAnswerValue = answers[q.id] || "";
const answerValue = formatStoredAnswer(rawAnswerValue, q.type);
const wasEdited = editHistory.some(
(edit) => (edit.oldAnswers[q.id] ?? "") !== ansValue
(edit) => (edit.oldAnswers[q.id] ?? "") !== rawAnswerValue
);
const qAnswerHistory = editHistory.map((edit) => ({
editedAt: edit.editedAt,
answer: edit.oldAnswers[q.id] ?? "",
answer: formatStoredAnswer(edit.oldAnswers[q.id], q.type),
}));
return (
@@ -135,7 +137,7 @@ export default function SubmissionDetailClient({
)}
</CardHeader>
<CardContent className="space-y-4">
{ansValue ? (
{answerValue ? (
<div className="flex items-start justify-between gap-4 p-3 bg-muted rounded-md group">
<div className="flex flex-col min-w-0">
{wasEdited && (
@@ -144,14 +146,14 @@ export default function SubmissionDetailClient({
</span>
)}
<span className="text-sm whitespace-pre-wrap break-all leading-relaxed">
{ansValue}
{answerValue}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleCopy(ansValue, q.id)}
onClick={() => handleCopy(answerValue, q.id)}
>
{copiedId === q.id ? (
<Check className="w-4 h-4 text-green-500" />
+25 -15
View File
@@ -6,6 +6,10 @@ import { notFound } from "next/navigation";
import LoginDialog from "@/components/login-dialog";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
import { RealtimeRefresh } from "@/components/realtime-refresh";
import {
decodeCheckboxAnswer,
type FormAnswerValue,
} from "@/lib/form-answer-values";
export const dynamic = "force-dynamic";
@@ -26,6 +30,10 @@ export default async function FormsSubmitPage(
return notFound();
}
if (!discordId) {
return <LoginDialog />;
}
if (!(await canAccessForm(form, discordId))) {
return notFound();
}
@@ -35,34 +43,36 @@ export default async function FormsSubmitPage(
orderBy: (q, { asc }) => [asc(q.displayOrder)],
});
const initialAnswers: Record<string, string> = {};
if (discordId) {
const existingSubmission = await db.query.submissions.findFirst({
where: (s, { eq, and }) =>
and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)),
});
const existingSubmission = await db.query.submissions.findFirst({
where: (s, { eq, and }) =>
and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)),
});
if (existingSubmission) {
const answersList = await db.query.answers.findMany({
where: (a, { eq }) => eq(a.submissionId, existingSubmission.id),
});
for (const a of answersList) {
initialAnswers[a.questionId] = a.value;
}
const initialAnswers: Record<string, FormAnswerValue> = {};
if (existingSubmission) {
const answersList = await db.query.answers.findMany({
where: (a, { eq }) => eq(a.submissionId, existingSubmission.id),
});
const questionTypes = new Map(questionsList.map((question) => [question.id, question.type]));
for (const answer of answersList) {
initialAnswers[answer.questionId] =
questionTypes.get(answer.questionId) === "checkbox"
? decodeCheckboxAnswer(answer.value)
: answer.value;
}
}
return (
<>
<RealtimeRefresh topic="forms" formId={form.id} />
<div className="max-w-2xl mx-auto p-4 md:p-6 space-y-6">
<div className="mx-auto max-w-2xl">
<FormFillerClient
form={form}
questions={questionsList}
initialAnswers={initialAnswers}
hasSubmission={Boolean(existingSubmission)}
/>
</div>
{!discordId && <LoginDialog />}
</>
);
}
+653 -305
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -64,7 +64,7 @@ export default async function FormLayout({
<ThemeToggle />
</div>
</header>
<main className="flex-1 p-6 md:p-8 overflow-y-auto max-w-6xl mx-auto w-full">
<main className="mx-auto w-full max-w-6xl flex-1 overflow-y-auto px-3 py-4 sm:p-6 md:p-8">
{children}
</main>
</div>
+11 -6
View File
@@ -13,19 +13,24 @@ import { Button } from "@/components/ui/button";
export default function LoginDialog() {
return (
<Dialog open>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogContent
showCloseButton={false}
onInteractOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle className="text-xl font-bold">ต้อง Login</DialogTitle>
<DialogDescription className="text-base mt-1">
คุณต้องล็อกอินเพื่อดำเนินการต่อ
<DialogTitle className="text-xl font-bold">
เข้าสู่ระบบก่อนกรอกแบบฟอร์ม
</DialogTitle>
<DialogDescription className="mt-1 text-base">
ระบบใช้บัญชี Discord เพื่อตรวจสอบสิทธิ์และเก็บคำตอบหนึ่งชุดต่อคน
</DialogDescription>
</DialogHeader>
<Button
className="w-full gap-2 mt-2"
className="mt-2 w-full"
onClick={() => signIn("discord", { callbackUrl: window.location.href })}
>
<svg
className="w-5 h-5"
data-icon="inline-start"
viewBox="0 0 127.14 96.36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
+84
View File
@@ -0,0 +1,84 @@
"use client"
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn(
"flex w-full flex-col overflow-hidden rounded-2xl border",
className
)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b data-open:bg-muted/50", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between gap-6 border border-transparent p-4 text-left text-sm font-medium transition-all outline-none hover:underline disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden px-4 text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-xl has-[>[data-slot=field]]:border *:data-[slot=field]:p-4 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+42
View File
@@ -0,0 +1,42 @@
# ADR 0001: Form response UX and answer representation
- Status: Accepted
- Date: 2026-07-24
## Context
The form flow supports one Discord-linked submission per person, local draft recovery,
response updates, deletion, role-based access, and four question types. The previous
interface blurred local drafts and server submissions, hid role-gated forms behind a
404 before login, front-loaded long descriptions, and represented checkbox answers as
comma-separated text.
## Decision
1. Authentication happens before authorization. Anonymous visitors to a valid direct
form link see the Discord login prompt; authenticated visitors without permission
continue to receive the not-found state.
2. Drafts remain browser-local and are explicitly described as saved on this device.
Draft storage contains both normal answers and free text entered for “Other”.
3. Long form descriptions remain available in full but start collapsed behind a
clearly labelled rules disclosure.
4. The UI distinguishes a new answer, an editable submitted answer, and a closed form.
When a form closes, an existing response is read-only but can still be deleted.
5. Question text programmatically labels its control. Radio and checkbox choices use
semantic fieldsets, and image enlargement uses a keyboard-accessible button.
6. Checkbox answers are stored as JSON arrays in the existing text column. Readers
retain a fallback for legacy comma-separated values, so no database migration is
required.
7. Server Actions independently validate authentication, form access, required
answers, question ownership, answer shape, and allowed choices.
## Consequences
- Existing checkbox answers remain readable, while new answers can safely contain
commas.
- Local drafts do not follow a person to another device; the interface says so.
- Users must authenticate before seeing form questions.
- Closing a form prevents new submissions and updates but does not prevent a person
from deleting their existing response.
- A future cross-device draft feature should introduce a server-side draft entity
rather than changing the meaning of the current local draft.
+36
View File
@@ -0,0 +1,36 @@
# Glossary
## Form
An administrator-authored questionnaire with an open or closed state and optional
Discord role restrictions.
## Question
An ordered prompt within a form. Its answer shape is text, long text, one choice, or
multiple choices.
## Local draft
Unsubmitted or in-progress answers stored in the current browser for one form. A local
draft is device-specific and is not authoritative.
## Submission
The server-saved answer set for one form and one Discord account. A person can have at
most one submission per form.
## Update
A replacement of the answer values in an existing submission. The previous values are
retained in edit history.
## Other answer
Free text associated with an explicit “Other” choice. Selecting “Other” requires
non-empty text, even when the overall question is optional.
## Closed form
A form that no longer accepts new submissions or updates. A person with an existing
submission may still read or delete it.
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import {
decodeCheckboxAnswer,
encodeAnswerValue,
formatStoredAnswer,
} from "@/lib/form-answer-values";
describe("form answer values", () => {
test("round-trips checkbox choices containing commas", () => {
const choices = ["Alpha, beta", "Gamma"];
const stored = encodeAnswerValue(choices);
expect(decodeCheckboxAnswer(stored)).toEqual(choices);
expect(formatStoredAnswer(stored, "checkbox")).toBe("Alpha, beta, Gamma");
});
test("continues to read legacy comma-separated checkbox answers", () => {
expect(decodeCheckboxAnswer("Alpha, Beta")).toEqual(["Alpha", "Beta"]);
});
test("does not decode JSON-looking text answers", () => {
expect(formatStoredAnswer('["plain text"]', "text")).toBe('["plain text"]');
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { QuestionType } from "@/db/schema/form";
export type FormAnswerValue = string | string[];
export function decodeCheckboxAnswer(value: FormAnswerValue | undefined): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (!value) {
return [];
}
try {
const parsed: unknown = JSON.parse(value);
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
return parsed;
}
} catch {
// Older checkbox answers were stored as comma-separated text.
}
return value.split(", ").filter(Boolean);
}
export function encodeAnswerValue(value: FormAnswerValue): string {
return Array.isArray(value) ? JSON.stringify(value) : value;
}
export function formatStoredAnswer(
value: string | undefined,
questionType: QuestionType | string
): string {
if (!value) {
return "";
}
return questionType === "checkbox"
? decodeCheckboxAnswer(value).join(", ")
: value;
}