init
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import {
|
||||
useFormContext,
|
||||
useController,
|
||||
} from "react-hook-form"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { FieldShell } from "../field-shell"
|
||||
import {
|
||||
AsyncSelectField,
|
||||
AsyncMultiSelectField,
|
||||
type AsyncSelectFieldProps,
|
||||
type AsyncMultiSelectFieldProps,
|
||||
} from "../controls/async-select-field"
|
||||
import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/components/ui/field"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
|
||||
// ── Inline create types ──
|
||||
|
||||
export type InlineCreateFormProps = {
|
||||
onSuccess: (newItem?: { value: string; label: string }) => void
|
||||
}
|
||||
|
||||
export type InlineCreateConfig = {
|
||||
createForm: (props: InlineCreateFormProps) => React.ReactNode
|
||||
createLabel?: string
|
||||
}
|
||||
|
||||
function extractItems(response: unknown): any[] {
|
||||
if (Array.isArray(response)) return response
|
||||
if (response && typeof response === "object") {
|
||||
const obj = response as Record<string, unknown>
|
||||
if (obj.data && typeof obj.data === "object" && !Array.isArray(obj.data)) {
|
||||
const nested = obj.data as Record<string, unknown>
|
||||
if (Array.isArray(nested.data)) return nested.data
|
||||
}
|
||||
if (Array.isArray(obj.data)) return obj.data
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// ── Props forwarded to the underlying control ──
|
||||
|
||||
type AsyncSelectControlProps = Omit<
|
||||
AsyncSelectFieldProps,
|
||||
keyof import("../types").BaseFieldControlProps<any> | "options" | "loading" | "onInputValueChange"
|
||||
>
|
||||
|
||||
type AsyncMultiSelectControlProps = Omit<
|
||||
AsyncMultiSelectFieldProps,
|
||||
keyof import("../types").BaseFieldControlProps<any> | "options" | "loading" | "onInputValueChange"
|
||||
>
|
||||
|
||||
// ── Shared base props ──
|
||||
|
||||
type BaseRhfAsyncFieldProps = {
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
queryKey: string[]
|
||||
staleTime?: number
|
||||
} & Partial<InlineCreateConfig>
|
||||
|
||||
type WithLoadOptions = {
|
||||
loadOptions: () => Promise<any[]>
|
||||
listFn?: never
|
||||
mapOption?: never
|
||||
}
|
||||
|
||||
type WithCrudClient<TItem> = {
|
||||
loadOptions?: never
|
||||
listFn: () => Promise<any>
|
||||
mapOption?: (item: TItem) => any
|
||||
}
|
||||
|
||||
type DataSource<TItem = unknown> = WithLoadOptions | WithCrudClient<TItem>
|
||||
|
||||
function useAsyncOptions<TItem>(
|
||||
queryKey: string[],
|
||||
source: DataSource<TItem>,
|
||||
staleTime?: number,
|
||||
) {
|
||||
return useQuery<any[]>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if ("loadOptions" in source && source.loadOptions) {
|
||||
return source.loadOptions()
|
||||
}
|
||||
if ("listFn" in source && source.listFn) {
|
||||
const response = await source.listFn()
|
||||
const items = extractItems(response)
|
||||
return source.mapOption ? items.map(source.mapOption) : items
|
||||
}
|
||||
return []
|
||||
},
|
||||
staleTime: staleTime ?? 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Single-select wrapper ──
|
||||
|
||||
type RhfAsyncSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TItem = unknown,
|
||||
> = {
|
||||
name: TName
|
||||
multiple?: false
|
||||
} & BaseRhfAsyncFieldProps & DataSource<TItem> & AsyncSelectControlProps
|
||||
|
||||
export function RhfAsyncSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TItem = unknown,
|
||||
>(props: RhfAsyncSelectFieldProps<TValues, TName, TItem>) {
|
||||
const {
|
||||
name, label, description, required, disabled,
|
||||
queryKey, staleTime,
|
||||
loadOptions, listFn, mapOption,
|
||||
createForm, createLabel,
|
||||
...controlProps
|
||||
} = props
|
||||
|
||||
const source = { loadOptions, listFn, mapOption } as DataSource<TItem>
|
||||
|
||||
const { control } = useFormContext<TValues>()
|
||||
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||
const { data: options = [], isLoading } = useAsyncOptions(queryKey, source, staleTime)
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const getLabel = controlProps.getOptionLabel ?? ((o: any) => o.label)
|
||||
|
||||
const filtered = inputValue
|
||||
? options.filter((o) => String(getLabel(o)).toLowerCase().includes(inputValue.toLowerCase()))
|
||||
: options
|
||||
|
||||
const handleCreateSuccess = (newItem?: { value: string; label: string }) => {
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
if (newItem) {
|
||||
field.onChange(newItem)
|
||||
}
|
||||
setIsCreateOpen(false)
|
||||
}
|
||||
|
||||
// When a createForm is provided, render a custom label row with the "+" button
|
||||
if (createForm) {
|
||||
return (
|
||||
<Field data-invalid={!!error || undefined}>
|
||||
{label && (
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>
|
||||
{label}
|
||||
{required && <span className="text-destructive ms-0.5">*</span>}
|
||||
</FieldLabel>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
title={`Add new ${createLabel ?? label}`}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<AsyncSelectField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
disabled={field.disabled}
|
||||
invalid={!!error}
|
||||
options={filtered}
|
||||
loading={isLoading}
|
||||
onInputValueChange={setInputValue}
|
||||
{...controlProps}
|
||||
/>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
{error && <FieldError>{error.message}</FieldError>}
|
||||
|
||||
<Dialog open={isCreateOpen} onOpenChange={(v) => { if (!v) setIsCreateOpen(false) }}>
|
||||
<DialogContent className="min-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
Add {createLabel ?? label}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
{createForm({ onSuccess: handleCreateSuccess })}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldShell label={label} error={error?.message} description={description} required={required}>
|
||||
<AsyncSelectField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
disabled={field.disabled}
|
||||
invalid={!!error}
|
||||
options={filtered}
|
||||
loading={isLoading}
|
||||
onInputValueChange={setInputValue}
|
||||
{...controlProps}
|
||||
/>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Multi-select wrapper ──
|
||||
|
||||
type RhfAsyncMultiSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TItem = unknown,
|
||||
> = {
|
||||
name: TName
|
||||
multiple: true
|
||||
} & BaseRhfAsyncFieldProps & DataSource<TItem> & AsyncMultiSelectControlProps
|
||||
|
||||
export function RhfAsyncMultiSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TItem = unknown,
|
||||
>(props: RhfAsyncMultiSelectFieldProps<TValues, TName, TItem>) {
|
||||
const {
|
||||
name, label, description, required, disabled,
|
||||
queryKey, staleTime,
|
||||
loadOptions, listFn, mapOption,
|
||||
...controlProps
|
||||
} = props
|
||||
|
||||
const source = { loadOptions, listFn, mapOption } as DataSource<TItem>
|
||||
|
||||
const { control } = useFormContext<TValues>()
|
||||
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||
const { data: options = [], isLoading } = useAsyncOptions(queryKey, source, staleTime)
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
|
||||
const getLabel = controlProps.getOptionLabel ?? ((o: any) => o.label)
|
||||
|
||||
const filtered = inputValue
|
||||
? options.filter((o) => String(getLabel(o)).toLowerCase().includes(inputValue.toLowerCase()))
|
||||
: options
|
||||
|
||||
return (
|
||||
<FieldShell label={label} error={error?.message} description={description} required={required}>
|
||||
<AsyncMultiSelectField
|
||||
value={field.value ?? []}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
disabled={field.disabled}
|
||||
invalid={!!error}
|
||||
options={filtered}
|
||||
loading={isLoading}
|
||||
onInputValueChange={setInputValue}
|
||||
{...controlProps}
|
||||
/>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { useFormContext, useController } from "react-hook-form"
|
||||
import { CheckboxField, type CheckboxFieldProps } from "../controls/checkbox-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { FieldError } from "@/shared/components/ui/field"
|
||||
|
||||
type RhfCheckboxFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<CheckboxFieldProps, keyof BaseFieldControlProps<boolean>>
|
||||
|
||||
export function RhfCheckboxField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>({
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
...controlProps
|
||||
}: RhfCheckboxFieldProps<TValues, TName>) {
|
||||
const { control } = useFormContext<TValues>()
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({ name, control, disabled })
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border p-4">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
{label && (
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{error && <FieldError>{error.message}</FieldError>}
|
||||
</div>
|
||||
<CheckboxField
|
||||
{...(controlProps as any)}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
disabled={field.disabled}
|
||||
invalid={!!error}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { FileInputField, type FileInputFieldProps } from "../controls/file-input-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfFileFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<FileInputFieldProps, keyof BaseFieldControlProps<File | null>>
|
||||
|
||||
export function RhfFileField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfFileFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={FileInputField} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { SelectField, type SelectFieldProps } from "../controls/select-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<SelectFieldProps, keyof BaseFieldControlProps<string>>
|
||||
|
||||
export function RhfSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfSelectFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={SelectField} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { TextInputField, type TextInputFieldProps } from "../controls/text-input-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfTextFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<TextInputFieldProps, keyof BaseFieldControlProps<string>>
|
||||
|
||||
export function RhfTextField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfTextFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={TextInputField} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { TextareaField, type TextareaFieldProps } from "../controls/textarea-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfTextareaFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<TextareaFieldProps, keyof BaseFieldControlProps<string>>
|
||||
|
||||
export function RhfTextareaField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfTextareaFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={TextareaField} />
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfTextField } from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import type { InlineCreateFormProps } from "./rhf-async-select-field"
|
||||
|
||||
const schema = z.object({ title: z.string().min(1, "Required") })
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export type SimpleTitleFormProps = InlineCreateFormProps & {
|
||||
placeholder?: string
|
||||
submitLabel?: string
|
||||
createFn: (title: string) => Promise<any>
|
||||
mapResult?: (data: any) => { value: string; label: string }
|
||||
}
|
||||
|
||||
const defaultMapResult = (data: any) => {
|
||||
const item = data?.data ?? data
|
||||
return {
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? String(item.id),
|
||||
}
|
||||
}
|
||||
|
||||
export function SimpleTitleForm({
|
||||
onSuccess,
|
||||
placeholder = "Enter name",
|
||||
submitLabel = "Create",
|
||||
createFn,
|
||||
mapResult = defaultMapResult,
|
||||
}: SimpleTitleFormProps) {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "" },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const result = await createFn(values.title)
|
||||
toast.success(`${submitLabel}d successfully`)
|
||||
form.reset()
|
||||
onSuccess(mapResult(result))
|
||||
} catch {
|
||||
toast.error(`Failed to ${submitLabel.toLowerCase()}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField name="title" label="Name" placeholder={placeholder} required />
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Plus />
|
||||
{form.formState.isSubmitting ? "Creating..." : submitLabel}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user