fix: 优化保存连接的键盘编辑流程

This commit is contained in:
victorwon2001 committed 2026-09-13 19:18:24 +09:00
1 parent a10f03b543
commit a57700c9c2
4 files changed
+256 -5

No files matched your search

@@ -0,0 +1,176 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SavedConnection } from "@/types/global";
import ConnectionItem from "./ConnectionItem";
import {
SavedConnectionsContext,
type SavedConnectionsContextValue,
} from "./context";
vi.mock("@/components/ui/context-menu", () => ({
ContextMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
ContextMenuTrigger: ({ children }: { children: ReactNode }) => (
<>{children}</>
),
ContextMenuContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ContextMenuItem: ({
children,
onClick,
}: {
children: ReactNode;
onClick?: () => void;
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
ContextMenuSeparator: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("./MoveToGroupMenu", () => ({ MoveToGroupContextMenu: () => null }));
const connection: SavedConnection = {
id: "ssh-1",
name: "Production",
type: "ssh",
host: "prod.example.com",
port: 22,
username: "root",
};
const handleConnectOnlyMock = vi.fn();
const onEditConnectionMock = vi.fn();
function createContextValue(): SavedConnectionsContextValue {
return {
isDragEnabled: false,
isPointerDragEnabled: false,
dragTarget: null,
expandedGroups: new Set(),
selectedConnectionIds: new Set([connection.id]),
keyboardActiveConnectionId: null,
savedConnections: [connection],
savedGroups: [],
toggleGroup: vi.fn(),
handleConnect: vi.fn(),
handleConnectOnly: handleConnectOnlyMock,
handleOpenSftp: vi.fn(),
handleConnectSelected: vi.fn(),
handleCopyConnection: vi.fn(),
requestMoveConnectionToGroup: vi.fn(),
requestMoveSelectedConnectionsToGroup: vi.fn(),
handleConnectionSelectionStart: vi.fn(),
handleConnectionContextMenu: vi.fn(),
registerConnectionElement: vi.fn(),
onEditConnection: onEditConnectionMock,
onNewConnection: vi.fn(),
requestDeleteConnection: vi.fn(),
setRenamingConn: vi.fn(),
setRenameValue: vi.fn(),
setDeleteFolderTarget: vi.fn(),
openNewFolderDialog: vi.fn(),
openRenameFolderDialog: vi.fn(),
requestOpenGroupConnections: vi.fn(),
handleDragStart: vi.fn(),
handleDragEnd: vi.fn(),
handleDragEnterItem: vi.fn(),
handleDragOverItem: vi.fn(),
handleDragLeaveItem: vi.fn(),
handleDropItem: vi.fn(),
handlePointerDragStart: vi.fn(),
handlePointerDragMove: vi.fn(),
handlePointerDragEnd: vi.fn(),
handlePointerDragCancel: vi.fn(),
t: ((key: string) => key) as SavedConnectionsContextValue["t"],
};
}
function renderConnectionItem() {
return render(
<SavedConnectionsContext.Provider value={createContextValue()}>
<ConnectionItem conn={connection} indented={false} />
</SavedConnectionsContext.Provider>,
);
}
function getConnectionTrigger(container: HTMLElement) {
const trigger = container.querySelector<HTMLElement>(
`[data-saved-drop-id="${connection.id}"]`,
);
if (!trigger) throw new Error("Connection trigger not found");
return trigger;
}
describe("ConnectionItem", () => {
beforeEach(() => {
handleConnectOnlyMock.mockReset();
onEditConnectionMock.mockReset();
});
it("connects exactly once when the focused connection receives Enter", async () => {
const user = userEvent.setup();
const { container } = renderConnectionItem();
const trigger = getConnectionTrigger(container);
trigger.focus();
await user.keyboard("{Enter}");
expect(handleConnectOnlyMock).toHaveBeenCalledTimes(1);
expect(handleConnectOnlyMock).toHaveBeenCalledWith(connection);
});
it("keeps nested action buttons independent from the parent Enter handler", async () => {
const user = userEvent.setup();
const { container } = renderConnectionItem();
const trigger = getConnectionTrigger(container);
const connectButton = screen.getAllByRole("button", {
name: "savedConnections.connect",
})[0];
const editButton = screen.getAllByRole("button", {
name: "savedConnections.edit",
})[0];
connectButton.focus();
await user.keyboard("{Enter}");
expect(handleConnectOnlyMock).toHaveBeenCalledTimes(1);
handleConnectOnlyMock.mockClear();
editButton.focus();
await user.keyboard("{Enter}");
expect(handleConnectOnlyMock).not.toHaveBeenCalled();
expect(onEditConnectionMock).toHaveBeenCalledTimes(1);
expect(onEditConnectionMock).toHaveBeenCalledWith(connection);
expect(document.activeElement).toBe(trigger);
});
it("preserves double-click connect and focuses the item before context-menu edit", () => {
const { container } = renderConnectionItem();
const trigger = getConnectionTrigger(container);
const row = trigger.firstElementChild as HTMLElement;
fireEvent.doubleClick(row);
expect(handleConnectOnlyMock).toHaveBeenCalledTimes(1);
const editButtons = screen.getAllByRole("button", {
name: "savedConnections.edit",
});
fireEvent.click(editButtons[1]);
expect(onEditConnectionMock).toHaveBeenCalledTimes(1);
expect(onEditConnectionMock).toHaveBeenCalledWith(connection);
expect(document.activeElement).toBe(trigger);
});
});
@@ -347,8 +347,10 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
const detailsOpenTimerRef = useRef<number | null>(null);
const detailsCloseTimerRef = useRef<number | null>(null);
const suppressDetailsUntilLeaveRef = useRef(false);
const itemRef = useRef<HTMLDivElement | null>(null);
const registerSelf = useCallback(
(element: HTMLDivElement | null) => {
itemRef.current = element;
registerConnectionElement(conn.id, element);
},
[conn.id, registerConnectionElement],
@@ -413,6 +415,12 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
closeDetails(true);
}, [closeDetails]);
const handleEditConnection = useCallback(() => {
closeAndSuppressDetails();
itemRef.current?.focus({ preventScroll: true });
onEditConnection(conn);
}, [closeAndSuppressDetails, conn, onEditConnection]);
useEffect(
() => () => {
clearDetailsOpenTimer();
@@ -428,9 +436,25 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
ref={registerSelf}
data-saved-drop-type="connection"
data-saved-drop-id={conn.id}
tabIndex={-1}
className="relative min-w-full w-max"
draggable={isDragEnabled && !isPointerDragEnabled}
onWheel={closeAndSuppressDetails}
onKeyDown={(event) => {
if (
event.target !== event.currentTarget ||
event.defaultPrevented ||
event.nativeEvent.isComposing ||
event.key === "Process" ||
event.key !== "Enter"
) {
return;
}
event.preventDefault();
closeAndSuppressDetails();
handleConnectOnly(conn);
}}
onPointerDown={
isPointerDragEnabled
? (e) => {
@@ -586,8 +610,7 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
}}
onClick={(e) => {
e.stopPropagation();
closeAndSuppressDetails();
onEditConnection(conn);
handleEditConnection();
}}
>
<MdEdit className="text-[0.95rem] cursor-pointer" />
@@ -629,8 +652,7 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
) : null}
<ContextMenuItem
onClick={() => {
closeAndSuppressDetails();
onEditConnection(conn);
handleEditConnection();
}}
>
<MdEdit className="text-[0.875rem] text-muted-foreground mr-2" />
+33
View File
@@ -210,4 +210,37 @@ describe("NewSessionPage", () => {
);
});
});
it("saves an edited connection when Enter is pressed in a single-line input", async () => {
render(<NewSessionPage />);
const nameInput = await screen.findByDisplayValue(rdpConnection.name);
invokeMock.mockClear();
fireEvent.keyDown(nameInput, { key: "Enter" });
await waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith(
"save_connection",
expect.objectContaining({
connection: expect.objectContaining({ id: rdpConnection.id }),
}),
);
expect(closeMock).toHaveBeenCalledTimes(1);
});
});
it("does not save from a textarea or while Enter is composing text", async () => {
render(<NewSessionPage />);
const nameInput = await screen.findByDisplayValue(rdpConnection.name);
const description = screen.getByPlaceholderText("dialog.descriptionPlaceholder");
invokeMock.mockClear();
fireEvent.keyDown(description, { key: "Enter" });
fireEvent.keyDown(nameInput, { key: "Enter", isComposing: true });
expect(invokeMock).not.toHaveBeenCalledWith("save_connection", expect.anything());
expect(closeMock).not.toHaveBeenCalled();
});
});
+21 -1
View File
@@ -1123,7 +1123,27 @@ export default function NewSessionPage() {
};
return (
<div className="h-full min-h-0 flex flex-col overflow-hidden bg-background text-foreground">
<div
className="h-full min-h-0 flex flex-col overflow-hidden bg-background text-foreground"
onKeyDown={(event) => {
if (
!editId ||
saveDisabled ||
event.defaultPrevented ||
event.nativeEvent.isComposing ||
event.key === "Process" ||
event.key !== "Enter"
) {
return;
}
if (!(event.target instanceof HTMLInputElement)) return;
if (!event.currentTarget.contains(event.target)) return;
event.preventDefault();
void handleSave();
}}
>
<ChildWindowHeader
title={t(editId ? "dialog.editConnection" : "dialog.newConnection")}
onClose={handleClose}