diff --git a/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts index ccaaf0a969..8c4566198b 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridBatchPasteFromCell.spec.ts @@ -7,6 +7,8 @@ import { createPinia, setActivePinia } from "pinia"; import { afterEach, describe, expect, it, vi } from "vitest"; import i18n from "@/i18n"; import type { QueryResult } from "@/types/database"; +import type { CustomSaveHandler } from "@/composables/useDataGridEditor"; +import { buildMongoUpdateDocument } from "@/lib/mongo/mongoDocumentValues"; import { TooltipProvider } from "@/components/ui/tooltip"; vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => { @@ -57,7 +59,16 @@ function defaultResult(): QueryResult { }; } -function mountGrid(options: { result?: QueryResult; quickEntry?: boolean; hideNullColumns?: boolean; readonlyColumnIndexes?: number[] } = {}) { +interface MountGridOptions { + result?: QueryResult; + quickEntry?: boolean; + hideNullColumns?: boolean; + readonlyColumnIndexes?: number[]; + databaseType?: "dameng" | "mongodb"; + customSaveHandler?: CustomSaveHandler; +} + +function mountGrid(options: MountGridOptions = {}) { const pinia = createPinia(); setActivePinia(pinia); const settingsStore = useSettingsStore(); @@ -76,9 +87,10 @@ function mountGrid(options: { result?: QueryResult; quickEntry?: boolean; hideNu default: () => h(DataGrid, { result: gridResult, - databaseType: "dameng", + databaseType: options.databaseType ?? "dameng", context: "table-data", editable: true, + customSaveHandler: options.customSaveHandler, readonlyColumnIndexes: options.readonlyColumnIndexes, tableMeta: { tableName: "paste_target", @@ -419,6 +431,69 @@ describe("DataGrid multi-row paste from a blank cell", () => { expect(pendingRows(host)).toHaveLength(1); }); + it("preserves an ordinary string when a Mongo column selection is pasted and saved", async () => { + const result: QueryResult = { + columns: ["_id", "status"], + rows: [ + ["1", "pending"], + ["2", "queued"], + ], + affected_rows: 0, + execution_time_ms: 0, + }; + const originals = [ + { _id: "1", status: "pending" }, + { _id: "2", status: "queued" }, + ]; + const updates: Array> = []; + const customSaveHandler: CustomSaveHandler = { + supportsInsert: false, + save: async ({ dirtyRows, columns }) => { + for (const [rowIndex, changes] of dirtyRows) { + updates.push(buildMongoUpdateDocument(changes, columns, originals[rowIndex])); + } + }, + }; + const { host } = mountGrid({ result, databaseType: "mongodb", customSaveHandler }); + await settle(); + await selectColumnHeader(host, 1); + await paste(host, "Y"); + gridRoot(host).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "s" })); + await settle(); + + expect(updates).toEqual([{ $set: { status: "Y" } }, { $set: { status: "Y" } }]); + }); + + it("keeps JSON-looking plain text when a Mongo column paste reaches a missing field", async () => { + const result: QueryResult = { + columns: ["_id", "status"], + rows: [ + ["1", null], + ["2", null], + ], + affected_rows: 0, + execution_time_ms: 0, + }; + const originals = [{ _id: "1" }, { _id: "2" }]; + const updates: Array> = []; + const customSaveHandler: CustomSaveHandler = { + supportsInsert: false, + save: async ({ dirtyRows, columns }) => { + for (const [rowIndex, changes] of dirtyRows) { + updates.push(buildMongoUpdateDocument(changes, columns, originals[rowIndex])); + } + }, + }; + const { host } = mountGrid({ result, databaseType: "mongodb", customSaveHandler }); + await settle(); + await selectColumnHeader(host, 1); + await paste(host, "{plain text"); + gridRoot(host).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "s" })); + await settle(); + + expect(updates).toEqual([{ $set: { status: "{plain text" } }, { $set: { status: "{plain text" } }]); + }); + it("routes DOM and canvas cell gestures through the same selection preparation", () => { const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGrid.vue"), "utf8"); const domGesture = source.slice(source.indexOf('@mousedown="\n prepareDataCellMouseDown'), source.indexOf('@mouseenter="onCellMouseenter')); diff --git a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts index 2fdc818110..a71963fd36 100644 --- a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts +++ b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts @@ -52,7 +52,9 @@ export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown { const trimmed = raw.trim(); if (trimmed === "NULL") return null; - if (/^(true|false|null)$/i.test(trimmed)) return JSON.parse(trimmed.toLowerCase()); + if (/^true$/i.test(trimmed)) return true; + if (/^false$/i.test(trimmed)) return false; + if (/^null$/i.test(trimmed)) return null; const shellDate = mongoShellDateToExtendedJson(trimmed); if (shellDate !== trimmed) return shellDate; @@ -68,7 +70,12 @@ export function parseMongoDocumentInputValue(raw: MongoInputValue): unknown { } if (/^-?\d+\.\d+$/.test(trimmed)) return Number(trimmed); if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith('"')) { - return mongoShellDateToExtendedJson(JSON.parse(trimmed)); + try { + return mongoShellDateToExtendedJson(JSON.parse(trimmed)); + } catch { + // JSON-shaped text is still valid user data when it is not valid JSON. + return raw; + } } return raw; } diff --git a/packages/app-tests/mongoDocumentValues.test.ts b/packages/app-tests/mongoDocumentValues.test.ts index ff0842f5d1..f0692641bf 100644 --- a/packages/app-tests/mongoDocumentValues.test.ts +++ b/packages/app-tests/mongoDocumentValues.test.ts @@ -61,6 +61,13 @@ test("preserves date-shaped Mongo strings instead of guessing Date", () => { assert.equal(parseMongoDocumentInputValue('"2025-08-14 02:25:43.718"'), "2025-08-14 02:25:43.718"); }); +test("preserves plain text that only resembles Mongo JSON input", () => { + for (const value of ["{plain text", "[plain text", '"plain text']) { + assert.doesNotThrow(() => parseMongoDocumentInputValue(value)); + assert.equal(parseMongoDocumentInputValue(value), value); + } +}); + test("preserves existing date-shaped Mongo string fields on grid update", () => { const original = { _id: "1",