From 1d839877d5c383c7edaae874b5e4169e3a8df736 Mon Sep 17 00:00:00 2001 From: Capelo Date: Sat, 5 Jul 2025 11:12:27 +0100 Subject: [PATCH 01/35] feat: add form field validation to surveys using Zod schemas --- app/survey/[id]/page.tsx | 41 ++++++++++++++---- components/survey/question-display.tsx | 8 ++-- components/survey/validation.ts | 58 ++++++++++++++++++++++++++ notes.md | 16 +++++++ package-lock.json | 9 ++-- package.json | 2 +- 6 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 components/survey/validation.ts create mode 100644 notes.md diff --git a/app/survey/[id]/page.tsx b/app/survey/[id]/page.tsx index 5fd26a4..ccb2f8c 100644 --- a/app/survey/[id]/page.tsx +++ b/app/survey/[id]/page.tsx @@ -10,12 +10,19 @@ import { Share2 } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import z from "zod"; +import { createSurveyValidationSchema } from "@/components/survey/validation"; + export default function SurveyPreview() { const params = useParams(); const [survey, setSurvey] = useState(); const [answers, setAnswers] = useState>({}); const router = useRouter(); + const [errors, setErrors] = useState + > | null>(null); + useEffect(() => { if (params.id) { @@ -35,16 +42,17 @@ export default function SurveyPreview() { }; const handleSubmit = () => { - // Validate required questions - const unansweredRequired = survey.questions - .filter(q => q.required) - .filter(q => !answers[q.id]); + const surveySchema = createSurveyValidationSchema(survey.questions); + const result = surveySchema.safeParse(answers); + console.log(result.error, result.error?.formErrors, result.error?.format()) - if (unansweredRequired.length > 0) { - toast.error("Please answer all required questions"); + if (!result.success) { + setErrors(result.error.format()); + toast.error("Please answer all required questions correctly."); return; } + // Format answers for submission const formattedAnswers = Object.entries(answers).map(([questionId, value]) => ({ questionId, @@ -64,6 +72,22 @@ export default function SurveyPreview() { router.push("/"); }; + const handleAnswerChange = (questionId: string, value: any) => { + setAnswers((prev) => ({ ...prev, [questionId]: value })); + // Clear errors for the field when it's changed for a better UX + if (errors?.[questionId]) { + setErrors((prevErrors) => { + if (!prevErrors) return null; + const newErrors = { ...prevErrors }; + delete (newErrors as any)[questionId]; + return newErrors; + }); + } + }; + + console.log('errors', errors?.['8954f9b4-87c3-4013-91b8-13d148ae9e84']) + console.log('questions', survey.questions) + return (
@@ -84,9 +108,8 @@ export default function SurveyPreview() { key={question.id} question={question} value={answers[question.id]} - onChange={(value) => - setAnswers({ ...answers, [question.id]: value }) - } + error={errors?.[question.id]?._errors[0]} + onChange={(value) => handleAnswerChange(question.id, value)} /> ))}
diff --git a/components/survey/question-display.tsx b/components/survey/question-display.tsx index 2d198f5..1ba35d8 100644 --- a/components/survey/question-display.tsx +++ b/components/survey/question-display.tsx @@ -16,11 +16,11 @@ interface QuestionDisplayProps { question: Question; value: any; onChange: (value: any) => void; + error?: string; } -export function QuestionDisplay({ question, value, onChange }: QuestionDisplayProps) { - const isAnswered = value !== undefined && value !== "" && (!Array.isArray(value) || value.length > 0); - const showError = question.required && !isAnswered; +export function QuestionDisplay({ question, value, onChange, error }: QuestionDisplayProps) { + const showError = !!error; return ( @@ -86,7 +86,7 @@ export function QuestionDisplay({ question, value, onChange }: QuestionDisplayPr )} {showError && ( -

This question is required

+

{error}

)}
); diff --git a/components/survey/validation.ts b/components/survey/validation.ts new file mode 100644 index 0000000..b40a0a1 --- /dev/null +++ b/components/survey/validation.ts @@ -0,0 +1,58 @@ +import { Question } from "@/types/survey"; +import { z } from "zod"; + +export function createSurveyValidationSchema(questions: Question[]) { + const shape = questions.reduce((acc, question) => { + let schema: any; + + switch (question.type) { + case "text": + case "radio": + case "singleselect": + schema = z.string() + if (question.required) { + schema = schema.min(1, { message: "This field is required." }); + } else { + // Allow empty string for optional text inputs + schema = schema.optional().or(z.literal("")); + } + break; + + case "checkbox": + case "multiselect": + schema = z.array(z.string()); + if (question.required) { + schema = schema.nonempty({ + message: "Please select at least one option.", + }); + } + break; + + case "date": + if (question.required) { + schema = z.coerce.date({ + errorMap: () => ({ message: "Please select a valid date." }), + }); + } else { + // Allow null/undefined for optional dates + schema = z.coerce.date().nullable().optional(); + } + break; + + case "rating": + schema = z.number().int().min(1, "A rating is required."); + if (!question.required) { + schema = schema.optional(); + } + break; + + default: + schema = z.any().optional(); + } + + acc[question.id] = schema; + return acc; + }, {} as Record); + + return z.object(shape); +} \ No newline at end of file diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..7f1f3e6 --- /dev/null +++ b/notes.md @@ -0,0 +1,16 @@ +# Development notes + +This file is intended to collect some thoughts and ideas that I have during the implementation of the feature. It will serve as a sanity check for some things I want to do — think of it like a dev journal. + + +## UI/UX improvements + +- [ ] Progress bar when viewing survey + +## Missing UI states or features +- [x] Survey form field validation +- [ ] Home page - no state for empty surveys list + +## Possible improvements to base logic + +- [ ] lib/survey always reads from LS - we could cache this value for read purposes (P2) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cd47866..630d3c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "tailwindcss-animate": "^1.0.7", "typescript": "5.2.2", "vaul": "^0.9.9", - "zod": "^3.23.8" + "zod": "^3.25.74" } }, "node_modules/@alloc/quick-lru": { @@ -7108,9 +7108,10 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.25.74", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.74.tgz", + "integrity": "sha512-J8poo92VuhKjNknViHRAIuuN6li/EwFbAC8OedzI8uxpEPGiXHGQu9wemIAioIpqgfB4SySaJhdk0mH5Y4ICBg==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index a0806d1..e67f8ca 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,6 @@ "tailwindcss-animate": "^1.0.7", "typescript": "5.2.2", "vaul": "^0.9.9", - "zod": "^3.23.8" + "zod": "^3.25.74" } } From 51391bb151278ef9795fad5195fd776d94863012 Mon Sep 17 00:00:00 2001 From: Capelo Date: Sat, 5 Jul 2025 11:16:38 +0100 Subject: [PATCH 02/35] feat: improve field accessibility by adding "Optional" indicator and removing asterisk --- components/survey/question-display.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/survey/question-display.tsx b/components/survey/question-display.tsx index 1ba35d8..9baec27 100644 --- a/components/survey/question-display.tsx +++ b/components/survey/question-display.tsx @@ -26,7 +26,7 @@ export function QuestionDisplay({ question, value, onChange, error }: QuestionDi {question.type === "text" && ( From d32422dd5457b6b5415b0e2acd9ed1df4f9ddd6c Mon Sep 17 00:00:00 2001 From: Capelo Date: Sat, 5 Jul 2025 11:17:08 +0100 Subject: [PATCH 03/35] chore: update notes --- notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/notes.md b/notes.md index 7f1f3e6..465dca9 100644 --- a/notes.md +++ b/notes.md @@ -9,6 +9,7 @@ This file is intended to collect some thoughts and ideas that I have during the ## Missing UI states or features - [x] Survey form field validation +- [x] Add optional indicator instead of asterisk - [ ] Home page - no state for empty surveys list ## Possible improvements to base logic From ac54694ae7519dc40b393a9521fbf3b7311e416b Mon Sep 17 00:00:00 2001 From: Capelo Date: Sat, 5 Jul 2025 11:17:44 +0100 Subject: [PATCH 04/35] chore: update notes --- notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/notes.md b/notes.md index 465dca9..97754e8 100644 --- a/notes.md +++ b/notes.md @@ -9,6 +9,7 @@ This file is intended to collect some thoughts and ideas that I have during the ## Missing UI states or features - [x] Survey form field validation +- [ ] Survey creation field validation - [x] Add optional indicator instead of asterisk - [ ] Home page - no state for empty surveys list From 7c95ef5ac4ab6e9c1f63d2c0e9ffca9a612aa62b Mon Sep 17 00:00:00 2001 From: Capelo Date: Sat, 5 Jul 2025 11:19:34 +0100 Subject: [PATCH 05/35] chore: remove logs --- app/survey/[id]/page.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/survey/[id]/page.tsx b/app/survey/[id]/page.tsx index ccb2f8c..57813b1 100644 --- a/app/survey/[id]/page.tsx +++ b/app/survey/[id]/page.tsx @@ -85,9 +85,6 @@ export default function SurveyPreview() { } }; - console.log('errors', errors?.['8954f9b4-87c3-4013-91b8-13d148ae9e84']) - console.log('questions', survey.questions) - return (
From 87ac276e4845fcc578041780213c062d72bccfa1 Mon Sep 17 00:00:00 2001 From: Capelo Date: Sat, 5 Jul 2025 11:20:57 +0100 Subject: [PATCH 06/35] chore: remove logs --- app/survey/[id]/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/survey/[id]/page.tsx b/app/survey/[id]/page.tsx index 57813b1..e6867c5 100644 --- a/app/survey/[id]/page.tsx +++ b/app/survey/[id]/page.tsx @@ -44,7 +44,6 @@ export default function SurveyPreview() { const handleSubmit = () => { const surveySchema = createSurveyValidationSchema(survey.questions); const result = surveySchema.safeParse(answers); - console.log(result.error, result.error?.formErrors, result.error?.format()) if (!result.success) { setErrors(result.error.format()); From ede949064c78329955fb13f621aa54e4c8b5f048 Mon Sep 17 00:00:00 2001 From: Capelo Date: Sat, 5 Jul 2025 11:52:17 +0100 Subject: [PATCH 07/35] chore: create reusable hook for zod validation --- app/survey/[id]/page.tsx | 29 ++++++++-------- hooks/use-zod-form-validation.ts | 57 ++++++++++++++++++++++++++++++++ notes.md | 2 ++ 3 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 hooks/use-zod-form-validation.ts diff --git a/app/survey/[id]/page.tsx b/app/survey/[id]/page.tsx index e6867c5..f5703e4 100644 --- a/app/survey/[id]/page.tsx +++ b/app/survey/[id]/page.tsx @@ -12,6 +12,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import z from "zod"; import { createSurveyValidationSchema } from "@/components/survey/validation"; +import { useZodFormValidation } from "@/hooks/use-zod-form-validation"; export default function SurveyPreview() { @@ -19,15 +20,18 @@ export default function SurveyPreview() { const [survey, setSurvey] = useState(); const [answers, setAnswers] = useState>({}); const router = useRouter(); - const [errors, setErrors] = useState - > | null>(null); - + const [surveySchema, setSurveySchema] = useState(); + const { errors, resetError, validate } = useZodFormValidation(surveySchema) useEffect(() => { if (params.id) { const loadedSurvey = getSurveyById(params.id as string); setSurvey(loadedSurvey); + + if (loadedSurvey) { + const surveySchema = createSurveyValidationSchema(loadedSurvey.questions); + setSurveySchema(surveySchema); + } } }, [params.id]); @@ -35,6 +39,8 @@ export default function SurveyPreview() { return
Survey not found
; } + + const handleShare = () => { const link = generateShareableLink(survey!.id); navigator.clipboard.writeText(link); @@ -42,11 +48,9 @@ export default function SurveyPreview() { }; const handleSubmit = () => { - const surveySchema = createSurveyValidationSchema(survey.questions); - const result = surveySchema.safeParse(answers); + const isValid = validate(answers); - if (!result.success) { - setErrors(result.error.format()); + if (!isValid) { toast.error("Please answer all required questions correctly."); return; } @@ -74,14 +78,7 @@ export default function SurveyPreview() { const handleAnswerChange = (questionId: string, value: any) => { setAnswers((prev) => ({ ...prev, [questionId]: value })); // Clear errors for the field when it's changed for a better UX - if (errors?.[questionId]) { - setErrors((prevErrors) => { - if (!prevErrors) return null; - const newErrors = { ...prevErrors }; - delete (newErrors as any)[questionId]; - return newErrors; - }); - } + resetError(questionId); }; return ( diff --git a/hooks/use-zod-form-validation.ts b/hooks/use-zod-form-validation.ts new file mode 100644 index 0000000..7bbdbaa --- /dev/null +++ b/hooks/use-zod-form-validation.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; +import z from "zod"; + +type FormErrors = z.ZodFormattedError< + Record +> | null + +/** + * Reusable logic for dealing with form validations. A schema is provided and the hook centralizes the error states, resetting errors, and imperative validation + */ +export function useZodFormValidation(currSchema?: z.ZodSchema): { + errors: FormErrors; + resetError: (field: string) => void; + validate: (values: unknown) => boolean; +} { + const [schema, setSchema] = useState(currSchema); + const [errors, setErrors] = useState(null); + + useEffect(() => { + setSchema(currSchema); + }, [currSchema]); + + + const validate = (values: unknown) => { + if (!schema) { + return true + } + + const result = schema.safeParse(values); + + if (!result.success) { + const newErrors = result.error.format() as FormErrors; + setErrors(newErrors); + return false; + } + + setErrors(null) + return true + } + + const resetError = (field: string) => { + if (errors) { + const newErrors = { ...errors }; + delete newErrors[field]; + setErrors(newErrors); + } + } + + + return { + errors, + validate, + resetError, + } +} \ No newline at end of file diff --git a/notes.md b/notes.md index 97754e8..f2a128f 100644 --- a/notes.md +++ b/notes.md @@ -6,10 +6,12 @@ This file is intended to collect some thoughts and ideas that I have during the ## UI/UX improvements - [ ] Progress bar when viewing survey +- [ ] Share functionality UX ## Missing UI states or features - [x] Survey form field validation - [ ] Survey creation field validation +- [x] Move zod validation to hook - [x] Add optional indicator instead of asterisk - [ ] Home page - no state for empty surveys list From 8ae6caec1f06f8707c71708340f279b446bafe98 Mon Sep 17 00:00:00 2001 From: Capelo Date: Sat, 5 Jul 2025 11:52:58 +0100 Subject: [PATCH 08/35] chore: adjust doc --- hooks/use-zod-form-validation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/use-zod-form-validation.ts b/hooks/use-zod-form-validation.ts index 7bbdbaa..85b7420 100644 --- a/hooks/use-zod-form-validation.ts +++ b/hooks/use-zod-form-validation.ts @@ -8,7 +8,7 @@ type FormErrors = z.ZodFormattedError< > | null /** - * Reusable logic for dealing with form validations. A schema is provided and the hook centralizes the error states, resetting errors, and imperative validation + * Reusable logic for dealing with form validations. A schema is provided and the hook centralizes the error state management, resetting errors, and imperative validation */ export function useZodFormValidation(currSchema?: z.ZodSchema): { errors: FormErrors; From 45b742fd8adcfc6f95313b2a944729101eac56cb Mon Sep 17 00:00:00 2001 From: Capelo Date: Sat, 5 Jul 2025 12:40:58 +0100 Subject: [PATCH 09/35] feat: add validation to survey creation --- app/create/page.tsx | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/app/create/page.tsx b/app/create/page.tsx index 172c331..6ce4fa5 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -4,10 +4,18 @@ import { QuestionBuilder } from "@/components/survey/question-builder"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { useZodFormValidation } from "@/hooks/use-zod-form-validation"; import { saveSurvey } from "@/lib/survey"; import { Question, Survey } from "@/types/survey"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import z from 'zod' + +const surveySchema = z.object({ + title: z.string().min(1, { message: 'Title is required' }), + description: z.string(), + questions: z.array(z.any()).min(1, { message: 'At least one question is required' }) +}) export default function CreateSurvey() { const router = useRouter(); @@ -18,8 +26,10 @@ export default function CreateSurvey() { questions: [], createdAt: new Date().toISOString(), }); + const { errors, validate, resetError } = useZodFormValidation(surveySchema) const addQuestion = () => { + resetError('questions'); const newQuestion: Question = { id: crypto.randomUUID(), type: "text", @@ -50,6 +60,10 @@ export default function CreateSurvey() { }; const handleSave = () => { + const isValid = validate(survey) + if (!isValid) { + return; + } saveSurvey(survey); router.push("/"); }; @@ -60,11 +74,20 @@ export default function CreateSurvey() {

Create New Test

- setSurvey({ ...survey, title: e.target.value })} - /> +
+ { + setSurvey({ ...survey, title: e.target.value }); + resetError('title'); + }} + /> + {errors?.title &&

{errors.title._errors}

} +
+
+ +