--- name: generate-frontend-forms description: Guide for creating forms using Sentry's new form system. Use when implementing forms, form fields, validation, or auto-save functionality. --- # Form System Guide This skill provides patterns for building forms using Sentry's new form system built on TanStack React Form and Zod validation. ## 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. ## 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. --- ## Form Hook: `useScrapsForm` The main hook for creating forms with validation and submission handling. ### 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 ( {field => ( )} Submit ); } ``` > **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. ### Returned Properties | Property | Description | | ---------------- | ---------------------------------------------- | | `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 | --- ## Field Components All fields are accessed via the `field` render prop and follow consistent patterns. ### Input Field (Text) ```tsx {field => ( )} ``` ### Number Field ```tsx {field => ( )} ``` ### Select Field (Single) ```tsx {field => ( )} ``` ### Select Field (Multiple) ```tsx {field => ( )} ``` ### Switch Field (Boolean) ```tsx {field => ( )} ``` ### TextArea Field ```tsx {field => ( )} ``` ### Range Field (Slider) ```tsx {field => ( )} ``` --- ## 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 ``` ### Row Layout (Horizontal) Label on left (~50%), field on right. Compact layout for settings pages. ```tsx ``` ### 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 // Compact: hint text appears in tooltip when hovering the label // Also works with Stack layout ``` **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 ### 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 {field => ( First Name: )} ``` ### Layout Props | Prop | Type | Description | | ---------- | ----------- | ------------------------------------------------------------- | | `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 | --- ## Field Groups Group related fields into sections with a title. ```tsx {/* ... */} {/* ... */} {/* ... */} {/* ... */} ``` --- ## 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 // ✅ Provide a reason when disabling ``` --- ## Validation with Zod ### Schema Definition ```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'], }); ``` ### Conditional Fields Use `form.Subscribe` to show/hide fields based on other field values: ```tsx state.values.plan === 'enterprise'}> {showBilling => showBilling ? ( {field => ( )} ) : null } ``` --- ## 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'}` ### 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`. ### 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 ( { return fetchMutation({ url: '/user/', method: 'PUT', data, }); }, onSuccess: data => { // Update React Query cache queryClient.setQueryData(['user'], old => ({...old, ...data})); }, }} > {field => ( )} ); } ``` ### Auto-Save Behavior by Field Type | Field Type | When it saves | | ----------------- | ----------------------------------------------------------- | | Input, TextArea | On blur (when user leaves field) | | Select (single) | Immediately when selection changes | | Select (multiple) | When menu closes, or when X/clear clicked while menu closed | | Switch | Immediately when toggled | | Range | When user releases the slider, or immediately with keyboard | ### 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) ### 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 value ? 'This will remove all members without 2FA. Continue?' : 'Are you sure you want to allow members without 2FA?' } mutationOptions={{...}} > {field => ( )} ``` **Confirm Config Options:** | Type | Description | | -------------------------------- | ------------------------------------------------------------------------------------------- | | `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} ``` --- ## Form Submission > **Important**: Always use TanStack Query mutations (`useMutation`) for form submissions. This ensures proper loading states, error handling, and cache management. ### Using 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(() => {}); }, }); // ... } ``` ### Submit Button ```tsx Save Changes ``` The `SubmitButton` automatically: - Disables while submission is pending - Triggers form validation before submit --- ## Do's and Don'ts ### Form System Choice ```tsx // ❌ Don't use legacy JsonForm for new forms ; // ✅ Use useScrapsForm with Zod validation 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: ''}, }); ``` ### 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(() => {}); }; ``` ### Field Value Handling ```tsx // ❌ Don't use field.state.value directly when it might be undefined // ✅ Provide fallback for optional fields ``` ### 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'); ``` ### 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})); }, }} ``` ### Auto-Save Mutation Typing with Mixed-Type Schemas 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` 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; // ❌ Don't use generic types - breaks field type narrowing const mutationOptions = mutationOptions({ mutationFn: (data: Record) => fetchMutation({...}), }); // ✅ Use schema-inferred type for proper type narrowing const mutationOptions = mutationOptions({ mutationFn: (data: Partial) => fetchMutation({...}), }); ``` This ensures that when you use `name="theme"`, the field correctly infers `string` type, and `name="notifications"` infers `boolean` type. ### Layout Choice ```tsx // ❌ Don't use Row layout when labels are very long // ✅ Use Stack layout for long labels ``` --- ## 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 `` and `` - [ ] Use `` for each field - [ ] Choose appropriate layout (Stack or Row) - [ ] Handle server errors with `setFieldErrors` - [ ] Add `` for submission When creating auto-save fields: - [ ] Use `` component - [ ] Pass `schema` for validation - [ ] Pass `initialValue` from current data - [ ] Configure `mutationOptions` with `mutationFn` - [ ] Update cache in `onSuccess` callback --- ## File References | File | Purpose | | --------------------------------------------------------- | --------------------------- | | `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 |