@object-ui/fields 0.3.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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/dist/fields/src/index.d.ts +143 -0
  4. package/dist/fields/src/widgets/BooleanField.d.ts +2 -0
  5. package/dist/fields/src/widgets/CurrencyField.d.ts +2 -0
  6. package/dist/fields/src/widgets/DateField.d.ts +2 -0
  7. package/dist/fields/src/widgets/EmailField.d.ts +2 -0
  8. package/dist/fields/src/widgets/LookupField.d.ts +6 -0
  9. package/dist/fields/src/widgets/NumberField.d.ts +2 -0
  10. package/dist/fields/src/widgets/PhoneField.d.ts +2 -0
  11. package/dist/fields/src/widgets/RichTextField.d.ts +7 -0
  12. package/dist/fields/src/widgets/SelectField.d.ts +2 -0
  13. package/dist/fields/src/widgets/TextAreaField.d.ts +2 -0
  14. package/dist/fields/src/widgets/TextField.d.ts +2 -0
  15. package/dist/fields/src/widgets/UrlField.d.ts +2 -0
  16. package/dist/fields/src/widgets/types.d.ts +9 -0
  17. package/dist/index.cjs +6 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +1060 -0
  20. package/package.json +52 -0
  21. package/src/index.tsx +775 -0
  22. package/src/widgets/BooleanField.tsx +40 -0
  23. package/src/widgets/CurrencyField.tsx +62 -0
  24. package/src/widgets/DateField.tsx +19 -0
  25. package/src/widgets/EmailField.tsx +29 -0
  26. package/src/widgets/LookupField.tsx +186 -0
  27. package/src/widgets/NumberField.tsx +27 -0
  28. package/src/widgets/PhoneField.tsx +29 -0
  29. package/src/widgets/RichTextField.tsx +42 -0
  30. package/src/widgets/SelectField.tsx +38 -0
  31. package/src/widgets/TextAreaField.tsx +41 -0
  32. package/src/widgets/TextField.tsx +36 -0
  33. package/src/widgets/UrlField.tsx +38 -0
  34. package/src/widgets/types.ts +11 -0
package/src/index.tsx ADDED
@@ -0,0 +1,775 @@
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 type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
11
+ import { Badge, Avatar, AvatarFallback, Button } from '@object-ui/components';
12
+ import { Check, X } from 'lucide-react';
13
+
14
+ /**
15
+ * Cell renderer props
16
+ */
17
+ export interface CellRendererProps {
18
+ value: any;
19
+ field: FieldMetadata;
20
+ isEditing?: boolean;
21
+ onChange?: (value: any) => void;
22
+ }
23
+
24
+ /**
25
+ * Format currency value
26
+ */
27
+ export function formatCurrency(value: number, currency: string = 'USD'): string {
28
+ try {
29
+ return new Intl.NumberFormat('en-US', {
30
+ style: 'currency',
31
+ currency,
32
+ }).format(value);
33
+ } catch {
34
+ return `${currency} ${value.toFixed(2)}`;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Format percent value
40
+ */
41
+ export function formatPercent(value: number, precision: number = 2): string {
42
+ return `${(value * 100).toFixed(precision)}%`;
43
+ }
44
+
45
+ /**
46
+ * Format date value
47
+ */
48
+ export function formatDate(value: string | Date, _format?: string): string {
49
+ if (!value) return '-';
50
+ const date = typeof value === 'string' ? new Date(value) : value;
51
+ if (isNaN(date.getTime())) return '-';
52
+
53
+ // Default format: MMM DD, YYYY
54
+ return date.toLocaleDateString('en-US', {
55
+ year: 'numeric',
56
+ month: 'short',
57
+ day: 'numeric',
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Format datetime value
63
+ */
64
+ export function formatDateTime(value: string | Date): string {
65
+ if (!value) return '-';
66
+ const date = typeof value === 'string' ? new Date(value) : value;
67
+ if (isNaN(date.getTime())) return '-';
68
+
69
+ return date.toLocaleDateString('en-US', {
70
+ year: 'numeric',
71
+ month: 'short',
72
+ day: 'numeric',
73
+ hour: '2-digit',
74
+ minute: '2-digit',
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Text field cell renderer
80
+ */
81
+ export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
82
+ return <span className="truncate">{value || '-'}</span>;
83
+ }
84
+
85
+ /**
86
+ * Number field cell renderer
87
+ */
88
+ export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
89
+ if (value == null) return <span>-</span>;
90
+
91
+ const numField = field as any;
92
+ const precision = numField.precision ?? 0;
93
+ const formatted = typeof value === 'number' ? value.toFixed(precision) : value;
94
+
95
+ return <span className="tabular-nums">{formatted}</span>;
96
+ }
97
+
98
+ /**
99
+ * Currency field cell renderer
100
+ */
101
+ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
102
+ if (value == null) return <span>-</span>;
103
+
104
+ const currencyField = field as any;
105
+ const currency = currencyField.currency || 'USD';
106
+ const formatted = formatCurrency(Number(value), currency);
107
+
108
+ return <span className="tabular-nums font-medium">{formatted}</span>;
109
+ }
110
+
111
+ /**
112
+ * Percent field cell renderer
113
+ */
114
+ export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
115
+ if (value == null) return <span>-</span>;
116
+
117
+ const percentField = field as any;
118
+ const precision = percentField.precision ?? 2;
119
+ const formatted = formatPercent(Number(value), precision);
120
+
121
+ return <span className="tabular-nums">{formatted}</span>;
122
+ }
123
+
124
+ /**
125
+ * Boolean field cell renderer
126
+ */
127
+ export function BooleanCellRenderer({ value }: CellRendererProps): React.ReactElement {
128
+ return (
129
+ <div className="flex items-center">
130
+ {value ? (
131
+ <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 gap-1">
132
+ <Check className="size-3" />
133
+ True
134
+ </Badge>
135
+ ) : (
136
+ <Badge variant="outline" className="bg-gray-50 text-gray-500 border-gray-200 gap-1">
137
+ <X className="size-3" />
138
+ False
139
+ </Badge>
140
+ )}
141
+ </div>
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Date field cell renderer
147
+ */
148
+ export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
149
+ const dateField = field as any;
150
+ const formatted = formatDate(value, dateField.format);
151
+
152
+ return <span className="tabular-nums">{formatted}</span>;
153
+ }
154
+
155
+ /**
156
+ * DateTime field cell renderer
157
+ */
158
+ export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
159
+ const formatted = formatDateTime(value);
160
+
161
+ return <span className="tabular-nums text-sm">{formatted}</span>;
162
+ }
163
+
164
+ /**
165
+ * Select field cell renderer (with badges)
166
+ */
167
+ export function SelectCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
168
+ const selectField = field as any;
169
+ const options: SelectOptionMetadata[] = selectField.options || [];
170
+
171
+ if (!value) return <span>-</span>;
172
+
173
+ // Color to Tailwind class mapping for custom Badge styling
174
+ const getColorClasses = (color?: string) => {
175
+ const colorMap: Record<string, string> = {
176
+ gray: 'bg-gray-100 text-gray-800 border-gray-300',
177
+ red: 'bg-red-100 text-red-800 border-red-300',
178
+ orange: 'bg-orange-100 text-orange-800 border-orange-300',
179
+ yellow: 'bg-yellow-100 text-yellow-800 border-yellow-300',
180
+ green: 'bg-green-100 text-green-800 border-green-300',
181
+ blue: 'bg-blue-100 text-blue-800 border-blue-300',
182
+ indigo: 'bg-indigo-100 text-indigo-800 border-indigo-300',
183
+ purple: 'bg-purple-100 text-purple-800 border-purple-300',
184
+ pink: 'bg-pink-100 text-pink-800 border-pink-300',
185
+ };
186
+ return colorMap[color || 'blue'] || colorMap.blue;
187
+ };
188
+
189
+ // Handle multiple values
190
+ if (Array.isArray(value)) {
191
+ return (
192
+ <div className="flex flex-wrap gap-1">
193
+ {value.map((val, idx) => {
194
+ const option = options.find(opt => opt.value === val);
195
+ const label = option?.label || val;
196
+ const colorClasses = getColorClasses(option?.color);
197
+
198
+ return (
199
+ <Badge
200
+ key={idx}
201
+ variant="outline"
202
+ className={colorClasses}
203
+ >
204
+ {label}
205
+ </Badge>
206
+ );
207
+ })}
208
+ </div>
209
+ );
210
+ }
211
+
212
+ // Handle single value
213
+ const option = options.find(opt => opt.value === value);
214
+ const label = option?.label || value;
215
+ const colorClasses = getColorClasses(option?.color);
216
+
217
+ return (
218
+ <Badge
219
+ variant="outline"
220
+ className={colorClasses}
221
+ >
222
+ {label}
223
+ </Badge>
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Email field cell renderer
229
+ */
230
+ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
231
+ if (!value) return <span>-</span>;
232
+
233
+ return (
234
+ <Button
235
+ variant="link"
236
+ className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
237
+ asChild
238
+ >
239
+ <a
240
+ href={`mailto:${value}`}
241
+ onClick={(e) => e.stopPropagation()}
242
+ >
243
+ {value}
244
+ </a>
245
+ </Button>
246
+ );
247
+ }
248
+
249
+ /**
250
+ * URL field cell renderer
251
+ */
252
+ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElement {
253
+ if (!value) return <span>-</span>;
254
+
255
+ return (
256
+ <Button
257
+ variant="link"
258
+ className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
259
+ asChild
260
+ >
261
+ <a
262
+ href={value}
263
+ target="_blank"
264
+ rel="noopener noreferrer"
265
+ onClick={(e) => e.stopPropagation()}
266
+ >
267
+ {value}
268
+ </a>
269
+ </Button>
270
+ );
271
+ }
272
+
273
+ /**
274
+ * Phone field cell renderer
275
+ */
276
+ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
277
+ if (!value) return <span>-</span>;
278
+
279
+ return (
280
+ <a
281
+ href={`tel:${value}`}
282
+ className="text-blue-600 hover:text-blue-800"
283
+ onClick={(e) => e.stopPropagation()}
284
+ >
285
+ {value}
286
+ </a>
287
+ );
288
+ }
289
+
290
+ /**
291
+ * File field cell renderer
292
+ */
293
+ export function FileCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
294
+ if (!value) return <span>-</span>;
295
+
296
+ const fileField = field as any;
297
+ const isMultiple = fileField.multiple;
298
+
299
+ if (Array.isArray(value)) {
300
+ const count = value.length;
301
+ return (
302
+ <span className="text-sm text-gray-600">
303
+ {count} {count === 1 ? 'file' : 'files'}
304
+ </span>
305
+ );
306
+ }
307
+
308
+ const fileName = value.name || value.original_name || 'File';
309
+ return <span className="text-sm truncate">{fileName}</span>;
310
+ }
311
+
312
+ /**
313
+ * Image field cell renderer (with thumbnails)
314
+ */
315
+ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
316
+ if (!value) return <span>-</span>;
317
+
318
+ if (Array.isArray(value)) {
319
+ return (
320
+ <div className="flex -space-x-2">
321
+ {value.slice(0, 3).map((img, idx) => (
322
+ <img
323
+ key={idx}
324
+ src={img.url || ''}
325
+ alt={img.name || `Image ${idx + 1}`}
326
+ className="size-8 rounded-md border-2 border-white object-cover"
327
+ />
328
+ ))}
329
+ {value.length > 3 && (
330
+ <div className="size-8 rounded-md border-2 border-white bg-gray-100 flex items-center justify-center text-xs font-medium text-gray-600">
331
+ +{value.length - 3}
332
+ </div>
333
+ )}
334
+ </div>
335
+ );
336
+ }
337
+
338
+ return (
339
+ <img
340
+ src={value.url || ''}
341
+ alt={value.name || 'Image'}
342
+ className="size-10 rounded-md object-cover"
343
+ />
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Lookup/Master-Detail field cell renderer
349
+ */
350
+ export function LookupCellRenderer({ value }: CellRendererProps): React.ReactElement {
351
+ if (!value) return <span>-</span>;
352
+
353
+ if (Array.isArray(value)) {
354
+ return (
355
+ <div className="flex flex-wrap gap-1">
356
+ {value.map((item, idx) => {
357
+ const label = item.name || item.label || item._id || String(item);
358
+ return (
359
+ <span
360
+ key={idx}
361
+ className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800"
362
+ >
363
+ {label}
364
+ </span>
365
+ );
366
+ })}
367
+ </div>
368
+ );
369
+ }
370
+
371
+ if (typeof value === 'object' && value !== null) {
372
+ const label = value.name || value.label || value._id || '[Object]';
373
+ return <span className="truncate">{label}</span>;
374
+ }
375
+
376
+ return <span className="truncate">{String(value)}</span>;
377
+ }
378
+
379
+ /**
380
+ * Formula field cell renderer (read-only)
381
+ */
382
+ export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactElement {
383
+ return (
384
+ <span className="text-gray-700 font-mono text-sm">
385
+ {value != null ? String(value) : '-'}
386
+ </span>
387
+ );
388
+ }
389
+
390
+ /**
391
+ * User/Owner field cell renderer (with avatars)
392
+ */
393
+ export function UserCellRenderer({ value }: CellRendererProps): React.ReactElement {
394
+ if (!value) return <span>-</span>;
395
+
396
+ if (Array.isArray(value)) {
397
+ return (
398
+ <div className="flex -space-x-2">
399
+ {value.slice(0, 3).map((user, idx) => {
400
+ const name = user.name || user.username || 'User';
401
+ const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
402
+
403
+ return (
404
+ <Avatar
405
+ key={idx}
406
+ className="size-8 border-2 border-white"
407
+ title={name}
408
+ >
409
+ <AvatarFallback className="bg-blue-500 text-white text-xs">
410
+ {initials}
411
+ </AvatarFallback>
412
+ </Avatar>
413
+ );
414
+ })}
415
+ {value.length > 3 && (
416
+ <Avatar className="size-8 border-2 border-white">
417
+ <AvatarFallback className="bg-gray-200 text-gray-600 text-xs">
418
+ +{value.length - 3}
419
+ </AvatarFallback>
420
+ </Avatar>
421
+ )}
422
+ </div>
423
+ );
424
+ }
425
+
426
+ const name = value.name || value.username || 'User';
427
+ const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
428
+
429
+ return (
430
+ <div className="flex items-center gap-2">
431
+ <Avatar className="size-8">
432
+ <AvatarFallback className="bg-blue-500 text-white text-xs">
433
+ {initials}
434
+ </AvatarFallback>
435
+ </Avatar>
436
+ <span className="truncate">{name}</span>
437
+ </div>
438
+ );
439
+ }
440
+
441
+ /**
442
+ * Field Registry
443
+ * Stores mapping between field types and their renderers.
444
+ */
445
+ const fieldRegistry = new Map<string, React.FC<CellRendererProps>>();
446
+
447
+ /**
448
+ * Register a custom field renderer
449
+ * @param type Field type (e.g. 'text', 'location', 'my-custom-type')
450
+ * @param renderer React component to render the field
451
+ */
452
+ export function registerFieldRenderer(type: string, renderer: React.FC<CellRendererProps>) {
453
+ fieldRegistry.set(type, renderer);
454
+ }
455
+
456
+ /**
457
+ * Get the appropriate cell renderer for a field type
458
+ */
459
+ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps> {
460
+ // 1. Try exact match in registry
461
+ if (fieldRegistry.has(fieldType)) {
462
+ return fieldRegistry.get(fieldType)!;
463
+ }
464
+
465
+ // 2. Fallback to standard mappings if not overridden
466
+ const standardMap: Record<string, React.FC<CellRendererProps>> = {
467
+ text: TextCellRenderer,
468
+ textarea: TextCellRenderer,
469
+ markdown: TextCellRenderer,
470
+ html: TextCellRenderer,
471
+ number: NumberCellRenderer,
472
+ currency: CurrencyCellRenderer,
473
+ percent: PercentCellRenderer,
474
+ boolean: BooleanCellRenderer,
475
+ date: DateCellRenderer,
476
+ datetime: DateTimeCellRenderer,
477
+ time: TextCellRenderer,
478
+ select: SelectCellRenderer,
479
+ lookup: SelectCellRenderer, // Default fallback
480
+ master_detail: SelectCellRenderer, // Default fallback
481
+ email: EmailCellRenderer,
482
+ url: UrlCellRenderer,
483
+ phone: PhoneCellRenderer,
484
+ file: FileCellRenderer,
485
+ image: ImageCellRenderer,
486
+ formula: FormulaCellRenderer,
487
+ summary: FormulaCellRenderer,
488
+ auto_number: TextCellRenderer,
489
+ user: UserCellRenderer,
490
+ owner: UserCellRenderer,
491
+ password: () => <span>••••••</span>,
492
+ location: TextCellRenderer, // Default fallback
493
+ object: () => <span className="text-gray-500 italic">[Object]</span>,
494
+ vector: () => <span className="text-gray-500 italic">[Vector]</span>,
495
+ grid: () => <span className="text-gray-500 italic">[Grid]</span>,
496
+ };
497
+
498
+ // 3. Register standard renderers implicitly if not present
499
+ // This ensures that if we call registerFieldRenderer('text', Custom), it works,
500
+ // but if we don't, we get the standard one.
501
+ return standardMap[fieldType] || TextCellRenderer;
502
+ }
503
+
504
+ // Register standard renderers immediately
505
+ registerFieldRenderer('lookup', LookupCellRenderer);
506
+ registerFieldRenderer('master_detail', LookupCellRenderer);
507
+ registerFieldRenderer('select', SelectCellRenderer);
508
+
509
+
510
+
511
+ /**
512
+ * Map field type to form component type
513
+ *
514
+ * @param fieldType - The ObjectQL field type identifier to convert
515
+ * (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
516
+ * @returns The normalized form field type string used in the form schema
517
+ * (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
518
+ */
519
+ export function mapFieldTypeToFormType(fieldType: string): string {
520
+ const typeMap: Record<string, string> = {
521
+ // Text-based fields
522
+ text: 'input',
523
+ textarea: 'textarea',
524
+ markdown: 'textarea', // Markdown editor (fallback to textarea)
525
+ html: 'textarea', // Rich text editor (fallback to textarea)
526
+
527
+ // Numeric fields
528
+ number: 'input',
529
+ currency: 'input',
530
+ percent: 'input',
531
+
532
+ // Date/Time fields
533
+ date: 'date-picker',
534
+ datetime: 'date-picker',
535
+ time: 'input', // Time picker (fallback to input with type="time")
536
+
537
+ // Boolean
538
+ boolean: 'switch',
539
+
540
+ // Selection fields
541
+ select: 'select',
542
+ lookup: 'select',
543
+ master_detail: 'select',
544
+
545
+ // Contact fields
546
+ email: 'input',
547
+ phone: 'input',
548
+ url: 'input',
549
+
550
+ // File fields
551
+ file: 'file-upload',
552
+ image: 'file-upload',
553
+
554
+ // Special fields
555
+ password: 'input',
556
+ location: 'input', // Location/map field (fallback to input)
557
+
558
+ // Auto-generated/computed fields (typically read-only)
559
+ formula: 'input',
560
+ summary: 'input',
561
+ auto_number: 'input',
562
+
563
+ // Complex data types
564
+ object: 'input', // JSON object (fallback to input)
565
+ vector: 'input', // Vector/embedding data (fallback to input)
566
+ grid: 'input', // Grid/table data (fallback to input)
567
+ };
568
+
569
+ return typeMap[fieldType] || 'input';
570
+ }
571
+
572
+ /**
573
+ * Formats file size in bytes to human-readable string
574
+ * @param bytes - File size in bytes (must be non-negative)
575
+ * @returns Formatted string (e.g., "5 MB", "1.5 GB")
576
+ */
577
+ export function formatFileSize(bytes: number): string {
578
+ if (bytes < 0 || !Number.isFinite(bytes)) {
579
+ return '0 B';
580
+ }
581
+
582
+ if (bytes === 0) {
583
+ return '0 B';
584
+ }
585
+
586
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
587
+ let size = bytes;
588
+ let unitIndex = 0;
589
+
590
+ while (size >= 1024 && unitIndex < units.length - 1) {
591
+ size /= 1024;
592
+ unitIndex++;
593
+ }
594
+
595
+ return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
596
+ }
597
+
598
+ /**
599
+ * Build validation rules from field metadata
600
+ * @param field - Field metadata from ObjectStack
601
+ * @returns Validation rule object compatible with react-hook-form
602
+ */
603
+ export function buildValidationRules(field: any): any {
604
+ const rules: any = {};
605
+
606
+ // Required validation
607
+ if (field.required) {
608
+ rules.required = typeof field.required_message === 'string'
609
+ ? field.required_message
610
+ : `${field.label || field.name} is required`;
611
+ }
612
+
613
+ // Length validation for text fields
614
+ if (field.min_length) {
615
+ rules.minLength = {
616
+ value: field.min_length,
617
+ message: field.min_length_message || `Minimum length is ${field.min_length} characters`,
618
+ };
619
+ }
620
+
621
+ if (field.max_length) {
622
+ rules.maxLength = {
623
+ value: field.max_length,
624
+ message: field.max_length_message || `Maximum length is ${field.max_length} characters`,
625
+ };
626
+ }
627
+
628
+ // Number range validation
629
+ if (field.min !== undefined) {
630
+ rules.min = {
631
+ value: field.min,
632
+ message: field.min_message || `Minimum value is ${field.min}`,
633
+ };
634
+ }
635
+
636
+ if (field.max !== undefined) {
637
+ rules.max = {
638
+ value: field.max,
639
+ message: field.max_message || `Maximum value is ${field.max}`,
640
+ };
641
+ }
642
+
643
+ // Pattern validation
644
+ if (field.pattern) {
645
+ rules.pattern = {
646
+ value: typeof field.pattern === 'string' ? new RegExp(field.pattern) : field.pattern,
647
+ message: field.pattern_message || 'Invalid format',
648
+ };
649
+ }
650
+
651
+ // Email validation
652
+ if (field.type === 'email') {
653
+ rules.pattern = {
654
+ value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
655
+ message: 'Please enter a valid email address',
656
+ };
657
+ }
658
+
659
+ // URL validation
660
+ if (field.type === 'url') {
661
+ rules.pattern = {
662
+ value: /^https?:\/\/.+/,
663
+ message: 'Please enter a valid URL',
664
+ };
665
+ }
666
+
667
+ // Custom validation function
668
+ if (field.validate) {
669
+ rules.validate = field.validate;
670
+ }
671
+
672
+ return Object.keys(rules).length > 0 ? rules : undefined;
673
+ }
674
+
675
+ /**
676
+ * Evaluate a conditional expression for field visibility
677
+ * @param condition - Condition object from field metadata
678
+ * @param formData - Current form values
679
+ * @returns Whether the condition is met
680
+ */
681
+ export function evaluateCondition(condition: any, formData: any): boolean {
682
+ if (!condition) return true;
683
+
684
+ // Simple field equality check
685
+ if (condition.field && condition.value !== undefined) {
686
+ const fieldValue = formData[condition.field];
687
+ if (condition.operator === '=' || condition.operator === '==') {
688
+ return fieldValue === condition.value;
689
+ } else if (condition.operator === '!=') {
690
+ return fieldValue !== condition.value;
691
+ } else if (condition.operator === '>') {
692
+ return fieldValue > condition.value;
693
+ } else if (condition.operator === '>=') {
694
+ return fieldValue >= condition.value;
695
+ } else if (condition.operator === '<') {
696
+ return fieldValue < condition.value;
697
+ } else if (condition.operator === '<=') {
698
+ return fieldValue <= condition.value;
699
+ } else if (condition.operator === 'in') {
700
+ return Array.isArray(condition.value) && condition.value.includes(fieldValue);
701
+ }
702
+ }
703
+
704
+ // AND/OR logic
705
+ if (condition.and && Array.isArray(condition.and)) {
706
+ return condition.and.every((c: any) => evaluateCondition(c, formData));
707
+ }
708
+
709
+ if (condition.or && Array.isArray(condition.or)) {
710
+ return condition.or.some((c: any) => evaluateCondition(c, formData));
711
+ }
712
+
713
+ // Default to true if condition format is unknown
714
+ return true;
715
+ }
716
+
717
+ import { ComponentRegistry } from '@object-ui/core';
718
+ import { TextField } from './widgets/TextField';
719
+ import { NumberField } from './widgets/NumberField';
720
+ import { BooleanField } from './widgets/BooleanField';
721
+ import { SelectField } from './widgets/SelectField';
722
+ import { DateField } from './widgets/DateField';
723
+ import { EmailField } from './widgets/EmailField';
724
+ import { PhoneField } from './widgets/PhoneField';
725
+ import { UrlField } from './widgets/UrlField';
726
+ import { CurrencyField } from './widgets/CurrencyField';
727
+ import { TextAreaField } from './widgets/TextAreaField';
728
+ import { RichTextField } from './widgets/RichTextField';
729
+ import { LookupField } from './widgets/LookupField';
730
+
731
+ export function registerFields() {
732
+ // Basic fields
733
+ ComponentRegistry.register('text', TextField);
734
+ ComponentRegistry.register('textarea', TextAreaField);
735
+ ComponentRegistry.register('number', NumberField);
736
+ ComponentRegistry.register('boolean', BooleanField);
737
+ ComponentRegistry.register('select', SelectField);
738
+ ComponentRegistry.register('date', DateField);
739
+
740
+ // Contact fields
741
+ ComponentRegistry.register('email', EmailField);
742
+ ComponentRegistry.register('phone', PhoneField);
743
+ ComponentRegistry.register('url', UrlField);
744
+
745
+ // Specialized fields
746
+ ComponentRegistry.register('currency', CurrencyField);
747
+ ComponentRegistry.register('markdown', RichTextField);
748
+ ComponentRegistry.register('html', RichTextField);
749
+ ComponentRegistry.register('lookup', LookupField);
750
+ ComponentRegistry.register('master_detail', LookupField);
751
+
752
+ // Register with field: prefix for explicit field widgets
753
+ ComponentRegistry.register('field:text', TextField);
754
+ ComponentRegistry.register('field:textarea', TextAreaField);
755
+ ComponentRegistry.register('field:number', NumberField);
756
+ ComponentRegistry.register('field:email', EmailField);
757
+ ComponentRegistry.register('field:phone', PhoneField);
758
+ ComponentRegistry.register('field:url', UrlField);
759
+ ComponentRegistry.register('field:currency', CurrencyField);
760
+ ComponentRegistry.register('field:lookup', LookupField);
761
+ }
762
+
763
+ export * from './widgets/types';
764
+ export * from './widgets/TextField';
765
+ export * from './widgets/NumberField';
766
+ export * from './widgets/BooleanField';
767
+ export * from './widgets/SelectField';
768
+ export * from './widgets/DateField';
769
+ export * from './widgets/EmailField';
770
+ export * from './widgets/PhoneField';
771
+ export * from './widgets/UrlField';
772
+ export * from './widgets/CurrencyField';
773
+ export * from './widgets/TextAreaField';
774
+ export * from './widgets/RichTextField';
775
+ export * from './widgets/LookupField';