From b9653555a70dffde9a2bac743145789e8ed002f6 Mon Sep 17 00:00:00 2001 From: KumarManglam-123 Date: Thu, 29 Jan 2026 15:36:56 +0530 Subject: [PATCH 1/4] feat: add auto-save draft support for form editor --- .gitignore | 3 + components/FormEditor.tsx | 389 ++++++++++++-------------------------- hooks/useAutoSaveDraft.ts | 38 ++++ package-lock.json | 10 +- package.json | 4 +- 5 files changed, 170 insertions(+), 274 deletions(-) create mode 100644 hooks/useAutoSaveDraft.ts diff --git a/.gitignore b/.gitignore index 24111c6..a957732 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts + +# clerk configuration (can include secrets) +/.clerk/ diff --git a/components/FormEditor.tsx b/components/FormEditor.tsx index 7962eb4..46963e4 100644 --- a/components/FormEditor.tsx +++ b/components/FormEditor.tsx @@ -1,13 +1,27 @@ "use client"; + import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Trash2, Plus, Save, ArrowUp, ArrowDown } from "lucide-react"; import toast from "react-hot-toast"; +import { useAutoSaveDraft } from "@/hooks/useAutoSaveDraft"; + type FieldType = { id: string; label: string; @@ -44,18 +58,38 @@ const fieldTypes = [ const FormEditor: React.FC = ({ form, onSave }) => { const [formData, setFormData] = useState({ formTitle: "", - formFields: [] + formFields: [], }); + const draftKey = `genform-draft-${form?.id || "new"}`; + + const { status, loadDraft, clearDraft } = useAutoSaveDraft( + draftKey, + formData + ); + useEffect(() => { + const savedDraft = loadDraft(); + + if (savedDraft) { + setFormData(savedDraft); + toast.success("Draft restored"); + return; + } + if (form) { - const parsedContent = typeof form.content === 'string' ? JSON.parse(form.content) : form.content; + const parsedContent = + typeof form.content === "string" + ? JSON.parse(form.content) + : form.content; + setFormData({ formTitle: parsedContent.formTitle || "", - formFields: parsedContent.formFields?.map((field: any, index: number) => ({ - id: `field-${index}`, - ...field - })) || [] + formFields: + parsedContent.formFields?.map((field: any, index: number) => ({ + id: `field-${index}`, + ...field, + })) || [], }); } }, [form]); @@ -67,45 +101,45 @@ const FormEditor: React.FC = ({ form, onSave }) => { name: `field_${formData.formFields.length + 1}`, type: "text", placeholder: "Enter value", - required: false + required: false, }; - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, - formFields: [...prev.formFields, newField] + formFields: [...prev.formFields, newField], })); }; const updateField = (fieldId: string, updates: Partial) => { - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, - formFields: prev.formFields.map(field => + formFields: prev.formFields.map((field) => field.id === fieldId ? { ...field, ...updates } : field - ) + ), })); }; const deleteField = (fieldId: string) => { - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, - formFields: prev.formFields.filter(field => field.id !== fieldId) + formFields: prev.formFields.filter((field) => field.id !== fieldId), })); }; - const moveField = (fieldId: string, direction: 'up' | 'down') => { - const currentIndex = formData.formFields.findIndex(field => field.id === fieldId); - if (currentIndex === -1) return; + const moveField = (fieldId: string, direction: "up" | "down") => { + const index = formData.formFields.findIndex((f) => f.id === fieldId); + if (index === -1) return; - const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + const newIndex = direction === "up" ? index - 1 : index + 1; if (newIndex < 0 || newIndex >= formData.formFields.length) return; - const items = Array.from(formData.formFields); - const [movedItem] = items.splice(currentIndex, 1); - items.splice(newIndex, 0, movedItem); + const items = [...formData.formFields]; + const [moved] = items.splice(index, 1); + items.splice(newIndex, 0, moved); - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, - formFields: items + formFields: items, })); }; @@ -120,272 +154,93 @@ const FormEditor: React.FC = ({ form, onSave }) => { return; } - // Validate fields - for (const field of formData.formFields) { - if (!field.label.trim() || !field.name.trim()) { - toast.error("All fields must have a label and name"); - return; - } - } - onSave(formData); + clearDraft(); + toast.success("Form saved successfully"); }; - const addOption = (fieldId: string) => { - updateField(fieldId, { - options: [ - ...(formData.formFields.find(f => f.id === fieldId)?.options || []), - "New Option" - ] - }); - }; - - const updateOption = (fieldId: string, optionIndex: number, value: string) => { - const field = formData.formFields.find(f => f.id === fieldId); - if (field?.options) { - const newOptions = [...field.options]; - newOptions[optionIndex] = value; - updateField(fieldId, { options: newOptions }); - } - }; - - const deleteOption = (fieldId: string, optionIndex: number) => { - const field = formData.formFields.find(f => f.id === fieldId); - if (field?.options) { - const newOptions = field.options.filter((_, index) => index !== optionIndex); - updateField(fieldId, { options: newOptions }); - } - }; - - const needsOptions = (type: string) => ["select", "radio", "checkbox"].includes(type); - const needsPlaceholder = (type: string) => !["select", "radio", "checkbox", "date", "time", "datetime-local"].includes(type); - return (
- {/* Form Title */} Form Settings + -
-
- - setFormData(prev => ({ ...prev, formTitle: e.target.value }))} - placeholder="Enter form title" - className="mt-1" - /> -
-
+ + + setFormData((prev) => ({ + ...prev, + formTitle: e.target.value, + })) + } + placeholder="Enter form title" + />
- {/* Fields Editor */} - - Form Fields ({formData.formFields.length}) + + Form Fields - -
- {formData.formFields.map((field, index) => ( - - -
-
- - -
- -
-
-
- - updateField(field.id, { label: e.target.value })} - placeholder="Field label" - /> -
-
- - updateField(field.id, { name: e.target.value })} - placeholder="field_name" - /> -
-
- - -
-
- -
- {needsPlaceholder(field.type) && ( -
- - updateField(field.id, { placeholder: e.target.value })} - placeholder="Enter placeholder text" - /> -
- )} -
- updateField(field.id, { required: e.target.checked })} - className="rounded" - /> - -
-
- - {/* Options for select, radio, checkbox */} - {needsOptions(field.type) && ( -
-
- - -
-
- {field.options?.map((option, optionIndex) => ( -
- updateOption(field.id, optionIndex, e.target.value)} - placeholder={`Option ${optionIndex + 1}`} - /> - -
- )) || []} -
-
- )} -
- - -
-
-
- ))} - - {formData.formFields.length === 0 && ( -
- No fields added yet. Click "Add Field" to get started. -
- )} -
-
-
- {/* Preview Section */} - - - Form Preview - - -
-

- {formData.formTitle || "Untitled Form"} -

- {formData.formFields.length > 0 ? ( -
- {formData.formFields.map((field) => ( -
- - {field.type === "textarea" ? ( -
- {field.placeholder || "Textarea field"} -
- ) : field.type === "select" ? ( -
- Select {field.options?.[0] || "option"}... -
- ) : ( -
- {field.placeholder || `${field.type} field`} -
- )} -
- ))} -
- ) : ( -

No fields added yet

- )} -
+ + {formData.formFields.map((field, index) => ( + + +
+ + updateField(field.id, { label: e.target.value }) + } + /> + + + + + + +
+
+
+ ))}
- {/* Save Button */} -
+
- {formData.formFields.length} field{formData.formFields.length !== 1 ? 's' : ''} in this form + {status === "saving" && "Saving draft..."} + {status === "saved" && "Draft saved ✓"}
- @@ -394,4 +249,4 @@ const FormEditor: React.FC = ({ form, onSave }) => { ); }; -export default FormEditor; \ No newline at end of file +export default FormEditor; diff --git a/hooks/useAutoSaveDraft.ts b/hooks/useAutoSaveDraft.ts new file mode 100644 index 0000000..edd08fb --- /dev/null +++ b/hooks/useAutoSaveDraft.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type StatusType = "idle" | "saving" | "saved"; + +export function useAutoSaveDraft(key: string, data: T) { + const [status, setStatus] = useState("idle"); + + useEffect(() => { + if (!data) return; + + setStatus("saving"); + + const timeout = setTimeout(() => { + localStorage.setItem(key, JSON.stringify(data)); + setStatus("saved"); + }, 800); + + return () => clearTimeout(timeout); + }, [data, key]); + + const loadDraft = (): T | null => { + const saved = localStorage.getItem(key); + return saved ? JSON.parse(saved) : null; + }; + + const clearDraft = () => { + localStorage.removeItem(key); + setStatus("idle"); + }; + + return { + status, + loadDraft, + clearDraft, + }; +} diff --git a/package-lock.json b/package-lock.json index 7f093d5..01de5db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,8 +51,8 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "@types/stripe": "^8.0.417", "eslint": "^9.18.0", "eslint-config-next": "^15.1.4", @@ -2413,9 +2413,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "devOptional": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 23880de..4eebc24 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "@types/stripe": "^8.0.417", "eslint": "^9.18.0", "eslint-config-next": "^15.1.4", From 041040b0c2ff2f845e5abc820b69efc7c909a00a Mon Sep 17 00:00:00 2001 From: Aman Singh <151001715+Amansingh0807@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:09:01 +0530 Subject: [PATCH 2/4] Update .gitignore to remove clerk configuration Remove clerk configuration from .gitignore --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index a957732..24111c6 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,3 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts - -# clerk configuration (can include secrets) -/.clerk/ From 9a8bc65cc9a10daca73e4e80bbdef2af6d53f2ed Mon Sep 17 00:00:00 2001 From: KumarManglam-123 Date: Fri, 6 Feb 2026 11:51:06 +0530 Subject: [PATCH 3/4] chore: revert unintended package file changes --- package-lock.json | 10 +++++----- package.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 01de5db..7f093d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,8 +51,8 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@types/node": "^20", - "@types/react": "^19.2.10", - "@types/react-dom": "^19.2.3", + "@types/react": "^19", + "@types/react-dom": "^19", "@types/stripe": "^8.0.417", "eslint": "^9.18.0", "eslint-config-next": "^15.1.4", @@ -2413,9 +2413,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", - "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4eebc24..23880de 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@types/node": "^20", - "@types/react": "^19.2.10", - "@types/react-dom": "^19.2.3", + "@types/react": "^19", + "@types/react-dom": "^19", "@types/stripe": "^8.0.417", "eslint": "^9.18.0", "eslint-config-next": "^15.1.4", From eba3fbbf86f2cfacb0b16655ed09315254eb5062 Mon Sep 17 00:00:00 2001 From: KumarManglam-123 Date: Sun, 1 Mar 2026 19:05:15 +0530 Subject: [PATCH 4/4] fix: restore field types, options UI and live preview with auto-save --- components/FormEditor.tsx | 137 +++++++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/components/FormEditor.tsx b/components/FormEditor.tsx index 46963e4..5f5b67b 100644 --- a/components/FormEditor.tsx +++ b/components/FormEditor.tsx @@ -92,7 +92,7 @@ const FormEditor: React.FC = ({ form, onSave }) => { })) || [], }); } - }, [form]); + }, [form, loadDraft]); const addField = () => { const newField: FieldType = { @@ -194,12 +194,14 @@ const FormEditor: React.FC = ({ form, onSave }) => { {formData.formFields.map((field, index) => ( + {/* label + controls row */}
updateField(field.id, { label: e.target.value }) } + placeholder="Field Label" />
+ + {/* type selector */} +
+ + +
+ + {/* placeholder input */} +
+ + + updateField(field.id, { placeholder: e.target.value }) + } + placeholder="Placeholder" + /> +
+ + {/* required toggle */} +
+ + updateField(field.id, { required: e.target.checked }) + } + /> + +
+ + {/* options for select/radio/checkbox */} + {["select", "radio", "checkbox"].includes(field.type) && ( +
+ + {(field.options || []).map((option, idx) => ( + { + const newOptions = [...(field.options || [])]; + newOptions[idx] = e.target.value; + updateField(field.id, { options: newOptions }); + }} + /> + ))} + +
+ )}
))} + {/* live preview card */} + + + Live Preview + + + {formData.formFields.map((field) => ( +
+ + + {field.type === "textarea" && ( +