Розроблення конструктора форм (Drag-and-Drop Form Builder) на веб-сайту
Конструктор форм — інструмент, який дозволяє користувачам без навичок програмування створювати довільні форми: опитування, заявки, реєстрації, квізи. Ключові вимоги: гнучка схема полів, візуальний редактор з drag-and-drop, рендеринг форм на сайті і збір відповідей.
Архітектура
Система складається з трьох незалежних частин:
- Builder — React-компонент редактора форм (drag-and-drop)
- Renderer — React-компонент для відображення та заповнення формы
- Backend — API для зберігання схем, збору відповідей, аналітики
Схема форми — це JSON, який інтерпретується рендерером. Це дає повну гнучкість без змін коду при додаванні нового типу поля.
Структура схеми форми (JSON Schema)
{
"id": "uuid-v4",
"title": "Заявка на зворотний дзвінок",
"description": "Ми перезвоним протягом 30 хвилин",
"settings": {
"submit_label": "Відправити заявку",
"success_message": "Спасибі! Ми зв'яжемось з вами.",
"redirect_url": null,
"notify_emails": ["[email protected]"],
"allow_multiple_submissions": false
},
"fields": [
{
"id": "field_1",
"type": "text",
"label": "Ім'я",
"placeholder": "Введіть ваше ім'я",
"required": true,
"validation": { "min_length": 2, "max_length": 100 }
},
{
"id": "field_2",
"type": "phone",
"label": "Телефон",
"required": true,
"validation": { "pattern": "^\\+?[\\d\\s\\-\\(\\)]{7,20}$" }
},
{
"id": "field_3",
"type": "select",
"label": "Зручний час дзвінка",
"required": false,
"options": [
{ "value": "morning", "label": "9:00 – 12:00" },
{ "value": "afternoon", "label": "12:00 – 17:00" },
{ "value": "evening", "label": "17:00 – 20:00" }
]
},
{
"id": "field_4",
"type": "conditional_group",
"condition": { "field": "field_3", "operator": "equals", "value": "evening" },
"fields": [
{
"id": "field_4_1",
"type": "checkbox",
"label": "Підтверджую, що дзвінок після 17:00 мені зручна",
"required": true
}
]
}
]
}
Підтримувані типи полів
| Тип | Опис |
|---|---|
text |
Однорядковий текст |
textarea |
Багаторядковий текст |
email |
Email з вбудованою валідацією |
phone |
Телефон з маскою |
number |
Число з min/max/step |
select |
Випадаючий список |
multiselect |
Вибір кількох значень |
radio |
Радіокнопки |
checkbox |
Один чекбокс (згода) |
checkbox_group |
Група чекбоксів |
date |
Дата |
date_range |
Діапазон дат |
file |
Завантаження файлу |
rating |
Оцінка зірочками (1–5) |
scale |
Шкала (NPS, 0–10) |
heading |
Заголовок (не поле введення) |
paragraph |
Текстовий блок |
divider |
Розділювач |
conditional_group |
Група з умовою відображення |
Builder: компонент редактора
Використовується @dnd-kit — більш сучасна альтернатива react-beautiful-dnd:
import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
function FormBuilder({ schema, onChange }: BuilderProps) {
const [fields, setFields] = useState(schema.fields);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (active.id !== over?.id) {
setFields((items) => {
const oldIndex = items.findIndex((i) => i.id === active.id);
const newIndex = items.findIndex((i) => i.id === over!.id);
const reordered = arrayMove(items, oldIndex, newIndex);
onChange({ ...schema, fields: reordered });
return reordered;
});
}
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map(f => f.id)} strategy={verticalListSortingStrategy}>
{fields.map(field => (
<SortableFieldCard
key={field.id}
field={field}
onEdit={(updated) => updateField(field.id, updated)}
onDelete={() => removeField(field.id)}
/>
))}
</SortableContext>
</DndContext>
);
}
Панель інструментів зліва — палітра типів полів. Перетаскування з палітри на холст додає нове поле в потрібне місце.
Renderer: рендеринг та валідація
Рендерер працює з тією ж JSON-схемою. Валідація — через React Hook Form з динамічною реєстрацією полів:
import { useForm } from 'react-hook-form';
function FormRenderer({ schema, onSubmit }: RendererProps) {
const { register, handleSubmit, watch, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit(onSubmit)}>
{schema.fields.map(field => (
<FormField
key={field.id}
field={field}
register={register}
errors={errors}
watch={watch}
/>
))}
<button type="submit">{schema.settings.submit_label}</button>
</form>
);
}
function FormField({ field, register, errors, watch }) {
// Умовна логіка: показувати поле лише якщо умова виконана
if (field.condition) {
const watchValue = watch(field.condition.field);
const conditionMet = evaluateCondition(watchValue, field.condition);
if (!conditionMet) return null;
}
const rules = buildValidationRules(field);
switch (field.type) {
case 'text':
case 'email':
case 'phone':
return (
<div>
<label>{field.label}{field.required && ' *'}</label>
<input {...register(field.id, rules)} placeholder={field.placeholder} />
{errors[field.id] && <span>{errors[field.id].message}</span>}
</div>
);
case 'select':
return (
<div>
<label>{field.label}</label>
<select {...register(field.id, rules)}>
<option value="">Виберіть...</option>
{field.options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
);
// ... інші типи
}
}
База даних
CREATE TABLE forms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE,
schema JSONB NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_by INTEGER,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE form_submissions (
id BIGSERIAL PRIMARY KEY,
form_id UUID REFERENCES forms(id),
data JSONB NOT NULL, -- { "field_1": "Іван", "field_2": "+79991234567" }
metadata JSONB DEFAULT '{}', -- IP, user agent, UTM
submitted_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_submissions_form ON form_submissions (form_id, submitted_at DESC);
CREATE INDEX idx_submissions_data ON form_submissions USING gin(data);
JSONB для відповідей — правильний вибір: структура кожної форми унікальна, і фіксована схема таблиці не підходить. GIN-індекс дозволяє шукати по значеннях конкретних полів.
Обробка відправки форми
// POST /api/forms/{slug}/submit
async function submitForm(req: Request, res: Response) {
const form = await getFormBySlug(req.params.slug);
if (!form || !form.is_active) return res.status(404).json({ error: 'Form not found' });
const schema = form.schema;
const errors = validateSubmission(schema.fields, req.body);
if (Object.keys(errors).length) {
return res.status(422).json({ errors });
}
const submission = await saveSubmission(form.id, req.body, {
ip: req.ip,
user_agent: req.headers['user-agent'],
referer: req.headers.referer,
});
// Сповіщення
if (schema.settings.notify_emails?.length) {
await sendNotificationEmail(form, submission);
}
if (schema.settings.webhook_url) {
await triggerWebhook(schema.settings.webhook_url, submission);
}
return res.json({
success: true,
message: schema.settings.success_message,
redirect: schema.settings.redirect_url,
});
}
Аналітика відповідей
Для кожної форми — сторінка з агрегованою статистикою:
- Кількість відповідей за днями (графік)
- Для
select/radio/checkbox_group— розподіл відповідей (pie chart) - Для числових полів — середнє, медіана, діапазон
- Експорт всіх відповідей в CSV/Excel
-- Розподіл відповідей для поля select
SELECT
data->>'field_3' AS answer,
COUNT(*) AS count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) AS percent
FROM form_submissions
WHERE form_id = $1
AND data ? 'field_3'
GROUP BY data->>'field_3'
ORDER BY count DESC;
Строки реалізації
Конструктор з базовими типами полів (text, email, select, checkbox), без умовної логіки — 10–12 робочих днів. Повний набір типів, умовна логіка, файлові поля, аналітика відповідей, експорт в CSV, webhook, вбудовування через iframe — 16–22 робочі дні.







