Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 69 additions & 5 deletions .claude/skills/generate-frontend-forms/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,62 @@ The form system automatically shows:
- **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
<AutoSaveField
name="require2FA"
schema={schema}
initialValue={false}
confirm={value =>
value
? 'This will remove all members without 2FA. Continue?'
: 'Are you sure you want to allow members without 2FA?'
}
mutationOptions={{...}}
>
{field => (
<field.Layout.Row label="Require Two-Factor Auth">
<field.Switch checked={field.state.value} onChange={field.handleChange} />
</field.Layout.Row>
)}
</AutoSaveField>
```

**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
Expand Down Expand Up @@ -522,8 +578,8 @@ function MyForm() {
...defaultFormOptions,
defaultValues: {...},
validators: {onDynamic: schema},
onSubmit: async ({value}) => {
await mutation.mutateAsync(value);
onSubmit: ({value}) => {
return mutation.mutateAsync(value).catch(() => {});
},
});

Expand Down Expand Up @@ -586,13 +642,21 @@ onSubmit: async ({value}) => {
await api.post('/users', value);
};

// ✅ Use mutations with fetchMutation
// ❌ 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: async ({value}) => {
await mutation.mutateAsync(value);
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(() => {});
};
```

Expand Down
82 changes: 78 additions & 4 deletions static/app/components/core/form/field/autoSaveField.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useId} from 'react';
import {useId, useRef} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {DeepKeys, DeepValue, FieldApi} from '@tanstack/react-form';
import {useMutation, type UseMutationOptions} from '@tanstack/react-query';
Expand All @@ -7,6 +7,25 @@ import {type z} from 'zod';
import {AutoSaveContextProvider} from '@sentry/scraps/form/autoSaveContext';
import {useScrapsForm, type BoundFieldComponents} from '@sentry/scraps/form/scrapsForm';

import {openConfirmModal} from 'sentry/components/confirm';

/**
* Configuration for confirmation dialogs before applying changes.
* Used for dangerous operations like security settings.
* Always focuses the Cancel button for safety.
*
* @example
* // Simple string - always show this message
* confirm="Are you sure you want to save?"
*
* @example
* // Function - return message based on new value, or undefined to skip
* confirm={(value) => value ? "Enable this feature?" : "Disable this feature?"}
*/
type ConfirmConfig<TValue = unknown> =
| React.ReactNode
| ((value: TValue) => React.ReactNode | undefined);

/** Form data type coming from the schema */
type SchemaFieldName<TSchema extends z.ZodObject<z.ZodRawShape>> = Extract<
DeepKeys<z.infer<TSchema>>,
Expand Down Expand Up @@ -109,16 +128,36 @@ interface AutoSaveFieldProps<
* Zod schema for validation
*/
schema: TSchema;

/**
* Optional confirmation dialog before saving.
* Shows a modal and requires user confirmation before applying changes.
*
* @example
* // Simple string - always show this message
* confirm="Are you sure you want to save this change?"
*
* @example
* // Function - return message based on new value, or undefined to skip
* confirm={(value) => value ? "Enable this feature?" : "Disable this feature?"}
*
* @example
* // Function with conditional confirmation
* confirm={(value) => value === 'dangerous' ? "This is irreversible!" : undefined}
*/
confirm?: ConfirmConfig<z.infer<TSchema>[TFieldName]>;
}

export function AutoSaveField<
TSchema extends z.ZodObject<z.ZodRawShape>,
TFieldName extends Extract<keyof z.infer<TSchema>, string>,
>(props: AutoSaveFieldProps<TSchema, TFieldName>) {
const {name, schema, initialValue, mutationOptions, children} = props;
const {name, schema, initialValue, mutationOptions, confirm, children} = props;

const id = useId();
const mutation = useMutation(mutationOptions);
// Track pending confirmation to prevent duplicate modals
const pendingConfirmRef = useRef(false);

const form = useScrapsForm({
formId: `${name}-${id}-(auto-save)`,
Expand All @@ -137,10 +176,45 @@ export function AutoSaveField<
},
},
onSubmit: ({value}) => {
if (mutation.status === 'pending') {
if (mutation.status === 'pending' || pendingConfirmRef.current) {
return Promise.resolve();
}
return mutation.mutateAsync(value);

const fieldValue = value[name];

// Determine confirmation message
const confirmMessage =
typeof confirm === 'function' ? confirm(fieldValue) : confirm;

if (confirmMessage) {
pendingConfirmRef.current = true;
return new Promise<void>(resolve => {
openConfirmModal({
message: confirmMessage,
isDangerous: true,
onConfirm: () => {
pendingConfirmRef.current = false;
// Resolve on both success and failure - error handling is done by
// TanStack Query (onError callback, mutation.isError state)
mutation.mutateAsync(value).then(() => resolve(), resolve);
},
onClose: () => {
// onClose is always called, even after confirming,
// so we check pendingConfirmRef to avoid resetting the form
// after a successful confirm
if (pendingConfirmRef.current) {
form.reset();
resolve();
}
pendingConfirmRef.current = false;
},
});
});
}

// Resolve on both success and failure - error handling is done by
// TanStack Query (onError callback, mutation.isError state)
return mutation.mutateAsync(value).catch(() => {});
},
});

Expand Down
Loading
Loading