дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(database): submit and revert pending console result edits from the keyboard (#2370)
- The console result grid reuses the table editor's Submit / Revert actions and shortcuts, active only while the grid has focus and pending edits - ⌘↩ while editing a cell confirms that cell before submitting (the table editor does the same) - Submit / Revert button tooltips show their shortcuts; the result grid supports Go to Column - ⌘↩ still executes the statement in the SQL editor Closes #2332 Co-authored-by: LU XIANGRUI <[email protected]>
Цей коміт міститься в:
1 джерело
b50219b6fa
коміт
68dd88993b
6 files changed
+141
-14
No files matched your search
@@ -507,6 +507,7 @@ export function Grid(props: GridProps) {
|
||||
<input
|
||||
key={c}
|
||||
className={styles.cellEditor}
|
||||
data-cell-editor=""
|
||||
autoFocus
|
||||
aria-label={`Edit ${columns[c]!.name}`}
|
||||
value={editing.text}
|
||||
|
||||
@@ -4,13 +4,26 @@
|
||||
* a toolbar (re-run, export / copy as CSV, JSON, SQL…), the grid and a floating row count.
|
||||
*/
|
||||
import { copySelectionOnKeyDown } from "@/ui/selectableText";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { call, commands, type DbPlanNode, type DbQueryResult } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { Button, EmptyState, Icon, IconButton, Select, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
Spinner,
|
||||
Tabs,
|
||||
Tree,
|
||||
showContextMenu,
|
||||
T,
|
||||
tooltipAttrs,
|
||||
} from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { ChartView } from "./ChartView";
|
||||
import { ColumnsPopup } from "./ColumnsPopup";
|
||||
import { DataView, ExtractorSelect, PanelToggle, copyFormatOf, exportMenu, type DataPanel } from "./DataView";
|
||||
import type { ExportData, ExportFormat } from "./export";
|
||||
import { buildRowEdits, emptyEdits, hasEdits, splitTarget, type Cell, type PendingEdits } from "./model";
|
||||
@@ -45,6 +58,7 @@ import {
|
||||
type ResultTab,
|
||||
} from "./store";
|
||||
import styles from "./Database.module.css";
|
||||
import { registerTableEditor, tableEditorChanged, type TableEditorHandle } from "./tableEditorFocus";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
function time(t: number): string {
|
||||
@@ -327,6 +341,38 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Submit / Revert / Go to Column (⌘↩ / ⌥⌘Z / ⌘F12) reach the latest state through the handle, as in the table editor.
|
||||
const canSubmit = editable && dirty && !busy;
|
||||
const viewRef = useRef<HTMLDivElement>(null);
|
||||
const handleRef = useRef<TableEditorHandle>({
|
||||
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 (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.view} ref={viewRef} data-db-table-editor="">
|
||||
<div className={styles.toolbar}>
|
||||
<IconButton icon="db-table" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
|
||||
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
|
||||
@@ -372,10 +418,21 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
disabled={busy}
|
||||
onClick={() => setEdits((e) => ({ ...e, inserts: [...e.inserts, new Map()] }))}
|
||||
/>
|
||||
<Button size="small" variant="primary" disabled={!dirty || busy} onClick={() => void submit()}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => void submit()}
|
||||
{...tooltipAttrs("Submit", shortcutLabel("db.submitChanges"))}
|
||||
>
|
||||
<T>Submit</T>
|
||||
</Button>
|
||||
<Button size="small" disabled={!dirty || busy} onClick={() => setEdits(() => emptyEdits())}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => setEdits(() => emptyEdits())}
|
||||
{...tooltipAttrs("Revert", shortcutLabel("db.revertChanges"))}
|
||||
>
|
||||
<T>Revert</T>
|
||||
</Button>
|
||||
</>
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{columnsAt && (
|
||||
<ColumnsPopup
|
||||
anchor={columnsAt}
|
||||
table={tableLabel || tab.title}
|
||||
columns={columns}
|
||||
onPick={(col) => {
|
||||
setColumnsAt(null);
|
||||
setRevealColumn({ col });
|
||||
}}
|
||||
onClose={() => setColumnsAt(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()] }))}
|
||||
/>
|
||||
<Button size="small" variant="primary" disabled={!dirty} onClick={() => void submit()}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
disabled={!dirty}
|
||||
onClick={() => void submit()}
|
||||
{...tooltipAttrs("Submit", shortcutLabel("db.submitChanges"))}
|
||||
>
|
||||
<T>Submit</T>
|
||||
</Button>
|
||||
<Button size="small" disabled={!dirty} onClick={() => setEdits(emptyEdits())}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!dirty}
|
||||
onClick={() => setEdits(emptyEdits())}
|
||||
{...tooltipAttrs("Revert", shortcutLabel("db.revertChanges"))}
|
||||
>
|
||||
<T>Revert</T>
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -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(<ResultsView />));
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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<Element, TableEditorHandle>();
|
||||
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();
|
||||
};
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача