Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fair-banks-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@genseki/react': minor
'@example/erp': minor
---

[Feat] Add combobox as a default connect autoField
33 changes: 30 additions & 3 deletions examples/erp/genseki/collections/posts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,21 +185,48 @@ export const options = builder.options(fields, {
],
}
},

author: async ({ body }) => {
const opt = (body as any).__options ?? {}
const search: string = opt.search ?? ''
const take = Math.max(1, Math.min(Number(opt.take ?? 10) || 10, 50))

if (body.title === 'DISABLED') {
return {
disabled: true,
options: [],
}
}
const authors = await prisma.user.findMany({ select: { id: true, name: true } })

const where = search ? { name: { contains: search, mode: 'insensitive' as const } } : {}

const authors = await prisma.user.findMany({
where,
select: { id: true, name: true },
orderBy: { id: 'asc' },
take,
})

return {
disabled: false,
options: authors.map((author) => ({ label: author.name ?? '(No Name)', value: author.id })),
}
},
tag: async () => {
const tags = await prisma.tag.findMany({ select: { id: true, name: true } })

tag: async ({ body }: { body: any }) => {
const opt = body.__options ?? {}
const search: string = opt.search ?? ''
const take = Math.max(1, Math.min(Number(opt.take ?? 10) || 10, 50))

const where = search ? { name: { contains: search, mode: 'insensitive' as const } } : {}

const tags = await prisma.tag.findMany({
where,
select: { id: true, name: true },
orderBy: { id: 'asc' },
take,
})

return {
disabled: false,
options: tags.map((tag) => ({ label: tag.name, value: tag.id })),
Expand Down
153 changes: 123 additions & 30 deletions packages/react/src/react/components/compound/auto-field/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { type ReactNode, startTransition, useMemo } from 'react'
import { type ReactNode, startTransition, useMemo, useState } from 'react'
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'

import { EnvelopeIcon } from '@phosphor-icons/react'
Expand All @@ -12,6 +12,9 @@ import {
Button,
Checkbox,
type CheckboxProps,
Combobox,
type ComboboxItem,
type ComboboxProps,
DatePicker,
type DatePickerProps,
FormField,
Expand Down Expand Up @@ -271,6 +274,93 @@ export function AutoSelectField(props: AutoSelectField) {
)
}

export interface AutoComboboxFieldProps
extends Omit<ComboboxProps, 'items' | 'onSearch' | 'isLoading'> {
name?: string

optionsName: string
optionsFetchPath: string

take?: number
searchDelay?: number
}

export function AutoComboboxField(props: AutoComboboxFieldProps) {
const {
value,
onChange,
label,
description,
placeholder = 'Search…',
className,
isDisabled,
isRequired,
errorMessage,
onOpenChange,
deselectable,
optionsName,
optionsFetchPath,
take = 10,
searchDelay = 300,
} = props

const form = useFormContext()
const formValues = useWatch({ control: form.control })

const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')

const [active, setActive] = useState(false)

useDebounce(search, () => setDebouncedSearch(search), searchDelay)

const query = useQuery<{ status: 200; body: FieldOptionsCallbackReturn }>({
queryKey: ['POST', optionsFetchPath, optionsName, debouncedSearch, take],
enabled: active,
queryFn: async () => {
const res = await fetch(`/api${optionsFetchPath}?name=${encodeURIComponent(optionsName)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formValues,
__options: { search: debouncedSearch, take },
}),
})
if (!res.ok) throw new Error('Failed to fetch options')
return res.json()
},
})

const items: ComboboxItem[] = useMemo(
() => (query.data?.body.options ?? []).map((o) => ({ label: o.label, value: String(o.value) })),
[query.data]
)

const mergedDisabled = (query.data?.body.disabled ?? false) || isDisabled

return (
<Combobox
className={cn('w-full', className)}
label={label}
description={description}
placeholder={placeholder}
value={value ?? null}
onChange={onChange}
items={items}
isLoading={query.isFetching}
isDisabled={mergedDisabled}
isRequired={isRequired}
errorMessage={errorMessage}
deselectable={deselectable}
onSearch={setSearch}
onOpenChange={(isOpen) => {
if (isOpen && !active) setActive(true)
onOpenChange?.(isOpen)
}}
/>
)
}

const AutoRichTextField = (props: {
name: string
onChange?: (content: string | Content | Content[]) => void
Expand Down Expand Up @@ -576,26 +666,26 @@ export function AutoOneRelationshipField(props: AutoRelationshipFieldProps) {
if (fieldShape.hidden) return null
const disabled = fieldShape.disabled || false

const commonProps = {
label: fieldShape.label,
className: props.className,
description: fieldShape.description,
placeholder: fieldShape.placeholder,
}

const connectComponent = (name: string, options: string) => (
<FormField
key={name}
name={name}
control={control}
render={({ field, fieldState, formState }) => (
<FormItemController field={field} fieldState={fieldState} formState={formState}>
<AutoSelectField
{...commonProps}
<AutoComboboxField
value={(field.value as string | null) ?? null}
onChange={field.onChange}
label={fieldShape.label}
description={fieldShape.description}
placeholder={fieldShape.placeholder}
isDisabled={disabled}
isRequired={fieldShape.required}
errorMessage={fieldState.error?.message}
optionsFetchPath={props.optionsFetchPath}
optionsName={options}
deselectable={fieldShape.deselectable}
deselectable={props.deselectable}
take={10}
/>
</FormItemController>
)}
Expand All @@ -622,7 +712,7 @@ export function AutoOneRelationshipField(props: AutoRelationshipFieldProps) {
switch (fieldShape.type) {
case 'connect':
return (
<div className="rounded-md border border-border bg-surface-tertiary overflow-hidden">
<div className="rounded-md border border-border bg-surface-tertiary">
{fieldShape.label && (
<>
<Typography
Expand Down Expand Up @@ -682,28 +772,31 @@ export function AutoManyRelationshipField(props: AutoManyRelationshipFieldProps)
if (fieldShape.hidden) return null
const disabled = fieldShape.disabled || props.disabled

const connectComponent = (name: string, options: string) => {
const commonProps = {
label: fieldShape.label,
className: props.className,
description: fieldShape.description,
placeholder: fieldShape.placeholder,
}
return (
<AutoFormField
name={name}
component={
<AutoSelectField
{...commonProps}
const connectComponent = (name: string, options: string) => (
<FormField
key={name}
name={name}
control={control}
render={({ field, fieldState, formState }) => (
<FormItemController field={field} fieldState={fieldState} formState={formState}>
<AutoComboboxField
value={(field.value as string | null) ?? null}
onChange={field.onChange}
label={fieldShape.label}
description={fieldShape.description}
placeholder={fieldShape.placeholder}
isDisabled={disabled}
optionsName={options}
isRequired={fieldShape.required}
errorMessage={fieldState.error?.message}
optionsFetchPath={props.optionsFetchPath}
deselectable={props.deselectable}
optionsName={options}
take={10}
/>
}
/>
)
}
</FormItemController>
)}
/>
)

const createComponent = (name: string) => {
return Object.entries(fieldShape.fields).map(([key, childField]) => (
Expand Down
Loading
Loading