Skip to content

Commit 5a27126

Browse files
Copilothotlong
andcommitted
Implement essential CRM field widgets: Email, Phone, URL, Currency, TextArea, RichText, and Lookup
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent e826197 commit 5a27126

8 files changed

Lines changed: 450 additions & 3 deletions

File tree

packages/fields/src/index.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -720,18 +720,44 @@ import { NumberField } from './widgets/NumberField';
720720
import { BooleanField } from './widgets/BooleanField';
721721
import { SelectField } from './widgets/SelectField';
722722
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';
723730

724731
export function registerFields() {
732+
// Basic fields
725733
ComponentRegistry.register('text', TextField);
726-
ComponentRegistry.register('textarea', TextField); // TextField handles rows logic inside
734+
ComponentRegistry.register('textarea', TextAreaField);
727735
ComponentRegistry.register('number', NumberField);
728736
ComponentRegistry.register('boolean', BooleanField);
729737
ComponentRegistry.register('select', SelectField);
730738
ComponentRegistry.register('date', DateField);
731739

732-
// Register aliases or specific widgets
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
733753
ComponentRegistry.register('field:text', TextField);
734-
ComponentRegistry.register('field:number', NumberField);
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);
735761
}
736762

737763
export * from './widgets/types';
@@ -740,3 +766,10 @@ export * from './widgets/NumberField';
740766
export * from './widgets/BooleanField';
741767
export * from './widgets/SelectField';
742768
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';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React from 'react';
2+
import { Input } from '@object-ui/components';
3+
import { FieldWidgetProps } from './types';
4+
5+
/**
6+
* Format currency value for display
7+
*/
8+
function formatCurrency(value: number, currency: string = 'USD'): string {
9+
try {
10+
return new Intl.NumberFormat('en-US', {
11+
style: 'currency',
12+
currency,
13+
}).format(value);
14+
} catch {
15+
return `${currency} ${value.toFixed(2)}`;
16+
}
17+
}
18+
19+
export function CurrencyField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<number>) {
20+
const currencyField = field as any;
21+
const currency = currencyField.currency || 'USD';
22+
const precision = currencyField.precision ?? 2;
23+
24+
if (readonly) {
25+
if (value == null) return <span className="text-sm">-</span>;
26+
return (
27+
<span className="text-sm font-medium tabular-nums">
28+
{formatCurrency(Number(value), currency)}
29+
</span>
30+
);
31+
}
32+
33+
// Parse and format on blur to ensure valid currency format
34+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
35+
const val = parseFloat(e.target.value);
36+
if (!isNaN(val)) {
37+
onChange(parseFloat(val.toFixed(precision)));
38+
}
39+
};
40+
41+
return (
42+
<div className="relative">
43+
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
44+
{currency === 'USD' ? '$' : currency}
45+
</span>
46+
<Input
47+
type="number"
48+
value={value ?? ''}
49+
onChange={(e) => {
50+
const val = e.target.value === '' ? null : parseFloat(e.target.value);
51+
onChange(val as any);
52+
}}
53+
onBlur={handleBlur}
54+
placeholder={field.placeholder || '0.00'}
55+
disabled={readonly}
56+
className={`pl-8 ${props.className || ''}`}
57+
step={Math.pow(10, -precision).toFixed(precision)}
58+
aria-invalid={!!errorMessage}
59+
/>
60+
</div>
61+
);
62+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from 'react';
2+
import { Input } from '@object-ui/components';
3+
import { FieldWidgetProps } from './types';
4+
5+
export function EmailField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
6+
if (readonly) {
7+
if (!value) return <span className="text-sm">-</span>;
8+
return (
9+
<a
10+
href={`mailto:${value}`}
11+
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
12+
>
13+
{value}
14+
</a>
15+
);
16+
}
17+
18+
return (
19+
<Input
20+
type="email"
21+
value={value || ''}
22+
onChange={(e) => onChange(e.target.value)}
23+
placeholder={field.placeholder || 'email@example.com'}
24+
disabled={readonly}
25+
className={props.className}
26+
aria-invalid={!!errorMessage}
27+
/>
28+
);
29+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import React, { useState } from 'react';
2+
import {
3+
Button,
4+
Dialog,
5+
DialogContent,
6+
DialogHeader,
7+
DialogTitle,
8+
DialogTrigger,
9+
Input,
10+
Badge
11+
} from '@object-ui/components';
12+
import { Search, X } from 'lucide-react';
13+
import { FieldWidgetProps } from './types';
14+
15+
interface LookupOption {
16+
value: string | number;
17+
label: string;
18+
[key: string]: any;
19+
}
20+
21+
/**
22+
* Lookup field for selecting related records
23+
* Supports single and multi-select with search
24+
*/
25+
export function LookupField({ value, onChange, field, readonly }: FieldWidgetProps<any>) {
26+
const [isOpen, setIsOpen] = useState(false);
27+
const [searchQuery, setSearchQuery] = useState('');
28+
29+
const lookupField = field as any;
30+
const options: LookupOption[] = lookupField.options || [];
31+
const multiple = lookupField.multiple || false;
32+
const displayField = lookupField.display_field || 'label';
33+
34+
// Filter options based on search
35+
const filteredOptions = options.filter(opt =>
36+
opt.label.toLowerCase().includes(searchQuery.toLowerCase())
37+
);
38+
39+
// Get selected option(s)
40+
const selectedOptions = multiple
41+
? (Array.isArray(value) ? value : []).map(v =>
42+
options.find(opt => opt.value === v)
43+
).filter(Boolean)
44+
: value ? [options.find(opt => opt.value === value)].filter(Boolean) : [];
45+
46+
const handleSelect = (option: LookupOption) => {
47+
if (multiple) {
48+
const currentValues = Array.isArray(value) ? value : [];
49+
const isSelected = currentValues.includes(option.value);
50+
51+
if (isSelected) {
52+
onChange(currentValues.filter(v => v !== option.value));
53+
} else {
54+
onChange([...currentValues, option.value]);
55+
}
56+
} else {
57+
onChange(option.value);
58+
setIsOpen(false);
59+
}
60+
};
61+
62+
const handleRemove = (optionValue: any) => {
63+
if (multiple) {
64+
const currentValues = Array.isArray(value) ? value : [];
65+
onChange(currentValues.filter(v => v !== optionValue));
66+
} else {
67+
onChange(null);
68+
}
69+
};
70+
71+
if (readonly) {
72+
if (!selectedOptions.length) {
73+
return <span className="text-sm">-</span>;
74+
}
75+
76+
if (multiple) {
77+
return (
78+
<div className="flex flex-wrap gap-1">
79+
{selectedOptions.map((opt, idx) => (
80+
<Badge key={idx} variant="outline">
81+
{opt?.[displayField] || opt?.label}
82+
</Badge>
83+
))}
84+
</div>
85+
);
86+
}
87+
88+
return (
89+
<span className="text-sm">
90+
{selectedOptions[0]?.[displayField] || selectedOptions[0]?.label}
91+
</span>
92+
);
93+
}
94+
95+
return (
96+
<div className="space-y-2">
97+
{/* Selected values display */}
98+
{selectedOptions.length > 0 && (
99+
<div className="flex flex-wrap gap-1">
100+
{selectedOptions.map((opt, idx) => (
101+
<Badge
102+
key={idx}
103+
variant="outline"
104+
className="gap-1"
105+
>
106+
{opt?.[displayField] || opt?.label}
107+
<button
108+
onClick={() => handleRemove(opt?.value)}
109+
className="ml-1 hover:text-destructive"
110+
type="button"
111+
>
112+
<X className="size-3" />
113+
</button>
114+
</Badge>
115+
))}
116+
</div>
117+
)}
118+
119+
{/* Lookup dialog trigger */}
120+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
121+
<DialogTrigger asChild>
122+
<Button
123+
variant="outline"
124+
className="w-full justify-start text-left font-normal"
125+
type="button"
126+
>
127+
<Search className="mr-2 size-4" />
128+
{selectedOptions.length === 0
129+
? field.placeholder || 'Select...'
130+
: multiple ? `${selectedOptions.length} selected` : 'Change selection'
131+
}
132+
</Button>
133+
</DialogTrigger>
134+
<DialogContent className="max-w-md">
135+
<DialogHeader>
136+
<DialogTitle>
137+
{field.label || 'Select'} {multiple && '(multiple)'}
138+
</DialogTitle>
139+
</DialogHeader>
140+
141+
{/* Search input */}
142+
<div className="space-y-4">
143+
<Input
144+
placeholder="Search..."
145+
value={searchQuery}
146+
onChange={(e) => setSearchQuery(e.target.value)}
147+
className="w-full"
148+
/>
149+
150+
{/* Options list */}
151+
<div className="max-h-64 overflow-y-auto space-y-1">
152+
{filteredOptions.length === 0 ? (
153+
<p className="text-sm text-gray-500 text-center py-4">
154+
No options found
155+
</p>
156+
) : (
157+
filteredOptions.map((option) => {
158+
const isSelected = multiple
159+
? (Array.isArray(value) ? value : []).includes(option.value)
160+
: value === option.value;
161+
162+
return (
163+
<button
164+
key={option.value}
165+
onClick={() => handleSelect(option)}
166+
className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-gray-100 flex items-center justify-between ${
167+
isSelected ? 'bg-blue-50 text-blue-700' : ''
168+
}`}
169+
type="button"
170+
>
171+
<span>{option.label}</span>
172+
{isSelected && (
173+
<Badge variant="default" className="ml-2">Selected</Badge>
174+
)}
175+
</button>
176+
);
177+
})
178+
)}
179+
</div>
180+
</div>
181+
</DialogContent>
182+
</Dialog>
183+
</div>
184+
);
185+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from 'react';
2+
import { Input } from '@object-ui/components';
3+
import { FieldWidgetProps } from './types';
4+
5+
export function PhoneField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
6+
if (readonly) {
7+
if (!value) return <span className="text-sm">-</span>;
8+
return (
9+
<a
10+
href={`tel:${value}`}
11+
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
12+
>
13+
{value}
14+
</a>
15+
);
16+
}
17+
18+
return (
19+
<Input
20+
type="tel"
21+
value={value || ''}
22+
onChange={(e) => onChange(e.target.value)}
23+
placeholder={field.placeholder || '(555) 123-4567'}
24+
disabled={readonly}
25+
className={props.className}
26+
aria-invalid={!!errorMessage}
27+
/>
28+
);
29+
}

0 commit comments

Comments
 (0)