generate-frontend-forms

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Form System Guide

表单系统指南

This skill provides patterns for building forms using Sentry's new form system built on TanStack React Form and Zod validation.
本指南提供了使用基于TanStack React Form和Zod验证的Sentry新表单系统构建表单的模式。

Core Principle

核心原则

  • Always use the new form system (
    useScrapsForm
    ,
    AutoSaveField
    ) for new forms. Never create new forms with the legacy JsonForm or Reflux-based systems.
  • All forms should be schema based. DO NOT create a form without schema validation.
  • 新表单必须使用新的表单系统(
    useScrapsForm
    AutoSaveField
    )。绝对不要使用旧版JsonForm或基于Reflux的系统创建新表单。
  • 所有表单都必须基于Schema。禁止创建没有Schema验证的表单。

Imports

导入

All form components are exported from
@sentry/scraps/form
:
tsx
import {z} from 'zod';

import {
  AutoSaveField,
  defaultFormOptions,
  setFieldErrors,
  useScrapsForm,
} from '@sentry/scraps/form';
Important: DO NOT import from deeper paths, like '@sentry/scraps/form/field'. You can only use what is part of the PUBLIC interface in the index file in @sentry/scraps/form.

所有表单组件都从
@sentry/scraps/form
导出:
tsx
import {z} from 'zod';

import {
  AutoSaveField,
  defaultFormOptions,
  setFieldErrors,
  useScrapsForm,
} from '@sentry/scraps/form';
重要提示:不要从更深层级的路径导入,例如'@sentry/scraps/form/field'。只能使用
@sentry/scraps/form
索引文件中公开的公共接口。

Form Hook:
useScrapsForm

表单Hook:
useScrapsForm

The main hook for creating forms with validation and submission handling.
这是创建带有验证和提交处理功能表单的核心Hook。

Basic Usage

基础用法

tsx
import {z} from 'zod';

import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';

const schema = z.object({
  email: z.string().email('Invalid email'),
  name: z.string().min(2, 'Name must be at least 2 characters'),
});

function MyForm() {
  const form = useScrapsForm({
    ...defaultFormOptions,
    defaultValues: {
      email: '',
      name: '',
    },
    validators: {
      onDynamic: schema,
    },
    onSubmit: ({value, formApi}) => {
      // Handle submission
      console.log(value);
    },
  });

  return (
    <form.AppForm>
      <form.FormWrapper>
        <form.AppField name="email">
          {field => (
            <field.Layout.Stack label="Email" required>
              <field.Input value={field.state.value} onChange={field.handleChange} />
            </field.Layout.Stack>
          )}
        </form.AppField>

        <form.SubmitButton>Submit</form.SubmitButton>
      </form.FormWrapper>
    </form.AppForm>
  );
}
Important: Always spread
defaultFormOptions
first. It configures validation to run on submit initially, then on every change after the first submission. This is why validators are defined as
onDynamic
, and it's what provides a consistent UX.
tsx
import {z} from 'zod';

import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';

const schema = z.object({
  email: z.string().email('Invalid email'),
  name: z.string().min(2, 'Name must be at least 2 characters'),
});

function MyForm() {
  const form = useScrapsForm({
    ...defaultFormOptions,
    defaultValues: {
      email: '',
      name: '',
    },
    validators: {
      onDynamic: schema,
    },
    onSubmit: ({value, formApi}) => {
      // Handle submission
      console.log(value);
    },
  });

  return (
    <form.AppForm>
      <form.FormWrapper>
        <form.AppField name="email">
          {field => (
            <field.Layout.Stack label="Email" required>
              <field.Input value={field.state.value} onChange={field.handleChange} />
            </field.Layout.Stack>
          )}
        </form.AppField>

        <form.SubmitButton>Submit</form.SubmitButton>
      </form.FormWrapper>
    </form.AppForm>
  );
}
重要提示:务必先展开
defaultFormOptions
。它会配置验证逻辑,首次提交时运行验证,之后每次内容变更都会触发验证。这也是为什么验证器要定义为
onDynamic
,以此提供一致的用户体验。

Returned Properties

返回属性

PropertyDescription
AppForm
Root wrapper component (provides form context)
FormWrapper
Form element wrapper (handles submit)
AppField
Field renderer component
FieldGroup
Section grouping with title
SubmitButton
Pre-wired submit button
Subscribe
Subscribe to form state changes
reset()
Reset form to default values
handleSubmit()
Manually trigger submission

属性名称描述
AppForm
根容器组件(提供表单上下文)
FormWrapper
表单元素容器(处理提交逻辑)
AppField
字段渲染组件
FieldGroup
带标题的字段分组组件
SubmitButton
预配置的提交按钮
Subscribe
订阅表单状态变更
reset()
将表单重置为默认值
handleSubmit()
手动触发提交

Field Components

字段组件

All fields are accessed via the
field
render prop and follow consistent patterns.
所有字段都通过
field
渲染属性访问,并遵循一致的模式。

Input Field (Text)

输入字段(文本)

tsx
<form.AppField name="firstName">
  {field => (
    <field.Layout.Stack label="First Name" required>
      <field.Input
        value={field.state.value}
        onChange={field.handleChange}
        placeholder="Enter your name"
      />
    </field.Layout.Stack>
  )}
</form.AppField>
tsx
<form.AppField name="firstName">
  {field => (
    <field.Layout.Stack label="First Name" required>
      <field.Input
        value={field.state.value}
        onChange={field.handleChange}
        placeholder="Enter your name"
      />
    </field.Layout.Stack>
  )}
</form.AppField>

Number Field

数字字段

tsx
<form.AppField name="age">
  {field => (
    <field.Layout.Stack label="Age" required>
      <field.Number
        value={field.state.value}
        onChange={field.handleChange}
        min={0}
        max={120}
        step={1}
      />
    </field.Layout.Stack>
  )}
</form.AppField>
tsx
<form.AppField name="age">
  {field => (
    <field.Layout.Stack label="Age" required>
      <field.Number
        value={field.state.value}
        onChange={field.handleChange}
        min={0}
        max={120}
        step={1}
      />
    </field.Layout.Stack>
  )}
</form.AppField>

Select Field (Single)

选择字段(单选)

tsx
<form.AppField name="country">
  {field => (
    <field.Layout.Stack label="Country">
      <field.Select
        value={field.state.value}
        onChange={field.handleChange}
        options={[
          {value: 'us', label: 'United States'},
          {value: 'uk', label: 'United Kingdom'},
        ]}
      />
    </field.Layout.Stack>
  )}
</form.AppField>
tsx
<form.AppField name="country">
  {field => (
    <field.Layout.Stack label="Country">
      <field.Select
        value={field.state.value}
        onChange={field.handleChange}
        options={[
          {value: 'us', label: 'United States'},
          {value: 'uk', label: 'United Kingdom'},
        ]}
      />
    </field.Layout.Stack>
  )}
</form.AppField>

Select Field (Multiple)

选择字段(多选)

tsx
<form.AppField name="tags">
  {field => (
    <field.Layout.Stack label="Tags">
      <field.Select
        multiple
        value={field.state.value}
        onChange={field.handleChange}
        options={[
          {value: 'bug', label: 'Bug'},
          {value: 'feature', label: 'Feature'},
        ]}
        clearable
      />
    </field.Layout.Stack>
  )}
</form.AppField>
tsx
<form.AppField name="tags">
  {field => (
    <field.Layout.Stack label="Tags">
      <field.Select
        multiple
        value={field.state.value}
        onChange={field.handleChange}
        options={[
          {value: 'bug', label: 'Bug'},
          {value: 'feature', label: 'Feature'},
        ]}
        clearable
      />
    </field.Layout.Stack>
  )}
</form.AppField>

Switch Field (Boolean)

开关字段(布尔值)

tsx
<form.AppField name="notifications">
  {field => (
    <field.Layout.Stack label="Enable notifications">
      <field.Switch checked={field.state.value} onChange={field.handleChange} />
    </field.Layout.Stack>
  )}
</form.AppField>
tsx
<form.AppField name="notifications">
  {field => (
    <field.Layout.Stack label="Enable notifications">
      <field.Switch checked={field.state.value} onChange={field.handleChange} />
    </field.Layout.Stack>
  )}
</form.AppField>

TextArea Field

文本域字段

tsx
<form.AppField name="bio">
  {field => (
    <field.Layout.Stack label="Bio">
      <field.TextArea
        value={field.state.value}
        onChange={field.handleChange}
        rows={4}
        placeholder="Tell us about yourself"
      />
    </field.Layout.Stack>
  )}
</form.AppField>
tsx
<form.AppField name="bio">
  {field => (
    <field.Layout.Stack label="Bio">
      <field.TextArea
        value={field.state.value}
        onChange={field.handleChange}
        rows={4}
        placeholder="Tell us about yourself"
      />
    </field.Layout.Stack>
  )}
</form.AppField>

Range Field (Slider)

范围字段(滑块)

tsx
<form.AppField name="volume">
  {field => (
    <field.Layout.Stack label="Volume">
      <field.Range
        value={field.state.value}
        onChange={field.handleChange}
        min={0}
        max={100}
        step={10}
      />
    </field.Layout.Stack>
  )}
</form.AppField>

tsx
<form.AppField name="volume">
  {field => (
    <field.Layout.Stack label="Volume">
      <field.Range
        value={field.state.value}
        onChange={field.handleChange}
        min={0}
        max={100}
        step={10}
      />
    </field.Layout.Stack>
  )}
</form.AppField>

Layouts

布局

Two layout options are available for positioning labels and fields.
提供两种布局选项用于放置标签和字段。

Stack Layout (Vertical)

堆叠布局(垂直)

Label above, field below. Best for forms with longer labels or mobile layouts.
tsx
<field.Layout.Stack
  label="Email Address"
  hintText="We'll never share your email"
  required
>
  <field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Stack>
标签在上,字段在下。适合标签较长或移动端布局的表单。
tsx
<field.Layout.Stack
  label="Email Address"
  hintText="We'll never share your email"
  required
>
  <field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Stack>

Row Layout (Horizontal)

行布局(水平)

Label on left (~50%), field on right. Compact layout for settings pages.
tsx
<field.Layout.Row label="Email Address" hintText="We'll never share your email" required>
  <field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
标签在左侧(约占50%),字段在右侧。适合设置页面的紧凑布局。
tsx
<field.Layout.Row label="Email Address" hintText="We'll never share your email" required>
  <field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>

Compact Variant

紧凑变体

Both Stack and Row layouts support a
variant="compact"
prop. In compact mode, the hint text appears as a tooltip on the label instead of being displayed below. This saves vertical space while still providing the hint information.
tsx
// Default: hint text appears below the label
<field.Layout.Row label="Email" hintText="We'll never share your email">
  <field.Input ... />
</field.Layout.Row>

// Compact: hint text appears in tooltip when hovering the label
<field.Layout.Row label="Email" hintText="We'll never share your email" variant="compact">
  <field.Input ... />
</field.Layout.Row>

// Also works with Stack layout
<field.Layout.Stack label="Email" hintText="We'll never share your email" variant="compact">
  <field.Input ... />
</field.Layout.Stack>
When to Use Compact:
  • Settings pages with many fields where vertical space is limited
  • Forms where hint text is supplementary, not essential
  • Dashboards or panels with constrained height
堆叠布局和行布局都支持
variant="compact"
属性。在紧凑模式下,提示文本会显示为标签的工具提示,而不是在标签下方。这样可以节省垂直空间,同时仍能提供提示信息。
tsx
// 默认:提示文本显示在标签下方
<field.Layout.Row label="Email" hintText="We'll never share your email">
  <field.Input ... />
</field.Layout.Row>

// 紧凑模式:鼠标悬停在标签上时显示提示文本的工具提示
<field.Layout.Row label="Email" hintText="We'll never share your email" variant="compact">
  <field.Input ... />
</field.Layout.Row>

// 堆叠布局同样适用
<field.Layout.Stack label="Email" hintText="We'll never share your email" variant="compact">
  <field.Input ... />
</field.Layout.Stack>
何时使用紧凑模式
  • 字段较多、垂直空间有限的设置页面
  • 提示文本为补充信息而非必需的表单
  • 高度受限的仪表盘或面板

Custom Layouts

自定义布局

You are allowed to create new layouts if necessary, or not use any layouts at all. Without a layout, you should render
field.meta.Label
and optionally
field.meta.HintText
for a11y.
tsx
<form.AppField name="firstName">
  {field => (
    <Flex gap="md">
      <field.Meta.Label required>First Name:</field.Meta.Label>
      <field.Input value={field.state.value ?? ''} onChange={field.handleChange} />
    </Flex>
  )}
</form.AppField>
如有必要,你可以创建新的布局,或者不使用任何布局。如果不使用布局,你应该渲染
field.meta.Label
,并可选地渲染
field.meta.HintText
以保证可访问性。
tsx
<form.AppField name="firstName">
  {field => (
    <Flex gap="md">
      <field.Meta.Label required>First Name:</field.Meta.Label>
      <field.Input value={field.state.value ?? ''} onChange={field.handleChange} />
    </Flex>
  )}
</form.AppField>

Layout Props

布局属性

PropTypeDescription
label
string
Field label text
hintText
string
Helper text (below label by default, tooltip in compact mode)
required
boolean
Shows required indicator
variant
"compact"
Shows hint text in tooltip instead of below label

属性类型描述
label
string
字段标签文本
hintText
string
辅助文本(默认在标签下方,紧凑模式下为工具提示)
required
boolean
显示必填指示器
variant
"compact"
将提示文本显示为工具提示而非标签下方的文本

Field Groups

字段分组

Group related fields into sections with a title.
tsx
<form.FieldGroup title="Personal Information">
  <form.AppField name="firstName">{/* ... */}</form.AppField>
  <form.AppField name="lastName">{/* ... */}</form.AppField>
</form.FieldGroup>

<form.FieldGroup title="Contact Information">
  <form.AppField name="email">{/* ... */}</form.AppField>
  <form.AppField name="phone">{/* ... */}</form.AppField>
</form.FieldGroup>

将相关字段分组为带标题的章节。
tsx
<form.FieldGroup title="Personal Information">
  <form.AppField name="firstName">{/* ... */}</form.AppField>
  <form.AppField name="lastName">{/* ... */}</form.AppField>
</form.FieldGroup>

<form.FieldGroup title="Contact Information">
  <form.AppField name="email">{/* ... */}</form.AppField>
  <form.AppField name="phone">{/* ... */}</form.AppField>
</form.FieldGroup>

Disabled State

禁用状态

Fields accept
disabled
as a boolean or string. When a string is provided, it displays as a tooltip explaining why the field is disabled.
tsx
// ❌ Don't disable without explanation
<field.Input disabled value={field.state.value} onChange={field.handleChange} />

// ✅ Provide a reason when disabling
<field.Input
  disabled="This feature requires a Business plan"
  value={field.state.value}
  onChange={field.handleChange}
/>

字段接受
disabled
属性,值可以是布尔值或字符串。当传入字符串时,会显示为工具提示,解释字段被禁用的原因。
tsx
// ❌ 不要在没有说明的情况下禁用字段
<field.Input disabled value={field.state.value} onChange={field.handleChange} />

// ✅ 禁用时请提供原因
<field.Input
  disabled="This feature requires a Business plan"
  value={field.state.value}
  onChange={field.handleChange}
/>

Validation with Zod

使用Zod进行验证

Schema Definition

Schema定义

tsx
import {z} from 'zod';

const userSchema = z.object({
  email: z.string().email('Please enter a valid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  age: z.number().gte(13, 'You must be at least 13 years old'),
  bio: z.string().optional(),
  tags: z.array(z.string()).optional(),
  address: z.object({
    street: z.string().min(1, 'Street is required'),
    city: z.string().min(1, 'City is required'),
  }),
});
tsx
import {z} from 'zod';

const userSchema = z.object({
  email: z.string().email('Please enter a valid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  age: z.number().gte(13, 'You must be at least 13 years old'),
  bio: z.string().optional(),
  tags: z.array(z.string()).optional(),
  address: z.object({
    street: z.string().min(1, 'Street is required'),
    city: z.string().min(1, 'City is required'),
  }),
});

Conditional Validation

条件验证

Use
.refine()
for cross-field validation:
tsx
const schema = z
  .object({
    password: z.string(),
    confirmPassword: z.string(),
  })
  .refine(data => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });
使用
.refine()
进行跨字段验证:
tsx
const schema = z
  .object({
    password: z.string(),
    confirmPassword: z.string(),
  })
  .refine(data => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });

Conditional Fields

条件字段

Use
form.Subscribe
to show/hide fields based on other field values:
tsx
<form.Subscribe selector={state => state.values.plan === 'enterprise'}>
  {showBilling =>
    showBilling ? (
      <form.AppField name="billingEmail">
        {field => (
          <field.Layout.Stack label="Billing Email" required>
            <field.Input value={field.state.value} onChange={field.handleChange} />
          </field.Layout.Stack>
        )}
      </form.AppField>
    ) : null
  }
</form.Subscribe>

使用
form.Subscribe
根据其他字段的值显示/隐藏字段:
tsx
<form.Subscribe selector={state => state.values.plan === 'enterprise'}>
  {showBilling =>
    showBilling ? (
      <form.AppField name="billingEmail">
        {field => (
          <field.Layout.Stack label="Billing Email" required>
            <field.Input value={field.state.value} onChange={field.handleChange} />
          </field.Layout.Stack>
        )}
      </form.AppField>
    ) : null
  }
</form.Subscribe>

Error Handling

错误处理

Server-Side Errors

服务端错误

Use
setFieldErrors
to display backend validation errors:
tsx
import {useMutation} from '@tanstack/react-query';

import {setFieldErrors} from '@sentry/scraps/form';

import {fetchMutation} from 'sentry/utils/queryClient';

function MyForm() {
  const mutation = useMutation({
    mutationFn: (data: {email: string; username: string}) => {
      return fetchMutation({
        url: '/users/',
        method: 'POST',
        data,
      });
    },
  });

  const form = useScrapsForm({
    ...defaultFormOptions,
    defaultValues: {email: '', username: ''},
    validators: {onDynamic: schema},
    onSubmit: async ({value, formApi}) => {
      try {
        await mutation.mutateAsync(value);
      } catch (error) {
        // Set field-specific errors from backend
        setFieldErrors(formApi, {
          email: {message: 'This email is already registered'},
          username: {message: 'Username is taken'},
        });
      }
    },
  });

  // ...
}
Important:
setFieldErrors
supports nested paths with dot notation:
'address.city': {message: 'City not found'}
使用
setFieldErrors
显示后端验证错误:
tsx
import {useMutation} from '@tanstack/react-query';

import {setFieldErrors} from '@sentry/scraps/form';

import {fetchMutation} from 'sentry/utils/queryClient';

function MyForm() {
  const mutation = useMutation({
    mutationFn: (data: {email: string; username: string}) => {
      return fetchMutation({
        url: '/users/',
        method: 'POST',
        data,
      });
    },
  });

  const form = useScrapsForm({
    ...defaultFormOptions,
    defaultValues: {email: '', username: ''},
    validators: {onDynamic: schema},
    onSubmit: async ({value, formApi}) => {
      try {
        await mutation.mutateAsync(value);
      } catch (error) {
        // 从后端设置字段级错误
        setFieldErrors(formApi, {
          email: {message: 'This email is already registered'},
          username: {message: 'Username is taken'},
        });
      }
    },
  });

  // ...
}
重要提示
setFieldErrors
支持使用点符号的嵌套路径:
'address.city': {message: 'City not found'}

Error Display

错误显示

Validation errors automatically show as a warning icon with tooltip in the field's trailing area. No additional code needed.

验证错误会自动显示为字段尾部区域的警告图标和工具提示,无需额外代码。

Auto-Save Pattern

自动保存模式

For settings pages where each field saves independently, use
AutoSaveField
.
对于每个字段独立保存的设置页面,使用
AutoSaveField

Basic Auto-Save Field

基础自动保存字段

tsx
import {z} from 'zod';

import {AutoSaveField} from '@sentry/scraps/form';

import {fetchMutation} from 'sentry/utils/queryClient';

const schema = z.object({
  displayName: z.string().min(1, 'Display name is required'),
});

function SettingsForm() {
  return (
    <AutoSaveField
      name="displayName"
      schema={schema}
      initialValue={user.displayName}
      mutationOptions={{
        mutationFn: data => {
          return fetchMutation({
            url: '/user/',
            method: 'PUT',
            data,
          });
        },
        onSuccess: data => {
          // Update React Query cache
          queryClient.setQueryData(['user'], old => ({...old, ...data}));
        },
      }}
    >
      {field => (
        <field.Layout.Row label="Display Name">
          <field.Input value={field.state.value} onChange={field.handleChange} />
        </field.Layout.Row>
      )}
    </AutoSaveField>
  );
}
tsx
import {z} from 'zod';

import {AutoSaveField} from '@sentry/scraps/form';

import {fetchMutation} from 'sentry/utils/queryClient';

const schema = z.object({
  displayName: z.string().min(1, 'Display name is required'),
});

function SettingsForm() {
  return (
    <AutoSaveField
      name="displayName"
      schema={schema}
      initialValue={user.displayName}
      mutationOptions={{
        mutationFn: data => {
          return fetchMutation({
            url: '/user/',
            method: 'PUT',
            data,
          });
        },
        onSuccess: data => {
          // 更新React Query缓存
          queryClient.setQueryData(['user'], old => ({...old, ...data}));
        },
      }}
    >
      {field => (
        <field.Layout.Row label="Display Name">
          <field.Input value={field.state.value} onChange={field.handleChange} />
        </field.Layout.Row>
      )}
    </AutoSaveField>
  );
}

Auto-Save Behavior by Field Type

按字段类型划分的自动保存行为

Field TypeWhen it saves
Input, TextAreaOn blur (when user leaves field)
Select (single)Immediately when selection changes
Select (multiple)When menu closes, or when X/clear clicked while menu closed
SwitchImmediately when toggled
RangeWhen user releases the slider, or immediately with keyboard
字段类型保存时机
Input、TextArea失去焦点时(用户离开字段)
Select(单选)选择项变更时立即保存
Select(多选)菜单关闭时,或菜单关闭时点击清除按钮
Switch切换时立即保存
Range用户释放滑块时,或使用键盘操作时立即保存

Auto-Save Status Indicators

自动保存状态指示器

The form system automatically shows:
  • Spinner while saving (pending)
  • Checkmark on success (fades after 2s)
  • Warning icon on validation error (with tooltip)
表单系统会自动显示:
  • 加载中图标:保存进行中(pending状态)
  • 勾选图标:保存成功(2秒后消失)
  • 警告图标:验证错误(带工具提示)

Confirmation Dialogs

确认对话框

For dangerous operations (security settings, permissions), use the
confirm
prop to show a confirmation modal before saving. The
confirm
prop accepts either a string or a function.
tsx
<AutoSaveField
  name="require2FA"
  schema={schema}
  initialValue={false}
  confirm={value =>
    value
      ? 'This will remove all members without 2FA. Continue?'
      : 'Are you sure you want to allow members without 2FA?'
  }
  mutationOptions={{...}}
>
  {field => (
    <field.Layout.Row label="Require Two-Factor Auth">
      <field.Switch checked={field.state.value} onChange={field.handleChange} />
    </field.Layout.Row>
  )}
</AutoSaveField>
Confirm Config Options:
TypeDescription
string
Always show this message before saving
(value) => string | undefined
Function that returns a message based on the new value, or
undefined
to skip confirmation
Note: Confirmation dialogs always focus the Cancel button for safety, preventing accidental confirmation of dangerous operations.
Examples:
tsx
// ✅ Simple string - always confirm
confirm="Are you sure you want to change this setting?"

// ✅ Only confirm when ENABLING (return undefined to skip)
confirm={value => value ? 'Are you sure you want to enable this?' : undefined}

// ✅ Only confirm when DISABLING
confirm={value => !value ? 'Disabling this removes security protection.' : undefined}

// ✅ Different messages for each direction
confirm={value =>
  value
    ? 'Enable 2FA requirement for all members?'
    : 'Allow members without 2FA?'
}

// ✅ For select fields - confirm specific values
confirm={value => value === 'delete' ? 'This will permanently delete all data!' : undefined}

对于危险操作(安全设置、权限变更),使用
confirm
属性在保存前显示确认模态框。
confirm
属性可以接受字符串或函数。
tsx
<AutoSaveField
  name="require2FA"
  schema={schema}
  initialValue={false}
  confirm={value =>
    value
      ? 'This will remove all members without 2FA. Continue?'
      : 'Are you sure you want to allow members without 2FA?'
  }
  mutationOptions={{...}}
>
  {field => (
    <field.Layout.Row label="Require Two-Factor Auth">
      <field.Switch checked={field.state.value} onChange={field.handleChange} />
    </field.Layout.Row>
  )}
</AutoSaveField>
确认配置选项:
类型描述
string
保存前始终显示该消息
(value) => string | undefined
根据新值返回消息的函数,返回
undefined
则跳过确认
注意:确认对话框始终将“取消”按钮设为焦点,防止意外确认危险操作。
示例:
tsx
// ✅ 简单字符串 - 始终确认
confirm="Are you sure you want to change this setting?"

// ✅ 仅在启用时确认(返回undefined则跳过)
confirm={value => value ? 'Are you sure you want to enable this?' : undefined}

// ✅ 仅在禁用时确认
confirm={value => !value ? 'Disabling this removes security protection.' : undefined}

// ✅ 不同操作方向显示不同消息
confirm={value =>
  value
    ? 'Enable 2FA requirement for all members?'
    : 'Allow members without 2FA?'
}

// ✅ 针对选择字段 - 确认特定值
confirm={value => value === 'delete' ? 'This will permanently delete all data!' : undefined}

Form Submission

表单提交

Important: Always use TanStack Query mutations (
useMutation
) for form submissions. This ensures proper loading states, error handling, and cache management.
重要提示:表单提交必须使用TanStack Query mutations(
useMutation
)。这能确保正确的加载状态、错误处理和缓存管理。

Using Mutations

使用Mutations

tsx
import {useMutation} from '@tanstack/react-query';

import {fetchMutation} from 'sentry/utils/queryClient';

function MyForm() {
  const mutation = useMutation({
    mutationFn: (data: FormData) => {
      return fetchMutation({
        url: '/endpoint/',
        method: 'POST',
        data,
      });
    },
    onSuccess: () => {
      // Handle success (e.g., show toast, redirect)
    },
  });

  const form = useScrapsForm({
    ...defaultFormOptions,
    defaultValues: {...},
    validators: {onDynamic: schema},
    onSubmit: ({value}) => {
      return mutation.mutateAsync(value).catch(() => {});
    },
  });

  // ...
}
tsx
import {useMutation} from '@tanstack/react-query';

import {fetchMutation} from 'sentry/utils/queryClient';

function MyForm() {
  const mutation = useMutation({
    mutationFn: (data: FormData) => {
      return fetchMutation({
        url: '/endpoint/',
        method: 'POST',
        data,
      });
    },
    onSuccess: () => {
      // 处理成功逻辑(如显示提示、跳转页面)
    },
  });

  const form = useScrapsForm({
    ...defaultFormOptions,
    defaultValues: {...},
    validators: {onDynamic: schema},
    onSubmit: ({value}) => {
      return mutation.mutateAsync(value).catch(() => {});
    },
  });

  // ...
}

Submit Button

提交按钮

tsx
<Flex gap="md" justify="end">
  <Button onClick={() => form.reset()}>Reset</Button>
  <form.SubmitButton>Save Changes</form.SubmitButton>
</Flex>
The
SubmitButton
automatically:
  • Disables while submission is pending
  • Triggers form validation before submit

tsx
<Flex gap="md" justify="end">
  <Button onClick={() => form.reset()}>Reset</Button>
  <form.SubmitButton>Save Changes</form.SubmitButton>
</Flex>
SubmitButton
会自动:
  • 提交进行中时禁用按钮
  • 提交前触发表单验证

Do's and Don'ts

注意事项

Form System Choice

表单系统选择

tsx
// ❌ Don't use legacy JsonForm for new forms
<JsonForm fields={[{name: 'email', type: 'text'}]} />;

// ✅ Use useScrapsForm with Zod validation
const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {email: ''},
  validators: {onDynamic: schema},
});
tsx
// ❌ 不要使用旧版JsonForm创建新表单
<JsonForm fields={[{name: 'email', type: 'text'}]} />;

// ✅ 使用带Zod验证的useScrapsForm
const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {email: ''},
  validators: {onDynamic: schema},
});

Default Options

默认选项

tsx
// ❌ Don't forget defaultFormOptions
const form = useScrapsForm({
  defaultValues: {name: ''},
});

// ✅ Always spread defaultFormOptions first
const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {name: ''},
});
tsx
// ❌ 不要忘记defaultFormOptions
const form = useScrapsForm({
  defaultValues: {name: ''},
});

// ✅ 务必先展开defaultFormOptions
const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {name: ''},
});

Form Submissions

表单提交

tsx
// ❌ Don't call API directly in onSubmit
onSubmit: async ({value}) => {
  await api.post('/users', value);
};

// ❌ Don't use mutateAsync without .catch() - causes unhandled rejection
onSubmit: ({value}) => {
  return mutation.mutateAsync(value);
};

// ✅ Use mutations with fetchMutation and .catch(() => {})
const mutation = useMutation({
  mutationFn: data => fetchMutation({url: '/users/', method: 'POST', data}),
});

onSubmit: ({value}) => {
  // Return the promise to keep form.isSubmitting working
  // Add .catch(() => {}) to avoid unhandled rejection - error handling
  // is done by TanStack Query (onError callback, mutation.isError state)
  return mutation.mutateAsync(value).catch(() => {});
};
tsx
// ❌ 不要在onSubmit中直接调用API
onSubmit: async ({value}) => {
  await api.post('/users', value);
};

// ❌ 不要在没有.catch()的情况下使用mutateAsync - 会导致未处理的拒绝错误
onSubmit: ({value}) => {
  return mutation.mutateAsync(value);
};

// ✅ 使用带fetchMutation的mutations,并添加.catch(() => {})
const mutation = useMutation({
  mutationFn: data => fetchMutation({url: '/users/', method: 'POST', data}),
});

onSubmit: ({value}) => {
  // 返回Promise以保持form.isSubmitting正常工作
  // 添加.catch(() => {})避免未处理的拒绝错误 - 错误处理由TanStack Query完成(onError回调、mutation.isError状态)
  return mutation.mutateAsync(value).catch(() => {});
};

Field Value Handling

字段值处理

tsx
// ❌ Don't use field.state.value directly when it might be undefined
<field.Input value={field.state.value} />

// ✅ Provide fallback for optional fields
<field.Input value={field.state.value ?? ''} />
tsx
// ❌ 当字段值可能为undefined时,不要直接使用field.state.value
<field.Input value={field.state.value} />

// ✅ 为可选字段提供默认值
<field.Input value={field.state.value ?? ''} />

Validation Messages

验证消息

tsx
// ❌ Don't use generic error messages
z.string().min(1);

// ✅ Provide helpful, specific error messages
z.string().min(1, 'Email address is required');
tsx
// ❌ 不要使用通用错误消息
z.string().min(1);

// ✅ 提供有用的、具体的错误消息
z.string().min(1, 'Email address is required');

Auto-Save Cache Updates

自动保存缓存更新

tsx
// ❌ Don't forget to update the cache after auto-save
mutationOptions={{
  mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
}}

// ✅ Update React Query cache on success
mutationOptions={{
  mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
  onSuccess: (data) => {
    queryClient.setQueryData(['user'], old => ({...old, ...data}));
  },
}}
tsx
// ❌ 自动保存后不要忘记更新缓存
mutationOptions={{
  mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
}}

// ✅ 在onSuccess回调中更新React Query缓存
mutationOptions={{
  mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
  onSuccess: (data) => {
    queryClient.setQueryData(['user'], old => ({...old, ...data}));
  },
}}

Auto-Save Mutation Typing with Mixed-Type Schemas

混合类型Schema的自动保存Mutation类型定义

When using
AutoSaveField
with schemas that have mixed types (e.g., strings and booleans), the mutation options must be typed using the schema-inferred type. Using generic types like
Record<string, unknown>
breaks TanStack Form's ability to narrow field types.
tsx
const preferencesSchema = z.object({
  theme: z.string(),
  language: z.string(),
  notifications: z.boolean(),
});

type Preferences = z.infer<typeof preferencesSchema>;

// ❌ Don't use generic types - breaks field type narrowing
const mutationOptions = mutationOptions({
  mutationFn: (data: Record<string, unknown>) => fetchMutation({...}),
});

// ✅ Use schema-inferred type for proper type narrowing
const mutationOptions = mutationOptions({
  mutationFn: (data: Partial<Preferences>) => fetchMutation({...}),
});
This ensures that when you use
name="theme"
, the field correctly infers
string
type, and
name="notifications"
infers
boolean
type.
当使用包含混合类型(如字符串和布尔值)的Schema的
AutoSaveField
时,mutation选项必须使用Schema推断的类型。使用
Record<string, unknown>
等通用类型会破坏TanStack Form的字段类型收窄能力。
tsx
const preferencesSchema = z.object({
  theme: z.string(),
  language: z.string(),
  notifications: z.boolean(),
});

type Preferences = z.infer<typeof preferencesSchema>;

// ❌ 不要使用通用类型 - 会破坏字段类型收窄
const mutationOptions = mutationOptions({
  mutationFn: (data: Record<string, unknown>) => fetchMutation({...}),
});

// ✅ 使用Schema推断的类型以实现正确的类型收窄
const mutationOptions = mutationOptions({
  mutationFn: (data: Partial<Preferences>) => fetchMutation({...}),
});
这样确保当你使用
name="theme"
时,字段会正确推断为
string
类型,
name="notifications"
会推断为
boolean
类型。

Layout Choice

布局选择

tsx
// ❌ Don't use Row layout when labels are very long
<field.Layout.Row label="Please enter the primary email address for your account">

// ✅ Use Stack layout for long labels
<field.Layout.Stack label="Please enter the primary email address for your account">

tsx
// ❌ 标签很长时不要使用行布局
<field.Layout.Row label="Please enter the primary email address for your account">

// ✅ 标签很长时使用堆叠布局
<field.Layout.Stack label="Please enter the primary email address for your account">

Quick Reference Checklist

快速参考检查清单

When creating a new form:
  • Import from
    @sentry/scraps/form
    and
    zod
  • Define Zod schema with helpful error messages
  • Use
    useScrapsForm
    with
    ...defaultFormOptions
  • Set
    defaultValues
    matching schema shape
  • Set
    validators: {onDynamic: schema}
  • Wrap with
    <form.AppForm>
    and
    <form.FormWrapper>
  • Use
    <form.AppField>
    for each field
  • Choose appropriate layout (Stack or Row)
  • Handle server errors with
    setFieldErrors
  • Add
    <form.SubmitButton>
    for submission
When creating auto-save fields:
  • Use
    <AutoSaveField>
    component
  • Pass
    schema
    for validation
  • Pass
    initialValue
    from current data
  • Configure
    mutationOptions
    with
    mutationFn
  • Update cache in
    onSuccess
    callback

创建新表单时:
  • @sentry/scraps/form
    zod
    导入
  • 定义带有有用错误消息的Zod Schema
  • 使用带有
    ...defaultFormOptions
    useScrapsForm
  • 设置与Schema结构匹配的
    defaultValues
  • 设置
    validators: {onDynamic: schema}
  • 使用
    <form.AppForm>
    <form.FormWrapper>
    包裹
  • 每个字段使用
    <form.AppField>
  • 选择合适的布局(堆叠或行)
  • 使用
    setFieldErrors
    处理服务端错误
  • 添加
    <form.SubmitButton>
    用于提交
创建自动保存字段时:
  • 使用
    <AutoSaveField>
    组件
  • 传入
    schema
    用于验证
  • 从当前数据传入
    initialValue
  • 配置带有
    mutationFn
    mutationOptions
  • onSuccess
    回调中更新缓存

File References

文件参考

FilePurpose
static/app/components/core/form/scrapsForm.tsx
Main form hook
static/app/components/core/form/field/autoSaveField.tsx
Auto-save wrapper
static/app/components/core/form/field/*.tsx
Individual field components
static/app/components/core/form/layout/index.tsx
Layout components
static/app/components/core/form/form.stories.tsx
Usage examples
文件路径用途
static/app/components/core/form/scrapsForm.tsx
主表单Hook
static/app/components/core/form/field/autoSaveField.tsx
自动保存包装组件
static/app/components/core/form/field/*.tsx
各个字段组件
static/app/components/core/form/layout/index.tsx
布局组件
static/app/components/core/form/form.stories.tsx
使用示例