diff --git a/src/plays/custom-form-engine/CustomFormEngine.jsx b/src/plays/custom-form-engine/CustomFormEngine.jsx new file mode 100644 index 000000000..91ac6613a --- /dev/null +++ b/src/plays/custom-form-engine/CustomFormEngine.jsx @@ -0,0 +1,281 @@ +import PlayHeader from 'common/playlists/PlayHeader'; +import { useMemo, useRef, useState } from 'react'; +import './styles.css'; + +const FORM_SCHEMA = [ + { + id: 'fullName', + label: 'Full Name', + type: 'text', + placeholder: 'Ada Lovelace', + validators: [ + { type: 'required', message: 'Name is required.' }, + { type: 'minLength', value: 3, message: 'Name must be at least 3 characters.' } + ] + }, + { + id: 'email', + label: 'Email', + type: 'email', + placeholder: 'ada@example.com', + validators: [ + { type: 'required', message: 'Email is required.' }, + { type: 'email', message: 'Enter a valid email address.' } + ] + }, + { + id: 'age', + label: 'Age', + type: 'number', + placeholder: '25', + validators: [ + { type: 'required', message: 'Age is required.' }, + { type: 'numberRange', min: 18, max: 120, message: 'Age must be between 18 and 120.' } + ] + }, + { + id: 'country', + label: 'Country', + type: 'select', + options: ['', 'India', 'United States', 'Germany', 'Japan'], + validators: [{ type: 'required', message: 'Please select a country.' }] + }, + { + id: 'subscribe', + label: 'Subscribe to updates', + type: 'checkbox', + validators: [] + } +]; + +const createInitialValues = (schema) => { + const result = {}; + schema.forEach((field) => { + if (field.type === 'checkbox') { + result[field.id] = false; + return; + } + result[field.id] = ''; + }); + return result; +}; + +const validators = { + required: (value, field) => { + if (field.type === 'checkbox') { + return value === true; + } + return String(value || '').trim().length > 0; + }, + minLength: (value, rule) => String(value || '').trim().length >= rule.value, + email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '').trim()), + numberRange: (value, rule) => { + const parsed = Number(value); + if (Number.isNaN(parsed)) return false; + return parsed >= rule.min && parsed <= rule.max; + } +}; + +function CustomFormEngine(props) { + const [schema, setSchema] = useState(FORM_SCHEMA); + const [values, setValues] = useState(() => createInitialValues(FORM_SCHEMA)); + const [errors, setErrors] = useState({}); + const [submittedData, setSubmittedData] = useState(null); + const inputRefs = useRef({}); + + const requiredCount = useMemo(() => { + return schema.filter((field) => field.validators.some((rule) => rule.type === 'required')).length; + }, [schema]); + + const getFieldError = (field, value, allValues) => { + const rules = field.validators || []; + for (let i = 0; i < rules.length; i += 1) { + const rule = rules[i]; + if (rule.type === 'required') { + const ok = validators.required(value, field, allValues); + if (!ok) return rule.message; + continue; + } + const validator = validators[rule.type]; + if (!validator) continue; + if (!validator(value, rule, allValues)) { + return rule.message; + } + } + return ''; + }; + + const validateField = (fieldId, allValues) => { + const field = schema.find((item) => item.id === fieldId); + if (!field) return ''; + return getFieldError(field, allValues[fieldId], allValues); + }; + + const validateForm = (allValues) => { + const nextErrors = {}; + schema.forEach((field) => { + const message = getFieldError(field, allValues[field.id], allValues); + if (message) { + nextErrors[field.id] = message; + } + }); + return nextErrors; + }; + + const handleChange = (field, event) => { + const nextValue = field.type === 'checkbox' ? event.target.checked : event.target.value; + setValues((prev) => { + const nextValues = { ...prev, [field.id]: nextValue }; + const message = validateField(field.id, nextValues); + setErrors((prevErrors) => ({ ...prevErrors, [field.id]: message })); + return nextValues; + }); + }; + + const addDynamicField = () => { + const fieldId = `skill_${schema.length + 1}`; + const newField = { + id: fieldId, + label: `Skill ${schema.length - 3}`, + type: 'text', + placeholder: 'React, TypeScript, Testing...', + validators: [ + { type: 'required', message: 'Skill is required.' }, + { type: 'minLength', value: 2, message: 'Skill should be at least 2 characters.' } + ] + }; + + setSchema((prev) => [...prev, newField]); + setValues((prev) => ({ ...prev, [fieldId]: '' })); + setErrors((prev) => ({ ...prev, [fieldId]: '' })); + }; + + const focusFirstInvalid = (nextErrors) => { + const firstInvalid = schema.find((field) => nextErrors[field.id]); + if (!firstInvalid) return; + const element = inputRefs.current[firstInvalid.id]; + if (element && typeof element.focus === 'function') { + element.focus(); + } + }; + + const handleSubmit = (event) => { + event.preventDefault(); + const nextErrors = validateForm(values); + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) { + focusFirstInvalid(nextErrors); + return; + } + setSubmittedData(values); + }; + + const renderField = (field) => { + if (field.type === 'select') { + return ( + + ); + } + + if (field.type === 'checkbox') { + return ( + { + inputRefs.current[field.id] = el; + }} + type="checkbox" + checked={values[field.id]} + onChange={(event) => handleChange(field, event)} + /> + ); + } + + return ( + { + inputRefs.current[field.id] = el; + }} + type={field.type} + placeholder={field.placeholder || ''} + value={values[field.id]} + onChange={(event) => handleChange(field, event)} + /> + ); + }; + + return ( +
+ +
+
+
+

Custom Form Engine

+

+ Schema-driven, dynamic fields, and a validation engine with focus management using + refs. +

+ +
+ +
+ {schema.map((field) => { + const error = errors[field.id]; + return ( +
+ + {renderField(field)} + {error ? {error} : null} +
+ ); + })} + + +
+ +
+

+ Required fields: {requiredCount} +

+

+ Total fields: {schema.length} +

+
+ + {submittedData ? ( +
+

Submitted Data

+
{JSON.stringify(submittedData, null, 2)}
+
+ ) : null} +
+
+
+ ); +} + +export default CustomFormEngine; diff --git a/src/plays/custom-form-engine/Readme.md b/src/plays/custom-form-engine/Readme.md new file mode 100644 index 000000000..68a4b13f7 --- /dev/null +++ b/src/plays/custom-form-engine/Readme.md @@ -0,0 +1,37 @@ +# custom-form-engine + +A hard-level play that builds a schema-based custom form engine from scratch. + +## Play Demographic + +- Language: JavaScript +- Level: Hard + +## What You Will Learn + +- How to build a dynamic form from a schema config. +- How to create a custom validation engine without form libraries. +- How to use `useRef` for focus management on invalid fields. +- How to add fields dynamically at runtime while preserving validation behavior. + +## Features + +- Schema-driven rendering (`text`, `email`, `number`, `select`, `checkbox`) +- Validation rules (`required`, `minLength`, `email`, `numberRange`) +- Real-time field validation and on-submit full validation +- Focus first invalid input with refs +- Dynamic field addition (`Skill` fields) + +## Implementation Details + +The main logic is in `CustomFormEngine.jsx` and is split into: + +- `FORM_SCHEMA`: Declarative field definitions. +- `validators`: Small rule functions that form a validation engine. +- `validateField` and `validateForm`: Rule executor helpers. +- `inputRefs`: Ref registry keyed by field id for error focus handling. + +## Resources + +- React docs on refs: https://react.dev/reference/react/useRef +- React docs on forms: https://react.dev/learn/sharing-state-between-components diff --git a/src/plays/custom-form-engine/styles.css b/src/plays/custom-form-engine/styles.css new file mode 100644 index 000000000..46c965853 --- /dev/null +++ b/src/plays/custom-form-engine/styles.css @@ -0,0 +1,101 @@ +.custom-form-engine { + max-width: 900px; + margin: 0 auto; + padding: 1rem; +} + +.custom-form-engine__intro { + background: #f6f9ff; + border: 1px solid #dbe5ff; + border-radius: 0.75rem; + padding: 1rem; + margin-bottom: 1rem; +} + +.custom-form-engine__intro h2 { + margin: 0 0 0.5rem; +} + +.custom-form-engine__intro p { + margin: 0 0 1rem; +} + +.add-field-btn, +.submit-btn { + border: none; + border-radius: 0.5rem; + padding: 0.65rem 1rem; + font-size: 0.95rem; + cursor: pointer; +} + +.add-field-btn { + background: #1f4dd8; + color: #fff; +} + +.submit-btn { + background: #0d9488; + color: #fff; + margin-top: 0.25rem; +} + +.custom-form-engine__form { + display: grid; + gap: 0.75rem; +} + +.form-row { + display: grid; + gap: 0.35rem; +} + +.form-row label { + font-weight: 600; +} + +.form-row input, +.form-row select { + border: 1px solid #cdd5e1; + border-radius: 0.5rem; + padding: 0.6rem 0.7rem; + font-size: 0.95rem; +} + +.form-row input[type='checkbox'] { + width: 20px; + height: 20px; +} + +.error-text { + color: #b91c1c; + font-size: 0.85rem; +} + +.custom-form-engine__meta { + margin-top: 1rem; + background: #f8fafc; + border-radius: 0.75rem; + padding: 0.85rem 1rem; +} + +.custom-form-engine__meta p { + margin: 0.25rem 0; +} + +.custom-form-engine__result { + margin-top: 1rem; + border: 1px solid #d1d5db; + border-radius: 0.75rem; + padding: 0.75rem; + background: #fff; +} + +.custom-form-engine__result pre { + margin: 0; + overflow-x: auto; + background: #0f172a; + color: #f8fafc; + border-radius: 0.5rem; + padding: 0.75rem; +}