Skip to content

Commit 37945e8

Browse files
committed
=feat(play):add-custom-form-engine-hard-level-play
1 parent 538d3b9 commit 37945e8

3 files changed

Lines changed: 419 additions & 0 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import PlayHeader from 'common/playlists/PlayHeader';
2+
import { useMemo, useRef, useState } from 'react';
3+
import './styles.css';
4+
5+
const FORM_SCHEMA = [
6+
{
7+
id: 'fullName',
8+
label: 'Full Name',
9+
type: 'text',
10+
placeholder: 'Ada Lovelace',
11+
validators: [
12+
{ type: 'required', message: 'Name is required.' },
13+
{ type: 'minLength', value: 3, message: 'Name must be at least 3 characters.' }
14+
]
15+
},
16+
{
17+
id: 'email',
18+
label: 'Email',
19+
type: 'email',
20+
placeholder: 'ada@example.com',
21+
validators: [
22+
{ type: 'required', message: 'Email is required.' },
23+
{ type: 'email', message: 'Enter a valid email address.' }
24+
]
25+
},
26+
{
27+
id: 'age',
28+
label: 'Age',
29+
type: 'number',
30+
placeholder: '25',
31+
validators: [
32+
{ type: 'required', message: 'Age is required.' },
33+
{ type: 'numberRange', min: 18, max: 120, message: 'Age must be between 18 and 120.' }
34+
]
35+
},
36+
{
37+
id: 'country',
38+
label: 'Country',
39+
type: 'select',
40+
options: ['', 'India', 'United States', 'Germany', 'Japan'],
41+
validators: [{ type: 'required', message: 'Please select a country.' }]
42+
},
43+
{
44+
id: 'subscribe',
45+
label: 'Subscribe to updates',
46+
type: 'checkbox',
47+
validators: []
48+
}
49+
];
50+
51+
const createInitialValues = (schema) => {
52+
const result = {};
53+
schema.forEach((field) => {
54+
if (field.type === 'checkbox') {
55+
result[field.id] = false;
56+
return;
57+
}
58+
result[field.id] = '';
59+
});
60+
return result;
61+
};
62+
63+
const validators = {
64+
required: (value, field) => {
65+
if (field.type === 'checkbox') {
66+
return value === true;
67+
}
68+
return String(value || '').trim().length > 0;
69+
},
70+
minLength: (value, rule) => String(value || '').trim().length >= rule.value,
71+
email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '').trim()),
72+
numberRange: (value, rule) => {
73+
const parsed = Number(value);
74+
if (Number.isNaN(parsed)) return false;
75+
return parsed >= rule.min && parsed <= rule.max;
76+
}
77+
};
78+
79+
function CustomFormEngine(props) {
80+
const [schema, setSchema] = useState(FORM_SCHEMA);
81+
const [values, setValues] = useState(() => createInitialValues(FORM_SCHEMA));
82+
const [errors, setErrors] = useState({});
83+
const [submittedData, setSubmittedData] = useState(null);
84+
const inputRefs = useRef({});
85+
86+
const requiredCount = useMemo(() => {
87+
return schema.filter((field) => field.validators.some((rule) => rule.type === 'required')).length;
88+
}, [schema]);
89+
90+
const getFieldError = (field, value, allValues) => {
91+
const rules = field.validators || [];
92+
for (let i = 0; i < rules.length; i += 1) {
93+
const rule = rules[i];
94+
if (rule.type === 'required') {
95+
const ok = validators.required(value, field, allValues);
96+
if (!ok) return rule.message;
97+
continue;
98+
}
99+
const validator = validators[rule.type];
100+
if (!validator) continue;
101+
if (!validator(value, rule, allValues)) {
102+
return rule.message;
103+
}
104+
}
105+
return '';
106+
};
107+
108+
const validateField = (fieldId, allValues) => {
109+
const field = schema.find((item) => item.id === fieldId);
110+
if (!field) return '';
111+
return getFieldError(field, allValues[fieldId], allValues);
112+
};
113+
114+
const validateForm = (allValues) => {
115+
const nextErrors = {};
116+
schema.forEach((field) => {
117+
const message = getFieldError(field, allValues[field.id], allValues);
118+
if (message) {
119+
nextErrors[field.id] = message;
120+
}
121+
});
122+
return nextErrors;
123+
};
124+
125+
const handleChange = (field, event) => {
126+
const nextValue = field.type === 'checkbox' ? event.target.checked : event.target.value;
127+
setValues((prev) => {
128+
const nextValues = { ...prev, [field.id]: nextValue };
129+
const message = validateField(field.id, nextValues);
130+
setErrors((prevErrors) => ({ ...prevErrors, [field.id]: message }));
131+
return nextValues;
132+
});
133+
};
134+
135+
const addDynamicField = () => {
136+
const fieldId = `skill_${schema.length + 1}`;
137+
const newField = {
138+
id: fieldId,
139+
label: `Skill ${schema.length - 3}`,
140+
type: 'text',
141+
placeholder: 'React, TypeScript, Testing...',
142+
validators: [
143+
{ type: 'required', message: 'Skill is required.' },
144+
{ type: 'minLength', value: 2, message: 'Skill should be at least 2 characters.' }
145+
]
146+
};
147+
148+
setSchema((prev) => [...prev, newField]);
149+
setValues((prev) => ({ ...prev, [fieldId]: '' }));
150+
setErrors((prev) => ({ ...prev, [fieldId]: '' }));
151+
};
152+
153+
const focusFirstInvalid = (nextErrors) => {
154+
const firstInvalid = schema.find((field) => nextErrors[field.id]);
155+
if (!firstInvalid) return;
156+
const element = inputRefs.current[firstInvalid.id];
157+
if (element && typeof element.focus === 'function') {
158+
element.focus();
159+
}
160+
};
161+
162+
const handleSubmit = (event) => {
163+
event.preventDefault();
164+
const nextErrors = validateForm(values);
165+
setErrors(nextErrors);
166+
if (Object.keys(nextErrors).length > 0) {
167+
focusFirstInvalid(nextErrors);
168+
return;
169+
}
170+
setSubmittedData(values);
171+
};
172+
173+
const renderField = (field) => {
174+
if (field.type === 'select') {
175+
return (
176+
<select
177+
id={field.id}
178+
name={field.id}
179+
ref={(el) => {
180+
inputRefs.current[field.id] = el;
181+
}}
182+
value={values[field.id]}
183+
onChange={(event) => handleChange(field, event)}
184+
>
185+
{field.options.map((option) => (
186+
<option key={`${field.id}-${option || 'default'}`} value={option}>
187+
{option || 'Select an option'}
188+
</option>
189+
))}
190+
</select>
191+
);
192+
}
193+
194+
if (field.type === 'checkbox') {
195+
return (
196+
<input
197+
id={field.id}
198+
name={field.id}
199+
ref={(el) => {
200+
inputRefs.current[field.id] = el;
201+
}}
202+
type="checkbox"
203+
checked={values[field.id]}
204+
onChange={(event) => handleChange(field, event)}
205+
/>
206+
);
207+
}
208+
209+
return (
210+
<input
211+
id={field.id}
212+
name={field.id}
213+
ref={(el) => {
214+
inputRefs.current[field.id] = el;
215+
}}
216+
type={field.type}
217+
placeholder={field.placeholder || ''}
218+
value={values[field.id]}
219+
onChange={(event) => handleChange(field, event)}
220+
/>
221+
);
222+
};
223+
224+
return (
225+
<div className="play-details">
226+
<PlayHeader play={props} />
227+
<div className="play-details-body">
228+
<section className="custom-form-engine">
229+
<div className="custom-form-engine__intro">
230+
<h2>Custom Form Engine</h2>
231+
<p>
232+
Schema-driven, dynamic fields, and a validation engine with focus management using
233+
refs.
234+
</p>
235+
<button type="button" className="add-field-btn" onClick={addDynamicField}>
236+
Add Dynamic Skill Field
237+
</button>
238+
</div>
239+
240+
<form className="custom-form-engine__form" onSubmit={handleSubmit} noValidate>
241+
{schema.map((field) => {
242+
const error = errors[field.id];
243+
return (
244+
<div key={field.id} className="form-row">
245+
<label htmlFor={field.id}>
246+
{field.label}
247+
{field.validators.some((rule) => rule.type === 'required') ? ' *' : ''}
248+
</label>
249+
{renderField(field)}
250+
{error ? <span className="error-text">{error}</span> : null}
251+
</div>
252+
);
253+
})}
254+
255+
<button type="submit" className="submit-btn">
256+
Validate and Submit
257+
</button>
258+
</form>
259+
260+
<div className="custom-form-engine__meta">
261+
<p>
262+
<strong>Required fields:</strong> {requiredCount}
263+
</p>
264+
<p>
265+
<strong>Total fields:</strong> {schema.length}
266+
</p>
267+
</div>
268+
269+
{submittedData ? (
270+
<div className="custom-form-engine__result">
271+
<h3>Submitted Data</h3>
272+
<pre>{JSON.stringify(submittedData, null, 2)}</pre>
273+
</div>
274+
) : null}
275+
</section>
276+
</div>
277+
</div>
278+
);
279+
}
280+
281+
export default CustomFormEngine;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# custom-form-engine
2+
3+
A hard-level play that builds a schema-based custom form engine from scratch.
4+
5+
## Play Demographic
6+
7+
- Language: JavaScript
8+
- Level: Hard
9+
10+
## What You Will Learn
11+
12+
- How to build a dynamic form from a schema config.
13+
- How to create a custom validation engine without form libraries.
14+
- How to use `useRef` for focus management on invalid fields.
15+
- How to add fields dynamically at runtime while preserving validation behavior.
16+
17+
## Features
18+
19+
- Schema-driven rendering (`text`, `email`, `number`, `select`, `checkbox`)
20+
- Validation rules (`required`, `minLength`, `email`, `numberRange`)
21+
- Real-time field validation and on-submit full validation
22+
- Focus first invalid input with refs
23+
- Dynamic field addition (`Skill` fields)
24+
25+
## Implementation Details
26+
27+
The main logic is in `CustomFormEngine.jsx` and is split into:
28+
29+
- `FORM_SCHEMA`: Declarative field definitions.
30+
- `validators`: Small rule functions that form a validation engine.
31+
- `validateField` and `validateForm`: Rule executor helpers.
32+
- `inputRefs`: Ref registry keyed by field id for error focus handling.
33+
34+
## Resources
35+
36+
- React docs on refs: https://react.dev/reference/react/useRef
37+
- React docs on forms: https://react.dev/learn/sharing-state-between-components

0 commit comments

Comments
 (0)