@object-ui/react 0.3.0 → 0.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/SchemaRenderer.d.ts +7 -0
  3. package/dist/SchemaRenderer.js +7 -0
  4. package/dist/components/form/FieldFactory.d.ts +43 -0
  5. package/dist/components/form/FieldFactory.js +157 -0
  6. package/dist/components/form/FormRenderer.d.ts +40 -0
  7. package/dist/components/form/FormRenderer.js +87 -0
  8. package/dist/components/form/FormRenderer.test.d.ts +8 -0
  9. package/dist/components/form/FormRenderer.test.js +534 -0
  10. package/dist/components/form/index.d.ts +11 -0
  11. package/dist/components/form/index.js +9 -0
  12. package/dist/context/SchemaRendererContext.d.ts +13 -0
  13. package/dist/context/SchemaRendererContext.js +21 -0
  14. package/dist/context/index.d.ts +8 -1
  15. package/dist/context/index.js +8 -1
  16. package/dist/hooks/index.d.ts +9 -1
  17. package/dist/hooks/index.js +9 -1
  18. package/dist/hooks/useActionRunner.d.ts +32 -0
  19. package/dist/hooks/useActionRunner.js +65 -0
  20. package/dist/hooks/useExpression.d.ts +27 -0
  21. package/dist/hooks/useExpression.js +41 -0
  22. package/dist/index.d.ts +8 -0
  23. package/dist/index.js +8 -0
  24. package/dist/index.test.d.ts +7 -0
  25. package/dist/index.test.js +7 -0
  26. package/package.json +16 -4
  27. package/src/SchemaRenderer.tsx +8 -0
  28. package/src/components/form/FieldFactory.tsx +504 -0
  29. package/src/components/form/FormRenderer.test.tsx +599 -0
  30. package/src/components/form/FormRenderer.tsx +209 -0
  31. package/src/components/form/README.md +352 -0
  32. package/src/components/form/index.ts +12 -0
  33. package/src/context/SchemaRendererContext.tsx +40 -0
  34. package/src/context/index.ts +9 -1
  35. package/src/hooks/index.ts +11 -1
  36. package/src/hooks/useActionRunner.ts +81 -0
  37. package/src/hooks/useExpression.ts +56 -0
  38. package/src/index.test.ts +8 -0
  39. package/src/index.ts +9 -0
  40. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,504 @@
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 React from 'react';
10
+ import { UseFormReturn } from 'react-hook-form';
11
+ import type { FormField } from '@objectstack/spec/ui';
12
+
13
+ /**
14
+ * Extended form field with additional properties for complex widgets
15
+ * These properties are not part of the standard FormField schema but may be
16
+ * provided by specific implementations
17
+ */
18
+ export interface ExtendedFormField extends FormField {
19
+ multiple?: boolean;
20
+ accept?: string[];
21
+ options?: Array<{
22
+ label: string;
23
+ value: string;
24
+ disabled?: boolean;
25
+ }>;
26
+ }
27
+
28
+ export interface FieldFactoryProps {
29
+ /**
30
+ * Field configuration from FormFieldSchema
31
+ */
32
+ field: FormField;
33
+ /**
34
+ * React Hook Form methods
35
+ */
36
+ methods: UseFormReturn<any>;
37
+ /**
38
+ * Whether the field is disabled
39
+ */
40
+ disabled?: boolean;
41
+ }
42
+
43
+ /**
44
+ * FieldFactory component that renders different input types based on
45
+ * the widget property or field type
46
+ */
47
+ export const FieldFactory: React.FC<FieldFactoryProps> = ({
48
+ field,
49
+ methods,
50
+ disabled = false,
51
+ }) => {
52
+ const { register, formState: { errors } } = methods;
53
+
54
+ // Cast to extended field for properties not in base schema
55
+ const extendedField = field as ExtendedFormField;
56
+
57
+ // Determine the widget type
58
+ const widgetType = field.widget || 'text';
59
+ const fieldName = field.field;
60
+ const error = errors[fieldName];
61
+
62
+ // Helper function to handle multiple select value conversion
63
+ const handleMultipleSelectValue = (value: any) => {
64
+ if (extendedField.multiple && value instanceof HTMLCollection) {
65
+ return Array.from(value as HTMLCollectionOf<HTMLOptionElement>).map((opt) => opt.value);
66
+ }
67
+ return value;
68
+ };
69
+
70
+ // Handle conditional visibility
71
+ // Note: visibleOn expression evaluation is not yet implemented
72
+ // Fields are always visible unless explicitly hidden
73
+ // Skip if explicitly hidden
74
+ if (field.hidden) {
75
+ return null;
76
+ }
77
+
78
+ // Common field wrapper
79
+ const renderField = (input: React.ReactNode) => (
80
+ <div className="space-y-2">
81
+ {field.label && (
82
+ <label htmlFor={fieldName} className="block text-sm font-medium text-gray-700">
83
+ {field.label}
84
+ {field.required && <span className="text-red-500 ml-1">*</span>}
85
+ </label>
86
+ )}
87
+ {input}
88
+ {field.helpText && (
89
+ <p className="text-sm text-gray-500">{field.helpText}</p>
90
+ )}
91
+ {error && (
92
+ <p className="text-sm text-red-600">{error.message as string}</p>
93
+ )}
94
+ </div>
95
+ );
96
+
97
+ // Render based on widget type
98
+ switch (widgetType.toLowerCase()) {
99
+ case 'text':
100
+ case 'string':
101
+ case 'email':
102
+ case 'password':
103
+ case 'url':
104
+ case 'tel':
105
+ return renderField(
106
+ <input
107
+ id={fieldName}
108
+ type={widgetType === 'string' ? 'text' : widgetType}
109
+ placeholder={field.placeholder}
110
+ disabled={disabled}
111
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
112
+ {...register(fieldName, {
113
+ required: field.required ? `${field.label || fieldName} is required` : false,
114
+ })}
115
+ />
116
+ );
117
+
118
+ case 'number':
119
+ case 'integer':
120
+ case 'float':
121
+ return renderField(
122
+ <input
123
+ id={fieldName}
124
+ type="number"
125
+ placeholder={field.placeholder}
126
+ disabled={disabled}
127
+ step={widgetType === 'integer' ? '1' : 'any'}
128
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
129
+ {...register(fieldName, {
130
+ required: field.required ? `${field.label || fieldName} is required` : false,
131
+ valueAsNumber: true,
132
+ })}
133
+ />
134
+ );
135
+
136
+ case 'checkbox':
137
+ case 'boolean':
138
+ return (
139
+ <div className="flex items-start space-x-2">
140
+ <input
141
+ id={fieldName}
142
+ type="checkbox"
143
+ disabled={disabled}
144
+ className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
145
+ {...register(fieldName)}
146
+ />
147
+ <div className="flex-1">
148
+ {field.label && (
149
+ <label htmlFor={fieldName} className="text-sm font-medium text-gray-700">
150
+ {field.label}
151
+ {field.required && <span className="text-red-500 ml-1">*</span>}
152
+ </label>
153
+ )}
154
+ {field.helpText && (
155
+ <p className="text-sm text-gray-500 mt-1">{field.helpText}</p>
156
+ )}
157
+ {error && (
158
+ <p className="text-sm text-red-600 mt-1">{error.message as string}</p>
159
+ )}
160
+ </div>
161
+ </div>
162
+ );
163
+
164
+ case 'textarea':
165
+ return renderField(
166
+ <textarea
167
+ id={fieldName}
168
+ placeholder={field.placeholder}
169
+ disabled={disabled}
170
+ rows={4}
171
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
172
+ {...register(fieldName, {
173
+ required: field.required ? `${field.label || fieldName} is required` : false,
174
+ })}
175
+ />
176
+ );
177
+
178
+ case 'select':
179
+ case 'dropdown':
180
+ return renderField(
181
+ <select
182
+ id={fieldName}
183
+ disabled={disabled}
184
+ multiple={extendedField.multiple}
185
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
186
+ {...register(fieldName, {
187
+ required: field.required ? `${field.label || fieldName} is required` : false,
188
+ setValueAs: handleMultipleSelectValue,
189
+ })}
190
+ >
191
+ {!extendedField.multiple && <option value="">{field.placeholder || 'Select an option'}</option>}
192
+ {extendedField.options?.map((option) => (
193
+ <option key={option.value} value={option.value} disabled={option.disabled}>
194
+ {option.label}
195
+ </option>
196
+ ))}
197
+ </select>
198
+ );
199
+
200
+ case 'date':
201
+ return renderField(
202
+ <input
203
+ id={fieldName}
204
+ type="date"
205
+ placeholder={field.placeholder}
206
+ disabled={disabled}
207
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
208
+ {...register(fieldName, {
209
+ required: field.required ? `${field.label || fieldName} is required` : false,
210
+ })}
211
+ />
212
+ );
213
+
214
+ case 'datetime':
215
+ case 'datetime-local':
216
+ return renderField(
217
+ <input
218
+ id={fieldName}
219
+ type="datetime-local"
220
+ placeholder={field.placeholder}
221
+ disabled={disabled}
222
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
223
+ {...register(fieldName, {
224
+ required: field.required ? `${field.label || fieldName} is required` : false,
225
+ })}
226
+ />
227
+ );
228
+
229
+ case 'time':
230
+ return renderField(
231
+ <input
232
+ id={fieldName}
233
+ type="time"
234
+ placeholder={field.placeholder}
235
+ disabled={disabled}
236
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
237
+ {...register(fieldName, {
238
+ required: field.required ? `${field.label || fieldName} is required` : false,
239
+ })}
240
+ />
241
+ );
242
+
243
+ case 'currency':
244
+ return renderField(
245
+ <div className="relative">
246
+ <span className="absolute left-3 top-2 text-gray-500">$</span>
247
+ <input
248
+ id={fieldName}
249
+ type="number"
250
+ placeholder={field.placeholder}
251
+ disabled={disabled}
252
+ step="0.01"
253
+ className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
254
+ {...register(fieldName, {
255
+ required: field.required ? `${field.label || fieldName} is required` : false,
256
+ valueAsNumber: true,
257
+ })}
258
+ />
259
+ </div>
260
+ );
261
+
262
+ case 'percent':
263
+ return renderField(
264
+ <div className="relative">
265
+ <input
266
+ id={fieldName}
267
+ type="number"
268
+ placeholder={field.placeholder}
269
+ disabled={disabled}
270
+ step="0.01"
271
+ min="0"
272
+ max="100"
273
+ className="w-full pr-8 pl-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
274
+ {...register(fieldName, {
275
+ required: field.required ? `${field.label || fieldName} is required` : false,
276
+ valueAsNumber: true,
277
+ })}
278
+ />
279
+ <span className="absolute right-3 top-2 text-gray-500">%</span>
280
+ </div>
281
+ );
282
+
283
+ case 'phone':
284
+ return renderField(
285
+ <input
286
+ id={fieldName}
287
+ type="tel"
288
+ placeholder={field.placeholder || '+1 (555) 000-0000'}
289
+ disabled={disabled}
290
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
291
+ {...register(fieldName, {
292
+ required: field.required ? `${field.label || fieldName} is required` : false,
293
+ })}
294
+ />
295
+ );
296
+
297
+ case 'markdown':
298
+ return renderField(
299
+ <textarea
300
+ id={fieldName}
301
+ placeholder={field.placeholder || 'Enter markdown text...'}
302
+ disabled={disabled}
303
+ rows={8}
304
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono"
305
+ {...register(fieldName, {
306
+ required: field.required ? `${field.label || fieldName} is required` : false,
307
+ })}
308
+ />
309
+ );
310
+
311
+ case 'html':
312
+ return renderField(
313
+ <textarea
314
+ id={fieldName}
315
+ placeholder={field.placeholder || 'Enter HTML...'}
316
+ disabled={disabled}
317
+ rows={8}
318
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono"
319
+ {...register(fieldName, {
320
+ required: field.required ? `${field.label || fieldName} is required` : false,
321
+ })}
322
+ />
323
+ );
324
+
325
+ case 'file':
326
+ return renderField(
327
+ <input
328
+ id={fieldName}
329
+ type="file"
330
+ disabled={disabled}
331
+ multiple={extendedField.multiple}
332
+ accept={extendedField.accept?.join(',')}
333
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
334
+ {...register(fieldName, {
335
+ required: field.required ? `${field.label || fieldName} is required` : false,
336
+ })}
337
+ />
338
+ );
339
+
340
+ case 'image':
341
+ return renderField(
342
+ <input
343
+ id={fieldName}
344
+ type="file"
345
+ disabled={disabled}
346
+ multiple={extendedField.multiple}
347
+ accept={extendedField.accept?.join(',') || 'image/*'}
348
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
349
+ {...register(fieldName, {
350
+ required: field.required ? `${field.label || fieldName} is required` : false,
351
+ })}
352
+ />
353
+ );
354
+
355
+ case 'location':
356
+ return renderField(
357
+ <div className="space-y-2">
358
+ <input
359
+ id={`${fieldName}-lat`}
360
+ type="number"
361
+ placeholder="Latitude"
362
+ disabled={disabled}
363
+ step="any"
364
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
365
+ {...register(`${fieldName}.lat`, {
366
+ required: field.required ? 'Latitude is required' : false,
367
+ valueAsNumber: true,
368
+ })}
369
+ />
370
+ <input
371
+ id={`${fieldName}-lng`}
372
+ type="number"
373
+ placeholder="Longitude"
374
+ disabled={disabled}
375
+ step="any"
376
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
377
+ {...register(`${fieldName}.lng`, {
378
+ required: field.required ? 'Longitude is required' : false,
379
+ valueAsNumber: true,
380
+ })}
381
+ />
382
+ </div>
383
+ );
384
+
385
+ case 'lookup':
386
+ case 'master_detail':
387
+ return renderField(
388
+ <select
389
+ id={fieldName}
390
+ disabled={disabled}
391
+ multiple={extendedField.multiple}
392
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
393
+ {...register(fieldName, {
394
+ required: field.required ? `${field.label || fieldName} is required` : false,
395
+ setValueAs: handleMultipleSelectValue,
396
+ })}
397
+ >
398
+ {!extendedField.multiple && <option value="">{field.placeholder || 'Select an option'}</option>}
399
+ {extendedField.options?.map((option) => (
400
+ <option key={option.value} value={option.value} disabled={option.disabled}>
401
+ {option.label}
402
+ </option>
403
+ ))}
404
+ </select>
405
+ );
406
+
407
+ case 'user':
408
+ case 'owner':
409
+ return renderField(
410
+ <select
411
+ id={fieldName}
412
+ disabled={disabled}
413
+ multiple={extendedField.multiple}
414
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
415
+ {...register(fieldName, {
416
+ required: field.required ? `${field.label || fieldName} is required` : false,
417
+ setValueAs: handleMultipleSelectValue,
418
+ })}
419
+ >
420
+ {!extendedField.multiple && <option value="">{field.placeholder || 'Select user'}</option>}
421
+ {extendedField.options?.map((option) => (
422
+ <option key={option.value} value={option.value} disabled={option.disabled}>
423
+ {option.label}
424
+ </option>
425
+ ))}
426
+ </select>
427
+ );
428
+
429
+ case 'formula':
430
+ case 'summary':
431
+ case 'auto_number':
432
+ // Read-only computed fields - display as disabled text input
433
+ return renderField(
434
+ <input
435
+ id={fieldName}
436
+ type="text"
437
+ disabled={true}
438
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 cursor-not-allowed text-gray-600"
439
+ {...register(fieldName)}
440
+ />
441
+ );
442
+
443
+ case 'object':
444
+ return renderField(
445
+ <textarea
446
+ id={fieldName}
447
+ placeholder={field.placeholder || 'Enter JSON object...'}
448
+ disabled={disabled}
449
+ rows={6}
450
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono text-sm"
451
+ {...register(fieldName, {
452
+ required: field.required ? `${field.label || fieldName} is required` : false,
453
+ validate: (value) => {
454
+ if (!value) return true;
455
+ try {
456
+ JSON.parse(value);
457
+ return true;
458
+ } catch (e) {
459
+ return 'Invalid JSON format';
460
+ }
461
+ },
462
+ })}
463
+ />
464
+ );
465
+
466
+ case 'vector':
467
+ // Vector fields are typically read-only or require specialized input
468
+ return renderField(
469
+ <input
470
+ id={fieldName}
471
+ type="text"
472
+ placeholder={field.placeholder || 'Vector data (read-only)'}
473
+ disabled={true}
474
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 cursor-not-allowed text-gray-600"
475
+ {...register(fieldName)}
476
+ />
477
+ );
478
+
479
+ case 'grid':
480
+ // Grid fields require complex table/grid editor - placeholder for now
481
+ return renderField(
482
+ <div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-600">
483
+ <p className="text-sm">Grid editor not yet implemented</p>
484
+ </div>
485
+ );
486
+
487
+ default:
488
+ // Default to text input
489
+ return renderField(
490
+ <input
491
+ id={fieldName}
492
+ type="text"
493
+ placeholder={field.placeholder}
494
+ disabled={disabled}
495
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
496
+ {...register(fieldName, {
497
+ required: field.required ? `${field.label || fieldName} is required` : false,
498
+ })}
499
+ />
500
+ );
501
+ }
502
+ };
503
+
504
+ FieldFactory.displayName = 'FieldFactory';