@object-ui/plugin-form 3.0.3 → 3.1.1
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/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +11 -0
- package/dist/index.js +1217 -861
- package/dist/index.umd.cjs +2 -2
- package/dist/plugin-form/src/EmbeddableForm.d.ts +49 -0
- package/dist/plugin-form/src/FormAnalytics.d.ts +38 -0
- package/dist/plugin-form/src/FormSection.d.ts +6 -0
- package/dist/plugin-form/src/autoLayout.d.ts +60 -0
- package/dist/plugin-form/src/index.d.ts +5 -0
- package/package.json +8 -8
- package/src/DrawerForm.tsx +49 -24
- package/src/EmbeddableForm.tsx +240 -0
- package/src/FormAnalytics.tsx +209 -0
- package/src/FormSection.tsx +9 -1
- package/src/ModalForm.tsx +145 -45
- package/src/ObjectForm.tsx +12 -4
- package/src/SplitForm.tsx +3 -2
- package/src/TabbedForm.tsx +3 -2
- package/src/WizardForm.tsx +3 -2
- package/src/__tests__/EmbeddableFormPrefill.test.tsx +186 -0
- package/src/__tests__/MobileUX.test.tsx +433 -0
- package/src/__tests__/NewVariants.test.tsx +196 -0
- package/src/__tests__/autoLayout.test.ts +342 -0
- package/src/autoLayout.ts +168 -0
- package/src/index.tsx +52 -0
package/src/ModalForm.tsx
CHANGED
|
@@ -13,19 +13,23 @@
|
|
|
13
13
|
* Aligns with @objectstack/spec FormView type: 'modal'
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
|
16
|
+
import React, { useState, useCallback, useEffect, useMemo, useId } from 'react';
|
|
17
17
|
import type { FormField, DataSource } from '@object-ui/types';
|
|
18
18
|
import {
|
|
19
19
|
Dialog,
|
|
20
|
-
|
|
20
|
+
MobileDialogContent,
|
|
21
21
|
DialogHeader,
|
|
22
22
|
DialogTitle,
|
|
23
23
|
DialogDescription,
|
|
24
|
+
Skeleton,
|
|
25
|
+
Button,
|
|
24
26
|
cn,
|
|
25
27
|
} from '@object-ui/components';
|
|
28
|
+
import { Loader2 } from 'lucide-react';
|
|
26
29
|
import { FormSection } from './FormSection';
|
|
27
|
-
import { SchemaRenderer } from '@object-ui/react';
|
|
30
|
+
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
|
|
28
31
|
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
|
|
32
|
+
import { applyAutoLayout, inferModalSize } from './autoLayout';
|
|
29
33
|
|
|
30
34
|
export interface ModalFormSectionConfig {
|
|
31
35
|
name?: string;
|
|
@@ -101,19 +105,58 @@ const modalSizeClasses: Record<string, string> = {
|
|
|
101
105
|
full: 'max-w-[95vw] w-full',
|
|
102
106
|
};
|
|
103
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Container-query-based grid classes for form field layout.
|
|
110
|
+
* Uses @container / @md: / @2xl: / @4xl: variants so that the grid
|
|
111
|
+
* responds to the modal's actual width instead of the viewport,
|
|
112
|
+
* ensuring single-column on narrow mobile modals regardless of viewport size.
|
|
113
|
+
*/
|
|
114
|
+
const CONTAINER_GRID_COLS: Record<number, string | undefined> = {
|
|
115
|
+
1: undefined, // let the form renderer use its default (space-y-4)
|
|
116
|
+
2: 'grid gap-4 grid-cols-1 @md:grid-cols-2',
|
|
117
|
+
3: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3',
|
|
118
|
+
4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4',
|
|
119
|
+
};
|
|
120
|
+
|
|
104
121
|
export const ModalForm: React.FC<ModalFormProps> = ({
|
|
105
122
|
schema,
|
|
106
123
|
dataSource,
|
|
107
124
|
className,
|
|
108
125
|
}) => {
|
|
126
|
+
const { fieldLabel } = useSafeFieldLabel();
|
|
109
127
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
110
128
|
const [formFields, setFormFields] = useState<FormField[]>([]);
|
|
111
129
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
112
130
|
const [loading, setLoading] = useState(true);
|
|
113
131
|
const [error, setError] = useState<Error | null>(null);
|
|
132
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
114
133
|
|
|
115
134
|
const isOpen = schema.open !== false;
|
|
116
|
-
|
|
135
|
+
|
|
136
|
+
// Stable form id for linking the external submit button to the form element
|
|
137
|
+
const formId = useId();
|
|
138
|
+
|
|
139
|
+
// Compute auto-layout for flat fields (no sections) to determine inferred columns
|
|
140
|
+
const autoLayoutResult = useMemo(() => {
|
|
141
|
+
if (schema.sections?.length || schema.customFields?.length) return null;
|
|
142
|
+
return applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
|
|
143
|
+
}, [formFields, objectSchema, schema.columns, schema.mode, schema.sections, schema.customFields]);
|
|
144
|
+
|
|
145
|
+
// Auto-upgrade modal size when auto-layout infers multi-column and user hasn't set modalSize
|
|
146
|
+
const effectiveModalSize = useMemo(() => {
|
|
147
|
+
if (schema.modalSize) return schema.modalSize;
|
|
148
|
+
if (autoLayoutResult?.columns && autoLayoutResult.columns > 1) {
|
|
149
|
+
return inferModalSize(autoLayoutResult.columns);
|
|
150
|
+
}
|
|
151
|
+
// Auto-upgrade for sections: use the max columns across all sections
|
|
152
|
+
if (schema.sections?.length) {
|
|
153
|
+
const maxCols = Math.max(...schema.sections.map(s => Number(s.columns) || 1));
|
|
154
|
+
if (maxCols > 1) return inferModalSize(maxCols);
|
|
155
|
+
}
|
|
156
|
+
return 'default';
|
|
157
|
+
}, [schema.modalSize, autoLayoutResult, schema.sections]);
|
|
158
|
+
|
|
159
|
+
const sizeClass = modalSizeClasses[effectiveModalSize] || modalSizeClasses.default;
|
|
117
160
|
|
|
118
161
|
// Fetch object schema
|
|
119
162
|
useEffect(() => {
|
|
@@ -176,7 +219,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
|
|
|
176
219
|
const field = objectSchema.fields[fieldName];
|
|
177
220
|
fields.push({
|
|
178
221
|
name: fieldName,
|
|
179
|
-
label: field.label || fieldName,
|
|
222
|
+
label: fieldLabel(schema.objectName, fieldName, field.label || fieldName),
|
|
180
223
|
type: mapFieldTypeToFormType(field.type),
|
|
181
224
|
required: field.required || false,
|
|
182
225
|
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
@@ -227,7 +270,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
|
|
|
227
270
|
|
|
228
271
|
generated.push({
|
|
229
272
|
name,
|
|
230
|
-
label: field.label || name,
|
|
273
|
+
label: fieldLabel(schema.objectName, name, field.label || name),
|
|
231
274
|
type: mapFieldTypeToFormType(field.type),
|
|
232
275
|
required: field.required || false,
|
|
233
276
|
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
@@ -246,16 +289,17 @@ export const ModalForm: React.FC<ModalFormProps> = ({
|
|
|
246
289
|
|
|
247
290
|
// Handle form submission
|
|
248
291
|
const handleSubmit = useCallback(async (data: Record<string, any>) => {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
292
|
+
setIsSubmitting(true);
|
|
293
|
+
try {
|
|
294
|
+
if (!dataSource) {
|
|
295
|
+
if (schema.onSuccess) {
|
|
296
|
+
await schema.onSuccess(data);
|
|
297
|
+
}
|
|
298
|
+
// Close modal on success
|
|
299
|
+
schema.onOpenChange?.(false);
|
|
300
|
+
return data;
|
|
252
301
|
}
|
|
253
|
-
// Close modal on success
|
|
254
|
-
schema.onOpenChange?.(false);
|
|
255
|
-
return data;
|
|
256
|
-
}
|
|
257
302
|
|
|
258
|
-
try {
|
|
259
303
|
let result;
|
|
260
304
|
if (schema.mode === 'create') {
|
|
261
305
|
result = await dataSource.create(schema.objectName, data);
|
|
@@ -273,6 +317,8 @@ export const ModalForm: React.FC<ModalFormProps> = ({
|
|
|
273
317
|
schema.onError(err as Error);
|
|
274
318
|
}
|
|
275
319
|
throw err;
|
|
320
|
+
} finally {
|
|
321
|
+
setIsSubmitting(false);
|
|
276
322
|
}
|
|
277
323
|
}, [schema, dataSource]);
|
|
278
324
|
|
|
@@ -290,16 +336,24 @@ export const ModalForm: React.FC<ModalFormProps> = ({
|
|
|
290
336
|
: 'vertical';
|
|
291
337
|
|
|
292
338
|
// Build base form schema
|
|
339
|
+
// Actions are hidden inside the form renderer — we render them in a sticky footer instead
|
|
340
|
+
const showSubmit = schema.showSubmit !== false && schema.mode !== 'view';
|
|
341
|
+
const showCancel = schema.showCancel !== false;
|
|
342
|
+
const submitLabel = schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update');
|
|
343
|
+
const cancelLabel = schema.cancelText || 'Cancel';
|
|
344
|
+
|
|
293
345
|
const baseFormSchema = {
|
|
294
346
|
type: 'form' as const,
|
|
295
347
|
layout: formLayout,
|
|
296
348
|
defaultValues: formData,
|
|
297
|
-
submitLabel
|
|
298
|
-
cancelLabel
|
|
299
|
-
showSubmit
|
|
300
|
-
showCancel
|
|
349
|
+
submitLabel,
|
|
350
|
+
cancelLabel,
|
|
351
|
+
showSubmit,
|
|
352
|
+
showCancel,
|
|
301
353
|
onSubmit: handleSubmit,
|
|
302
354
|
onCancel: handleCancel,
|
|
355
|
+
showActions: false, // Hide actions — rendered in sticky footer
|
|
356
|
+
id: formId, // Link external submit button via form attribute
|
|
303
357
|
};
|
|
304
358
|
|
|
305
359
|
const renderContent = () => {
|
|
@@ -314,9 +368,13 @@ export const ModalForm: React.FC<ModalFormProps> = ({
|
|
|
314
368
|
|
|
315
369
|
if (loading) {
|
|
316
370
|
return (
|
|
317
|
-
<div className="
|
|
318
|
-
|
|
319
|
-
|
|
371
|
+
<div className="space-y-4" data-testid="modal-form-skeleton">
|
|
372
|
+
{[1, 2, 3].map((i) => (
|
|
373
|
+
<div key={i} className="space-y-2">
|
|
374
|
+
<Skeleton className="h-4 w-24" />
|
|
375
|
+
<Skeleton className="h-10 w-full" />
|
|
376
|
+
</div>
|
|
377
|
+
))}
|
|
320
378
|
</div>
|
|
321
379
|
);
|
|
322
380
|
}
|
|
@@ -325,53 +383,95 @@ export const ModalForm: React.FC<ModalFormProps> = ({
|
|
|
325
383
|
if (schema.sections?.length) {
|
|
326
384
|
return (
|
|
327
385
|
<div className="space-y-6">
|
|
328
|
-
{schema.sections.map((section, index) =>
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
386
|
+
{schema.sections.map((section, index) => {
|
|
387
|
+
const sectionCols = section.columns || 1;
|
|
388
|
+
return (
|
|
389
|
+
<FormSection
|
|
390
|
+
key={section.name || section.label || index}
|
|
391
|
+
label={section.label}
|
|
392
|
+
description={section.description}
|
|
393
|
+
columns={sectionCols}
|
|
394
|
+
gridClassName={CONTAINER_GRID_COLS[sectionCols]}
|
|
395
|
+
>
|
|
396
|
+
<SchemaRenderer
|
|
397
|
+
schema={{
|
|
398
|
+
...baseFormSchema,
|
|
399
|
+
fields: buildSectionFields(section),
|
|
400
|
+
// Actions are in the sticky footer, not inside sections
|
|
401
|
+
}}
|
|
402
|
+
/>
|
|
403
|
+
</FormSection>
|
|
404
|
+
);
|
|
405
|
+
})}
|
|
345
406
|
</div>
|
|
346
407
|
);
|
|
347
408
|
}
|
|
348
409
|
|
|
349
|
-
//
|
|
410
|
+
// Reuse pre-computed auto-layout result for flat fields
|
|
411
|
+
const layoutResult = autoLayoutResult ?? applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
|
|
412
|
+
|
|
413
|
+
// Flat fields layout — use container-query grid classes so the form
|
|
414
|
+
// responds to the modal width, not the viewport width.
|
|
415
|
+
const containerFieldClass = CONTAINER_GRID_COLS[layoutResult.columns || 1];
|
|
416
|
+
|
|
350
417
|
return (
|
|
351
418
|
<SchemaRenderer
|
|
352
419
|
schema={{
|
|
353
420
|
...baseFormSchema,
|
|
354
|
-
fields:
|
|
355
|
-
columns:
|
|
421
|
+
fields: layoutResult.fields,
|
|
422
|
+
columns: layoutResult.columns,
|
|
423
|
+
...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}),
|
|
356
424
|
}}
|
|
357
425
|
/>
|
|
358
426
|
);
|
|
359
427
|
};
|
|
360
428
|
|
|
429
|
+
const hasFooter = !loading && !error && (showSubmit || showCancel);
|
|
430
|
+
|
|
361
431
|
return (
|
|
362
432
|
<Dialog open={isOpen} onOpenChange={schema.onOpenChange}>
|
|
363
|
-
<
|
|
433
|
+
<MobileDialogContent className={cn(sizeClass, 'flex flex-col h-[100dvh] sm:h-auto sm:max-h-[90vh] overflow-hidden p-0', className, schema.className)}>
|
|
364
434
|
{(schema.title || schema.description) && (
|
|
365
|
-
<DialogHeader>
|
|
435
|
+
<DialogHeader className="shrink-0 px-4 pt-4 sm:px-6 sm:pt-6 pb-2 border-b">
|
|
366
436
|
{schema.title && <DialogTitle>{schema.title}</DialogTitle>}
|
|
367
437
|
{schema.description && <DialogDescription>{schema.description}</DialogDescription>}
|
|
368
438
|
</DialogHeader>
|
|
369
439
|
)}
|
|
370
440
|
|
|
371
|
-
<div className="py-
|
|
441
|
+
<div className="@container flex-1 overflow-y-auto px-4 sm:px-6 py-4">
|
|
372
442
|
{renderContent()}
|
|
373
443
|
</div>
|
|
374
|
-
|
|
444
|
+
|
|
445
|
+
{/* Sticky footer — always visible action buttons */}
|
|
446
|
+
{hasFooter && (
|
|
447
|
+
<div className="shrink-0 border-t px-4 sm:px-6 py-3 bg-background" data-testid="modal-form-footer">
|
|
448
|
+
<div className="flex flex-col sm:flex-row gap-2 sm:justify-end">
|
|
449
|
+
{showCancel && (
|
|
450
|
+
<Button
|
|
451
|
+
type="button"
|
|
452
|
+
variant="outline"
|
|
453
|
+
onClick={handleCancel}
|
|
454
|
+
disabled={isSubmitting}
|
|
455
|
+
className="w-full sm:w-auto"
|
|
456
|
+
>
|
|
457
|
+
{cancelLabel}
|
|
458
|
+
</Button>
|
|
459
|
+
)}
|
|
460
|
+
{showSubmit && (
|
|
461
|
+
<Button
|
|
462
|
+
type="submit"
|
|
463
|
+
form={formId}
|
|
464
|
+
disabled={isSubmitting}
|
|
465
|
+
className="w-full sm:w-auto"
|
|
466
|
+
>
|
|
467
|
+
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
468
|
+
{submitLabel}
|
|
469
|
+
</Button>
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
)}
|
|
474
|
+
</MobileDialogContent>
|
|
375
475
|
</Dialog>
|
|
376
476
|
);
|
|
377
477
|
};
|
package/src/ObjectForm.tsx
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import React, { useEffect, useState, useCallback } from 'react';
|
|
17
17
|
import type { ObjectFormSchema, FormField, FormSchema, DataSource } from '@object-ui/types';
|
|
18
|
-
import { SchemaRenderer } from '@object-ui/react';
|
|
18
|
+
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
|
|
19
19
|
import { mapFieldTypeToFormType, buildValidationRules, evaluateCondition, formatFileSize } from '@object-ui/fields';
|
|
20
20
|
import { TabbedForm } from './TabbedForm';
|
|
21
21
|
import { WizardForm } from './WizardForm';
|
|
@@ -23,6 +23,7 @@ import { SplitForm } from './SplitForm';
|
|
|
23
23
|
import { DrawerForm } from './DrawerForm';
|
|
24
24
|
import { ModalForm } from './ModalForm';
|
|
25
25
|
import { FormSection } from './FormSection';
|
|
26
|
+
import { applyAutoLayout } from './autoLayout';
|
|
26
27
|
|
|
27
28
|
export interface ObjectFormProps {
|
|
28
29
|
/**
|
|
@@ -201,6 +202,7 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
201
202
|
schema,
|
|
202
203
|
dataSource,
|
|
203
204
|
}) => {
|
|
205
|
+
const { fieldLabel } = useSafeFieldLabel();
|
|
204
206
|
|
|
205
207
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
206
208
|
const [formFields, setFormFields] = useState<FormField[]>([]);
|
|
@@ -332,7 +334,7 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
332
334
|
// Auto-generate field from schema
|
|
333
335
|
const formField: FormField = {
|
|
334
336
|
name: name,
|
|
335
|
-
label: field.label || fieldName,
|
|
337
|
+
label: fieldLabel(schema.objectName, name, field.label || fieldName),
|
|
336
338
|
type: mapFieldTypeToFormType(field.type),
|
|
337
339
|
required: field.required || false,
|
|
338
340
|
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
@@ -575,12 +577,18 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
575
577
|
);
|
|
576
578
|
}
|
|
577
579
|
|
|
580
|
+
// Apply auto-layout: infer columns and colSpan when not explicitly configured
|
|
581
|
+
const hasSections = schema.sections?.length;
|
|
582
|
+
const autoLayoutResult = !hasSections
|
|
583
|
+
? applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode)
|
|
584
|
+
: { fields: formFields, columns: schema.columns };
|
|
585
|
+
|
|
578
586
|
// Default flat form (no sections)
|
|
579
587
|
const formSchema: FormSchema = {
|
|
580
588
|
type: 'form',
|
|
581
|
-
fields:
|
|
589
|
+
fields: autoLayoutResult.fields,
|
|
582
590
|
layout: formLayout,
|
|
583
|
-
columns:
|
|
591
|
+
columns: autoLayoutResult.columns,
|
|
584
592
|
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
|
|
585
593
|
cancelLabel: schema.cancelText,
|
|
586
594
|
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
|
package/src/SplitForm.tsx
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
cn,
|
|
24
24
|
} from '@object-ui/components';
|
|
25
25
|
import { FormSection } from './FormSection';
|
|
26
|
-
import { SchemaRenderer } from '@object-ui/react';
|
|
26
|
+
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
|
|
27
27
|
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
|
|
28
28
|
|
|
29
29
|
export interface SplitFormSectionConfig {
|
|
@@ -85,6 +85,7 @@ export const SplitForm: React.FC<SplitFormProps> = ({
|
|
|
85
85
|
dataSource,
|
|
86
86
|
className,
|
|
87
87
|
}) => {
|
|
88
|
+
const { fieldLabel } = useSafeFieldLabel();
|
|
88
89
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
89
90
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
90
91
|
const [loading, setLoading] = useState(true);
|
|
@@ -151,7 +152,7 @@ export const SplitForm: React.FC<SplitFormProps> = ({
|
|
|
151
152
|
const field = objectSchema.fields[fieldName];
|
|
152
153
|
fields.push({
|
|
153
154
|
name: fieldName,
|
|
154
|
-
label: field.label || fieldName,
|
|
155
|
+
label: fieldLabel(schema.objectName, fieldName, field.label || fieldName),
|
|
155
156
|
type: mapFieldTypeToFormType(field.type),
|
|
156
157
|
required: field.required || false,
|
|
157
158
|
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
package/src/TabbedForm.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import React, { useState, useCallback } from 'react';
|
|
|
17
17
|
import type { FormField, DataSource } from '@object-ui/types';
|
|
18
18
|
import { Tabs, TabsContent, TabsList, TabsTrigger, cn } from '@object-ui/components';
|
|
19
19
|
import { FormSection } from './FormSection';
|
|
20
|
-
import { SchemaRenderer } from '@object-ui/react';
|
|
20
|
+
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
|
|
21
21
|
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
|
|
22
22
|
|
|
23
23
|
export interface FormSectionConfig {
|
|
@@ -166,6 +166,7 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
|
|
|
166
166
|
dataSource,
|
|
167
167
|
className,
|
|
168
168
|
}) => {
|
|
169
|
+
const { fieldLabel } = useSafeFieldLabel();
|
|
169
170
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
170
171
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
171
172
|
const [loading, setLoading] = useState(true);
|
|
@@ -232,7 +233,7 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
|
|
|
232
233
|
const field = objectSchema.fields[fieldName];
|
|
233
234
|
fields.push({
|
|
234
235
|
name: fieldName,
|
|
235
|
-
label: field.label || fieldName,
|
|
236
|
+
label: fieldLabel(schema.objectName, fieldName, field.label || fieldName),
|
|
236
237
|
type: mapFieldTypeToFormType(field.type),
|
|
237
238
|
required: field.required || false,
|
|
238
239
|
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
package/src/WizardForm.tsx
CHANGED
|
@@ -18,7 +18,7 @@ import type { FormField, DataSource } from '@object-ui/types';
|
|
|
18
18
|
import { Button, cn } from '@object-ui/components';
|
|
19
19
|
import { Check, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
20
20
|
import { FormSection } from './FormSection';
|
|
21
|
-
import { SchemaRenderer } from '@object-ui/react';
|
|
21
|
+
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
|
|
22
22
|
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
|
|
23
23
|
import type { FormSectionConfig } from './TabbedForm';
|
|
24
24
|
|
|
@@ -153,6 +153,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({
|
|
|
153
153
|
dataSource,
|
|
154
154
|
className,
|
|
155
155
|
}) => {
|
|
156
|
+
const { fieldLabel } = useSafeFieldLabel();
|
|
156
157
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
157
158
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
158
159
|
const [loading, setLoading] = useState(true);
|
|
@@ -221,7 +222,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({
|
|
|
221
222
|
const field = objectSchema.fields[fieldName];
|
|
222
223
|
fields.push({
|
|
223
224
|
name: fieldName,
|
|
224
|
-
label: field.label || fieldName,
|
|
225
|
+
label: fieldLabel(schema.objectName, fieldName, field.label || fieldName),
|
|
225
226
|
type: mapFieldTypeToFormType(field.type),
|
|
226
227
|
required: field.required || false,
|
|
227
228
|
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
|
|
13
|
+
// Capture the initialData passed to ObjectForm
|
|
14
|
+
let capturedSchema: any = null;
|
|
15
|
+
let capturedOnSuccess: ((data: any) => void) | null = null;
|
|
16
|
+
|
|
17
|
+
vi.mock('../ObjectForm', () => ({
|
|
18
|
+
ObjectForm: ({ schema, dataSource }: any) => {
|
|
19
|
+
capturedSchema = schema;
|
|
20
|
+
capturedOnSuccess = schema?.onSuccess ?? null;
|
|
21
|
+
return (
|
|
22
|
+
<div>
|
|
23
|
+
<div data-testid="object-form">
|
|
24
|
+
{schema?.initialData && (
|
|
25
|
+
<div data-testid="initial-data">
|
|
26
|
+
{JSON.stringify(schema.initialData)}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
<button
|
|
31
|
+
data-testid="mock-submit"
|
|
32
|
+
onClick={() => schema?.onSuccess?.({ name: 'submitted' })}
|
|
33
|
+
>
|
|
34
|
+
Submit
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock('@object-ui/types', () => ({
|
|
42
|
+
// DataSource type is only used for typing, provide empty default
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import { EmbeddableForm } from '../EmbeddableForm';
|
|
46
|
+
import type { EmbeddableFormConfig } from '../EmbeddableForm';
|
|
47
|
+
|
|
48
|
+
const baseConfig: EmbeddableFormConfig = {
|
|
49
|
+
formId: 'test-form',
|
|
50
|
+
objectName: 'contacts',
|
|
51
|
+
title: 'Test Form',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe('EmbeddableForm - URL Prefill', () => {
|
|
55
|
+
const originalLocation = window.location;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
capturedSchema = null;
|
|
59
|
+
capturedOnSuccess = null;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
// Restore window.location
|
|
64
|
+
Object.defineProperty(window, 'location', {
|
|
65
|
+
writable: true,
|
|
66
|
+
value: originalLocation,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reads URL search params and passes them as initialData', () => {
|
|
71
|
+
Object.defineProperty(window, 'location', {
|
|
72
|
+
writable: true,
|
|
73
|
+
value: { ...originalLocation, search: '?name=John&email=john@test.com' },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
render(<EmbeddableForm config={baseConfig} />);
|
|
77
|
+
|
|
78
|
+
expect(capturedSchema).not.toBeNull();
|
|
79
|
+
expect(capturedSchema.initialData).toEqual({
|
|
80
|
+
name: 'John',
|
|
81
|
+
email: 'john@test.com',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('passes explicit prefillParams as initialData', () => {
|
|
86
|
+
Object.defineProperty(window, 'location', {
|
|
87
|
+
writable: true,
|
|
88
|
+
value: { ...originalLocation, search: '' },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
render(
|
|
92
|
+
<EmbeddableForm
|
|
93
|
+
config={baseConfig}
|
|
94
|
+
prefillParams={{ company: 'Acme', role: 'Admin' }}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(capturedSchema.initialData).toEqual({
|
|
99
|
+
company: 'Acme',
|
|
100
|
+
role: 'Admin',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('explicit prefillParams override URL params for the same field', () => {
|
|
105
|
+
Object.defineProperty(window, 'location', {
|
|
106
|
+
writable: true,
|
|
107
|
+
value: { ...originalLocation, search: '?name=URLName&email=url@test.com' },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
render(
|
|
111
|
+
<EmbeddableForm
|
|
112
|
+
config={baseConfig}
|
|
113
|
+
prefillParams={{ name: 'ExplicitName' }}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// name should come from prefillParams, email should come from URL
|
|
118
|
+
expect(capturedSchema.initialData).toEqual({
|
|
119
|
+
name: 'ExplicitName',
|
|
120
|
+
email: 'url@test.com',
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('passes undefined initialData when no params are provided', () => {
|
|
125
|
+
Object.defineProperty(window, 'location', {
|
|
126
|
+
writable: true,
|
|
127
|
+
value: { ...originalLocation, search: '' },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
render(<EmbeddableForm config={baseConfig} />);
|
|
131
|
+
|
|
132
|
+
expect(capturedSchema.initialData).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('shows the thank-you page after successful submission', async () => {
|
|
136
|
+
Object.defineProperty(window, 'location', {
|
|
137
|
+
writable: true,
|
|
138
|
+
value: { ...originalLocation, search: '' },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
render(
|
|
142
|
+
<EmbeddableForm
|
|
143
|
+
config={{
|
|
144
|
+
...baseConfig,
|
|
145
|
+
thankYouPage: {
|
|
146
|
+
title: 'All Done!',
|
|
147
|
+
message: 'We got your response.',
|
|
148
|
+
},
|
|
149
|
+
}}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Form should be visible initially
|
|
154
|
+
expect(screen.getByText('Test Form')).toBeInTheDocument();
|
|
155
|
+
|
|
156
|
+
// Click mock submit to trigger onSuccess
|
|
157
|
+
const submitBtn = screen.getByTestId('mock-submit');
|
|
158
|
+
fireEvent.click(submitBtn);
|
|
159
|
+
|
|
160
|
+
// Thank-you page should appear
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
expect(screen.getByText('All Done!')).toBeInTheDocument();
|
|
163
|
+
expect(screen.getByText('We got your response.')).toBeInTheDocument();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Form title should no longer be visible
|
|
167
|
+
expect(screen.queryByText('Test Form')).not.toBeInTheDocument();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('shows default thank-you message when no custom thankYouPage is configured', async () => {
|
|
171
|
+
Object.defineProperty(window, 'location', {
|
|
172
|
+
writable: true,
|
|
173
|
+
value: { ...originalLocation, search: '' },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
render(<EmbeddableForm config={baseConfig} />);
|
|
177
|
+
|
|
178
|
+
const submitBtn = screen.getByTestId('mock-submit');
|
|
179
|
+
fireEvent.click(submitBtn);
|
|
180
|
+
|
|
181
|
+
await waitFor(() => {
|
|
182
|
+
expect(screen.getByText('Thank You!')).toBeInTheDocument();
|
|
183
|
+
expect(screen.getByText('Your submission has been received successfully.')).toBeInTheDocument();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|