fix(objects): copy selected table names to the system clipboard
This commit is contained in:
1 parent
645adff972
commit
1d5ad22407
2 files changed
+188
-3
No files matched your search
@@ -121,6 +121,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { treeNodePinIdentity, type PinnedTreeNodeIdentity } from "@/lib/app/pinnedItems";
|
||||
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { formatSidebarTableNamesForCopy, type SidebarTableCopyTarget } from "@/lib/sidebar/sidebarTableNameCopy";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import MySqlEventEditor from "@/components/objects/MySqlEventEditor.vue";
|
||||
@@ -2472,7 +2473,29 @@ async function confirmDuplicateStructure() {
|
||||
}
|
||||
}
|
||||
|
||||
function copySelectedTablesToClipboard() {
|
||||
function objectBrowserTableNamesCopyText(rows: readonly ObjectBrowserRow[]): string {
|
||||
const targets = rows.map(
|
||||
(row) =>
|
||||
({
|
||||
id: row.name,
|
||||
label: row.name,
|
||||
type: "table",
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
catalog: props.catalog,
|
||||
schema: row.schema || selectedSchema.value || undefined,
|
||||
}) as SidebarTableCopyTarget,
|
||||
);
|
||||
return formatSidebarTableNamesForCopy(targets, {
|
||||
separator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
|
||||
includeSchema: settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema,
|
||||
databaseType: effectiveDatabaseType.value,
|
||||
driverProfile: props.connection.driver_profile,
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
|
||||
});
|
||||
}
|
||||
|
||||
async function copySelectedTablesToClipboard() {
|
||||
const selectedRows = selectedTableRows.value;
|
||||
if (selectedRows.length === 0) return;
|
||||
connectionStore.treeClipboard = {
|
||||
@@ -2485,7 +2508,12 @@ function copySelectedTablesToClipboard() {
|
||||
tableComment: row.comment,
|
||||
})),
|
||||
};
|
||||
toast(t("contextMenu.pasteTableClipboardUpdated"), 2000);
|
||||
try {
|
||||
await copyToClipboard(objectBrowserTableNamesCopyText(selectedRows));
|
||||
toast(t("contextMenu.pasteTableClipboardUpdated"), 2000);
|
||||
} catch (e: any) {
|
||||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function canPasteTableClipboard(): boolean {
|
||||
@@ -2586,7 +2614,7 @@ function onObjectBrowserKeydown(event: KeyboardEvent) {
|
||||
if (selectedTableCount.value === 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copySelectedTablesToClipboard();
|
||||
void copySelectedTablesToClipboard();
|
||||
return;
|
||||
}
|
||||
if (eventTargetAllowsAppClipboardShortcut(event, "v")) {
|
||||
|
||||
Vendored
+157
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ObjectBrowser from "@/components/objects/ObjectBrowser.vue";
|
||||
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listObjects: vi.fn(),
|
||||
listSchemas: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
store: { treeClipboard: null as unknown },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listObjects: (...args: unknown[]) => mocks.listObjects(...args),
|
||||
listSchemas: (...args: unknown[]) => mocks.listSchemas(...args),
|
||||
listObjectStatistics: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
get treeClipboard() {
|
||||
return mocks.store.treeClipboard;
|
||||
},
|
||||
set treeClipboard(value: unknown) {
|
||||
mocks.store.treeClipboard = value;
|
||||
},
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
getConfig: () => connection,
|
||||
orderByPinnedTreeNodes: (rows: unknown[]) => rows,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({
|
||||
editorSettings: {
|
||||
shortcuts: { refreshData: "F5" },
|
||||
objectBrowserViewMode: "list",
|
||||
objectBrowserShowCheckbox: false,
|
||||
sidebarCopyTableNameSeparator: "newline",
|
||||
sidebarCopyTableNameIncludeSchema: false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, locale: ref("en-US") }) }));
|
||||
vi.mock("@/i18n", () => ({ default: { install: () => undefined } }));
|
||||
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/lib/common/clipboard", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/common/clipboard")>()),
|
||||
copyToClipboard: (...args: unknown[]) => mocks.copyToClipboard(...args),
|
||||
}));
|
||||
vi.mock("vue-virtual-scroller", () => ({
|
||||
RecycleScroller: defineComponent({
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
props.items.map((item) => slots.default?.({ item })),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/ui/searchable-select", () => ({ SearchableSelect: { render: () => null } }));
|
||||
vi.mock("@/components/ui/ToolbarOverflowMenu.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/ui/CustomContextMenu.vue", () => ({
|
||||
default: defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => slots.default?.({ onContextMenu: () => undefined, isOpen: false });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/objects/ProcedureExecutionDialog.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/objects/CustomTypeInfoPanel.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/export/XlsxHeaderDialog.vue", () => ({ default: { render: () => null } }));
|
||||
|
||||
const connection = {
|
||||
id: "mysql-copy",
|
||||
name: "MySQL",
|
||||
db_type: "mysql",
|
||||
database: "app",
|
||||
driver_profile: null,
|
||||
url_params: null,
|
||||
transport_layers: [],
|
||||
} as unknown as ConnectionConfig;
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.store.treeClipboard = null;
|
||||
mocks.listObjects.mockResolvedValue([
|
||||
{ name: "orders", object_type: "TABLE" },
|
||||
{ name: "users", object_type: "TABLE" },
|
||||
{ name: "audit", object_type: "TABLE" },
|
||||
]);
|
||||
mocks.listSchemas.mockResolvedValue([]);
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.copyToClipboard.mockResolvedValue(undefined);
|
||||
invalidateObjectBrowserRowsCache({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
invalidateObjectBrowserRowsCache({});
|
||||
});
|
||||
|
||||
async function mountBrowser() {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp({ setup: () => () => h(ObjectBrowser, { connection, database: "app" }) });
|
||||
mountedApps.push({ app, host });
|
||||
app.mount(host);
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("users"));
|
||||
return host;
|
||||
}
|
||||
|
||||
function rowFor(host: HTMLElement, name: string): HTMLElement {
|
||||
const row = [...host.querySelectorAll<HTMLElement>(".grid.h-\\[34px\\]")].find((element) => element.textContent?.includes(name));
|
||||
expect(row, name).toBeDefined();
|
||||
return row!;
|
||||
}
|
||||
|
||||
describe("ObjectBrowser table copy", () => {
|
||||
it("writes the selected table names to the system clipboard on Ctrl+C", async () => {
|
||||
const host = await mountBrowser();
|
||||
rowFor(host, "orders").dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
rowFor(host, "users").dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
await nextTick();
|
||||
|
||||
host.querySelector<HTMLElement>("[data-object-browser-root]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "c", ctrlKey: true, bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("orders\nusers"));
|
||||
expect(mocks.store.treeClipboard).toMatchObject({ kind: "table-copy", tables: [{ tableName: "orders" }, { tableName: "users" }] });
|
||||
expect(mocks.toast).toHaveBeenCalledWith("contextMenu.pasteTableClipboardUpdated", 2000);
|
||||
});
|
||||
|
||||
it("reports a failed system clipboard write instead of claiming success", async () => {
|
||||
mocks.copyToClipboard.mockRejectedValue(new Error("denied"));
|
||||
const host = await mountBrowser();
|
||||
rowFor(host, "orders").dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
|
||||
await nextTick();
|
||||
|
||||
host.querySelector<HTMLElement>("[data-object-browser-root]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "c", ctrlKey: true, bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("grid.copyFailed", 5000));
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith("contextMenu.pasteTableClipboardUpdated", 2000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user