@letar/forms

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.

apps/form-develop-app/src/app/relation-demo/page.tsx
'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 === &apos;Category&apos;</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>
  )
}

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 createForm registry 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 labelField of the same model: the first wins and a warning is printed — set the label in overrides.
  • The third argument takes overrides, exclude and clientOptions (the second argument of useClientQueries).

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' },
})

On this page