From c0e48f3a79090b4a4ad049a12ed95478eff947ef Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 26 Feb 2026 10:26:36 +0100 Subject: [PATCH 1/4] ref: better AppForm --- static/app/components/core/form/scrapsForm.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/static/app/components/core/form/scrapsForm.tsx b/static/app/components/core/form/scrapsForm.tsx index c18027ea6af34d..c831d5fe70a9be 100644 --- a/static/app/components/core/form/scrapsForm.tsx +++ b/static/app/components/core/form/scrapsForm.tsx @@ -3,6 +3,7 @@ import { createFormHook, formOptions, revalidateLogic, + type AnyFormApi, type DeepKeys, } from '@tanstack/react-form'; @@ -55,7 +56,7 @@ const {useAppForm} = createFormHook({ formComponents: { FieldGroup, SubmitButton, - FormWrapper, + AppForm, }, fieldContext, formContext, @@ -78,6 +79,14 @@ function SubmitButton(props: ButtonProps) { ); } +function AppForm({children, form}: {children: React.ReactNode; form: AnyFormApi}) { + return ( + + {children} + + ); +} + function FormWrapper({children}: {children: React.ReactNode}) { const form = useFormContext(); From b128cea1e4b53c2dc943132eb986c3b8856cace2 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 26 Feb 2026 10:54:57 +0100 Subject: [PATCH 2/4] ref: unify FormWrapper and AppForm --- .../core/form/field/autoSaveField.tsx | 6 +- .../core/form/field/baseField.spec.tsx | 4 +- .../core/form/field/passwordField.spec.tsx | 2 +- .../core/form/field/rangeField.spec.tsx | 2 +- .../core/form/field/selectField.spec.tsx | 16 +- .../core/form/field/switchField.spec.tsx | 2 +- .../core/form/field/textAreaField.spec.tsx | 2 +- .../app/components/core/form/form.stories.tsx | 376 +++++++++--------- .../customCommitsResolutionModal.tsx | 100 +++-- .../views/settings/account/accountEmails.tsx | 44 +- .../views/settings/account/passwordForm.tsx | 100 +++-- .../authTokenDetails.tsx | 64 ++- .../organizationAuthTokens/newAuthToken.tsx | 56 ++- .../organizationSettingsForm.tsx | 93 ++--- .../organizationSecurityAndPrivacy/index.tsx | 142 ++++--- .../organizationTeams/teamSettings/index.tsx | 112 +++--- 16 files changed, 548 insertions(+), 573 deletions(-) diff --git a/static/app/components/core/form/field/autoSaveField.tsx b/static/app/components/core/form/field/autoSaveField.tsx index a7663daab88557..eb17d4634fa932 100644 --- a/static/app/components/core/form/field/autoSaveField.tsx +++ b/static/app/components/core/form/field/autoSaveField.tsx @@ -231,11 +231,9 @@ export function AutoSaveField< }); return ( - + - - {field => children(field as never)} - + {field => children(field as never)} ); diff --git a/static/app/components/core/form/field/baseField.spec.tsx b/static/app/components/core/form/field/baseField.spec.tsx index dde85abf5bd02a..1ba6a7632d8743 100644 --- a/static/app/components/core/form/field/baseField.spec.tsx +++ b/static/app/components/core/form/field/baseField.spec.tsx @@ -22,7 +22,7 @@ function TestForm({label, hintText, required, defaultValue, validator}: TestForm }); return ( - + {field => ( @@ -49,7 +49,7 @@ function CompactTestForm({label, hintText, layout = 'Row'}: CompactTestFormProps }); return ( - + {field => { const LayoutComponent = field.Layout[layout]; diff --git a/static/app/components/core/form/field/passwordField.spec.tsx b/static/app/components/core/form/field/passwordField.spec.tsx index 571de11aaf1104..01c2ff10be2316 100644 --- a/static/app/components/core/form/field/passwordField.spec.tsx +++ b/static/app/components/core/form/field/passwordField.spec.tsx @@ -15,7 +15,7 @@ function TestForm({defaultValue = '', disabled, label = 'Password'}: TestFormPro }); return ( - + {field => ( diff --git a/static/app/components/core/form/field/rangeField.spec.tsx b/static/app/components/core/form/field/rangeField.spec.tsx index 2c2a55f65c7a25..64dec92179ad21 100644 --- a/static/app/components/core/form/field/rangeField.spec.tsx +++ b/static/app/components/core/form/field/rangeField.spec.tsx @@ -35,7 +35,7 @@ function TestForm({ }); return ( - + {field => ( diff --git a/static/app/components/core/form/field/selectField.spec.tsx b/static/app/components/core/form/field/selectField.spec.tsx index 48d91b8377b7f6..bbf03dceb099bf 100644 --- a/static/app/components/core/form/field/selectField.spec.tsx +++ b/static/app/components/core/form/field/selectField.spec.tsx @@ -39,7 +39,7 @@ function TestForm({ }); return ( - + {field => ( @@ -102,7 +102,7 @@ describe('SelectField', () => { }); return ( - + {field => ( { }); return ( - + {field => ( { }); return ( - + {field => ( // @ts-expect-error value should be string[] when multiple is true @@ -189,7 +189,7 @@ describe('SelectField', () => { }); return ( - + {field => ( // @ts-expect-error value should be string when multiple is false @@ -214,7 +214,7 @@ describe('SelectField', () => { }); return ( - + {field => ( { }); return ( - + {field => ( + {field => ( diff --git a/static/app/components/core/form/field/switchField.spec.tsx b/static/app/components/core/form/field/switchField.spec.tsx index 08433a865f6332..140c41505dac92 100644 --- a/static/app/components/core/form/field/switchField.spec.tsx +++ b/static/app/components/core/form/field/switchField.spec.tsx @@ -36,7 +36,7 @@ function TestForm({ }); return ( - + {field => ( diff --git a/static/app/components/core/form/field/textAreaField.spec.tsx b/static/app/components/core/form/field/textAreaField.spec.tsx index 9ec1fa33d52b78..d4e1ed0d1fb49e 100644 --- a/static/app/components/core/form/field/textAreaField.spec.tsx +++ b/static/app/components/core/form/field/textAreaField.spec.tsx @@ -31,7 +31,7 @@ function TestForm({ }); return ( - + {field => ( diff --git a/static/app/components/core/form/form.stories.tsx b/static/app/components/core/form/form.stories.tsx index 4403e65dd15ac1..d7ccb626e89b2e 100644 --- a/static/app/components/core/form/form.stories.tsx +++ b/static/app/components/core/form/form.stories.tsx @@ -287,147 +287,145 @@ function BasicForm() { } return ( - - - - - {field => ( - - - - )} - - - {field => ( - - - - )} - - - {field => ( - - - - )} - - - {field => ( - + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + state.values.age === 42}> + {showSecret => + showSecret ? ( + - - - )} - - - {field => ( - - - - )} - - - {field => ( - - - - )} - - - {field => ( - - - - )} - - state.values.age === 42}> - {showSecret => - showSecret ? ( - - {field => ( - - - - )} - - ) : null - } - - - - - - {field => ( - - - - )} - - - {field => ( - - - - )} - - - {field => ( - - - - )} - - - - - - Submit - - + {field => ( + + + + )} + + ) : null + } + + + + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + + + + Submit + ); } @@ -444,56 +442,54 @@ function CompactExample() { }); return ( - - - - - {field => ( - - - - )} - - - {field => ( - - - - )} - - - - - - {field => ( - - - - )} - - - {field => ( - - - - )} - - - + + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + ); } diff --git a/static/app/components/customCommitsResolutionModal.tsx b/static/app/components/customCommitsResolutionModal.tsx index a17d7f9c140076..e9f26dabc694f1 100644 --- a/static/app/components/customCommitsResolutionModal.tsx +++ b/static/app/components/customCommitsResolutionModal.tsx @@ -56,57 +56,55 @@ function CustomCommitsResolutionModal({ }); return ( - - -
-

{t('Resolved In')}

-
- - - {field => ( - - { - return queryOptions({ - ...apiOptions.as()( - '/projects/$organizationIdOrSlug/$projectIdOrSlug/commits/', - { - path: { - organizationIdOrSlug: orgSlug, - projectIdOrSlug: projectSlug, - }, - query: {query: debouncedInput}, - staleTime: 30_000, - } - ), - select: ({json: commits}) => - commits.map(c => ({ - value: c, - textValue: c.id, - label: , - details: ( - - {t('Created')} - - ), - })), - }); - }} - placeholder={t('e.g. d86b832')} - /> - - )} - - -
- - - {t('Resolve')} - -
-
+ +
+

{t('Resolved In')}

+
+ + + {field => ( + + { + return queryOptions({ + ...apiOptions.as()( + '/projects/$organizationIdOrSlug/$projectIdOrSlug/commits/', + { + path: { + organizationIdOrSlug: orgSlug, + projectIdOrSlug: projectSlug, + }, + query: {query: debouncedInput}, + staleTime: 30_000, + } + ), + select: ({json: commits}) => + commits.map(c => ({ + value: c, + textValue: c.id, + label: , + details: ( + + {t('Created')} + + ), + })), + }); + }} + placeholder={t('e.g. d86b832')} + /> + + )} + + +
+ + + {t('Resolve')} + +
); } diff --git a/static/app/views/settings/account/accountEmails.tsx b/static/app/views/settings/account/accountEmails.tsx index f920d7ddd2e168..9a89fd8b39aa6f 100644 --- a/static/app/views/settings/account/accountEmails.tsx +++ b/static/app/views/settings/account/accountEmails.tsx @@ -84,29 +84,27 @@ function AccountEmails() { - - - - - {field => ( - - - - )} - - - - {t('Add email')} - - + + + + {field => ( + + + + )} + + + + {t('Add email')} + diff --git a/static/app/views/settings/account/passwordForm.tsx b/static/app/views/settings/account/passwordForm.tsx index 19c3880390b2ac..2cf72b3d1c23b4 100644 --- a/static/app/views/settings/account/passwordForm.tsx +++ b/static/app/views/settings/account/passwordForm.tsx @@ -62,57 +62,55 @@ export function PasswordForm() { return ( - - - - - {t('Changing your password will invalidate all logged in sessions.')} - - - {field => ( - - - - )} - - - {field => ( - - - - )} - - - {field => ( - - - - )} - - - {t('Change password')} - - - + + + + {t('Changing your password will invalidate all logged in sessions.')} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + {t('Change password')} + + ); diff --git a/static/app/views/settings/organizationAuthTokens/authTokenDetails.tsx b/static/app/views/settings/organizationAuthTokens/authTokenDetails.tsx index 1303e97f1c510f..a386365e8c23a8 100644 --- a/static/app/views/settings/organizationAuthTokens/authTokenDetails.tsx +++ b/static/app/views/settings/organizationAuthTokens/authTokenDetails.tsx @@ -140,39 +140,37 @@ function AuthTokenDetailsForm({token}: {token: OrgAuthToken}) { }); return ( - - - - {field => ( - - - - )} - - - -
{tokenPreview(token.tokenLastCharacters || '****')}
-
- - -
{token.scopes.slice().sort().join(', ')}
-
- - - - {t('Save Changes')} - -
+ + + {field => ( + + + + )} + + + +
{tokenPreview(token.tokenLastCharacters || '****')}
+
+ + +
{token.scopes.slice().sort().join(', ')}
+
+ + + + {t('Save Changes')} +
); } diff --git a/static/app/views/settings/organizationAuthTokens/newAuthToken.tsx b/static/app/views/settings/organizationAuthTokens/newAuthToken.tsx index ca9f801f9075a7..419fc51eebe677 100644 --- a/static/app/views/settings/organizationAuthTokens/newAuthToken.tsx +++ b/static/app/views/settings/organizationAuthTokens/newAuthToken.tsx @@ -97,35 +97,33 @@ function AuthTokenCreateForm({ }); return ( - - - - {field => ( - - - - )} - - - -
-
org:ci
- {t('Source Map Upload, Release Creation')} -
-
- - - - {t('Create Token')} - -
+ + + {field => ( + + + + )} + + + +
+
org:ci
+ {t('Source Map Upload, Release Creation')} +
+
+ + + + {t('Create Token')} +
); } diff --git a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx index c22d800b14ce65..fe6b8fb45cd9ea 100644 --- a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx +++ b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.tsx @@ -454,55 +454,50 @@ function OrganizationSettingsForm({initialData, onSave}: Props) { {/* Slug — explicit save with warning */} - - - - {field => ( - - field.handleChange(slugify(value))} - disabled={!hasWriteAccess} - /> - - )} - - state.values.slug !== initialData.slug} - > - {isDirty => - isDirty && ( - - - {tct( - 'Changing your organization slug will break organization tokens, may impact integrations, and break links to your organization. You will be redirected to the new slug after saving. [link:Learn more]', - { - link: ( - - ), - } - )} - - - - - {t('Save')} - - - - ) - } - - + + + {field => ( + + field.handleChange(slugify(value))} + disabled={!hasWriteAccess} + /> + + )} + + state.values.slug !== initialData.slug} + > + {isDirty => + isDirty && ( + + + {tct( + 'Changing your organization slug will break organization tokens, may impact integrations, and break links to your organization. You will be redirected to the new slug after saving. [link:Learn more]', + { + link: ( + + ), + } + )} + + + + + {t('Save')} + + + + ) + } + {/* Display Name */} diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx index c6d07a51ba862f..75540b2e82fbb4 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx @@ -425,80 +425,78 @@ function ScrubbingConfigurationFieldGroup({hasOrgWrite}: {hasOrgWrite: boolean}) return ( - - - - - {field => ( - - - - )} - + + + + {field => ( + + + + )} + - - {field => ( - - - - )} - - {hasOrgWrite ? ( - - - state.values.sensitiveFields !== initialSensitiveFields || - state.values.safeFields !== initialSafeFields - } - > - {hasChanged => ( - - - {t( - 'Changes to your scrubbing configuration will apply to all new events.' - )} - - - )} - - - - - {t('Save')} - - + + {field => ( + + + + )} + + {hasOrgWrite ? ( + + + state.values.sensitiveFields !== initialSensitiveFields || + state.values.safeFields !== initialSafeFields + } + > + {hasChanged => ( + + + {t( + 'Changes to your scrubbing configuration will apply to all new events.' + )} + + + )} + + + + + {t('Save')} + - ) : null} - - + + ) : null} + ); diff --git a/static/app/views/settings/organizationTeams/teamSettings/index.tsx b/static/app/views/settings/organizationTeams/teamSettings/index.tsx index b89151f4d1ab48..bbc22a780caac7 100644 --- a/static/app/views/settings/organizationTeams/teamSettings/index.tsx +++ b/static/app/views/settings/organizationTeams/teamSettings/index.tsx @@ -101,65 +101,63 @@ export default function TeamSettings() { )} - - - - - {field => ( - - field.handleChange(slugify(value))} - placeholder="e.g. operations, web-frontend, mobile-ios" - disabled={isDisabled} - /> - - )} - - - {field => ( - - - - )} - + + + + {field => ( + + field.handleChange(slugify(value))} + placeholder="e.g. operations, web-frontend, mobile-ios" + disabled={isDisabled} + /> + + )} + + + {field => ( + + + + )} + - {isDisabled ? null : ( - - state.values.slug !== team.slug}> - {hasChanged => ( - - - {t('You will be redirected to the new team slug after saving.')} - - - )} - - - - {t('Save')} - + {isDisabled ? null : ( + + state.values.slug !== team.slug}> + {hasChanged => ( + + + {t('You will be redirected to the new team slug after saving.')} + + + )} + + + + {t('Save')} - )} - - + + )} + From 1a286eacfbdff13e9bd271294af51f1fb56a763e Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 26 Feb 2026 11:00:00 +0100 Subject: [PATCH 3/4] ref: update skills --- .../skills/generate-frontend-forms/SKILL.md | 43 +++++++++---------- .../skills/migrate-frontend-forms/SKILL.md | 2 +- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/.agents/skills/generate-frontend-forms/SKILL.md b/.agents/skills/generate-frontend-forms/SKILL.md index b3df4f63b4dc7c..ddfbf3f33e6f43 100644 --- a/.agents/skills/generate-frontend-forms/SKILL.md +++ b/.agents/skills/generate-frontend-forms/SKILL.md @@ -65,18 +65,16 @@ function MyForm() { }); return ( - - - - {field => ( - - - - )} - - - Submit - + + + {field => ( + + + + )} + + + Submit ); } @@ -86,16 +84,15 @@ function MyForm() { ### 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 | +| Property | Description | +| ---------------- | ------------------------------------------------------------------------------------------------------------- | +| `AppForm` | Root wrapper component (provides form context and renders `
` element). Must receive `form={form}` prop. | +| `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 | --- @@ -772,7 +769,7 @@ When creating a new form: - [ ] Use `useScrapsForm` with `...defaultFormOptions` - [ ] Set `defaultValues` matching schema shape - [ ] Set `validators: {onDynamic: schema}` -- [ ] Wrap with `` and `` +- [ ] Wrap with `` - [ ] Use `` for each field - [ ] Choose appropriate layout (Stack or Row) - [ ] Handle server errors with `setFieldErrors` diff --git a/.agents/skills/migrate-frontend-forms/SKILL.md b/.agents/skills/migrate-frontend-forms/SKILL.md index ec2f24e5f066b1..1a5a0d85bb9fb6 100644 --- a/.agents/skills/migrate-frontend-forms/SKILL.md +++ b/.agents/skills/migrate-frontend-forms/SKILL.md @@ -452,7 +452,7 @@ function SlugForm({project}: {project: Project}) { }); return ( - + {field => ( From 1f9282d59978bef0184b72eaa724c4ff4713df68 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 26 Feb 2026 14:04:41 +0100 Subject: [PATCH 4/4] fix: types --- static/app/components/core/form/field/radioField.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/core/form/field/radioField.spec.tsx b/static/app/components/core/form/field/radioField.spec.tsx index e0018995bc2a3d..0b49c0bfb5b543 100644 --- a/static/app/components/core/form/field/radioField.spec.tsx +++ b/static/app/components/core/form/field/radioField.spec.tsx @@ -27,7 +27,7 @@ function TestForm({ }); return ( - + {field => (