fix(providers): confirm before discarding unsaved edits in the provider sheet

- BaseProviderForm and AmpcodeForm now snapshot the initial form
  signature on mount and report dirty/clean state to the parent via a
  new onDirtyChange callback
- ProviderSheet tracks the form's dirty flag and wraps the cancel
  button plus the new Sheet.confirmClose hook with a confirmation
  prompt; the prompt is skipped during submit and in detail mode
- Sheet gains an optional confirmClose hook so that Escape, overlay
  click and the close button all route through the same async check
  before starting the close animation
- Add i18n keys (en/zh-CN/zh-TW/ru) for the unsaved-changes prompt
This commit is contained in:
LTbinglingfeng
2026-05-25 02:00:50 +08:00
parent dd3c39ec32
commit 8c3c9c1ec4
8 changed files with 114 additions and 6 deletions
+17 -2
View File
@@ -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<boolean>;
}
const CLOSE_ANIMATION_DURATION = 280;
@@ -46,6 +52,7 @@ export function Sheet({
closeDisabled = false,
className,
ariaLabel,
confirmClose,
children,
}: PropsWithChildren<SheetProps>) {
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 () => {
@@ -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<boolean> => {
if (!isEditingForm || !isDirty || submitting) {
return Promise.resolve(true);
}
return new Promise<boolean>((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({
<button
type="button"
className={`${styles.footerBtn} ${styles.footerBtnGhost}`}
onClick={onClose}
onClick={handleCancelClick}
disabled={submitting}
>
{t('providersPage.actions.cancel')}
@@ -205,6 +245,7 @@ export function ProviderSheet({
})}
footer={footer}
closeDisabled={submitting}
confirmClose={confirmDiscardIfDirty}
>
{renderBody()}
</Sheet>
@@ -1,4 +1,4 @@
import { useId, useState } from 'react';
import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapsible } from '@/components/ui/Collapsible';
import { IconPlus, IconX } from '@/components/ui/icons';
@@ -52,6 +52,7 @@ interface AmpcodeFormProps {
mutating: boolean;
formId: string;
onSubmit: (config: AmpcodeConfig) => Promise<void>;
onDirtyChange?: (dirty: boolean) => void;
}
export function AmpcodeForm({
@@ -59,13 +60,26 @@ export function AmpcodeForm({
mutating,
formId,
onSubmit,
onDirtyChange,
}: AmpcodeFormProps) {
const { t } = useTranslation();
const fid = useId();
const initialConfig = (resource?.raw as AmpcodeConfig | undefined) ?? {};
const [form, setForm] = useState<AmpcodeFormState>(() => buildState(initialConfig));
const [initialFormSignature] = useState<string>(() =>
JSON.stringify(buildState(initialConfig))
);
const [error, setError] = useState<string | null>(null);
const isDirty = useMemo(
() => JSON.stringify(form) !== initialFormSignature,
[form, initialFormSignature]
);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
@@ -1,4 +1,4 @@
import { useId, useMemo, useState } from 'react';
import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
IconAlertTriangle,
@@ -44,6 +44,7 @@ interface BaseProviderFormProps {
mutating: boolean;
formId: string;
onSubmit: (input: ProviderEntryFormInput) => Promise<void>;
onDirtyChange?: (dirty: boolean) => void;
}
const emptyHeader = () => ({ key: '', value: '' });
@@ -196,6 +197,7 @@ export function BaseProviderForm({
mutating,
formId,
onSubmit,
onDirtyChange,
}: BaseProviderFormProps) {
const { t } = useTranslation();
const descriptor = PROVIDER_DESCRIPTORS[brand];
@@ -203,8 +205,20 @@ export function BaseProviderForm({
const [form, setForm] = useState<ProviderEntryFormInput>(() =>
buildInitialForm(brand, resource, mode)
);
const [initialFormSignature] = useState<string>(() =>
JSON.stringify(buildInitialForm(brand, resource, mode))
);
const [error, setError] = useState<string | null>(null);
const isDirty = useMemo(
() => JSON.stringify(form) !== initialFormSignature,
[form, initialFormSignature]
);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
const fallbackApiKey = useMemo(() => {
if (mode !== 'edit' || !resource) return '';
if (brand === 'openaiCompatibility') return '';
+6
View File
@@ -1600,6 +1600,12 @@
"disabled": "Disabled",
"toggleFailed": "Failed to update status"
},
"unsavedChanges": {
"title": "Discard unsaved changes?",
"message": "You have unsaved edits in this form. Closing will discard them.",
"discard": "Discard changes",
"keepEditing": "Keep editing"
},
"connectivity": {
"test": "Test",
"testAll": "Test all",
+6
View File
@@ -1597,6 +1597,12 @@
"disabled": "Отключено",
"toggleFailed": "Не удалось обновить статус"
},
"unsavedChanges": {
"title": "Отменить несохранённые изменения?",
"message": "В форме есть несохранённые изменения. При закрытии они будут потеряны.",
"discard": "Отменить изменения",
"keepEditing": "Продолжить"
},
"connectivity": {
"test": "Проверить",
"testAll": "Проверить все",
+6
View File
@@ -1600,6 +1600,12 @@
"disabled": "已停用",
"toggleFailed": "更新状态失败"
},
"unsavedChanges": {
"title": "确定丢弃未保存的修改吗?",
"message": "当前表单还有未保存的修改,关闭后会被丢弃。",
"discard": "丢弃修改",
"keepEditing": "继续编辑"
},
"connectivity": {
"test": "测试",
"testAll": "全部测试",
+6
View File
@@ -1626,6 +1626,12 @@
"disabled": "已停用",
"toggleFailed": "更新狀態失敗"
},
"unsavedChanges": {
"title": "確定捨棄未儲存的變更嗎?",
"message": "目前表單還有尚未儲存的變更,關閉後會遺失。",
"discard": "捨棄變更",
"keepEditing": "繼續編輯"
},
"connectivity": {
"test": "測試",
"testAll": "全部測試",