@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,501 @@
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 } 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 [objectSchema, setObjectSchema] = useState<any>(null);
157
+ const [formData, setFormData] = useState<Record<string, any>>({});
158
+ const [loading, setLoading] = useState(true);
159
+ const [error, setError] = useState<Error | null>(null);
160
+ const [currentStep, setCurrentStep] = useState(0);
161
+ const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
162
+ const [submitting, setSubmitting] = useState(false);
163
+
164
+ const totalSteps = schema.sections.length;
165
+ const isFirstStep = currentStep === 0;
166
+ const isLastStep = currentStep === totalSteps - 1;
167
+
168
+ // Fetch object schema
169
+ React.useEffect(() => {
170
+ const fetchSchema = async () => {
171
+ if (!dataSource) {
172
+ setLoading(false);
173
+ return;
174
+ }
175
+
176
+ try {
177
+ const schemaData = await dataSource.getObjectSchema(schema.objectName);
178
+ setObjectSchema(schemaData);
179
+ } catch (err) {
180
+ setError(err as Error);
181
+ }
182
+ };
183
+
184
+ fetchSchema();
185
+ }, [schema.objectName, dataSource]);
186
+
187
+ // Fetch initial data
188
+ React.useEffect(() => {
189
+ const fetchData = async () => {
190
+ if (schema.mode === 'create' || !schema.recordId || !dataSource) {
191
+ setFormData(schema.initialData || schema.initialValues || {});
192
+ setLoading(false);
193
+ return;
194
+ }
195
+
196
+ try {
197
+ const data = await dataSource.findOne(schema.objectName, schema.recordId);
198
+ setFormData(data || {});
199
+ } catch (err) {
200
+ setError(err as Error);
201
+ } finally {
202
+ setLoading(false);
203
+ }
204
+ };
205
+
206
+ if (objectSchema || !dataSource) {
207
+ fetchData();
208
+ }
209
+ }, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
210
+
211
+ // Build section fields from object schema
212
+ const buildSectionFields = useCallback((section: FormSectionConfig): FormField[] => {
213
+ const fields: FormField[] = [];
214
+
215
+ for (const fieldDef of section.fields) {
216
+ const fieldName = typeof fieldDef === 'string' ? fieldDef : fieldDef.name;
217
+
218
+ if (typeof fieldDef === 'object') {
219
+ fields.push(fieldDef);
220
+ } else if (objectSchema?.fields?.[fieldName]) {
221
+ const field = objectSchema.fields[fieldName];
222
+ fields.push({
223
+ name: fieldName,
224
+ label: field.label || fieldName,
225
+ type: mapFieldTypeToFormType(field.type),
226
+ required: field.required || false,
227
+ disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
228
+ placeholder: field.placeholder,
229
+ description: field.help || field.description,
230
+ validation: buildValidationRules(field),
231
+ field: field,
232
+ options: field.options,
233
+ multiple: field.multiple,
234
+ });
235
+ } else {
236
+ fields.push({
237
+ name: fieldName,
238
+ label: fieldName,
239
+ type: 'input',
240
+ });
241
+ }
242
+ }
243
+
244
+ return fields;
245
+ }, [objectSchema, schema.readOnly, schema.mode]);
246
+
247
+ // Current section fields
248
+ const currentSectionFields = useMemo(() => {
249
+ if (currentStep >= 0 && currentStep < totalSteps) {
250
+ return buildSectionFields(schema.sections[currentStep]);
251
+ }
252
+ return [];
253
+ }, [currentStep, totalSteps, schema.sections, buildSectionFields]);
254
+
255
+ // Handle step data collection (merge partial data into formData)
256
+ const handleStepSubmit = useCallback(async (stepData: Record<string, any>) => {
257
+ const mergedData = { ...formData, ...stepData };
258
+ setFormData(mergedData);
259
+
260
+ // Mark step as completed
261
+ setCompletedSteps(prev => new Set(prev).add(currentStep));
262
+
263
+ if (isLastStep) {
264
+ // Final submission
265
+ setSubmitting(true);
266
+ try {
267
+ if (!dataSource) {
268
+ if (schema.onSuccess) {
269
+ await schema.onSuccess(mergedData);
270
+ }
271
+ return mergedData;
272
+ }
273
+
274
+ let result;
275
+ if (schema.mode === 'create') {
276
+ result = await dataSource.create(schema.objectName, mergedData);
277
+ } else if (schema.mode === 'edit' && schema.recordId) {
278
+ result = await dataSource.update(schema.objectName, schema.recordId, mergedData);
279
+ }
280
+
281
+ if (schema.onSuccess) {
282
+ await schema.onSuccess(result);
283
+ }
284
+ return result;
285
+ } catch (err) {
286
+ if (schema.onError) {
287
+ schema.onError(err as Error);
288
+ }
289
+ throw err;
290
+ } finally {
291
+ setSubmitting(false);
292
+ }
293
+ } else {
294
+ // Move to next step
295
+ goToStep(currentStep + 1);
296
+ }
297
+ }, [formData, currentStep, isLastStep, schema, dataSource]);
298
+
299
+ // Navigation
300
+ const goToStep = useCallback((step: number) => {
301
+ if (step >= 0 && step < totalSteps) {
302
+ setCurrentStep(step);
303
+ if (schema.onStepChange) {
304
+ schema.onStepChange(step);
305
+ }
306
+ }
307
+ }, [totalSteps, schema]);
308
+
309
+ const handlePrev = useCallback(() => {
310
+ goToStep(currentStep - 1);
311
+ }, [currentStep, goToStep]);
312
+
313
+ const handleCancel = useCallback(() => {
314
+ if (schema.onCancel) {
315
+ schema.onCancel();
316
+ }
317
+ }, [schema]);
318
+
319
+ const handleStepClick = useCallback((step: number) => {
320
+ if (schema.allowSkip || completedSteps.has(step) || step <= currentStep) {
321
+ goToStep(step);
322
+ }
323
+ }, [schema.allowSkip, completedSteps, currentStep, goToStep]);
324
+
325
+ if (error) {
326
+ return (
327
+ <div className="p-4 border border-red-300 bg-red-50 rounded-md">
328
+ <h3 className="text-red-800 font-semibold">Error loading form</h3>
329
+ <p className="text-red-600 text-sm mt-1">{error.message}</p>
330
+ </div>
331
+ );
332
+ }
333
+
334
+ if (loading) {
335
+ return (
336
+ <div className="p-8 text-center">
337
+ <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
338
+ <p className="mt-2 text-sm text-gray-600">Loading form...</p>
339
+ </div>
340
+ );
341
+ }
342
+
343
+ const currentSection = schema.sections[currentStep];
344
+
345
+ return (
346
+ <div className={cn('w-full', className, schema.className)}>
347
+ {/* Step Indicator */}
348
+ {schema.showStepIndicator !== false && (
349
+ <nav aria-label="Progress" className="mb-8">
350
+ <ol className="flex items-center">
351
+ {schema.sections.map((section, index) => {
352
+ const isActive = index === currentStep;
353
+ const isCompleted = completedSteps.has(index);
354
+ const isClickable = schema.allowSkip || isCompleted || index <= currentStep;
355
+
356
+ return (
357
+ <li
358
+ key={index}
359
+ className={cn(
360
+ 'relative flex-1',
361
+ index !== totalSteps - 1 && 'pr-8 sm:pr-12'
362
+ )}
363
+ >
364
+ {/* Connector line */}
365
+ {index !== totalSteps - 1 && (
366
+ <div
367
+ className="absolute top-3 sm:top-4 left-6 -right-4 sm:left-10 sm:-right-2 h-0.5"
368
+ aria-hidden="true"
369
+ >
370
+ <div
371
+ className={cn(
372
+ 'h-full',
373
+ isCompleted ? 'bg-primary' : 'bg-muted'
374
+ )}
375
+ />
376
+ </div>
377
+ )}
378
+
379
+ <button
380
+ type="button"
381
+ className={cn(
382
+ 'group relative flex items-center',
383
+ isClickable ? 'cursor-pointer' : 'cursor-not-allowed'
384
+ )}
385
+ onClick={() => handleStepClick(index)}
386
+ disabled={!isClickable}
387
+ >
388
+ {/* Step circle - smaller on mobile */}
389
+ <span
390
+ className={cn(
391
+ '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',
392
+ isCompleted && 'bg-primary text-primary-foreground',
393
+ isActive && !isCompleted && 'border-2 border-primary bg-background text-primary',
394
+ !isActive && !isCompleted && 'border-2 border-muted bg-background text-muted-foreground'
395
+ )}
396
+ >
397
+ {isCompleted ? (
398
+ <Check className="h-3 w-3 sm:h-4 sm:w-4" />
399
+ ) : (
400
+ index + 1
401
+ )}
402
+ </span>
403
+
404
+ {/* Step label */}
405
+ <span className="ml-2 sm:ml-3 text-xs sm:text-sm font-medium hidden sm:block">
406
+ <span
407
+ className={cn(
408
+ isActive ? 'text-foreground' : 'text-muted-foreground'
409
+ )}
410
+ >
411
+ {section.label || `Step ${index + 1}`}
412
+ </span>
413
+ </span>
414
+ </button>
415
+ </li>
416
+ );
417
+ })}
418
+ </ol>
419
+ </nav>
420
+ )}
421
+
422
+ {/* Current Step Content */}
423
+ <div className="min-h-[200px]">
424
+ {currentSection && (
425
+ <FormSection
426
+ label={currentSection.label}
427
+ description={currentSection.description}
428
+ columns={currentSection.columns || 1}
429
+ >
430
+ {currentSectionFields.length > 0 ? (
431
+ <SchemaRenderer
432
+ schema={{
433
+ type: 'form' as const,
434
+ fields: currentSectionFields,
435
+ layout: 'vertical' as const,
436
+ defaultValues: formData,
437
+ showSubmit: false,
438
+ showCancel: false,
439
+ onSubmit: handleStepSubmit,
440
+ }}
441
+ />
442
+ ) : (
443
+ <div className="text-center py-8 text-muted-foreground">
444
+ No fields configured for this step
445
+ </div>
446
+ )}
447
+ </FormSection>
448
+ )}
449
+ </div>
450
+
451
+ {/* Navigation Buttons */}
452
+ <div className="flex items-center justify-between mt-6 pt-4 border-t">
453
+ <div>
454
+ {schema.showCancel !== false && (
455
+ <Button
456
+ variant="ghost"
457
+ onClick={handleCancel}
458
+ >
459
+ {schema.cancelText || 'Cancel'}
460
+ </Button>
461
+ )}
462
+ </div>
463
+
464
+ <div className="flex items-center gap-2">
465
+ {/* Step counter */}
466
+ <span className="text-sm text-muted-foreground mr-2">
467
+ Step {currentStep + 1} of {totalSteps}
468
+ </span>
469
+
470
+ {!isFirstStep && (
471
+ <Button
472
+ variant="outline"
473
+ onClick={handlePrev}
474
+ >
475
+ <ChevronLeft className="h-4 w-4 mr-1" />
476
+ {schema.prevText || 'Back'}
477
+ </Button>
478
+ )}
479
+
480
+ {isLastStep ? (
481
+ <Button
482
+ onClick={() => handleStepSubmit(formData)}
483
+ disabled={submitting || schema.mode === 'view'}
484
+ >
485
+ {submitting ? 'Submitting...' : (schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'))}
486
+ </Button>
487
+ ) : (
488
+ <Button
489
+ onClick={() => handleStepSubmit(formData)}
490
+ >
491
+ {schema.nextText || 'Next'}
492
+ <ChevronRight className="h-4 w-4 ml-1" />
493
+ </Button>
494
+ )}
495
+ </div>
496
+ </div>
497
+ </div>
498
+ );
499
+ };
500
+
501
+ export default WizardForm;