fix(frontend): replace browser confirm dialogs with AlertDialog

统一删除、撤销与未保存离开等确认操作为 shadcn AlertDialog,避免
window.confirm/alert 打断界面风格;同步更新 WAF 编辑器相关单测与 changelog。
This commit is contained in:
ryan
2026-08-06 13:08:43 +08:00
parent cffa009b8c
commit 484b49d79d
9 changed files with 429 additions and 100 deletions
@@ -1,12 +1,13 @@
import { render } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import { UnsavedChanges } from './unsaved-changes';
afterEach(() => vi.restoreAllMocks());
it('blocks same-origin application links when dirty and confirmation is declined', () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
it('blocks same-origin application links when dirty and confirmation is declined', async () => {
const user = userEvent.setup();
const { container } = render(
<>
<UnsavedChanges dirty />
@@ -18,13 +19,18 @@ it('blocks same-origin application links when dirty and confirmation is declined
cancelable: true,
button: 0,
});
container.querySelector('a')!.dispatchEvent(event);
expect(window.confirm).toHaveBeenCalledOnce();
act(() => {
container.querySelector('a')!.dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(true);
expect(
await screen.findByText('存在未保存的更改,确定离开吗?'),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '取消' }));
expect(screen.queryByText('存在未保存的更改,确定离开吗?')).toBeNull();
});
it('does not block application links without changes', () => {
const confirm = vi.spyOn(window, 'confirm');
const { getByRole } = render(
<>
<UnsavedChanges dirty={false} />
@@ -38,36 +44,49 @@ it('does not block application links without changes', () => {
});
event.preventDefault();
getByRole('link').dispatchEvent(event);
expect(confirm).not.toHaveBeenCalled();
expect(screen.queryByText('存在未保存的更改,确定离开吗?')).toBeNull();
});
it('restores declined Back and Forward transitions by indexed delta', () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
it('restores declined Back and Forward transitions by indexed delta', async () => {
const user = userEvent.setup();
const go = vi.spyOn(history, 'go').mockImplementation(() => undefined);
history.replaceState({ __wafEditorIndex: 4 }, '');
render(<UnsavedChanges dirty />);
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 3 } }),
);
act(() => {
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 3 } }),
);
});
expect(go).toHaveBeenLastCalledWith(1);
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 4 } }),
);
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 6 } }),
);
expect(await screen.findByText('存在未保存的更改,确定离开吗?')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '取消' }));
act(() => {
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 4 } }),
);
});
act(() => {
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 6 } }),
);
});
expect(go).toHaveBeenLastCalledWith(-2);
});
it('prompts and restores the current URL for an unknown unindexed history entry', () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
it('prompts and restores the current URL for an unknown unindexed history entry', async () => {
history.replaceState({ __wafEditorIndex: 4 }, '', '/waf/rules/editor?id=9');
const push = vi.spyOn(history, 'pushState');
render(<UnsavedChanges dirty />);
window.dispatchEvent(
new PopStateEvent('popstate', { state: { legacy: true } }),
);
expect(window.confirm).toHaveBeenCalledOnce();
act(() => {
window.dispatchEvent(
new PopStateEvent('popstate', { state: { legacy: true } }),
);
});
expect(await screen.findByText('存在未保存的更改,确定离开吗?')).toBeTruthy();
expect(push).toHaveBeenCalledWith(
expect.objectContaining({ __wafEditorIndex: 4 }),
'',
@@ -1,13 +1,39 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { getHistoryTransition } from './editor-behavior';
const historyIndexKey = '__wafEditorIndex';
type PendingLeave =
| { kind: 'href'; href: string }
| { kind: 'history'; restoreDelta: number };
export function UnsavedChanges({ dirty }: { dirty: boolean }) {
const [pendingLeave, setPendingLeave] = useState<PendingLeave | null>(null);
const dirtyRef = useRef(dirty);
const allowLeaveRef = useRef(false);
dirtyRef.current = dirty;
useEffect(() => {
if (!dirty) return;
if (!dirty) {
setPendingLeave(null);
return;
}
const initialState =
history.state && typeof history.state === 'object' ? history.state : {};
let currentIndex = Number.isInteger(initialState[historyIndexKey])
@@ -15,41 +41,51 @@ export function UnsavedChanges({ dirty }: { dirty: boolean }) {
: 0;
const currentUrl =
window.location.pathname + window.location.search + window.location.hash;
history.replaceState(
{ ...initialState, [historyIndexKey]: currentIndex },
'',
);
const originalPushState = history.pushState.bind(history);
const originalReplaceState = history.replaceState.bind(history);
history.pushState = (data, unused, url) => {
currentIndex++;
originalPushState(
{ ...data, [historyIndexKey]: currentIndex },
{ ...(data as object), [historyIndexKey]: currentIndex },
unused,
url,
);
};
history.replaceState = (data, unused, url) =>
originalReplaceState(
{ ...data, [historyIndexKey]: currentIndex },
{ ...(data as object), [historyIndexKey]: currentIndex },
unused,
url,
);
let restoring = false;
const handler = (event: BeforeUnloadEvent) => {
if (dirty) event.preventDefault();
if (dirtyRef.current && !allowLeaveRef.current) {
event.preventDefault();
}
};
const clickHandler = (event: MouseEvent) => {
if (
!dirty ||
!dirtyRef.current ||
allowLeaveRef.current ||
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
) {
return;
}
const link = (event.target as Element | null)?.closest(
'a[href]',
) as HTMLAnchorElement | null;
@@ -58,39 +94,55 @@ export function UnsavedChanges({ dirty }: { dirty: boolean }) {
link.target === '_blank' ||
new URL(link.href, window.location.href).origin !==
window.location.origin
)
) {
return;
if (!window.confirm('存在未保存的更改,确定离开吗?'))
event.preventDefault();
}
event.preventDefault();
setPendingLeave({ kind: 'href', href: link.href });
};
const popstateHandler = (event: PopStateEvent) => {
const hasTargetIndex = Number.isInteger(event.state?.[historyIndexKey]);
const targetIndex = hasTargetIndex
? (event.state[historyIndexKey] as number)
: currentIndex;
if (restoring) {
restoring = false;
currentIndex = targetIndex;
return;
}
if (window.confirm('存在未保存的更改,确定离开吗?')) {
if (allowLeaveRef.current || !dirtyRef.current) {
currentIndex = targetIndex;
return;
}
// Browser already navigated; restore immediately and ask for confirmation.
if (!hasTargetIndex) {
const destination =
window.location.pathname +
window.location.search +
window.location.hash;
originalPushState(
{ ...initialState, [historyIndexKey]: currentIndex },
'',
currentUrl,
);
setPendingLeave({ kind: 'href', href: destination });
return;
}
const { restoreDelta } = getHistoryTransition(currentIndex, targetIndex);
restoring = true;
history.go(getHistoryTransition(currentIndex, targetIndex).restoreDelta);
history.go(restoreDelta);
setPendingLeave({ kind: 'history', restoreDelta });
};
window.addEventListener('beforeunload', handler);
document.addEventListener('click', clickHandler, true);
window.addEventListener('popstate', popstateHandler);
return () => {
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
@@ -99,5 +151,47 @@ export function UnsavedChanges({ dirty }: { dirty: boolean }) {
window.removeEventListener('popstate', popstateHandler);
};
}, [dirty]);
return null;
const handleConfirmLeave = () => {
if (!pendingLeave) return;
allowLeaveRef.current = true;
const leave = pendingLeave;
setPendingLeave(null);
if (leave.kind === 'href') {
window.location.assign(leave.href);
return;
}
// Re-apply the history navigation the user originally attempted.
const delta = -leave.restoreDelta;
if (delta !== 0) {
history.go(delta);
}
};
return (
<AlertDialog
open={Boolean(pendingLeave)}
onOpenChange={(open) => {
if (!open) setPendingLeave(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>未保存的更改</AlertDialogTitle>
<AlertDialogDescription>
存在未保存的更改,确定离开吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmLeave}>
确定离开
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}