From 6c3e5ae469dc403dc240aef71f3680d752fdfa44 Mon Sep 17 00:00:00 2001 From: Vadim Lazuk Date: Tue, 10 Nov 2026 00:22:56 +0300 Subject: [PATCH] feat(database): JSON-aware value editor for json cells (#2410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #2333 Editing json / jsonb / MySQL JSON cells showed one long plain text line, and invalid JSON only failed on Submit. The Value panel and the grid's ⇧Enter window now open the IDE's code editor for JSON cells, pretty-printed (big integers and duplicate keys preserved, large values formatted off the main thread) with highlighting, folding and find; validation as you type blocks Apply only for JSON columns; Reformat; Apply of an unchanged value creates no pending edit, and drafts survive opening the window. XML and bytea modes from #2333 remain. Co-authored-by: Inf1mum <8135225+Inf1mum@users.noreply.github.com> --- e2e/databaseJsonValue.spec.ts | 149 +++++++++++ src/editor/core/cm/setup.ts | 4 +- src/features/database/DataView.tsx | 124 +++++++-- src/features/database/Database.module.css | 31 +++ src/features/database/ExportDialog.test.tsx | 5 +- src/features/database/Grid.test.tsx | 219 ++++++++++++++- src/features/database/Grid.tsx | 44 ++- src/features/database/JsonValueEditor.tsx | 282 ++++++++++++++++++++ src/features/database/index.ts | 2 + src/features/database/jsonValue.test.ts | 83 ++++++ src/features/database/jsonValue.ts | 76 ++++++ src/features/database/jsonValue.worker.ts | 6 + src/features/database/valueEditorActions.ts | 65 +++++ src/platform/keymaps/eclipse-macos.ts | 2 + src/platform/keymaps/eclipse-windows.ts | 2 + src/platform/keymaps/eclipse.test.ts | 2 +- src/platform/keymaps/intellij-macos.ts | 3 + src/platform/keymaps/intellij-windows.ts | 3 + src/platform/keymaps/vscode-macos.ts | 3 + src/platform/locales/text/en.json | 3 + src/platform/locales/text/zh.json | 3 + 21 files changed, 1079 insertions(+), 32 deletions(-) create mode 100644 e2e/databaseJsonValue.spec.ts create mode 100644 src/features/database/JsonValueEditor.tsx create mode 100644 src/features/database/jsonValue.test.ts create mode 100644 src/features/database/jsonValue.ts create mode 100644 src/features/database/jsonValue.worker.ts create mode 100644 src/features/database/valueEditorActions.ts diff --git a/e2e/databaseJsonValue.spec.ts b/e2e/databaseJsonValue.spec.ts new file mode 100644 index 000000000..1c75a9f21 --- /dev/null +++ b/e2e/databaseJsonValue.spec.ts @@ -0,0 +1,149 @@ +import type { EditorView } from "@codemirror/view"; +import { expect, test } from "./fixtures"; + +const compact = '{"name":"Ada","tags":["math","code"],"n":9007199254740993}'; + +for (const type of ["json", "text"]) { + test(`JSON value window preserves text and uses ${type} validation rules`, async ({ workbench: page }) => { + await page.evaluate( + async ({ compact, type }) => { + const reactPath = "/node_modules/.vite/deps/react.js"; + const domPath = "/node_modules/.vite/deps/react-dom_client.js"; + const viewPath = "/src/features/database/DataView.tsx"; + const react = await import(reactPath); + const dom = await import(domPath); + const { createElement, useState } = react.default ?? react; + const { createRoot } = dom.default ?? dom; + const { DataView } = await import(viewPath); + const host = document.createElement("div"); + host.style.cssText = "position:fixed;inset:100px;z-index:100;display:flex;background:var(--bg-main)"; + document.body.append(host); + function Value() { + const [value, setValue] = useState(compact); + return createElement(DataView, { + columns: [{ name: "payload", type }], + rowCount: 1, + value: () => value, + editable: true, + panel: "value", + onPanelChange: () => {}, + onEdit: (_row: number, _col: number, text: string) => setValue(text), + }); + } + createRoot(host).render(createElement(Value)); + }, + { compact, type }, + ); + const cell = page.getByRole("gridcell", { name: compact, exact: true }); + await cell.click(); + const panel = page.getByRole("complementary", { name: "Value panel" }); + const editor = panel.locator(".cm-content"); + await expect(editor).toContainText("9007199254740993"); + await expect(panel.getByRole("button", { name: "Apply", exact: true })).toBeDisabled(); + await cell.press("Shift+Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog.locator(".cm-content")).toBeFocused(); + await expect + .poll(() => + dialog.locator(".cm-editor").evaluate((editor) => { + const body = editor.closest("[data-dialog-body]")!; + return Math.abs(body.clientHeight - editor.clientHeight); + }), + ) + .toBeLessThanOrEqual(4); + await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(cell).toHaveText(compact); + await editor.evaluate((element) => { + const view = (element as HTMLElement & { cmTile: { view: EditorView } }).cmTile.view; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: '{"name":"Grace","n":9007199254740993}' }, + }); + view.focus(); + }); + await page.keyboard.press("Meta+Alt+L"); + await expect(editor.locator(".cm-line")).toHaveCount(4); + await page.keyboard.press("Meta+f"); + await expect(panel.locator(".cm-search")).toBeVisible(); + await panel.locator(".cm-search button[name=close]").click(); + await panel.getByRole("button", { name: "Open in Window", exact: true }).click(); + await expect(dialog.locator(".cm-content")).toContainText("Grace"); + await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeEnabled(); + await dialog.locator(".cm-content").evaluate((element) => { + const view = (element as HTMLElement & { cmTile: { view: EditorView } }).cmTile.view; + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "plain text" } }); + }); + const apply = dialog.getByRole("button", { name: "Apply", exact: true }); + if (type === "json") { + await expect(apply).toBeDisabled(); + await expect(dialog.getByRole("status")).toBeVisible(); + } else { + await expect(apply).toBeEnabled(); + await apply.click(); + await expect(page.getByRole("gridcell", { name: "plain text", exact: true })).toBeVisible(); + } + }); +} + +test("validates a large JSON value in a worker and applies the exact edited text", async ({ workbench: page }) => { + await page.evaluate(async () => { + const reactPath = "/node_modules/.vite/deps/react.js"; + const domPath = "/node_modules/.vite/deps/react-dom_client.js"; + const viewPath = "/src/features/database/JsonValueEditor.tsx"; + const react = await import(reactPath); + const dom = await import(domPath); + const { createElement } = react.default ?? react; + const { createRoot } = dom.default ?? dom; + const { JsonValueWindow } = await import(viewPath); + const host = document.createElement("div"); + document.body.append(host); + const result = document.createElement("output"); + result.id = "applied-json"; + document.body.append(result); + createRoot(host).render( + createElement(JsonValueWindow, { + title: "Edit payload", + initial: '{"large":"' + "x".repeat(5_000_000) + '"}', + validate: true, + onClose: () => {}, + onApply: (text: string) => { + result.textContent = text; + }, + }), + ); + }); + const dialog = page.getByRole("dialog", { name: "Edit payload" }); + const editor = dialog.locator(".cm-content"); + await expect(dialog.getByRole("button", { name: "Reformat", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Reformat", exact: true }).click(); + await expect + .poll(() => + editor.evaluate( + (element) => + (element as HTMLElement & { cmTile: { view: EditorView } }).cmTile.view.state.doc.toString() === + '{\n "large": "' + "x".repeat(5_000_000) + '"\n}', + ), + ) + .toBe(true); + await editor.evaluate((element) => { + const view = (element as HTMLElement & { cmTile: { view: EditorView } }).cmTile.view; + view.dispatch({ changes: { from: view.state.doc.length - 1, to: view.state.doc.length } }); + }); + await expect(dialog.getByRole("status")).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled(); + await editor.evaluate((element) => { + const view = (element as HTMLElement & { cmTile: { view: EditorView } }).cmTile.view; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: '{\n "large": "' + "x".repeat(5_000_000) + '"\n}' }, + }); + }); + await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeEnabled(); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect + .poll(() => + page + .locator("#applied-json") + .evaluate((element) => element.textContent === '{\n "large": "' + "x".repeat(5_000_000) + '"\n}'), + ) + .toBe(true); +}); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 1c01eac6b..a6ee0d556 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -317,6 +317,8 @@ export interface SetupOptions { wrapOverride?: boolean | null; /** Extra extensions owned by the slot (update listeners, focus handlers). */ extra?: Extension; + /** Standalone value editors supply their own extensions without file-backed contributions. */ + contributions?: Extension; } /** @@ -407,7 +409,7 @@ export function baseExtensions(o: SetupOptions): Extension { wrapOverride: o.wrapOverride, }), ), - compartments.contributions.of(contributionExtensions(o.ctx)), + compartments.contributions.of(o.contributions ?? contributionExtensions(o.ctx)), compartments.distractionFree.of(distractionFreeExtension(o.settings)), o.extra ?? [], ]; diff --git a/src/features/database/DataView.tsx b/src/features/database/DataView.tsx index 8c4a41e4f..9a8ab407c 100644 --- a/src/features/database/DataView.tsx +++ b/src/features/database/DataView.tsx @@ -26,6 +26,15 @@ import { } from "./resultView"; import { openDialog } from "./store"; import { hasLineBreak } from "./cellPreview"; +import { + initialJsonText, + isJsonColumn, + isJsonValue, + jsonErrorOf, + JSON_INLINE_LIMIT, + type JsonError, +} from "./jsonValue"; +import { JsonValueEditor, JsonValueWindow, type JsonEditorApi } from "./JsonValueEditor"; import styles from "./Database.module.css"; import { tx } from "@/platform/i18n"; @@ -479,8 +488,9 @@ function SidePanel(props: { ) : props.panel === "value" ? ( props.onEdit(range.lead.row, range.lead.col, v)} @@ -558,33 +568,109 @@ function RecordField(props: { column: GridColumn; cell: Cell; editable: boolean; ); } -function ValueView(props: { column: string; cell: Cell; editable: boolean; onCommit: (v: Cell) => void }) { - const [text, setText] = useState(props.cell ?? ""); - const dirty = text !== (props.cell ?? "") || (props.cell === null && text !== ""); +/** The Value panel's editor: plain text, or the JSON code editor for JSON cells (#2333). */ +function ValueView(props: { column: GridColumn; cell: Cell; editable: boolean; onCommit: (v: Cell) => void }) { + const name = props.column.name; + const [json] = useState(() => isJsonValue(props.column.type, props.cell)); + const validate = isJsonColumn(props.column.type); + // JSON opens pretty-printed; the panel applies what the editor shows. + const [initial] = useState(() => (json ? initialJsonText(props.cell ?? "") : (props.cell ?? ""))); + const [text, setText] = useState(initial); + const [error, setError] = useState(() => + props.cell !== null && validate && initial.length <= JSON_INLINE_LIMIT ? jsonErrorOf(initial) : null, + ); + const [pending, setPending] = useState(json && initial.length > JSON_INLINE_LIMIT); + const editor = useRef(null); + const dirty = text !== initial; + const commit = () => { + if (props.editable && dirty && !error && !pending) props.onCommit(text); + }; + const [windowOpen, setWindowOpen] = useState(false); return (
-