@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/ModalForm.tsx
DELETED
|
@@ -1,485 +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
|
-
* ModalForm Component
|
|
11
|
-
*
|
|
12
|
-
* A form variant that renders inside a Dialog (modal) overlay.
|
|
13
|
-
* Aligns with @objectstack/spec FormView type: 'modal'
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import React, { useState, useCallback, useEffect, useMemo, useId } from 'react';
|
|
17
|
-
import type { FormField, DataSource } from '@object-ui/types';
|
|
18
|
-
import {
|
|
19
|
-
Dialog,
|
|
20
|
-
MobileDialogContent,
|
|
21
|
-
DialogHeader,
|
|
22
|
-
DialogTitle,
|
|
23
|
-
DialogDescription,
|
|
24
|
-
Skeleton,
|
|
25
|
-
Button,
|
|
26
|
-
cn,
|
|
27
|
-
} from '@object-ui/components';
|
|
28
|
-
import { Loader2 } from 'lucide-react';
|
|
29
|
-
import { FormSection } from './FormSection';
|
|
30
|
-
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
|
|
31
|
-
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
|
|
32
|
-
import { applyAutoLayout, inferModalSize } from './autoLayout';
|
|
33
|
-
|
|
34
|
-
export interface ModalFormSectionConfig {
|
|
35
|
-
name?: string;
|
|
36
|
-
label?: string;
|
|
37
|
-
description?: string;
|
|
38
|
-
columns?: 1 | 2 | 3 | 4;
|
|
39
|
-
fields: (string | FormField)[];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface ModalFormSchema {
|
|
43
|
-
type: 'object-form';
|
|
44
|
-
formType: 'modal';
|
|
45
|
-
objectName: string;
|
|
46
|
-
mode: 'create' | 'edit' | 'view';
|
|
47
|
-
recordId?: string | number;
|
|
48
|
-
title?: string;
|
|
49
|
-
description?: string;
|
|
50
|
-
sections?: ModalFormSectionConfig[];
|
|
51
|
-
fields?: string[];
|
|
52
|
-
customFields?: FormField[];
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Whether the modal is open.
|
|
56
|
-
* @default true
|
|
57
|
-
*/
|
|
58
|
-
open?: boolean;
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Callback when open state changes.
|
|
62
|
-
*/
|
|
63
|
-
onOpenChange?: (open: boolean) => void;
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Modal dialog size.
|
|
67
|
-
* @default 'default'
|
|
68
|
-
*/
|
|
69
|
-
modalSize?: 'sm' | 'default' | 'lg' | 'xl' | 'full';
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Whether to show a close button in the header.
|
|
73
|
-
* @default true
|
|
74
|
-
*/
|
|
75
|
-
modalCloseButton?: boolean;
|
|
76
|
-
|
|
77
|
-
// Common form props
|
|
78
|
-
showSubmit?: boolean;
|
|
79
|
-
submitText?: string;
|
|
80
|
-
showCancel?: boolean;
|
|
81
|
-
cancelText?: string;
|
|
82
|
-
initialValues?: Record<string, any>;
|
|
83
|
-
initialData?: Record<string, any>;
|
|
84
|
-
readOnly?: boolean;
|
|
85
|
-
layout?: 'vertical' | 'horizontal';
|
|
86
|
-
columns?: number;
|
|
87
|
-
onSuccess?: (data: any) => void | Promise<void>;
|
|
88
|
-
onError?: (error: Error) => void;
|
|
89
|
-
onCancel?: () => void;
|
|
90
|
-
className?: string;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export interface ModalFormProps {
|
|
94
|
-
schema: ModalFormSchema;
|
|
95
|
-
dataSource?: DataSource;
|
|
96
|
-
className?: string;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Size class map for the dialog content.
|
|
101
|
-
*
|
|
102
|
-
* Uses `sm:` prefix so that `tailwind-merge` correctly resolves the conflict
|
|
103
|
-
* with MobileDialogContent's base `sm:max-w-lg` class. On mobile (< sm) the
|
|
104
|
-
* dialog is already full-screen, so max-width only matters at sm+ breakpoints.
|
|
105
|
-
*/
|
|
106
|
-
const modalSizeClasses: Record<string, string> = {
|
|
107
|
-
sm: 'sm:max-w-sm',
|
|
108
|
-
default: 'sm:max-w-lg',
|
|
109
|
-
lg: 'sm:max-w-2xl',
|
|
110
|
-
xl: 'sm:max-w-5xl',
|
|
111
|
-
full: 'sm:max-w-[95vw] sm:w-full',
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Container-query-based grid classes for form field layout.
|
|
116
|
-
* Uses @container / @md: / @2xl: / @4xl: variants so that the grid
|
|
117
|
-
* responds to the modal's actual width instead of the viewport,
|
|
118
|
-
* ensuring single-column on narrow mobile modals regardless of viewport size.
|
|
119
|
-
*/
|
|
120
|
-
const CONTAINER_GRID_COLS: Record<number, string | undefined> = {
|
|
121
|
-
1: undefined, // let the form renderer use its default (space-y-4)
|
|
122
|
-
2: 'grid gap-4 grid-cols-1 @md:grid-cols-2',
|
|
123
|
-
3: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3',
|
|
124
|
-
4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4',
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
export const ModalForm: React.FC<ModalFormProps> = ({
|
|
128
|
-
schema,
|
|
129
|
-
dataSource,
|
|
130
|
-
className,
|
|
131
|
-
}) => {
|
|
132
|
-
const { fieldLabel } = useSafeFieldLabel();
|
|
133
|
-
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
134
|
-
const [formFields, setFormFields] = useState<FormField[]>([]);
|
|
135
|
-
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
136
|
-
const [loading, setLoading] = useState(true);
|
|
137
|
-
const [error, setError] = useState<Error | null>(null);
|
|
138
|
-
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
139
|
-
|
|
140
|
-
const isOpen = schema.open !== false;
|
|
141
|
-
|
|
142
|
-
// Stable form id for linking the external submit button to the form element
|
|
143
|
-
const formId = useId();
|
|
144
|
-
|
|
145
|
-
// Compute auto-layout for flat fields (no sections) to determine inferred columns
|
|
146
|
-
const autoLayoutResult = useMemo(() => {
|
|
147
|
-
if (schema.sections?.length || schema.customFields?.length) return null;
|
|
148
|
-
return applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
|
|
149
|
-
}, [formFields, objectSchema, schema.columns, schema.mode, schema.sections, schema.customFields]);
|
|
150
|
-
|
|
151
|
-
// Auto-upgrade modal size when auto-layout infers multi-column and user hasn't set modalSize
|
|
152
|
-
const effectiveModalSize = useMemo(() => {
|
|
153
|
-
if (schema.modalSize) return schema.modalSize;
|
|
154
|
-
if (autoLayoutResult?.columns && autoLayoutResult.columns > 1) {
|
|
155
|
-
return inferModalSize(autoLayoutResult.columns);
|
|
156
|
-
}
|
|
157
|
-
// Auto-upgrade for sections: use the max columns across all sections
|
|
158
|
-
if (schema.sections?.length) {
|
|
159
|
-
const maxCols = Math.max(...schema.sections.map(s => Number(s.columns) || 1));
|
|
160
|
-
if (maxCols > 1) return inferModalSize(maxCols);
|
|
161
|
-
}
|
|
162
|
-
return 'default';
|
|
163
|
-
}, [schema.modalSize, autoLayoutResult, schema.sections]);
|
|
164
|
-
|
|
165
|
-
const sizeClass = modalSizeClasses[effectiveModalSize] || modalSizeClasses.default;
|
|
166
|
-
|
|
167
|
-
// Fetch object schema
|
|
168
|
-
useEffect(() => {
|
|
169
|
-
const fetchSchema = async () => {
|
|
170
|
-
if (!dataSource) {
|
|
171
|
-
setLoading(false);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
try {
|
|
175
|
-
const data = await dataSource.getObjectSchema(schema.objectName);
|
|
176
|
-
setObjectSchema(data);
|
|
177
|
-
} catch (err) {
|
|
178
|
-
setError(err as Error);
|
|
179
|
-
setLoading(false);
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
fetchSchema();
|
|
183
|
-
}, [schema.objectName, dataSource]);
|
|
184
|
-
|
|
185
|
-
// Fetch initial data
|
|
186
|
-
useEffect(() => {
|
|
187
|
-
const fetchData = async () => {
|
|
188
|
-
if (schema.mode === 'create' || !schema.recordId) {
|
|
189
|
-
setFormData(schema.initialData || schema.initialValues || {});
|
|
190
|
-
setLoading(false);
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (!dataSource) {
|
|
195
|
-
setFormData(schema.initialData || schema.initialValues || {});
|
|
196
|
-
setLoading(false);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
const data = await dataSource.findOne(schema.objectName, schema.recordId);
|
|
202
|
-
setFormData(data || {});
|
|
203
|
-
} catch (err) {
|
|
204
|
-
setError(err as Error);
|
|
205
|
-
} finally {
|
|
206
|
-
setLoading(false);
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
if (objectSchema || !dataSource) {
|
|
211
|
-
fetchData();
|
|
212
|
-
}
|
|
213
|
-
}, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
|
|
214
|
-
|
|
215
|
-
// Build form fields from section config
|
|
216
|
-
const buildSectionFields = useCallback((section: ModalFormSectionConfig): FormField[] => {
|
|
217
|
-
const fields: FormField[] = [];
|
|
218
|
-
|
|
219
|
-
for (const fieldDef of section.fields) {
|
|
220
|
-
const fieldName = typeof fieldDef === 'string' ? fieldDef : fieldDef.name;
|
|
221
|
-
|
|
222
|
-
if (typeof fieldDef === 'object') {
|
|
223
|
-
fields.push(fieldDef);
|
|
224
|
-
} else if (objectSchema?.fields?.[fieldName]) {
|
|
225
|
-
const field = objectSchema.fields[fieldName];
|
|
226
|
-
fields.push({
|
|
227
|
-
name: fieldName,
|
|
228
|
-
label: fieldLabel(schema.objectName, fieldName, field.label || fieldName),
|
|
229
|
-
type: mapFieldTypeToFormType(field.type),
|
|
230
|
-
required: field.required || false,
|
|
231
|
-
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
232
|
-
placeholder: field.placeholder,
|
|
233
|
-
description: field.help || field.description,
|
|
234
|
-
validation: buildValidationRules(field),
|
|
235
|
-
field: field,
|
|
236
|
-
options: field.options,
|
|
237
|
-
multiple: field.multiple,
|
|
238
|
-
});
|
|
239
|
-
} else {
|
|
240
|
-
fields.push({
|
|
241
|
-
name: fieldName,
|
|
242
|
-
label: fieldName,
|
|
243
|
-
type: 'input',
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return fields;
|
|
249
|
-
}, [objectSchema, schema.readOnly, schema.mode]);
|
|
250
|
-
|
|
251
|
-
// Build fields from flat field list (when no sections)
|
|
252
|
-
useEffect(() => {
|
|
253
|
-
if (!objectSchema && dataSource) return;
|
|
254
|
-
|
|
255
|
-
if (schema.customFields?.length) {
|
|
256
|
-
setFormFields(schema.customFields);
|
|
257
|
-
setLoading(false);
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (schema.sections?.length) {
|
|
262
|
-
setLoading(false);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (!objectSchema) return;
|
|
267
|
-
|
|
268
|
-
const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {});
|
|
269
|
-
const generated: FormField[] = [];
|
|
270
|
-
|
|
271
|
-
for (const fieldName of fieldsToShow) {
|
|
272
|
-
const name = typeof fieldName === 'string' ? fieldName : (fieldName as any).name;
|
|
273
|
-
if (!name) continue;
|
|
274
|
-
const field = objectSchema.fields?.[name];
|
|
275
|
-
if (!field) continue;
|
|
276
|
-
|
|
277
|
-
generated.push({
|
|
278
|
-
name,
|
|
279
|
-
label: fieldLabel(schema.objectName, name, field.label || name),
|
|
280
|
-
type: mapFieldTypeToFormType(field.type),
|
|
281
|
-
required: field.required || false,
|
|
282
|
-
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
283
|
-
placeholder: field.placeholder,
|
|
284
|
-
description: field.help || field.description,
|
|
285
|
-
validation: buildValidationRules(field),
|
|
286
|
-
field: field,
|
|
287
|
-
options: field.options,
|
|
288
|
-
multiple: field.multiple,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
setFormFields(generated);
|
|
293
|
-
setLoading(false);
|
|
294
|
-
}, [objectSchema, schema.fields, schema.customFields, schema.sections, schema.readOnly, schema.mode, dataSource]);
|
|
295
|
-
|
|
296
|
-
// Handle form submission
|
|
297
|
-
const handleSubmit = useCallback(async (data: Record<string, any>) => {
|
|
298
|
-
setIsSubmitting(true);
|
|
299
|
-
try {
|
|
300
|
-
if (!dataSource) {
|
|
301
|
-
if (schema.onSuccess) {
|
|
302
|
-
await schema.onSuccess(data);
|
|
303
|
-
}
|
|
304
|
-
// Close modal on success
|
|
305
|
-
schema.onOpenChange?.(false);
|
|
306
|
-
return data;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
let result;
|
|
310
|
-
if (schema.mode === 'create') {
|
|
311
|
-
result = await dataSource.create(schema.objectName, data);
|
|
312
|
-
} else if (schema.mode === 'edit' && schema.recordId) {
|
|
313
|
-
result = await dataSource.update(schema.objectName, schema.recordId, data);
|
|
314
|
-
}
|
|
315
|
-
if (schema.onSuccess) {
|
|
316
|
-
await schema.onSuccess(result);
|
|
317
|
-
}
|
|
318
|
-
// Close modal on success
|
|
319
|
-
schema.onOpenChange?.(false);
|
|
320
|
-
return result;
|
|
321
|
-
} catch (err) {
|
|
322
|
-
if (schema.onError) {
|
|
323
|
-
schema.onError(err as Error);
|
|
324
|
-
}
|
|
325
|
-
throw err;
|
|
326
|
-
} finally {
|
|
327
|
-
setIsSubmitting(false);
|
|
328
|
-
}
|
|
329
|
-
}, [schema, dataSource]);
|
|
330
|
-
|
|
331
|
-
// Handle cancel
|
|
332
|
-
const handleCancel = useCallback(() => {
|
|
333
|
-
if (schema.onCancel) {
|
|
334
|
-
schema.onCancel();
|
|
335
|
-
}
|
|
336
|
-
// Close modal on cancel
|
|
337
|
-
schema.onOpenChange?.(false);
|
|
338
|
-
}, [schema]);
|
|
339
|
-
|
|
340
|
-
const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
|
|
341
|
-
? schema.layout
|
|
342
|
-
: 'vertical';
|
|
343
|
-
|
|
344
|
-
// Build base form schema
|
|
345
|
-
// Actions are hidden inside the form renderer — we render them in a sticky footer instead
|
|
346
|
-
const showSubmit = schema.showSubmit !== false && schema.mode !== 'view';
|
|
347
|
-
const showCancel = schema.showCancel !== false;
|
|
348
|
-
const submitLabel = schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update');
|
|
349
|
-
const cancelLabel = schema.cancelText || 'Cancel';
|
|
350
|
-
|
|
351
|
-
const baseFormSchema = {
|
|
352
|
-
type: 'form' as const,
|
|
353
|
-
layout: formLayout,
|
|
354
|
-
defaultValues: formData,
|
|
355
|
-
submitLabel,
|
|
356
|
-
cancelLabel,
|
|
357
|
-
showSubmit,
|
|
358
|
-
showCancel,
|
|
359
|
-
onSubmit: handleSubmit,
|
|
360
|
-
onCancel: handleCancel,
|
|
361
|
-
showActions: false, // Hide actions — rendered in sticky footer
|
|
362
|
-
id: formId, // Link external submit button via form attribute
|
|
363
|
-
};
|
|
364
|
-
|
|
365
|
-
const renderContent = () => {
|
|
366
|
-
if (error) {
|
|
367
|
-
return (
|
|
368
|
-
<div className="p-4 border border-red-300 bg-red-50 rounded-md">
|
|
369
|
-
<h3 className="text-red-800 font-semibold">Error loading form</h3>
|
|
370
|
-
<p className="text-red-600 text-sm mt-1">{error.message}</p>
|
|
371
|
-
</div>
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (loading) {
|
|
376
|
-
return (
|
|
377
|
-
<div className="space-y-4" data-testid="modal-form-skeleton">
|
|
378
|
-
{[1, 2, 3].map((i) => (
|
|
379
|
-
<div key={i} className="space-y-2">
|
|
380
|
-
<Skeleton className="h-4 w-24" />
|
|
381
|
-
<Skeleton className="h-10 w-full" />
|
|
382
|
-
</div>
|
|
383
|
-
))}
|
|
384
|
-
</div>
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Sections layout
|
|
389
|
-
if (schema.sections?.length) {
|
|
390
|
-
return (
|
|
391
|
-
<div className="space-y-6">
|
|
392
|
-
{schema.sections.map((section, index) => {
|
|
393
|
-
const sectionCols = section.columns || 1;
|
|
394
|
-
return (
|
|
395
|
-
<FormSection
|
|
396
|
-
key={section.name || section.label || index}
|
|
397
|
-
label={section.label}
|
|
398
|
-
description={section.description}
|
|
399
|
-
columns={sectionCols}
|
|
400
|
-
gridClassName={CONTAINER_GRID_COLS[sectionCols]}
|
|
401
|
-
>
|
|
402
|
-
<SchemaRenderer
|
|
403
|
-
schema={{
|
|
404
|
-
...baseFormSchema,
|
|
405
|
-
fields: buildSectionFields(section),
|
|
406
|
-
// Actions are in the sticky footer, not inside sections
|
|
407
|
-
}}
|
|
408
|
-
/>
|
|
409
|
-
</FormSection>
|
|
410
|
-
);
|
|
411
|
-
})}
|
|
412
|
-
</div>
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Reuse pre-computed auto-layout result for flat fields
|
|
417
|
-
const layoutResult = autoLayoutResult ?? applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
|
|
418
|
-
|
|
419
|
-
// Flat fields layout — use container-query grid classes so the form
|
|
420
|
-
// responds to the modal width, not the viewport width.
|
|
421
|
-
const containerFieldClass = CONTAINER_GRID_COLS[layoutResult.columns || 1];
|
|
422
|
-
|
|
423
|
-
return (
|
|
424
|
-
<SchemaRenderer
|
|
425
|
-
schema={{
|
|
426
|
-
...baseFormSchema,
|
|
427
|
-
fields: layoutResult.fields,
|
|
428
|
-
columns: layoutResult.columns,
|
|
429
|
-
...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}),
|
|
430
|
-
}}
|
|
431
|
-
/>
|
|
432
|
-
);
|
|
433
|
-
};
|
|
434
|
-
|
|
435
|
-
const hasFooter = !loading && !error && (showSubmit || showCancel);
|
|
436
|
-
|
|
437
|
-
return (
|
|
438
|
-
<Dialog open={isOpen} onOpenChange={schema.onOpenChange}>
|
|
439
|
-
<MobileDialogContent className={cn(sizeClass, 'flex flex-col h-[100dvh] sm:h-auto sm:max-h-[90vh] overflow-hidden p-0', className, schema.className)}>
|
|
440
|
-
{(schema.title || schema.description) && (
|
|
441
|
-
<DialogHeader className="shrink-0 px-4 pt-4 sm:px-6 sm:pt-6 pb-2 border-b">
|
|
442
|
-
{schema.title && <DialogTitle>{schema.title}</DialogTitle>}
|
|
443
|
-
{schema.description && <DialogDescription>{schema.description}</DialogDescription>}
|
|
444
|
-
</DialogHeader>
|
|
445
|
-
)}
|
|
446
|
-
|
|
447
|
-
<div className="@container flex-1 overflow-y-auto px-4 sm:px-6 py-4">
|
|
448
|
-
{renderContent()}
|
|
449
|
-
</div>
|
|
450
|
-
|
|
451
|
-
{/* Sticky footer — always visible action buttons */}
|
|
452
|
-
{hasFooter && (
|
|
453
|
-
<div className="shrink-0 border-t px-4 sm:px-6 py-3 bg-background" data-testid="modal-form-footer">
|
|
454
|
-
<div className="flex flex-col sm:flex-row gap-2 sm:justify-end">
|
|
455
|
-
{showCancel && (
|
|
456
|
-
<Button
|
|
457
|
-
type="button"
|
|
458
|
-
variant="outline"
|
|
459
|
-
onClick={handleCancel}
|
|
460
|
-
disabled={isSubmitting}
|
|
461
|
-
className="w-full sm:w-auto"
|
|
462
|
-
>
|
|
463
|
-
{cancelLabel}
|
|
464
|
-
</Button>
|
|
465
|
-
)}
|
|
466
|
-
{showSubmit && (
|
|
467
|
-
<Button
|
|
468
|
-
type="submit"
|
|
469
|
-
form={formId}
|
|
470
|
-
disabled={isSubmitting}
|
|
471
|
-
className="w-full sm:w-auto"
|
|
472
|
-
>
|
|
473
|
-
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
474
|
-
{submitLabel}
|
|
475
|
-
</Button>
|
|
476
|
-
)}
|
|
477
|
-
</div>
|
|
478
|
-
</div>
|
|
479
|
-
)}
|
|
480
|
-
</MobileDialogContent>
|
|
481
|
-
</Dialog>
|
|
482
|
-
);
|
|
483
|
-
};
|
|
484
|
-
|
|
485
|
-
export default ModalForm;
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
|
|
2
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
-
import '@testing-library/jest-dom';
|
|
4
|
-
import { ObjectForm } from './ObjectForm';
|
|
5
|
-
import { ObjectStackAdapter } from '@object-ui/data-objectstack';
|
|
6
|
-
import { setupServer } from 'msw/node';
|
|
7
|
-
import { http, HttpResponse } from 'msw';
|
|
8
|
-
import { registerAllFields } from '@object-ui/fields';
|
|
9
|
-
import React from 'react';
|
|
10
|
-
import { ContactObject } from '../../../examples/crm/src/objects/contact.object';
|
|
11
|
-
|
|
12
|
-
// Register widget renderers
|
|
13
|
-
registerAllFields();
|
|
14
|
-
|
|
15
|
-
const BASE_URL = process.env.OBJECTSTACK_API_URL || 'http://localhost';
|
|
16
|
-
|
|
17
|
-
// --- Mock Data ---
|
|
18
|
-
|
|
19
|
-
const mockSchema = ContactObject;
|
|
20
|
-
|
|
21
|
-
const mockRecord = {
|
|
22
|
-
id: '1',
|
|
23
|
-
name: 'Alice Johnson',
|
|
24
|
-
email: 'alice@example.com',
|
|
25
|
-
status: 'Active'
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
// --- MSW Setup ---
|
|
29
|
-
|
|
30
|
-
const handlers = [
|
|
31
|
-
// .well-known discovery endpoint (used by client.connect())
|
|
32
|
-
http.get(`${BASE_URL}/.well-known/objectstack`, () => {
|
|
33
|
-
return HttpResponse.json({
|
|
34
|
-
name: 'ObjectStack API',
|
|
35
|
-
version: '1.0',
|
|
36
|
-
endpoints: {
|
|
37
|
-
data: '/api/v1/data',
|
|
38
|
-
metadata: '/api/v1/meta'
|
|
39
|
-
},
|
|
40
|
-
capabilities: {
|
|
41
|
-
graphql: false,
|
|
42
|
-
search: false,
|
|
43
|
-
websockets: false,
|
|
44
|
-
files: true,
|
|
45
|
-
analytics: false,
|
|
46
|
-
hub: false
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
}),
|
|
50
|
-
|
|
51
|
-
// OPTIONS handler for CORS preflight
|
|
52
|
-
http.options(`${BASE_URL}/*`, () => {
|
|
53
|
-
return new HttpResponse(null, {
|
|
54
|
-
status: 200,
|
|
55
|
-
headers: {
|
|
56
|
-
'Access-Control-Allow-Origin': '*',
|
|
57
|
-
'Access-Control-Allow-Methods': 'GET,HEAD,POST,PUT,DELETE,CONNECT,OPTIONS,TRACE,PATCH',
|
|
58
|
-
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
59
|
-
},
|
|
60
|
-
});
|
|
61
|
-
}),
|
|
62
|
-
|
|
63
|
-
// Health check / Connection check (ObjectStackClient often pings root or /api/v1)
|
|
64
|
-
http.get(`${BASE_URL}/api/v1`, () => {
|
|
65
|
-
return HttpResponse.json({ status: 'ok', version: '1.0.0' });
|
|
66
|
-
}),
|
|
67
|
-
|
|
68
|
-
// Mock Schema Fetch: GET /api/v1/metadata/object/:name and /api/v1/meta/object/:name (client uses /meta)
|
|
69
|
-
http.get(`${BASE_URL}/api/v1/metadata/object/:name`, ({ params }) => {
|
|
70
|
-
const { name } = params;
|
|
71
|
-
if (name === 'contact') {
|
|
72
|
-
return HttpResponse.json(mockSchema);
|
|
73
|
-
}
|
|
74
|
-
return new HttpResponse(null, { status: 404 });
|
|
75
|
-
}),
|
|
76
|
-
http.get(`${BASE_URL}/api/v1/meta/object/:name`, ({ params }) => {
|
|
77
|
-
const { name } = params;
|
|
78
|
-
if (name === 'contact') {
|
|
79
|
-
return HttpResponse.json(mockSchema);
|
|
80
|
-
}
|
|
81
|
-
return new HttpResponse(null, { status: 404 });
|
|
82
|
-
}),
|
|
83
|
-
|
|
84
|
-
// Mock Record Fetch: GET /api/v1/data/:object/:id
|
|
85
|
-
http.get(`${BASE_URL}/api/v1/data/:object/:id`, ({ params }) => {
|
|
86
|
-
const { object, id } = params;
|
|
87
|
-
if (object === 'contact' && id === '1') {
|
|
88
|
-
return HttpResponse.json({ record: mockRecord });
|
|
89
|
-
}
|
|
90
|
-
return new HttpResponse(null, { status: 404 });
|
|
91
|
-
})
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
const server = setupServer(...handlers);
|
|
95
|
-
|
|
96
|
-
// --- Test Suite ---
|
|
97
|
-
|
|
98
|
-
describe('ObjectForm with ObjectStack/MSW', () => {
|
|
99
|
-
// Only start MSW if we are NOT using a real server
|
|
100
|
-
if (!process.env.OBJECTSTACK_API_URL) {
|
|
101
|
-
beforeAll(() => server.listen());
|
|
102
|
-
afterEach(() => server.resetHandlers());
|
|
103
|
-
afterAll(() => server.close());
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Create real adapter instance pointing to MSW or Real Server
|
|
107
|
-
const dataSource = new ObjectStackAdapter({
|
|
108
|
-
baseUrl: BASE_URL,
|
|
109
|
-
// Add custom fetch for environment that might need it, or rely on global fetch
|
|
110
|
-
// fetch: global.fetch
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('loads schema and renders form fields', async () => {
|
|
114
|
-
render(
|
|
115
|
-
<ObjectForm
|
|
116
|
-
schema={{
|
|
117
|
-
type: 'object-form',
|
|
118
|
-
objectName: 'contact', // Triggers schema fetch
|
|
119
|
-
mode: 'create'
|
|
120
|
-
}}
|
|
121
|
-
dataSource={dataSource} // Logic moves from mock fn to real adapter + MSW
|
|
122
|
-
/>
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
// Verify fields appear (async as schema loads via HTTP)
|
|
126
|
-
await waitFor(() => {
|
|
127
|
-
// Changed from 'Full Name' to 'Name' based on CRM example schema
|
|
128
|
-
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
129
|
-
}, { timeout: 2000 }); // Give slight buffer for network mock
|
|
130
|
-
expect(screen.getByText('Email')).toBeInTheDocument();
|
|
131
|
-
expect(screen.getByText('Status')).toBeInTheDocument();
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('loads record data in edit mode', async () => {
|
|
135
|
-
render(
|
|
136
|
-
<ObjectForm
|
|
137
|
-
schema={{
|
|
138
|
-
type: 'object-form',
|
|
139
|
-
objectName: 'contact',
|
|
140
|
-
mode: 'edit',
|
|
141
|
-
recordId: '1'
|
|
142
|
-
}}
|
|
143
|
-
dataSource={dataSource}
|
|
144
|
-
/>
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
// Initial load of schema logic + data fetch
|
|
148
|
-
await waitFor(() => {
|
|
149
|
-
// Changed from 'Full Name' to 'Name'
|
|
150
|
-
expect(screen.getByRole('textbox', { name: /Name/i })).toHaveValue('Alice Johnson');
|
|
151
|
-
}, { timeout: 2000 }); // Give slight buffer for network mock
|
|
152
|
-
|
|
153
|
-
// Changed from 'Email Address' to 'Email'
|
|
154
|
-
expect(screen.getByRole('textbox', { name: /Email/i })).toHaveValue('alice@example.com');
|
|
155
|
-
});
|
|
156
|
-
});
|