@object-ui/plugin-form 0.3.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +21 -0
- package/CHANGELOG.md +15 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1400 -280
- package/dist/index.umd.cjs +2 -2
- package/dist/packages/plugin-form/src/DrawerForm.d.ts +61 -0
- package/dist/packages/plugin-form/src/FormSection.d.ts +49 -0
- package/dist/packages/plugin-form/src/FormVariants.test.d.ts +0 -0
- package/dist/packages/plugin-form/src/ModalForm.d.ts +60 -0
- package/dist/packages/plugin-form/src/ObjectForm.msw.test.d.ts +0 -0
- package/dist/packages/plugin-form/src/ObjectForm.test.d.ts +1 -0
- package/dist/packages/plugin-form/src/SplitForm.d.ts +50 -0
- package/dist/packages/plugin-form/src/TabbedForm.d.ts +123 -0
- package/dist/packages/plugin-form/src/WizardForm.d.ts +112 -0
- package/dist/packages/plugin-form/src/__tests__/NewVariants.test.d.ts +8 -0
- package/dist/packages/plugin-form/src/index.d.ts +15 -0
- package/package.json +10 -8
- package/src/DrawerForm.tsx +385 -0
- package/src/FormSection.tsx +144 -0
- package/src/FormVariants.test.tsx +219 -0
- package/src/ModalForm.tsx +379 -0
- package/src/ObjectForm.msw.test.tsx +156 -0
- package/src/ObjectForm.test.tsx +61 -0
- package/src/ObjectForm.tsx +267 -15
- package/src/SplitForm.tsx +299 -0
- package/src/TabbedForm.tsx +394 -0
- package/src/WizardForm.tsx +501 -0
- package/src/__tests__/NewVariants.test.tsx +488 -0
- package/src/index.tsx +62 -2
- package/vite.config.ts +18 -0
- package/vitest.config.ts +12 -0
- package/vitest.setup.ts +1 -0
- package/dist/plugin-form/src/index.d.ts +0 -3
- /package/dist/{plugin-form → packages/plugin-form}/src/ObjectForm.d.ts +0 -0
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { ObjectForm } from './ObjectForm';
|
|
4
|
+
import { registerAllFields } from '@object-ui/fields';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
// Ensure fields are registered
|
|
8
|
+
registerAllFields();
|
|
9
|
+
|
|
10
|
+
describe('ObjectForm Integration', () => {
|
|
11
|
+
const objectSchema = {
|
|
12
|
+
name: 'test_object',
|
|
13
|
+
fields: {
|
|
14
|
+
name: {
|
|
15
|
+
type: 'text',
|
|
16
|
+
label: 'Name'
|
|
17
|
+
},
|
|
18
|
+
price: {
|
|
19
|
+
type: 'currency',
|
|
20
|
+
label: 'Price',
|
|
21
|
+
scale: 2
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const mockDataSource: any = {
|
|
27
|
+
getObjectSchema: vi.fn().mockResolvedValue(objectSchema),
|
|
28
|
+
createRecord: vi.fn(),
|
|
29
|
+
updateRecord: vi.fn(),
|
|
30
|
+
getRecord: vi.fn(),
|
|
31
|
+
query: vi.fn()
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
it('renders fields using specialized components', async () => {
|
|
35
|
+
render(
|
|
36
|
+
<ObjectForm
|
|
37
|
+
schema={{
|
|
38
|
+
type: 'object-form',
|
|
39
|
+
objectName: 'test_object',
|
|
40
|
+
mode: 'create'
|
|
41
|
+
}}
|
|
42
|
+
dataSource={mockDataSource}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Wait for schema to load (useEffect)
|
|
47
|
+
await waitFor(() => {
|
|
48
|
+
expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('test_object');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Check if labels are present
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
expect(screen.queryByText('Name')).toBeTruthy();
|
|
54
|
+
});
|
|
55
|
+
expect(screen.getByText('Price')).toBeTruthy();
|
|
56
|
+
|
|
57
|
+
// Assert input exists
|
|
58
|
+
// Since we don't have getByLabelText working reliably without full accessibility tree in happy-dom sometimes,
|
|
59
|
+
// we can try looking for inputs.
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/ObjectForm.tsx
CHANGED
|
@@ -17,6 +17,12 @@ import React, { useEffect, useState, useCallback } from 'react';
|
|
|
17
17
|
import type { ObjectFormSchema, FormField, FormSchema, DataSource } from '@object-ui/types';
|
|
18
18
|
import { SchemaRenderer } from '@object-ui/react';
|
|
19
19
|
import { mapFieldTypeToFormType, buildValidationRules, evaluateCondition, formatFileSize } from '@object-ui/fields';
|
|
20
|
+
import { TabbedForm } from './TabbedForm';
|
|
21
|
+
import { WizardForm } from './WizardForm';
|
|
22
|
+
import { SplitForm } from './SplitForm';
|
|
23
|
+
import { DrawerForm } from './DrawerForm';
|
|
24
|
+
import { ModalForm } from './ModalForm';
|
|
25
|
+
import { FormSection } from './FormSection';
|
|
20
26
|
|
|
21
27
|
export interface ObjectFormProps {
|
|
22
28
|
/**
|
|
@@ -58,6 +64,144 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
58
64
|
schema,
|
|
59
65
|
dataSource,
|
|
60
66
|
}) => {
|
|
67
|
+
|
|
68
|
+
// Route to specialized form variant based on formType
|
|
69
|
+
if (schema.formType === 'tabbed' && schema.sections?.length) {
|
|
70
|
+
return (
|
|
71
|
+
<TabbedForm
|
|
72
|
+
schema={{
|
|
73
|
+
...schema,
|
|
74
|
+
formType: 'tabbed',
|
|
75
|
+
sections: schema.sections.map(s => ({
|
|
76
|
+
name: s.name,
|
|
77
|
+
label: s.label,
|
|
78
|
+
description: s.description,
|
|
79
|
+
columns: s.columns,
|
|
80
|
+
fields: s.fields,
|
|
81
|
+
})),
|
|
82
|
+
defaultTab: schema.defaultTab,
|
|
83
|
+
tabPosition: schema.tabPosition,
|
|
84
|
+
}}
|
|
85
|
+
dataSource={dataSource}
|
|
86
|
+
className={schema.className}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (schema.formType === 'wizard' && schema.sections?.length) {
|
|
92
|
+
return (
|
|
93
|
+
<WizardForm
|
|
94
|
+
schema={{
|
|
95
|
+
...schema,
|
|
96
|
+
formType: 'wizard',
|
|
97
|
+
sections: schema.sections.map(s => ({
|
|
98
|
+
name: s.name,
|
|
99
|
+
label: s.label,
|
|
100
|
+
description: s.description,
|
|
101
|
+
columns: s.columns,
|
|
102
|
+
fields: s.fields,
|
|
103
|
+
})),
|
|
104
|
+
allowSkip: schema.allowSkip,
|
|
105
|
+
showStepIndicator: schema.showStepIndicator,
|
|
106
|
+
nextText: schema.nextText,
|
|
107
|
+
prevText: schema.prevText,
|
|
108
|
+
onStepChange: schema.onStepChange,
|
|
109
|
+
}}
|
|
110
|
+
dataSource={dataSource}
|
|
111
|
+
className={schema.className}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (schema.formType === 'split' && schema.sections?.length) {
|
|
117
|
+
return (
|
|
118
|
+
<SplitForm
|
|
119
|
+
schema={{
|
|
120
|
+
...schema,
|
|
121
|
+
formType: 'split',
|
|
122
|
+
sections: schema.sections.map(s => ({
|
|
123
|
+
name: s.name,
|
|
124
|
+
label: s.label,
|
|
125
|
+
description: s.description,
|
|
126
|
+
columns: s.columns,
|
|
127
|
+
fields: s.fields,
|
|
128
|
+
})),
|
|
129
|
+
splitDirection: schema.splitDirection,
|
|
130
|
+
splitSize: schema.splitSize,
|
|
131
|
+
splitResizable: schema.splitResizable,
|
|
132
|
+
}}
|
|
133
|
+
dataSource={dataSource}
|
|
134
|
+
className={schema.className}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (schema.formType === 'drawer') {
|
|
140
|
+
const { layout: _layout, ...drawerRest } = schema;
|
|
141
|
+
const drawerLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal') ? schema.layout : undefined;
|
|
142
|
+
return (
|
|
143
|
+
<DrawerForm
|
|
144
|
+
schema={{
|
|
145
|
+
...drawerRest,
|
|
146
|
+
layout: drawerLayout,
|
|
147
|
+
formType: 'drawer',
|
|
148
|
+
sections: schema.sections?.map(s => ({
|
|
149
|
+
name: s.name,
|
|
150
|
+
label: s.label,
|
|
151
|
+
description: s.description,
|
|
152
|
+
columns: s.columns,
|
|
153
|
+
fields: s.fields,
|
|
154
|
+
})),
|
|
155
|
+
open: schema.open,
|
|
156
|
+
onOpenChange: schema.onOpenChange,
|
|
157
|
+
drawerSide: schema.drawerSide,
|
|
158
|
+
drawerWidth: schema.drawerWidth,
|
|
159
|
+
}}
|
|
160
|
+
dataSource={dataSource}
|
|
161
|
+
className={schema.className}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (schema.formType === 'modal') {
|
|
167
|
+
const { layout: _layout2, ...modalRest } = schema;
|
|
168
|
+
const modalLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal') ? schema.layout : undefined;
|
|
169
|
+
return (
|
|
170
|
+
<ModalForm
|
|
171
|
+
schema={{
|
|
172
|
+
...modalRest,
|
|
173
|
+
layout: modalLayout,
|
|
174
|
+
formType: 'modal',
|
|
175
|
+
sections: schema.sections?.map(s => ({
|
|
176
|
+
name: s.name,
|
|
177
|
+
label: s.label,
|
|
178
|
+
description: s.description,
|
|
179
|
+
columns: s.columns,
|
|
180
|
+
fields: s.fields,
|
|
181
|
+
})),
|
|
182
|
+
open: schema.open,
|
|
183
|
+
onOpenChange: schema.onOpenChange,
|
|
184
|
+
modalSize: schema.modalSize,
|
|
185
|
+
modalCloseButton: schema.modalCloseButton,
|
|
186
|
+
}}
|
|
187
|
+
dataSource={dataSource}
|
|
188
|
+
className={schema.className}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Default: simple form
|
|
194
|
+
return <SimpleObjectForm schema={schema} dataSource={dataSource} />;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* SimpleObjectForm — default form variant with auto-generated fields from ObjectQL schema.
|
|
199
|
+
*/
|
|
200
|
+
const SimpleObjectForm: React.FC<ObjectFormProps> = ({
|
|
201
|
+
schema,
|
|
202
|
+
dataSource,
|
|
203
|
+
}) => {
|
|
204
|
+
|
|
61
205
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
62
206
|
const [formFields, setFormFields] = useState<FormField[]>([]);
|
|
63
207
|
const [initialData, setInitialData] = useState<any>(null);
|
|
@@ -83,10 +227,13 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
83
227
|
throw new Error('DataSource is required when using ObjectQL schema fetching (inline fields not provided)');
|
|
84
228
|
}
|
|
85
229
|
const schemaData = await dataSource.getObjectSchema(schema.objectName);
|
|
230
|
+
if (!schemaData) {
|
|
231
|
+
throw new Error(`No schema found for object "${schema.objectName}"`);
|
|
232
|
+
}
|
|
86
233
|
setObjectSchema(schemaData);
|
|
87
234
|
} catch (err) {
|
|
88
|
-
console.error('Failed to fetch object schema:', err);
|
|
89
235
|
setError(err as Error);
|
|
236
|
+
setLoading(false);
|
|
90
237
|
}
|
|
91
238
|
};
|
|
92
239
|
|
|
@@ -99,6 +246,9 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
99
246
|
});
|
|
100
247
|
} else if (schema.objectName && dataSource) {
|
|
101
248
|
fetchObjectSchema();
|
|
249
|
+
} else if (!hasInlineFields) {
|
|
250
|
+
// No objectName or dataSource and no inline fields — cannot proceed
|
|
251
|
+
setLoading(false);
|
|
102
252
|
}
|
|
103
253
|
}, [schema.objectName, dataSource, hasInlineFields]);
|
|
104
254
|
|
|
@@ -107,6 +257,7 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
107
257
|
const fetchInitialData = async () => {
|
|
108
258
|
if (!schema.recordId || schema.mode === 'create') {
|
|
109
259
|
setInitialData(schema.initialData || schema.initialValues || {});
|
|
260
|
+
setLoading(false);
|
|
110
261
|
return;
|
|
111
262
|
}
|
|
112
263
|
|
|
@@ -154,23 +305,33 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
154
305
|
// Determine which fields to include
|
|
155
306
|
const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {});
|
|
156
307
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
308
|
+
// Support object format for fields in schema (legacy/compat)
|
|
309
|
+
const fieldNames = Array.isArray(fieldsToShow)
|
|
310
|
+
? fieldsToShow
|
|
311
|
+
: Object.keys(fieldsToShow);
|
|
312
|
+
|
|
313
|
+
fieldNames.forEach((fieldName) => {
|
|
314
|
+
// If fieldsToShow is an array of strings, fieldName is the string
|
|
315
|
+
// If fieldsToShow is array of objects (unlikely but possible in some formats), we need to extract name
|
|
316
|
+
const name = typeof fieldName === 'string' ? fieldName : (fieldName as any).name;
|
|
317
|
+
if (!name) return;
|
|
318
|
+
|
|
319
|
+
const field = objectSchema.fields?.[name];
|
|
320
|
+
if (!field && !hasInlineFields) return; // Skip if not found in object definition unless inline
|
|
160
321
|
|
|
161
322
|
// Check field-level permissions for create/edit modes
|
|
162
|
-
const hasWritePermission = !field
|
|
323
|
+
const hasWritePermission = !field?.permissions || field?.permissions.write !== false;
|
|
163
324
|
if (schema.mode !== 'view' && !hasWritePermission) return; // Skip fields without write permission
|
|
164
325
|
|
|
165
326
|
// Check if there's a custom field configuration
|
|
166
|
-
const customField = schema.customFields?.find(f => f.name ===
|
|
327
|
+
const customField = schema.customFields?.find(f => f.name === name);
|
|
167
328
|
|
|
168
329
|
if (customField) {
|
|
169
330
|
generatedFields.push(customField);
|
|
170
|
-
} else {
|
|
331
|
+
} else if (field) {
|
|
171
332
|
// Auto-generate field from schema
|
|
172
333
|
const formField: FormField = {
|
|
173
|
-
name:
|
|
334
|
+
name: name,
|
|
174
335
|
label: field.label || fieldName,
|
|
175
336
|
type: mapFieldTypeToFormType(field.type),
|
|
176
337
|
required: field.required || false,
|
|
@@ -178,6 +339,8 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
178
339
|
placeholder: field.placeholder,
|
|
179
340
|
description: field.help || field.description,
|
|
180
341
|
validation: buildValidationRules(field),
|
|
342
|
+
// Important: Pass the original field metadata so widgets can access properties like precision, currency, etc.
|
|
343
|
+
field: field,
|
|
181
344
|
};
|
|
182
345
|
|
|
183
346
|
// Add field-specific properties
|
|
@@ -187,17 +350,27 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
187
350
|
}
|
|
188
351
|
|
|
189
352
|
if (field.type === 'number' || field.type === 'currency' || field.type === 'percent') {
|
|
353
|
+
formField.inputType = 'number';
|
|
190
354
|
formField.min = field.min;
|
|
191
355
|
formField.max = field.max;
|
|
192
356
|
formField.step = field.precision ? Math.pow(10, -field.precision) : undefined;
|
|
193
357
|
}
|
|
194
358
|
|
|
359
|
+
if (field.type === 'date') {
|
|
360
|
+
formField.inputType = 'date';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (field.type === 'datetime') {
|
|
364
|
+
formField.inputType = 'datetime-local';
|
|
365
|
+
}
|
|
366
|
+
|
|
195
367
|
if (field.type === 'text' || field.type === 'textarea' || field.type === 'markdown' || field.type === 'html') {
|
|
196
368
|
formField.maxLength = field.max_length;
|
|
197
369
|
formField.minLength = field.min_length;
|
|
198
370
|
}
|
|
199
371
|
|
|
200
372
|
if (field.type === 'file' || field.type === 'image') {
|
|
373
|
+
formField.inputType = 'file';
|
|
201
374
|
formField.multiple = field.multiple;
|
|
202
375
|
formField.accept = field.accept ? field.accept.join(',') : undefined;
|
|
203
376
|
// Add validation hints for file size and dimensions
|
|
@@ -246,11 +419,29 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
246
419
|
});
|
|
247
420
|
|
|
248
421
|
setFormFields(generatedFields);
|
|
249
|
-
|
|
250
|
-
|
|
422
|
+
|
|
423
|
+
// Only set loading to false if we are not going to fetch data
|
|
424
|
+
// This prevents a flash of empty form before data is loaded in edit mode
|
|
425
|
+
const willFetchData = !hasInlineFields && (schema.recordId && schema.mode !== 'create' && dataSource);
|
|
426
|
+
if (!willFetchData) {
|
|
427
|
+
setLoading(false);
|
|
428
|
+
}
|
|
429
|
+
}, [objectSchema, schema.fields, schema.customFields, schema.readOnly, schema.mode, hasInlineFields, schema.recordId, dataSource]);
|
|
251
430
|
|
|
252
431
|
// Handle form submission
|
|
253
|
-
const handleSubmit = useCallback(async (formData: any) => {
|
|
432
|
+
const handleSubmit = useCallback(async (formData: any, e?: any) => {
|
|
433
|
+
// If we receive an event as the first argument, it means the Form renderer passed the event instead of data
|
|
434
|
+
// This happens when react-hook-form's handleSubmit is bypassed or configured incorrectly
|
|
435
|
+
if (formData && (formData.nativeEvent || formData._reactName === 'onSubmit')) {
|
|
436
|
+
console.warn('ObjectForm: Received Event instead of data in handleSubmit! This suggests a Form renderer issue.');
|
|
437
|
+
// Proceed defensively - we can't do much if we don't have data, but let's try to not crash
|
|
438
|
+
// If we are here, formData is actually the event
|
|
439
|
+
if (e === undefined) {
|
|
440
|
+
e = formData;
|
|
441
|
+
formData = {}; // Reset to empty object or we try to submit the Event object
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
254
445
|
// For inline fields without a dataSource, just call the success callback
|
|
255
446
|
if (hasInlineFields && !dataSource) {
|
|
256
447
|
if (schema.onSuccess) {
|
|
@@ -299,6 +490,24 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
299
490
|
}
|
|
300
491
|
}, [schema]);
|
|
301
492
|
|
|
493
|
+
// Calculate default values from schema fields
|
|
494
|
+
const schemaDefaultValues = React.useMemo(() => {
|
|
495
|
+
if (!objectSchema?.fields) return {};
|
|
496
|
+
const defaults: Record<string, any> = {};
|
|
497
|
+
Object.keys(objectSchema.fields).forEach(key => {
|
|
498
|
+
const field = objectSchema.fields[key];
|
|
499
|
+
if (field.defaultValue !== undefined) {
|
|
500
|
+
defaults[key] = field.defaultValue;
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
return defaults;
|
|
504
|
+
}, [objectSchema]);
|
|
505
|
+
|
|
506
|
+
const finalDefaultValues = {
|
|
507
|
+
...schemaDefaultValues,
|
|
508
|
+
...initialData
|
|
509
|
+
};
|
|
510
|
+
|
|
302
511
|
// Render error state
|
|
303
512
|
if (error) {
|
|
304
513
|
return (
|
|
@@ -322,19 +531,62 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
322
531
|
// Convert to FormSchema
|
|
323
532
|
// Note: FormSchema currently only supports 'vertical' and 'horizontal' layouts
|
|
324
533
|
// Map 'grid' and 'inline' to 'vertical' as fallback
|
|
534
|
+
const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
|
|
535
|
+
? schema.layout
|
|
536
|
+
: 'vertical';
|
|
537
|
+
|
|
538
|
+
// If sections are provided for the simple form, render with FormSection grouping
|
|
539
|
+
if (schema.sections?.length && (!schema.formType || schema.formType === 'simple')) {
|
|
540
|
+
return (
|
|
541
|
+
<div className="w-full space-y-6">
|
|
542
|
+
{schema.sections.map((section, index) => {
|
|
543
|
+
// Filter formFields to only include fields in this section
|
|
544
|
+
const sectionFieldNames = section.fields.map(f => typeof f === 'string' ? f : f.name);
|
|
545
|
+
const sectionFields = formFields.filter(f => sectionFieldNames.includes(f.name));
|
|
546
|
+
|
|
547
|
+
return (
|
|
548
|
+
<FormSection
|
|
549
|
+
key={section.name || section.label || index}
|
|
550
|
+
label={section.label}
|
|
551
|
+
description={section.description}
|
|
552
|
+
collapsible={section.collapsible}
|
|
553
|
+
collapsed={section.collapsed}
|
|
554
|
+
columns={section.columns}
|
|
555
|
+
>
|
|
556
|
+
<SchemaRenderer
|
|
557
|
+
schema={{
|
|
558
|
+
type: 'form',
|
|
559
|
+
fields: sectionFields,
|
|
560
|
+
layout: formLayout,
|
|
561
|
+
defaultValues: finalDefaultValues,
|
|
562
|
+
// Only show action buttons after the last section
|
|
563
|
+
showSubmit: index === schema.sections!.length - 1 && schema.showSubmit !== false && schema.mode !== 'view',
|
|
564
|
+
showCancel: index === schema.sections!.length - 1 && schema.showCancel !== false,
|
|
565
|
+
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
|
|
566
|
+
cancelLabel: schema.cancelText,
|
|
567
|
+
onSubmit: handleSubmit,
|
|
568
|
+
onCancel: handleCancel,
|
|
569
|
+
} as FormSchema}
|
|
570
|
+
/>
|
|
571
|
+
</FormSection>
|
|
572
|
+
);
|
|
573
|
+
})}
|
|
574
|
+
</div>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Default flat form (no sections)
|
|
325
579
|
const formSchema: FormSchema = {
|
|
326
580
|
type: 'form',
|
|
327
581
|
fields: formFields,
|
|
328
|
-
layout:
|
|
329
|
-
? schema.layout
|
|
330
|
-
: 'vertical',
|
|
582
|
+
layout: formLayout,
|
|
331
583
|
columns: schema.columns,
|
|
332
584
|
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
|
|
333
585
|
cancelLabel: schema.cancelText,
|
|
334
586
|
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
|
|
335
587
|
showCancel: schema.showCancel !== false,
|
|
336
588
|
resetOnSubmit: schema.showReset,
|
|
337
|
-
defaultValues:
|
|
589
|
+
defaultValues: finalDefaultValues,
|
|
338
590
|
onSubmit: handleSubmit,
|
|
339
591
|
onCancel: handleCancel,
|
|
340
592
|
className: schema.className,
|