@k3-universe/react-kit 0.0.26 → 0.0.27
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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15979 -15536
- package/dist/kit/builder/form/components/FormBuilder.d.ts.map +1 -1
- package/dist/kit/builder/form/components/FormBuilderField.d.ts.map +1 -1
- package/dist/kit/builder/form/hooks/useFormBuilder.d.ts +15 -0
- package/dist/kit/builder/form/hooks/useFormBuilder.d.ts.map +1 -0
- package/dist/kit/builder/form/index.d.ts +1 -0
- package/dist/kit/builder/form/index.d.ts.map +1 -1
- package/dist/kit/builder/form/types.d.ts +1 -1
- package/dist/kit/builder/form/types.d.ts.map +1 -1
- package/dist/kit/components/forminfo/FormInfoError.d.ts +12 -0
- package/dist/kit/components/forminfo/FormInfoError.d.ts.map +1 -0
- package/dist/kit/components/forminfo/index.d.ts +2 -0
- package/dist/kit/components/forminfo/index.d.ts.map +1 -0
- package/dist/kit/themes/base.css +1 -1
- package/dist/kit/themes/clean-slate.css +11 -5
- package/dist/kit/themes/default.css +11 -5
- package/dist/kit/themes/minimal-modern.css +11 -5
- package/dist/kit/themes/spotify.css +11 -5
- package/package.json +11 -8
- package/src/index.ts +1 -0
- package/src/kit/builder/form/components/FormBuilder.tsx +51 -332
- package/src/kit/builder/form/components/FormBuilderField.tsx +12 -4
- package/src/kit/builder/form/hooks/useFormBuilder.ts +399 -0
- package/src/kit/builder/form/index.ts +1 -0
- package/src/kit/builder/form/types.ts +8 -1
- package/src/kit/components/forminfo/FormInfoError.tsx +120 -0
- package/src/kit/components/forminfo/index.ts +1 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useForm, type FieldValues, type UseFormReturn } from 'react-hook-form';
|
|
3
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { FormBuilderFieldConfig, FormBuilderSectionConfig } from '../types';
|
|
6
|
+
|
|
7
|
+
export interface UseFormBuilderOptions<TFieldValues extends FieldValues = FieldValues> {
|
|
8
|
+
sections: FormBuilderSectionConfig<TFieldValues>[];
|
|
9
|
+
defaultValues?: Partial<TFieldValues>;
|
|
10
|
+
schema?: z.ZodType<TFieldValues>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UseFormBuilderReturn<TFieldValues extends FieldValues = FieldValues> {
|
|
14
|
+
form: UseFormReturn<TFieldValues>;
|
|
15
|
+
sections: FormBuilderSectionConfig<TFieldValues>[];
|
|
16
|
+
schema: z.ZodType<unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useFormBuilder<TFieldValues extends FieldValues = FieldValues>(
|
|
20
|
+
options: UseFormBuilderOptions<TFieldValues>
|
|
21
|
+
): UseFormBuilderReturn<TFieldValues> {
|
|
22
|
+
const { sections, defaultValues, schema: providedSchema } = options;
|
|
23
|
+
|
|
24
|
+
// Generate schema from field configs if not provided
|
|
25
|
+
const generatedSchema = useMemo(() => {
|
|
26
|
+
if (providedSchema) return providedSchema;
|
|
27
|
+
|
|
28
|
+
const generateFieldSchema = (
|
|
29
|
+
field: FormBuilderFieldConfig<TFieldValues, string>
|
|
30
|
+
): z.ZodType<unknown> => {
|
|
31
|
+
if (field.validation && field.validation instanceof z.ZodType) {
|
|
32
|
+
return field.validation;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle validation object format
|
|
36
|
+
if (
|
|
37
|
+
field.validation &&
|
|
38
|
+
typeof field.validation === 'object' &&
|
|
39
|
+
!(field.validation instanceof z.ZodType)
|
|
40
|
+
) {
|
|
41
|
+
const validationObj = field.validation as Record<string, unknown>;
|
|
42
|
+
let baseSchema: z.ZodType<unknown>;
|
|
43
|
+
|
|
44
|
+
switch (field.type) {
|
|
45
|
+
case 'email':
|
|
46
|
+
baseSchema = z.string().email(
|
|
47
|
+
(validationObj.email as { message?: string })?.message || 'Invalid email address'
|
|
48
|
+
);
|
|
49
|
+
break;
|
|
50
|
+
case 'number':
|
|
51
|
+
baseSchema = z.number();
|
|
52
|
+
break;
|
|
53
|
+
case 'checkbox':
|
|
54
|
+
case 'switch':
|
|
55
|
+
baseSchema = z.boolean();
|
|
56
|
+
break;
|
|
57
|
+
case 'select':
|
|
58
|
+
case 'radio':
|
|
59
|
+
case 'autocomplete':
|
|
60
|
+
// Check if field supports multiple values
|
|
61
|
+
if ((field as { multiple?: boolean }).multiple) {
|
|
62
|
+
// Multiple: array of string/number/boolean
|
|
63
|
+
baseSchema = z.array(z.union([z.string(), z.number(), z.boolean()]));
|
|
64
|
+
} else {
|
|
65
|
+
// Single: string, number, boolean, or null (when empty)
|
|
66
|
+
baseSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
case 'file':
|
|
70
|
+
baseSchema = z.array(z.unknown());
|
|
71
|
+
break;
|
|
72
|
+
case 'date_picker':
|
|
73
|
+
case 'date':
|
|
74
|
+
case 'time':
|
|
75
|
+
case 'date_time':
|
|
76
|
+
case 'month':
|
|
77
|
+
baseSchema = z.date();
|
|
78
|
+
break;
|
|
79
|
+
case 'date_range':
|
|
80
|
+
case 'time_range':
|
|
81
|
+
case 'date_time_range':
|
|
82
|
+
case 'month_range':
|
|
83
|
+
baseSchema = z.object({
|
|
84
|
+
from: z.date().optional().nullable(),
|
|
85
|
+
to: z.date().optional().nullable(),
|
|
86
|
+
}).nullable();
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
baseSchema = z.string();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (validationObj.min && baseSchema instanceof z.ZodNumber) {
|
|
93
|
+
baseSchema = baseSchema.min(
|
|
94
|
+
(validationObj.min as { value: number }).value,
|
|
95
|
+
(validationObj.min as { message?: string }).message
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (validationObj.max && baseSchema instanceof z.ZodNumber) {
|
|
99
|
+
baseSchema = baseSchema.max(
|
|
100
|
+
(validationObj.max as { value: number }).value,
|
|
101
|
+
(validationObj.max as { message?: string }).message
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (validationObj.minLength && baseSchema instanceof z.ZodString) {
|
|
105
|
+
baseSchema = baseSchema.min(
|
|
106
|
+
(validationObj.minLength as { value: number }).value,
|
|
107
|
+
(validationObj.minLength as { message?: string }).message
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (validationObj.maxLength && baseSchema instanceof z.ZodString) {
|
|
111
|
+
baseSchema = baseSchema.max(
|
|
112
|
+
(validationObj.maxLength as { value: number }).value,
|
|
113
|
+
(validationObj.maxLength as { message?: string }).message
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
// Array item count constraints
|
|
117
|
+
if (baseSchema instanceof z.ZodArray) {
|
|
118
|
+
let arr = baseSchema as z.ZodArray<z.ZodTypeAny>;
|
|
119
|
+
if (validationObj.minItems) {
|
|
120
|
+
arr = arr.min(
|
|
121
|
+
(validationObj.minItems as { value: number }).value,
|
|
122
|
+
(validationObj.minItems as { message?: string }).message,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (validationObj.maxItems) {
|
|
126
|
+
arr = arr.max(
|
|
127
|
+
(validationObj.maxItems as { value: number }).value,
|
|
128
|
+
(validationObj.maxItems as { message?: string }).message,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
// If required and file field, enforce at least 1 item when no explicit minItems
|
|
132
|
+
if (field.type === 'file' && field.required && !validationObj.minItems) {
|
|
133
|
+
arr = arr.min(1, `${field.label} requires at least 1 file`);
|
|
134
|
+
}
|
|
135
|
+
baseSchema = arr;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Make optional fields accept undefined/null/empty FIRST (before any refinements)
|
|
139
|
+
if (!field.required) {
|
|
140
|
+
return baseSchema.nullish(); // Accepts null, undefined, and the base type
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// For required fields, add validation
|
|
144
|
+
// For string fields, enforce non-empty when required (only if minLength not already set)
|
|
145
|
+
if (baseSchema instanceof z.ZodString && !validationObj.minLength) {
|
|
146
|
+
baseSchema = baseSchema.min(1, `${field.label || field.name} is required`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// For union fields (select/radio/autocomplete), add refinement for required validation
|
|
150
|
+
if (baseSchema instanceof z.ZodUnion) {
|
|
151
|
+
baseSchema = baseSchema.refine(
|
|
152
|
+
(val) => val !== null && val !== undefined,
|
|
153
|
+
{ message: `${field.label || field.name} is required` }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return baseSchema;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let fieldSchema: z.ZodType<unknown>;
|
|
161
|
+
|
|
162
|
+
const fieldLabel = field.label || field.name;
|
|
163
|
+
|
|
164
|
+
switch (field.type) {
|
|
165
|
+
case 'email':
|
|
166
|
+
fieldSchema = z.string().email('Please enter a valid email address');
|
|
167
|
+
break;
|
|
168
|
+
case 'number':
|
|
169
|
+
fieldSchema = z.number({
|
|
170
|
+
message: `${fieldLabel} must be a number`,
|
|
171
|
+
});
|
|
172
|
+
break;
|
|
173
|
+
case 'checkbox':
|
|
174
|
+
case 'switch':
|
|
175
|
+
fieldSchema = z.boolean({
|
|
176
|
+
message: `${fieldLabel} must be true or false`,
|
|
177
|
+
});
|
|
178
|
+
break;
|
|
179
|
+
case 'select':
|
|
180
|
+
case 'radio':
|
|
181
|
+
case 'autocomplete':
|
|
182
|
+
// Check if field supports multiple values
|
|
183
|
+
if ((field as { multiple?: boolean }).multiple) {
|
|
184
|
+
// Multiple: array of string/number/boolean
|
|
185
|
+
fieldSchema = z.array(z.union([z.string(), z.number(), z.boolean()]));
|
|
186
|
+
} else {
|
|
187
|
+
// Single: string, number, boolean, or null (when empty)
|
|
188
|
+
fieldSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
case 'file': {
|
|
192
|
+
let arr = z.array(z.unknown());
|
|
193
|
+
// If required, ensure at least 1 file
|
|
194
|
+
if (field.required) {
|
|
195
|
+
arr = arr.min(1, `Please select at least 1 file for ${fieldLabel}`);
|
|
196
|
+
}
|
|
197
|
+
fieldSchema = arr;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case 'date_picker':
|
|
201
|
+
case 'date':
|
|
202
|
+
case 'time':
|
|
203
|
+
case 'date_time':
|
|
204
|
+
case 'month':
|
|
205
|
+
fieldSchema = z.date({
|
|
206
|
+
message: `${fieldLabel} must be a valid date`,
|
|
207
|
+
});
|
|
208
|
+
break;
|
|
209
|
+
case 'date_range':
|
|
210
|
+
case 'time_range':
|
|
211
|
+
case 'date_time_range':
|
|
212
|
+
case 'month_range':
|
|
213
|
+
fieldSchema = z.object({
|
|
214
|
+
from: z.date().optional().nullable(),
|
|
215
|
+
to: z.date().optional().nullable(),
|
|
216
|
+
}).nullable();
|
|
217
|
+
break;
|
|
218
|
+
case 'object':
|
|
219
|
+
if (field.fields) {
|
|
220
|
+
const nestedSchema: Record<string, z.ZodType<unknown>> = {};
|
|
221
|
+
for (const subField of field.fields) {
|
|
222
|
+
nestedSchema[subField.name] = generateFieldSchema(subField);
|
|
223
|
+
}
|
|
224
|
+
fieldSchema = z.object(nestedSchema);
|
|
225
|
+
} else {
|
|
226
|
+
fieldSchema = z.object({});
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
case 'array':
|
|
230
|
+
if (field.fields && field.fields.length > 0) {
|
|
231
|
+
const itemSchema: Record<string, z.ZodType<unknown>> = {};
|
|
232
|
+
for (const subField of field.fields) {
|
|
233
|
+
itemSchema[subField.name] = generateFieldSchema(subField);
|
|
234
|
+
}
|
|
235
|
+
fieldSchema = z.array(z.object(itemSchema));
|
|
236
|
+
} else {
|
|
237
|
+
fieldSchema = z.array(z.unknown());
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
case 'custom_field':
|
|
241
|
+
fieldSchema = z.any();
|
|
242
|
+
break;
|
|
243
|
+
default:
|
|
244
|
+
// Default to string for text, textarea, password, etc.
|
|
245
|
+
fieldSchema = z.string();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Make optional fields accept undefined/null/empty FIRST (before any refinements)
|
|
249
|
+
if (!field.required) {
|
|
250
|
+
return fieldSchema.nullish(); // Accepts null, undefined, and the base type
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// For required fields, add validation
|
|
254
|
+
// For string fields, enforce non-empty when required
|
|
255
|
+
if (fieldSchema instanceof z.ZodString) {
|
|
256
|
+
fieldSchema = fieldSchema.min(1, `${fieldLabel} is required`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// For array fields (multiple select/autocomplete), enforce at least 1 item when required
|
|
260
|
+
if (fieldSchema instanceof z.ZodArray) {
|
|
261
|
+
fieldSchema = fieldSchema.min(1, `${fieldLabel} requires at least 1 selection`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// For union fields (single select/radio/autocomplete), add refinement for required validation
|
|
265
|
+
if (fieldSchema instanceof z.ZodUnion) {
|
|
266
|
+
fieldSchema = fieldSchema.refine(
|
|
267
|
+
(val) => val !== null && val !== undefined,
|
|
268
|
+
{ message: `${fieldLabel} is required` }
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return fieldSchema;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const schemaObject: Record<string, z.ZodType<unknown>> = {};
|
|
276
|
+
|
|
277
|
+
const forEachField = (secs: FormBuilderSectionConfig<TFieldValues>[]) => {
|
|
278
|
+
for (const section of secs) {
|
|
279
|
+
// Traverse tabs if present
|
|
280
|
+
if (section.tabs && section.tabs.length > 0) {
|
|
281
|
+
for (const tab of section.tabs) {
|
|
282
|
+
forEachField(tab.sections);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Process fields
|
|
286
|
+
for (const field of section.fields ?? []) {
|
|
287
|
+
schemaObject[field.name] = generateFieldSchema(field);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
forEachField(sections);
|
|
293
|
+
|
|
294
|
+
return z.object(schemaObject) as unknown as z.ZodType<TFieldValues>;
|
|
295
|
+
}, [sections, providedSchema]);
|
|
296
|
+
|
|
297
|
+
// Generate default values from field configs
|
|
298
|
+
const generatedDefaultValues = useMemo(() => {
|
|
299
|
+
const values: Record<string, unknown> = { ...((defaultValues ?? {}) as Record<string, unknown>) };
|
|
300
|
+
|
|
301
|
+
const processFields = (fields: FormBuilderFieldConfig<TFieldValues, string>[]) => {
|
|
302
|
+
for (const field of fields) {
|
|
303
|
+
// Skip if value already exists
|
|
304
|
+
if (values[field.name] !== undefined) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Use explicit defaultValue if provided
|
|
309
|
+
if (field.defaultValue !== undefined) {
|
|
310
|
+
values[field.name] = field.defaultValue;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Set sensible defaults based on field type to avoid undefined
|
|
315
|
+
switch (field.type) {
|
|
316
|
+
case 'text':
|
|
317
|
+
case 'email':
|
|
318
|
+
case 'textarea':
|
|
319
|
+
case 'password':
|
|
320
|
+
case 'select':
|
|
321
|
+
case 'radio':
|
|
322
|
+
values[field.name] = '';
|
|
323
|
+
break;
|
|
324
|
+
case 'number':
|
|
325
|
+
values[field.name] = null;
|
|
326
|
+
break;
|
|
327
|
+
case 'checkbox':
|
|
328
|
+
case 'switch':
|
|
329
|
+
values[field.name] = false;
|
|
330
|
+
break;
|
|
331
|
+
case 'file':
|
|
332
|
+
case 'array':
|
|
333
|
+
values[field.name] = [];
|
|
334
|
+
break;
|
|
335
|
+
case 'object':
|
|
336
|
+
if (field.fields) {
|
|
337
|
+
const nestedValues: Record<string, unknown> = {};
|
|
338
|
+
for (const subField of field.fields) {
|
|
339
|
+
if (subField.defaultValue !== undefined) {
|
|
340
|
+
nestedValues[subField.name] = subField.defaultValue;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
values[field.name] = nestedValues;
|
|
344
|
+
} else {
|
|
345
|
+
values[field.name] = {};
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
case 'date_picker':
|
|
349
|
+
case 'date':
|
|
350
|
+
case 'time':
|
|
351
|
+
case 'date_time':
|
|
352
|
+
case 'month':
|
|
353
|
+
values[field.name] = null;
|
|
354
|
+
break;
|
|
355
|
+
case 'date_range':
|
|
356
|
+
case 'time_range':
|
|
357
|
+
case 'date_time_range':
|
|
358
|
+
case 'month_range':
|
|
359
|
+
values[field.name] = null;
|
|
360
|
+
break;
|
|
361
|
+
case 'autocomplete':
|
|
362
|
+
values[field.name] = field.multiple ? [] : null;
|
|
363
|
+
break;
|
|
364
|
+
default:
|
|
365
|
+
// For unknown types, use empty string as safe default
|
|
366
|
+
values[field.name] = '';
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const forEachSection = (secs: FormBuilderSectionConfig<TFieldValues>[]) => {
|
|
372
|
+
for (const section of secs) {
|
|
373
|
+
if (section.tabs && section.tabs.length > 0) {
|
|
374
|
+
for (const tab of section.tabs) {
|
|
375
|
+
forEachSection(tab.sections);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
processFields(section.fields ?? []);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
forEachSection(sections);
|
|
383
|
+
|
|
384
|
+
return values;
|
|
385
|
+
}, [sections, defaultValues]);
|
|
386
|
+
|
|
387
|
+
// Create form with validation
|
|
388
|
+
const form = useForm<TFieldValues>({
|
|
389
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
390
|
+
resolver: zodResolver(generatedSchema as any) as unknown as import('react-hook-form').Resolver<TFieldValues, any, TFieldValues>,
|
|
391
|
+
defaultValues: generatedDefaultValues as unknown as import('react-hook-form').DefaultValues<TFieldValues>,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
form,
|
|
396
|
+
sections,
|
|
397
|
+
schema: generatedSchema,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
@@ -182,9 +182,15 @@ export interface FormBuilderSectionConfig<TFieldValues extends FieldValues = Fie
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
export interface FormBuilderProps<TFieldValues extends FieldValues = FieldValues> {
|
|
185
|
+
// Accept form from useFormBuilder hook
|
|
186
|
+
form?: UseFormReturn<TFieldValues>
|
|
185
187
|
sections: Array<FormBuilderSectionConfig<TFieldValues>>
|
|
188
|
+
|
|
189
|
+
// Optional overrides (for backward compatibility)
|
|
186
190
|
schema?: z.ZodType<TFieldValues>
|
|
187
191
|
defaultValues?: DeepPartial<TFieldValues> | DefaultValues<TFieldValues> | null
|
|
192
|
+
|
|
193
|
+
// Handlers
|
|
188
194
|
onSubmit: (data: TFieldValues) => void | Promise<void>
|
|
189
195
|
onCancel?: () => void
|
|
190
196
|
onReset?: () => void
|
|
@@ -193,6 +199,8 @@ export interface FormBuilderProps<TFieldValues extends FieldValues = FieldValues
|
|
|
193
199
|
value: unknown,
|
|
194
200
|
allValues: TFieldValues
|
|
195
201
|
) => void
|
|
202
|
+
|
|
203
|
+
// UI customization
|
|
196
204
|
submitLabel?: string
|
|
197
205
|
cancelLabel?: string
|
|
198
206
|
resetLabel?: string
|
|
@@ -203,7 +211,6 @@ export interface FormBuilderProps<TFieldValues extends FieldValues = FieldValues
|
|
|
203
211
|
showActions?: boolean
|
|
204
212
|
customActions?: React.ReactNode
|
|
205
213
|
showActionsSeparator?: boolean
|
|
206
|
-
form?: UseFormReturn<TFieldValues>
|
|
207
214
|
}
|
|
208
215
|
|
|
209
216
|
// Re-export for external consumers that build custom section nodes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { AlertCircle } from 'lucide-react';
|
|
2
|
+
import type { FieldErrors, FieldValues } from 'react-hook-form';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../../shadcn/lib/utils';
|
|
6
|
+
import { Alert, AlertDescription, AlertTitle } from '../../../shadcn/ui/alert';
|
|
7
|
+
|
|
8
|
+
type ErrorMessage = {
|
|
9
|
+
path: string;
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type FormInfoErrorProps<TFieldValues extends FieldValues = FieldValues> = {
|
|
14
|
+
errors: FieldErrors<TFieldValues> | undefined;
|
|
15
|
+
title?: ReactNode;
|
|
16
|
+
description?: ReactNode;
|
|
17
|
+
className?: string;
|
|
18
|
+
showFieldPath?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ignoredKeys = new Set(['message', 'type', 'types', 'ref']);
|
|
22
|
+
|
|
23
|
+
function collectMessages(
|
|
24
|
+
errors: FieldErrors<FieldValues> | undefined,
|
|
25
|
+
parentPath: string[] = []
|
|
26
|
+
): ErrorMessage[] {
|
|
27
|
+
if (!errors) return [];
|
|
28
|
+
|
|
29
|
+
const entries = Array.isArray(errors)
|
|
30
|
+
? errors.map((value, index) => [String(index), value])
|
|
31
|
+
: Object.entries(errors as Record<string, unknown>);
|
|
32
|
+
|
|
33
|
+
const messages: ErrorMessage[] = [];
|
|
34
|
+
|
|
35
|
+
for (const [key, value] of entries) {
|
|
36
|
+
if (!value) continue;
|
|
37
|
+
|
|
38
|
+
const currentPath = typeof key === 'string' && key === 'root'
|
|
39
|
+
? parentPath
|
|
40
|
+
: [...parentPath, String(key)];
|
|
41
|
+
|
|
42
|
+
if (typeof value === 'object') {
|
|
43
|
+
if ('message' in value && value.message) {
|
|
44
|
+
messages.push({
|
|
45
|
+
path: currentPath.filter(Boolean).join('.'),
|
|
46
|
+
message: String(value.message),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if ('types' in value && value.types) {
|
|
51
|
+
for (const msg of Object.values(value.types)) {
|
|
52
|
+
if (!msg) continue;
|
|
53
|
+
messages.push({
|
|
54
|
+
path: currentPath.filter(Boolean).join('.'),
|
|
55
|
+
message: String(msg),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (value && typeof value === 'object') {
|
|
61
|
+
const source = Array.isArray(value)
|
|
62
|
+
? value.map((nestedValue, index) => [String(index), nestedValue])
|
|
63
|
+
: Object.entries(value);
|
|
64
|
+
|
|
65
|
+
const nestedEntries = source.filter(
|
|
66
|
+
([nestedKey]) => !ignoredKeys.has(nestedKey)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (nestedEntries.length > 0) {
|
|
70
|
+
const nested: Record<string, unknown> = Object.fromEntries(nestedEntries);
|
|
71
|
+
messages.push(...collectMessages(nested as FieldErrors<FieldValues>, currentPath));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return messages;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function FormInfoError<TFieldValues extends FieldValues = FieldValues>({
|
|
81
|
+
errors,
|
|
82
|
+
title,
|
|
83
|
+
description,
|
|
84
|
+
className,
|
|
85
|
+
showFieldPath = true,
|
|
86
|
+
}: FormInfoErrorProps<TFieldValues>) {
|
|
87
|
+
const messages = collectMessages(errors as FieldErrors<FieldValues> | undefined);
|
|
88
|
+
|
|
89
|
+
if (messages.length === 0) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Alert variant="destructive" className={cn('gap-2', className)}>
|
|
95
|
+
<AlertCircle className="mt-1" />
|
|
96
|
+
<div className="flex flex-col gap-2">
|
|
97
|
+
<div>
|
|
98
|
+
<AlertTitle>
|
|
99
|
+
{title ?? 'Please review the following issues'}
|
|
100
|
+
</AlertTitle>
|
|
101
|
+
<AlertDescription>
|
|
102
|
+
{description ?? 'Some fields need your attention before continuing.'}
|
|
103
|
+
</AlertDescription>
|
|
104
|
+
</div>
|
|
105
|
+
<ul className="grid gap-1 text-sm text-destructive">
|
|
106
|
+
{messages.map(({ path, message }, index) => (
|
|
107
|
+
<li key={`${path}-${index}`} className="leading-snug">
|
|
108
|
+
{showFieldPath && path ? (
|
|
109
|
+
<span className="font-medium">{path}: </span>
|
|
110
|
+
) : null}
|
|
111
|
+
<span>{message}</span>
|
|
112
|
+
</li>
|
|
113
|
+
))}
|
|
114
|
+
</ul>
|
|
115
|
+
</div>
|
|
116
|
+
</Alert>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default FormInfoError;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FormInfoError';
|