@object-ui/fields 0.3.0 → 0.5.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/dist/fields/src/complex-widgets.test.d.ts +1 -0
- package/dist/fields/src/datetime-widgets.test.d.ts +1 -0
- package/dist/fields/src/fields.stories.d.ts +56 -0
- package/dist/fields/src/index.d.ts +48 -0
- package/dist/fields/src/new-widgets.test.d.ts +8 -0
- package/dist/fields/src/remaining-widgets.test.d.ts +1 -0
- package/dist/fields/src/standard-widgets.test.d.ts +0 -0
- package/dist/fields/src/text-rich-widgets.test.d.ts +1 -0
- package/dist/fields/src/visual-widgets.test.d.ts +1 -0
- package/dist/fields/src/widgets/AddressField.d.ts +16 -0
- package/dist/fields/src/widgets/AutoNumberField.d.ts +6 -0
- package/dist/fields/src/widgets/AvatarField.d.ts +6 -0
- package/dist/fields/src/widgets/CodeField.d.ts +7 -0
- package/dist/fields/src/widgets/ColorField.d.ts +6 -0
- package/dist/fields/src/widgets/CurrencyField.d.ts +1 -1
- package/dist/fields/src/widgets/DateTimeField.d.ts +2 -0
- package/dist/fields/src/widgets/FileField.d.ts +2 -0
- package/dist/fields/src/widgets/FormulaField.d.ts +6 -0
- package/dist/fields/src/widgets/GeolocationField.d.ts +14 -0
- package/dist/fields/src/widgets/GridField.d.ts +7 -0
- package/dist/fields/src/widgets/ImageField.d.ts +2 -0
- package/dist/fields/src/widgets/LocationField.d.ts +2 -0
- package/dist/fields/src/widgets/LookupField.d.ts +1 -1
- package/dist/fields/src/widgets/MasterDetailField.d.ts +14 -0
- package/dist/fields/src/widgets/ObjectField.d.ts +6 -0
- package/dist/fields/src/widgets/PasswordField.d.ts +2 -0
- package/dist/fields/src/widgets/PercentField.d.ts +2 -0
- package/dist/fields/src/widgets/QRCodeField.d.ts +6 -0
- package/dist/fields/src/widgets/RatingField.d.ts +6 -0
- package/dist/fields/src/widgets/SignatureField.d.ts +6 -0
- package/dist/fields/src/widgets/SliderField.d.ts +6 -0
- package/dist/fields/src/widgets/SummaryField.d.ts +6 -0
- package/dist/fields/src/widgets/TimeField.d.ts +2 -0
- package/dist/fields/src/widgets/UserField.d.ts +6 -0
- package/dist/fields/src/widgets/VectorField.d.ts +6 -0
- package/dist/fields/src/widgets/types.d.ts +2 -0
- package/dist/index.cjs +4 -2
- package/dist/index.js +2003 -618
- package/package.json +10 -8
- package/src/complex-widgets.test.tsx +146 -0
- package/src/datetime-widgets.test.tsx +93 -0
- package/src/fields.stories.tsx +566 -0
- package/src/index.tsx +303 -68
- package/src/new-widgets.test.tsx +302 -0
- package/src/remaining-widgets.test.tsx +172 -0
- package/src/standard-widgets.test.tsx +278 -0
- package/src/text-rich-widgets.test.tsx +104 -0
- package/src/visual-widgets.test.tsx +97 -0
- package/src/widgets/AddressField.tsx +112 -0
- package/src/widgets/AutoNumberField.tsx +16 -0
- package/src/widgets/AvatarField.tsx +110 -0
- package/src/widgets/BooleanField.tsx +19 -11
- package/src/widgets/CodeField.tsx +34 -0
- package/src/widgets/ColorField.tsx +44 -0
- package/src/widgets/CurrencyField.tsx +8 -7
- package/src/widgets/DateField.tsx +5 -2
- package/src/widgets/DateTimeField.tsx +28 -0
- package/src/widgets/EmailField.tsx +7 -3
- package/src/widgets/FileField.tsx +114 -0
- package/src/widgets/FormulaField.tsx +31 -0
- package/src/widgets/GeolocationField.tsx +151 -0
- package/src/widgets/GridField.tsx +63 -0
- package/src/widgets/ImageField.tsx +108 -0
- package/src/widgets/LocationField.tsx +46 -0
- package/src/widgets/LookupField.tsx +5 -5
- package/src/widgets/MasterDetailField.tsx +131 -0
- package/src/widgets/NumberField.tsx +8 -4
- package/src/widgets/ObjectField.tsx +85 -0
- package/src/widgets/PasswordField.tsx +45 -0
- package/src/widgets/PercentField.tsx +49 -0
- package/src/widgets/PhoneField.tsx +7 -3
- package/src/widgets/QRCodeField.tsx +96 -0
- package/src/widgets/RatingField.tsx +66 -0
- package/src/widgets/RichTextField.tsx +5 -5
- package/src/widgets/SelectField.tsx +7 -5
- package/src/widgets/SignatureField.tsx +140 -0
- package/src/widgets/SliderField.tsx +39 -0
- package/src/widgets/SummaryField.tsx +29 -0
- package/src/widgets/TextAreaField.tsx +9 -6
- package/src/widgets/TextField.tsx +13 -8
- package/src/widgets/TimeField.tsx +22 -0
- package/src/widgets/UrlField.tsx +7 -3
- package/src/widgets/UserField.tsx +88 -0
- package/src/widgets/VectorField.tsx +28 -0
- package/src/widgets/types.ts +2 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { FieldWidgetProps } from './types';
|
|
3
|
+
import { cn } from '@object-ui/components';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GridField - Sub-table/grid data display
|
|
7
|
+
* Shows tabular data in a simplified format
|
|
8
|
+
* For full editing capabilities, use the grid plugin
|
|
9
|
+
*/
|
|
10
|
+
export function GridField({ value, field, readonly, ...props }: FieldWidgetProps<any[]>) {
|
|
11
|
+
const gridField = (field || (props as any).schema) as any;
|
|
12
|
+
const columns = gridField?.columns || [];
|
|
13
|
+
|
|
14
|
+
if (!value || !Array.isArray(value)) {
|
|
15
|
+
return <span className="text-sm text-gray-500">-</span>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (readonly) {
|
|
19
|
+
return (
|
|
20
|
+
<div className={cn("text-sm", props.className)}>
|
|
21
|
+
<span className="text-gray-700">{value.length} rows</span>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Simple read-only table view
|
|
27
|
+
return (
|
|
28
|
+
<div className={cn("border border-gray-200 rounded-md overflow-hidden", props.className)}>
|
|
29
|
+
<div className="overflow-auto max-h-60">
|
|
30
|
+
<table className="w-full text-sm">
|
|
31
|
+
<thead className="bg-gray-50 border-b border-gray-200">
|
|
32
|
+
<tr>
|
|
33
|
+
{columns.map((col: any, idx: number) => (
|
|
34
|
+
<th
|
|
35
|
+
key={idx}
|
|
36
|
+
className="px-3 py-2 text-left text-xs font-medium text-gray-700"
|
|
37
|
+
>
|
|
38
|
+
{col.label || col.name}
|
|
39
|
+
</th>
|
|
40
|
+
))}
|
|
41
|
+
</tr>
|
|
42
|
+
</thead>
|
|
43
|
+
<tbody className="divide-y divide-gray-200">
|
|
44
|
+
{value.slice(0, 5).map((row: any, rowIdx: number) => (
|
|
45
|
+
<tr key={rowIdx} className="hover:bg-gray-50">
|
|
46
|
+
{columns.map((col: any, colIdx: number) => (
|
|
47
|
+
<td key={colIdx} className="px-3 py-2 text-gray-900">
|
|
48
|
+
{row[col.name] != null ? String(row[col.name]) : '-'}
|
|
49
|
+
</td>
|
|
50
|
+
))}
|
|
51
|
+
</tr>
|
|
52
|
+
))}
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
56
|
+
{value.length > 5 && (
|
|
57
|
+
<div className="bg-gray-50 px-3 py-2 text-xs text-gray-500 border-t border-gray-200">
|
|
58
|
+
Showing 5 of {value.length} rows
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import React, { useRef } from 'react';
|
|
2
|
+
import { Button } from '@object-ui/components';
|
|
3
|
+
import { Upload, X, Image as ImageIcon } from 'lucide-react';
|
|
4
|
+
import { FieldWidgetProps } from './types';
|
|
5
|
+
|
|
6
|
+
export function ImageField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
7
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
8
|
+
const imageField = (field || (props as any).schema) as any;
|
|
9
|
+
const multiple = imageField?.multiple || false;
|
|
10
|
+
const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
|
|
11
|
+
|
|
12
|
+
if (readonly) {
|
|
13
|
+
if (!value) return <span className="text-sm">-</span>;
|
|
14
|
+
|
|
15
|
+
const images = Array.isArray(value) ? value : [value];
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-wrap gap-2">
|
|
18
|
+
{images.map((img: any, idx: number) => (
|
|
19
|
+
<img
|
|
20
|
+
key={idx}
|
|
21
|
+
src={img.url || ''}
|
|
22
|
+
alt={img.name || `Image ${idx + 1}`}
|
|
23
|
+
className="size-20 rounded-md object-cover border border-gray-200"
|
|
24
|
+
/>
|
|
25
|
+
))}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const images = value ? (Array.isArray(value) ? value : [value]) : [];
|
|
31
|
+
|
|
32
|
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
33
|
+
const selectedFiles = Array.from(e.target.files || []);
|
|
34
|
+
if (selectedFiles.length === 0) return;
|
|
35
|
+
|
|
36
|
+
const imageObjects = selectedFiles.map(file => ({
|
|
37
|
+
name: file.name,
|
|
38
|
+
original_name: file.name,
|
|
39
|
+
size: file.size,
|
|
40
|
+
mime_type: file.type,
|
|
41
|
+
// In a real implementation, this would upload the image and return a URL
|
|
42
|
+
url: URL.createObjectURL(file),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
if (multiple) {
|
|
46
|
+
onChange([...images, ...imageObjects]);
|
|
47
|
+
} else {
|
|
48
|
+
onChange(imageObjects[0]);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleRemove = (index: number) => {
|
|
53
|
+
if (multiple) {
|
|
54
|
+
const newImages = images.filter((_: any, i: number) => i !== index);
|
|
55
|
+
onChange(newImages.length > 0 ? newImages : null);
|
|
56
|
+
} else {
|
|
57
|
+
onChange(null);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className={props.className}>
|
|
63
|
+
<input
|
|
64
|
+
ref={inputRef}
|
|
65
|
+
type="file"
|
|
66
|
+
multiple={multiple}
|
|
67
|
+
accept={accept}
|
|
68
|
+
onChange={handleFileChange}
|
|
69
|
+
className="hidden"
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
<div className="space-y-2">
|
|
73
|
+
{images.length > 0 && (
|
|
74
|
+
<div className="grid grid-cols-4 gap-2">
|
|
75
|
+
{images.map((img: any, idx: number) => (
|
|
76
|
+
<div key={idx} className="relative group">
|
|
77
|
+
<img
|
|
78
|
+
src={img.url || ''}
|
|
79
|
+
alt={img.name || `Image ${idx + 1}`}
|
|
80
|
+
className="size-20 rounded-md object-cover border border-gray-200"
|
|
81
|
+
/>
|
|
82
|
+
<Button
|
|
83
|
+
type="button"
|
|
84
|
+
variant="destructive"
|
|
85
|
+
size="sm"
|
|
86
|
+
onClick={() => handleRemove(idx)}
|
|
87
|
+
className="absolute top-1 right-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
88
|
+
>
|
|
89
|
+
<X className="size-3" />
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
<Button
|
|
97
|
+
type="button"
|
|
98
|
+
variant="outline"
|
|
99
|
+
onClick={() => inputRef.current?.click()}
|
|
100
|
+
className="w-full"
|
|
101
|
+
>
|
|
102
|
+
<ImageIcon className="size-4 mr-2" />
|
|
103
|
+
{images.length > 0 ? 'Add More Images' : 'Upload Image'}
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
export function LocationField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
6
|
+
const config = field || (props as any).schema;
|
|
7
|
+
// Location is stored as { latitude, longitude } object
|
|
8
|
+
// For display, convert to "latitude, longitude" string format
|
|
9
|
+
const displayValue = value && typeof value === 'object'
|
|
10
|
+
? `${value.latitude || 0}, ${value.longitude || 0}`
|
|
11
|
+
: '';
|
|
12
|
+
|
|
13
|
+
if (readonly) {
|
|
14
|
+
return <span className="text-sm">{displayValue || '-'}</span>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
18
|
+
const val = e.target.value;
|
|
19
|
+
if (!val.trim()) {
|
|
20
|
+
onChange(null);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Parse as coordinates (latitude, longitude)
|
|
25
|
+
const parts = val.split(',').map(p => p.trim());
|
|
26
|
+
if (parts.length === 2) {
|
|
27
|
+
const lat = parseFloat(parts[0]);
|
|
28
|
+
const lng = parseFloat(parts[1]);
|
|
29
|
+
if (!isNaN(lat) && !isNaN(lng)) {
|
|
30
|
+
onChange({ latitude: lat, longitude: lng });
|
|
31
|
+
}
|
|
32
|
+
// If invalid, don't update the value
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Input
|
|
38
|
+
type="text"
|
|
39
|
+
value={displayValue}
|
|
40
|
+
onChange={handleChange}
|
|
41
|
+
placeholder={config?.placeholder || 'latitude, longitude'}
|
|
42
|
+
disabled={readonly || props.disabled}
|
|
43
|
+
className={props.className}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -22,12 +22,12 @@ interface LookupOption {
|
|
|
22
22
|
* Lookup field for selecting related records
|
|
23
23
|
* Supports single and multi-select with search
|
|
24
24
|
*/
|
|
25
|
-
export function LookupField({ value, onChange, field, readonly }: FieldWidgetProps<any>) {
|
|
25
|
+
export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
26
26
|
const [isOpen, setIsOpen] = useState(false);
|
|
27
27
|
const [searchQuery, setSearchQuery] = useState('');
|
|
28
28
|
|
|
29
|
-
const lookupField = field as any;
|
|
30
|
-
const options: LookupOption[] = lookupField
|
|
29
|
+
const lookupField = (field || (props as any).schema) as any;
|
|
30
|
+
const options: LookupOption[] = lookupField?.options || [];
|
|
31
31
|
const multiple = lookupField.multiple || false;
|
|
32
32
|
const displayField = lookupField.display_field || 'label';
|
|
33
33
|
|
|
@@ -127,7 +127,7 @@ export function LookupField({ value, onChange, field, readonly }: FieldWidgetPro
|
|
|
127
127
|
>
|
|
128
128
|
<Search className="mr-2 size-4" />
|
|
129
129
|
{selectedOptions.length === 0
|
|
130
|
-
?
|
|
130
|
+
? lookupField?.placeholder || 'Select...'
|
|
131
131
|
: multiple ? `${selectedOptions.length} selected` : 'Change selection'
|
|
132
132
|
}
|
|
133
133
|
</Button>
|
|
@@ -135,7 +135,7 @@ export function LookupField({ value, onChange, field, readonly }: FieldWidgetPro
|
|
|
135
135
|
<DialogContent className="max-w-md">
|
|
136
136
|
<DialogHeader>
|
|
137
137
|
<DialogTitle>
|
|
138
|
-
{
|
|
138
|
+
{lookupField?.label || 'Select'} {multiple && '(multiple)'}
|
|
139
139
|
</DialogTitle>
|
|
140
140
|
</DialogHeader>
|
|
141
141
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Button, Badge, cn } from '@object-ui/components';
|
|
3
|
+
import { Plus, X, ExternalLink } from 'lucide-react';
|
|
4
|
+
import { FieldWidgetProps } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Master-Detail relationship data structure
|
|
8
|
+
*/
|
|
9
|
+
export interface MasterDetailValue {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
[key: string]: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Master-Detail field widget - manages one-to-many relationships
|
|
17
|
+
* Displays related records with add/remove capabilities
|
|
18
|
+
*/
|
|
19
|
+
export function MasterDetailField({
|
|
20
|
+
value,
|
|
21
|
+
onChange,
|
|
22
|
+
field,
|
|
23
|
+
readonly,
|
|
24
|
+
className,
|
|
25
|
+
...props
|
|
26
|
+
}: FieldWidgetProps<MasterDetailValue[]>) {
|
|
27
|
+
const items = value || [];
|
|
28
|
+
const config = field || (props as any).schema;
|
|
29
|
+
|
|
30
|
+
const handleAdd = () => {
|
|
31
|
+
// This would typically open a dialog to select/create related records
|
|
32
|
+
// For now, we'll just show a placeholder
|
|
33
|
+
const newItem: MasterDetailValue = {
|
|
34
|
+
id: `new-${Date.now()}`,
|
|
35
|
+
label: 'New Related Record',
|
|
36
|
+
};
|
|
37
|
+
onChange([...items, newItem]);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const handleRemove = (id: string) => {
|
|
41
|
+
onChange(items.filter(item => item.id !== id));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleView = (item: MasterDetailValue) => {
|
|
45
|
+
// This would typically navigate to the detail view
|
|
46
|
+
console.log('View detail:', item);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (readonly) {
|
|
50
|
+
return (
|
|
51
|
+
<div className={cn("space-y-2", className)}>
|
|
52
|
+
{items.length === 0 ? (
|
|
53
|
+
<span className="text-sm text-muted-foreground">No related records</span>
|
|
54
|
+
) : (
|
|
55
|
+
items.map((item) => (
|
|
56
|
+
<div
|
|
57
|
+
key={item.id}
|
|
58
|
+
className="flex items-center justify-between p-2 border rounded hover:bg-muted/50"
|
|
59
|
+
>
|
|
60
|
+
<span className="text-sm">{item.label}</span>
|
|
61
|
+
<Button
|
|
62
|
+
type="button"
|
|
63
|
+
variant="ghost"
|
|
64
|
+
size="sm"
|
|
65
|
+
onClick={() => handleView(item)}
|
|
66
|
+
>
|
|
67
|
+
<ExternalLink className="w-4 h-4" />
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
))
|
|
71
|
+
)}
|
|
72
|
+
<p className="text-xs text-muted-foreground">
|
|
73
|
+
{items.length} {items.length === 1 ? 'record' : 'records'}
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className={cn("space-y-3", className)}>
|
|
81
|
+
<div className="space-y-2">
|
|
82
|
+
{items.map((item) => (
|
|
83
|
+
<div
|
|
84
|
+
key={item.id}
|
|
85
|
+
className="flex items-center justify-between gap-2 p-2 border rounded hover:bg-muted/50"
|
|
86
|
+
>
|
|
87
|
+
<div className="flex items-center gap-2 flex-1">
|
|
88
|
+
<Badge variant="outline">{item.id}</Badge>
|
|
89
|
+
<span className="text-sm flex-1">{item.label}</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="flex items-center gap-1">
|
|
92
|
+
<Button
|
|
93
|
+
type="button"
|
|
94
|
+
variant="ghost"
|
|
95
|
+
size="sm"
|
|
96
|
+
onClick={() => handleView(item)}
|
|
97
|
+
>
|
|
98
|
+
<ExternalLink className="w-4 h-4" />
|
|
99
|
+
</Button>
|
|
100
|
+
<Button
|
|
101
|
+
type="button"
|
|
102
|
+
variant="ghost"
|
|
103
|
+
size="sm"
|
|
104
|
+
onClick={() => handleRemove(item.id)}
|
|
105
|
+
disabled={props.disabled}
|
|
106
|
+
>
|
|
107
|
+
<X className="w-4 h-4" />
|
|
108
|
+
</Button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
))}
|
|
112
|
+
{items.length === 0 && (
|
|
113
|
+
<div className="text-sm text-muted-foreground text-center py-4 border border-dashed rounded bg-muted/20">
|
|
114
|
+
No related records
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<Button
|
|
120
|
+
type="button"
|
|
121
|
+
variant="outline"
|
|
122
|
+
className="w-full"
|
|
123
|
+
onClick={handleAdd}
|
|
124
|
+
disabled={props.disabled}
|
|
125
|
+
>
|
|
126
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
127
|
+
Add {config?.label || 'Record'}
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -8,20 +8,24 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
8
8
|
return <span className="text-sm">{value ?? '-'}</span>;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const numberField = (field || (props as any).schema) as NumberFieldMetadata;
|
|
12
|
+
const precision = numberField?.precision;
|
|
13
|
+
|
|
14
|
+
// Filter out non-DOM props
|
|
15
|
+
const { inputType, ...domProps } = props as any;
|
|
12
16
|
|
|
13
17
|
return (
|
|
14
18
|
<Input
|
|
19
|
+
{...domProps}
|
|
15
20
|
type="number"
|
|
16
21
|
value={value ?? ''}
|
|
17
22
|
onChange={(e) => {
|
|
18
23
|
const val = e.target.value;
|
|
19
24
|
onChange(val === '' ? (null as any) : Number(val));
|
|
20
25
|
}}
|
|
21
|
-
placeholder={
|
|
22
|
-
disabled={readonly}
|
|
26
|
+
placeholder={numberField?.placeholder}
|
|
27
|
+
disabled={readonly || domProps.disabled}
|
|
23
28
|
step={precision ? Math.pow(10, -precision) : 'any'}
|
|
24
|
-
className={props.className}
|
|
25
29
|
/>
|
|
26
30
|
);
|
|
27
31
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Textarea, cn } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ObjectField - JSON object editor
|
|
7
|
+
* Allows editing structured JSON data
|
|
8
|
+
*/
|
|
9
|
+
export function ObjectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
10
|
+
const config = field || (props as any).schema;
|
|
11
|
+
|
|
12
|
+
// Initialize string state based on value
|
|
13
|
+
const getInitialJsonString = () => {
|
|
14
|
+
if (value === undefined || value === null) return '';
|
|
15
|
+
return JSON.stringify(value, null, 2);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const [jsonString, setJsonString] = useState(getInitialJsonString);
|
|
19
|
+
const [error, setError] = useState<string | null>(null);
|
|
20
|
+
|
|
21
|
+
// Sync internal string state when value changes externally
|
|
22
|
+
// This is a controlled component pattern where we need to sync external changes
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
try {
|
|
25
|
+
if (value === undefined || value === null) {
|
|
26
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
|
|
27
|
+
setJsonString('');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Only update if the parsed internal state doesn't match the new value
|
|
31
|
+
// This prevents cursor jumping/reformatting while typing valid JSON
|
|
32
|
+
const currentParsed = jsonString ? JSON.parse(jsonString) : null;
|
|
33
|
+
if (JSON.stringify(currentParsed) !== JSON.stringify(value)) {
|
|
34
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
|
|
35
|
+
setJsonString(JSON.stringify(value, null, 2));
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Fallback if internal state was invalid JSON
|
|
39
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
|
|
40
|
+
setJsonString(JSON.stringify(value, null, 2));
|
|
41
|
+
}
|
|
42
|
+
}, [value, jsonString]);
|
|
43
|
+
|
|
44
|
+
if (readonly) {
|
|
45
|
+
if (!value) return <span className="text-sm">-</span>;
|
|
46
|
+
return (
|
|
47
|
+
<pre className={cn("text-xs bg-gray-50 p-2 rounded border border-gray-200 overflow-auto max-h-40", props.className)}>
|
|
48
|
+
{JSON.stringify(value, null, 2)}
|
|
49
|
+
</pre>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
54
|
+
const str = e.target.value;
|
|
55
|
+
setJsonString(str);
|
|
56
|
+
setError(null);
|
|
57
|
+
|
|
58
|
+
if (!str.trim()) {
|
|
59
|
+
onChange(null);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(str);
|
|
65
|
+
onChange(parsed);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
// Invalid JSON - don't propagate change to parent, but keep local state
|
|
68
|
+
setError("Invalid JSON");
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="space-y-1">
|
|
74
|
+
<Textarea
|
|
75
|
+
value={jsonString}
|
|
76
|
+
onChange={handleChange}
|
|
77
|
+
placeholder={config?.placeholder || '{\n "key": "value"\n}'}
|
|
78
|
+
disabled={readonly || props.disabled}
|
|
79
|
+
className={cn("font-mono text-xs", error ? "border-red-500 focus-visible:ring-red-500" : "", props.className)}
|
|
80
|
+
rows={6}
|
|
81
|
+
/>
|
|
82
|
+
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { Button } from '@object-ui/components';
|
|
4
|
+
import { Eye, EyeOff } from 'lucide-react';
|
|
5
|
+
import { FieldWidgetProps } from './types';
|
|
6
|
+
|
|
7
|
+
export function PasswordField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string>) {
|
|
8
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
9
|
+
const config = field || (props as any).schema;
|
|
10
|
+
|
|
11
|
+
if (readonly) {
|
|
12
|
+
return <span className="text-sm">••••••••</span>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Filter out non-DOM props
|
|
16
|
+
const { inputType, ...domProps } = props as any;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="relative">
|
|
20
|
+
<Input
|
|
21
|
+
{...domProps}
|
|
22
|
+
type={showPassword ? 'text' : 'password'}
|
|
23
|
+
value={value || ''}
|
|
24
|
+
onChange={(e) => onChange(e.target.value)}
|
|
25
|
+
placeholder={config?.placeholder}
|
|
26
|
+
disabled={readonly || domProps.disabled}
|
|
27
|
+
className={`pr-10 ${className || ''}`}
|
|
28
|
+
/>
|
|
29
|
+
<Button
|
|
30
|
+
type="button"
|
|
31
|
+
variant="ghost"
|
|
32
|
+
size="sm"
|
|
33
|
+
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
34
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
35
|
+
tabIndex={-1}
|
|
36
|
+
>
|
|
37
|
+
{showPassword ? (
|
|
38
|
+
<EyeOff className="size-4 text-gray-500" />
|
|
39
|
+
) : (
|
|
40
|
+
<Eye className="size-4 text-gray-500" />
|
|
41
|
+
)}
|
|
42
|
+
</Button>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
|
|
6
|
+
const percentField = (field || (props as any).schema) as any;
|
|
7
|
+
const precision = percentField?.precision ?? 2;
|
|
8
|
+
|
|
9
|
+
if (readonly) {
|
|
10
|
+
if (value == null) return <span className="text-sm">-</span>;
|
|
11
|
+
return (
|
|
12
|
+
<span className="text-sm font-medium tabular-nums">
|
|
13
|
+
{(value * 100).toFixed(precision)}%
|
|
14
|
+
</span>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Convert between stored value (0-1) and display value (0-100)
|
|
19
|
+
const displayValue = value != null ? (value * 100) : '';
|
|
20
|
+
|
|
21
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
22
|
+
if (e.target.value === '') {
|
|
23
|
+
onChange(null as any);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const parsed = parseFloat(e.target.value);
|
|
27
|
+
const val = isNaN(parsed) ? null : parsed / 100;
|
|
28
|
+
onChange(val as any);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="relative">
|
|
33
|
+
<Input
|
|
34
|
+
{...props}
|
|
35
|
+
type="number"
|
|
36
|
+
value={displayValue}
|
|
37
|
+
onChange={handleChange}
|
|
38
|
+
placeholder={percentField?.placeholder || '0'}
|
|
39
|
+
disabled={readonly || props.disabled}
|
|
40
|
+
className={`pr-8 ${className || ''}`}
|
|
41
|
+
step={Math.pow(10, -precision).toFixed(precision)}
|
|
42
|
+
aria-invalid={!!errorMessage}
|
|
43
|
+
/>
|
|
44
|
+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
|
45
|
+
%
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Input } from '@object-ui/components';
|
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
5
|
export function PhoneField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
|
|
6
|
+
const config = field || (props as any).schema;
|
|
6
7
|
if (readonly) {
|
|
7
8
|
if (!value) return <span className="text-sm">-</span>;
|
|
8
9
|
return (
|
|
@@ -15,14 +16,17 @@ export function PhoneField({ value, onChange, field, readonly, errorMessage, ...
|
|
|
15
16
|
);
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
// Filter out non-DOM props
|
|
20
|
+
const { inputType, ...domProps } = props as any;
|
|
21
|
+
|
|
18
22
|
return (
|
|
19
23
|
<Input
|
|
24
|
+
{...domProps}
|
|
20
25
|
type="tel"
|
|
21
26
|
value={value || ''}
|
|
22
27
|
onChange={(e) => onChange(e.target.value)}
|
|
23
|
-
placeholder={
|
|
24
|
-
disabled={readonly}
|
|
25
|
-
className={props.className}
|
|
28
|
+
placeholder={config?.placeholder || '(555) 123-4567'}
|
|
29
|
+
disabled={readonly || domProps.disabled}
|
|
26
30
|
aria-invalid={!!errorMessage}
|
|
27
31
|
/>
|
|
28
32
|
);
|