Relation Fields
Select fields that load options from related database models
Overview
Relation fields automatically load options from related database models. Powered by RelationFieldProvider and ZenStack's form.relation directive.
Full example
The complete sandbox example, read directly from the form-develop-app source at build time.
'use client'
import { schema } from '@/generated/schema'
import { useCreateCategory, useCreateRecipe } from '@/lib/hooks'
import { Badge, Box, Code, Heading, HStack, Separator, Text, VStack } from '@chakra-ui/react'
import { Form, RelationFieldProvider, relationMeta, withUIMeta } from '@letar/forms'
import { useZenStackRelations } from '@letar/forms-query/zenstack'
import { useState } from 'react'
import { z } from 'zod/v4'
import { DemoPageLayout, SubmittedDataPreview } from '../_components'
/**
* Демонстрация RelationFieldProvider для автозагрузки опций
*
* Показывает:
* 1. Автозагрузку опций из ZenStack hooks
* 2. Использование relationMeta() для обогащения схемы
* 3. Интеграцию с Form.AutoFields
*/
// === Схема с relationMeta ===
const RecipeFormSchema = withUIMeta(
z.object({
title: z.string().min(2),
portions: z.number().min(1).max(100),
categoryId: z.string().optional(),
}),
{
title: { title: 'Название рецепта', placeholder: 'Введите название...' },
portions: { title: 'Количество порций', fieldType: 'numberInput' },
categoryId: relationMeta({
title: 'Категория',
model: 'Category',
labelField: 'name',
fieldType: 'select',
}),
},
)
type RecipeFormData = z.infer<typeof RecipeFormSchema>
// Настройки справочников поверх собранных из схемы: описание опции. Вынесено за компонент — литерал в JSX пересобирал бы массив relations на каждом рендере
const relationOverrides = { Category: { descriptionField: 'description' } }
const recipeInitialValues: RecipeFormData = {
title: '',
portions: 4,
categoryId: undefined,
}
// === Схема для создания категории ===
const CategorySchema = z.object({
name: z
.string()
.min(2)
.meta({ ui: { title: 'Название', placeholder: 'Например: Десерты' } }),
description: z
.string()
.optional()
.meta({ ui: { title: 'Описание', fieldType: 'textarea' } }),
color: z.string().meta({ ui: { title: 'Цвет', fieldType: 'colorPicker' } }),
})
type CategoryFormData = z.infer<typeof CategorySchema>
const categoryInitialValues: CategoryFormData = {
name: '',
description: '',
color: '#3182CE',
}
export default function RelationDemoPage() {
const [recipeData, setRecipeData] = useState<RecipeFormData | null>(null)
const [categoryData, setCategoryData] = useState<CategoryFormData | null>(null)
// ZenStack mutations
const createCategory = useCreateCategory()
const createRecipe = useCreateRecipe()
// Справочники берутся из самой form-схемы: fieldProps.relation → useFindMany модели из схемы ZenStack
const relations = useZenStackRelations(schema, RecipeFormSchema, { overrides: relationOverrides })
const handleCategorySubmit = async (data: CategoryFormData) => {
await createCategory.mutateAsync({ data })
setCategoryData(data)
}
const handleRecipeSubmit = async (data: RecipeFormData) => {
await createRecipe.mutateAsync({
data: {
title: data.title,
portions: data.portions,
categoryId: data.categoryId || undefined,
},
})
setRecipeData(data)
}
return (
<DemoPageLayout
title="Relation Field Provider Demo"
description="Демонстрация автозагрузки опций для relation полей с использованием RelationFieldProvider и relationMeta()."
maxW="1200px"
>
<VStack gap={8} align="stretch">
{/* === СЕКЦИЯ 1: Создание категории === */}
<Box p={6} borderWidth={1} borderRadius="lg">
<HStack mb={4}>
<Heading size="md">1. Создайте категорию</Heading>
<Badge colorPalette="blue">Справочник</Badge>
</HStack>
<Text color="fg.muted" mb={4}>
Сначала создайте несколько категорий, которые будут загружены в select ниже.
</Text>
<Form.FromSchema
schema={CategorySchema}
initialValue={categoryInitialValues}
onSubmit={handleCategorySubmit}
submitLabel="Создать категорию"
/>
{categoryData && <SubmittedDataPreview data={categoryData} title="Создана категория:" />}
</Box>
<Separator />
{/* === СЕКЦИЯ 2: RelationFieldProvider === */}
<Box p={6} borderWidth={1} borderRadius="lg">
<HStack mb={4}>
<Heading size="md">2. RelationFieldProvider</Heading>
<Badge colorPalette="green">Автозагрузка опций</Badge>
</HStack>
<Text color="fg.muted" mb={4}>
Категории автоматически загружаются через <Code>useZenStackRelations</Code> (<Code>useFindMany</Code>{' '}
модели из схемы ZenStack) и передаются в поле <Code>categoryId</Code>.
</Text>
<Code
display="block"
whiteSpace="pre"
mb={4}
p={3}
bg="gray.100"
borderRadius="md"
_dark={{ bg: 'gray.800' }}
>
{`const relations = useZenStackRelations(schema, RecipeFormSchema)
<RelationFieldProvider relations={relations}>
<Form schema={RecipeFormSchema} ...>
<Form.AutoFields /> {/* categoryId получит options автоматически */}
</Form>
</RelationFieldProvider>`}
</Code>
{/* Форма с RelationFieldProvider */}
<RelationFieldProvider relations={relations}>
<Form schema={RecipeFormSchema} initialValue={recipeInitialValues} onSubmit={handleRecipeSubmit}>
<VStack gap={4} align="stretch">
<Form.AutoFields />
<HStack justify="flex-end" gap={2}>
<Form.Button.Reset variant="outline">Сбросить</Form.Button.Reset>
<Form.Button.Submit>Создать рецепт</Form.Button.Submit>
</HStack>
</VStack>
</Form>
</RelationFieldProvider>
{recipeData && <SubmittedDataPreview data={recipeData} title="Создан рецепт:" />}
</Box>
<Separator />
{/* === СЕКЦИЯ 3: Как это работает === */}
<Box p={6} borderWidth={1} borderRadius="lg" bg="gray.50" _dark={{ bg: 'gray.900' }}>
<Heading size="md" mb={4}>
Как это работает
</Heading>
<VStack align="stretch" gap={4}>
<Box>
<Text fontWeight="bold" mb={2}>
1. Обогащаем схему с relationMeta:
</Text>
<Code display="block" whiteSpace="pre" p={3} bg="white" borderRadius="md">
{`const RecipeFormSchema = withUIMeta(Schema, {
categoryId: relationMeta({
title: 'Категория',
model: 'Category', // Имя модели
labelField: 'name', // Поле для отображения
}),
})`}
</Code>
</Box>
<Box>
<Text fontWeight="bold" mb={2}>
2. Собираем relations из схемы и оборачиваем форму в RelationFieldProvider:
</Text>
<Code display="block" whiteSpace="pre" p={3} bg="white" borderRadius="md">
{`// модель и labelField берутся из fieldProps.relation схемы
const relations = useZenStackRelations(schema, RecipeFormSchema)
<RelationFieldProvider relations={relations}>`}
</Code>
</Box>
<Box>
<Text fontWeight="bold" mb={2}>
3. SchemaFieldWithRelations автоматически:
</Text>
<VStack align="start" gap={1} pl={4}>
<Text>
• Находит поле с <Code>fieldProps.relation.model === 'Category'</Code>
</Text>
<Text>• Берёт options из контекста RelationFieldProvider</Text>
<Text>• Передаёт их в компонент Select</Text>
</VStack>
</Box>
</VStack>
</Box>
<Separator />
{/* === СЕКЦИЯ 4: Дополнительные возможности === */}
<Box p={6} borderWidth={1} borderRadius="lg">
<Heading size="md" mb={4}>
Дополнительные возможности
</Heading>
<HStack gap={8} flexWrap="wrap" align="start">
<VStack align="start" gap={2}>
<Text fontWeight="bold">Фильтрация и сортировка</Text>
<Code display="block" whiteSpace="pre" p={2} bg="gray.100" borderRadius="md" fontSize="sm">
{`{
model: 'Category',
useQuery: useFindManyCategory,
labelField: 'name',
queryArgs: {
where: { isActive: true },
orderBy: { name: 'asc' },
},
}`}
</Code>
</VStack>
<VStack align="start" gap={2}>
<Text fontWeight="bold">Хуки для кастомных компонентов</Text>
<Code display="block" whiteSpace="pre" p={2} bg="gray.100" borderRadius="md" fontSize="sm">
{`import { useRelationOptions } from '@letar/forms'
function CustomSelect() {
const { options, isLoading } = useRelationOptions('Category')
return <MySelect options={options} loading={isLoading} />
}`}
</Code>
</VStack>
<VStack align="start" gap={2}>
<Text fontWeight="bold">HOC withRelations</Text>
<Code display="block" whiteSpace="pre" p={2} bg="gray.100" borderRadius="md" fontSize="sm">
{`import { withRelations } from '@letar/forms'
const FormWithRelations = withRelations(MyForm, [
{ model: 'Category', useQuery: useFindManyCategory, labelField: 'name' },
])`}
</Code>
</VStack>
</HStack>
</Box>
</VStack>
</DemoPageLayout>
)
}shadcn-вариант этого примера ещё не готов.
Vue-пример для этого поля появится позже.
Angular-пример для этого поля появится позже.
Schema Setup
model Recipe {
categoryId String @meta("form.title", "Category") @meta("form.relation.labelField", "name")
category Category @relation(fields: [categoryId], references: [id])
}⛔ @meta("form.relation", { labelField: "name" }) with an object literal breaks
zenstack generate entirely (Unsupported attribute arg value: ObjectExpr) — always use the
flat dot-path form shown above, one @meta call per key.
RelationFieldProvider
Wrap your form to provide relation data:
import { RelationFieldProvider } from '@letar/forms'
;<RelationFieldProvider
relations={[{ model: 'Category', useQuery: useFindManyCategory, labelField: 'name' }]}
>
<Form schema={RecipeCreateFormSchema} onSubmit={save}>
<Form.Field.String name="title" />
<Form.Field.Select name="categoryId" />
<Form.Button.Submit>Save</Form.Button.Submit>
</Form>
</RelationFieldProvider>Relations from the schema: useZenStackRelations
Writing relations by hand means one useFindMany adapter per model. With ZenStack, useZenStackRelations from
@letar/forms-query/zenstack builds the list from the form schema itself: it walks the fields that have
fieldProps.relation and takes useFindMany of the model from the ZenStack schema.
import { schema } from '@/generated/schema'
import { useZenStackRelations } from '@letar/forms-query/zenstack'
// Outside the component: a literal in JSX would rebuild the array on every render
const overrides = { Category: { queryArgs: { orderBy: { name: 'asc' } }, descriptionField: 'note' } }
function RecipeForm() {
const relations = useZenStackRelations(schema, RecipeCreateFormSchema, { overrides })
return (
<RelationFieldProvider relations={relations}>
<Form schema={RecipeCreateFormSchema} onSubmit={save}>
<Form.AutoFields />
</Form>
</RelationFieldProvider>
)
}- One model used by several fields is loaded once.
- Fields with a
createFormregistry key (Select.Category) are skipped: that component loads its own data. - A model missing from the ZenStack schema throws with the list of available models.
- Two fields asking for different
labelFieldof the same model: the first wins and a warning is printed — set the label inoverrides. - The third argument takes
overrides,excludeandclientOptions(the second argument ofuseClientQueries).
Second option line: descriptionField
descriptionField is the record field that Select and Combobox (Chakra and shadcn) draw as a second line under
the label in the dropdown list. The trigger and the input after a pick keep the label only. Set it in the schema,
no field code:
categoryId String @meta("form.relation.labelField", "name") @meta("form.relation.descriptionField", "note")descriptionField in RelationConfig (or in overrides of useZenStackRelations) wins over the schema. An empty
value means no second line. The local search also matches the description; searchInDescription={false} on the field
turns that off. Live example: /relation-demo in form-develop-app.
useRelationOptions Hook
Access relation options from custom components:
const { options, isLoading } = useRelationOptions('category')withRelations HOC
Alternative to wrapping with RelationFieldProvider:
const RecipeForm = withRelations(BaseRecipeForm, {
category: { useQuery: useFindManyCategory, labelField: 'name' },
})