mcpField
G
generate-frontend-forms

generate-frontend-forms

github.com/getsentry开发45.7k86 次安装今天更新
代码仓库

关于 generate-frontend-forms

为 Sentry 开发者提供基于 TanStack React Form 和 Zod 的表单构建指南,强调 schema 验证、无障碍字段集成及自动保存模式。

主要功能

  1. 01使用 useScrapsForm 构建表单
  2. 02通过 Zod 定义验证 schema
  3. 03支持 AutoSaveForm 自动保存
  4. 04集成自定义字段与布局
  5. 05处理字段及服务端错误

适用场景

  1. 01为新功能创建经过验证的表单
  2. 02构建按字段自动保存的设置页
  3. 03将后端验证错误映射到字段
  4. 04添加自定义输入控件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/getsentry/sentry --skill generate-frontend-forms

安装后在 Claude Code 中输入/generate-frontend-forms确认已加载。

详细介绍

表单系统指南

本技能介绍了使用基于 TanStack React Form 和 Zod 验证构建的 Sentry 新表单系统来构建表单的模式。

核心原则

  • 新表单始终使用新表单系统(useScrapsForm、AutoSaveForm)。切勿使用旧版 JsonForm 或基于 Reflux 的系统创建新表单。

  • 所有表单都应基于架构。请勿创建没有架构验证的表单。

导入

所有表单组件都从 @sentry/scraps/form 导出:

import {z} from 'zod';

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

重要:请勿从更深层的路径导入,例如 '@sentry/scraps/form/field'。你只能使用 @sentry/scraps/form 中 index 文件所提供的公共接口。


表单 Hook:useScrapsForm

用于创建表单并处理验证与提交的主要 Hook。

基本用法
import {z} from 'zod';

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

const schema = z.object({
  email: z.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={form}>
      <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.AppForm>
  );
}

重要:始终先展开 defaultFormOptions。它会配置验证,使其最初在提交时运行,并在首次提交后于每次更改时运行。这就是验证器要定义为 onDynamic 的原因,也是实现一致用户体验的方式。

返回的属性
属性说明
AppForm根包装组件(提供表单上下文并渲染 <form> 元素)。必须接收 form={form} 属性。
AppField字段渲染器组件
FieldGroup带标题的分区分组
SubmitButton预先配置好的提交按钮
Subscribe订阅表单状态变化
reset()将表单重置为默认值
handleSubmit()手动触发表单提交

字段组件

已注册的字段通过 field 渲染属性访问,并遵循一致的模式。对于不含输入框的只读信息,请按下文所述使用 DisplayField。

输入字段(文本)
<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>
数字字段
<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>
选择字段(单选)

使用 field.Select 进行表单选项选择。不要使用基础 Menu 组件组合表单选择器;这些组件是供共享控件使用的内部展示原语。

<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>
选择字段(多选)
<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>
开关字段(布尔值)
<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>
复选框组(多项选择)

使用 field.Checkbox.Group 和 field.Checkbox.Item,将多项选择存储为一个字符串数组。请阅读复选框组文档,了解示例、布局、验证、禁用状态和自动保存行为。

文本区域字段
<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>
范围字段(滑块)
<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>
单选按钮字段

单选按钮字段使用可组合的 API,包括 Radio.Group 和 Radio.Item。Radio.Group 提供组上下文,以更改标签的渲染方式,从而遵循正确的无障碍语义。

重要:布局(及其标签)必须渲染在 Radio.Group 内部。组上下文由 Radio.Group 提供,因此将布局放在外部会导致无障碍语义不正确。

<form.AppField name="priority">
  {field => (
    <field.Radio.Group value={field.state.value} onChange={field.handleChange}>
      <field.Layout.Stack label="Priority">
        <field.Radio.Item value="low">Low</field.Radio.Item>
        <field.Radio.Item value="medium">Medium</field.Radio.Item>
        <field.Radio.Item value="high" description="Urgent issues">
          High
        </field.Radio.Item>
      </field.Layout.Stack>
    </field.Radio.Group>
  )}
</form.AppField>

若要水平排列单选项,请在布局内部使用 Flex 或 Stack 包装器:

import {Flex} from '@sentry/scraps/layout';

<field.Radio.Group value={field.state.value} onChange={field.handleChange}>
  <field.Layout.Row label="Priority">
    <Flex gap="lg">
      <field.Radio.Item value="low">Low</field.Radio.Item>
      <field.Radio.Item value="high">High</field.Radio.Item>
    </Flex>
  </field.Layout.Row>
</field.Radio.Group>;
使用 BaseField 创建自定义字段

对于没有内置组件的一次性字段(例如颜色选择器或任何自定义输入框),请使用 field.Base。它提供一个渲染属性,其中包含所有必需的无障碍和表单集成属性(ref、disabled、aria-invalid、aria-describedby、onBlur、name、id),你可以将这些属性展开并传递给原生元素。

<form.AppField name="color">
  {field => (
    <field.Layout.Row label="Brand Color">
      <field.Base<HTMLInputElement>>
        {(baseProps, {indicator}) => (
          <Flex flexGrow={1}>
            <input
              {...baseProps}
              type="color"
              value={field.state.value}
              onChange={e => field.handleChange(e.target.value)}
            />
            {indicator}
          </Flex>
        )}
      </field.Base>
    </field.Layout.Row>
  )}
</form.AppField>

渲染属性会接收两个参数:

  1. baseProps — 要展开并传递给元素的无障碍和表单集成属性(ref、disabled、aria-invalid、aria-describedby、onBlur、name、id)
  2. {indicator} — 自动保存状态指示器(微调器/勾号),以 React 节点的形式提供,你可以根据自定义布局将它放在合适的位置

元素类型会根据传入的 ref 推断;因此,如果你没有传入 ref,就必须手动将其标注为 <field.Base<HTMLInputElement>>。

field.Base 会自动处理以下事项:

  • 合并 refs(用于滚动到哈希目标和转发外部 ref)
  • 自动保存待处理时禁用字段
  • 根据验证状态设置 aria-invalid
  • 通过 aria-describedby 关联提示文本

请使用 field.Base,而不要构建重复实现这些逻辑的自定义包装器。它适用于任何原生 HTML 元素或接受标准属性的第三方组件。


布局

注册字段请使用 field.Layout.Stack 或 field.Layout.Row。这些布局需要字段上下文,并提供标签与输入框的关联。对于仅用于展示的信息,请改用 DisplayField.Stack 或 DisplayField.Row。

Stack 布局(垂直)

标签在上,字段在下。最适合标签较长的表单或移动端布局。

<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 布局(水平)

标签在左侧(约占 50%),字段在右侧。适用于设置页面的紧凑布局。

<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>
紧凑变体

Stack 和 Row 布局都支持 variant="compact" 属性。在紧凑模式下,提示文本会作为工具提示显示在标签上,而不是显示在下方。这样可以节省垂直空间,同时仍能提供提示信息。

// 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>

何时使用紧凑模式:

  • 字段较多且垂直空间有限的设置页面
  • 提示文本仅作补充、并非必不可少的表单
  • 高度受限的仪表板或面板
自定义布局

必要时,你可以创建新布局,也可以完全不使用任何布局。不使用布局时,为了满足无障碍要求,_应当_渲染 field.meta.Label,并可选择渲染 field.meta.HintText。

<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>
布局属性
属性类型描述
labelstring字段标签文本
hintTextstring辅助文本(默认显示在标签下方;紧凑模式下显示为工具提示)
requiredboolean显示必填指示器
variant"compact"将提示文本显示为工具提示,而不是显示在标签下方

仅显示信息

对于日期、状态文本和其他无需输入的信息,请使用 DisplayField.Row 或 DisplayField.Stack。从 @sentry/scraps/form 导入 DisplayField,并直接在 FieldGroup 中或与已注册字段并列渲染:

<DisplayField.Row label={t('Created')}>
  <Text>
    <DateTime date={data.dateCreated} />
  </Text>
</DisplayField.Row>
  • Row 使用与 field.Layout.Row 相同的响应式布局:存在查询容器时,在 md 容器断点以下会堆叠显示。没有查询容器时,则保持在同一行。
  • Stack 始终将值放在术语下方。
  • 两者都接受 label、children、hintText、padding 和 variant="compact"。紧凑模式会将提示放在工具提示中;否则,提示会在行布局中显示于术语下方,在堆叠布局中显示于值之后。
  • 它们会渲染 dl/dt/dd,不包含 <label>、输入关联、验证或错误焦点注册。无需表单或字段上下文。
  • 请显式设置显示值的样式,例如使用 <Text>;DisplayField 不会用 Text 包装其子元素。

不要使用 AppField 或 AutoSaveForm 注册仅用于显示的信息,也不要仅为了布局而将其加入架构或 defaultValues。对于从表单状态派生的值,请使用 form.Subscribe 在渲染期间计算,而不是通过 form.setFieldValue 存储第二个值:

<form.Subscribe selector={state => state.values.provider}>
  {provider => (
    <DisplayField.Stack label={t('Selected provider')}>
      <Text>{provider ?? t('None selected')}</Text>
    </DisplayField.Stack>
  )}
</form.Subscribe>

只读输入仍是真正的输入。对于用户需要从输入中选择或复制的值,请保留 AppField、field.Layout.* 和 field.Input readOnly。在 field.Base 上保留处理表单状态的自定义控件;不要用 DisplayField 替换它们。

字段组

将相关字段按标题分组到各个区块中。

<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 可以是布尔值或字符串。提供字符串时,该字符串会显示为工具提示,解释字段被禁用的原因。

// ❌ 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}
/>

使用 Zod 进行验证

架构定义
import {z} from 'zod';

const userSchema = z.object({
  email: z.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'),
  }),
});
使用 Refine 处理可空字段

当字段初始值为 null(例如,没有初始选项的必填选择字段)时,请在架构中使用 .nullable().refine()。这会使架构的 输入 类型(允许 null)与 输出 类型(不允许 null)有所区别。要正确处理这种情况:

  1. 将 defaultValues 显式声明为 z.input<typeof schema> 类型——这样初始值可以是 null。
  2. 在 onSubmit 中调用 schema.parse(value),将类型从 z.input 收窄为 z.output,在传给变更操作前移除 null。
const schema = z.object({
  provider: z
    .enum(['GitHub', 'LaunchDarkly'])
    .nullable()
    .refine(v => v !== null, 'Provider is required'),
  name: z.string().min(1, 'Name is required'),
});

// z.input allows null for the provider field
const defaultValues: z.input<typeof schema> = {
  provider: null,
  name: '',
};

// z.output<typeof schema> has provider as non-null after refine
type FormOutput = z.output<typeof schema>;

const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues,
  validators: {onDynamic: schema},
  onSubmit: ({value}) => {
    // schema.parse narrows null away — mutation receives z.output
    return mutation.mutateAsync(schema.parse(value)).catch(() => {});
  },
});

重要:不要使用非空断言(value.provider!)或类型转换来规避可空字段。使用 schema.parse() 的方式既类型安全,也会在运行时进行验证。

条件验证

当一个值的有效性取决于另一个值时(例如,匹配密码),请使用 .refine()。不要使用表单级别的 .superRefine() 来要求仅在显示时才必填的字段;请将验证器放在条件字段上。

const schema = z
  .object({
    password: z.string(),
    confirmPassword: z.string(),
  })
  .refine(data => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });
条件字段

使用 form.Subscribe 根据其他字段的值显示或隐藏字段。让表单架构中的条件必填值保持宽松,然后在渲染出的 AppField 上验证,避免隐藏字段阻止表单提交:

const schema = z.object({
  plan: z.enum(['standard', 'enterprise']),
  billingEmail: z.string(),
});

<form.Subscribe selector={state => state.values.plan === 'enterprise'}>
  {showBilling =>
    showBilling ? (
      <form.AppField
        name="billingEmail"
        validators={{
          onDynamic: z.email('Enter a valid billing email'),
        }}
      >
        {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>;

对于可空数字字段,请在表单架构中保留 z.number().nullable(),并使用如下字段验证器:z.number().positive('Amount must be greater than zero').nullable().refine(value => value !== null, 'Amount is required')。当数字输入为空时,refine 会拒绝其产生的 null。


错误处理

服务端错误

使用 setFieldErrors 显示后端验证错误:

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

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

import {fetchMutation} from 'sentry/utils/queryClient';
import {RequestError} from 'sentry/utils/requestError/requestError';
import {requestErrorToFieldErrors} from 'sentry/utils/requestError/requestErrorToFieldErrors';

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) {
        if (error instanceof RequestError) {
          setFieldErrors(formApi, requestErrorToFieldErrors(error, formApi.state.values));
        }
      }
    },
  });

  // ...
}

setFieldErrors 接受 Scraps 的 FieldErrors 契约,不接受 Sentry 的 RequestError。在应用边界使用 requestErrorToFieldErrors 来:

  • 在调用位置缩小未知错误的类型后,接收 RequestError;
  • 仅保留表单值中存在的键;
  • 将字符串和数组响应值转换为 {message: string}。

不要直接将 API 错误传给 Scraps:

// ❌ RequestError is an app type, not a Scraps form error
setFieldErrors(formApi, error);

// ✅ Narrow at the app call site, then convert to the Scraps contract
if (error instanceof RequestError) {
  setFieldErrors(formApi, requestErrorToFieldErrors(error, formApi.state.values));
}

在表单代码中创建字段消息时,请使用直接对象:

setFieldErrors(formApi, {
  email: {message: 'This email is already registered'},
  username: {message: 'Username is taken'},
});

重要:setFieldErrors 支持使用点号表示法指定嵌套路径:'address.city': {message: 'City not found'}

错误显示

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


自动保存模式

对于每个字段单独保存的设置页面,请使用 AutoSaveForm。

基本自动保存表单
import {z} from 'zod';

import {AutoSaveForm} 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 (
    <AutoSaveForm
      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>
      )}
    </AutoSaveForm>
  );
}
不同字段类型的自动保存行为
字段类型保存时机
Input、TextArea失去焦点时(用户离开字段时)
Select(单选)选择更改后立即保存
Select(多选)菜单关闭时,或菜单关闭状态下点击 X/清除时
Switch切换后立即保存
Radio选择更改后立即保存
Range用户松开滑块时,或通过键盘操作时立即保存
自动保存状态指示器

表单系统会自动显示:

  • 保存期间显示加载指示器(待处理)
  • 成功后显示勾选标记(2 秒后淡出)
  • 验证错误时显示警告图标(带工具提示)

重要:不要使用 Toast 提示自动保存状态。内置的行内指示器(加载指示器、勾选标记、警告图标)才是正确的反馈机制。对于频繁随更改而保存的字段,Toast 提示会造成干扰。

自动保存请求错误

AutoSaveForm 从表单错误上下文获取特定于应用的错误映射。Sentry 通过 ScrapsProviders 安装此映射一次即可。不要在每个 AutoSaveForm 调用位置捕获 RequestError,也不要调用 requestErrorToFieldErrors。

Sentry 提供程序:

  • 将失败的变更请求的类型缩小为 RequestError;
  • 使用 requestErrorToFieldErrors 匹配后端字段错误;
  • 使用 getRequestErrorUserMessage 获取请求详情和状态消息;
  • 对其他错误使用 Failed to save 作为回退消息。

Scraps 仍独立于 Sentry 的 API 错误类型。在 Sentry 应用之外,除非宿主应用提供自己的映射器,否则表单错误上下文会使用通用回退消息。

确认对话框

对于危险操作(安全设置、权限),请使用 confirm 属性,在保存前显示确认模态框。confirm 属性接受字符串或函数。

<AutoSaveForm
  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>
  )}
</AutoSaveForm>

确认配置选项:

类型描述
string保存前始终显示此消息
(value) => string | undefined根据新值返回消息的函数;返回 undefined 则跳过确认

注意:确认对话框始终将焦点放在“取消”按钮上,以确保安全,防止意外确认危险操作。

示例:

// ✅ 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}

表单提交

重要:表单提交时务必使用 TanStack Query mutations(useMutation)。这能确保正确处理加载状态、错误和缓存。

使用 Mutations
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(() => {});
    },
  });

  // ...
}
保存后重置

表单提交后仍留在页面上时(例如设置页面),请在 mutation 成功后调用 form.reset()。这会将表单与更新后的 defaultValues 重新同步,使其恢复为未修改状态——任何依赖表单是否已修改的 UI(例如条件显示的“保存”/“取消”按钮)都能正确更新。

onSubmit: ({value}) =>
  mutation
    .mutateAsync(value)
    .then(() => form.reset())
    .catch(() => {}),

注意:AutoSaveForm 会自动处理此操作。只有使用 useScrapsForm 时才需要添加这一步。

提交按钮
<Flex gap="md" justify="end">
  <form.ResetButton>Reset</form.ResetButton>
  <form.SubmitButton>Save Changes</form.SubmitButton>
</Flex>

SubmitButton 会自动:

  • 在提交待处理时禁用
  • 在提交前触发表单验证

应做事项与禁忌

表单系统选择
// ❌ 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},
});
默认选项
// ❌ Don't forget defaultFormOptions
const form = useScrapsForm({
  defaultValues: {name: ''},
});

// ✅ Always spread defaultFormOptions first
const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {name: ''},
});
可空默认值
// ❌ Don't use non-null assertions or type casts
onSubmit: ({value}) => {
  return mutation.mutateAsync({...value, provider: value.provider!});
};

// ❌ Don't skip typing defaultValues when the schema has refine
const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {provider: null, name: ''}, // type is inferred but imprecise
});

// ✅ Use z.input for defaultValues and schema.parse in onSubmit
const defaultValues: z.input<typeof schema> = {provider: null, name: ''};

const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues,
  validators: {onDynamic: schema},
  onSubmit: ({value}) => {
    return mutation.mutateAsync(schema.parse(value)).catch(() => {});
  },
});
表单提交
// ❌ 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)
  // Add .then(() => form.reset()) if the form stays on the page after save
  return mutation
    .mutateAsync(value)
    .then(() => form.reset())
    .catch(() => {});
};
字段值处理
// ❌ 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 ?? ''} />
验证消息
// ❌ Don't use generic error messages
z.string().min(1);

// ✅ Provide helpful, specific error messages
z.string().min(1, 'Email address is required');
自动保存反馈
// ❌ Don't use toasts for auto-save status
mutationOptions={{
  mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
  onSuccess: () => {
    addSuccessMessage('Saved!'); // ❌ noisy and disruptive
  },
}}

// ✅ Rely on built-in inline indicators (spinner, checkmark, warning icon)
mutationOptions={{
  mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}),
  onSuccess: (data) => {
    queryClient.setQueryData(['user'], old => ({...old, ...data}));
    // No toast needed - AutoSaveForm shows a checkmark automatically
  },
}}
自动保存缓存更新

务必在 onSuccess 中更新数据存储或缓存。否则,将字段切换回原始值时不会触发保存——TanStack Form 会与 defaultValues(根据 initialValue 推导)进行比较,并在值匹配时跳过提交。

// ❌ 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}));
  },
}}
自动保存 Mutation 类型

mutationFn 应使用 API 的数据类型,而不是 Zod schema 类型。schema 用于客户端字段验证,而 mutation 应接受 API 端点接受的任何类型。也不要使用 Record<string, unknown> 之类的泛型,因为这会破坏 TanStack Form 缩窄字段类型的能力。

绝不要向 mutationOptions、useMutation 或任何 TanStack Query 函数传入调用位置泛型。 必须推导类型,而不是断言类型。完整规则请参阅 static/AGENTS.md 中的 “TanStack Query Type Inference”。

// ❌ NEVER pass generics to mutationOptions/useMutation
mutationOptions<unknown, RequestError, Variables, Context>({...})
useMutation<Response, RequestError, Variables>({...})

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

// ❌ Don't tie mutation type to the zod schema
const opts = mutationOptions({
  mutationFn: (data: Partial<z.infer<typeof preferencesSchema>>) => fetchMutation({...}),
});

// ❌ Don't explicitly type context — it's inferred from onMutate return
type MyContext = {previousData: UserDetails};

// ❌ Don't use RequestError as the error generic — use runtime narrowing instead

// ✅ Use the API's data type on mutationFn, let everything else be inferred
const opts = mutationOptions({
  mutationFn: (data: Partial<UserDetails>) =>
    fetchMutation<UserDetails>({...}),
});

确保 Zod schema 的类型与 API 类型兼容。例如,如果 API 需要 'off' | 'low' | 'high' 这样的字符串联合类型,请使用 z.enum(['off', 'low', 'high']),而不是 z.string()。

保存后重置表单

TanStack Form 中的 defaultValues 是响应式的。当表单挂载后查询数据到达时,通过 defaultValues 传入该数据即可更新尚未修改的表单。不要添加仅为填充异步查询结果而调用 form.reset() 的 effect。表单仍处于挂载状态且已修改时,成功保存后重置仍然有用。

// ❌ Don't forget to reset forms that stay on the page after save
onSubmit: ({value}) => {
  return mutation.mutateAsync(value).catch(() => {});
};

// ✅ Call form.reset() after successful save to sync with updated defaultValues
onSubmit: ({value}) => {
  return mutation
    .mutateAsync(value)
    .then(() => form.reset())
    .catch(() => {});
};
布局选择
// ❌ 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">

快速参考清单

创建新表单时:

  • 从 @sentry/scraps/form 和 zod 导入
  • 定义带有明确错误消息的 Zod schema
  • 使用 useScrapsForm 和 ...defaultFormOptions
  • 设置与 schema 结构匹配的 defaultValues(如果 schema 使用 .refine(),请使用 z.input<typeof schema>)
  • 设置 validators: {onDynamic: schema}
  • 使用 <form.AppForm form={form}> 包裹
  • 每个已注册的输入项都使用 <form.AppField>,包括只读输入项
  • 仅用于显示的信息使用 DisplayField.Row 或 DisplayField.Stack;不要将其纳入表单状态
  • 选择合适的布局(Stack 或 Row)
  • 在调用位置将未知错误缩窄为 RequestError,使用 requestErrorToFieldErrors 转换,然后调用 setFieldErrors
  • 添加 <form.SubmitButton> 以提交表单
  • 如果表单提交后仍留在页面上,请在 mutation 成功后调用 form.reset()

创建自动保存字段时:

  • 使用 <AutoSaveForm> 组件
  • 传入用于验证的 schema
  • 传入当前数据中的 initialValue
  • 使用 mutationFn 配置 mutationOptions
  • 在 onSuccess 回调中更新缓存
  • 让 Sentry 表单错误提供程序处理常见请求错误

File References

FilePurpose
static/app/components/core/form/scrapsForm.tsxMain form hook
static/app/components/core/form/autoSaveForm.tsxAuto-save wrapper
static/app/components/core/form/formErrorContext.tsxHost error-mapper contract
static/app/scrapsProviders/formError.tsxSentry request-error mapper
static/app/components/core/form/field/*.tsxIndividual field components
static/app/components/core/form/layout/index.tsxLayout components
static/app/components/core/form/displayField.tsxDisplay-only layouts
static/app/components/core/form/form.stories.tsxUsage examples

评论

支持 Markdown