@ainelo/form-engine 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +869 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# `@ainelo/form-engine`
|
|
4
|
+
|
|
5
|
+
**A powerful, JSON-driven React form engine** — conditional logic, multi-step pagination, OTP, file uploads, repeat groups, multilingual labels, and more. Build complex forms without writing form code.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@ainelo/form-engine)
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
[](https://www.typescriptlang.org/)
|
|
10
|
+
[](https://react.dev/)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Why `@ainelo/form-engine`?
|
|
17
|
+
|
|
18
|
+
Most React form libraries give you primitives — you still wire validation, conditionals, multi-step logic, file uploads, and OTP flows yourself. `@ainelo/form-engine` is the only library in the React ecosystem that combines all of the following **out of the box**, with zero boilerplate:
|
|
19
|
+
|
|
20
|
+
| Feature | react-hook-form | Formik | RJSF | **form-engine** |
|
|
21
|
+
|---|:---:|:---:|:---:|:---:|
|
|
22
|
+
| JSON-driven rendering | ❌ | ❌ | ✅ | ✅ |
|
|
23
|
+
| Conditional display (AND/OR/computed) | ❌ | ❌ | ⚠️ | ✅ |
|
|
24
|
+
| Multi-step + progress bar | ❌ | ❌ | ❌ | ✅ |
|
|
25
|
+
| Repeat groups (dynamic arrays) | ❌ | ❌ | ✅ | ✅ |
|
|
26
|
+
| OTP flow (send + verify + resend) | ❌ | ❌ | ❌ | ✅ |
|
|
27
|
+
| File-to-bucket upload/delete | ❌ | ❌ | ❌ | ✅ |
|
|
28
|
+
| Cascading dropdowns | ❌ | ❌ | ❌ | ✅ |
|
|
29
|
+
| Multilingual labels | ❌ | ❌ | ⚠️ | ✅ |
|
|
30
|
+
| Radix UI + Tailwind (shadcn-compatible) | ❌ | ❌ | ❌ | ✅ |
|
|
31
|
+
| Zod validation (auto-generated) | ❌ | ❌ | ❌ | ✅ |
|
|
32
|
+
| Draft persistence (Zustand) | ❌ | ❌ | ❌ | ✅ |
|
|
33
|
+
|
|
34
|
+
**The sweet spot:** if your stack is React + Zod + Radix UI + Tailwind (the modern default), `@ainelo/form-engine` plugs in natively and eliminates weeks of form infrastructure work.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Overview
|
|
39
|
+
|
|
40
|
+
`@ainelo/form-engine` lets you describe a form as a **plain JSON object** and render it as a fully functional, validated, multi-step React form. No boilerplate. No manual `useState` for every field. You define the structure — the engine handles the rest.
|
|
41
|
+
|
|
42
|
+
**Key capabilities:**
|
|
43
|
+
|
|
44
|
+
- **All standard field types** — text, textarea, dropdown, radio, checkbox, date, number, boolean, password, file
|
|
45
|
+
- **Rich media fields** — image, video, audio, document, PDF, voice recording
|
|
46
|
+
- **Rich text editor** — TipTap integration
|
|
47
|
+
- **OTP verification** — built-in phone/email OTP flow with resend logic
|
|
48
|
+
- **File-to-bucket uploads** — upload and delete files via a configurable API
|
|
49
|
+
- **Conditional display** — show/hide sections and fields based on other field values
|
|
50
|
+
- **Repeat groups** — dynamic lists of fields (e.g. list of passengers, children, etc.)
|
|
51
|
+
- **Multi-step pagination** — by section or by field count, with progress bar
|
|
52
|
+
- **Multilingual labels** — every text supports `{ fr: "...", en: "..." }` objects
|
|
53
|
+
- **Zod validation** — automatic schema generation from field definitions
|
|
54
|
+
- **Form persistence** — draft saving via Zustand
|
|
55
|
+
- **Theming** — primary color + per-container style overrides
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm install @ainelo/form-engine
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Peer dependencies
|
|
66
|
+
|
|
67
|
+
The following packages must be installed in your project:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install react react-dom react-hook-form zod zustand lucide-react react-hot-toast \
|
|
71
|
+
@tiptap/react @tiptap/starter-kit @tiptap/extension-image \
|
|
72
|
+
@tiptap/extension-link @tiptap/extension-placeholder
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Quick start
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import { FormEngine } from "@ainelo/form-engine";
|
|
81
|
+
import type { FormStructure } from "@ainelo/form-engine";
|
|
82
|
+
|
|
83
|
+
const form: FormStructure = {
|
|
84
|
+
name: "Contact",
|
|
85
|
+
provider: "Acme Corp",
|
|
86
|
+
description: "Simple contact form",
|
|
87
|
+
status: "ACTIVE",
|
|
88
|
+
isDeclaration: 0,
|
|
89
|
+
isTest: 0,
|
|
90
|
+
theme: { primaryColor: "#008080" },
|
|
91
|
+
layoutOptions: {
|
|
92
|
+
paginationMode: "bySection",
|
|
93
|
+
progressBarType: { type: "linear", visible: true },
|
|
94
|
+
},
|
|
95
|
+
sections: [
|
|
96
|
+
{
|
|
97
|
+
sectionId: "section-contact",
|
|
98
|
+
title: { fr: "Vos informations", en: "Your information" },
|
|
99
|
+
formFields: [
|
|
100
|
+
{
|
|
101
|
+
formFieldId: "f-name",
|
|
102
|
+
fieldName: "full_name",
|
|
103
|
+
fieldType: "text",
|
|
104
|
+
label: { fr: "Nom complet", en: "Full name" },
|
|
105
|
+
response: { responseValue: "" },
|
|
106
|
+
width: "half",
|
|
107
|
+
validations: [
|
|
108
|
+
{ validationType: "required", errMsg: "Le nom est requis" },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
formFieldId: "f-email",
|
|
113
|
+
fieldName: "email",
|
|
114
|
+
fieldType: "text",
|
|
115
|
+
label: "Email",
|
|
116
|
+
response: { responseValue: "" },
|
|
117
|
+
width: "half",
|
|
118
|
+
validations: [
|
|
119
|
+
{ validationType: "required", errMsg: "L'email est requis" },
|
|
120
|
+
{ validationType: "email", errMsg: "Email invalide" },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export default function App() {
|
|
129
|
+
return (
|
|
130
|
+
<FormEngine
|
|
131
|
+
form={form}
|
|
132
|
+
formId="contact-form"
|
|
133
|
+
onSubmit={(data) => console.log(data)}
|
|
134
|
+
currentLang="fr"
|
|
135
|
+
submitButtonText="Envoyer"
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## `<FormEngine>` props
|
|
144
|
+
|
|
145
|
+
| Prop | Type | Required | Default | Description |
|
|
146
|
+
|------|------|:--------:|---------|-------------|
|
|
147
|
+
| `form` | `FormStructure` | ✅ | — | The complete form definition object |
|
|
148
|
+
| `formId` | `string` | ✅ | — | Unique instance ID — used for draft persistence |
|
|
149
|
+
| `onSubmit` | `(data: Record<string, any>) => void` | ✅ | — | Called when the form is valid and submitted |
|
|
150
|
+
| `currentLang` | `string` | | `"fr"` | Active language code for multilingual labels |
|
|
151
|
+
| `submitButtonText` | `string` | | `"Continuer"` | Label of the submit / next button |
|
|
152
|
+
| `paginationMode` | `"byFields" \| "bySection"` | | `"byFields"` | Overrides `form.layoutOptions.paginationMode` |
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Form structure reference
|
|
157
|
+
|
|
158
|
+
### `FormStructure`
|
|
159
|
+
|
|
160
|
+
The root object passed to the `form` prop.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
{
|
|
164
|
+
name: string;
|
|
165
|
+
provider: string;
|
|
166
|
+
description: string;
|
|
167
|
+
status: "ACTIVE" | "INACTIVE";
|
|
168
|
+
isDeclaration: number; // 1 = official declaration, 0 = standard
|
|
169
|
+
isTest: number; // 1 = test, 0 = production
|
|
170
|
+
version?: string;
|
|
171
|
+
createdAt?: string; // ISO date string
|
|
172
|
+
updatedAt?: string; // ISO date string
|
|
173
|
+
sections: Section[];
|
|
174
|
+
theme?: FormTheme;
|
|
175
|
+
layoutOptions?: FormEngineLayoutOptions;
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
### `FormTheme`
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
{
|
|
185
|
+
primaryColor?: string; // Hex color — e.g. "#008080"
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
### `FormEngineLayoutOptions`
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
{
|
|
195
|
+
paginationMode?: "byFields" | "bySection";
|
|
196
|
+
progressBarType?: ProgressBarType;
|
|
197
|
+
headerOptions?: HeaderOptions;
|
|
198
|
+
displayDeleteButton?: boolean; // Show "clear all" button — default: true
|
|
199
|
+
containerStyles?: ContainerStylesOptions;
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
#### `ProgressBarType`
|
|
204
|
+
|
|
205
|
+
| `type` value | Description |
|
|
206
|
+
|---|---|
|
|
207
|
+
| `"default_step"` | Classic numbered steps |
|
|
208
|
+
| `"linear"` | Horizontal progress bar |
|
|
209
|
+
| `"pastel"` | Pastel-toned step bar |
|
|
210
|
+
| `"custom"` | Custom colors via `colors` |
|
|
211
|
+
| `"section_bubble"` | Bubble indicators per section |
|
|
212
|
+
| `"section_bubble_pastel"` | Pastel bubble indicators |
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
progressBarType: {
|
|
216
|
+
type: "linear",
|
|
217
|
+
visible: true,
|
|
218
|
+
colors: {
|
|
219
|
+
background: "#e5e7eb",
|
|
220
|
+
foreground: "#008080",
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
#### `HeaderOptions`
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
{
|
|
229
|
+
visible?: boolean;
|
|
230
|
+
type: "default" | "custom";
|
|
231
|
+
customHeader?: React.ReactNode; // Only used when type === "custom"
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
#### `ContainerStylesOptions`
|
|
236
|
+
|
|
237
|
+
Customize the border and background of the form containers:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
{
|
|
241
|
+
mainContainer?: ContainerStyles; // Outer container (sections + buttons)
|
|
242
|
+
sectionContainer?: ContainerStyles; // Inner container (each section)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ContainerStyles
|
|
246
|
+
{
|
|
247
|
+
borderColor?: string; // e.g. "#e5e7eb"
|
|
248
|
+
borderWidth?: string | number; // e.g. "1px" or 1
|
|
249
|
+
backgroundColor?: string; // e.g. "#f9fafb"
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
### `Section`
|
|
256
|
+
|
|
257
|
+
A section groups related fields. Each section becomes a page in `"bySection"` mode.
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
{
|
|
261
|
+
sectionId: string;
|
|
262
|
+
title?: string | { [lang: string]: string } | null;
|
|
263
|
+
subTitle?: string | { [lang: string]: string } | null;
|
|
264
|
+
isSpacer?: string;
|
|
265
|
+
position?: number;
|
|
266
|
+
formFields: FormField[];
|
|
267
|
+
conditionalDisplay?: ConditionalDisplayGroup;
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
### `FormField`
|
|
274
|
+
|
|
275
|
+
The core building block. Every field in `section.formFields` follows this shape:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
{
|
|
279
|
+
formFieldId: string; // Unique ID — UUID recommended
|
|
280
|
+
fieldName: string; // Technical key — becomes the key in onSubmit data
|
|
281
|
+
slug?: string; // Human-readable key for URLs / exports
|
|
282
|
+
fieldType: FieldType; // See table below
|
|
283
|
+
label: string | { [lang: string]: string };
|
|
284
|
+
response: { responseValue: string | string[] | number | number[] | boolean | File | File[] | null };
|
|
285
|
+
selectOptions?: SelectOption[];
|
|
286
|
+
validations?: ValidationRule[];
|
|
287
|
+
position?: number;
|
|
288
|
+
placeholder?: string | { [lang: string]: string };
|
|
289
|
+
tooltip?: string | { [lang: string]: string };
|
|
290
|
+
helpText?: string | { [lang: string]: string };
|
|
291
|
+
example?: string | { [lang: string]: string };
|
|
292
|
+
width?: "full" | "half";
|
|
293
|
+
enableSearch?: boolean;
|
|
294
|
+
conditionalDisplay?: ConditionalDisplayGroup;
|
|
295
|
+
repeatGroup?: FormRepeatGroup;
|
|
296
|
+
repeatRule?: RepeatRule;
|
|
297
|
+
fileToBucketManage?: FileToBucketManage;
|
|
298
|
+
otpFormFieldExecOptions?: OTPFormFieldExecOptions;
|
|
299
|
+
emailFormFieldExecOptions?: EmailFormFieldExecOptions;
|
|
300
|
+
detailInfos?: DetailInfosConfig;
|
|
301
|
+
dynamicFilterRule?: DynamicFilterRule;
|
|
302
|
+
flattenOnSubmit?: boolean;
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
#### Field types
|
|
307
|
+
|
|
308
|
+
| `fieldType` | UI rendered | Notes |
|
|
309
|
+
|---|---|---|
|
|
310
|
+
| `"text"` | Text input | Also used for email with validation |
|
|
311
|
+
| `"textarea"` | Multi-line input | |
|
|
312
|
+
| `"dropdown"` | Select list | Requires `selectOptions`. Supports `enableSearch` |
|
|
313
|
+
| `"radio"` | Radio buttons | Requires `selectOptions` |
|
|
314
|
+
| `"checkbox"` | Checkboxes | Requires `selectOptions` |
|
|
315
|
+
| `"date"` | Date picker | |
|
|
316
|
+
| `"number"` | Numeric input | |
|
|
317
|
+
| `"bool"` | Toggle / switch | |
|
|
318
|
+
| `"file"` | File upload | Generic |
|
|
319
|
+
| `"password"` | Password input | Masked |
|
|
320
|
+
| `"IMAGE"` | Image upload | Preview included |
|
|
321
|
+
| `"VIDEO"` | Video upload | |
|
|
322
|
+
| `"AUDIO"` | Audio upload | |
|
|
323
|
+
| `"DOCUMENT"` | Document upload | |
|
|
324
|
+
| `"PDF"` | PDF upload | |
|
|
325
|
+
| `"VOICE"` | Voice recording | Browser mic |
|
|
326
|
+
| `"TIP_TAP_DOC_TEXT"` | Rich text editor | TipTap-powered |
|
|
327
|
+
| `"OTP"` | OTP code input | Requires `otpFormFieldExecOptions` |
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
### `SelectOption`
|
|
332
|
+
|
|
333
|
+
Used with `dropdown`, `radio`, and `checkbox` fields:
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
{
|
|
337
|
+
value: string;
|
|
338
|
+
label: string | { [lang: string]: string };
|
|
339
|
+
points?: number; // Score / weight for quiz-like forms
|
|
340
|
+
icon?: string; // Icon name or URL
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
### `ValidationRule`
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
{
|
|
350
|
+
validationType?: ValidationType;
|
|
351
|
+
value?: number | string | string[];
|
|
352
|
+
errMsg: string; // Error message displayed to the user
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
#### Validation types
|
|
357
|
+
|
|
358
|
+
| `validationType` | `value` type | Description |
|
|
359
|
+
|---|---|---|
|
|
360
|
+
| `"required"` | — | Field must not be empty |
|
|
361
|
+
| `"minLength"` | `number` | Minimum character count |
|
|
362
|
+
| `"maxLength"` | `number` | Maximum character count |
|
|
363
|
+
| `"regex"` | `string` | Must match the given pattern |
|
|
364
|
+
| `"email"` | — | Must be a valid email address |
|
|
365
|
+
| `"number"` | — | Must be a valid number |
|
|
366
|
+
| `"fileSize"` | `number` | Max file size in bytes |
|
|
367
|
+
| `"phone_number"` | — | Must be a valid phone number |
|
|
368
|
+
| `"fileType"` | `string[]` | Allowed MIME types — e.g. `["image/png", "image/jpeg"]` |
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## Conditional display
|
|
373
|
+
|
|
374
|
+
Show or hide a section or field based on the values of other fields.
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
{
|
|
378
|
+
rules: ConditionalDisplayRule | ConditionalDisplayRule[];
|
|
379
|
+
logic?: "AND" | "OR"; // Default: "AND"
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### `ConditionalDisplayRule`
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
{
|
|
387
|
+
fieldName?: string | string[]; // Target by technical key
|
|
388
|
+
formFieldId?: string | string[]; // Target by UUID
|
|
389
|
+
operator?: ConditionalOperator;
|
|
390
|
+
value?: string | number | boolean | string[] | number[];
|
|
391
|
+
numeric_compare_to?: number; // For aggregation comparisons
|
|
392
|
+
typeComputation?: "SOMME" | "MULTIPLICATION";
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### Operators
|
|
397
|
+
|
|
398
|
+
`"=="` · `"!="` · `">"` · `"<"` · `"IN"` · `"NOT IN"` · `"CONTAINS"` · `"NOT CONTAINS"`
|
|
399
|
+
|
|
400
|
+
### Examples
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
// Show field only if "has_vehicle" equals "yes"
|
|
404
|
+
conditionalDisplay: {
|
|
405
|
+
rules: { fieldName: "has_vehicle", operator: "==", value: "yes" },
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Show section if country is France OR Belgium
|
|
409
|
+
conditionalDisplay: {
|
|
410
|
+
rules: { fieldName: "country", operator: "IN", value: ["FR", "BE"] },
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Show if score1 + score2 > 10
|
|
414
|
+
conditionalDisplay: {
|
|
415
|
+
rules: {
|
|
416
|
+
fieldName: ["score1", "score2"],
|
|
417
|
+
typeComputation: "SOMME",
|
|
418
|
+
operator: ">",
|
|
419
|
+
numeric_compare_to: 10,
|
|
420
|
+
},
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Repeat groups
|
|
427
|
+
|
|
428
|
+
A `FormRepeatGroup` defines a block of fields that users can repeat N times (e.g. list of children, co-applicants).
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
// On FormField
|
|
432
|
+
repeatGroup: {
|
|
433
|
+
repeatGroupId: string;
|
|
434
|
+
fieldName: string;
|
|
435
|
+
label: string | { [lang: string]: string };
|
|
436
|
+
minRepeats?: number;
|
|
437
|
+
maxRepeats?: number;
|
|
438
|
+
initialRepeats?: number;
|
|
439
|
+
formFields: FormField[];
|
|
440
|
+
position?: number;
|
|
441
|
+
conditionalDisplay?: ConditionalDisplayGroup;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
repeatRule: {
|
|
445
|
+
min?: number;
|
|
446
|
+
max?: number;
|
|
447
|
+
prefillEmpty?: boolean; // Pre-fill one empty instance on initial render
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
## OTP field
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
{
|
|
457
|
+
formFieldId: "f-otp",
|
|
458
|
+
fieldName: "otp_code",
|
|
459
|
+
fieldType: "OTP",
|
|
460
|
+
label: "Code de vérification",
|
|
461
|
+
response: { responseValue: "" },
|
|
462
|
+
otpFormFieldExecOptions: {
|
|
463
|
+
serverDns: "https://api.example.com",
|
|
464
|
+
postApiEndPoint: "/api/otp/verify",
|
|
465
|
+
otpLength: 6,
|
|
466
|
+
linkedEmailFieldName: "email", // Triggers OTP send when this field is filled
|
|
467
|
+
sendOTPApiConfig: {
|
|
468
|
+
serverDns: "https://api.example.com",
|
|
469
|
+
postApiEndPoint: "/api/otp/send",
|
|
470
|
+
bearer: "my-token",
|
|
471
|
+
},
|
|
472
|
+
autoValidate: true,
|
|
473
|
+
enableResend: true,
|
|
474
|
+
resendCooldownSeconds: 60,
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
To trigger OTP send automatically from the email field, add `emailFormFieldExecOptions` to it:
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
{
|
|
483
|
+
formFieldId: "f-email",
|
|
484
|
+
fieldName: "email",
|
|
485
|
+
fieldType: "text",
|
|
486
|
+
label: "Email",
|
|
487
|
+
response: { responseValue: "" },
|
|
488
|
+
validations: [{ validationType: "email", errMsg: "Email invalide" }],
|
|
489
|
+
emailFormFieldExecOptions: {
|
|
490
|
+
triggerOTPSend: true,
|
|
491
|
+
linkedOTPFieldName: "otp_code",
|
|
492
|
+
otpSendApiConfig: {
|
|
493
|
+
serverDns: "https://api.example.com",
|
|
494
|
+
postApiEndPoint: "/api/otp/send",
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## File uploads to a bucket
|
|
503
|
+
|
|
504
|
+
Add `fileToBucketManage` to any file-type field to automatically upload on selection and delete on removal:
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
fileToBucketManage: {
|
|
508
|
+
uploadOption: {
|
|
509
|
+
serverDns: "https://bucket.example.com",
|
|
510
|
+
postApiEndPoint: "/api/files/upload-file",
|
|
511
|
+
payload: { secret_key: "my-secret", folder_name: "avatars" },
|
|
512
|
+
bearer: "my-token",
|
|
513
|
+
timeoutMs: 30000,
|
|
514
|
+
},
|
|
515
|
+
deleteOption: {
|
|
516
|
+
serverDns: "https://bucket.example.com",
|
|
517
|
+
deleteApiEndPoint: "/api/files/delete-file",
|
|
518
|
+
payload: { secret_key: "my-secret", folder_name: "avatars" },
|
|
519
|
+
},
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
## Dynamic filter (cascading dropdowns)
|
|
526
|
+
|
|
527
|
+
Filter a dropdown's options based on another field's selected value:
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
{
|
|
531
|
+
formFieldId: "f-city",
|
|
532
|
+
fieldName: "city",
|
|
533
|
+
fieldType: "dropdown",
|
|
534
|
+
label: "Ville",
|
|
535
|
+
response: { responseValue: "" },
|
|
536
|
+
dynamicFilterRule: {
|
|
537
|
+
dependentFieldName: "country", // When "country" changes, reload city options
|
|
538
|
+
filterType: "exact",
|
|
539
|
+
dataSource: {
|
|
540
|
+
type: "static",
|
|
541
|
+
data: {
|
|
542
|
+
FR: [
|
|
543
|
+
{ value: "paris", label: "Paris" },
|
|
544
|
+
{ value: "lyon", label: "Lyon" },
|
|
545
|
+
],
|
|
546
|
+
BE: [
|
|
547
|
+
{ value: "bruxelles", label: "Bruxelles" },
|
|
548
|
+
{ value: "liege", label: "Liège" },
|
|
549
|
+
],
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
---
|
|
557
|
+
|
|
558
|
+
## Detail fields on selection
|
|
559
|
+
|
|
560
|
+
Automatically reveal additional fields when the user selects a specific option:
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
{
|
|
564
|
+
formFieldId: "f-sector",
|
|
565
|
+
fieldName: "sector",
|
|
566
|
+
fieldType: "dropdown",
|
|
567
|
+
label: "Secteur",
|
|
568
|
+
response: { responseValue: "" },
|
|
569
|
+
selectOptions: [
|
|
570
|
+
{ value: "agri", label: "Agriculture" },
|
|
571
|
+
{ value: "other", label: "Autre" },
|
|
572
|
+
],
|
|
573
|
+
detailInfos: {
|
|
574
|
+
other: {
|
|
575
|
+
label: "Précisez votre secteur",
|
|
576
|
+
formFields: [
|
|
577
|
+
{
|
|
578
|
+
formFieldId: "f-sector-detail",
|
|
579
|
+
fieldName: "sector_detail",
|
|
580
|
+
fieldType: "text",
|
|
581
|
+
label: "Secteur exact",
|
|
582
|
+
response: { responseValue: "" },
|
|
583
|
+
},
|
|
584
|
+
],
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## Multilingual support
|
|
593
|
+
|
|
594
|
+
Every `label`, `placeholder`, `tooltip`, `helpText`, and `example` field accepts either a plain string or a language map:
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
label: { fr: "Nom complet", en: "Full name", es: "Nombre completo" }
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
Pass `currentLang="en"` to `<FormEngine>` to switch the active language at runtime.
|
|
601
|
+
|
|
602
|
+
---
|
|
603
|
+
|
|
604
|
+
## What `onSubmit` receives
|
|
605
|
+
|
|
606
|
+
The `onSubmit` callback receives a flat `Record<string, any>` where each key is a field's `fieldName`. File fields return the uploaded URL (if `fileToBucketManage` is configured) or the `File` object. Repeat groups return an array of objects.
|
|
607
|
+
|
|
608
|
+
```ts
|
|
609
|
+
// Example output
|
|
610
|
+
{
|
|
611
|
+
full_name: "Alice Dupont",
|
|
612
|
+
email: "alice@example.com",
|
|
613
|
+
sector: "other",
|
|
614
|
+
sector_detail: "Fintech",
|
|
615
|
+
passengers: [
|
|
616
|
+
{ passenger_name: "Bob", passenger_age: 32 },
|
|
617
|
+
{ passenger_name: "Carol", passenger_age: 28 },
|
|
618
|
+
],
|
|
619
|
+
id_document: "https://bucket.example.com/files/doc-abc123.pdf",
|
|
620
|
+
}
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
---
|
|
624
|
+
|
|
625
|
+
## Complete real-world example
|
|
626
|
+
|
|
627
|
+
A 3-step registration form with:
|
|
628
|
+
- **Step 1** — identity (text + email + OTP verification)
|
|
629
|
+
- **Step 2** — profile (dropdown + conditional field + cascading dropdown)
|
|
630
|
+
- **Step 3** — passengers (repeat group, 1–5 entries)
|
|
631
|
+
|
|
632
|
+
```tsx
|
|
633
|
+
import { FormEngine } from "@ainelo/form-engine";
|
|
634
|
+
import type { FormStructure } from "@ainelo/form-engine";
|
|
635
|
+
|
|
636
|
+
const registrationForm: FormStructure = {
|
|
637
|
+
name: "Registration",
|
|
638
|
+
provider: "Acme Corp",
|
|
639
|
+
description: "Multi-step registration with OTP and repeat group",
|
|
640
|
+
status: "ACTIVE",
|
|
641
|
+
isDeclaration: 0,
|
|
642
|
+
isTest: 0,
|
|
643
|
+
theme: { primaryColor: "#6366f1" },
|
|
644
|
+
layoutOptions: {
|
|
645
|
+
paginationMode: "bySection",
|
|
646
|
+
progressBarType: { type: "linear", visible: true },
|
|
647
|
+
displayDeleteButton: false,
|
|
648
|
+
},
|
|
649
|
+
sections: [
|
|
650
|
+
// ── Step 1 — Identity + OTP ──────────────────────────────────────
|
|
651
|
+
{
|
|
652
|
+
sectionId: "s-identity",
|
|
653
|
+
title: { fr: "Votre identité", en: "Your identity" },
|
|
654
|
+
formFields: [
|
|
655
|
+
{
|
|
656
|
+
formFieldId: "f-firstname",
|
|
657
|
+
fieldName: "first_name",
|
|
658
|
+
fieldType: "text",
|
|
659
|
+
label: { fr: "Prénom", en: "First name" },
|
|
660
|
+
response: { responseValue: "" },
|
|
661
|
+
width: "half",
|
|
662
|
+
validations: [{ validationType: "required", errMsg: "Requis" }],
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
formFieldId: "f-lastname",
|
|
666
|
+
fieldName: "last_name",
|
|
667
|
+
fieldType: "text",
|
|
668
|
+
label: { fr: "Nom", en: "Last name" },
|
|
669
|
+
response: { responseValue: "" },
|
|
670
|
+
width: "half",
|
|
671
|
+
validations: [{ validationType: "required", errMsg: "Requis" }],
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
formFieldId: "f-email",
|
|
675
|
+
fieldName: "email",
|
|
676
|
+
fieldType: "text",
|
|
677
|
+
label: "Email",
|
|
678
|
+
response: { responseValue: "" },
|
|
679
|
+
validations: [
|
|
680
|
+
{ validationType: "required", errMsg: "Requis" },
|
|
681
|
+
{ validationType: "email", errMsg: "Email invalide" },
|
|
682
|
+
],
|
|
683
|
+
// Triggers OTP send automatically when a valid email is entered
|
|
684
|
+
emailFormFieldExecOptions: {
|
|
685
|
+
triggerOTPSend: true,
|
|
686
|
+
linkedOTPFieldName: "otp_code",
|
|
687
|
+
otpSendApiConfig: {
|
|
688
|
+
serverDns: "https://api.example.com",
|
|
689
|
+
postApiEndPoint: "/api/otp/send",
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
formFieldId: "f-otp",
|
|
695
|
+
fieldName: "otp_code",
|
|
696
|
+
fieldType: "OTP",
|
|
697
|
+
label: { fr: "Code de vérification (6 chiffres)", en: "Verification code (6 digits)" },
|
|
698
|
+
response: { responseValue: "" },
|
|
699
|
+
otpFormFieldExecOptions: {
|
|
700
|
+
serverDns: "https://api.example.com",
|
|
701
|
+
postApiEndPoint: "/api/otp/verify",
|
|
702
|
+
otpLength: 6,
|
|
703
|
+
linkedEmailFieldName: "email",
|
|
704
|
+
autoValidate: true,
|
|
705
|
+
enableResend: true,
|
|
706
|
+
resendCooldownSeconds: 60,
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
// ── Step 2 — Profile (conditional + cascading) ───────────────────
|
|
713
|
+
{
|
|
714
|
+
sectionId: "s-profile",
|
|
715
|
+
title: { fr: "Votre profil", en: "Your profile" },
|
|
716
|
+
formFields: [
|
|
717
|
+
{
|
|
718
|
+
formFieldId: "f-country",
|
|
719
|
+
fieldName: "country",
|
|
720
|
+
fieldType: "dropdown",
|
|
721
|
+
label: { fr: "Pays", en: "Country" },
|
|
722
|
+
response: { responseValue: "" },
|
|
723
|
+
enableSearch: true,
|
|
724
|
+
selectOptions: [
|
|
725
|
+
{ value: "FR", label: { fr: "France", en: "France" } },
|
|
726
|
+
{ value: "BE", label: { fr: "Belgique", en: "Belgium" } },
|
|
727
|
+
{ value: "CI", label: { fr: "Côte d'Ivoire", en: "Ivory Coast" } },
|
|
728
|
+
],
|
|
729
|
+
validations: [{ validationType: "required", errMsg: "Requis" }],
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
// City options change dynamically based on selected country
|
|
733
|
+
formFieldId: "f-city",
|
|
734
|
+
fieldName: "city",
|
|
735
|
+
fieldType: "dropdown",
|
|
736
|
+
label: { fr: "Ville", en: "City" },
|
|
737
|
+
response: { responseValue: "" },
|
|
738
|
+
dynamicFilterRule: {
|
|
739
|
+
dependentFieldName: "country",
|
|
740
|
+
filterType: "exact",
|
|
741
|
+
dataSource: {
|
|
742
|
+
type: "static",
|
|
743
|
+
data: {
|
|
744
|
+
FR: [{ value: "paris", label: "Paris" }, { value: "lyon", label: "Lyon" }],
|
|
745
|
+
BE: [{ value: "bruxelles", label: "Bruxelles" }, { value: "liege", label: "Liège" }],
|
|
746
|
+
CI: [{ value: "abidjan", label: "Abidjan" }, { value: "bouake", label: "Bouaké" }],
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
formFieldId: "f-sector",
|
|
753
|
+
fieldName: "sector",
|
|
754
|
+
fieldType: "dropdown",
|
|
755
|
+
label: { fr: "Secteur d'activité", en: "Business sector" },
|
|
756
|
+
response: { responseValue: "" },
|
|
757
|
+
selectOptions: [
|
|
758
|
+
{ value: "agri", label: { fr: "Agriculture", en: "Agriculture" } },
|
|
759
|
+
{ value: "tech", label: "Tech" },
|
|
760
|
+
{ value: "other", label: { fr: "Autre", en: "Other" } },
|
|
761
|
+
],
|
|
762
|
+
// Reveals a free-text field when "Autre" is selected
|
|
763
|
+
detailInfos: {
|
|
764
|
+
other: {
|
|
765
|
+
label: { fr: "Précisez", en: "Specify" },
|
|
766
|
+
formFields: [
|
|
767
|
+
{
|
|
768
|
+
formFieldId: "f-sector-detail",
|
|
769
|
+
fieldName: "sector_detail",
|
|
770
|
+
fieldType: "text",
|
|
771
|
+
label: { fr: "Secteur exact", en: "Exact sector" },
|
|
772
|
+
response: { responseValue: "" },
|
|
773
|
+
},
|
|
774
|
+
],
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
],
|
|
779
|
+
},
|
|
780
|
+
|
|
781
|
+
// ── Step 3 — Passengers (repeat group) ───────────────────────────
|
|
782
|
+
{
|
|
783
|
+
sectionId: "s-passengers",
|
|
784
|
+
title: { fr: "Passagers", en: "Passengers" },
|
|
785
|
+
formFields: [
|
|
786
|
+
{
|
|
787
|
+
formFieldId: "f-passengers",
|
|
788
|
+
fieldName: "passengers",
|
|
789
|
+
fieldType: "text",
|
|
790
|
+
label: { fr: "Passagers", en: "Passengers" },
|
|
791
|
+
response: { responseValue: "" },
|
|
792
|
+
repeatGroup: {
|
|
793
|
+
repeatGroupId: "rg-passengers",
|
|
794
|
+
fieldName: "passengers",
|
|
795
|
+
label: { fr: "Passager", en: "Passenger" },
|
|
796
|
+
minRepeats: 1,
|
|
797
|
+
maxRepeats: 5,
|
|
798
|
+
initialRepeats: 1,
|
|
799
|
+
formFields: [
|
|
800
|
+
{
|
|
801
|
+
formFieldId: "f-p-name",
|
|
802
|
+
fieldName: "passenger_name",
|
|
803
|
+
fieldType: "text",
|
|
804
|
+
label: { fr: "Nom complet", en: "Full name" },
|
|
805
|
+
response: { responseValue: "" },
|
|
806
|
+
width: "half",
|
|
807
|
+
validations: [{ validationType: "required", errMsg: "Requis" }],
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
formFieldId: "f-p-age",
|
|
811
|
+
fieldName: "passenger_age",
|
|
812
|
+
fieldType: "number",
|
|
813
|
+
label: { fr: "Âge", en: "Age" },
|
|
814
|
+
response: { responseValue: "" },
|
|
815
|
+
width: "half",
|
|
816
|
+
validations: [{ validationType: "required", errMsg: "Requis" }],
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
formFieldId: "f-p-passport",
|
|
820
|
+
fieldName: "passport_scan",
|
|
821
|
+
fieldType: "PDF",
|
|
822
|
+
label: { fr: "Scan passeport", en: "Passport scan" },
|
|
823
|
+
response: { responseValue: "" },
|
|
824
|
+
fileToBucketManage: {
|
|
825
|
+
uploadOption: {
|
|
826
|
+
serverDns: "https://bucket.example.com",
|
|
827
|
+
postApiEndPoint: "/api/files/upload-file",
|
|
828
|
+
payload: { folder_name: "passports" },
|
|
829
|
+
bearer: "my-token",
|
|
830
|
+
},
|
|
831
|
+
deleteOption: {
|
|
832
|
+
serverDns: "https://bucket.example.com",
|
|
833
|
+
deleteApiEndPoint: "/api/files/delete-file",
|
|
834
|
+
payload: { folder_name: "passports" },
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
],
|
|
839
|
+
},
|
|
840
|
+
repeatRule: { min: 1, max: 5, prefillEmpty: true },
|
|
841
|
+
},
|
|
842
|
+
],
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
export default function RegistrationPage() {
|
|
848
|
+
return (
|
|
849
|
+
<FormEngine
|
|
850
|
+
form={registrationForm}
|
|
851
|
+
formId="registration-2024"
|
|
852
|
+
currentLang="fr"
|
|
853
|
+
submitButtonText="Suivant"
|
|
854
|
+
onSubmit={(data) => {
|
|
855
|
+
// data.first_name, data.last_name, data.email
|
|
856
|
+
// data.country, data.city, data.sector, data.sector_detail?
|
|
857
|
+
// data.passengers: [{ passenger_name, passenger_age, passport_scan }]
|
|
858
|
+
console.log(data);
|
|
859
|
+
}}
|
|
860
|
+
/>
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
---
|
|
866
|
+
|
|
867
|
+
## License
|
|
868
|
+
|
|
869
|
+
MIT © [Lionel TOTON](mailto:totonlionel@gmail.com)
|
package/package.json
CHANGED