Skip to content

Commit 4cd0b85

Browse files
committed
Add generic Field components and refactor ObjectForm
Introduces a set of reusable field components (TextField, NumberField, BooleanField, SelectField, DateField, TextAreaField) and a generic Field wrapper in the UI package. Refactors ObjectForm to use these new field components via react-hook-form's Controller, improving form flexibility and consistency. Also updates Checkbox to use Radix UI primitives.
1 parent 5a4f817 commit 4cd0b85

File tree

12 files changed

+435
-21
lines changed

12 files changed

+435
-21
lines changed

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

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useEffect } from 'react';
2-
import { useForm } from 'react-hook-form';
3-
import { Button, Input, Label, Spinner } from '@objectql/ui';
2+
import { useForm, Controller } from 'react-hook-form';
3+
import { Button, Spinner, Field } from '@objectql/ui';
44

55
interface ObjectFormProps {
66
objectName: string;
@@ -12,7 +12,7 @@ interface ObjectFormProps {
1212

1313
export function ObjectForm({ objectName, initialValues, onSubmit, onCancel, headers }: ObjectFormProps) {
1414
const [schema, setSchema] = useState<any>(null);
15-
const { register, handleSubmit, reset } = useForm({
15+
const { control, handleSubmit, reset } = useForm({
1616
defaultValues: initialValues || {}
1717
});
1818

@@ -38,14 +38,25 @@ export function ObjectForm({ objectName, initialValues, onSubmit, onCancel, head
3838
if (['id', '_id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'].includes(key)) return null;
3939

4040
return (
41-
<div key={key} className="space-y-2">
42-
<Label htmlFor={key}>{field.label || field.title || key}</Label>
43-
<Input
44-
id={key}
45-
{...register(key, { required: !field.optional })}
46-
type={field.type === 'number' || field.type === 'integer' || field.type === 'float' ? 'number' : field.type === 'password' ? 'password' : 'text'}
47-
/>
48-
</div>
41+
<Controller
42+
key={key}
43+
name={key}
44+
control={control}
45+
rules={{ required: field.required }}
46+
render={({ field: { value, onChange }, fieldState: { error } }) => (
47+
<Field
48+
name={key}
49+
label={field.label || field.title || key}
50+
type={field.type}
51+
value={value}
52+
onChange={onChange}
53+
error={error?.message}
54+
required={field.required}
55+
description={field.description}
56+
options={field.options}
57+
/>
58+
)}
59+
/>
4960
);
5061
})}
5162
<div className="flex justify-end gap-2 pt-4">
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React from "react"
2+
import { Checkbox } from "../ui/checkbox"
3+
import { Label } from "../ui/label"
4+
import { cn } from "@/lib/utils"
5+
import { FieldProps } from "./types"
6+
7+
export function BooleanField({
8+
value,
9+
onChange,
10+
disabled,
11+
readOnly,
12+
className,
13+
error,
14+
label,
15+
required,
16+
description,
17+
name,
18+
}: FieldProps<boolean>) {
19+
return (
20+
<div className={cn("grid gap-2", className)}>
21+
<div className="flex items-center space-x-2">
22+
<Checkbox
23+
id={name}
24+
checked={value}
25+
onCheckedChange={(checked) => {
26+
if (readOnly) return;
27+
onChange?.(checked as boolean)
28+
}}
29+
disabled={disabled || readOnly}
30+
className={cn(error && "border-destructive")}
31+
/>
32+
{label && (
33+
<Label htmlFor={name} className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", error && "text-destructive")}>
34+
{label}
35+
{required && <span className="text-destructive ml-1">*</span>}
36+
</Label>
37+
)}
38+
</div>
39+
{description && !error && (
40+
<p className="text-sm text-muted-foreground">{description}</p>
41+
)}
42+
{error && <p className="text-sm text-destructive">{error}</p>}
43+
</div>
44+
)
45+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as React from "react"
2+
import { format } from "date-fns"
3+
import { Calendar as CalendarIcon } from "lucide-react"
4+
5+
import { cn } from "@/lib/utils"
6+
import { Button } from "../ui/button"
7+
import { Calendar } from "../ui/calendar"
8+
import {
9+
Popover,
10+
PopoverContent,
11+
PopoverTrigger,
12+
} from "../ui/popover"
13+
import { Label } from "../ui/label"
14+
import { FieldProps } from "./types"
15+
16+
export function DateField({
17+
value,
18+
onChange,
19+
disabled,
20+
readOnly,
21+
className,
22+
placeholder,
23+
error,
24+
label,
25+
required,
26+
description,
27+
name,
28+
}: FieldProps<Date>) {
29+
return (
30+
<div className={cn("grid gap-2", className)}>
31+
{label && (
32+
<Label htmlFor={name} className={cn(error && "text-destructive")}>
33+
{label}
34+
{required && <span className="text-destructive ml-1">*</span>}
35+
</Label>
36+
)}
37+
<Popover>
38+
<PopoverTrigger asChild>
39+
<Button
40+
id={name}
41+
variant={"outline"}
42+
disabled={disabled || readOnly}
43+
className={cn(
44+
"w-full justify-start text-left font-normal",
45+
!value && "text-muted-foreground",
46+
error && "border-destructive focus-visible:ring-destructive"
47+
)}
48+
>
49+
<CalendarIcon className="mr-2 h-4 w-4" />
50+
{value ? format(value, "PPP") : <span>{placeholder || "Pick a date"}</span>}
51+
</Button>
52+
</PopoverTrigger>
53+
<PopoverContent className="w-auto p-0">
54+
<Calendar
55+
mode="single"
56+
selected={value}
57+
onSelect={onChange}
58+
initialFocus
59+
/>
60+
</PopoverContent>
61+
</Popover>
62+
{description && !error && (
63+
<p className="text-sm text-muted-foreground">{description}</p>
64+
)}
65+
{error && <p className="text-sm text-destructive">{error}</p>}
66+
</div>
67+
)
68+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React from 'react'
2+
import { FieldProps } from './types'
3+
import { TextField } from './TextField'
4+
import { NumberField } from './NumberField'
5+
import { BooleanField } from './BooleanField'
6+
import { SelectField } from './SelectField'
7+
import { DateField } from './DateField'
8+
import { TextAreaField } from './TextAreaField'
9+
10+
export interface GenericFieldProps extends FieldProps {
11+
type: string
12+
}
13+
14+
export function Field({ type, ...props }: GenericFieldProps) {
15+
switch (type) {
16+
case 'text':
17+
case 'string':
18+
case 'email':
19+
case 'url':
20+
case 'password':
21+
case 'tel':
22+
return <TextField type={type === 'string' ? 'text' : type as any} {...props} />
23+
case 'number':
24+
case 'integer':
25+
case 'float':
26+
case 'currency':
27+
case 'percent':
28+
return <NumberField {...props} />
29+
case 'boolean':
30+
return <BooleanField {...props} />
31+
case 'date':
32+
case 'datetime':
33+
return <DateField {...props} />
34+
case 'select':
35+
return <SelectField {...props} />
36+
case 'textarea':
37+
case 'longtext':
38+
return <TextAreaField {...props} />
39+
default:
40+
return <TextField {...props} />
41+
}
42+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React from "react"
2+
import { Input } from "../ui/input"
3+
import { Label } from "../ui/label"
4+
import { cn } from "@/lib/utils"
5+
import { FieldProps } from "./types"
6+
7+
export interface NumberFieldProps extends FieldProps<number> {
8+
min?: number
9+
max?: number
10+
step?: number
11+
}
12+
13+
export function NumberField({
14+
value,
15+
onChange,
16+
disabled,
17+
readOnly,
18+
className,
19+
placeholder,
20+
error,
21+
label,
22+
required,
23+
description,
24+
name,
25+
min,
26+
max,
27+
step
28+
}: NumberFieldProps) {
29+
return (
30+
<div className={cn("grid gap-2", className)}>
31+
{label && (
32+
<Label htmlFor={name} className={cn(error && "text-destructive")}>
33+
{label}
34+
{required && <span className="text-destructive ml-1">*</span>}
35+
</Label>
36+
)}
37+
<Input
38+
id={name}
39+
type="number"
40+
value={value ?? ""}
41+
onChange={(e) => {
42+
const val = e.target.value === "" ? undefined : Number(e.target.value)
43+
// Handle NaN if necessary, but type="number" blocks most non-numbers
44+
onChange?.(val as number)
45+
}}
46+
disabled={disabled}
47+
readOnly={readOnly}
48+
placeholder={placeholder}
49+
min={min}
50+
max={max}
51+
step={step}
52+
className={cn(error && "border-destructive focus-visible:ring-destructive")}
53+
/>
54+
{description && !error && (
55+
<p className="text-sm text-muted-foreground">{description}</p>
56+
)}
57+
{error && <p className="text-sm text-destructive">{error}</p>}
58+
</div>
59+
)
60+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React from "react"
2+
import {
3+
Select,
4+
SelectContent,
5+
SelectItem,
6+
SelectTrigger,
7+
SelectValue,
8+
} from "../ui/select"
9+
import { Label } from "../ui/label"
10+
import { cn } from "@/lib/utils"
11+
import { FieldProps } from "./types"
12+
13+
export function SelectField({
14+
value,
15+
onChange,
16+
disabled,
17+
readOnly,
18+
className,
19+
placeholder,
20+
error,
21+
label,
22+
required,
23+
description,
24+
name,
25+
options = [],
26+
}: FieldProps<string | number>) {
27+
const normalizedOptions = React.useMemo(() => {
28+
return options.map((option) => {
29+
if (typeof option === "object" && option !== null && "value" in option) {
30+
return option
31+
}
32+
return { label: String(option), value: option }
33+
})
34+
}, [options])
35+
36+
return (
37+
<div className={cn("grid gap-2", className)}>
38+
{label && (
39+
<Label htmlFor={name} className={cn(error && "text-destructive")}>
40+
{label}
41+
{required && <span className="text-destructive ml-1">*</span>}
42+
</Label>
43+
)}
44+
<Select
45+
value={value?.toString()}
46+
onValueChange={onChange}
47+
disabled={disabled || readOnly}
48+
>
49+
<SelectTrigger
50+
id={name}
51+
className={cn(error && "border-destructive focus:ring-destructive")}
52+
>
53+
<SelectValue placeholder={placeholder} />
54+
</SelectTrigger>
55+
<SelectContent>
56+
{normalizedOptions.map((option) => (
57+
<SelectItem key={option.value} value={option.value?.toString() ?? ""}>
58+
{option.label}
59+
</SelectItem>
60+
))}
61+
</SelectContent>
62+
</Select>
63+
{description && !error && (
64+
<p className="text-sm text-muted-foreground">{description}</p>
65+
)}
66+
{error && <p className="text-sm text-destructive">{error}</p>}
67+
</div>
68+
)
69+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from "react"
2+
import { Textarea } from "../ui/textarea"
3+
import { Label } from "../ui/label"
4+
import { cn } from "@/lib/utils"
5+
import { FieldProps } from "./types"
6+
7+
export function TextAreaField({
8+
value,
9+
onChange,
10+
disabled,
11+
readOnly,
12+
className,
13+
placeholder,
14+
error,
15+
label,
16+
required,
17+
description,
18+
name,
19+
}: FieldProps<string>) {
20+
return (
21+
<div className={cn("grid gap-2", className)}>
22+
{label && (
23+
<Label htmlFor={name} className={cn(error && "text-destructive")}>
24+
{label}
25+
{required && <span className="text-destructive ml-1">*</span>}
26+
</Label>
27+
)}
28+
<Textarea
29+
id={name}
30+
value={value || ""}
31+
onChange={(e) => onChange?.(e.target.value)}
32+
disabled={disabled}
33+
readOnly={readOnly}
34+
placeholder={placeholder}
35+
className={cn(error && "border-destructive focus-visible:ring-destructive", "min-h-[80px]")}
36+
/>
37+
{description && !error && (
38+
<p className="text-sm text-muted-foreground">{description}</p>
39+
)}
40+
{error && <p className="text-sm text-destructive">{error}</p>}
41+
</div>
42+
)
43+
}

0 commit comments

Comments
 (0)