diff --git a/src/features/database/Grid.tsx b/src/features/database/Grid.tsx index 7bcc9c711..d9d8375b2 100644 --- a/src/features/database/Grid.tsx +++ b/src/features/database/Grid.tsx @@ -507,6 +507,7 @@ export function Grid(props: GridProps) { (null); + const handleRef = useRef({ + dirty: false, + submit: () => {}, + revert: () => {}, + showColumns: () => {}, + }); + const [columnsAt, setColumnsAt] = useState<{ x: number; y: number } | null>(null); + const [revealColumn, setRevealColumn] = useState<{ col: number } | null>(null); + useLayoutEffect(() => { + const handle = handleRef.current; + handle.dirty = canSubmit; + handle.submit = () => { + if (canSubmit) void submit(); + }; + handle.revert = () => { + if (!busy) setEdits(() => emptyEdits()); + }; + handle.showColumns = () => { + const rect = viewRef.current?.getBoundingClientRect(); + if (view === "table" && rect) setColumnsAt({ x: rect.left + rect.width / 2, y: rect.top + 48 }); + }; + tableEditorChanged(handle); + }); + const hasResult = !!r; + useEffect( + () => (hasResult && viewRef.current ? registerTableEditor(viewRef.current, handleRef.current) : undefined), + [hasResult], + ); + if (!r) return null; const data = (): ExportData => ({ columns, @@ -349,7 +395,7 @@ function Result({ tab }: { tab: ResultTab }) { // else: the table's details are still loading. } return ( -
+
setView("table")} /> setView("chart")} /> @@ -372,10 +418,21 @@ function Result({ tab }: { tab: ResultTab }) { disabled={busy} onClick={() => setEdits((e) => ({ ...e, inserts: [...e.inserts, new Map()] }))} /> - - @@ -421,6 +478,7 @@ function Result({ tab }: { tab: ResultTab }) { isModified={(row, col) => !!edits.updates.get(row)?.has(resultNames[col]!)} editable={editable && !busy} onEdit={onEdit} + revealColumn={revealColumn} rowMenu={(rows) => editable && !busy ? [ @@ -441,6 +499,18 @@ function Result({ tab }: { tab: ResultTab }) {
)} + {columnsAt && ( + { + setColumnsAt(null); + setRevealColumn({ col }); + }} + onClose={() => setColumnsAt(null)} + /> + )}
); } diff --git a/src/features/database/TableEditor.tsx b/src/features/database/TableEditor.tsx index 03bc6cd6a..c3545059a 100644 --- a/src/features/database/TableEditor.tsx +++ b/src/features/database/TableEditor.tsx @@ -13,7 +13,7 @@ import { type DbTablePage, type RequestId, } from "@/platform/ipc"; -import { Button, EmptyState, Icon, IconButton, Select, Spinner, T, showContextMenu } from "@/ui"; +import { Button, EmptyState, Icon, IconButton, Select, Spinner, T, showContextMenu, tooltipAttrs } from "@/ui"; import { notifications, type EditorInput } from "@/workbench/contributions"; import { SqlFilterInput } from "./SqlFilterInput"; import { ColumnsPopup } from "./ColumnsPopup"; @@ -241,10 +241,21 @@ export function TableEditor({ input }: { input: EditorInput }) { title="Add Row" onClick={() => setEdits((e) => ({ ...e, inserts: [...e.inserts, new Map()] }))} /> - - diff --git a/src/features/database/results-view.test.tsx b/src/features/database/results-view.test.tsx index b8d96c60f..82a133fd7 100644 --- a/src/features/database/results-view.test.tsx +++ b/src/features/database/results-view.test.tsx @@ -1,8 +1,10 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc"; +import { getContext } from "@/platform/context"; import { ResultsView } from "./ResultsView"; import { executeConsole, useDatabase, type ResultTab } from "./store"; +import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus"; const backend = vi.hoisted(() => ({ driver: "postgres" as DbDriver, @@ -326,3 +328,36 @@ describe("read-only results with a reason", () => { ); }); }); + +describe("result grid keyboard Submit / Revert (#2332)", () => { + it("confirms the cell being edited, then submits; reverts pending edits", async () => { + const uninstall = installTableEditorFocus(); + try { + show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]); + await act(async () => render()); + const submit = await screen.findByRole("button", { name: "Submit" }); + expect(submit.getAttribute("data-tooltip-shortcut")).toBeTruthy(); + const grid = screen.getByRole("grid"); + act(() => grid.focus()); + expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: false }); + + fireEvent.doubleClick(screen.getByRole("gridcell", { name: "Ann" })); + fireEvent.change(screen.getByRole("textbox", { name: "Edit name" }), { target: { value: "Bob" } }); + // Still the grid while a cell is being edited: ⌘↩ confirms the cell, then submits. + expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: true }); + await act(async () => activeTableEditor()!.submit()); + expect(backend.applyEdits).toHaveBeenCalledWith("pg", "public", "users", [ + { kind: "update", key: [{ column: "id", value: "1" }], values: [{ column: "name", value: "Bob" }] }, + ]); + + fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" })); + fireEvent.change(screen.getByRole("textbox", { name: "Edit name" }), { target: { value: "Cara" } }); + act(() => activeTableEditor()!.revert()); + expect(screen.queryByRole("textbox", { name: "Edit name" })).toBeNull(); + expect(screen.getByRole("gridcell", { name: "Ann" })).toBeTruthy(); + expect(getContext().dbTableEditorChanges).toBe(false); + } finally { + uninstall(); + } + }); +}); diff --git a/src/features/database/table-editor.test.tsx b/src/features/database/table-editor.test.tsx index 2980a62c5..ad923f695 100644 --- a/src/features/database/table-editor.test.tsx +++ b/src/features/database/table-editor.test.tsx @@ -104,8 +104,8 @@ describe("table editor capabilities", () => { fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" })); const input = screen.getByRole("textbox", { name: "Edit name" }); - // A text field keeps ⌘↩ for its own edit. - expect(getContext().dbTableEditorFocus).toBe(false); + // The inline cell editor still counts as the grid: ⌘↩ confirms the cell, then submits (#2332). + expect(getContext().dbTableEditorFocus).toBe(true); fireEvent.change(input, { target: { value: "Bob" } }); fireEvent.keyDown(input, { key: "Enter" }); act(() => grid.focus()); diff --git a/src/features/database/tableEditorFocus.ts b/src/features/database/tableEditorFocus.ts index 4453d2f92..8a145364b 100644 --- a/src/features/database/tableEditorFocus.ts +++ b/src/features/database/tableEditorFocus.ts @@ -1,7 +1,9 @@ /** - * The table editor that has focus, for Submit / Revert (⌘↩ / ⌥⌘Z) and Go to Column (⌘F12). Not - * while a text field has it: ⌘↩ there applies the field's own edit (the Value panel, the cell editor). + * The table editor or editable result grid that has focus, for Submit / Revert (⌘↩ / ⌥⌘Z) and Go to + * Column (⌘F12). Not while a text field has it: ⌘↩ there applies the field's own edit (the Value + * panel, the multi-line editor). The grid's inline cell editor counts as the grid: the cell is confirmed first. */ +import { flushSync } from "react-dom"; import { setContext } from "@/platform/context"; export interface TableEditorHandle { @@ -13,12 +15,15 @@ export interface TableEditorHandle { const handles = new WeakMap(); let active: TableEditorHandle | null = null; +let editingCell = false; -const isTextField = (el: Element) => !!el.closest("input, textarea, select, [contenteditable='true']"); +const isCellEditor = (el: Element) => el.matches("[data-cell-editor]"); +const isTextField = (el: Element) => + !isCellEditor(el) && !!el.closest("input, textarea, select, [contenteditable='true']"); function update() { setContext("dbTableEditorFocus", !!active); - setContext("dbTableEditorChanges", !!active?.dirty); + setContext("dbTableEditorChanges", !!active && (active.dirty || editingCell)); } function onFocusIn(event: FocusEvent) { @@ -29,6 +34,7 @@ function onFocusIn(event: FocusEvent) { target?.closest("[data-db-table-editor]") ?? (target?.hasAttribute("data-editor-focus") ? target.querySelector("[data-db-table-editor]") : null); active = (root && !isTextField(target!) && handles.get(root)) || null; + editingCell = !!active && isCellEditor(target!); update(); } @@ -49,7 +55,10 @@ export function tableEditorChanged(handle: TableEditorHandle) { if (active === handle) update(); } +/** The active editor, after confirming the cell being edited so its value counts. */ export function activeTableEditor(): TableEditorHandle | null { + const el = document.activeElement; + if (active && el instanceof HTMLElement && isCellEditor(el)) flushSync(() => el.blur()); return active; } @@ -58,6 +67,7 @@ export function installTableEditorFocus(): () => void { return () => { document.removeEventListener("focusin", onFocusIn); active = null; + editingCell = false; update(); }; }