@object-ui/plugin-form 0.5.0 → 3.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.
@@ -0,0 +1,286 @@
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
+ * P3.4 Integration Test — Form Validation + Submit Workflow
11
+ *
12
+ * Tests the full lifecycle: required-field validation → inline errors →
13
+ * successful submit → form reset.
14
+ */
15
+
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
17
+ import { render, screen, waitFor } from '@testing-library/react';
18
+ import userEvent from '@testing-library/user-event';
19
+ import React from 'react';
20
+ import { ObjectForm } from '../ObjectForm';
21
+
22
+ // ─── Shared Fixtures ────────────────────────────────────────────────────
23
+
24
+ const mockObjectSchema = {
25
+ name: 'contacts',
26
+ fields: {
27
+ firstName: { label: 'First Name', type: 'text', required: true },
28
+ lastName: { label: 'Last Name', type: 'text', required: false },
29
+ email: { label: 'Email', type: 'email', required: true },
30
+ },
31
+ };
32
+
33
+ const createMockDataSource = () => ({
34
+ getObjectSchema: vi.fn().mockResolvedValue(mockObjectSchema),
35
+ findOne: vi.fn().mockResolvedValue({}),
36
+ find: vi.fn().mockResolvedValue([]),
37
+ create: vi.fn().mockResolvedValue({ id: '1' }),
38
+ update: vi.fn().mockResolvedValue({ id: '1' }),
39
+ delete: vi.fn().mockResolvedValue(true),
40
+ });
41
+
42
+ // ─── Tests ──────────────────────────────────────────────────────────────
43
+
44
+ describe('P3.4 Form Validation + Submit Workflow', () => {
45
+ let mockDataSource: ReturnType<typeof createMockDataSource>;
46
+
47
+ beforeEach(() => {
48
+ mockDataSource = createMockDataSource();
49
+ });
50
+
51
+ // --- Required field validation before submit ---
52
+
53
+ it('validates required fields and prevents submission when empty', async () => {
54
+ const user = userEvent.setup();
55
+ const onSuccess = vi.fn();
56
+
57
+ render(
58
+ <ObjectForm
59
+ schema={{
60
+ type: 'object-form',
61
+ objectName: 'contacts',
62
+ mode: 'create',
63
+ fields: ['firstName', 'email'],
64
+ onSuccess,
65
+ }}
66
+ dataSource={mockDataSource as any}
67
+ />,
68
+ );
69
+
70
+ // Wait for the form to load
71
+ await waitFor(() => {
72
+ expect(screen.getByText('First Name')).toBeInTheDocument();
73
+ });
74
+
75
+ // Click submit without filling in required fields
76
+ const submitButton = screen.getByRole('button', { name: /create/i });
77
+ await user.click(submitButton);
78
+
79
+ // The data source should NOT have been called
80
+ await waitFor(() => {
81
+ expect(mockDataSource.create).not.toHaveBeenCalled();
82
+ });
83
+ expect(onSuccess).not.toHaveBeenCalled();
84
+ });
85
+
86
+ // --- Inline validation errors ---
87
+
88
+ it('shows inline validation error messages for required fields', async () => {
89
+ const user = userEvent.setup();
90
+
91
+ render(
92
+ <ObjectForm
93
+ schema={{
94
+ type: 'object-form',
95
+ objectName: 'contacts',
96
+ mode: 'create',
97
+ fields: ['firstName', 'email'],
98
+ }}
99
+ dataSource={mockDataSource as any}
100
+ />,
101
+ );
102
+
103
+ await waitFor(() => {
104
+ expect(screen.getByText('First Name')).toBeInTheDocument();
105
+ });
106
+
107
+ // Submit with empty required fields
108
+ const submitButton = screen.getByRole('button', { name: /create/i });
109
+ await user.click(submitButton);
110
+
111
+ // Validation messages should appear
112
+ await waitFor(() => {
113
+ expect(screen.getByText(/first name is required/i)).toBeInTheDocument();
114
+ });
115
+ });
116
+
117
+ // --- Successful submit calls handler ---
118
+
119
+ it('calls onSuccess handler after successful submission', async () => {
120
+ const user = userEvent.setup();
121
+ const onSuccess = vi.fn();
122
+
123
+ render(
124
+ <ObjectForm
125
+ schema={{
126
+ type: 'object-form',
127
+ objectName: 'contacts',
128
+ mode: 'create',
129
+ fields: ['firstName', 'email'],
130
+ onSuccess,
131
+ }}
132
+ dataSource={mockDataSource as any}
133
+ />,
134
+ );
135
+
136
+ // Wait for the form to load
137
+ await waitFor(() => {
138
+ expect(screen.getByText('First Name')).toBeInTheDocument();
139
+ });
140
+
141
+ // Fill in the required fields
142
+ const inputs = screen.getAllByRole('textbox');
143
+ // First Name input
144
+ await user.type(inputs[0], 'John');
145
+ // Email input
146
+ await user.type(inputs[1], 'john@example.com');
147
+
148
+ // Submit
149
+ const submitButton = screen.getByRole('button', { name: /create/i });
150
+ await user.click(submitButton);
151
+
152
+ // dataSource.create should have been called
153
+ await waitFor(() => {
154
+ expect(mockDataSource.create).toHaveBeenCalledWith(
155
+ 'contacts',
156
+ expect.objectContaining({
157
+ firstName: 'John',
158
+ email: 'john@example.com',
159
+ }),
160
+ );
161
+ });
162
+
163
+ // onSuccess should have been called with result
164
+ await waitFor(() => {
165
+ expect(onSuccess).toHaveBeenCalledWith({ id: '1' });
166
+ });
167
+ });
168
+
169
+ // --- Form reset after submit ---
170
+
171
+ it('resets form fields after successful submission when showReset is true', async () => {
172
+ const user = userEvent.setup();
173
+
174
+ render(
175
+ <ObjectForm
176
+ schema={{
177
+ type: 'object-form',
178
+ objectName: 'contacts',
179
+ mode: 'create',
180
+ fields: ['firstName', 'email'],
181
+ showReset: true,
182
+ onSuccess: vi.fn(),
183
+ }}
184
+ dataSource={mockDataSource as any}
185
+ />,
186
+ );
187
+
188
+ await waitFor(() => {
189
+ expect(screen.getByText('First Name')).toBeInTheDocument();
190
+ });
191
+
192
+ const inputs = screen.getAllByRole('textbox');
193
+ await user.type(inputs[0], 'Jane');
194
+ await user.type(inputs[1], 'jane@example.com');
195
+
196
+ // Submit
197
+ const submitButton = screen.getByRole('button', { name: /create/i });
198
+ await user.click(submitButton);
199
+
200
+ // After successful submit the inputs should be cleared
201
+ await waitFor(() => {
202
+ expect(mockDataSource.create).toHaveBeenCalled();
203
+ });
204
+
205
+ await waitFor(() => {
206
+ expect(inputs[0]).toHaveValue('');
207
+ });
208
+ });
209
+
210
+ // --- Inline-field form (no dataSource) ---
211
+
212
+ it('supports inline customFields without a dataSource', async () => {
213
+ const user = userEvent.setup();
214
+ const onSuccess = vi.fn();
215
+
216
+ render(
217
+ <ObjectForm
218
+ schema={{
219
+ type: 'object-form',
220
+ objectName: 'inline_form',
221
+ mode: 'create',
222
+ customFields: [
223
+ { name: 'username', label: 'Username', type: 'input', required: true },
224
+ ],
225
+ onSuccess,
226
+ }}
227
+ />,
228
+ );
229
+
230
+ await waitFor(() => {
231
+ expect(screen.getByText('Username')).toBeInTheDocument();
232
+ });
233
+
234
+ // Submit empty — should not succeed
235
+ const submitButton = screen.getByRole('button', { name: /create/i });
236
+ await user.click(submitButton);
237
+ expect(onSuccess).not.toHaveBeenCalled();
238
+
239
+ // Fill in and resubmit
240
+ const input = screen.getByRole('textbox');
241
+ await user.type(input, 'admin');
242
+ await user.click(submitButton);
243
+
244
+ await waitFor(() => {
245
+ expect(onSuccess).toHaveBeenCalledWith(
246
+ expect.objectContaining({ username: 'admin' }),
247
+ );
248
+ });
249
+ });
250
+
251
+ // --- Error callback on failed submission ---
252
+
253
+ it('calls onError when dataSource.create rejects', async () => {
254
+ const user = userEvent.setup();
255
+ const onError = vi.fn();
256
+ mockDataSource.create.mockRejectedValueOnce(new Error('Network failure'));
257
+
258
+ render(
259
+ <ObjectForm
260
+ schema={{
261
+ type: 'object-form',
262
+ objectName: 'contacts',
263
+ mode: 'create',
264
+ fields: ['firstName', 'email'],
265
+ onError,
266
+ }}
267
+ dataSource={mockDataSource as any}
268
+ />,
269
+ );
270
+
271
+ await waitFor(() => {
272
+ expect(screen.getByText('First Name')).toBeInTheDocument();
273
+ });
274
+
275
+ const inputs = screen.getAllByRole('textbox');
276
+ await user.type(inputs[0], 'John');
277
+ await user.type(inputs[1], 'john@example.com');
278
+
279
+ const submitButton = screen.getByRole('button', { name: /create/i });
280
+ await user.click(submitButton);
281
+
282
+ await waitFor(() => {
283
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
284
+ });
285
+ });
286
+ });
package/src/index.tsx CHANGED
@@ -12,6 +12,18 @@ import { ObjectForm } from './ObjectForm';
12
12
 
13
13
  export { ObjectForm };
14
14
  export type { ObjectFormProps } from './ObjectForm';
15
+ export { FormSection } from './FormSection';
16
+ export type { FormSectionProps } from './FormSection';
17
+ export { TabbedForm } from './TabbedForm';
18
+ export type { TabbedFormProps, TabbedFormSchema, FormSectionConfig } from './TabbedForm';
19
+ export { WizardForm } from './WizardForm';
20
+ export type { WizardFormProps, WizardFormSchema } from './WizardForm';
21
+ export { SplitForm } from './SplitForm';
22
+ export type { SplitFormProps, SplitFormSchema } from './SplitForm';
23
+ export { DrawerForm } from './DrawerForm';
24
+ export type { DrawerFormProps, DrawerFormSchema } from './DrawerForm';
25
+ export { ModalForm } from './ModalForm';
26
+ export type { ModalFormProps, ModalFormSchema } from './ModalForm';
15
27
 
16
28
  // Register object-form component
17
29
  const ObjectFormRenderer: React.FC<{ schema: any }> = ({ schema }) => {
@@ -19,7 +31,52 @@ const ObjectFormRenderer: React.FC<{ schema: any }> = ({ schema }) => {
19
31
  };
20
32
 
21
33
  ComponentRegistry.register('object-form', ObjectFormRenderer, {
22
- namespace: 'plugin-form'
34
+ namespace: 'plugin-form',
35
+ label: 'Object Form',
36
+ category: 'plugin',
37
+ inputs: [
38
+ { name: 'objectName', type: 'string', label: 'Object Name', required: true },
39
+ { name: 'fields', type: 'array', label: 'Fields' },
40
+ { name: 'mode', type: 'enum', label: 'Mode', enum: ['create', 'edit', 'view'] },
41
+ { name: 'formType', type: 'enum', label: 'Form Type', enum: ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal'] },
42
+ { name: 'sections', type: 'array', label: 'Sections' },
43
+ { name: 'title', type: 'string', label: 'Title' },
44
+ { name: 'description', type: 'string', label: 'Description' },
45
+ { name: 'layout', type: 'enum', label: 'Layout', enum: ['vertical', 'horizontal', 'inline', 'grid'] },
46
+ { name: 'columns', type: 'number', label: 'Columns' },
47
+ // Tabbed
48
+ { name: 'defaultTab', type: 'string', label: 'Default Tab' },
49
+ { name: 'tabPosition', type: 'enum', label: 'Tab Position', enum: ['top', 'bottom', 'left', 'right'] },
50
+ // Wizard
51
+ { name: 'allowSkip', type: 'boolean', label: 'Allow Skip Steps' },
52
+ { name: 'showStepIndicator', type: 'boolean', label: 'Show Step Indicator' },
53
+ // Split
54
+ { name: 'splitDirection', type: 'enum', label: 'Split Direction', enum: ['horizontal', 'vertical'] },
55
+ { name: 'splitSize', type: 'number', label: 'Split Panel Size (%)' },
56
+ { name: 'splitResizable', type: 'boolean', label: 'Split Resizable' },
57
+ // Drawer
58
+ { name: 'drawerSide', type: 'enum', label: 'Drawer Side', enum: ['top', 'bottom', 'left', 'right'] },
59
+ { name: 'drawerWidth', type: 'string', label: 'Drawer Width' },
60
+ // Modal
61
+ { name: 'modalSize', type: 'enum', label: 'Modal Size', enum: ['sm', 'default', 'lg', 'xl', 'full'] },
62
+ ]
23
63
  });
24
- // Note: 'form' type is handled by @object-ui/components Form component
25
- // This plugin only handles 'object-form' which integrates with ObjectQL/ObjectStack
64
+
65
+ // Register as view:form for the standard view protocol
66
+ // This allows using { type: 'view:form', objectName: '...' } in schemas
67
+ // skipFallback prevents overwriting the basic 'form' component from @object-ui/components
68
+ ComponentRegistry.register('form', ObjectFormRenderer, {
69
+ namespace: 'view',
70
+ skipFallback: true,
71
+ label: 'Data Form View',
72
+ category: 'view',
73
+ inputs: [
74
+ { name: 'objectName', type: 'string', label: 'Object Name', required: true },
75
+ { name: 'fields', type: 'array', label: 'Fields' },
76
+ { name: 'mode', type: 'enum', label: 'Mode', enum: ['create', 'edit', 'view'] },
77
+ ]
78
+ });
79
+
80
+ // Note: 'form' type (without namespace) is handled by @object-ui/components Form component
81
+ // This plugin registers as 'view:form' (with view namespace) for the view protocol
82
+ // ObjectForm internally uses { type: 'form' } to render the basic Form component
@@ -0,0 +1,12 @@
1
+ /// <reference types="vitest" />
2
+ import { defineConfig } from 'vite';
3
+ import react from '@vitejs/plugin-react';
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ test: {
8
+ environment: 'happy-dom',
9
+ globals: true,
10
+ setupFiles: ['./vitest.setup.ts'],
11
+ },
12
+ });
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom';