fix: 优化保存连接的键盘编辑流程
This commit is contained in:
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" />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user