diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 43f675e96..632b3a833 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -11,6 +11,7 @@ import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements, ty import { formatSqlInsert } from "@/lib/exportFormats"; import { uuid } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settingsStore"; +import { expandNestedJsonStringsForCopy } from "@/lib/jsonCopyValue"; import type { DatabaseType, QueryResult } from "@/types/database"; import type { QueryResultExportRequest } from "@/lib/api"; @@ -395,7 +396,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function copyRowsAsJson(items: RowItem[]) { if (items.length === 0) return; const value = items.length === 1 ? rowToJsonObject(items[0]) : items.map(rowToJsonObject); - await copyText(JSON.stringify(value, null, 2)); + const copyValue = options.databaseType.value === "mongodb" ? expandNestedJsonStringsForCopy(value) : value; + await copyText(JSON.stringify(copyValue, null, 2)); } // --- Cell/row copy --- diff --git a/apps/desktop/src/lib/jsonCopyValue.ts b/apps/desktop/src/lib/jsonCopyValue.ts new file mode 100644 index 000000000..328a729a3 --- /dev/null +++ b/apps/desktop/src/lib/jsonCopyValue.ts @@ -0,0 +1,30 @@ +export function expandNestedJsonStringsForCopy(value: unknown): unknown { + if (typeof value === "string") { + return parseNestedJsonString(value) ?? value; + } + + if (Array.isArray(value)) { + return value.map(expandNestedJsonStringsForCopy); + } + + if (isPlainObject(value)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expandNestedJsonStringsForCopy(item)])); + } + + return value; +} + +function parseNestedJsonString(value: string): unknown | undefined { + const trimmed = value.trim(); + if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return undefined; + + try { + return expandNestedJsonStringsForCopy(JSON.parse(trimmed)); + } catch { + return undefined; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/app-tests/jsonCopyValue.test.ts b/packages/app-tests/jsonCopyValue.test.ts new file mode 100644 index 000000000..b3e1258e9 --- /dev/null +++ b/packages/app-tests/jsonCopyValue.test.ts @@ -0,0 +1,44 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { expandNestedJsonStringsForCopy } from "../../apps/desktop/src/lib/jsonCopyValue.ts"; + +test("expands nested JSON strings for copied rows", () => { + const value = { + _id: "67218700e884ae1f527640b6", + accountId: 581, + data: '{"endingBalance":{"beginningBalance":"0","endingBalance":"20000","endingDate":"2024-10-30"},"financeChargeInfo":null,"interestChargeInfo":null,"Line":[]}', + status: "draft", + }; + + assert.deepEqual(expandNestedJsonStringsForCopy(value), { + _id: "67218700e884ae1f527640b6", + accountId: 581, + data: { + endingBalance: { + beginningBalance: "0", + endingBalance: "20000", + endingDate: "2024-10-30", + }, + financeChargeInfo: null, + interestChargeInfo: null, + Line: [], + }, + status: "draft", + }); +}); + +test("recursively expands JSON strings in arrays and objects", () => { + const value = { + items: ['{"id":1,"meta":"{\\"ok\\":true}"}', "plain text"], + }; + + assert.deepEqual(expandNestedJsonStringsForCopy(value), { + items: [{ id: 1, meta: { ok: true } }, "plain text"], + }); +}); + +test("keeps non-object JSON-like cell strings unchanged", () => { + assert.equal(expandNestedJsonStringsForCopy("123"), "123"); + assert.equal(expandNestedJsonStringsForCopy('"text"'), '"text"'); + assert.equal(expandNestedJsonStringsForCopy("2024-10-30T01:08:14.454Z"), "2024-10-30T01:08:14.454Z"); +}); diff --git a/packages/app-tests/useDataGridExport.test.ts b/packages/app-tests/useDataGridExport.test.ts index 99928b39c..85ed57a61 100644 --- a/packages/app-tests/useDataGridExport.test.ts +++ b/packages/app-tests/useDataGridExport.test.ts @@ -15,8 +15,12 @@ const apiMock = vi.hoisted(() => ({ exportQueryResultMarkdown: vi.fn(), exportQueryResultsXlsx: vi.fn(), })); +const clipboardMock = vi.hoisted(() => ({ + copyToClipboard: vi.fn(), +})); vi.mock("@/lib/api", () => apiMock); +vi.mock("@/lib/clipboard", () => clipboardMock); vi.mock("@/lib/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); @@ -184,6 +188,7 @@ function buildTableDataExportHarness() { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); + clipboardMock.copyToClipboard.mockResolvedValue(undefined); apiMock.startQueryResultExport.mockImplementation(async (_request, onProgress) => { onProgress({ exportId: _request.exportId, tableName: "", rowsExported: 2, totalRows: 2, status: "Done" }); return { exportId: _request.exportId, tableName: "", rowsExported: 2, totalRows: 2, status: "Done" }; @@ -194,6 +199,101 @@ beforeEach(() => { }); }); +test("copy row JSON expands nested JSON strings", async () => { + const contextCell = ref({ rowId: 1, rowIndex: 0, col: 0 }); + const jsonString = '{"endingBalance":{"beginningBalance":"0","endingBalance":"20000","endingDate":"2024-10-30"},"financeChargeInfo":null,"interestChargeInfo":null,"Line":[]}'; + const row = { + id: 1, + data: ["67218700e884ae1f527640b6", jsonString, "draft"], + isNew: false, + isDeleted: false, + isDirtyCol: [false, false, false], + status: "", + }; + const composable = useDataGridExport({ + columns: computed(() => ["_id", "data", "status"]), + displayItems: computed(() => [row]), + sql: computed(() => undefined), + tableMeta: computed(() => undefined), + databaseType: computed(() => "mongodb"), + connectionId: computed(() => "conn-1"), + database: computed(() => "db"), + context: computed(() => "results"), + sourceColumns: computed(() => undefined), + columnTypes: computed(() => undefined), + whereInput: computed(() => undefined), + orderBy: computed(() => undefined), + exportBatchSize: computed(() => 1000), + hasCellSelection: computed(() => false), + selectedCells: computed(() => ({ columns: [], rows: [] })), + selectedRange: computed(() => null), + contextCell, + getRowItem: () => row, + selectedRowIds: ref(new Set()), + hasRowSelection: computed(() => false), + }); + + await composable.copyRow(); + + assert.equal(clipboardMock.copyToClipboard.mock.calls.length, 1); + assert.deepEqual(JSON.parse(clipboardMock.copyToClipboard.mock.calls[0][0]), { + _id: "67218700e884ae1f527640b6", + data: { + endingBalance: { + beginningBalance: "0", + endingBalance: "20000", + endingDate: "2024-10-30", + }, + financeChargeInfo: null, + interestChargeInfo: null, + Line: [], + }, + status: "draft", + }); +}); + +test("copy row JSON keeps nested JSON strings for non-MongoDB rows", async () => { + const contextCell = ref({ rowId: 1, rowIndex: 0, col: 0 }); + const jsonString = '{"enabled":true}'; + const row = { + id: 1, + data: [1, jsonString], + isNew: false, + isDeleted: false, + isDirtyCol: [false, false], + status: "", + }; + const composable = useDataGridExport({ + columns: computed(() => ["id", "payload"]), + displayItems: computed(() => [row]), + sql: computed(() => undefined), + tableMeta: computed(() => undefined), + databaseType: computed(() => "mysql"), + connectionId: computed(() => "conn-1"), + database: computed(() => "db"), + context: computed(() => "table"), + sourceColumns: computed(() => undefined), + columnTypes: computed(() => undefined), + whereInput: computed(() => undefined), + orderBy: computed(() => undefined), + exportBatchSize: computed(() => 1000), + hasCellSelection: computed(() => false), + selectedCells: computed(() => ({ columns: [], rows: [] })), + selectedRange: computed(() => null), + contextCell, + getRowItem: () => row, + selectedRowIds: ref(new Set()), + hasRowSelection: computed(() => false), + }); + + await composable.copyRow(); + + assert.deepEqual(JSON.parse(clipboardMock.copyToClipboard.mock.calls[0][0]), { + id: 1, + payload: jsonString, + }); +}); + test("default data grid export file names use sanitized base names and compact local timestamps", () => { vi.useFakeTimers(); try {