Loading...
Loading...
Guide for migrating forms from the legacy JsonForm/FormModel system to the new TanStack-based form system.
npx skill4agent add getsentry/sentry migrate-frontend-forms| Old System | New System | Notes |
|---|---|---|
| | Default behavior |
| | |
| | On layout components |
| | String shows tooltip |
| JSX in layout | Render |
| | Transform data in mutation function |
| | Transform API errors in catch block |
| | Show toast in mutation onSuccess callback |
| | Control toast content in onSuccess callback |
| | Call form.reset() in mutation onError |
| | Use regular form with explicit Save button |
| | On layout components |
| | On layout components |
| | On layout + Zod schema |
confirm{
name: 'require2FA',
type: 'boolean',
confirm: {
true: 'Enable 2FA for all members?',
false: 'Allow members without 2FA?',
},
isDangerous: true,
}<AutoSaveField
name="require2FA"
confirm={value =>
value
? 'Enable 2FA for all members?'
: 'Allow members without 2FA?'
}
{...}
>variant="compact"{
name: 'field',
help: 'This is help text',
showHelpInTooltip: true,
}<field.Layout.Row
label="Field"
hintText="This is help text"
variant="compact"
>disabled="reason"{
name: 'field',
disabled: true,
disabledReason: 'Requires Business plan',
}<field.Input
disabled="Requires Business plan"
{...}
/>{
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>mutationFngetDatamutationFn// 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}),
}<AutoSaveField
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>
)}
</AutoSaveField>mutationOptions={{
mutationFn: fieldData => {
return fetchMutation({
url: `/projects/${org}/${project}/`,
method: 'PUT',
data: {options: fieldData}, // getData equivalent
});
},
}}AutoSaveFieldRecord<string, unknown>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
mutationOptions={{
mutationFn: (data: Record<string, unknown>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
},
}}
// ✅ Use schema-inferred type for proper type narrowing
mutationOptions={{
mutationFn: (data: Partial<Preferences>) => {
return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}});
},
}}setFieldErrorsmapFormErrorssetFieldErrors// 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';
const form = useScrapsForm({
...defaultFormOptions,
defaultValues: {...},
validators: {onDynamic: schema},
onSubmit: async ({value, formApi}) => {
try {
await mutation.mutateAsync(value);
} catch (error) {
// Transform API errors and set on fields (equivalent to mapFormErrors)
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);
}
}
},
});onSubmit: async ({value, formApi}) => {
try {
await mutation.mutateAsync(value);
} catch (error) {
// API returns {email: ['Already taken'], username: ['Invalid']}
const errors = error.responseJSON;
if (errors) {
setFieldErrors(formApi, {
email: {message: errors.email?.[0]},
username: {message: errors.username?.[0]},
});
}
}
},Note:supports nested paths with dot notation:setFieldErrors'config.schedule': {message: 'Invalid schedule'}
onSuccesssaveMessageonSuccess{
name: 'fingerprintingRules',
saveOnBlur: false,
saveMessageAlertVariant: 'info',
saveMessage: t('Changing fingerprint rules will apply to future events only.'),
}import {addSuccessMessage} from 'sentry/actionCreators/indicator';
<AutoSaveField
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.'));
},
}}
>onSuccessformatMessageValuefalseonSuccess{
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));
},onErrorresetOnErrorform.reset()onError// Form-level reset on error
<Form resetOnError apiEndpoint="/auth/" {...}>
// Or field-level (BooleanField always resets on error)
<FormField resetOnError name="enabled" {...}>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
}
},
});<AutoSaveField
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: AutoSaveField with TanStack Query already handles error states gracefully - the mutation'sstate is reflected in the UI. Manual reset is typically only needed for specific UX requirements like password fields.isError
useScrapsFormsaveOnBlur: falseuseScrapsForm{
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">
<Button onClick={() => form.reset()}>Cancel</Button>
<form.SubmitButton>Save</form.SubmitButton>
</Flex>
</form.AppForm>
);
}FormSearchFormSearchFormSearchrouteimport {FormSearch} from 'sentry/components/core/form';
<FormSearch route="/settings/account/details/">
<FieldGroup title={t('Account Details')}>
<AutoSaveField 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>
)}
</AutoSaveField>
</FieldGroup>
</FormSearch>| Prop | Type | Description |
|---|---|---|
| | The settings route for this form (e.g., |
| | The form content — rendered unchanged at runtime. |
routeFormSearch<AutoSaveField><form.AppField>FormSearchlabelhintTextt()FormSearchpnpm run extract-form-fieldsscripts/extractFormFields.ts<FormSearch>namelabelhintTextroutestatic/app/components/core/form/generatedFieldRegistry.tsRun the command after any change to forms inside awrapper (adds, removals, label changes). The generated file is checked in and should not be edited manually.FormSearch
JsonFormsentry/data/formsFormSearch| Feature | Usage | Reason |
|---|---|---|
| 3 forms | Undo in toasts adds complexity with minimal benefit. Use simple error toasts instead. |
helphintTextshowHelpInTooltipvariant="compact"disabledReasondisabled="reason string"extraHelpconfirm(value) => message | undefinedgetDatamapFormErrorssaveMessagesaveOnBlur: falseonSuccess<FormSearch route="...">pnpm run extract-form-fieldsgeneratedFieldRegistry.ts