mcpField
M
migrate-frontend-forms

migrate-frontend-forms

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

关于 migrate-frontend-forms

为 Sentry 开发者提供将 JsonForm、FormModel 等旧表单迁移到基于 TanStack 的新系统的指南,涵盖功能映射、错误适配和保存行为调整。

主要功能

  1. 01映射旧表单配置到新 API
  2. 02迁移自动保存与提交行为
  3. 03转换服务端字段错误
  4. 04调整成功与失败回调
  5. 05处理仅展示内容和表单重置

适用场景

  1. 01将 JsonForm 迁移到新表单系统
  2. 02替换旧的按焦点保存逻辑
  3. 03迁移后端错误映射机制
  4. 04保留保存提示及重置行为

安装

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

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

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

详细介绍

表单迁移指南

此技能可帮助将表单从 Sentry 的旧版表单系统(JsonForm、FormModel)迁移到基于 TanStack 的新系统。

功能映射

旧系统新系统备注
saveOnBlur: trueAutoSaveForm默认行为
confirmconfirm propstring | ((value) => string | undefined)
showHelpInTooltipvariant="compact"用于布局组件
disabledReasondisabled="reason"字符串会显示工具提示
extraHelp布局中的 JSX在字段下方渲染 <Text>
getDatamutationFn在 mutation 函数中转换数据
mapFormErrors请求错误适配器普通表单需显式提供;自动保存表单会提供
saveMessageonSuccess在 mutation 的 onSuccess 回调中显示 toast
formatMessageValueonSuccess在 onSuccess 回调中控制 toast 内容
resetOnErroronError在 mutation 的 onError 中调用 form.reset()
saveOnBlur: falseuseScrapsForm使用带有显式保存按钮的普通表单
(自动)form.reset()若表单在成功 mutation 后仍留在页面上,则调用
helphintText用于布局组件
labellabel用于布局组件
requiredrequired用于布局组件和 Zod schema
仅用于显示的内容DisplayField.Row / DisplayField.Stack不注册输入项,也不包含在表单状态中

功能详情

confirm → confirm prop

旧版:

{
  name: 'require2FA',
  type: 'boolean',
  confirm: {
    true: 'Enable 2FA for all members?',
    false: 'Allow members without 2FA?',
  },
  isDangerous: true,
}

新版:

<AutoSaveForm
  name="require2FA"
  confirm={value =>
    value
      ? 'Enable 2FA for all members?'
      : 'Allow members without 2FA?'
  }
  {...}
>
showHelpInTooltip → variant="compact"

旧版:

{
  name: 'field',
  help: 'This is help text',
  showHelpInTooltip: true,
}

新版:

<field.Layout.Row
  label="Field"
  hintText="This is help text"
  variant="compact"
>
disabledReason → disabled="reason"

旧版:

{
  name: 'field',
  disabled: true,
  disabledReason: 'Requires Business plan',
}

新版:

<field.Input
  disabled="Requires Business plan"
  {...}
/>
extraHelp → JSX

旧版:

{
  name: 'sensitiveFields',
  help: 'Main help text',
  extraHelp: 'Note: These fields apply org-wide',
}

新版:

<field.Layout.Stack label="Sensitive Fields" hintText="Main help text">
  <field.TextArea {...} />
  <Text size="sm" variant="muted">
    Note: These fields apply org-wide
  </Text>
</field.Layout.Stack>
getData → mutationFn

getData 函数会在将字段数据发送到 API 前对其进行转换。在新系统中,请在 mutationFn 中处理此操作。

旧版:

// Wrap field value in 'options' key
{
  name: 'sentry:csp_ignored_sources_defaults',
  type: 'boolean',
  getData: data => ({options: data}),
}

// Or extract/transform specific fields
{
  name: 'slug',
  getData: (data: {slug?: string}) => ({slug: data.slug}),
}

新版:

<AutoSaveForm
  name="sentry:csp_ignored_sources_defaults"
  schema={schema}
  initialValue={project.options['sentry:csp_ignored_sources_defaults']}
  mutationOptions={{
    mutationFn: data => {
      // Transform data before API call (equivalent to getData)
      const transformed = {options: data};
      return fetchMutation({
        url: `/projects/${organization.slug}/${project.slug}/`,
        method: 'PUT',
        data: transformed,
      });
    },
  }}
>
  {field => (
    <field.Layout.Row label="Use default ignored sources">
      <field.Switch checked={field.state.value} onChange={field.handleChange} />
    </field.Layout.Row>
  )}
</AutoSaveForm>

更简单的写法 - 如果你只需要包装值:

mutationOptions={{
  mutationFn: fieldData => {
    return fetchMutation({
      url: `/projects/${org}/${project}/`,
      method: 'PUT',
      data: {options: fieldData}, // getData equivalent
    });
  },
}}

重要:正确为 mutation 添加类型

mutationFn 应使用 API 的数据类型(例如 Partial<Organization>、Partial<Project>)声明类型,而不是使用 schema 推断出的类型。schema 仅用于客户端字段验证——mutation 接收的是 API 端点接受的任何数据。让 mutation 依赖 schema 会将两个无关的关注点耦合起来,并可能在 schema 类型与 API 类型不完全匹配时导致类型错误。

// ❌ Don't use generic types - breaks field type narrowing
mutationOptions={{
  mutationFn: (data: Record<string, unknown>) => {
    return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
  },
}}

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

// ✅ Use the API's data type
mutationOptions={{
  mutationFn: (data: Partial<UserDetails>) => {
    return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
  },
}}

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

绝不要向 useMutation、mutationOptions 或任何 TanStack Query 函数传入调用处的泛型。 这适用于所有泛型——数据、错误、变量和上下文。必须推断类型,而不是断言类型。完整规则请参阅 static/AGENTS.md 中的“TanStack Query Type Inference”。

// ❌ Generics on useMutation — NEVER do this
const mutation = useMutation<CodeOwner, RequestError, [Payload]>({
  mutationFn: ([payload]) => fetchMutation({url, method: 'POST', data: payload}),
});

// ❌ Generics on mutationOptions — NEVER do this either
mutationOptions<unknown, RequestError, Variables, MyContext>({...})

// ❌ Explicit context type — inferred from onMutate return
type MyContext = {changeId: string};

// ❌ RequestError as error generic — it's a type assertion in disguise
// Other things can go wrong that would NOT yield a RequestError

// ✅ Type the mutationFn payload; fetchMutation<T> carries the return type
const mutation = useMutation({
  mutationFn: (payload: {codeMappingId: string; raw: string}) =>
    fetchMutation<CodeOwner>({
      url: `/projects/${org}/${project}/codeowners/`,
      method: 'POST',
      data: payload,
    }),
});

// ✅ Context is inferred from onMutate, error is Error by default
mutationOptions({
  mutationFn: (variables: MyVars) => fetchMutation<MyResponse>({...}),
  onMutate: async () => {
    return {changeId: uniqueId()};  // context type inferred from this
  },
  onError: (_error, _vars, context) => {
    // context?.changeId is typed automatically
    // _error is Error — use runtime narrowing for RequestError
  },
})
mapFormErrors → requestErrorToFieldErrors + setFieldErrors

mapFormErrors 函数用于将 API 错误响应转换为字段级错误。在新系统中,使用 requestErrorToFieldErrors 转换 Sentry API 错误,然后将 Scraps 的 FieldErrors 结果传递给 setFieldErrors。

不要将 RequestError 直接传递给 setFieldErrors。Scraps 不依赖于 Sentry 的 API 客户端类型。

旧:

// Form-level error transformer
function mapMonitorFormErrors(responseJson?: any) {
  if (responseJson.config === undefined) {
    return responseJson;
  }
  // Flatten nested config errors to dot notation
  const {config, ...rest} = responseJson;
  const configErrors = Object.fromEntries(
    Object.entries(config).map(([key, value]) => [`config.${key}`, value])
  );
  return {...rest, ...configErrors};
}

<Form mapFormErrors={mapMonitorFormErrors} {...}>

新:

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

import {RequestError} from 'sentry/utils/requestError/requestError';

const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {...},
  validators: {onDynamic: schema},
  onSubmit: async ({value, formApi}) => {
    try {
      await mutation.mutateAsync(value);
    } catch (error) {
      if (!(error instanceof RequestError)) {
        return;
      }

      // Keep custom mapping only when the legacy form reshaped the response.
      const responseJson = error.responseJSON;
      if (responseJson?.config) {
        // Flatten nested errors to dot notation
        const {config, ...rest} = responseJson;
        const errors: Record<string, {message: string}> = {};

        for (const [key, value] of Object.entries(rest)) {
          errors[key] = {message: Array.isArray(value) ? value[0] : String(value)};
        }
        for (const [key, value] of Object.entries(config)) {
          errors[`config.${key}`] = {message: Array.isArray(value) ? value[0] : String(value)};
        }

        setFieldErrors(formApi, errors);
      }
    }
  },
});

更简单的模式 - 适用于扁平错误响应:

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

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

onSubmit: async ({value, formApi}) => {
  try {
    await mutation.mutateAsync(value);
  } catch (error) {
    if (!(error instanceof RequestError)) {
      addErrorMessage(t('Unable to save changes.'));
      return;
    }

    const handled = setFieldErrors(
      formApi,
      requestErrorToFieldErrors(error, formApi.state.values)
    );

    if (!handled) {
      addErrorMessage(t('Unable to save changes.'));
    }
  }
},

requestErrorToFieldErrors 接受 RequestError。在 Sentry 调用点处对未知错误进行收窄后再转换。适配器会根据 formApi.state.values 过滤响应键,并返回 Scraps 的字段错误结构。仅当迁移需要自定义响应重构时(例如上面的嵌套 config 示例),才使用直接的 FieldErrors 对象。

对于 AutoSaveForm,标准请求错误处理是自动的。Sentry 表单错误提供程序使用 requestErrorToFieldErrors 处理字段错误,使用 getRequestErrorUserMessage 处理请求详情或状态消息。不要为每个自动保存字段添加常规表单的 catch 块。

注意:setFieldErrors 支持使用点号表示法的嵌套路径:'config.schedule': {message: 'Invalid schedule'}

saveMessage → onSuccess

saveMessage 在保存成功后显示自定义 toast/alert。在新系统中,请在 mutation 的 onSuccess 回调中处理此逻辑。

旧:

{
  name: 'fingerprintingRules',
  saveOnBlur: false,
  saveMessageAlertVariant: 'info',
  saveMessage: t('Changing fingerprint rules will apply to future events only.'),
}

新:

import {addSuccessMessage} from 'sentry/actionCreators/indicator';

<AutoSaveForm
  name="fingerprintingRules"
  schema={schema}
  initialValue={project.fingerprintingRules}
  mutationOptions={{
    mutationFn: data => fetchMutation({...}),
    onSuccess: () => {
      // Custom success message (equivalent to saveMessage)
      addSuccessMessage(t('Changing fingerprint rules will apply to future events only.'));
    },
  }}
>
formatMessageValue → onSuccess

formatMessageValue 控制更改后的值在成功 toast 中的显示方式。将其设置为 false 会完全禁止显示该值(适用于大型文本字段)。在新系统中,你可以在 onSuccess 中直接控制这一点。

旧:

{
  name: 'fingerprintingRules',
  saveMessage: t('Rules updated'),
  formatMessageValue: false, // Don't show the (potentially huge) value in toast
}

新:

mutationOptions={{
  mutationFn: data => fetchMutation({...}),
  onSuccess: () => {
    // Just show the message, no value (equivalent to formatMessageValue: false)
    addSuccessMessage(t('Rules updated'));
  },
}}

// Or if you want to show a formatted value:
onSuccess: (data) => {
  addSuccessMessage(t('Slug changed to %s', data.slug));
},
resetOnError → onError

resetOnError 选项在保存失败时将字段恢复为之前的值。在新系统中,请在 mutation 的 onError 回调中调用 form.reset()。

旧:

// Form-level reset on error
<Form resetOnError apiEndpoint="/auth/" {...}>

// Or field-level (BooleanField always resets on error)
<FormField resetOnError name="enabled" {...}>

新(使用 useScrapsForm):

const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues: {password: ''},
  validators: {onDynamic: schema},
  onSubmit: async ({value}) => {
    try {
      await mutation.mutateAsync(value);
    } catch (error) {
      // Reset form to previous values on error (equivalent to resetOnError)
      form.reset();
      throw error; // Re-throw if you want error handling to continue
    }
  },
});

新(使用 AutoSaveForm):

<AutoSaveForm
  name="enabled"
  schema={schema}
  initialValue={settings.enabled}
  mutationOptions={{
    mutationFn: data => fetchMutation({...}),
    onError: () => {
      // The field automatically shows error state via TanStack Query
      // If you need to reset the value, you can pass a reset callback
    },
  }}
>

注意:配合 TanStack Query 的 AutoSaveForm 已经优雅地处理了错误状态 - mutation 的 isError 状态会反映在 UI 中。手动重置通常仅在特定 UX 需求(如密码字段)时才需要。

保存后重置

当使用 useScrapsForm 处理保存后仍停留在页面上的表单时,请在 mutation 成功后调用 form.reset()。这会将表单与更新后的 defaultValues 重新同步,使其再次变为 pristine 状态 — 任何依赖于表单 dirty 状态的 UI(如条件显示的 Save/Cancel 按钮)将正确更新。

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

注意:AutoSaveForm 会自动处理此逻辑。只有在使用 useScrapsForm 时才需要添加此代码。

saveOnBlur: false → useScrapsForm

设置了 saveOnBlur: false 的字段会显示带有 Save/Cancel 按钮的内联 alert,而不是自动保存。这用于危险操作(slug 变更)或大型文本编辑(fingerprint rules)。

在新系统中,使用带有 useScrapsForm 的常规表单和显式的 Save 按钮。这保留了在提交之前显示警告的 UX。

旧:

{
  name: 'slug',
  type: 'string',
  saveOnBlur: false,
  saveMessageAlertVariant: 'warning',
  saveMessage: t("Changing a project's slug can break your build scripts!"),
}

新:

import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';

const slugSchema = z.object({
  slug: z.string().min(1, 'Slug is required'),
});

function SlugForm({project}: {project: Project}) {
  const mutation = useMutation({
    mutationFn: (data: {slug: string}) =>
      fetchMutation({url: `/projects/${org}/${project.slug}/`, method: 'PUT', data}),
  });

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

  return (
    <form.AppForm form={form}>
      <form.AppField name="slug">
        {field => (
          <field.Layout.Stack label="Project Slug">
            <field.Input value={field.state.value} onChange={field.handleChange} />
          </field.Layout.Stack>
        )}
      </form.AppField>

      {/* Warning shown before saving (equivalent to saveMessage) */}
      <Alert variant="warning">
        {t("Changing a project's slug can break your build scripts!")}
      </Alert>

      <Flex gap="sm" justify="end">
        <form.ResetButton>Reset</form.ResetButton>
        <form.SubmitButton>Save</form.SubmitButton>
      </Flex>
    </form.AppForm>
  );
}

何时使用此模式:

  • 用户应在提交前看到警告的危险操作(slug 变更、安全令牌)
  • 希望在保存前完成编辑的大型多行文本字段(fingerprint rules、filters)
  • 任何自动保存不合理的字段

通过表单提交,而不是绕过它。 遵循上面的 SlugForm 模式 — mutation 在 onSubmit 中运行,Save 按钮为 <form.SubmitButton>。不要在没有 onSubmit 的情况下渲染 <form.AppForm> 并通过独立的 <Button onClick> 触发 mutation:

// ❌ Form is never submitted; mutation fires from a separate button
const form = useScrapsForm({
  ...defaultFormOptions,
  defaultValues,
  validators: {onDynamic: schema},
  // no onSubmit
});

return (
  <form.AppForm form={form}>
    <form.AppField name="codeMappingId">{...}</form.AppField>
    <Button onClick={() => mutation.mutate(...)}>Save</Button>
  </form.AppForm>
);

一个从未真正提交的表单会绕过验证、pending/disabled 状态和字段错误接线。

仅用于展示的信息

对于日期、状态文本或其他不含输入项的内容,请使用 @sentry/scraps/form 中的 DisplayField.Row 或 DisplayField.Stack。不要仅仅为了实现布局而创建 AppField 或 AutoSaveForm:

<DisplayField.Row label={t('Created')}>
  <Text>
    <DateTime date={data.dateCreated} />
  </Text>
</DisplayField.Row>

移除仅用于展示这些内容的 schema 条目和默认值。必要时使用 <Text> 保留文本样式。使用现有的 form.Subscribe 在渲染期间计算派生的展示值;不要使用 form.setFieldValue 将这些值复制到表单状态中。

Row 与响应式字段布局一致;Stack 则始终采用堆叠布局。这些组件使用描述列表语义,不包含 <label>、字段上下文或错误聚焦注册。将实际的只读输入保留在 AppField 内的 field.Input readOnly 和 field.Layout.* 中,以便保留输入关联。处理表单状态的自定义控件仍需要 field.Base。

有关布局属性和订阅示例,请参阅表单生成指南。

保留表单搜索功能

Sentry 的 SettingsSearch 允许用户搜索单个设置字段。迁移表单时,必须通过使用 FormSearch 包裹迁移后的表单来保留此搜索功能。

FormSearch 组件

FormSearch 是一个构建时标记组件——它没有任何运行时行为,只会原样渲染其子项。静态提取脚本会读取它的 route 属性,将表单字段与其导航路由关联起来,使这些字段能够出现在 SettingsSearch 搜索结果中。

import {FormSearch} from 'sentry/components/core/form';

<FormSearch route="/settings/account/details/">
  <FieldGroup title={t('Account Details')}>
    <AutoSaveForm name="name" schema={schema} initialValue={user.name} mutationOptions={...}>
      {field => (
        <field.Layout.Row label={t('Name')} hintText={t('Your full name')} required>
          <field.Input />
        </field.Layout.Row>
      )}
    </AutoSaveForm>
  </FieldGroup>
</FormSearch>

属性:

属性类型描述
routestring此表单对应的设置路由(例如 '/settings/account/details/')。用于搜索导航。
childrenReactNode表单内容——运行时原样渲染。

规则:

  • route 必须与设置页面 URL 完全一致(包括末尾的斜杠)。
  • 使用单个 FormSearch 包裹整个表单区块,而不是单个字段。
  • FormSearch 内的每个 <AutoSaveForm> 或 <form.AppField> 都会被索引。请确保 label 和 hintText 是纯字符串字面量或 t() 调用——提取器会跳过计算得出或动态生成的字符串。
表单字段注册表

添加或更新 FormSearch 包装器后,请重新生成字段注册表,以便搜索结果保持最新:

pnpm run extract-form-fields

此脚本(./scripts/extractFormFields.ts)会扫描所有 TSX 文件,查找 <FormSearch> 组件,提取字段元数据(name、label、hintText、route),并将生成的注册表写入 static/app/views/settings/fieldRegistry.generated.ts。请将此生成文件与迁移 PR 一并提交——它是源码树的一部分。

在对 FormSearch 包装器内的表单进行任何更改后(添加、移除、更改标签),都请运行此命令。生成的文件会纳入版本控制,不应手动编辑。

迁移:已可搜索的旧表单

如果要迁移的旧版 JsonForm 已被 SettingsSearch 索引(即它在 sentry/data/forms 中有条目),则必须为新表单添加 FormSearch 包装器,以保留搜索功能。新旧来源会并存——对于相同的路由与字段组合,新的注册表条目优先于旧条目;但移除旧表单后,旧条目也会随之消失。

处理可空的初始值

旧版选择字段通常以空值/未定义值开头,并要求用户进行选择。字段始终为必填项时,请在 schema 中使用 .nullable().refine(),使用 z.input<typeof schema> 为 defaultValues 指定类型,并在 onSubmit 中调用 schema.parse(value)。

旧版:

{
  name: 'provider',
  type: 'select',
  required: true,
  choices: [['github', 'GitHub'], ['launchdarkly', 'LaunchDarkly']],
}

新版:

const schema = z.object({
  provider: z
    .enum(['github', 'launchdarkly'])
    .nullable()
    .refine(v => v !== null, 'Provider is required'),
});

// z.input accepts null; z.output (after refine) does not
const defaultValues: z.input<typeof schema> = {
  provider: null,
};

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

只要必填字段没有有意义的初始值,就需要采用此模式。区分 z.input 和 z.output,可确保表单接受 null 作为默认值,同时 mutation 接收经过验证的非空类型。

条件必填字段

迁移仅在其他值使字段可见时才必填的旧字段时,请保持表单级 schema 的宽松性,并在渲染出来的 AppField 上进行验证。**请勿使用表单级 .superRefine() 来处理此类条件必填要求:**即使字段处于隐藏状态,表单级验证仍会运行,因此即使用户看不到或无法修正该字段,必填错误也可能阻止提交。

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.string().trim().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>;

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

有意不迁移的功能

功能用法原因
allowUndo3 个表单Toast 中的撤销功能会增加复杂性,收益却很有限。请改用简单的错误 Toast。

迁移检查清单

  • 将 JsonForm/FormModel 替换为 useScrapsForm 或 AutoSaveForm
  • useMutation 不使用泛型——为 mutationFn 的 payload 指定类型,并使用 fetchMutation<T> 指定返回类型
  • 使用带 Save 按钮的 useScrapsForm 时:mutation 在 onSubmit 中运行,由 <form.SubmitButton> 触发(不要使用从未提交的表单)
  • 将输入字段配置对象转换为 JSX AppField 组件
  • 将仅用于展示的内容转换为 DisplayField.Row 或 DisplayField.Stack,不添加 schema 或表单状态条目
  • 使用宽松的表单 schema,并在渲染出来的 AppField 上验证条件必填字段
  • 将布局中的 help 替换为 hintText
  • 将 showHelpInTooltip 替换为 variant="compact"
  • 将 disabledReason 替换为 disabled="reason string"
  • 将 extraHelp 替换为布局中的额外 JSX
  • 将 confirm 对象转换为函数:(value) => message | undefined
  • 在 mutationFn 中处理 getData
  • 对于常规表单,在调用 requestErrorToFieldErrors 之前,将未知错误收窄为 RequestError,然后调用 setFieldErrors
  • 对于 AutoSaveForm,使用应用提供的请求错误处理
  • 仅在 mapFormErrors 重塑了 API 响应时保留自定义错误映射
  • 在 onSuccess 回调中处理 saveMessage
  • 将 saveOnBlur: false 字段转换为带 Save 按钮的常规表单
  • mutation 成功后调用 form.reset()(适用于留在当前页面的表单)
  • 确认 onSuccess 的缓存更新会与现有数据合并(使用 updater 函数)——某些 API 端点可能只返回部分对象
  • 如果旧表单可在 SettingsSearch 中搜索,请使用 <FormSearch route="..."> 包装迁移后的表单
  • 运行 pnpm run extract-form-fields 并提交更新后的 fieldRegistry.generated.ts

评论

支持 Markdown