@object-ui/fields 0.3.1 → 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,110 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
|
|
3
|
+
import { Upload, X } from 'lucide-react';
|
|
4
|
+
import { FieldWidgetProps } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Avatar field widget - provides an avatar/profile picture uploader
|
|
8
|
+
* Supports image URLs or file uploads
|
|
9
|
+
*/
|
|
10
|
+
export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
11
|
+
const [isHovered, setIsHovered] = React.useState(false);
|
|
12
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
13
|
+
|
|
14
|
+
const avatarField = (field || (props as any).schema) as any;
|
|
15
|
+
|
|
16
|
+
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
17
|
+
const file = event.target.files?.[0];
|
|
18
|
+
if (!file) return;
|
|
19
|
+
|
|
20
|
+
// Check file type
|
|
21
|
+
if (!file.type.startsWith('image/')) {
|
|
22
|
+
console.error('Please select an image file');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check file size (max 5MB)
|
|
27
|
+
if (file.size > 5 * 1024 * 1024) {
|
|
28
|
+
console.error('File size must be less than 5MB');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Convert to base64 or upload to server
|
|
33
|
+
const reader = new FileReader();
|
|
34
|
+
reader.onloadend = () => {
|
|
35
|
+
onChange(reader.result as string);
|
|
36
|
+
};
|
|
37
|
+
reader.readAsDataURL(file);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const handleRemove = () => {
|
|
41
|
+
onChange('');
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Extract initials for fallback
|
|
45
|
+
const getInitials = (): string => {
|
|
46
|
+
const name = avatarField?.defaultName || avatarField?.label || 'User';
|
|
47
|
+
return name
|
|
48
|
+
.split(' ')
|
|
49
|
+
.map((word: string) => word[0])
|
|
50
|
+
.join('')
|
|
51
|
+
.toUpperCase()
|
|
52
|
+
.slice(0, 2);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (readonly) {
|
|
56
|
+
return (
|
|
57
|
+
<Avatar className="w-16 h-16">
|
|
58
|
+
{value && <AvatarImage src={value} alt={avatarField?.label} />}
|
|
59
|
+
<AvatarFallback>{getInitials()}</AvatarFallback>
|
|
60
|
+
</Avatar>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex items-center gap-4">
|
|
66
|
+
<div
|
|
67
|
+
className="relative"
|
|
68
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
69
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
70
|
+
>
|
|
71
|
+
<Avatar className="w-16 h-16">
|
|
72
|
+
{value && <AvatarImage src={value} alt={avatarField?.label} />}
|
|
73
|
+
<AvatarFallback>{getInitials()}</AvatarFallback>
|
|
74
|
+
</Avatar>
|
|
75
|
+
{!readonly && isHovered && value && (
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={handleRemove}
|
|
79
|
+
className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full p-1 hover:bg-destructive/90"
|
|
80
|
+
>
|
|
81
|
+
<X className="w-3 h-3" />
|
|
82
|
+
</button>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div className="flex flex-col gap-2">
|
|
87
|
+
<input
|
|
88
|
+
ref={fileInputRef}
|
|
89
|
+
type="file"
|
|
90
|
+
accept="image/*"
|
|
91
|
+
onChange={handleFileSelect}
|
|
92
|
+
className="hidden"
|
|
93
|
+
/>
|
|
94
|
+
<Button
|
|
95
|
+
type="button"
|
|
96
|
+
variant="outline"
|
|
97
|
+
size="sm"
|
|
98
|
+
onClick={() => fileInputRef.current?.click()}
|
|
99
|
+
disabled={readonly || props.disabled}
|
|
100
|
+
>
|
|
101
|
+
<Upload className="w-4 h-4 mr-2" />
|
|
102
|
+
{value ? 'Change' : 'Upload'} Avatar
|
|
103
|
+
</Button>
|
|
104
|
+
<p className="text-xs text-muted-foreground">
|
|
105
|
+
PNG, JPG up to 5MB
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -1,26 +1,34 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useId } from 'react';
|
|
2
2
|
import { Switch, Checkbox, Label } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
5
|
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<boolean>) {
|
|
6
|
+
const config = (field || (props as any).schema) as any;
|
|
7
|
+
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
|
|
8
|
+
const widget = config?.widget;
|
|
9
|
+
// Generate unique ID using React's useId hook - must be before early returns (rules of hooks)
|
|
10
|
+
const generatedId = useId();
|
|
11
|
+
const id = config?.name || generatedId;
|
|
12
|
+
const label = config?.label || 'Checkbox';
|
|
13
|
+
|
|
6
14
|
if (readonly) {
|
|
7
15
|
return <span className="text-sm">{value ? 'Yes' : 'No'}</span>;
|
|
8
16
|
}
|
|
9
17
|
|
|
10
|
-
//
|
|
11
|
-
const
|
|
18
|
+
// Filter out non-DOM props
|
|
19
|
+
const { inputType, ...domProps } = props as any;
|
|
12
20
|
|
|
13
21
|
if (widget === 'checkbox') {
|
|
14
22
|
return (
|
|
15
23
|
<div className="flex items-center space-x-2">
|
|
16
24
|
<Checkbox
|
|
17
|
-
|
|
25
|
+
{...domProps}
|
|
26
|
+
id={id}
|
|
18
27
|
checked={!!value}
|
|
19
28
|
onCheckedChange={(checked) => onChange(!!checked)}
|
|
20
|
-
disabled={readonly}
|
|
21
|
-
className={props.className}
|
|
29
|
+
disabled={readonly || domProps.disabled}
|
|
22
30
|
/>
|
|
23
|
-
<Label htmlFor={
|
|
31
|
+
<Label htmlFor={id}>{label}</Label>
|
|
24
32
|
</div>
|
|
25
33
|
)
|
|
26
34
|
}
|
|
@@ -28,13 +36,13 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
|
|
|
28
36
|
return (
|
|
29
37
|
<div className="flex items-center space-x-2">
|
|
30
38
|
<Switch
|
|
31
|
-
|
|
39
|
+
{...domProps}
|
|
40
|
+
id={id}
|
|
32
41
|
checked={!!value}
|
|
33
42
|
onCheckedChange={onChange}
|
|
34
|
-
disabled={readonly}
|
|
35
|
-
className={props.className}
|
|
43
|
+
disabled={readonly || domProps.disabled}
|
|
36
44
|
/>
|
|
37
|
-
<Label htmlFor={
|
|
45
|
+
<Label htmlFor={id}>{label}</Label>
|
|
38
46
|
</div>
|
|
39
47
|
);
|
|
40
48
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Textarea, cn } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Code field widget - provides a code editor with syntax highlighting
|
|
7
|
+
* Uses a simple textarea with monospace font
|
|
8
|
+
* For advanced code editing, use the @object-ui/plugin-editor component
|
|
9
|
+
*/
|
|
10
|
+
export function CodeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
11
|
+
const config = field || (props as any).schema;
|
|
12
|
+
// Get code-specific configuration from field metadata
|
|
13
|
+
const language = (config as any)?.language ?? 'javascript';
|
|
14
|
+
|
|
15
|
+
if (readonly) {
|
|
16
|
+
return (
|
|
17
|
+
<pre className={cn("text-sm bg-muted p-2 rounded overflow-x-auto border", props.className)}>
|
|
18
|
+
<code>{value || '-'}</code>
|
|
19
|
+
</pre>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Textarea
|
|
25
|
+
value={value || ''}
|
|
26
|
+
onChange={(e) => onChange(e.target.value)}
|
|
27
|
+
placeholder={config?.placeholder || `// Write ${language} code here...`}
|
|
28
|
+
disabled={readonly || props.disabled}
|
|
29
|
+
className={cn("font-mono text-sm", props.className)}
|
|
30
|
+
rows={12}
|
|
31
|
+
spellCheck={false}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Color field widget - provides a color picker input
|
|
7
|
+
* Supports hex color values (e.g., #ff0000)
|
|
8
|
+
*/
|
|
9
|
+
export function ColorField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
10
|
+
const colorField = (field || (props as any).schema) as any;
|
|
11
|
+
|
|
12
|
+
if (readonly) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex items-center gap-2">
|
|
15
|
+
<div
|
|
16
|
+
className="w-6 h-6 rounded border border-input"
|
|
17
|
+
style={{ backgroundColor: value || '#000000' }}
|
|
18
|
+
/>
|
|
19
|
+
<span className="text-sm">{value || '-'}</span>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex items-center gap-2">
|
|
26
|
+
<input
|
|
27
|
+
type="color"
|
|
28
|
+
value={value || '#000000'}
|
|
29
|
+
onChange={(e) => onChange(e.target.value)}
|
|
30
|
+
disabled={readonly || props.disabled}
|
|
31
|
+
className="w-10 h-10 rounded border border-input cursor-pointer"
|
|
32
|
+
/>
|
|
33
|
+
<Input
|
|
34
|
+
type="text"
|
|
35
|
+
value={value || ''}
|
|
36
|
+
onChange={(e) => onChange(e.target.value)}
|
|
37
|
+
placeholder={colorField?.placeholder || '#000000'}
|
|
38
|
+
disabled={readonly || props.disabled}
|
|
39
|
+
className={props.className}
|
|
40
|
+
pattern="^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -16,10 +16,10 @@ function formatCurrency(value: number, currency: string = 'USD'): string {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function CurrencyField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<number>) {
|
|
20
|
-
const currencyField = field as any;
|
|
21
|
-
const currency = currencyField
|
|
22
|
-
const precision = currencyField
|
|
19
|
+
export function CurrencyField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
|
|
20
|
+
const currencyField = (field || (props as any).schema) as any;
|
|
21
|
+
const currency = currencyField?.currency || 'USD';
|
|
22
|
+
const precision = currencyField?.precision ?? 2;
|
|
23
23
|
|
|
24
24
|
if (readonly) {
|
|
25
25
|
if (value == null) return <span className="text-sm">-</span>;
|
|
@@ -44,6 +44,7 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
|
|
|
44
44
|
{currency === 'USD' ? '$' : currency}
|
|
45
45
|
</span>
|
|
46
46
|
<Input
|
|
47
|
+
{...props}
|
|
47
48
|
type="number"
|
|
48
49
|
value={value ?? ''}
|
|
49
50
|
onChange={(e) => {
|
|
@@ -51,9 +52,9 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
|
|
|
51
52
|
onChange(val as any);
|
|
52
53
|
}}
|
|
53
54
|
onBlur={handleBlur}
|
|
54
|
-
placeholder={
|
|
55
|
-
disabled={readonly}
|
|
56
|
-
className={`pl-8 ${
|
|
55
|
+
placeholder={currencyField?.placeholder || '0.00'}
|
|
56
|
+
disabled={readonly || props.disabled}
|
|
57
|
+
className={`pl-8 ${className || ''}`}
|
|
57
58
|
step={Math.pow(10, -precision).toFixed(precision)}
|
|
58
59
|
aria-invalid={!!errorMessage}
|
|
59
60
|
/>
|
|
@@ -7,13 +7,16 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
7
7
|
return <span className="text-sm">{value ? new Date(value).toLocaleDateString() : '-'}</span>;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
// Filter out non-DOM props
|
|
11
|
+
const { inputType, ...domProps } = props as any;
|
|
12
|
+
|
|
10
13
|
return (
|
|
11
14
|
<Input
|
|
15
|
+
{...domProps}
|
|
12
16
|
type="date"
|
|
13
17
|
value={value || ''}
|
|
14
18
|
onChange={(e) => onChange(e.target.value)}
|
|
15
|
-
disabled={readonly}
|
|
16
|
-
className={props.className}
|
|
19
|
+
disabled={readonly || domProps.disabled}
|
|
17
20
|
/>
|
|
18
21
|
);
|
|
19
22
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
6
|
+
if (readonly) {
|
|
7
|
+
if (!value) return <span className="text-sm">-</span>;
|
|
8
|
+
const date = new Date(value);
|
|
9
|
+
return (
|
|
10
|
+
<span className="text-sm">
|
|
11
|
+
{date.toLocaleDateString()} {date.toLocaleTimeString()}
|
|
12
|
+
</span>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Filter out non-DOM props
|
|
17
|
+
const { inputType, ...domProps } = props as any;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Input
|
|
21
|
+
{...domProps}
|
|
22
|
+
type="datetime-local"
|
|
23
|
+
value={value || ''}
|
|
24
|
+
onChange={(e) => onChange(e.target.value)}
|
|
25
|
+
disabled={readonly || domProps.disabled}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Input } from '@object-ui/components';
|
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
5
|
export function EmailField({ 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 EmailField({ 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="email"
|
|
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 || 'email@example.com'}
|
|
29
|
+
disabled={readonly || domProps.disabled}
|
|
26
30
|
aria-invalid={!!errorMessage}
|
|
27
31
|
/>
|
|
28
32
|
);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import React, { useRef } from 'react';
|
|
2
|
+
import { Button } from '@object-ui/components';
|
|
3
|
+
import { Upload, X, File as FileIcon } from 'lucide-react';
|
|
4
|
+
import { FieldWidgetProps } from './types';
|
|
5
|
+
|
|
6
|
+
export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
7
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
8
|
+
const fileField = (field || (props as any).schema) as any;
|
|
9
|
+
const multiple = fileField?.multiple || false;
|
|
10
|
+
const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
|
|
11
|
+
|
|
12
|
+
if (readonly) {
|
|
13
|
+
if (!value) return <span className="text-sm">-</span>;
|
|
14
|
+
|
|
15
|
+
const files = Array.isArray(value) ? value : [value];
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-wrap gap-2">
|
|
18
|
+
{files.map((file: any, idx: number) => (
|
|
19
|
+
<span key={idx} className="text-sm truncate max-w-xs">
|
|
20
|
+
{file.name || file.original_name || 'File'}
|
|
21
|
+
</span>
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const files = value ? (Array.isArray(value) ? value : [value]) : [];
|
|
28
|
+
|
|
29
|
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
30
|
+
const selectedFiles = Array.from(e.target.files || []);
|
|
31
|
+
if (selectedFiles.length === 0) return;
|
|
32
|
+
|
|
33
|
+
const fileObjects = selectedFiles.map(file => ({
|
|
34
|
+
name: file.name,
|
|
35
|
+
original_name: file.name,
|
|
36
|
+
size: file.size,
|
|
37
|
+
mime_type: file.type,
|
|
38
|
+
// In a real implementation, this would upload the file and return a URL
|
|
39
|
+
url: URL.createObjectURL(file),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
if (multiple) {
|
|
43
|
+
onChange([...files, ...fileObjects]);
|
|
44
|
+
} else {
|
|
45
|
+
onChange(fileObjects[0]);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const handleRemove = (index: number) => {
|
|
50
|
+
if (multiple) {
|
|
51
|
+
const newFiles = files.filter((_: any, i: number) => i !== index);
|
|
52
|
+
onChange(newFiles.length > 0 ? newFiles : null);
|
|
53
|
+
} else {
|
|
54
|
+
onChange(null);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className={props.className}>
|
|
60
|
+
<input
|
|
61
|
+
ref={inputRef}
|
|
62
|
+
type="file"
|
|
63
|
+
multiple={multiple}
|
|
64
|
+
accept={accept}
|
|
65
|
+
onChange={handleFileChange}
|
|
66
|
+
className="hidden"
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
<div className="space-y-2">
|
|
70
|
+
{files.length > 0 && (
|
|
71
|
+
<div className="space-y-1">
|
|
72
|
+
{files.map((file: any, idx: number) => (
|
|
73
|
+
<div
|
|
74
|
+
key={idx}
|
|
75
|
+
className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-md border border-gray-200"
|
|
76
|
+
>
|
|
77
|
+
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
78
|
+
<FileIcon className="size-4 text-gray-500 flex-shrink-0" />
|
|
79
|
+
<span className="text-sm truncate">
|
|
80
|
+
{file.name || file.original_name || 'File'}
|
|
81
|
+
</span>
|
|
82
|
+
{file.size && (
|
|
83
|
+
<span className="text-xs text-gray-500">
|
|
84
|
+
({(file.size / 1024).toFixed(1)} KB)
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
<Button
|
|
89
|
+
type="button"
|
|
90
|
+
variant="ghost"
|
|
91
|
+
size="sm"
|
|
92
|
+
onClick={() => handleRemove(idx)}
|
|
93
|
+
className="h-6 w-6 p-0"
|
|
94
|
+
>
|
|
95
|
+
<X className="size-3" />
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
<Button
|
|
103
|
+
type="button"
|
|
104
|
+
variant="outline"
|
|
105
|
+
onClick={() => inputRef.current?.click()}
|
|
106
|
+
className="w-full"
|
|
107
|
+
>
|
|
108
|
+
<Upload className="size-4 mr-2" />
|
|
109
|
+
{files.length > 0 ? 'Add More Files' : 'Upload File'}
|
|
110
|
+
</Button>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { FieldWidgetProps } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* FormulaField - Read-only computed field
|
|
6
|
+
* Values are computed on the backend and cannot be edited
|
|
7
|
+
*/
|
|
8
|
+
export function FormulaField({ value, field, ...props }: FieldWidgetProps<any>) {
|
|
9
|
+
const formulaField = (field || (props as any).schema) as any;
|
|
10
|
+
const returnType = formulaField?.return_type || 'text';
|
|
11
|
+
|
|
12
|
+
// Format based on return type
|
|
13
|
+
let displayValue = '-';
|
|
14
|
+
if (value != null) {
|
|
15
|
+
if (returnType === 'number' || returnType === 'currency') {
|
|
16
|
+
displayValue = typeof value === 'number' ? value.toFixed(2) : String(value);
|
|
17
|
+
} else if (returnType === 'boolean') {
|
|
18
|
+
displayValue = value ? 'Yes' : 'No';
|
|
19
|
+
} else if (returnType === 'date') {
|
|
20
|
+
displayValue = new Date(value).toLocaleDateString();
|
|
21
|
+
} else {
|
|
22
|
+
displayValue = String(value);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<span className={`text-sm font-mono text-gray-700 ${props.className || ''}`}>
|
|
28
|
+
{displayValue}
|
|
29
|
+
</span>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input, Button, Label } from '@object-ui/components';
|
|
3
|
+
import { MapPin, Crosshair } from 'lucide-react';
|
|
4
|
+
import { FieldWidgetProps } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Geolocation data structure
|
|
8
|
+
*/
|
|
9
|
+
export interface GeolocationValue {
|
|
10
|
+
latitude?: number;
|
|
11
|
+
longitude?: number;
|
|
12
|
+
accuracy?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Geolocation field widget - provides a location picker with coordinates
|
|
17
|
+
* Supports manual entry and browser geolocation API
|
|
18
|
+
*/
|
|
19
|
+
export function GeolocationField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<GeolocationValue>) {
|
|
20
|
+
const [isLoading, setIsLoading] = React.useState(false);
|
|
21
|
+
const location = value || {};
|
|
22
|
+
|
|
23
|
+
const handleFieldChange = (fieldName: keyof GeolocationValue, fieldValue: string) => {
|
|
24
|
+
onChange({
|
|
25
|
+
...location,
|
|
26
|
+
[fieldName]: fieldValue ? Number(fieldValue) : undefined,
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const getCurrentLocation = () => {
|
|
31
|
+
if (!navigator.geolocation) {
|
|
32
|
+
console.error('Geolocation is not supported by this browser');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setIsLoading(true);
|
|
37
|
+
navigator.geolocation.getCurrentPosition(
|
|
38
|
+
(position) => {
|
|
39
|
+
onChange({
|
|
40
|
+
latitude: position.coords.latitude,
|
|
41
|
+
longitude: position.coords.longitude,
|
|
42
|
+
accuracy: position.coords.accuracy,
|
|
43
|
+
});
|
|
44
|
+
setIsLoading(false);
|
|
45
|
+
},
|
|
46
|
+
(error) => {
|
|
47
|
+
console.error('Error getting location:', error.message);
|
|
48
|
+
setIsLoading(false);
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
enableHighAccuracy: true,
|
|
52
|
+
timeout: 5000,
|
|
53
|
+
maximumAge: 0,
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const formatLocation = (loc: GeolocationValue): string => {
|
|
59
|
+
if (!loc.latitude || !loc.longitude) return '-';
|
|
60
|
+
return `${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const openInMaps = () => {
|
|
64
|
+
if (!location.latitude || !location.longitude) return;
|
|
65
|
+
const url = `https://www.google.com/maps?q=${location.latitude},${location.longitude}`;
|
|
66
|
+
window.open(url, '_blank');
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (readonly) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="flex items-center gap-2">
|
|
72
|
+
<MapPin className="w-4 h-4 text-muted-foreground" />
|
|
73
|
+
<span className="text-sm">{formatLocation(location)}</span>
|
|
74
|
+
{location.latitude && location.longitude && (
|
|
75
|
+
<Button
|
|
76
|
+
type="button"
|
|
77
|
+
variant="link"
|
|
78
|
+
size="sm"
|
|
79
|
+
onClick={openInMaps}
|
|
80
|
+
className="p-0 h-auto"
|
|
81
|
+
>
|
|
82
|
+
View on map
|
|
83
|
+
</Button>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="space-y-3">
|
|
91
|
+
<div className="flex items-center gap-2">
|
|
92
|
+
<Button
|
|
93
|
+
type="button"
|
|
94
|
+
variant="outline"
|
|
95
|
+
size="sm"
|
|
96
|
+
onClick={getCurrentLocation}
|
|
97
|
+
disabled={readonly || isLoading}
|
|
98
|
+
>
|
|
99
|
+
<Crosshair className="w-4 h-4 mr-2" />
|
|
100
|
+
{isLoading ? 'Getting location...' : 'Use Current Location'}
|
|
101
|
+
</Button>
|
|
102
|
+
{location.latitude && location.longitude && (
|
|
103
|
+
<Button
|
|
104
|
+
type="button"
|
|
105
|
+
variant="link"
|
|
106
|
+
size="sm"
|
|
107
|
+
onClick={openInMaps}
|
|
108
|
+
>
|
|
109
|
+
<MapPin className="w-4 h-4 mr-2" />
|
|
110
|
+
View on map
|
|
111
|
+
</Button>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="grid grid-cols-2 gap-3">
|
|
116
|
+
<div>
|
|
117
|
+
<Label htmlFor="latitude" className="text-xs">Latitude</Label>
|
|
118
|
+
<Input
|
|
119
|
+
id="latitude"
|
|
120
|
+
type="number"
|
|
121
|
+
value={location.latitude ?? ''}
|
|
122
|
+
onChange={(e) => handleFieldChange('latitude', e.target.value)}
|
|
123
|
+
placeholder="37.7749"
|
|
124
|
+
disabled={readonly || props.disabled}
|
|
125
|
+
step="any"
|
|
126
|
+
className={props.className}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div>
|
|
131
|
+
<Label htmlFor="longitude" className="text-xs">Longitude</Label>
|
|
132
|
+
<Input
|
|
133
|
+
id="longitude"
|
|
134
|
+
type="number"
|
|
135
|
+
value={location.longitude ?? ''}
|
|
136
|
+
onChange={(e) => handleFieldChange('longitude', e.target.value)}
|
|
137
|
+
placeholder="-122.4194"
|
|
138
|
+
disabled={readonly || props.disabled}
|
|
139
|
+
step="any"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{location.accuracy && (
|
|
145
|
+
<p className="text-xs text-muted-foreground">
|
|
146
|
+
Accuracy: ±{location.accuracy.toFixed(0)}m
|
|
147
|
+
</p>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|