@object-ui/plugin-form 3.3.0 → 3.3.2
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/CHANGELOG.md +21 -0
- package/README.md +21 -1
- package/dist/index.js +109 -66
- package/dist/index.umd.cjs +2 -2
- package/dist/packages/plugin-form/src/DrawerForm.d.ts +2 -0
- package/dist/packages/plugin-form/src/autoLayout.d.ts +11 -4
- package/package.json +42 -10
- package/.turbo/turbo-build.log +0 -32
- package/src/DrawerForm.tsx +0 -410
- package/src/EmbeddableForm.tsx +0 -240
- package/src/FormAnalytics.tsx +0 -209
- package/src/FormSection.tsx +0 -152
- package/src/FormVariants.test.tsx +0 -219
- package/src/ModalForm.tsx +0 -485
- package/src/ObjectForm.msw.test.tsx +0 -156
- package/src/ObjectForm.stories.tsx +0 -85
- package/src/ObjectForm.test.tsx +0 -61
- package/src/ObjectForm.tsx +0 -609
- package/src/SplitForm.tsx +0 -300
- package/src/TabbedForm.tsx +0 -395
- package/src/WizardForm.tsx +0 -502
- package/src/__tests__/EmbeddableFormPrefill.test.tsx +0 -186
- package/src/__tests__/MobileUX.test.tsx +0 -433
- package/src/__tests__/NewVariants.test.tsx +0 -684
- package/src/__tests__/autoLayout.test.ts +0 -339
- package/src/__tests__/form-validation-submit.test.tsx +0 -286
- package/src/autoLayout.ts +0 -166
- package/src/index.tsx +0 -134
- package/tsconfig.json +0 -9
- package/vite.config.ts +0 -58
- package/vitest.config.ts +0 -12
- package/vitest.setup.ts +0 -1
package/src/DrawerForm.tsx
DELETED
|
@@ -1,410 +0,0 @@
|
|
|
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
|
-
/**
|
|
10
|
-
* DrawerForm Component
|
|
11
|
-
*
|
|
12
|
-
* A form variant that renders inside a slide-out Sheet (drawer) panel.
|
|
13
|
-
* Aligns with @objectstack/spec FormView type: 'drawer'
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
|
17
|
-
import type { FormField, DataSource } from '@object-ui/types';
|
|
18
|
-
import {
|
|
19
|
-
Sheet,
|
|
20
|
-
SheetContent,
|
|
21
|
-
SheetHeader,
|
|
22
|
-
SheetTitle,
|
|
23
|
-
SheetDescription,
|
|
24
|
-
cn,
|
|
25
|
-
} from '@object-ui/components';
|
|
26
|
-
import { FormSection } from './FormSection';
|
|
27
|
-
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
|
|
28
|
-
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
|
|
29
|
-
import { applyAutoLayout } from './autoLayout';
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Container-query-based grid classes for form field layout.
|
|
33
|
-
* Uses @container / @md: / @2xl: / @4xl: variants so that the grid
|
|
34
|
-
* responds to the drawer's actual width instead of the viewport.
|
|
35
|
-
*/
|
|
36
|
-
const CONTAINER_GRID_COLS: Record<number, string | undefined> = {
|
|
37
|
-
1: undefined,
|
|
38
|
-
2: 'grid gap-4 grid-cols-1 @md:grid-cols-2',
|
|
39
|
-
3: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3',
|
|
40
|
-
4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4',
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export interface DrawerFormSectionConfig {
|
|
44
|
-
name?: string;
|
|
45
|
-
label?: string;
|
|
46
|
-
description?: string;
|
|
47
|
-
columns?: 1 | 2 | 3 | 4;
|
|
48
|
-
fields: (string | FormField)[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface DrawerFormSchema {
|
|
52
|
-
type: 'object-form';
|
|
53
|
-
formType: 'drawer';
|
|
54
|
-
objectName: string;
|
|
55
|
-
mode: 'create' | 'edit' | 'view';
|
|
56
|
-
recordId?: string | number;
|
|
57
|
-
title?: string;
|
|
58
|
-
description?: string;
|
|
59
|
-
sections?: DrawerFormSectionConfig[];
|
|
60
|
-
fields?: string[];
|
|
61
|
-
customFields?: FormField[];
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Whether the drawer is open.
|
|
65
|
-
* @default true
|
|
66
|
-
*/
|
|
67
|
-
open?: boolean;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Callback when open state changes.
|
|
71
|
-
*/
|
|
72
|
-
onOpenChange?: (open: boolean) => void;
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Drawer side.
|
|
76
|
-
* @default 'right'
|
|
77
|
-
*/
|
|
78
|
-
drawerSide?: 'top' | 'bottom' | 'left' | 'right';
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Drawer width (CSS value for left/right, or height for top/bottom).
|
|
82
|
-
* Applied via className overrides since Sheet uses cva variants.
|
|
83
|
-
* @default undefined (uses Sheet default)
|
|
84
|
-
*/
|
|
85
|
-
drawerWidth?: string;
|
|
86
|
-
|
|
87
|
-
// Common form props
|
|
88
|
-
showSubmit?: boolean;
|
|
89
|
-
submitText?: string;
|
|
90
|
-
showCancel?: boolean;
|
|
91
|
-
cancelText?: string;
|
|
92
|
-
initialValues?: Record<string, any>;
|
|
93
|
-
initialData?: Record<string, any>;
|
|
94
|
-
readOnly?: boolean;
|
|
95
|
-
layout?: 'vertical' | 'horizontal';
|
|
96
|
-
columns?: number;
|
|
97
|
-
onSuccess?: (data: any) => void | Promise<void>;
|
|
98
|
-
onError?: (error: Error) => void;
|
|
99
|
-
onCancel?: () => void;
|
|
100
|
-
className?: string;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export interface DrawerFormProps {
|
|
104
|
-
schema: DrawerFormSchema;
|
|
105
|
-
dataSource?: DataSource;
|
|
106
|
-
className?: string;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export const DrawerForm: React.FC<DrawerFormProps> = ({
|
|
110
|
-
schema,
|
|
111
|
-
dataSource,
|
|
112
|
-
className,
|
|
113
|
-
}) => {
|
|
114
|
-
const { fieldLabel } = useSafeFieldLabel();
|
|
115
|
-
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
116
|
-
const [formFields, setFormFields] = useState<FormField[]>([]);
|
|
117
|
-
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
118
|
-
const [loading, setLoading] = useState(true);
|
|
119
|
-
const [error, setError] = useState<Error | null>(null);
|
|
120
|
-
|
|
121
|
-
const isOpen = schema.open !== false;
|
|
122
|
-
const side = schema.drawerSide || 'right';
|
|
123
|
-
|
|
124
|
-
// Fetch object schema
|
|
125
|
-
useEffect(() => {
|
|
126
|
-
const fetchSchema = async () => {
|
|
127
|
-
if (!dataSource) {
|
|
128
|
-
setLoading(false);
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
try {
|
|
132
|
-
const data = await dataSource.getObjectSchema(schema.objectName);
|
|
133
|
-
setObjectSchema(data);
|
|
134
|
-
} catch (err) {
|
|
135
|
-
setError(err as Error);
|
|
136
|
-
setLoading(false);
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
fetchSchema();
|
|
140
|
-
}, [schema.objectName, dataSource]);
|
|
141
|
-
|
|
142
|
-
// Fetch initial data
|
|
143
|
-
useEffect(() => {
|
|
144
|
-
const fetchData = async () => {
|
|
145
|
-
if (schema.mode === 'create' || !schema.recordId) {
|
|
146
|
-
setFormData(schema.initialData || schema.initialValues || {});
|
|
147
|
-
setLoading(false);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (!dataSource) {
|
|
152
|
-
setFormData(schema.initialData || schema.initialValues || {});
|
|
153
|
-
setLoading(false);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
const data = await dataSource.findOne(schema.objectName, schema.recordId);
|
|
159
|
-
setFormData(data || {});
|
|
160
|
-
} catch (err) {
|
|
161
|
-
setError(err as Error);
|
|
162
|
-
} finally {
|
|
163
|
-
setLoading(false);
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
if (objectSchema || !dataSource) {
|
|
168
|
-
fetchData();
|
|
169
|
-
}
|
|
170
|
-
}, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
|
|
171
|
-
|
|
172
|
-
// Build form fields from section config
|
|
173
|
-
const buildSectionFields = useCallback((section: DrawerFormSectionConfig): FormField[] => {
|
|
174
|
-
const fields: FormField[] = [];
|
|
175
|
-
|
|
176
|
-
for (const fieldDef of section.fields) {
|
|
177
|
-
const fieldName = typeof fieldDef === 'string' ? fieldDef : fieldDef.name;
|
|
178
|
-
|
|
179
|
-
if (typeof fieldDef === 'object') {
|
|
180
|
-
fields.push(fieldDef);
|
|
181
|
-
} else if (objectSchema?.fields?.[fieldName]) {
|
|
182
|
-
const field = objectSchema.fields[fieldName];
|
|
183
|
-
fields.push({
|
|
184
|
-
name: fieldName,
|
|
185
|
-
label: fieldLabel(schema.objectName, fieldName, field.label || fieldName),
|
|
186
|
-
type: mapFieldTypeToFormType(field.type),
|
|
187
|
-
required: field.required || false,
|
|
188
|
-
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
189
|
-
placeholder: field.placeholder,
|
|
190
|
-
description: field.help || field.description,
|
|
191
|
-
validation: buildValidationRules(field),
|
|
192
|
-
field: field,
|
|
193
|
-
options: field.options,
|
|
194
|
-
multiple: field.multiple,
|
|
195
|
-
});
|
|
196
|
-
} else {
|
|
197
|
-
fields.push({
|
|
198
|
-
name: fieldName,
|
|
199
|
-
label: fieldName,
|
|
200
|
-
type: 'input',
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return fields;
|
|
206
|
-
}, [objectSchema, schema.readOnly, schema.mode]);
|
|
207
|
-
|
|
208
|
-
// Build fields from flat field list (when no sections provided)
|
|
209
|
-
useEffect(() => {
|
|
210
|
-
if (!objectSchema && dataSource) return;
|
|
211
|
-
|
|
212
|
-
if (schema.customFields?.length) {
|
|
213
|
-
setFormFields(schema.customFields);
|
|
214
|
-
setLoading(false);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (schema.sections?.length) {
|
|
219
|
-
// Fields are built per-section in the render
|
|
220
|
-
setLoading(false);
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (!objectSchema) return;
|
|
225
|
-
|
|
226
|
-
const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {});
|
|
227
|
-
const generated: FormField[] = [];
|
|
228
|
-
|
|
229
|
-
for (const fieldName of fieldsToShow) {
|
|
230
|
-
const name = typeof fieldName === 'string' ? fieldName : (fieldName as any).name;
|
|
231
|
-
if (!name) continue;
|
|
232
|
-
const field = objectSchema.fields?.[name];
|
|
233
|
-
if (!field) continue;
|
|
234
|
-
|
|
235
|
-
generated.push({
|
|
236
|
-
name,
|
|
237
|
-
label: fieldLabel(schema.objectName, name, field.label || name),
|
|
238
|
-
type: mapFieldTypeToFormType(field.type),
|
|
239
|
-
required: field.required || false,
|
|
240
|
-
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
241
|
-
placeholder: field.placeholder,
|
|
242
|
-
description: field.help || field.description,
|
|
243
|
-
validation: buildValidationRules(field),
|
|
244
|
-
field: field,
|
|
245
|
-
options: field.options,
|
|
246
|
-
multiple: field.multiple,
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
setFormFields(generated);
|
|
251
|
-
setLoading(false);
|
|
252
|
-
}, [objectSchema, schema.fields, schema.customFields, schema.sections, schema.readOnly, schema.mode, dataSource]);
|
|
253
|
-
|
|
254
|
-
// Handle form submission
|
|
255
|
-
const handleSubmit = useCallback(async (data: Record<string, any>) => {
|
|
256
|
-
if (!dataSource) {
|
|
257
|
-
if (schema.onSuccess) {
|
|
258
|
-
await schema.onSuccess(data);
|
|
259
|
-
}
|
|
260
|
-
// Close drawer on success
|
|
261
|
-
schema.onOpenChange?.(false);
|
|
262
|
-
return data;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
try {
|
|
266
|
-
let result;
|
|
267
|
-
if (schema.mode === 'create') {
|
|
268
|
-
result = await dataSource.create(schema.objectName, data);
|
|
269
|
-
} else if (schema.mode === 'edit' && schema.recordId) {
|
|
270
|
-
result = await dataSource.update(schema.objectName, schema.recordId, data);
|
|
271
|
-
}
|
|
272
|
-
if (schema.onSuccess) {
|
|
273
|
-
await schema.onSuccess(result);
|
|
274
|
-
}
|
|
275
|
-
// Close drawer on success
|
|
276
|
-
schema.onOpenChange?.(false);
|
|
277
|
-
return result;
|
|
278
|
-
} catch (err) {
|
|
279
|
-
if (schema.onError) {
|
|
280
|
-
schema.onError(err as Error);
|
|
281
|
-
}
|
|
282
|
-
throw err;
|
|
283
|
-
}
|
|
284
|
-
}, [schema, dataSource]);
|
|
285
|
-
|
|
286
|
-
// Handle cancel
|
|
287
|
-
const handleCancel = useCallback(() => {
|
|
288
|
-
if (schema.onCancel) {
|
|
289
|
-
schema.onCancel();
|
|
290
|
-
}
|
|
291
|
-
// Close drawer on cancel
|
|
292
|
-
schema.onOpenChange?.(false);
|
|
293
|
-
}, [schema]);
|
|
294
|
-
|
|
295
|
-
// Width style for the drawer content
|
|
296
|
-
const widthStyle = useMemo(() => {
|
|
297
|
-
if (!schema.drawerWidth) return undefined;
|
|
298
|
-
const isHorizontal = side === 'left' || side === 'right';
|
|
299
|
-
return isHorizontal
|
|
300
|
-
? { width: schema.drawerWidth, maxWidth: schema.drawerWidth }
|
|
301
|
-
: { height: schema.drawerWidth, maxHeight: schema.drawerWidth };
|
|
302
|
-
}, [schema.drawerWidth, side]);
|
|
303
|
-
|
|
304
|
-
const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
|
|
305
|
-
? schema.layout
|
|
306
|
-
: 'vertical';
|
|
307
|
-
|
|
308
|
-
// Build base form schema
|
|
309
|
-
const baseFormSchema = {
|
|
310
|
-
type: 'form' as const,
|
|
311
|
-
layout: formLayout,
|
|
312
|
-
defaultValues: formData,
|
|
313
|
-
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
|
|
314
|
-
cancelLabel: schema.cancelText,
|
|
315
|
-
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
|
|
316
|
-
showCancel: schema.showCancel !== false,
|
|
317
|
-
onSubmit: handleSubmit,
|
|
318
|
-
onCancel: handleCancel,
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
const renderContent = () => {
|
|
322
|
-
if (error) {
|
|
323
|
-
return (
|
|
324
|
-
<div className="p-4 border border-red-300 bg-red-50 rounded-md">
|
|
325
|
-
<h3 className="text-red-800 font-semibold">Error loading form</h3>
|
|
326
|
-
<p className="text-red-600 text-sm mt-1">{error.message}</p>
|
|
327
|
-
</div>
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (loading) {
|
|
332
|
-
return (
|
|
333
|
-
<div className="p-8 text-center">
|
|
334
|
-
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
|
335
|
-
<p className="mt-2 text-sm text-gray-600">Loading form...</p>
|
|
336
|
-
</div>
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Sections layout
|
|
341
|
-
if (schema.sections?.length) {
|
|
342
|
-
return (
|
|
343
|
-
<div className="space-y-6">
|
|
344
|
-
{schema.sections.map((section, index) => {
|
|
345
|
-
const sectionCols = section.columns || 1;
|
|
346
|
-
return (
|
|
347
|
-
<FormSection
|
|
348
|
-
key={section.name || section.label || index}
|
|
349
|
-
label={section.label}
|
|
350
|
-
description={section.description}
|
|
351
|
-
columns={sectionCols}
|
|
352
|
-
gridClassName={CONTAINER_GRID_COLS[sectionCols]}
|
|
353
|
-
>
|
|
354
|
-
<SchemaRenderer
|
|
355
|
-
schema={{
|
|
356
|
-
...baseFormSchema,
|
|
357
|
-
fields: buildSectionFields(section),
|
|
358
|
-
showSubmit: index === schema.sections!.length - 1 && baseFormSchema.showSubmit,
|
|
359
|
-
showCancel: index === schema.sections!.length - 1 && baseFormSchema.showCancel,
|
|
360
|
-
}}
|
|
361
|
-
/>
|
|
362
|
-
</FormSection>
|
|
363
|
-
);
|
|
364
|
-
})}
|
|
365
|
-
</div>
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Apply auto-layout for flat fields (infer columns + colSpan)
|
|
370
|
-
const autoLayoutResult = applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
|
|
371
|
-
|
|
372
|
-
// Flat fields layout — use container-query grid classes so the form
|
|
373
|
-
// responds to the drawer width, not the viewport width.
|
|
374
|
-
const containerFieldClass = CONTAINER_GRID_COLS[autoLayoutResult.columns || 1];
|
|
375
|
-
|
|
376
|
-
return (
|
|
377
|
-
<SchemaRenderer
|
|
378
|
-
schema={{
|
|
379
|
-
...baseFormSchema,
|
|
380
|
-
fields: autoLayoutResult.fields,
|
|
381
|
-
columns: autoLayoutResult.columns,
|
|
382
|
-
...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}),
|
|
383
|
-
}}
|
|
384
|
-
/>
|
|
385
|
-
);
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
return (
|
|
389
|
-
<Sheet open={isOpen} onOpenChange={schema.onOpenChange}>
|
|
390
|
-
<SheetContent
|
|
391
|
-
side={side}
|
|
392
|
-
className={cn('overflow-y-auto', className, schema.className)}
|
|
393
|
-
style={widthStyle}
|
|
394
|
-
>
|
|
395
|
-
{(schema.title || schema.description) && (
|
|
396
|
-
<SheetHeader>
|
|
397
|
-
{schema.title && <SheetTitle>{schema.title}</SheetTitle>}
|
|
398
|
-
{schema.description && <SheetDescription>{schema.description}</SheetDescription>}
|
|
399
|
-
</SheetHeader>
|
|
400
|
-
)}
|
|
401
|
-
|
|
402
|
-
<div className="@container py-4">
|
|
403
|
-
{renderContent()}
|
|
404
|
-
</div>
|
|
405
|
-
</SheetContent>
|
|
406
|
-
</Sheet>
|
|
407
|
-
);
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
export default DrawerForm;
|
package/src/EmbeddableForm.tsx
DELETED
|
@@ -1,240 +0,0 @@
|
|
|
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
|
-
/**
|
|
10
|
-
* EmbeddableForm Component
|
|
11
|
-
*
|
|
12
|
-
* A standalone embeddable form that can be accessed without authentication.
|
|
13
|
-
* Designed for external data collection use cases (surveys, registrations, etc.).
|
|
14
|
-
*
|
|
15
|
-
* Features:
|
|
16
|
-
* - Renders from ObjectFormSchema or inline field definitions
|
|
17
|
-
* - No authentication required (public access)
|
|
18
|
-
* - URL prefill parameters support (?name=John&email=...)
|
|
19
|
-
* - Configurable branding (logo, colors, title)
|
|
20
|
-
* - Success/thank-you page after submission
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import React, { useState, useCallback, useMemo } from 'react';
|
|
24
|
-
import type { DataSource, FormField } from '@object-ui/types';
|
|
25
|
-
import { ObjectForm } from './ObjectForm';
|
|
26
|
-
|
|
27
|
-
export interface EmbeddableFormConfig {
|
|
28
|
-
/** Unique form ID */
|
|
29
|
-
formId: string;
|
|
30
|
-
/** Object name to create records in */
|
|
31
|
-
objectName: string;
|
|
32
|
-
/** Form title displayed at the top */
|
|
33
|
-
title?: string;
|
|
34
|
-
/** Form description / instructions */
|
|
35
|
-
description?: string;
|
|
36
|
-
/** Fields to include in the form (subset of object fields) */
|
|
37
|
-
fields?: string[];
|
|
38
|
-
/** Custom field definitions for inline forms */
|
|
39
|
-
customFields?: FormField[];
|
|
40
|
-
/** Branding configuration */
|
|
41
|
-
branding?: {
|
|
42
|
-
logo?: string;
|
|
43
|
-
primaryColor?: string;
|
|
44
|
-
backgroundColor?: string;
|
|
45
|
-
};
|
|
46
|
-
/** Thank you page configuration */
|
|
47
|
-
thankYouPage?: {
|
|
48
|
-
title?: string;
|
|
49
|
-
message?: string;
|
|
50
|
-
redirectUrl?: string;
|
|
51
|
-
redirectDelay?: number;
|
|
52
|
-
};
|
|
53
|
-
/** Allow multiple submissions */
|
|
54
|
-
allowMultiple?: boolean;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface EmbeddableFormProps {
|
|
58
|
-
/** Form configuration */
|
|
59
|
-
config: EmbeddableFormConfig;
|
|
60
|
-
/** Data source for creating records */
|
|
61
|
-
dataSource?: DataSource;
|
|
62
|
-
/** URL search parameters for prefilling fields */
|
|
63
|
-
prefillParams?: Record<string, string>;
|
|
64
|
-
/** Additional CSS class */
|
|
65
|
-
className?: string;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* EmbeddableForm — Standalone form for external data collection.
|
|
70
|
-
*
|
|
71
|
-
* Can be rendered at a public URL (e.g., `/forms/:formId`) without auth.
|
|
72
|
-
* Submissions create records in the specified object via DataSource.
|
|
73
|
-
*/
|
|
74
|
-
export const EmbeddableForm: React.FC<EmbeddableFormProps> = ({
|
|
75
|
-
config,
|
|
76
|
-
dataSource,
|
|
77
|
-
prefillParams,
|
|
78
|
-
className,
|
|
79
|
-
}) => {
|
|
80
|
-
const [submitted, setSubmitted] = useState(false);
|
|
81
|
-
const [submitting, setSubmitting] = useState(false);
|
|
82
|
-
const [error, setError] = useState<string | null>(null);
|
|
83
|
-
|
|
84
|
-
// Build initial data from URL prefill params or window.location.search
|
|
85
|
-
const initialData = useMemo(() => {
|
|
86
|
-
const data: Record<string, string> = {};
|
|
87
|
-
// Explicit prefillParams take priority
|
|
88
|
-
if (prefillParams) {
|
|
89
|
-
for (const [key, value] of Object.entries(prefillParams)) {
|
|
90
|
-
data[key] = value;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
// Also parse URL search parameters for prefilling (Phase 14 L2)
|
|
94
|
-
if (typeof window !== 'undefined') {
|
|
95
|
-
const urlParams = new URLSearchParams(window.location.search);
|
|
96
|
-
urlParams.forEach((value, key) => {
|
|
97
|
-
if (!(key in data)) {
|
|
98
|
-
data[key] = value;
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
return Object.keys(data).length > 0 ? data : undefined;
|
|
103
|
-
}, [prefillParams]);
|
|
104
|
-
|
|
105
|
-
const handleSubmit = useCallback(async (formData: Record<string, any>) => {
|
|
106
|
-
setSubmitting(true);
|
|
107
|
-
setError(null);
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
if (dataSource) {
|
|
111
|
-
await dataSource.create(config.objectName, formData);
|
|
112
|
-
}
|
|
113
|
-
setSubmitted(true);
|
|
114
|
-
|
|
115
|
-
// Handle redirect after delay
|
|
116
|
-
if (config.thankYouPage?.redirectUrl) {
|
|
117
|
-
const delay = config.thankYouPage.redirectDelay ?? 3000;
|
|
118
|
-
setTimeout(() => {
|
|
119
|
-
window.location.href = config.thankYouPage!.redirectUrl!;
|
|
120
|
-
}, delay);
|
|
121
|
-
}
|
|
122
|
-
} catch (err) {
|
|
123
|
-
setError(err instanceof Error ? err.message : 'Failed to submit form. Please try again.');
|
|
124
|
-
} finally {
|
|
125
|
-
setSubmitting(false);
|
|
126
|
-
}
|
|
127
|
-
}, [dataSource, config]);
|
|
128
|
-
|
|
129
|
-
const handleReset = useCallback(() => {
|
|
130
|
-
setSubmitted(false);
|
|
131
|
-
setError(null);
|
|
132
|
-
}, []);
|
|
133
|
-
|
|
134
|
-
// Branding styles
|
|
135
|
-
const brandingStyle = useMemo(() => {
|
|
136
|
-
const style: React.CSSProperties = {};
|
|
137
|
-
if (config.branding?.backgroundColor) {
|
|
138
|
-
style.backgroundColor = config.branding.backgroundColor;
|
|
139
|
-
}
|
|
140
|
-
return style;
|
|
141
|
-
}, [config.branding]);
|
|
142
|
-
|
|
143
|
-
// Thank you page
|
|
144
|
-
if (submitted) {
|
|
145
|
-
const thankYou = config.thankYouPage;
|
|
146
|
-
return (
|
|
147
|
-
<div
|
|
148
|
-
className={`min-h-screen flex items-center justify-center p-4 ${className || ''}`}
|
|
149
|
-
style={brandingStyle}
|
|
150
|
-
>
|
|
151
|
-
<div className="max-w-md w-full bg-card rounded-lg shadow-lg p-8 text-center space-y-4">
|
|
152
|
-
<div className="text-4xl">✓</div>
|
|
153
|
-
<h2 className="text-xl font-semibold text-foreground">
|
|
154
|
-
{thankYou?.title || 'Thank You!'}
|
|
155
|
-
</h2>
|
|
156
|
-
<p className="text-muted-foreground">
|
|
157
|
-
{thankYou?.message || 'Your submission has been received successfully.'}
|
|
158
|
-
</p>
|
|
159
|
-
{config.allowMultiple && (
|
|
160
|
-
<button
|
|
161
|
-
onClick={handleReset}
|
|
162
|
-
className="mt-4 px-4 py-2 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
163
|
-
>
|
|
164
|
-
Submit Another Response
|
|
165
|
-
</button>
|
|
166
|
-
)}
|
|
167
|
-
{thankYou?.redirectUrl && (
|
|
168
|
-
<p className="text-xs text-muted-foreground">
|
|
169
|
-
Redirecting in {Math.ceil((thankYou.redirectDelay ?? 3000) / 1000)} seconds...
|
|
170
|
-
</p>
|
|
171
|
-
)}
|
|
172
|
-
</div>
|
|
173
|
-
</div>
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return (
|
|
178
|
-
<div
|
|
179
|
-
className={`min-h-screen flex items-center justify-center p-4 ${className || ''}`}
|
|
180
|
-
style={brandingStyle}
|
|
181
|
-
>
|
|
182
|
-
<div className="max-w-2xl w-full bg-card rounded-lg shadow-lg overflow-hidden">
|
|
183
|
-
{/* Header */}
|
|
184
|
-
<div
|
|
185
|
-
className="p-6 border-b"
|
|
186
|
-
style={config.branding?.primaryColor ? { borderBottomColor: config.branding.primaryColor } : undefined}
|
|
187
|
-
>
|
|
188
|
-
{config.branding?.logo && (
|
|
189
|
-
<img
|
|
190
|
-
src={config.branding.logo}
|
|
191
|
-
alt="Logo"
|
|
192
|
-
className="h-8 mb-4"
|
|
193
|
-
/>
|
|
194
|
-
)}
|
|
195
|
-
{config.title && (
|
|
196
|
-
<h1 className="text-xl font-semibold text-foreground">
|
|
197
|
-
{config.title}
|
|
198
|
-
</h1>
|
|
199
|
-
)}
|
|
200
|
-
{config.description && (
|
|
201
|
-
<p className="text-sm text-muted-foreground mt-1">
|
|
202
|
-
{config.description}
|
|
203
|
-
</p>
|
|
204
|
-
)}
|
|
205
|
-
</div>
|
|
206
|
-
|
|
207
|
-
{/* Form body */}
|
|
208
|
-
<div className="p-6">
|
|
209
|
-
{error && (
|
|
210
|
-
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/30 rounded-md text-sm text-destructive">
|
|
211
|
-
{error}
|
|
212
|
-
</div>
|
|
213
|
-
)}
|
|
214
|
-
<ObjectForm
|
|
215
|
-
schema={{
|
|
216
|
-
type: 'object-form',
|
|
217
|
-
objectName: config.objectName,
|
|
218
|
-
mode: 'create',
|
|
219
|
-
fields: config.fields,
|
|
220
|
-
customFields: config.customFields,
|
|
221
|
-
initialData,
|
|
222
|
-
onSuccess: handleSubmit,
|
|
223
|
-
submitLabel: submitting ? 'Submitting...' : 'Submit',
|
|
224
|
-
}}
|
|
225
|
-
dataSource={dataSource}
|
|
226
|
-
/>
|
|
227
|
-
</div>
|
|
228
|
-
|
|
229
|
-
{/* Footer */}
|
|
230
|
-
<div className="px-6 py-3 border-t bg-muted/20 text-center">
|
|
231
|
-
<p className="text-xs text-muted-foreground">
|
|
232
|
-
Powered by ObjectStack
|
|
233
|
-
</p>
|
|
234
|
-
</div>
|
|
235
|
-
</div>
|
|
236
|
-
</div>
|
|
237
|
-
);
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
export default EmbeddableForm;
|