From cc24014e23af13acffe43c16334a1520865105a4 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 20 May 2026 13:52:04 +0200 Subject: [PATCH 1/7] ref(forms): Migrate highlights settings --- .../highlightsSettingsForm.spec.tsx | 51 +++-- .../highlights/highlightsSettingsForm.tsx | 198 ++++++++++-------- 2 files changed, 154 insertions(+), 95 deletions(-) diff --git a/static/app/components/events/highlights/highlightsSettingsForm.spec.tsx b/static/app/components/events/highlights/highlightsSettingsForm.spec.tsx index 37f43328e976f3..e6cb2ef08af8b4 100644 --- a/static/app/components/events/highlights/highlightsSettingsForm.spec.tsx +++ b/static/app/components/events/highlights/highlightsSettingsForm.spec.tsx @@ -1,7 +1,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {DetailedProjectFixture} from 'sentry-fixture/project'; -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import {HighlightsSettingsForm} from 'sentry/components/events/highlights/highlightsSettingsForm'; import * as analytics from 'sentry/utils/analytics'; @@ -53,12 +53,14 @@ describe('HighlightsSettingForm', () => { await userEvent.type(tagInput, `\n${newTag}`); await userEvent.click(screen.getByText('Highlights')); - expect(updateProjectMock).toHaveBeenCalledWith( - url, - expect.objectContaining({ - data: {highlightTags: [...highlightTags, newTag]}, - }) - ); + await waitFor(() => { + expect(updateProjectMock).toHaveBeenCalledWith( + url, + expect.objectContaining({ + data: {highlightTags: [...highlightTags, newTag]}, + }) + ); + }); expect(analyticsSpy).toHaveBeenCalledWith( 'highlights.project_settings.updated_manually', expect.anything() @@ -84,11 +86,36 @@ describe('HighlightsSettingForm', () => { await userEvent.paste(JSON.stringify(newContext)); await userEvent.click(screen.getByText('Highlights')); - expect(updateProjectMock).toHaveBeenCalledWith( + await waitFor(() => { + expect(updateProjectMock).toHaveBeenCalledWith( + url, + expect.objectContaining({ + data: {highlightContext: newContext}, + }) + ); + }); + }); + + it('should reject highlight context values that are valid JSON but not context mappings', async () => { + render(, {organization}); + await screen.findByText('Highlights'); + + const url = `/projects/${organization.slug}/${project.slug}/`; + const updateProjectMock = MockApiClient.addMockResponse({ url, - expect.objectContaining({ - data: {highlightContext: newContext}, - }) - ); + method: 'PUT', + body: {...project, highlightTags, highlightContext}, + }); + + const contextInput = screen.getByRole('textbox', {name: 'Highlighted Context'}); + + await userEvent.clear(contextInput); + await userEvent.paste('123'); + await userEvent.click(screen.getByText('Highlights')); + + await waitFor(() => { + expect(contextInput).toHaveAttribute('aria-invalid', 'true'); + }); + expect(updateProjectMock).not.toHaveBeenCalled(); }); }); diff --git a/static/app/components/events/highlights/highlightsSettingsForm.tsx b/static/app/components/events/highlights/highlightsSettingsForm.tsx index 0349d16befbbe5..bad68863c217f7 100644 --- a/static/app/components/events/highlights/highlightsSettingsForm.tsx +++ b/static/app/components/events/highlights/highlightsSettingsForm.tsx @@ -1,116 +1,148 @@ -import {useQueryClient} from '@tanstack/react-query'; +import {mutationOptions} from '@tanstack/react-query'; +import {z} from 'zod'; +import {AutoSaveForm, FieldGroup} from '@sentry/scraps/form'; import {ExternalLink} from '@sentry/scraps/link'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {hasEveryAccess} from 'sentry/components/acl/access'; import {CONTEXT_DOCS_LINK} from 'sentry/components/events/contexts/utils'; -import {Form, type FormProps} from 'sentry/components/forms/form'; -import JsonForm from 'sentry/components/forms/jsonForm'; import {t, tct} from 'sentry/locale'; import type {DetailedProject} from 'sentry/types/project'; import {convertMultilineFieldValue, extractMultilineFields} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; -import { - makeDetailedProjectQueryKey, - useDetailedProject, -} from 'sentry/utils/project/useDetailedProject'; +import {useDetailedProject} from 'sentry/utils/project/useDetailedProject'; +import {useUpdateProject} from 'sentry/utils/project/useUpdateProject'; import {useOrganization} from 'sentry/utils/useOrganization'; interface HighlightsSettingsFormProps { - projectSlug: any; + projectSlug: string; } +const highlightTagsSchema = z.object({ + highlightTags: z.string(), +}); + +function parseHighlightContextValue( + value: string +): NonNullable { + if (value === '') { + return {}; + } + return z.record(z.string(), z.array(z.string())).parse(JSON.parse(value)); +} + +const highlightContextSchema = z.object({ + highlightContext: z.string().superRefine((value, ctx) => { + try { + parseHighlightContextValue(value); + } catch { + ctx.addIssue({code: 'custom', message: t('Invalid JSON')}); + } + }), +}); + export function HighlightsSettingsForm({projectSlug}: HighlightsSettingsFormProps) { const organization = useOrganization(); const {data: project} = useDetailedProject({ orgSlug: organization.slug, projectSlug, }); - const queryClient = useQueryClient(); + if (!project) { return null; } - const access = new Set(organization.access.concat(project.access)); - const formProps: FormProps = { - saveOnBlur: true, - allowUndo: true, - initialData: { - highlightTags: project.highlightTags, - highlightContext: project.highlightContext, - }, - apiMethod: 'PUT', - apiEndpoint: `/projects/${organization.slug}/${projectSlug}/`, - onSubmitSuccess: (updatedProject: DetailedProject) => { - queryClient.setQueryData( - makeDetailedProjectQueryKey({ - orgSlug: organization.slug, - projectSlug: project.slug, - }), - prev => - updatedProject ? {headers: prev?.headers ?? {}, json: updatedProject} : prev - ); - trackAnalytics('highlights.project_settings.updated_manually', {organization}); - addSuccessMessage(`Successfully updated highlights for '${project.name}'`); - }, + + return ; +} + +interface LoadedHighlightsSettingsFormProps { + project: DetailedProject; +} + +function LoadedHighlightsSettingsForm({project}: LoadedHighlightsSettingsFormProps) { + const organization = useOrganization(); + const hasAccess = hasEveryAccess(['project:write'], {organization, project}); + const {mutateAsync: updateProject} = useUpdateProject(project); + + const handleSubmitSuccess = () => { + trackAnalytics('highlights.project_settings.updated_manually', {organization}); + addSuccessMessage(`Successfully updated highlights for '${project.name}'`); }; + + const highlightTagsMutationOptions = mutationOptions({ + mutationFn: (data: {highlightTags: string}) => + updateProject({highlightTags: extractMultilineFields(data.highlightTags)}), + onSuccess: handleSubmitSuccess, + }); + + const highlightContextMutationOptions = mutationOptions({ + mutationFn: (data: {highlightContext: string}) => + updateProject({ + highlightContext: parseHighlightContextValue(data.highlightContext), + }), + onSuccess: handleSubmitSuccess, + }); + return ( -
- + + {field => ( + + + + )} + + + + {field => ( + , example: '{"user": ["id", "email"]}', } - ), - getValue: (val: string) => (val === '' ? {} : JSON.parse(val)), - setValue: (val: string) => { - const schema = JSON.stringify(val, null, 2); - if (schema === '{}') { - return ''; - } - return schema; - }, - validate: ({id, form}) => { - if (form.highlightContext) { - try { - JSON.parse(form.highlightContext); - } catch (e) { - return [[id, 'Invalid JSON']]; - } - } - return []; - }, - }, - ]} - /> - + )} + > + + + )} + + ); } From f7c6b8c7d5f4baf988afa896330a27f804ff796d Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 20 May 2026 13:59:03 +0200 Subject: [PATCH 2/7] ref(forms): submit parsed autosave values --- .../backendJsonFormAdapter/utils.ts | 2 +- .../app/components/core/form/autoSaveForm.mdx | 50 +++++++++--- .../core/form/autoSaveForm.spec.tsx | 78 +++++++++---------- .../app/components/core/form/autoSaveForm.tsx | 45 +++++++---- 4 files changed, 108 insertions(+), 67 deletions(-) diff --git a/static/app/components/backendJsonFormAdapter/utils.ts b/static/app/components/backendJsonFormAdapter/utils.ts index b60b5a3e1db89c..5782da428d3182 100644 --- a/static/app/components/backendJsonFormAdapter/utils.ts +++ b/static/app/components/backendJsonFormAdapter/utils.ts @@ -20,7 +20,7 @@ export function getZodType(fieldType: JsonFormAdapterFieldConfig['type']) { case 'url': return z.url(); case 'choice_mapper': - return z.object({}); + return z.record(z.string(), z.any()); case 'project_mapper': case 'table': return z.array(z.any()); diff --git a/static/app/components/core/form/autoSaveForm.mdx b/static/app/components/core/form/autoSaveForm.mdx index 0f7fb55034c17c..ae8b5a8eca7c72 100644 --- a/static/app/components/core/form/autoSaveForm.mdx +++ b/static/app/components/core/form/autoSaveForm.mdx @@ -26,14 +26,14 @@ For traditional submit-based forms, see [Form](./form.mdx). ```jsx -import {z} from 'zod'; +import { z } from "zod"; -import {AutoSaveForm} from '@sentry/scraps/form'; +import { AutoSaveForm } from "@sentry/scraps/form"; -import {fetchMutation} from 'sentry/utils/queryClient'; +import { fetchMutation } from "sentry/utils/queryClient"; const schema = z.object({ - displayName: z.string().min(1, 'Display name is required'), + displayName: z.string().min(1, "Display name is required"), }); ) => fetchMutation({url: '/user/', method: 'PUT', data}), - onSuccess: data => { - queryClient.setQueryData(['user'], old => ({...old, ...data})); + mutationFn: (data: Partial) => + fetchMutation({ url: "/user/", method: "PUT", data }), + onSuccess: (data) => { + queryClient.setQueryData(["user"], (old) => ({ ...old, ...data })); }, }} > - {field => ( + {(field) => ( @@ -88,6 +89,35 @@ const schema = z.object({ > [!NOTE] > Do NOT use toasts to communicate auto-save status. The built-in inline indicators are the correct feedback mechanism. Toasts are noisy and disruptive for fields that save frequently. +## Transformed Submit Values + +`AutoSaveForm` keeps field state typed as the schema input, but submits the schema's parsed output to the mutation. This matches `useScrapsForm` and lets you use transforms or narrower output types without extra parsing in `mutationFn`. + +> [!WARNING] +> The schema is applied to the form value on submit, so unknown keys are stripped per Zod's default behavior. For map-like fields with arbitrary keys, use `z.record(z.string(), …)` — not `z.object({})`, which declares zero keys and will strip everything at submit time. + +```jsx +const schema = z.object({ + highlightContext: z.string().transform((value) => JSON.parse(value)), +}); + + }) => + fetchMutation({ url: "/project/", method: "PUT", data }), + }} +> + {(field) => ( + + + + )} +; +``` + ## Confirmation Dialogs For dangerous operations (security settings, permissions), use the `confirm` prop to show a confirmation modal before saving. It accepts a string (always shown) or a function (conditionally shown). @@ -126,11 +156,11 @@ Type the `mutationFn` with the API's data type, **not** the zod schema type. The ```jsx // ❌ Don't tie mutation type to the zod schema mutationFn: (data: Partial>) => - fetchMutation({url: '/user/', method: 'PUT', data}) + fetchMutation({ url: "/user/", method: "PUT", data }); // ✅ Use the API's data type mutationFn: (data: Partial) => - fetchMutation({url: '/user/', method: 'PUT', data}) + fetchMutation({ url: "/user/", method: "PUT", data }); ``` Make sure the zod schema's types are compatible with the API type. For example, if the API expects a string union like `'off' | 'low' | 'high'`, use `z.enum(['off', 'low', 'high'])` instead of `z.string()`. diff --git a/static/app/components/core/form/autoSaveForm.spec.tsx b/static/app/components/core/form/autoSaveForm.spec.tsx index 50314e6d64cccc..729c4c0d8aa4d0 100644 --- a/static/app/components/core/form/autoSaveForm.spec.tsx +++ b/static/app/components/core/form/autoSaveForm.spec.tsx @@ -1,5 +1,4 @@ import {useState} from 'react'; -import {expectTypeOf} from 'expect-type'; import {z} from 'zod'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; @@ -12,45 +11,11 @@ const testSchema = z.object({ testField: z.string(), }); -describe('AutoSaveForm', () => { - describe('types', () => { - it('should have data type flow towards callbacks', () => { - function TypeTestField() { - return ( - { - expectTypeOf(variables).toEqualTypeOf<{testField: string}>(); - return { - context: true, - }; - }, - mutationFn: data => Promise.resolve(data.testField), - onSuccess: data => { - expectTypeOf(data).toEqualTypeOf(); - }, - onError: (error, variables, context) => { - expectTypeOf(error).toEqualTypeOf(); - expectTypeOf(variables).toEqualTypeOf<{testField: string}>(); - expectTypeOf(context).toEqualTypeOf<{context: boolean} | undefined>(); - }, - }} - > - {field => ( - - - - )} - - ); - } - void TypeTestField; - }); - }); +const transformedTestSchema = z.object({ + testField: z.string().transform(value => value.toUpperCase()), +}); +describe('AutoSaveForm', () => { describe('reset after save', () => { it('shows server-transformed value after successful save', async () => { // Simulates a server that uppercases the value @@ -64,7 +29,9 @@ describe('AutoSaveForm', () => { initialValue={serverState} mutationOptions={{ mutationFn: (data: {testField: string}) => { - return Promise.resolve({testField: data.testField.toUpperCase()}); + return Promise.resolve({ + testField: data.testField.toUpperCase(), + }); }, onSuccess: data => { setServerState(data.testField); @@ -95,6 +62,33 @@ describe('AutoSaveForm', () => { expect(input).toHaveValue('HELLO'); }); }); + + it('submits transformed schema values to the mutation', async () => { + const mutationFn = jest.fn((data: {testField: string}) => Promise.resolve(data)); + + render( + + {field => ( + + + + )} + + ); + + const input = screen.getByRole('textbox', {name: 'Name'}); + await userEvent.type(input, 'hello'); + await userEvent.tab(); + + await waitFor(() => { + expect(mutationFn).toHaveBeenCalledWith({testField: 'HELLO'}, expect.anything()); + }); + }); }); describe('error handling', () => { @@ -141,7 +135,9 @@ describe('AutoSaveForm', () => { mutationOptions={{ mutationFn: () => { const error = new RequestError('POST', '/test/', new Error('test')); - error.responseJSON = {testField: ['This value is not allowed']}; + error.responseJSON = { + testField: ['This value is not allowed'], + }; throw error; }, }} diff --git a/static/app/components/core/form/autoSaveForm.tsx b/static/app/components/core/form/autoSaveForm.tsx index 384ed449666355..89828d7fbf9d42 100644 --- a/static/app/components/core/form/autoSaveForm.tsx +++ b/static/app/components/core/form/autoSaveForm.tsx @@ -32,25 +32,28 @@ type ConfirmConfig = | React.ReactNode | ((value: TValue) => React.ReactNode | undefined); -/** Form data type coming from the schema */ +type SchemaInput = z.input; +type SchemaOutput = z.output; + +/** Form data type coming from the schema input */ type SchemaFieldName = Extract< - DeepKeys>, + DeepKeys>, string >; /** FieldApi’s TData must be DeepValue */ -type SchemaFieldValue< +type SchemaFieldInputValue< TSchema extends z.ZodObject, TFieldName extends SchemaFieldName, -> = DeepValue, TFieldName>; +> = DeepValue, TFieldName>; type AutoSaveFormRenderArg< TSchema extends z.ZodObject, TFieldName extends SchemaFieldName, > = FieldApi< - z.infer, + SchemaInput, TFieldName, - SchemaFieldValue, + SchemaFieldInputValue, // Field validators (all can be undefined to satisfy the constraints) undefined, // TOnMount undefined, // TOnChange @@ -106,7 +109,7 @@ interface AutoSaveFormProps< TData, TContext, TSchema extends z.ZodObject, - TFieldName extends Extract, string>, + TFieldName extends Extract, string>, > { /** * Render prop that receives field props and additional props @@ -118,7 +121,7 @@ interface AutoSaveFormProps< /** * Initial value - must match the schema's type for this field */ - initialValue: z.infer[TFieldName]; + initialValue: SchemaInput[TFieldName]; /** * TanStack Query mutation options - mutationFn receives single-field data @@ -126,7 +129,7 @@ interface AutoSaveFormProps< mutationOptions: UseMutationOptions< TData, Error, - NoInfer[TFieldName]>>, + NoInfer[TFieldName]>>, TContext >; @@ -156,7 +159,7 @@ interface AutoSaveFormProps< * // Function with conditional confirmation * confirm={(value) => value === 'dangerous' ? "This is irreversible!" : undefined} */ - confirm?: ConfirmConfig[TFieldName]>; + confirm?: ConfirmConfig[TFieldName]>; } export function AutoSaveForm< @@ -165,7 +168,7 @@ export function AutoSaveForm< // Will be fixed by https://github.com/typescript-eslint/typescript-eslint/pull/12206 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments TSchema extends z.ZodObject, - TFieldName extends Extract, string>, + TFieldName extends Extract, string>, >(props: AutoSaveFormProps) { const {name, schema, initialValue, mutationOptions, confirm, children} = props; const id = useId(); @@ -178,7 +181,7 @@ export function AutoSaveForm< formId: `${name}-${id}-(auto-save)`, defaultValues: {[name]: initialValue} as Record< TFieldName, - z.infer[TFieldName] + SchemaInput[TFieldName] >, validators: { onChange: schema.pick({[name]: true}) as never, @@ -202,7 +205,9 @@ export function AutoSaveForm< const hasBackendErrors = error instanceof RequestError ? setFieldErrors(formApi, error) : false; if (!hasBackendErrors) { - setFieldErrors(formApi, {[name]: {message: t('Failed to save')}} as never); + setFieldErrors(formApi, { + [name]: {message: t('Failed to save')}, + } as never); } }; @@ -210,6 +215,16 @@ export function AutoSaveForm< formApi.reset(); }; + const parsedValue = schema.pick({[name]: true} as never).safeParse(value); + + if (!parsedValue.success) { + return Promise.resolve(); + } + + const submittedValue = parsedValue.data as Record< + TFieldName, + SchemaOutput[TFieldName] + >; const fieldValue = value[name]; // Determine confirmation message @@ -226,7 +241,7 @@ export function AutoSaveForm< pendingConfirmRef.current = false; // Resolve on both success and failure - error handling is done by // TanStack Query (onError callback, mutation.isError state) - mutation.mutateAsync(value, {onError, onSuccess}).then(() => { + mutation.mutateAsync(submittedValue, {onError, onSuccess}).then(() => { resolve(); }, resolve); }, @@ -246,7 +261,7 @@ export function AutoSaveForm< // Resolve on both success and failure - error handling is done by // TanStack Query (onError callback, mutation.isError state) - return mutation.mutateAsync(value, {onError, onSuccess}).catch(() => {}); + return mutation.mutateAsync(submittedValue, {onError, onSuccess}).catch(() => {}); }, }); From 249df1ed1f935d5205c834e36206375a6e3f4092 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 20 May 2026 14:20:54 +0200 Subject: [PATCH 3/7] ref(forms): parse highlights context via schema transform --- .../highlights/highlightsSettingsForm.tsx | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/static/app/components/events/highlights/highlightsSettingsForm.tsx b/static/app/components/events/highlights/highlightsSettingsForm.tsx index bad68863c217f7..94424d068a554a 100644 --- a/static/app/components/events/highlights/highlightsSettingsForm.tsx +++ b/static/app/components/events/highlights/highlightsSettingsForm.tsx @@ -23,21 +23,16 @@ const highlightTagsSchema = z.object({ highlightTags: z.string(), }); -function parseHighlightContextValue( - value: string -): NonNullable { - if (value === '') { - return {}; - } - return z.record(z.string(), z.array(z.string())).parse(JSON.parse(value)); -} - const highlightContextSchema = z.object({ - highlightContext: z.string().superRefine((value, ctx) => { + highlightContext: z.string().transform((value, ctx) => { + if (value === '') { + return {}; + } try { - parseHighlightContextValue(value); + return z.record(z.string(), z.array(z.string())).parse(JSON.parse(value)); } catch { ctx.addIssue({code: 'custom', message: t('Invalid JSON')}); + return z.NEVER; } }), }); @@ -77,10 +72,7 @@ function LoadedHighlightsSettingsForm({project}: LoadedHighlightsSettingsFormPro }); const highlightContextMutationOptions = mutationOptions({ - mutationFn: (data: {highlightContext: string}) => - updateProject({ - highlightContext: parseHighlightContextValue(data.highlightContext), - }), + mutationFn: (data: z.output) => updateProject(data), onSuccess: handleSubmitSuccess, }); From 4db2943300c48f13d426d497b2f597a0de0e98a4 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 21 May 2026 07:50:22 +0200 Subject: [PATCH 4/7] ref(forms): revert formatter changes in autoSaveForm.mdx Restore single quotes, brace spacing, and arrow-param style to match the rest of the file; keep only the new "Transformed Submit Values" section as the intended change. --- .../app/components/core/form/autoSaveForm.mdx | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/static/app/components/core/form/autoSaveForm.mdx b/static/app/components/core/form/autoSaveForm.mdx index ae8b5a8eca7c72..ddfc02afada44c 100644 --- a/static/app/components/core/form/autoSaveForm.mdx +++ b/static/app/components/core/form/autoSaveForm.mdx @@ -26,14 +26,14 @@ For traditional submit-based forms, see [Form](./form.mdx). ```jsx -import { z } from "zod"; +import {z} from 'zod'; -import { AutoSaveForm } from "@sentry/scraps/form"; +import {AutoSaveForm} from '@sentry/scraps/form'; -import { fetchMutation } from "sentry/utils/queryClient"; +import {fetchMutation} from 'sentry/utils/queryClient'; const schema = z.object({ - displayName: z.string().min(1, "Display name is required"), + displayName: z.string().min(1, 'Display name is required'), }); ) => - fetchMutation({ url: "/user/", method: "PUT", data }), - onSuccess: (data) => { - queryClient.setQueryData(["user"], (old) => ({ ...old, ...data })); + mutationFn: (data: Partial) => fetchMutation({url: '/user/', method: 'PUT', data}), + onSuccess: data => { + queryClient.setQueryData(['user'], old => ({...old, ...data})); }, }} > - {(field) => ( + {field => ( @@ -98,7 +97,7 @@ const schema = z.object({ ```jsx const schema = z.object({ - highlightContext: z.string().transform((value) => JSON.parse(value)), + highlightContext: z.string().transform(value => JSON.parse(value)), }); }) => - fetchMutation({ url: "/project/", method: "PUT", data }), + mutationFn: (data: {highlightContext: Record}) => + fetchMutation({url: '/project/', method: 'PUT', data}), }} > - {(field) => ( + {field => ( )} -; + ``` ## Confirmation Dialogs @@ -156,11 +155,11 @@ Type the `mutationFn` with the API's data type, **not** the zod schema type. The ```jsx // ❌ Don't tie mutation type to the zod schema mutationFn: (data: Partial>) => - fetchMutation({ url: "/user/", method: "PUT", data }); + fetchMutation({url: '/user/', method: 'PUT', data}) // ✅ Use the API's data type mutationFn: (data: Partial) => - fetchMutation({ url: "/user/", method: "PUT", data }); + fetchMutation({url: '/user/', method: 'PUT', data}) ``` Make sure the zod schema's types are compatible with the API type. For example, if the API expects a string union like `'off' | 'low' | 'high'`, use `z.enum(['off', 'low', 'high'])` instead of `z.string()`. From 957985739a62e29f3322d5d4eca3888cda4ec506 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 21 May 2026 07:53:47 +0200 Subject: [PATCH 5/7] ref(forms): mention passthrough for arbitrary keys in autosave docs --- static/app/components/core/form/autoSaveForm.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/core/form/autoSaveForm.mdx b/static/app/components/core/form/autoSaveForm.mdx index ddfc02afada44c..d7ff584fcda476 100644 --- a/static/app/components/core/form/autoSaveForm.mdx +++ b/static/app/components/core/form/autoSaveForm.mdx @@ -93,7 +93,7 @@ const schema = z.object({ `AutoSaveForm` keeps field state typed as the schema input, but submits the schema's parsed output to the mutation. This matches `useScrapsForm` and lets you use transforms or narrower output types without extra parsing in `mutationFn`. > [!WARNING] -> The schema is applied to the form value on submit, so unknown keys are stripped per Zod's default behavior. For map-like fields with arbitrary keys, use `z.record(z.string(), …)` — not `z.object({})`, which declares zero keys and will strip everything at submit time. +> The schema is applied to the form value on submit, so unknown keys are stripped per Zod's default behavior. For map-like fields with arbitrary keys, use `z.record(z.string(), …)` — not `z.object({})`, which declares zero keys and will strip everything at submit time. You can also use `.passthrough()` to allow arbitrary values. ```jsx const schema = z.object({ From 710fe6e33c00bcb0091c642497911a6f81129e97 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 21 May 2026 07:57:18 +0200 Subject: [PATCH 6/7] ref(forms): use z.looseObject for choice_mapper and update autosave docs Switch the choice_mapper schema in backendJsonFormAdapter to z.looseObject({}) so arbitrary keys pass through, matching the modern Zod 4 idiom (replaces deprecated .passthrough()). Mention z.looseObject alongside z.record in the AutoSaveForm doc warning. --- static/app/components/backendJsonFormAdapter/utils.ts | 2 +- static/app/components/core/form/autoSaveForm.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/components/backendJsonFormAdapter/utils.ts b/static/app/components/backendJsonFormAdapter/utils.ts index 5782da428d3182..4a55b7b536f28e 100644 --- a/static/app/components/backendJsonFormAdapter/utils.ts +++ b/static/app/components/backendJsonFormAdapter/utils.ts @@ -20,7 +20,7 @@ export function getZodType(fieldType: JsonFormAdapterFieldConfig['type']) { case 'url': return z.url(); case 'choice_mapper': - return z.record(z.string(), z.any()); + return z.looseObject({}); case 'project_mapper': case 'table': return z.array(z.any()); diff --git a/static/app/components/core/form/autoSaveForm.mdx b/static/app/components/core/form/autoSaveForm.mdx index d7ff584fcda476..bc743135fb167b 100644 --- a/static/app/components/core/form/autoSaveForm.mdx +++ b/static/app/components/core/form/autoSaveForm.mdx @@ -93,7 +93,7 @@ const schema = z.object({ `AutoSaveForm` keeps field state typed as the schema input, but submits the schema's parsed output to the mutation. This matches `useScrapsForm` and lets you use transforms or narrower output types without extra parsing in `mutationFn`. > [!WARNING] -> The schema is applied to the form value on submit, so unknown keys are stripped per Zod's default behavior. For map-like fields with arbitrary keys, use `z.record(z.string(), …)` — not `z.object({})`, which declares zero keys and will strip everything at submit time. You can also use `.passthrough()` to allow arbitrary values. +> The schema is applied to the form value on submit, so unknown keys are stripped per Zod's default behavior. For map-like fields with arbitrary keys, use `z.record(z.string(), …)` or `z.looseObject({})` — not `z.object({})`, which declares zero keys and will strip everything at submit time. ```jsx const schema = z.object({ From e7cf6ed945e386e305c729bcb957ef38060e27c4 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Thu, 21 May 2026 08:01:08 +0200 Subject: [PATCH 7/7] ref(forms): restore type-flow test in autoSaveForm.spec --- .../core/form/autoSaveForm.spec.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/static/app/components/core/form/autoSaveForm.spec.tsx b/static/app/components/core/form/autoSaveForm.spec.tsx index 729c4c0d8aa4d0..992f9f856062ea 100644 --- a/static/app/components/core/form/autoSaveForm.spec.tsx +++ b/static/app/components/core/form/autoSaveForm.spec.tsx @@ -1,4 +1,5 @@ import {useState} from 'react'; +import {expectTypeOf} from 'expect-type'; import {z} from 'zod'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; @@ -16,6 +17,44 @@ const transformedTestSchema = z.object({ }); describe('AutoSaveForm', () => { + describe('types', () => { + it('should have data type flow towards callbacks', () => { + function TypeTestField() { + return ( + { + expectTypeOf(variables).toEqualTypeOf<{testField: string}>(); + return { + context: true, + }; + }, + mutationFn: data => Promise.resolve(data.testField), + onSuccess: data => { + expectTypeOf(data).toEqualTypeOf(); + }, + onError: (error, variables, context) => { + expectTypeOf(error).toEqualTypeOf(); + expectTypeOf(variables).toEqualTypeOf<{testField: string}>(); + expectTypeOf(context).toEqualTypeOf<{context: boolean} | undefined>(); + }, + }} + > + {field => ( + + + + )} + + ); + } + void TypeTestField; + }); + }); + describe('reset after save', () => { it('shows server-transformed value after successful save', async () => { // Simulates a server that uppercases the value