mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +08:00
fix(frontend): replace browser confirm dialogs with AlertDialog
统一删除、撤销与未保存离开等确认操作为 shadcn AlertDialog,避免 window.confirm/alert 打断界面风格;同步更新 WAF 编辑器相关单测与 changelog。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user