diff --git a/src/components/ui/Sheet/Sheet.tsx b/src/components/ui/Sheet/Sheet.tsx index 14a0d3f3..686fe556 100644 --- a/src/components/ui/Sheet/Sheet.tsx +++ b/src/components/ui/Sheet/Sheet.tsx @@ -26,6 +26,12 @@ interface SheetProps { closeDisabled?: boolean; className?: string; ariaLabel?: string; + /** + * If provided, called before starting the close animation when the user + * triggers a close (Escape, overlay click, or close button). Return false + * (or a Promise that resolves to false) to keep the sheet open. + */ + confirmClose?: () => boolean | Promise; } const CLOSE_ANIMATION_DURATION = 280; @@ -46,6 +52,7 @@ export function Sheet({ closeDisabled = false, className, ariaLabel, + confirmClose, children, }: PropsWithChildren) { const { t } = useTranslation(); @@ -106,9 +113,17 @@ export function Sheet({ }; }, [open, isVisible, startClose]); - const handleClose = useCallback(() => { + const handleClose = useCallback(async () => { + if (confirmClose) { + try { + const ok = await confirmClose(); + if (ok === false) return; + } catch { + return; + } + } startClose(true); - }, [startClose]); + }, [confirmClose, startClose]); useEffect(() => { return () => { diff --git a/src/features/providers/sheets/ProviderSheet.tsx b/src/features/providers/sheets/ProviderSheet.tsx index 74abc030..4813c9d1 100644 --- a/src/features/providers/sheets/ProviderSheet.tsx +++ b/src/features/providers/sheets/ProviderSheet.tsx @@ -1,7 +1,8 @@ -import { useCallback, useId, useState } from 'react'; +import { useCallback, useEffect, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Sheet } from '@/components/ui/Sheet'; import { IconLoader2, IconPencil } from '@/components/ui/icons'; +import { useNotificationStore } from '@/stores'; import { PROVIDER_DESCRIPTORS } from '../descriptors'; import type { ProviderBrand, @@ -41,11 +42,48 @@ export function ProviderSheet({ onUpdated, }: ProviderSheetProps) { const { t } = useTranslation(); + const { showConfirmation } = useNotificationStore(); const formId = useId(); const [submitting, setSubmitting] = useState(false); + const [isDirty, setIsDirty] = useState(false); + + // Reset dirty flag whenever the sheet is closed or the editing target + // (brand / resource / mode) changes — the child form will re-mount and + // re-report its own dirty state. + useEffect(() => { + setIsDirty(false); + }, [state.brand, state.mode, state.resource?.id, state.open]); + + const handleDirtyChange = useCallback((dirty: boolean) => { + setIsDirty(dirty); + }, []); const descriptor = PROVIDER_DESCRIPTORS[state.brand]; const isAmpcode = state.brand === 'ampcode'; + const isEditingForm = state.mode === 'create' || state.mode === 'edit'; + + const confirmDiscardIfDirty = useCallback((): Promise => { + if (!isEditingForm || !isDirty || submitting) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + showConfirmation({ + title: t('providersPage.unsavedChanges.title'), + message: t('providersPage.unsavedChanges.message'), + variant: 'danger', + confirmText: t('providersPage.unsavedChanges.discard'), + cancelText: t('providersPage.unsavedChanges.keepEditing'), + onConfirm: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + }, [isDirty, isEditingForm, showConfirmation, submitting, t]); + + const handleCancelClick = useCallback(() => { + void confirmDiscardIfDirty().then((ok) => { + if (ok) onClose(); + }); + }, [confirmDiscardIfDirty, onClose]); const titleText = state.mode === 'create' @@ -116,6 +154,7 @@ export function ProviderSheet({ mutating={submitting || workbench.mutating} formId={formId} onSubmit={handleAmpcodeSubmit} + onDirtyChange={handleDirtyChange} /> ); } @@ -128,6 +167,7 @@ export function ProviderSheet({ mutating={submitting || workbench.mutating} formId={formId} onSubmit={state.mode === 'create' ? handleCreate : handleUpdate} + onDirtyChange={handleDirtyChange} /> ); }; @@ -166,7 +206,7 @@ export function ProviderSheet({