@lub-crm/forms 1.0.0
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 +298 -0
- package/dist/lub-forms.css +1 -0
- package/dist/lub-forms.es.js +5848 -0
- package/dist/lub-forms.es.js.map +1 -0
- package/dist/lub-forms.standalone.js +10 -0
- package/dist/lub-forms.standalone.js.map +1 -0
- package/dist/lub-forms.umd.js +227 -0
- package/dist/lub-forms.umd.js.map +1 -0
- package/package.json +68 -0
- package/src/api/client.ts +115 -0
- package/src/api/index.ts +2 -0
- package/src/api/types.ts +202 -0
- package/src/core/FormProvider.tsx +228 -0
- package/src/core/FormRenderer.tsx +134 -0
- package/src/core/LubForm.tsx +476 -0
- package/src/core/StepManager.tsx +199 -0
- package/src/core/index.ts +4 -0
- package/src/embed.ts +188 -0
- package/src/fields/CheckboxField.tsx +62 -0
- package/src/fields/CheckboxGroupField.tsx +57 -0
- package/src/fields/CountryField.tsx +43 -0
- package/src/fields/DateField.tsx +33 -0
- package/src/fields/DateTimeField.tsx +33 -0
- package/src/fields/DividerField.tsx +16 -0
- package/src/fields/FieldWrapper.tsx +60 -0
- package/src/fields/FileField.tsx +45 -0
- package/src/fields/HiddenField.tsx +18 -0
- package/src/fields/HtmlField.tsx +17 -0
- package/src/fields/NumberField.tsx +39 -0
- package/src/fields/RadioField.tsx +57 -0
- package/src/fields/RecaptchaField.tsx +137 -0
- package/src/fields/SelectField.tsx +49 -0
- package/src/fields/StateField.tsx +84 -0
- package/src/fields/TextField.tsx +51 -0
- package/src/fields/TextareaField.tsx +37 -0
- package/src/fields/TimeField.tsx +33 -0
- package/src/fields/index.ts +84 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useConditionalLogic.ts +59 -0
- package/src/hooks/useFormApi.ts +118 -0
- package/src/hooks/useFormDesign.ts +48 -0
- package/src/hooks/useMultiStep.ts +98 -0
- package/src/index.ts +101 -0
- package/src/main.tsx +40 -0
- package/src/styles/index.css +707 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/countries.ts +163 -0
- package/src/utils/css-variables.ts +63 -0
- package/src/utils/index.ts +3 -0
- package/src/validation/conditional.ts +170 -0
- package/src/validation/index.ts +2 -0
- package/src/validation/schema-builder.ts +327 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { z, type ZodTypeAny } from "zod";
|
|
2
|
+
import type { FormField, FieldType } from "@/api/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a Zod schema from form field definitions
|
|
6
|
+
*/
|
|
7
|
+
export function buildFormSchema(fields: FormField[]): z.ZodSchema {
|
|
8
|
+
const shape: Record<string, ZodTypeAny> = {};
|
|
9
|
+
|
|
10
|
+
for (const field of fields) {
|
|
11
|
+
if (!field.is_active) continue;
|
|
12
|
+
|
|
13
|
+
// Skip non-input fields
|
|
14
|
+
if (isNonInputField(field.field_type)) continue;
|
|
15
|
+
|
|
16
|
+
shape[field.name] = buildFieldSchema(field);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return z.object(shape);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a Zod schema for a single field
|
|
24
|
+
*/
|
|
25
|
+
export function buildFieldSchema(field: FormField): ZodTypeAny {
|
|
26
|
+
let schema: ZodTypeAny;
|
|
27
|
+
|
|
28
|
+
switch (field.field_type) {
|
|
29
|
+
case "text":
|
|
30
|
+
case "textarea":
|
|
31
|
+
case "hidden":
|
|
32
|
+
schema = buildStringSchema(field);
|
|
33
|
+
break;
|
|
34
|
+
|
|
35
|
+
case "email":
|
|
36
|
+
schema = buildEmailSchema(field);
|
|
37
|
+
break;
|
|
38
|
+
|
|
39
|
+
case "phone":
|
|
40
|
+
schema = buildPhoneSchema(field);
|
|
41
|
+
break;
|
|
42
|
+
|
|
43
|
+
case "url":
|
|
44
|
+
schema = buildUrlSchema(field);
|
|
45
|
+
break;
|
|
46
|
+
|
|
47
|
+
case "number":
|
|
48
|
+
schema = buildNumberSchema(field);
|
|
49
|
+
break;
|
|
50
|
+
|
|
51
|
+
case "date":
|
|
52
|
+
case "time":
|
|
53
|
+
case "datetime":
|
|
54
|
+
schema = buildDateSchema(field);
|
|
55
|
+
break;
|
|
56
|
+
|
|
57
|
+
case "select":
|
|
58
|
+
case "radio":
|
|
59
|
+
case "country":
|
|
60
|
+
case "state":
|
|
61
|
+
schema = buildSelectSchema(field);
|
|
62
|
+
break;
|
|
63
|
+
|
|
64
|
+
case "checkbox":
|
|
65
|
+
schema = buildCheckboxSchema(field);
|
|
66
|
+
break;
|
|
67
|
+
|
|
68
|
+
case "checkbox_group":
|
|
69
|
+
schema = buildCheckboxGroupSchema(field);
|
|
70
|
+
break;
|
|
71
|
+
|
|
72
|
+
case "file":
|
|
73
|
+
schema = buildFileSchema(field);
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
default:
|
|
77
|
+
schema = z.any();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Make optional if not required
|
|
81
|
+
if (!field.required) {
|
|
82
|
+
schema = schema.optional();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return schema;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildStringSchema(field: FormField): ZodTypeAny {
|
|
89
|
+
let schema = z.string();
|
|
90
|
+
|
|
91
|
+
const rules = field.validation_rules;
|
|
92
|
+
const customError = rules?.custom_error;
|
|
93
|
+
|
|
94
|
+
if (field.required) {
|
|
95
|
+
schema = schema.min(1, customError || `${field.label} is required`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (rules?.min_length) {
|
|
99
|
+
schema = schema.min(
|
|
100
|
+
rules.min_length,
|
|
101
|
+
customError ||
|
|
102
|
+
`${field.label} must be at least ${rules.min_length} characters`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (rules?.max_length) {
|
|
107
|
+
schema = schema.max(
|
|
108
|
+
rules.max_length,
|
|
109
|
+
customError ||
|
|
110
|
+
`${field.label} must be at most ${rules.max_length} characters`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (rules?.pattern) {
|
|
115
|
+
schema = schema.regex(
|
|
116
|
+
new RegExp(rules.pattern),
|
|
117
|
+
customError || `${field.label} has an invalid format`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return schema;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildEmailSchema(field: FormField): ZodTypeAny {
|
|
125
|
+
let schema = z.string();
|
|
126
|
+
const customError = field.validation_rules?.custom_error;
|
|
127
|
+
|
|
128
|
+
if (field.required) {
|
|
129
|
+
schema = schema.min(1, customError || `${field.label} is required`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
schema = schema.email(customError || "Please enter a valid email address");
|
|
133
|
+
|
|
134
|
+
return schema;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildPhoneSchema(field: FormField): ZodTypeAny {
|
|
138
|
+
let schema = z.string();
|
|
139
|
+
const customError = field.validation_rules?.custom_error;
|
|
140
|
+
|
|
141
|
+
if (field.required) {
|
|
142
|
+
schema = schema.min(1, customError || `${field.label} is required`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Basic phone validation: at least 10 digits
|
|
146
|
+
schema = schema.refine(
|
|
147
|
+
(val) => {
|
|
148
|
+
if (!val) return true;
|
|
149
|
+
const digits = val.replace(/\D/g, "");
|
|
150
|
+
return digits.length >= 10;
|
|
151
|
+
},
|
|
152
|
+
{ message: customError || "Please enter a valid phone number" },
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return schema;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildUrlSchema(field: FormField): ZodTypeAny {
|
|
159
|
+
let schema = z.string();
|
|
160
|
+
const customError = field.validation_rules?.custom_error;
|
|
161
|
+
|
|
162
|
+
if (field.required) {
|
|
163
|
+
schema = schema.min(1, customError || `${field.label} is required`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
schema = schema.url(customError || "Please enter a valid URL");
|
|
167
|
+
|
|
168
|
+
return schema;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildNumberSchema(field: FormField): ZodTypeAny {
|
|
172
|
+
let schema = z.coerce.number({
|
|
173
|
+
invalid_type_error: `${field.label} must be a number`,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const rules = field.validation_rules;
|
|
177
|
+
const customError = rules?.custom_error;
|
|
178
|
+
|
|
179
|
+
if (rules?.min !== undefined) {
|
|
180
|
+
schema = schema.min(
|
|
181
|
+
rules.min,
|
|
182
|
+
customError || `${field.label} must be at least ${rules.min}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (rules?.max !== undefined) {
|
|
187
|
+
schema = schema.max(
|
|
188
|
+
rules.max,
|
|
189
|
+
customError || `${field.label} must be at most ${rules.max}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// For optional number fields, allow empty string
|
|
194
|
+
if (!field.required) {
|
|
195
|
+
return z.union([schema, z.literal("").transform(() => undefined)]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return schema;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildDateSchema(field: FormField): ZodTypeAny {
|
|
202
|
+
let schema = z.string();
|
|
203
|
+
const customError = field.validation_rules?.custom_error;
|
|
204
|
+
|
|
205
|
+
if (field.required) {
|
|
206
|
+
schema = schema.min(1, customError || `${field.label} is required`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Validate date format
|
|
210
|
+
schema = schema.refine(
|
|
211
|
+
(val) => {
|
|
212
|
+
if (!val) return true;
|
|
213
|
+
const date = new Date(val);
|
|
214
|
+
return !isNaN(date.getTime());
|
|
215
|
+
},
|
|
216
|
+
{ message: customError || "Please enter a valid date" },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return schema;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildSelectSchema(field: FormField): ZodTypeAny {
|
|
223
|
+
let schema = z.string();
|
|
224
|
+
const customError = field.validation_rules?.custom_error;
|
|
225
|
+
|
|
226
|
+
if (field.required) {
|
|
227
|
+
schema = schema.min(1, customError || `${field.label} is required`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Validate against options if available and allow_other is false
|
|
231
|
+
if (field.options?.options && !field.options.allow_other) {
|
|
232
|
+
const validValues = field.options.options.map((o) => o.value);
|
|
233
|
+
schema = schema.refine((val) => !val || validValues.includes(val), {
|
|
234
|
+
message: customError || "Please select a valid option",
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return schema;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildCheckboxSchema(field: FormField): ZodTypeAny {
|
|
242
|
+
const customError = field.validation_rules?.custom_error;
|
|
243
|
+
|
|
244
|
+
if (field.required) {
|
|
245
|
+
return z.literal(true, {
|
|
246
|
+
errorMap: () => ({
|
|
247
|
+
message: customError || `${field.label} is required`,
|
|
248
|
+
}),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return z.boolean().optional();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function buildCheckboxGroupSchema(field: FormField): ZodTypeAny {
|
|
256
|
+
let schema = z.array(z.string());
|
|
257
|
+
const customError = field.validation_rules?.custom_error;
|
|
258
|
+
|
|
259
|
+
if (field.required) {
|
|
260
|
+
schema = schema.min(1, customError || `Please select at least one option`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Validate against options if available
|
|
264
|
+
if (field.options?.options && !field.options.allow_other) {
|
|
265
|
+
const validValues = new Set(field.options.options.map((o) => o.value));
|
|
266
|
+
schema = schema.refine((arr) => arr.every((val) => validValues.has(val)), {
|
|
267
|
+
message: customError || "Please select valid options",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return schema;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildFileSchema(field: FormField): ZodTypeAny {
|
|
275
|
+
const rules = field.validation_rules;
|
|
276
|
+
const customError = rules?.custom_error;
|
|
277
|
+
|
|
278
|
+
let schema = z.instanceof(File).or(z.instanceof(FileList));
|
|
279
|
+
|
|
280
|
+
// Validate file type
|
|
281
|
+
if (rules?.allowed_types && rules.allowed_types.length > 0) {
|
|
282
|
+
schema = schema.refine(
|
|
283
|
+
(file) => {
|
|
284
|
+
if (!file) return true;
|
|
285
|
+
const files = file instanceof FileList ? Array.from(file) : [file];
|
|
286
|
+
return files.every((f) =>
|
|
287
|
+
rules.allowed_types!.some(
|
|
288
|
+
(type) =>
|
|
289
|
+
f.type === type ||
|
|
290
|
+
f.name.toLowerCase().endsWith(`.${type.replace(".", "")}`),
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
message:
|
|
296
|
+
customError ||
|
|
297
|
+
`File must be one of: ${rules.allowed_types.join(", ")}`,
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Validate file size
|
|
303
|
+
if (rules?.max_file_size) {
|
|
304
|
+
const maxBytes = rules.max_file_size * 1024 * 1024; // MB to bytes
|
|
305
|
+
schema = schema.refine(
|
|
306
|
+
(file) => {
|
|
307
|
+
if (!file) return true;
|
|
308
|
+
const files = file instanceof FileList ? Array.from(file) : [file];
|
|
309
|
+
return files.every((f) => f.size <= maxBytes);
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
message:
|
|
313
|
+
customError || `File must be smaller than ${rules.max_file_size}MB`,
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!field.required) {
|
|
319
|
+
return schema.optional().nullable();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return schema;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isNonInputField(type: FieldType): boolean {
|
|
326
|
+
return ["html", "divider", "recaptcha"].includes(type);
|
|
327
|
+
}
|