Skip to content
2 changes: 1 addition & 1 deletion static/app/components/backendJsonFormAdapter/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function getZodType(fieldType: JsonFormAdapterFieldConfig['type']) {
case 'url':
return z.url();
case 'choice_mapper':
return z.object({});
return z.looseObject({});
case 'project_mapper':
case 'table':
return z.array(z.any());
Expand Down
29 changes: 29 additions & 0 deletions static/app/components/core/form/autoSaveForm.mdx
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lots of unrelated formatting changes here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes... I was expecting these to be auto fixed in this PR 😅

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,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(), …)` or `z.looseObject({})` — 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)),
});

<AutoSaveForm
name="highlightContext"
schema={schema}
initialValue="{}"
mutationOptions={{
mutationFn: (data: {highlightContext: Record<string, string[]>}) =>
fetchMutation({url: '/project/', method: 'PUT', data}),
}}
>
{field => (
<field.Layout.Row label="Highlight Context">
<field.TextArea value={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
)}
</AutoSaveForm>
```

## 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).
Expand Down
39 changes: 37 additions & 2 deletions static/app/components/core/form/autoSaveForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const testSchema = z.object({
testField: z.string(),
});

const transformedTestSchema = z.object({
testField: z.string().transform(value => value.toUpperCase()),
});

describe('AutoSaveForm', () => {
describe('types', () => {
it('should have data type flow towards callbacks', () => {
Expand Down Expand Up @@ -64,7 +68,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);
Expand Down Expand Up @@ -95,6 +101,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(
<AutoSaveForm
name="testField"
schema={transformedTestSchema}
initialValue=""
mutationOptions={{mutationFn}}
>
{field => (
<field.Layout.Row label="Name">
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
)}
</AutoSaveForm>
);

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', () => {
Expand Down Expand Up @@ -141,7 +174,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;
},
}}
Expand Down
45 changes: 30 additions & 15 deletions static/app/components/core/form/autoSaveForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,28 @@ type ConfirmConfig<TValue = unknown> =
| React.ReactNode
| ((value: TValue) => React.ReactNode | undefined);

/** Form data type coming from the schema */
type SchemaInput<TSchema extends z.ZodObject> = z.input<TSchema>;
type SchemaOutput<TSchema extends z.ZodObject> = z.output<TSchema>;

/** Form data type coming from the schema input */
type SchemaFieldName<TSchema extends z.ZodObject> = Extract<
DeepKeys<z.infer<TSchema>>,
DeepKeys<SchemaInput<TSchema>>,
string
>;

/** FieldApi’s TData must be DeepValue<TParentData, TName> */
type SchemaFieldValue<
type SchemaFieldInputValue<
TSchema extends z.ZodObject,
TFieldName extends SchemaFieldName<TSchema>,
> = DeepValue<z.infer<TSchema>, TFieldName>;
> = DeepValue<SchemaInput<TSchema>, TFieldName>;

type AutoSaveFormRenderArg<
TSchema extends z.ZodObject,
TFieldName extends SchemaFieldName<TSchema>,
> = FieldApi<
z.infer<TSchema>,
SchemaInput<TSchema>,
TFieldName,
SchemaFieldValue<TSchema, TFieldName>,
SchemaFieldInputValue<TSchema, TFieldName>,
// Field validators (all can be undefined to satisfy the constraints)
undefined, // TOnMount
undefined, // TOnChange
Expand Down Expand Up @@ -106,7 +109,7 @@ interface AutoSaveFormProps<
TData,
TContext,
TSchema extends z.ZodObject,
TFieldName extends Extract<keyof z.infer<TSchema>, string>,
TFieldName extends Extract<keyof SchemaInput<TSchema>, string>,
> {
/**
* Render prop that receives field props and additional props
Expand All @@ -118,15 +121,15 @@ interface AutoSaveFormProps<
/**
* Initial value - must match the schema's type for this field
*/
initialValue: z.infer<TSchema>[TFieldName];
initialValue: SchemaInput<TSchema>[TFieldName];

/**
* TanStack Query mutation options - mutationFn receives single-field data
*/
mutationOptions: UseMutationOptions<
TData,
Error,
NoInfer<Record<TFieldName, z.infer<TSchema>[TFieldName]>>,
NoInfer<Record<TFieldName, SchemaOutput<TSchema>[TFieldName]>>,
TContext
>;

Expand Down Expand Up @@ -156,7 +159,7 @@ interface AutoSaveFormProps<
* // Function with conditional confirmation
* confirm={(value) => value === 'dangerous' ? "This is irreversible!" : undefined}
*/
confirm?: ConfirmConfig<z.infer<TSchema>[TFieldName]>;
confirm?: ConfirmConfig<SchemaInput<TSchema>[TFieldName]>;
}

export function AutoSaveForm<
Expand All @@ -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<z.ZodRawShape>,
TFieldName extends Extract<keyof z.infer<TSchema>, string>,
TFieldName extends Extract<keyof SchemaInput<TSchema>, string>,
>(props: AutoSaveFormProps<TData, TContext, TSchema, TFieldName>) {
const {name, schema, initialValue, mutationOptions, confirm, children} = props;
const id = useId();
Expand All @@ -178,7 +181,7 @@ export function AutoSaveForm<
formId: `${name}-${id}-(auto-save)`,
defaultValues: {[name]: initialValue} as Record<
TFieldName,
z.infer<TSchema>[TFieldName]
SchemaInput<TSchema>[TFieldName]
>,
validators: {
onChange: schema.pick({[name]: true}) as never,
Expand All @@ -202,14 +205,26 @@ 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);
}
};

const onSuccess = () => {
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<TSchema>[TFieldName]
>;
const fieldValue = value[name];

// Determine confirmation message
Expand All @@ -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);
},
Expand All @@ -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(() => {});
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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()
Expand All @@ -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(<HighlightsSettingsForm projectSlug={project.slug} />, {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();
});
});
Loading
Loading