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.record(z.string(), z.any());
case 'project_mapper':
case 'table':
return z.array(z.any());
Expand Down
50 changes: 40 additions & 10 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 @@ -26,28 +26,29 @@ For traditional submit-based forms, see [Form](./form.mdx).
</Storybook.Demo>

```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"),
});

<AutoSaveForm
name="displayName"
schema={schema}
initialValue={user.displayName}
mutationOptions={{
mutationFn: (data: Partial<User>) => fetchMutation({url: '/user/', method: 'PUT', data}),
onSuccess: data => {
queryClient.setQueryData(['user'], old => ({...old, ...data}));
mutationFn: (data: Partial<User>) =>
fetchMutation({ url: "/user/", method: "PUT", data }),
onSuccess: (data) => {
queryClient.setQueryData(["user"], (old) => ({ ...old, ...data }));
},
}}
>
{field => (
{(field) => (
<field.Layout.Row label="Display Name">
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
Expand Down Expand Up @@ -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.
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.

we can also use passthrough to allow arbitrary values

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.

passthrough is deprecated, so I am using looseObject({}) instead 710fe6e


```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 Expand Up @@ -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<z.infer<typeof schema>>) =>
fetchMutation({url: '/user/', method: 'PUT', data})
fetchMutation({ url: "/user/", method: "PUT", data });

// ✅ Use the API's data type
mutationFn: (data: Partial<UserDetails>) =>
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()`.
Expand Down
78 changes: 37 additions & 41 deletions static/app/components/core/form/autoSaveForm.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<AutoSaveForm
name="testField"
schema={testSchema}
initialValue=""
mutationOptions={{
onMutate: variables => {
expectTypeOf(variables).toEqualTypeOf<{testField: string}>();
return {
context: true,
};
},
mutationFn: data => Promise.resolve(data.testField),
onSuccess: data => {
expectTypeOf(data).toEqualTypeOf<string>();
},
onError: (error, variables, context) => {
expectTypeOf(error).toEqualTypeOf<Error>();
expectTypeOf(variables).toEqualTypeOf<{testField: string}>();
expectTypeOf(context).toEqualTypeOf<{context: boolean} | undefined>();
},
}}
>
{field => (
<field.Layout.Row label="Username">
<field.Input value={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
)}
</AutoSaveForm>
);
}
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
Expand All @@ -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);
Expand Down Expand Up @@ -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(
<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 +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;
},
}}
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
Loading
Loading