mcpField
M
migrate-frontend-forms

migrate-frontend-forms

github.com/getsentry開発45.7k82 回インストール今日更新
リポジトリ

migrate-frontend-forms について

JsonForm や FormModel のフォームを TanStack ベースの新システムへ移行する Sentry 開発者向けガイドです。機能の対応付け、エラー変換、保存動作の変更を説明します。

主な機能

  1. 01旧フォーム設定を新 API に対応付け
  2. 02自動保存と送信動作の移行
  3. 03サーバーエラーをフィールドエラーに変換
  4. 04成功・失敗コールバックの調整
  5. 05表示専用情報とフォームリセットの処理

活用シーン

  1. 01JsonForm のフォームを新システムへ移行
  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ミューテーション関数内でデータを変換
mapFormErrorsリクエストエラーアダプター通常のフォームでは明示的に指定し、自動保存では提供済み
saveMessageonSuccessonSuccess コールバック内のミューテーションでトーストを表示
formatMessageValueonSuccessonSuccess コールバックでトーストの内容を制御
resetOnErroronErrorミューテーションの onError 内で form.reset() を呼び出す
saveOnBlur: falseuseScrapsForm明示的な保存ボタンを備えた通常のフォームを使用
(自動)form.reset()ミューテーション成功後もフォームがページ上に残る場合に呼び出す
helphintTextレイアウトコンポーネントで使用
labellabelレイアウトコンポーネントで使用
requiredrequiredレイアウトと Zod スキーマで使用
表示専用コンテンツ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
    });
  },
}}

重要:ミューテーションの型を正しく指定する

mutationFn の型には、スキーマから推論される型ではなく、API のデータ型(例:Partial<Organization>、Partial<Project>)を指定してください。スキーマはクライアント側のフィールド検証にのみ使用され、ミューテーションが受け取るのは API エンドポイントが受け入れるデータです。ミューテーションをスキーマに結び付けると、互いに無関係な関心事が密結合し、スキーマの型が 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 スキーマの型が API の型と互換性がある(つまり、API の型に代入可能である)ことを確認してください。たとえば、API が 'off' | 'low' | 'high' のような文字列ユニオンを想定している場合は、z.string() ではなく z.enum(['off', 'low', 'high']) を使用してください。

useMutation、mutationOptions、または TanStack Query の関数に、呼び出し側でジェネリクスを渡してはいけません。 これはデータ、エラー、変数、コンテキストを含むすべてのジェネリクスに適用されます。型は明示的に指定せず、推論させてください。「TanStack Query Type Inference」の項にある static/AGENTS.md のルール全文を参照してください。

// ❌ 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 エラーレスポンスをフィールド固有のエラーに変換していました。新しいシステムでは、Sentry API エラーを requestErrorToFieldErrors で変換し、その 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 の呼び出し箇所で unknown エラーを絞り込んでください。アダプターはレスポンスのキーを formApi.state.values に対してフィルタリングし、Scraps のフィールドエラーの形で返します。直接 FieldErrors オブジェクトを使用するのは、上記のネストされた config の例のように、移行でレスポンスのカスタム整形が必要な場合だけにしてください。

AutoSaveForm では、標準的なリクエストエラー処理は自動的に行われます。Sentry のフォームエラープロバイダーは、フィールドエラーには requestErrorToFieldErrors を、リクエストの詳細やステータスメッセージには getRequestErrorUserMessage を使用します。通常のフォーム用の catch ブロックを各自動保存フィールドに追加しないでください。

Note: setFieldErrors はドット記法によるネストされたパスをサポートしています: 'config.schedule': {message: 'Invalid schedule'}

saveMessage → onSuccess

saveMessage は、保存の成功後にカスタムのトースト/アラートを表示していました。新しいシステムでは、これを 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 は、変更された値が成功トーストでどのように表示されるかを制御していました。これを 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
    },
  }}
>

Note: TanStack Query を使った AutoSaveForm は、すでにエラー状態を適切に処理します - mutation の isError 状態が UI に反映されます。手動でのリセットは、通常、パスワードフィールドのような特定の UX 要件にのみ必要です。

保存後のリセット

保存後もページに留まるフォームで useScrapsForm を使用する場合、mutation の成功後に form.reset() を呼び出してください。これにより、フォームが更新された defaultValues と再同期され、再び pristine な状態になります — フォームが dirty であることに依存する UI (条件付きで表示される保存/キャンセルボタンなど) は正しく更新されます。

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

Note: AutoSaveForm はこれを自動的に処理します。追加が必要なのは useScrapsForm を使用する場合だけです。

saveOnBlur: false → useScrapsForm

saveOnBlur: false のフィールドは、自動保存の代わりに保存/キャンセルボタンを備えたインラインアラートを表示していました。これは危険な操作 (slug の変更) や大きなテキストの編集 (fingerprint ルール) で使用されていました。

新しいシステムでは、useScrapsForm を使った通常のフォームと明示的な保存ボタンを使用します。これにより、コミットする前に警告を表示するという 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 ルール、フィルター)
  • 自動保存が意味をなさないあらゆるフィールド

フォームを通して送信し、フォームを迂回しないでください。 上記の SlugForm パターンに従ってください — mutation は onSubmit で実行され、保存ボタンは <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>

このコンテンツの表示だけに使われていたスキーマエントリーとデフォルト値を削除します。必要に応じて、<Text> を使ってテキストのスタイルを維持してください。レンダリング中の派生表示値の計算には既存の form.Subscribe を使用し、form.setFieldValue でフォームの状態にコピーしないでください。

Row はレスポンシブなフィールドレイアウトに合わせて配置し、Stack は常に縦に積み重ねて配置します。これらのコンポーネントは、<label>、フィールドコンテキスト、エラーフォーカスの登録を伴わない説明リストのセマンティクスを使用します。実際の読み取り専用入力は、入力との関連付けを維持できるよう、AppField と field.Layout.* の内側にある field.Input readOnly を使用してください。フォームの状態を扱うカスタムコントロールには、引き続き field.Base が必要です。

レイアウト用の props とサブスクリプションの例については、フォーム生成ガイドを参照してください。

フォーム検索機能の維持

Sentry の SettingsSearch では、ユーザーが個々の設定フィールドを検索できます。フォームを移行する際は、移行したフォームを FormSearch でラップして、この検索性を維持する必要があります。

FormSearch コンポーネント

FormSearch はビルド時のマーカーコンポーネントです。実行時の動作はなく、子要素をそのままレンダリングするだけです。route prop は静的抽出スクリプトによって読み取られ、フォームフィールドとナビゲーションルートを関連付けることで、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>

Props:

Prop型説明
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 ラッパーを追加する必要があります。旧ソースと新ソースは共存します。同じルートとフィールドの組み合わせでは新しいレジストリエントリーが古いエントリーより優先されますが、旧フォームを削除すると古いエントリーはなくなります。

null の初期値の処理

従来のセレクトフィールドは、空または未定義の値で開始し、選択が必要でした。フィールドが常に必須の場合は、スキーマで .nullable().refine() を使用し、defaultValues の型に z.input<typeof schema> を指定して、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 を受け入れつつ、ミューテーションには検証済みの null 以外の型を渡せます。

条件付き必須フィールド

別の値によって表示されている間だけ必須となる従来のフィールドを移行する場合は、フォームレベルのスキーマを寛容に保ち、レンダリングされた 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>;

null 許容の数値フィールドでは、フォームスキーマに z.number().nullable() を使用し、z.number().positive('Amount must be greater than zero').nullable().refine(value => value !== null, 'Amount is required') のようなフィールドバリデーターを使用します。フィールドバリデーターは、表示されている入力欄が空のときに出力される null を拒否します。

意図的に移行していないもの

機能使用箇所理由
allowUndo3 formsトーストでの取り消しは、メリットがわずかな一方で複雑さが増します。代わりにシンプルなエラートーストを使用してください。

移行チェックリスト

  • JsonForm/FormModel を useScrapsForm または AutoSaveForm に置き換える
  • useMutation にジェネリクスを指定しない — mutationFn のペイロードに型を指定し、戻り値の型には fetchMutation<T> を使用する
  • Save ボタンを使う useScrapsForm では、ミューテーションを onSubmit で実行し、<form.SubmitButton> から呼び出す(送信されないフォームを作らない)
  • 入力フィールドの設定オブジェクトを JSX の AppField コンポーネントに変換する
  • 表示専用のコンテンツを DisplayField.Row または DisplayField.Stack に変換し、スキーマやフォーム状態のエントリーを設けない
  • 条件付き必須フィールドは、寛容なフォームスキーマを使い、レンダリングされた 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 ボタン付きの通常のフォームに変換する
  • ミューテーションの成功後に form.reset() を呼び出す(ページに残るフォームの場合)
  • onSuccess のキャッシュ更新で既存データとのマージを確認する(アップデーター関数を使用する) — API エンドポイントによっては部分的なオブジェクトを返す場合があります
  • 以前のフォームが SettingsSearch で検索可能だった場合は、移行後のフォームを <FormSearch route="..."> でラップする
  • pnpm run extract-form-fields を実行し、更新された fieldRegistry.generated.ts をコミットする

コメント