@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.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/fields/src/index.d.ts +143 -0
- package/dist/fields/src/widgets/BooleanField.d.ts +2 -0
- package/dist/fields/src/widgets/CurrencyField.d.ts +2 -0
- package/dist/fields/src/widgets/DateField.d.ts +2 -0
- package/dist/fields/src/widgets/EmailField.d.ts +2 -0
- package/dist/fields/src/widgets/LookupField.d.ts +6 -0
- package/dist/fields/src/widgets/NumberField.d.ts +2 -0
- package/dist/fields/src/widgets/PhoneField.d.ts +2 -0
- package/dist/fields/src/widgets/RichTextField.d.ts +7 -0
- package/dist/fields/src/widgets/SelectField.d.ts +2 -0
- package/dist/fields/src/widgets/TextAreaField.d.ts +2 -0
- package/dist/fields/src/widgets/TextField.d.ts +2 -0
- package/dist/fields/src/widgets/UrlField.d.ts +2 -0
- package/dist/fields/src/widgets/types.d.ts +9 -0
- package/dist/index.cjs +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1060 -0
- package/package.json +52 -0
- package/src/index.tsx +775 -0
- package/src/widgets/BooleanField.tsx +40 -0
- package/src/widgets/CurrencyField.tsx +62 -0
- package/src/widgets/DateField.tsx +19 -0
- package/src/widgets/EmailField.tsx +29 -0
- package/src/widgets/LookupField.tsx +186 -0
- package/src/widgets/NumberField.tsx +27 -0
- package/src/widgets/PhoneField.tsx +29 -0
- package/src/widgets/RichTextField.tsx +42 -0
- package/src/widgets/SelectField.tsx +38 -0
- package/src/widgets/TextAreaField.tsx +41 -0
- package/src/widgets/TextField.tsx +36 -0
- package/src/widgets/UrlField.tsx +38 -0
- 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';
|