@object-ui/plugin-form 3.3.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,502 +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
- * WizardForm Component
11
- *
12
- * A multi-step wizard form that guides users through sections step by step.
13
- * Aligns with @objectstack/spec FormView type: 'wizard'
14
- */
15
-
16
- import React, { useState, useCallback, useMemo } from 'react';
17
- import type { FormField, DataSource } from '@object-ui/types';
18
- import { Button, cn } from '@object-ui/components';
19
- import { Check, ChevronLeft, ChevronRight } from 'lucide-react';
20
- import { FormSection } from './FormSection';
21
- import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
22
- import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
23
- import type { FormSectionConfig } from './TabbedForm';
24
-
25
- export interface WizardFormSchema {
26
- type: 'object-form';
27
- formType: 'wizard';
28
-
29
- /**
30
- * Object name for ObjectQL schema lookup
31
- */
32
- objectName: string;
33
-
34
- /**
35
- * Form mode
36
- */
37
- mode: 'create' | 'edit' | 'view';
38
-
39
- /**
40
- * Record ID (for edit/view modes)
41
- */
42
- recordId?: string | number;
43
-
44
- /**
45
- * Wizard step sections
46
- */
47
- sections: FormSectionConfig[];
48
-
49
- /**
50
- * Allow navigation to any step (not just sequential)
51
- * @default false
52
- */
53
- allowSkip?: boolean;
54
-
55
- /**
56
- * Show step indicators
57
- * @default true
58
- */
59
- showStepIndicator?: boolean;
60
-
61
- /**
62
- * Text for Next button
63
- * @default 'Next'
64
- */
65
- nextText?: string;
66
-
67
- /**
68
- * Text for Previous button
69
- * @default 'Back'
70
- */
71
- prevText?: string;
72
-
73
- /**
74
- * Submit button text (shown on last step)
75
- */
76
- submitText?: string;
77
-
78
- /**
79
- * Show cancel button
80
- * @default true
81
- */
82
- showCancel?: boolean;
83
-
84
- /**
85
- * Cancel button text
86
- */
87
- cancelText?: string;
88
-
89
- /**
90
- * Initial values
91
- */
92
- initialValues?: Record<string, any>;
93
-
94
- /**
95
- * Initial data (alias)
96
- */
97
- initialData?: Record<string, any>;
98
-
99
- /**
100
- * Read-only mode
101
- */
102
- readOnly?: boolean;
103
-
104
- /**
105
- * Callbacks
106
- */
107
- onSuccess?: (data: any) => void | Promise<void>;
108
- onError?: (error: Error) => void;
109
- onCancel?: () => void;
110
-
111
- /**
112
- * Called when step changes
113
- */
114
- onStepChange?: (step: number) => void;
115
-
116
- /**
117
- * CSS class
118
- */
119
- className?: string;
120
- }
121
-
122
- export interface WizardFormProps {
123
- schema: WizardFormSchema;
124
- dataSource?: DataSource;
125
- className?: string;
126
- }
127
-
128
- /**
129
- * WizardForm Component
130
- *
131
- * Renders a multi-step wizard form with step indicators and navigation.
132
- *
133
- * @example
134
- * ```tsx
135
- * <WizardForm
136
- * schema={{
137
- * type: 'object-form',
138
- * formType: 'wizard',
139
- * objectName: 'users',
140
- * mode: 'create',
141
- * sections: [
142
- * { label: 'Step 1: Personal', fields: ['firstName', 'lastName'] },
143
- * { label: 'Step 2: Contact', fields: ['email', 'phone'] },
144
- * { label: 'Step 3: Review', fields: [] },
145
- * ]
146
- * }}
147
- * dataSource={dataSource}
148
- * />
149
- * ```
150
- */
151
- export const WizardForm: React.FC<WizardFormProps> = ({
152
- schema,
153
- dataSource,
154
- className,
155
- }) => {
156
- const { fieldLabel } = useSafeFieldLabel();
157
- const [objectSchema, setObjectSchema] = useState<any>(null);
158
- const [formData, setFormData] = useState<Record<string, any>>({});
159
- const [loading, setLoading] = useState(true);
160
- const [error, setError] = useState<Error | null>(null);
161
- const [currentStep, setCurrentStep] = useState(0);
162
- const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
163
- const [submitting, setSubmitting] = useState(false);
164
-
165
- const totalSteps = schema.sections.length;
166
- const isFirstStep = currentStep === 0;
167
- const isLastStep = currentStep === totalSteps - 1;
168
-
169
- // Fetch object schema
170
- React.useEffect(() => {
171
- const fetchSchema = async () => {
172
- if (!dataSource) {
173
- setLoading(false);
174
- return;
175
- }
176
-
177
- try {
178
- const schemaData = await dataSource.getObjectSchema(schema.objectName);
179
- setObjectSchema(schemaData);
180
- } catch (err) {
181
- setError(err as Error);
182
- }
183
- };
184
-
185
- fetchSchema();
186
- }, [schema.objectName, dataSource]);
187
-
188
- // Fetch initial data
189
- React.useEffect(() => {
190
- const fetchData = async () => {
191
- if (schema.mode === 'create' || !schema.recordId || !dataSource) {
192
- setFormData(schema.initialData || schema.initialValues || {});
193
- setLoading(false);
194
- return;
195
- }
196
-
197
- try {
198
- const data = await dataSource.findOne(schema.objectName, schema.recordId);
199
- setFormData(data || {});
200
- } catch (err) {
201
- setError(err as Error);
202
- } finally {
203
- setLoading(false);
204
- }
205
- };
206
-
207
- if (objectSchema || !dataSource) {
208
- fetchData();
209
- }
210
- }, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
211
-
212
- // Build section fields from object schema
213
- const buildSectionFields = useCallback((section: FormSectionConfig): FormField[] => {
214
- const fields: FormField[] = [];
215
-
216
- for (const fieldDef of section.fields) {
217
- const fieldName = typeof fieldDef === 'string' ? fieldDef : fieldDef.name;
218
-
219
- if (typeof fieldDef === 'object') {
220
- fields.push(fieldDef);
221
- } else if (objectSchema?.fields?.[fieldName]) {
222
- const field = objectSchema.fields[fieldName];
223
- fields.push({
224
- name: fieldName,
225
- label: fieldLabel(schema.objectName, fieldName, field.label || fieldName),
226
- type: mapFieldTypeToFormType(field.type),
227
- required: field.required || false,
228
- disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
229
- placeholder: field.placeholder,
230
- description: field.help || field.description,
231
- validation: buildValidationRules(field),
232
- field: field,
233
- options: field.options,
234
- multiple: field.multiple,
235
- });
236
- } else {
237
- fields.push({
238
- name: fieldName,
239
- label: fieldName,
240
- type: 'input',
241
- });
242
- }
243
- }
244
-
245
- return fields;
246
- }, [objectSchema, schema.readOnly, schema.mode]);
247
-
248
- // Current section fields
249
- const currentSectionFields = useMemo(() => {
250
- if (currentStep >= 0 && currentStep < totalSteps) {
251
- return buildSectionFields(schema.sections[currentStep]);
252
- }
253
- return [];
254
- }, [currentStep, totalSteps, schema.sections, buildSectionFields]);
255
-
256
- // Handle step data collection (merge partial data into formData)
257
- const handleStepSubmit = useCallback(async (stepData: Record<string, any>) => {
258
- const mergedData = { ...formData, ...stepData };
259
- setFormData(mergedData);
260
-
261
- // Mark step as completed
262
- setCompletedSteps(prev => new Set(prev).add(currentStep));
263
-
264
- if (isLastStep) {
265
- // Final submission
266
- setSubmitting(true);
267
- try {
268
- if (!dataSource) {
269
- if (schema.onSuccess) {
270
- await schema.onSuccess(mergedData);
271
- }
272
- return mergedData;
273
- }
274
-
275
- let result;
276
- if (schema.mode === 'create') {
277
- result = await dataSource.create(schema.objectName, mergedData);
278
- } else if (schema.mode === 'edit' && schema.recordId) {
279
- result = await dataSource.update(schema.objectName, schema.recordId, mergedData);
280
- }
281
-
282
- if (schema.onSuccess) {
283
- await schema.onSuccess(result);
284
- }
285
- return result;
286
- } catch (err) {
287
- if (schema.onError) {
288
- schema.onError(err as Error);
289
- }
290
- throw err;
291
- } finally {
292
- setSubmitting(false);
293
- }
294
- } else {
295
- // Move to next step
296
- goToStep(currentStep + 1);
297
- }
298
- }, [formData, currentStep, isLastStep, schema, dataSource]);
299
-
300
- // Navigation
301
- const goToStep = useCallback((step: number) => {
302
- if (step >= 0 && step < totalSteps) {
303
- setCurrentStep(step);
304
- if (schema.onStepChange) {
305
- schema.onStepChange(step);
306
- }
307
- }
308
- }, [totalSteps, schema]);
309
-
310
- const handlePrev = useCallback(() => {
311
- goToStep(currentStep - 1);
312
- }, [currentStep, goToStep]);
313
-
314
- const handleCancel = useCallback(() => {
315
- if (schema.onCancel) {
316
- schema.onCancel();
317
- }
318
- }, [schema]);
319
-
320
- const handleStepClick = useCallback((step: number) => {
321
- if (schema.allowSkip || completedSteps.has(step) || step <= currentStep) {
322
- goToStep(step);
323
- }
324
- }, [schema.allowSkip, completedSteps, currentStep, goToStep]);
325
-
326
- if (error) {
327
- return (
328
- <div className="p-4 border border-red-300 bg-red-50 rounded-md">
329
- <h3 className="text-red-800 font-semibold">Error loading form</h3>
330
- <p className="text-red-600 text-sm mt-1">{error.message}</p>
331
- </div>
332
- );
333
- }
334
-
335
- if (loading) {
336
- return (
337
- <div className="p-8 text-center">
338
- <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
339
- <p className="mt-2 text-sm text-gray-600">Loading form...</p>
340
- </div>
341
- );
342
- }
343
-
344
- const currentSection = schema.sections[currentStep];
345
-
346
- return (
347
- <div className={cn('w-full', className, schema.className)}>
348
- {/* Step Indicator */}
349
- {schema.showStepIndicator !== false && (
350
- <nav aria-label="Progress" className="mb-8">
351
- <ol className="flex items-center">
352
- {schema.sections.map((section, index) => {
353
- const isActive = index === currentStep;
354
- const isCompleted = completedSteps.has(index);
355
- const isClickable = schema.allowSkip || isCompleted || index <= currentStep;
356
-
357
- return (
358
- <li
359
- key={index}
360
- className={cn(
361
- 'relative flex-1',
362
- index !== totalSteps - 1 && 'pr-8 sm:pr-12'
363
- )}
364
- >
365
- {/* Connector line */}
366
- {index !== totalSteps - 1 && (
367
- <div
368
- className="absolute top-3 sm:top-4 left-6 -right-4 sm:left-10 sm:-right-2 h-0.5"
369
- aria-hidden="true"
370
- >
371
- <div
372
- className={cn(
373
- 'h-full',
374
- isCompleted ? 'bg-primary' : 'bg-muted'
375
- )}
376
- />
377
- </div>
378
- )}
379
-
380
- <button
381
- type="button"
382
- className={cn(
383
- 'group relative flex items-center',
384
- isClickable ? 'cursor-pointer' : 'cursor-not-allowed'
385
- )}
386
- onClick={() => handleStepClick(index)}
387
- disabled={!isClickable}
388
- >
389
- {/* Step circle - smaller on mobile */}
390
- <span
391
- className={cn(
392
- 'flex h-6 w-6 sm:h-8 sm:w-8 items-center justify-center rounded-full text-xs sm:text-sm font-medium transition-colors',
393
- isCompleted && 'bg-primary text-primary-foreground',
394
- isActive && !isCompleted && 'border-2 border-primary bg-background text-primary',
395
- !isActive && !isCompleted && 'border-2 border-muted bg-background text-muted-foreground'
396
- )}
397
- >
398
- {isCompleted ? (
399
- <Check className="h-3 w-3 sm:h-4 sm:w-4" />
400
- ) : (
401
- index + 1
402
- )}
403
- </span>
404
-
405
- {/* Step label */}
406
- <span className="ml-2 sm:ml-3 text-xs sm:text-sm font-medium hidden sm:block">
407
- <span
408
- className={cn(
409
- isActive ? 'text-foreground' : 'text-muted-foreground'
410
- )}
411
- >
412
- {section.label || `Step ${index + 1}`}
413
- </span>
414
- </span>
415
- </button>
416
- </li>
417
- );
418
- })}
419
- </ol>
420
- </nav>
421
- )}
422
-
423
- {/* Current Step Content */}
424
- <div className="min-h-[200px]">
425
- {currentSection && (
426
- <FormSection
427
- label={currentSection.label}
428
- description={currentSection.description}
429
- columns={currentSection.columns || 1}
430
- >
431
- {currentSectionFields.length > 0 ? (
432
- <SchemaRenderer
433
- schema={{
434
- type: 'form' as const,
435
- fields: currentSectionFields,
436
- layout: 'vertical' as const,
437
- defaultValues: formData,
438
- showSubmit: false,
439
- showCancel: false,
440
- onSubmit: handleStepSubmit,
441
- }}
442
- />
443
- ) : (
444
- <div className="text-center py-8 text-muted-foreground">
445
- No fields configured for this step
446
- </div>
447
- )}
448
- </FormSection>
449
- )}
450
- </div>
451
-
452
- {/* Navigation Buttons */}
453
- <div className="flex items-center justify-between mt-6 pt-4 border-t">
454
- <div>
455
- {schema.showCancel !== false && (
456
- <Button
457
- variant="ghost"
458
- onClick={handleCancel}
459
- >
460
- {schema.cancelText || 'Cancel'}
461
- </Button>
462
- )}
463
- </div>
464
-
465
- <div className="flex items-center gap-2">
466
- {/* Step counter */}
467
- <span className="text-sm text-muted-foreground mr-2">
468
- Step {currentStep + 1} of {totalSteps}
469
- </span>
470
-
471
- {!isFirstStep && (
472
- <Button
473
- variant="outline"
474
- onClick={handlePrev}
475
- >
476
- <ChevronLeft className="h-4 w-4 mr-1" />
477
- {schema.prevText || 'Back'}
478
- </Button>
479
- )}
480
-
481
- {isLastStep ? (
482
- <Button
483
- onClick={() => handleStepSubmit(formData)}
484
- disabled={submitting || schema.mode === 'view'}
485
- >
486
- {submitting ? 'Submitting...' : (schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'))}
487
- </Button>
488
- ) : (
489
- <Button
490
- onClick={() => handleStepSubmit(formData)}
491
- >
492
- {schema.nextText || 'Next'}
493
- <ChevronRight className="h-4 w-4 ml-1" />
494
- </Button>
495
- )}
496
- </div>
497
- </div>
498
- </div>
499
- );
500
- };
501
-
502
- export default WizardForm;
@@ -1,186 +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
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
11
- import React from 'react';
12
-
13
- // Capture the initialData passed to ObjectForm
14
- let capturedSchema: any = null;
15
- let capturedOnSuccess: ((data: any) => void) | null = null;
16
-
17
- vi.mock('../ObjectForm', () => ({
18
- ObjectForm: ({ schema, dataSource }: any) => {
19
- capturedSchema = schema;
20
- capturedOnSuccess = schema?.onSuccess ?? null;
21
- return (
22
- <div>
23
- <div data-testid="object-form">
24
- {schema?.initialData && (
25
- <div data-testid="initial-data">
26
- {JSON.stringify(schema.initialData)}
27
- </div>
28
- )}
29
- </div>
30
- <button
31
- data-testid="mock-submit"
32
- onClick={() => schema?.onSuccess?.({ name: 'submitted' })}
33
- >
34
- Submit
35
- </button>
36
- </div>
37
- );
38
- },
39
- }));
40
-
41
- vi.mock('@object-ui/types', () => ({
42
- // DataSource type is only used for typing, provide empty default
43
- }));
44
-
45
- import { EmbeddableForm } from '../EmbeddableForm';
46
- import type { EmbeddableFormConfig } from '../EmbeddableForm';
47
-
48
- const baseConfig: EmbeddableFormConfig = {
49
- formId: 'test-form',
50
- objectName: 'contacts',
51
- title: 'Test Form',
52
- };
53
-
54
- describe('EmbeddableForm - URL Prefill', () => {
55
- const originalLocation = window.location;
56
-
57
- beforeEach(() => {
58
- capturedSchema = null;
59
- capturedOnSuccess = null;
60
- });
61
-
62
- afterEach(() => {
63
- // Restore window.location
64
- Object.defineProperty(window, 'location', {
65
- writable: true,
66
- value: originalLocation,
67
- });
68
- });
69
-
70
- it('reads URL search params and passes them as initialData', () => {
71
- Object.defineProperty(window, 'location', {
72
- writable: true,
73
- value: { ...originalLocation, search: '?name=John&email=john@test.com' },
74
- });
75
-
76
- render(<EmbeddableForm config={baseConfig} />);
77
-
78
- expect(capturedSchema).not.toBeNull();
79
- expect(capturedSchema.initialData).toEqual({
80
- name: 'John',
81
- email: 'john@test.com',
82
- });
83
- });
84
-
85
- it('passes explicit prefillParams as initialData', () => {
86
- Object.defineProperty(window, 'location', {
87
- writable: true,
88
- value: { ...originalLocation, search: '' },
89
- });
90
-
91
- render(
92
- <EmbeddableForm
93
- config={baseConfig}
94
- prefillParams={{ company: 'Acme', role: 'Admin' }}
95
- />
96
- );
97
-
98
- expect(capturedSchema.initialData).toEqual({
99
- company: 'Acme',
100
- role: 'Admin',
101
- });
102
- });
103
-
104
- it('explicit prefillParams override URL params for the same field', () => {
105
- Object.defineProperty(window, 'location', {
106
- writable: true,
107
- value: { ...originalLocation, search: '?name=URLName&email=url@test.com' },
108
- });
109
-
110
- render(
111
- <EmbeddableForm
112
- config={baseConfig}
113
- prefillParams={{ name: 'ExplicitName' }}
114
- />
115
- );
116
-
117
- // name should come from prefillParams, email should come from URL
118
- expect(capturedSchema.initialData).toEqual({
119
- name: 'ExplicitName',
120
- email: 'url@test.com',
121
- });
122
- });
123
-
124
- it('passes undefined initialData when no params are provided', () => {
125
- Object.defineProperty(window, 'location', {
126
- writable: true,
127
- value: { ...originalLocation, search: '' },
128
- });
129
-
130
- render(<EmbeddableForm config={baseConfig} />);
131
-
132
- expect(capturedSchema.initialData).toBeUndefined();
133
- });
134
-
135
- it('shows the thank-you page after successful submission', async () => {
136
- Object.defineProperty(window, 'location', {
137
- writable: true,
138
- value: { ...originalLocation, search: '' },
139
- });
140
-
141
- render(
142
- <EmbeddableForm
143
- config={{
144
- ...baseConfig,
145
- thankYouPage: {
146
- title: 'All Done!',
147
- message: 'We got your response.',
148
- },
149
- }}
150
- />
151
- );
152
-
153
- // Form should be visible initially
154
- expect(screen.getByText('Test Form')).toBeInTheDocument();
155
-
156
- // Click mock submit to trigger onSuccess
157
- const submitBtn = screen.getByTestId('mock-submit');
158
- fireEvent.click(submitBtn);
159
-
160
- // Thank-you page should appear
161
- await waitFor(() => {
162
- expect(screen.getByText('All Done!')).toBeInTheDocument();
163
- expect(screen.getByText('We got your response.')).toBeInTheDocument();
164
- });
165
-
166
- // Form title should no longer be visible
167
- expect(screen.queryByText('Test Form')).not.toBeInTheDocument();
168
- });
169
-
170
- it('shows default thank-you message when no custom thankYouPage is configured', async () => {
171
- Object.defineProperty(window, 'location', {
172
- writable: true,
173
- value: { ...originalLocation, search: '' },
174
- });
175
-
176
- render(<EmbeddableForm config={baseConfig} />);
177
-
178
- const submitBtn = screen.getByTestId('mock-submit');
179
- fireEvent.click(submitBtn);
180
-
181
- await waitFor(() => {
182
- expect(screen.getByText('Thank You!')).toBeInTheDocument();
183
- expect(screen.getByText('Your submission has been received successfully.')).toBeInTheDocument();
184
- });
185
- });
186
- });