Skip to content

Commit 4bd2aab

Browse files
committed
Add LookupField component for relational fields
Introduces a new LookupField component to support lookup and master-detail field types, enabling selection of related records via a searchable dropdown. Updates ObjectForm and Field to use LookupField when appropriate, and extends field type definitions to include referenceTo. Also updates exports and types to support the new component.
1 parent 4cd0b85 commit 4bd2aab

File tree

5 files changed

+218
-4
lines changed

5 files changed

+218
-4
lines changed

packages/client/src/components/dashboard/ObjectForm.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ export function ObjectForm({ objectName, initialValues, onSubmit, onCancel, head
3333
const fields = Object.entries(schema.fields || {});
3434

3535
return (
36-
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
36+
<form onSubmit={handleSubmit(onSubmit)} className="grid grid-cols-1 md:grid-cols-2 gap-4">
3737
{fields.map(([key, field]: [string, any]) => {
3838
if (['id', '_id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'].includes(key)) return null;
3939

40+
const isWide = field.is_wide || ['textarea', 'json', 'markdown', 'code'].includes(field.type);
41+
4042
return (
4143
<Controller
4244
key={key}
@@ -45,9 +47,11 @@ export function ObjectForm({ objectName, initialValues, onSubmit, onCancel, head
4547
rules={{ required: field.required }}
4648
render={({ field: { value, onChange }, fieldState: { error } }) => (
4749
<Field
50+
className={isWide ? "col-span-1 md:col-span-2" : ""}
4851
name={key}
4952
label={field.label || field.title || key}
5053
type={field.type}
54+
referenceTo={field.reference_to}
5155
value={value}
5256
onChange={onChange}
5357
error={error?.message}
@@ -59,7 +63,7 @@ export function ObjectForm({ objectName, initialValues, onSubmit, onCancel, head
5963
/>
6064
);
6165
})}
62-
<div className="flex justify-end gap-2 pt-4">
66+
<div className="flex justify-end gap-2 pt-4 col-span-1 md:col-span-2">
6367
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
6468
<Button type="submit">Save</Button>
6569
</div>

packages/ui/src/components/fields/Field.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@ import { BooleanField } from './BooleanField'
66
import { SelectField } from './SelectField'
77
import { DateField } from './DateField'
88
import { TextAreaField } from './TextAreaField'
9+
import { LookupField } from './LookupField'
910

1011
export interface GenericFieldProps extends FieldProps {
1112
type: string
13+
referenceTo?: string
1214
}
1315

14-
export function Field({ type, ...props }: GenericFieldProps) {
16+
export function Field({ type, referenceTo, ...props }: GenericFieldProps) {
1517
switch (type) {
1618
case 'text':
19+
1720
case 'string':
1821
case 'email':
1922
case 'url':
@@ -36,6 +39,12 @@ export function Field({ type, ...props }: GenericFieldProps) {
3639
case 'textarea':
3740
case 'longtext':
3841
return <TextAreaField {...props} />
42+
case 'lookup':
43+
case 'master_detail':
44+
if (referenceTo) {
45+
return <LookupField referenceTo={referenceTo} {...props} />
46+
}
47+
return <TextField {...props} />
3948
default:
4049
return <TextField {...props} />
4150
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import React, { useState, useEffect } from "react"
2+
import { Check, ChevronsUpDown, Loader2, X } from "lucide-react"
3+
4+
import { cn } from "@/lib/utils"
5+
import { Button } from "../ui/button"
6+
import {
7+
Command,
8+
CommandEmpty,
9+
CommandGroup,
10+
CommandInput,
11+
CommandItem,
12+
} from "../ui/command"
13+
import {
14+
Popover,
15+
PopoverContent,
16+
PopoverTrigger,
17+
} from "../ui/popover"
18+
import { Label } from "../ui/label"
19+
import { LookupFieldProps } from "./types"
20+
21+
function useDebounce<T>(value: T, delay: number): T {
22+
const [debouncedValue, setDebouncedValue] = useState<T>(value)
23+
24+
useEffect(() => {
25+
const handler = setTimeout(() => {
26+
setDebouncedValue(value)
27+
}, delay)
28+
29+
return () => {
30+
clearTimeout(handler)
31+
}
32+
}, [value, delay])
33+
34+
return debouncedValue
35+
}
36+
37+
export function LookupField({
38+
value,
39+
onChange,
40+
disabled,
41+
readOnly,
42+
className,
43+
placeholder,
44+
error,
45+
label,
46+
required,
47+
description,
48+
name,
49+
referenceTo, // The object name we are looking up
50+
}: LookupFieldProps) {
51+
const [open, setOpen] = useState(false)
52+
const [items, setItems] = useState<any[]>([])
53+
const [loading, setLoading] = useState(false)
54+
const [contentLabel, setContentLabel] = useState<string>("")
55+
const [search, setSearch] = useState("")
56+
57+
const debouncedSearch = useDebounce(search, 300)
58+
59+
// Fetch initial label if value exists but we don't have the label
60+
useEffect(() => {
61+
if (value && !contentLabel) {
62+
// If value is an object (expanded), use it
63+
if (typeof value === 'object' && (value as any).name) {
64+
setContentLabel((value as any).name || (value as any).title || (value as any)._id);
65+
return;
66+
}
67+
68+
if (typeof value !== 'string') return;
69+
70+
// Fetch single record to get label
71+
fetch(`/api/object/${referenceTo}/${value}`)
72+
.then(res => res.json())
73+
.then(data => {
74+
if (data) {
75+
setContentLabel(data.name || data.title || data.email || data._id || value);
76+
}
77+
})
78+
.catch(() => setContentLabel(value)); // Fallback to ID
79+
}
80+
}, [value, referenceTo]);
81+
82+
// Search items
83+
useEffect(() => {
84+
if (!open) return;
85+
86+
setLoading(true);
87+
const params = new URLSearchParams();
88+
89+
if (debouncedSearch) {
90+
// Try simple search first
91+
// In a real ObjectQL implementation this should use filters
92+
const filter = JSON.stringify([['name', 'contains', debouncedSearch], 'or', ['title', 'contains', debouncedSearch]]);
93+
params.append('filters', filter);
94+
}
95+
96+
// Always limit results
97+
params.append('top', '20');
98+
99+
fetch(`/api/object/${referenceTo}?${params.toString()}`)
100+
.then(res => res.json())
101+
.then(data => {
102+
const list = Array.isArray(data) ? data : (data.list || []);
103+
setItems(list);
104+
})
105+
.catch(console.error)
106+
.finally(() => setLoading(false));
107+
108+
}, [open, debouncedSearch, referenceTo]);
109+
110+
const handleSelect = (currentValue: string, item: any) => {
111+
onChange?.(currentValue === value ? undefined : currentValue)
112+
setContentLabel(item.name || item.title || item.email || item._id);
113+
setOpen(false)
114+
}
115+
116+
const handleClear = (e: React.MouseEvent) => {
117+
e.stopPropagation();
118+
onChange?.(undefined);
119+
setContentLabel("");
120+
}
121+
122+
return (
123+
<div className={cn("grid gap-2", className)}>
124+
{label && (
125+
<Label htmlFor={name} className={cn(error && "text-destructive")}>
126+
{label}
127+
{required && <span className="text-destructive ml-1">*</span>}
128+
</Label>
129+
)}
130+
<Popover open={open} onOpenChange={setOpen}>
131+
<PopoverTrigger asChild>
132+
<Button
133+
id={name}
134+
variant="outline"
135+
role="combobox"
136+
aria-expanded={open}
137+
className={cn(
138+
"w-full justify-between",
139+
!value && "text-muted-foreground",
140+
error && "border-destructive focus-visible:ring-destructive"
141+
)}
142+
disabled={disabled || readOnly}
143+
>
144+
{value ? contentLabel || value : (placeholder || "Select record...")}
145+
{value && !disabled && !readOnly ? (
146+
<X className="ml-2 h-4 w-4 opacity-50 hover:opacity-100" onClick={handleClear} />
147+
) : (
148+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
149+
)}
150+
</Button>
151+
</PopoverTrigger>
152+
<PopoverContent className="w-full p-0" align="start">
153+
<Command shouldFilter={false}>
154+
<CommandInput
155+
placeholder={`Search ${referenceTo}...`}
156+
value={search}
157+
onValueChange={setSearch}
158+
/>
159+
{loading && <div className="py-6 text-center text-sm text-muted-foreground flex justify-center"><Loader2 className="h-4 w-4 animate-spin mr-2" /> Loading...</div>}
160+
161+
{!loading && (
162+
<CommandGroup className="max-h-[200px] overflow-auto">
163+
{items.length === 0 ? (
164+
<CommandEmpty>No results found.</CommandEmpty>
165+
) : (
166+
items.map((item) => {
167+
const itemId = item._id || item.id;
168+
const itemLabel = item.name || item.title || item.email || itemId;
169+
return (
170+
<CommandItem
171+
key={itemId}
172+
value={itemId}
173+
onSelect={() => handleSelect(itemId, item)}
174+
>
175+
<Check
176+
className={cn(
177+
"mr-2 h-4 w-4",
178+
value === itemId ? "opacity-100" : "opacity-0"
179+
)}
180+
/>
181+
{itemLabel}
182+
</CommandItem>
183+
)
184+
})
185+
)}
186+
</CommandGroup>
187+
)}
188+
</Command>
189+
</PopoverContent>
190+
</Popover>
191+
{description && !error && (
192+
<p className="text-sm text-muted-foreground">{description}</p>
193+
)}
194+
{error && <p className="text-sm text-destructive">{error}</p>}
195+
</div>
196+
)
197+
}

packages/ui/src/components/fields/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from "./BooleanField"
66
export * from "./SelectField"
77
export * from "./DateField"
88
export * from "./Field"
9+
export * from "./LookupField"

packages/ui/src/components/fields/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
export interface FieldProps<T = any> {
32
value?: T
43
onChange?: (value: T | undefined) => void
@@ -17,3 +16,7 @@ export interface FieldProps<T = any> {
1716
// Specific options
1817
options?: ({ label: string; value: any } | string)[]
1918
}
19+
20+
export interface LookupFieldProps extends FieldProps<string> {
21+
referenceTo: string
22+
}

0 commit comments

Comments
 (0)