From e545a6d28377ad5dd1c6dd63cd74c62ebda3eda3 Mon Sep 17 00:00:00 2001 From: emrberk Date: Fri, 12 Jun 2026 12:57:16 +0300 Subject: [PATCH 01/15] refactor: migrate old grid.js into a new reusable ResultGrid component --- e2e/commands.js | 115 ++- e2e/questdb | 2 +- e2e/tests/console/grid.spec.js | 595 +++++++++++-- e2e/tests/console/schema.spec.js | 9 +- e2e/tests/enterprise/oidc.spec.js | 2 +- package.json | 2 + src/components/Button/index.tsx | 1 + src/components/ResultGrid/ResultGrid.tsx | 837 ++++++++++++++++++ src/components/ResultGrid/dimensions.ts | 4 + src/components/ResultGrid/index.ts | 21 + .../ResultGrid/inlineGridUtils.test.ts | 259 ++++++ src/components/ResultGrid/inlineGridUtils.ts | 168 ++++ .../ResultGrid/resultPageMarkdown.test.ts | 92 ++ .../ResultGrid/resultPageMarkdown.ts | 53 ++ src/components/ResultGrid/styles.ts | 396 +++++++++ src/components/ResultGrid/types.ts | 44 + .../ResultGrid/useContainerWidth.ts | 23 + .../ResultGrid/useGridKeyboardNav.ts | 189 ++++ src/components/ResultGrid/useScrollShadows.ts | 27 + .../ResultGrid/virtualRowMapping.test.ts | 33 + .../ResultGrid/virtualRowMapping.ts | 18 + src/js/console/grid.js | 93 +- src/providers/LocalStorageProvider/index.tsx | 46 +- src/providers/LocalStorageProvider/types.ts | 1 + src/scenes/Result/ResultGridAdapter.tsx | 186 ++++ src/scenes/Result/columnLayoutStore.ts | 71 ++ src/scenes/Result/index.tsx | 131 +-- src/scenes/Result/nextPageWindow.test.ts | 46 + src/scenes/Result/nextPageWindow.ts | 81 ++ src/scenes/Result/usePagedDataSource.ts | 223 +++++ src/utils/generateMatViewDDL.test.ts | 4 +- src/utils/localStorage/types.ts | 1 + src/utils/questdb/types.ts | 8 +- yarn.lock | 40 + 34 files changed, 3617 insertions(+), 204 deletions(-) create mode 100644 src/components/ResultGrid/ResultGrid.tsx create mode 100644 src/components/ResultGrid/dimensions.ts create mode 100644 src/components/ResultGrid/index.ts create mode 100644 src/components/ResultGrid/inlineGridUtils.test.ts create mode 100644 src/components/ResultGrid/inlineGridUtils.ts create mode 100644 src/components/ResultGrid/resultPageMarkdown.test.ts create mode 100644 src/components/ResultGrid/resultPageMarkdown.ts create mode 100644 src/components/ResultGrid/styles.ts create mode 100644 src/components/ResultGrid/types.ts create mode 100644 src/components/ResultGrid/useContainerWidth.ts create mode 100644 src/components/ResultGrid/useGridKeyboardNav.ts create mode 100644 src/components/ResultGrid/useScrollShadows.ts create mode 100644 src/components/ResultGrid/virtualRowMapping.test.ts create mode 100644 src/components/ResultGrid/virtualRowMapping.ts create mode 100644 src/scenes/Result/ResultGridAdapter.tsx create mode 100644 src/scenes/Result/columnLayoutStore.ts create mode 100644 src/scenes/Result/nextPageWindow.test.ts create mode 100644 src/scenes/Result/nextPageWindow.ts create mode 100644 src/scenes/Result/usePagedDataSource.ts diff --git a/e2e/commands.js b/e2e/commands.js index 2b267fe8d..49bb9032d 100644 --- a/e2e/commands.js +++ b/e2e/commands.js @@ -127,24 +127,127 @@ Cypress.Commands.add("getByDataHook", (name) => cy.get(`[data-hook="${name}"]`)) Cypress.Commands.add("getByRole", (name) => cy.get(`[role="${name}"]`)) Cypress.Commands.add("getGrid", () => - cy.get(".qg-viewport .qg-canvas").should("be.visible"), + cy + .get("[data-hook='grid-viewport'] [data-hook='grid-canvas']") + .should("be.visible"), ) -Cypress.Commands.add("getGridViewport", () => cy.get(".qg-viewport")) +Cypress.Commands.add("getGridViewport", () => + cy.get("[data-hook='grid-viewport']"), +) Cypress.Commands.add("getGridRow", (n) => - cy.get(".qg-r").filter(":visible").eq(n), + cy.get("[data-hook='grid-row']").filter(":visible").eq(n), ) Cypress.Commands.add("getColumnName", (n) => - cy.get(".qg-header-name").eq(n).invoke("text"), + cy.get("[data-hook='grid-header-name']").eq(n).invoke("text"), ) Cypress.Commands.add("getGridCol", (n) => - cy.get(".qg-c").filter(":visible").eq(n), + cy.get("[data-hook='grid-cell']").filter(":visible").eq(n), +) + +Cypress.Commands.add("getGridRows", () => + cy.get("[data-hook='grid-row']").filter(":visible"), +) + +Cypress.Commands.add("getGridCellAt", (row, col) => + cy.get(`#cell-${row}-${col}`), +) + +Cypress.Commands.add("getActiveCell", () => + cy.get("[data-hook='grid-cell'][aria-selected='true']"), +) + +Cypress.Commands.add("getFrozenCells", () => + cy.get("[data-hook='grid-cell'][data-frozen='true']"), ) -Cypress.Commands.add("getGridRows", () => cy.get(".qg-r").filter(":visible")) +Cypress.Commands.add("getGridHeaderCopy", (n) => + cy.get("[role='columnheader']").eq(n).find(".header-copy-btn"), +) + +Cypress.Commands.add("gridToolbar", (name) => + cy.get(`[data-hook='grid-toolbar-${name}']`), +) + +Cypress.Commands.add("selectGridCell", (row, col) => { + const selector = `#cell-${row}-${col}` + cy.get(selector).click() + cy.get(selector).should("have.attr", "aria-selected", "true") +}) + +Cypress.Commands.add("gridKey", (keyOptions) => + cy.get("[role='grid']").trigger("keydown", { force: true, ...keyOptions }), +) + +Cypress.Commands.add("resizeColumn", (n, dx) => { + cy.get("[data-hook='grid-col-resizer']") + .filter(":visible") + .eq(n) + .then(($resizer) => { + const rect = $resizer[0].getBoundingClientRect() + const startX = rect.x + rect.width / 2 + const y = rect.y + rect.height / 2 + cy.wrap($resizer).trigger("mousedown", { + button: 0, + clientX: startX, + clientY: y, + force: true, + }) + cy.get("body").trigger("mousemove", { + clientX: startX + dx, + clientY: y, + force: true, + }) + cy.get("body").trigger("mouseup", { + clientX: startX + dx, + clientY: y, + force: true, + }) + }) +}) + +Cypress.Commands.add("freezeColumnViaHandle", (dx) => { + cy.get("[data-hook='grid-freeze-handle']").then(($handle) => { + const rect = $handle[0].getBoundingClientRect() + const startX = rect.x + rect.width / 2 + const y = rect.y + rect.height / 2 + cy.wrap($handle).trigger("mousedown", { + button: 0, + clientX: startX, + clientY: y, + force: true, + }) + cy.get("body").trigger("mousemove", { + clientX: startX + dx, + clientY: y, + force: true, + }) + cy.get("body").trigger("mouseup", { + clientX: startX + dx, + clientY: y, + force: true, + }) + }) +}) + +Cypress.Commands.add("dragFreezeHandleTo", (clientX) => { + cy.get("[data-hook='grid-freeze-handle']").then(($handle) => { + const rect = $handle[0].getBoundingClientRect() + const startX = rect.x + rect.width / 2 + const y = rect.y + rect.height / 2 + cy.wrap($handle).trigger("mousedown", { + button: 0, + clientX: startX, + clientY: y, + force: true, + }) + cy.get("body").trigger("mousemove", { clientX, clientY: y, force: true }) + cy.get("body").trigger("mouseup", { clientX, clientY: y, force: true }) + }) +}) Cypress.Commands.add("typeQuery", (query) => cy.getEditor().realClick().type(query), diff --git a/e2e/questdb b/e2e/questdb index 5df7d4278..4fe188379 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 5df7d42780e63d473638d8b5c32cbad6659fc8a4 +Subproject commit 4fe188379eca5d8b1189484ce53565e113bac063 diff --git a/e2e/tests/console/grid.spec.js b/e2e/tests/console/grid.spec.js index 2f5a9f0ee..a1f1a262b 100644 --- a/e2e/tests/console/grid.spec.js +++ b/e2e/tests/console/grid.spec.js @@ -2,80 +2,567 @@ const rowHeight = 30 -const assertRowCount = () => { - cy.get(".qg-viewport").then(($el) => { - cy.getGridRows().should("have.length", Math.ceil($el.height() / rowHeight)) - }) -} +const threeColumnQuery = "select x a, x b, x c from long_sequence(20)" + +const distinctColumnQuery = + "select x a, x * 10 b, x * 100 c from long_sequence(20)" + +const readClipboard = () => + cy + .window() + .its("navigator.clipboard") + .then((clip) => clip.readText()) describe("questdb grid", () => { beforeEach(() => { cy.loadConsoleWithAuth() }) - it("when results empty", () => { - cy.typeQuery("select x from long_sequence(0)") - cy.runLine() - cy.getGridRows().should("have.length", 0) - }) + describe("rendering and pagination", () => { + it("when results empty", () => { + cy.typeQuery("select x from long_sequence(0)") + cy.runLine() + cy.getGridViewport().should("be.visible") + // Scoped from the viewport so the zero-length assertion doesn't wait for + // a grid-row that legitimately never appears. + cy.get("[data-hook='grid-row']").should("have.length", 0) + }) - it("when results have vertical scroll", () => { - cy.typeQuery(`select x from long_sequence(100)`) - cy.runLine() - cy.wait(100) - cy.getGridViewport().then(($el) => { - cy.getGridRows().should( - "have.length", - Math.ceil($el.height() / rowHeight), - ) + it("when results have vertical scroll", () => { + cy.typeQuery(`select x from long_sequence(100)`) + cy.runLine() + cy.wait(100) + + // The grid fills the viewport with the first rows... cy.getGridRow(0).should("contain", "1") + cy.getGridRows().should("have.length.greaterThan", 5) + + // ...and scrolling to the bottom brings the last row into view. + cy.getGridViewport().scrollTo("bottom") + cy.contains("[data-hook='grid-row']", "100").should("be.visible") }) - cy.getGridViewport().scrollTo("bottom") - cy.getGridViewport().then(($el) => { - const totalRows = Math.ceil($el.height() / rowHeight) - cy.getGridRows().should("have.length", totalRows) - cy.getGridRow(totalRows - 1).should("contain", "100") + it("multiple scrolls till the bottom", () => { + const rows = 1000 + const rowsPerPage = 128 + cy.typeQuery(`select x from long_sequence(${rows})`) + cy.runLine() + + for (let i = 0; i < rows; i += rowsPerPage) { + cy.getGridViewport().scrollTo(0, i * rowHeight) + cy.wait(100) + cy.getGrid() + .contains(i + 1) + .click() + } + + cy.getGridViewport().scrollTo("bottom") }) - }) - it("multiple scrolls till the bottom", () => { - const rows = 1000 - const rowsPerPage = 128 - cy.typeQuery(`select x from long_sequence(${rows})`) - cy.runLine() + it("multiple scrolls till the bottom with error", () => { + const rows = 1200 + cy.typeQuery(`select simulate_crash('P') from long_sequence(${rows})`) + cy.runLine() + + cy.getGridViewport().scrollTo(0, 999 * rowHeight) + cy.getCollapsedNotifications().should("contain", "1,200 rows in") - for (let i = 0; i < rows; i += rowsPerPage) { - cy.getGridViewport().scrollTo(0, i * rowHeight) + cy.getGridViewport().scrollTo("bottom") cy.wait(100) - cy.getGrid() - .contains(i + 1) - .click() - } + cy.getCollapsedNotifications().should( + "contain", + "simulated cairo exception", + ) + }) + }) + + describe("keyboard navigation", () => { + it("arrow keys move the active cell", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + + // When + cy.selectGridCell(0, 0) + cy.gridKey({ key: "ArrowRight" }) + cy.gridKey({ key: "ArrowDown" }) + + // Then + cy.getActiveCell().should("have.id", "cell-1-1") + }) + + it("Home and End jump to the row's first and last column", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + cy.selectGridCell(0, 1) + + // When / Then + cy.gridKey({ key: "End" }) + cy.getActiveCell().should("have.id", "cell-0-2") + + cy.gridKey({ key: "Home" }) + cy.getActiveCell().should("have.id", "cell-0-0") + }) + + it("Ctrl+Home and Ctrl+End jump to the grid corners", () => { + // Given + cy.typeQuery("select x from long_sequence(50)") + cy.runLine() + cy.selectGridCell(0, 0) + + // When / Then + cy.gridKey({ key: "End", ctrlKey: true }) + cy.getActiveCell().should("have.id", "cell-49-0") + + cy.gridKey({ key: "Home", ctrlKey: true }) + cy.getActiveCell().should("have.id", "cell-0-0") + }) + + it("PageDown and PageUp move by a viewport of rows", () => { + // Given + cy.typeQuery("select x from long_sequence(200)") + cy.runLine() + cy.selectGridCell(0, 0) + + // When + cy.gridKey({ key: "PageDown" }) + + // Then — moved well past a single row + cy.getActiveCell().should("not.have.id", "cell-0-0") + + // When / Then — back to the top + cy.gridKey({ key: "PageUp" }) + cy.getActiveCell().should("have.id", "cell-0-0") + }) + }) - cy.getGridViewport().scrollTo("bottom") + describe("copy", () => { + it("Ctrl+C copies the focused cell and pulses it", () => { + // Given + cy.typeQuery("select x from long_sequence(10)") + cy.runLine() + cy.selectGridCell(0, 0) + + // When + cy.realPress(["Control", "c"]) + + // Then + cy.getActiveCell().should("have.attr", "data-pulse", "true") + readClipboard().should("eq", "1") + }) + + it("the header copy button copies the column name", () => { + // Given + cy.typeQuery("select x from long_sequence(10)") + cy.runLine() + + // When + cy.getGridHeaderCopy(0).click({ force: true }) + + // Then + readClipboard().should("eq", "x") + }) }) - it("multiple scrolls till the bottom with error", () => { - const rows = 1200 - cy.typeQuery(`select simulate_crash('P') from long_sequence(${rows})`) - cy.runLine() + describe("yield focus to editor", () => { + it("F2 clears the selection and returns focus to the editor", () => { + // Given + cy.typeQuery("select x from long_sequence(10)") + cy.runLine() + cy.selectGridCell(0, 0) + cy.getActiveCell().should("exist") + + // When + cy.realPress("F2") + + // Then + cy.getActiveCell().should("not.exist") + cy.getEditor().find("textarea").should("be.focused") + }) + }) + + describe("move column to front", () => { + it("the '/' shortcut moves the focused column to the front", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + cy.selectGridCell(0, 2) + + // When + cy.gridKey({ key: "/" }) + + // Then + cy.getColumnName(0).should("eq", "c") + }) + + it("the toolbar button moves the focused column to the front", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + cy.selectGridCell(0, 2) + + // When + cy.gridToolbar("move-front").click() - cy.getGridViewport().scrollTo(0, 999 * rowHeight) - cy.getCollapsedNotifications().should("contain", "1,200 rows in") + // Then + cy.getColumnName(0).should("eq", "c") + }) + + it("the toolbar button is disabled until a cell is selected", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + + // Then — nothing selected yet + cy.gridToolbar("move-front").should("be.disabled") + + // When + cy.selectGridCell(0, 0) + + // Then + cy.gridToolbar("move-front").should("not.be.disabled") + }) + + it("keeps each value under its own header when a column is already frozen", () => { + // Given a result with distinct per-column values and the left column (a) frozen + cy.typeQuery(distinctColumnQuery) + cy.runLine() + cy.gridToolbar("freeze").click() + cy.getGridCellAt(0, 0).should("have.attr", "data-frozen", "true") + + // When the user moves the third column (c) to the front + cy.selectGridCell(0, 2) + cy.gridToolbar("move-front").click() + + // Then the frozen column stays first and the moved column follows it + cy.getColumnName(0).should("eq", "a") + cy.getColumnName(1).should("eq", "c") + cy.getColumnName(2).should("eq", "b") - cy.getGridViewport().scrollTo("bottom") - cy.wait(100) - cy.getCollapsedNotifications().should( - "contain", - "simulated cairo exception", - ) + // And every cell still shows its own column's value, not a neighbour's + cy.getGridCellAt(0, 0).should("have.text", "1") + cy.getGridCellAt(0, 1).should("have.text", "100") + cy.getGridCellAt(0, 2).should("have.text", "10") + }) }) - it("copy cell into the clipboard", () => { - cy.typeQuery("select x from long_sequence(10)") - cy.runLine() - cy.getGridCol(0).type("{ctrl}c") - cy.getGridCol(0).should("have.class", "qg-c-active-pulse") + describe("freeze left", () => { + it("the toolbar freezes and unfreezes the left column", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + + // When + cy.gridToolbar("freeze").click() + + // Then + cy.getGridCellAt(0, 0).should("have.attr", "data-frozen", "true") + cy.gridToolbar("freeze").should("have.attr", "data-selected", "true") + + // When + cy.gridToolbar("freeze").click() + + // Then + cy.getFrozenCells().should("have.length", 0) + cy.gridToolbar("freeze").should("have.attr", "data-selected", "false") + }) + + it("dragging the freeze handle freezes an additional column", () => { + // Given + cy.typeQuery("select x a, x b, x c, x d from long_sequence(20)") + cy.runLine() + cy.gridToolbar("freeze").click() + cy.getGridCellAt(0, 1).should("not.have.attr", "data-frozen") + + // When + cy.freezeColumnViaHandle(250) + + // Then + cy.getGridCellAt(0, 1).should("have.attr", "data-frozen", "true") + }) + + it("dragging the handle from an unfrozen grid freezes multiple columns", () => { + // Given a four-column result with no frozen columns + cy.typeQuery("select x a, x b, x c, x d from long_sequence(20)") + cy.runLine() + cy.getGridCellAt(0, 0).should("be.visible") + cy.getFrozenCells().should("have.length", 0) + cy.get("[data-hook='grid-freeze-handle']").should("exist") + + // When dragging the handle past the first two columns + cy.getGridCellAt(0, 1).then(($cell) => { + cy.dragFreezeHandleTo($cell[0].getBoundingClientRect().right) + }) + + // Then the first two columns are frozen and the third is not + cy.getGridCellAt(0, 0).should("have.attr", "data-frozen", "true") + cy.getGridCellAt(0, 1).should("have.attr", "data-frozen", "true") + cy.getGridCellAt(0, 2).should("not.have.attr", "data-frozen") + + // When dragging the handle further to include the third column + cy.getGridCellAt(0, 2).then(($cell) => { + cy.dragFreezeHandleTo($cell[0].getBoundingClientRect().right) + }) + + // Then the first three columns are frozen and the fourth is not + cy.getGridCellAt(0, 2).should("have.attr", "data-frozen", "true") + cy.getGridCellAt(0, 3).should("not.have.attr", "data-frozen") + }) + + it("keeps the frozen column pinned while scrolling horizontally", () => { + // Given a result wide enough to scroll horizontally, left column frozen + const columns = Array.from({ length: 20 }, (_, i) => `x c${i}`).join(", ") + cy.typeQuery(`select ${columns} from long_sequence(10)`) + cy.runLine() + cy.gridToolbar("freeze").click() + cy.getGridCellAt(0, 0).should("have.attr", "data-frozen", "true") + + let frozenLeft + cy.getGridCellAt(0, 0).then(($cell) => { + frozenLeft = $cell[0].getBoundingClientRect().left + }) + + // When scrolling all the way to the right + cy.getGridViewport().scrollTo("right") + + // Then the grid scrolled, the frozen column stayed put, and the shadow showed + cy.getGridViewport().should(($vp) => { + expect($vp[0].scrollLeft).to.be.greaterThan(0) + }) + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().left).to.be.closeTo( + frozenLeft, + 2, + ) + }) + cy.get("[data-hook='grid-frozen-shadow']").should("be.visible") + }) + }) + + describe("column resize", () => { + it("dragging the separator widens the column", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + let startWidth + cy.getGridCellAt(0, 0).then(($cell) => { + startWidth = $cell[0].getBoundingClientRect().width + }) + + // When + cy.resizeColumn(0, 120) + + // Then + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.greaterThan( + startWidth, + ) + }) + }) + + it("arrow keys on the separator resize the column", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + let startWidth + cy.getGridCellAt(0, 0).then(($cell) => { + startWidth = $cell[0].getBoundingClientRect().width + }) + + // When + cy.get("[data-hook='grid-col-resizer']") + .filter(":visible") + .first() + .focus() + cy.realPress("ArrowRight") + cy.realPress("ArrowRight") + cy.realPress("ArrowRight") + + // Then + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.greaterThan( + startWidth, + ) + }) + }) + }) + + describe("reset layout", () => { + it("the toolbar reset restores the default column width", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + let defaultWidth + cy.getGridCellAt(0, 0).then(($cell) => { + defaultWidth = $cell[0].getBoundingClientRect().width + }) + cy.resizeColumn(0, 150) + + // When + cy.gridToolbar("reset").click() + + // Then + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.closeTo( + defaultWidth, + 2, + ) + }) + }) + + it("Ctrl+B resets the layout", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + let defaultWidth + cy.getGridCellAt(0, 0).then(($cell) => { + defaultWidth = $cell[0].getBoundingClientRect().width + }) + cy.resizeColumn(0, 150) + + // When + cy.selectGridCell(0, 0) + cy.gridKey({ key: "b", ctrlKey: true }) + + // Then + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.closeTo( + defaultWidth, + 2, + ) + }) + }) + }) + + describe("layout persistence", () => { + it("a resized column keeps its width after a re-run", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + cy.resizeColumn(0, 150) + let widenedWidth + cy.getGridCellAt(0, 0).then(($cell) => { + widenedWidth = $cell[0].getBoundingClientRect().width + }) + + // When + cy.runLine() + + // Then + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.closeTo( + widenedWidth, + 2, + ) + }) + }) + + it("column order and freeze survive a re-run", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + cy.selectGridCell(0, 2) + cy.gridKey({ key: "/" }) + cy.getColumnName(0).should("eq", "c") + cy.gridToolbar("freeze").click() + + // When + cy.runLine() + + // Then + cy.getColumnName(0).should("eq", "c") + cy.getFrozenCells().should("have.length.greaterThan", 0) + }) + + it("reset clears the persisted layout for the next run", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + let defaultWidth + cy.getGridCellAt(0, 0).then(($cell) => { + defaultWidth = $cell[0].getBoundingClientRect().width + }) + cy.resizeColumn(0, 150) + cy.gridToolbar("reset").click() + + // When + cy.runLine() + + // Then + cy.getColumnName(0).should("eq", "a") + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.closeTo( + defaultWidth, + 2, + ) + }) + }) + }) + + describe("designated timestamp", () => { + const table = "grid_designated_ts" + + afterEach(() => { + cy.execQuery(`drop table if exists ${table}`) + }) + + it("colors only the designated timestamp column, not every timestamp column", () => { + // Given — ts is designated, ts2 is a plain timestamp column + cy.execQuery(`drop table if exists ${table}`) + cy.execQuery( + `create table ${table} (ts timestamp, ts2 timestamp, val long) timestamp(ts)`, + ) + cy.execQuery( + `insert into ${table} values('2024-01-01T00:00:00.000000Z','2024-01-01T00:00:00.000000Z',1)`, + ) + + // When + cy.typeQuery(`select * from ${table}`) + cy.runLine() + + // Then + cy.getGridCellAt(0, 0).should("have.attr", "data-timestamp", "true") + cy.getGridCellAt(0, 1).should("not.have.attr", "data-timestamp") + }) + }) + + describe("cell formatting", () => { + it("renders null values as the literal 'null'", () => { + // Given / When + cy.typeQuery("select cast(null as long) n from long_sequence(1)") + cy.runLine() + + // Then + cy.getGridCellAt(0, 0).should("have.text", "null") + }) + }) + + describe("toolbar actions", () => { + it("copies the current page as a Markdown table", () => { + // Given + cy.typeQuery("select x from long_sequence(5)") + cy.runLine() + + // When + cy.gridToolbar("markdown").click() + + // Then + readClipboard().should("contain", "| x") + }) + + it("refresh re-runs the query and keeps the rows", () => { + // Given + cy.typeQuery("select x from long_sequence(5)") + cy.runLine() + cy.intercept("/exec*").as("refresh") + + // When + cy.gridToolbar("refresh").click() + + // Then + cy.wait("@refresh") + cy.getGridRows().should("have.length.greaterThan", 0) + }) }) }) diff --git a/e2e/tests/console/schema.spec.js b/e2e/tests/console/schema.spec.js index 14426a185..6d3289f7c 100644 --- a/e2e/tests/console/schema.spec.js +++ b/e2e/tests/console/schema.spec.js @@ -287,7 +287,7 @@ describe("keyboard navigation", () => { cy.getEditorContent().should("be.visible") cy.typeQuery("SELECT 123123;") cy.runLine() - cy.contains(".qg-c", "123123").click() + cy.contains("[data-hook='grid-cell']", "123123").click() cy.focused().should("contain", "123123") cy.expandMatViews() @@ -295,9 +295,12 @@ describe("keyboard navigation", () => { "contain", `Materialized views (${materializedViews.length})`, ) - cy.contains(".qg-c-active", "123123").should("not.exist") + cy.contains( + "[data-hook='grid-cell'][aria-selected='true']", + "123123", + ).should("not.exist") - cy.contains(".qg-c", "123123").click() + cy.contains("[data-hook='grid-cell']", "123123").click() cy.focused().should("contain", "123123") cy.getByDataHook("collapse-materialized-views").should( "not.have.class", diff --git a/e2e/tests/enterprise/oidc.spec.js b/e2e/tests/enterprise/oidc.spec.js index a7c77022f..1c50a4798 100644 --- a/e2e/tests/enterprise/oidc.spec.js +++ b/e2e/tests/enterprise/oidc.spec.js @@ -221,7 +221,7 @@ describe("OIDC", () => { cy.logout() cy.loginWithUserAndPassword() cy.getEditor().should("be.visible") - cy.get(".qg-r").should("not.exist") + cy.get("[data-hook='grid-row']").should("not.exist") }) it("should preserve query and executeQuery params across OIDC redirect and show share-link confirmation dialog", () => { diff --git a/package.json b/package.json index 9cc6f242a..15d6a5664 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ "@styled-icons/remix-editor": "^10.46.0", "@styled-icons/remix-fill": "10.46.0", "@styled-icons/remix-line": "10.46.0", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.2", "allotment": "^1.19.3", "bowser": "^2.14.1", "compare-versions": "^5.0.1", diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 7ca76381e..745451c51 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -43,6 +43,7 @@ type BaseButtonProps = { fontSize?: FontSize onClick?: (event: MouseEvent) => void onDoubleClick?: (event: MouseEvent) => void + onMouseDown?: (event: MouseEvent) => void size?: Size fullWidth?: boolean type?: Type diff --git a/src/components/ResultGrid/ResultGrid.tsx b/src/components/ResultGrid/ResultGrid.tsx new file mode 100644 index 000000000..9465929e8 --- /dev/null +++ b/src/components/ResultGrid/ResultGrid.tsx @@ -0,0 +1,837 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" +import { useVirtualizer } from "@tanstack/react-virtual" +import { + useReactTable, + getCoreRowModel, + flexRender, + type ColumnDef, + type ColumnPinningState, +} from "@tanstack/react-table" + +import type { ColumnDefinition } from "../../utils/questdb/types" +import { unescapeHtml } from "../../utils/escapeHtml" +import type { ResultGridDataSource } from "./types" +import { + computeColumnWidths, + isLeftAligned, + formatCellValue, + formatColumnType, +} from "./inlineGridUtils" +import { useGridKeyboardNav } from "./useGridKeyboardNav" +import { + Cell, + CellText, + ColResizer, + DatasetRow, + GridContainer, + HeaderCell, + HeaderName, + HeaderNameRow, + HeaderRow, + HeaderType, + HEADER_HEIGHT, + FreezeHandle, + FrozenShadow, + ResizeGhost, + Row, + ROW_HEIGHT, + ScrollContainer, + StyledCopyButton, +} from "./styles" +import { MAX_VIRTUAL_ROWS, toAbsoluteIndex } from "./virtualRowMapping" +import { useContainerWidth } from "./useContainerWidth" +import { useScrollShadows } from "./useScrollShadows" + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + col?: ColumnDefinition + } +} + +const WIDTH_SAMPLE_ROWS = 1000 +const COLUMN_ID_PREFIX = "col_" +const columnId = (dataIndex: number) => `${COLUMN_ID_PREFIX}${dataIndex}` + +type GridCellProps = { + rowIndex: number + colIndex: number + rawValue: boolean | string | number | null + loaded: boolean + col: ColumnDefinition | undefined + colWidth: number + left: number + width: number + isActive: boolean + isPulsing: boolean + isDesignatedTimestamp: boolean + frozen?: boolean + onCellClick: (row: number, col: number) => void +} + +const GridCell = React.memo(function GridCell({ + rowIndex, + colIndex, + rawValue, + loaded, + col, + colWidth, + left, + width, + isActive, + isPulsing, + isDesignatedTimestamp, + frozen, + onCellClick, +}: GridCellProps) { + const colType = col?.type ?? "" + const align = isLeftAligned(colType) ? "left" : "right" + const displayValue = loaded + ? unescapeHtml(formatCellValue(rawValue, col, colWidth)) + : "" + return ( + onCellClick(rowIndex, colIndex)} + role="gridcell" + aria-colindex={colIndex + 1} + aria-selected={isActive} + > + {displayValue} + + ) +}) + +type Props = { + dataSource: ResultGridDataSource + runToken?: number // changes per run to reset focus/selection on the grid + isFocused?: boolean + initialColumnSizing?: Record + onColumnSizingCommit: (sizing: Record) => void + initialColumnOrder?: string[] + onColumnOrderCommit?: (order: string[]) => void + initialPinnedColumns?: string[] + onPinnedColumnsCommit?: (pinnedLeft: string[]) => void + onYieldFocus?: () => void + onResetLayout?: () => void + onSelectionChange?: (hasSelection: boolean) => void + onCellCopy?: () => void + onColumnCopy?: () => void +} + +export type ResultGridHandle = { + resetLayout: () => void + shuffleFocusedColumnToFront: () => void + toggleFreezeLeft: () => void +} + +const EMPTY_TABLE_DATA: DatasetRow[] = [] + +const NAV_KEYS = new Set([ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "PageUp", + "PageDown", + "Home", + "End", +]) + +export const ResultGrid = forwardRef( + ( + { + dataSource, + runToken, + isFocused = true, + initialColumnSizing, + onColumnSizingCommit, + initialColumnOrder, + onColumnOrderCommit, + initialPinnedColumns, + onPinnedColumnsCommit, + onYieldFocus, + onResetLayout, + onSelectionChange, + onCellCopy, + onColumnCopy, + }, + ref, + ) => { + const { + columns, + rowCount, + designatedTimestamp, + getRow, + sampleRows, + onVisibleRowsChange, + } = dataSource + + const gridRef = useRef(null) + const scrollRef = useRef(null) + const [hoverRow, setHoverRow] = useState(null) + const [freezeDragX, setFreezeDragX] = useState(null) + const freezeTargetRef = useRef(0) + + const containerWidth = useContainerWidth(gridRef) + const { scrolledDown, shadowLeft, handleScroll } = + useScrollShadows(scrollRef) + + const virtualRowCount = Math.min(rowCount, MAX_VIRTUAL_ROWS) + + const columnDefs = useMemo[]>(() => { + const widths = computeColumnWidths( + columns, + sampleRows.slice(0, WIDTH_SAMPLE_ROWS), + containerWidth, + ) + return columns.map((col, i) => ({ + id: columnId(i), + accessorFn: (row: DatasetRow) => row[i], + header: col.name, + size: widths[i], + minSize: 60, + meta: { col }, + })) + }, [columns, sampleRows, containerWidth]) + + const [columnOrder, setColumnOrder] = useState([]) + const [columnPinning, setColumnPinning] = useState({ + left: [], + right: [], + }) + + const table = useReactTable({ + // Rows come from the windowed data source, not the table — an empty + // dataset keeps the table from holding every row. + data: EMPTY_TABLE_DATA, + columns: columnDefs, + columnResizeMode: "onEnd", + state: { columnOrder, columnPinning }, + onColumnOrderChange: setColumnOrder, + onColumnPinningChange: setColumnPinning, + getCoreRowModel: getCoreRowModel(), + }) + + // Restore before paint; an empty value clears any prior overrides. A user + // resize updates TanStack's own columnSizing, not these props, so it stays. + useLayoutEffect(() => { + table.setColumnSizing(initialColumnSizing ?? {}) + }, [initialColumnSizing]) + + useLayoutEffect(() => { + setColumnOrder(initialColumnOrder ?? []) + }, [initialColumnOrder]) + + useLayoutEffect(() => { + setColumnPinning({ left: initialPinnedColumns ?? [], right: [] }) + }, [initialPinnedColumns]) + + const leftHeaders = table.getLeftHeaderGroups()[0]?.headers ?? [] + const centerHeaders = table.getCenterHeaderGroups()[0]?.headers ?? [] + const headers = [...leftHeaders, ...centerHeaders] + const frozenCount = leftHeaders.length + const frozenWidth = table.getLeftTotalSize() + + // Must follow the visual layout (pinned columns first), the same order as + // `headers`. getVisibleLeafColumns() ignores pinning, so deriving the data + // index from it would mismatch a frozen column whose neighbour was moved. + const visualLeafIds = useMemo( + () => headers.map((header) => header.column.id), + [columnOrder, columnPinning, columnDefs], + ) + const dataIndexAt = useCallback( + (visualCol: number): number => { + const id = visualLeafIds[visualCol] + return id ? parseInt(id.slice(COLUMN_ID_PREFIX.length), 10) : visualCol + }, + [visualLeafIds], + ) + + const getData = useCallback( + (row: number, col: number) => + getRow(toAbsoluteIndex(row, rowCount))?.[dataIndexAt(col)] ?? null, + [getRow, rowCount, dataIndexAt], + ) + + const getColumn = useCallback( + (col: number) => columns[dataIndexAt(col)], + [columns, dataIndexAt], + ) + + const moveColumnToFront = useCallback( + (visualCol: number) => { + const id = visualLeafIds[visualCol] + if (!id) return + const ids = columnOrder.length + ? columnOrder + : columnDefs.map((d) => d.id as string) + const next = [id, ...ids.filter((other) => other !== id)] + setColumnOrder(next) + onColumnOrderCommit?.(next) + }, + [visualLeafIds, columnOrder, columnDefs, onColumnOrderCommit], + ) + + const toggleFreeze = useCallback(() => { + let next: ColumnPinningState + if ((columnPinning.left ?? []).length > 0) { + next = { left: [], right: [] } + } else { + const firstId = table.getCenterLeafColumns()[0]?.id + next = firstId ? { left: [firstId], right: [] } : columnPinning + } + setColumnPinning(next) + onPinnedColumnsCommit?.(next.left ?? []) + }, [columnPinning, table, onPinnedColumnsCommit]) + + const applyFreeze = useCallback( + (count: number) => { + const pinned = visualLeafIds.slice(0, count) + setColumnPinning({ left: pinned, right: [] }) + onPinnedColumnsCommit?.(pinned) + }, + [visualLeafIds, onPinnedColumnsCommit], + ) + + const onFreezeMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + const gridEl = gridRef.current + const scrollEl = scrollRef.current + if (!gridEl || !scrollEl) return + const gridLeft = gridEl.getBoundingClientRect().left + const viewportWidth = scrollEl.clientWidth + const scrollLeft = scrollEl.scrollLeft + + // Hold the resize cursor for the whole drag — it inherits to the cells, + // which would otherwise reset it to default as the pointer leaves the + // handle. + document.body.style.cursor = "col-resize" + + // Candidate k = "freeze k columns"; its boundary is fixed in the frozen + // region but scrolls with content past it. Keep one column scrollable. + let cumulativeWidth = 0 + const candidates: { k: number; x: number }[] = [{ k: 0, x: 0 }] + for (let i = 0; i < headers.length - 1; i++) { + cumulativeWidth += headers[i].getSize() + const k = i + 1 + const x = + k <= frozenCount ? cumulativeWidth : cumulativeWidth - scrollLeft + if (x >= 0 && x <= viewportWidth) candidates.push({ k, x }) + } + + freezeTargetRef.current = frozenCount + + const onMove = (ev: MouseEvent) => { + const cursorX = ev.clientX - gridLeft + let best = candidates[0] + for (const c of candidates) { + if (Math.abs(c.x - cursorX) < Math.abs(best.x - cursorX)) best = c + } + setFreezeDragX(best.x) + freezeTargetRef.current = best.k + } + const onUp = () => { + window.removeEventListener("mousemove", onMove) + window.removeEventListener("mouseup", onUp) + document.body.style.cursor = "" + setFreezeDragX(null) + applyFreeze(freezeTargetRef.current) + } + window.addEventListener("mousemove", onMove) + window.addEventListener("mouseup", onUp) + }, + [headers, frozenCount, applyFreeze], + ) + + const resetLayout = useCallback(() => { + table.setColumnSizing({}) + setColumnOrder([]) + setColumnPinning({ left: [], right: [] }) + onPinnedColumnsCommit?.([]) + if (scrollRef.current) scrollRef.current.scrollLeft = 0 + onResetLayout?.() + }, [table, onResetLayout, onPinnedColumnsCommit]) + + const scrollContextRef = useRef<{ + scrollElement: HTMLElement + rowHeight: number + headerHeight: number + getColumnOffset: (col: number) => number + getColumnWidth: (col: number) => number + } | null>(null) + + useEffect(() => { + if (scrollRef.current) { + scrollContextRef.current = { + scrollElement: scrollRef.current, + rowHeight: ROW_HEIGHT, + headerHeight: HEADER_HEIGHT, + getColumnOffset: (col: number) => { + let offset = 0 + for (let i = 0; i < col; i++) { + offset += headers[i]?.getSize() ?? 0 + } + return offset + }, + getColumnWidth: (col: number) => headers[col]?.getSize() ?? 0, + } + } + }, [headers]) + + const { + focusedCell, + setFocusedCell, + copyPulse, + onCellClick, + onKeyDown, + onBlur, + } = useGridKeyboardNav( + virtualRowCount, + columns.length, + getData, + getColumn, + scrollContextRef, + onCellCopy, + ) + + const hasSelection = focusedCell != null + useEffect(() => { + onSelectionChange?.(hasSelection) + }, [hasSelection, onSelectionChange]) + + const hoverSuppressedRef = useRef(false) + + const handleGridKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (NAV_KEYS.has(e.key)) { + hoverSuppressedRef.current = true + setHoverRow(null) + } + if (e.key === "F2") { + e.preventDefault() + setFocusedCell(null) + gridRef.current?.blur() + onYieldFocus?.() + return + } + // stopPropagation so "/" doesn't reach DocSearch's global shortcut. + if (e.key === "/" && !e.metaKey && !e.ctrlKey && focusedCell) { + e.preventDefault() + e.stopPropagation() + moveColumnToFront(focusedCell.col) + setFocusedCell({ row: focusedCell.row, col: frozenCount }) + return + } + // preventDefault stops the browser's bookmark shortcut. + if ((e.ctrlKey || e.metaKey) && (e.key === "b" || e.key === "B")) { + e.preventDefault() + e.stopPropagation() + resetLayout() + return + } + onKeyDown(e) + }, + [ + onKeyDown, + focusedCell, + moveColumnToFront, + onYieldFocus, + setFocusedCell, + resetLayout, + frozenCount, + ], + ) + + useImperativeHandle( + ref, + () => ({ + resetLayout, + toggleFreezeLeft: toggleFreeze, + shuffleFocusedColumnToFront: () => { + if (!focusedCell) return + moveColumnToFront(focusedCell.col) + setFocusedCell({ row: focusedCell.row, col: frozenCount }) + }, + }), + [ + resetLayout, + toggleFreeze, + focusedCell, + moveColumnToFront, + setFocusedCell, + frozenCount, + ], + ) + + const prevRunTokenRef = useRef(runToken) + + const isCellFocused = (row: number, col: number) => + focusedCell?.row === row && focusedCell?.col === col + + const isCellPulsing = (row: number, col: number) => + copyPulse?.row === row && copyPulse?.col === col + + const rowVirtualizer = useVirtualizer({ + count: virtualRowCount, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 3, + }) + + const columnSizing = table.getState().columnSizing + const isResizingColumn = + !!table.getState().columnSizingInfo.isResizingColumn + + const wasResizingRef = useRef(isResizingColumn) + useEffect(() => { + if (wasResizingRef.current && !isResizingColumn) { + onColumnSizingCommit(columnSizing) + } + wasResizingRef.current = isResizingColumn + }, [isResizingColumn, columnSizing, onColumnSizingCommit]) + + const columnVirtualizer = useVirtualizer({ + horizontal: true, + count: headers.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => headers[index]?.getSize() ?? 100, + overscan: 2, + }) + + // Re-measure so a reorder/resize/new-data doesn't leave the virtualizer with + // stale per-index widths until the next scroll. + useEffect(() => { + columnVirtualizer.measure() + }, [ + columnSizing, + columnDefs, + columnOrder, + columnPinning, + columnVirtualizer, + ]) + + useEffect(() => { + if (prevRunTokenRef.current === runToken) return + prevRunTokenRef.current = runToken + setFocusedCell(null) + gridRef.current?.blur() + if (scrollRef.current) { + scrollRef.current.scrollTop = 0 + } + }, [runToken, setFocusedCell]) + + const totalWidth = columnVirtualizer.getTotalSize() + const totalHeight = rowVirtualizer.getTotalSize() + const virtualRows = rowVirtualizer.getVirtualItems() + const virtualColumns = columnVirtualizer.getVirtualItems() + + const firstVirtual = virtualRows[0]?.index ?? 0 + const lastVirtual = virtualRows[virtualRows.length - 1]?.index ?? 0 + const prevFirstAbsRef = useRef(0) + useEffect(() => { + if (!onVisibleRowsChange || virtualRowCount === 0) return + const firstAbs = toAbsoluteIndex(firstVirtual, rowCount) + const lastAbs = toAbsoluteIndex(lastVirtual, rowCount) + const lo = Math.min(firstAbs, lastAbs) + const hi = Math.max(firstAbs, lastAbs) + const direction = lo >= prevFirstAbsRef.current ? 1 : -1 + prevFirstAbsRef.current = lo + onVisibleRowsChange({ firstIndex: lo, lastIndex: hi, direction }) + }, [ + firstVirtual, + lastVirtual, + rowCount, + virtualRowCount, + onVisibleRowsChange, + ]) + + const headerSignature = virtualColumns + .map((c) => `${c.index}:${c.start}:${c.size}`) + .join("|") + + const headerRow = useMemo(() => { + const renderHeaderCell = ( + header: (typeof headers)[number], + visualIndex: number, + pos: { frozen: boolean; left: number; width: number }, + ) => { + const col = header.column.columnDef.meta?.col + const colType = col?.type ?? "" + const align = isLeftAligned(colType) ? "left" : "right" + return ( + + + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + + + {col ? formatColumnType(col) : colType} + { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return + e.preventDefault() + const step = e.shiftKey ? 40 : 10 + const delta = e.key === "ArrowRight" ? step : -step + const current = header.getSize() + const next = Math.max(60, current + delta) + const nextSizing = { + ...table.getState().columnSizing, + [header.column.id]: next, + } + table.setColumnSizing(nextSizing) + onColumnSizingCommit(nextSizing) + }} + role="separator" + aria-orientation="vertical" + aria-label={`Resize column ${col?.name ?? ""}`} + tabIndex={0} + /> + + ) + } + return ( + + {headers.slice(0, frozenCount).map((header, i) => + renderHeaderCell(header, i, { + frozen: true, + left: header.column.getStart("left"), + width: header.getSize(), + }), + )} + {virtualColumns.map((virtualCol) => { + if (virtualCol.index < frozenCount) return null + const header = headers[virtualCol.index] + if (!header) return null + return renderHeaderCell(header, virtualCol.index, { + frozen: false, + left: virtualCol.start, + width: virtualCol.size, + }) + })} + + ) + }, [ + headerSignature, + scrolledDown, + totalWidth, + columnSizing, + frozenCount, + // headerSignature omits order, so a same-width reorder needs this to + // avoid stale column names. + columnOrder, + columnPinning, + onColumnCopy, + onColumnSizingCommit, + ]) + + const sizingInfo = table.getState().columnSizingInfo + let resizeGhostLeft: number | null = null + if (sizingInfo.isResizingColumn) { + const idx = headers.findIndex( + (h) => h.column.id === sizingInfo.isResizingColumn, + ) + if (idx >= 0) { + let left = 0 + for (let i = 0; i < idx; i++) left += headers[i].getSize() + const futureWidth = Math.max( + 60, + headers[idx].getSize() + (sizingInfo.deltaOffset ?? 0), + ) + resizeGhostLeft = + left + futureWidth - (scrollRef.current?.scrollLeft ?? 0) + } + } + + return ( + { + hoverSuppressedRef.current = false + }} + role="grid" + aria-rowcount={rowCount + 1} + aria-colcount={columns.length} + aria-activedescendant={ + focusedCell ? `cell-${focusedCell.row}-${focusedCell.col}` : undefined + } + > + + {headerRow} + +
+ {virtualRows.map((virtualRow) => { + const virtualIndex = virtualRow.index + const absoluteIndex = toAbsoluteIndex(virtualIndex, rowCount) + const rowData = getRow(absoluteIndex) + const renderBodyCell = ( + header: (typeof headers)[number], + colIdx: number, + pos: { frozen: boolean; left: number; width: number }, + ) => { + const dataIndex = dataIndexAt(colIdx) + return ( + + ) + } + return ( + { + if (!hoverSuppressedRef.current) setHoverRow(virtualIndex) + }} + onMouseLeave={() => setHoverRow(null)} + style={{ + position: "absolute", + top: virtualRow.start, + width: totalWidth, + }} + role="row" + aria-rowindex={absoluteIndex + 2} + > + {headers.slice(0, frozenCount).map((header, i) => + renderBodyCell(header, i, { + frozen: true, + left: header.column.getStart("left"), + width: header.getSize(), + }), + )} + {virtualColumns.map((virtualCol) => { + if (virtualCol.index < frozenCount) return null + const header = headers[virtualCol.index] + if (!header) return null + return renderBodyCell(header, virtualCol.index, { + frozen: false, + left: virtualCol.start, + width: virtualCol.size, + }) + })} + + ) + })} +
+
+ {frozenCount > 0 && shadowLeft && ( + + )} + {(frozenCount > 0 || headers.length > 1) && ( + 0 + ? "Drag to freeze more or fewer columns" + : "Drag to freeze columns" + } + /> + )} + {freezeDragX !== null && } + {resizeGhostLeft !== null && ( + + )} +
+ ) + }, +) + +ResultGrid.displayName = "ResultGrid" diff --git a/src/components/ResultGrid/dimensions.ts b/src/components/ResultGrid/dimensions.ts new file mode 100644 index 000000000..d5a632ac3 --- /dev/null +++ b/src/components/ResultGrid/dimensions.ts @@ -0,0 +1,4 @@ +// Dependency-free so pure modules (e.g. virtualRowMapping) can import it +// without pulling React into a unit-test environment. +export const ROW_HEIGHT = 30 +export const HEADER_HEIGHT = 44 diff --git a/src/components/ResultGrid/index.ts b/src/components/ResultGrid/index.ts new file mode 100644 index 000000000..e5b3a82a1 --- /dev/null +++ b/src/components/ResultGrid/index.ts @@ -0,0 +1,21 @@ +export { ResultGrid } from "./ResultGrid" +export type { ResultGridHandle } from "./ResultGrid" + +export type { + DqlQueryResult, + ResultGridDataSource, + ResultGridRow, +} from "./types" +export { inMemoryDataSource } from "./types" + +export { + computeColumnWidths, + formatCellValue, + formatCellValueForCopy, + formatColumnType, + isLeftAligned, + isTimestampColumn, +} from "./inlineGridUtils" +export { buildResultPageMarkdown } from "./resultPageMarkdown" +export { HEADER_HEIGHT, ROW_HEIGHT } from "./dimensions" +export { toAbsoluteIndex } from "./virtualRowMapping" diff --git a/src/components/ResultGrid/inlineGridUtils.test.ts b/src/components/ResultGrid/inlineGridUtils.test.ts new file mode 100644 index 000000000..b7967a34a --- /dev/null +++ b/src/components/ResultGrid/inlineGridUtils.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect } from "vitest" +import { + computeColumnWidths, + formatCellValue, + formatCellValueForCopy, + formatColumnType, + isLeftAligned, + isTimestampColumn, +} from "./inlineGridUtils" +import type { ColumnDefinition } from "../../utils/questdb/types" + +const col = ( + name: string, + type: string, + extra: Partial = {}, +): ColumnDefinition => ({ name, type, ...extra }) + +describe("isLeftAligned", () => { + it("returns true for string-like types", () => { + expect(isLeftAligned("STRING")).toBe(true) + expect(isLeftAligned("SYMBOL")).toBe(true) + expect(isLeftAligned("VARCHAR")).toBe(true) + expect(isLeftAligned("ARRAY")).toBe(true) + }) + it("returns true regardless of case", () => { + expect(isLeftAligned("string")).toBe(true) + expect(isLeftAligned("Symbol")).toBe(true) + }) + it("returns false for numeric/timestamp/boolean types", () => { + expect(isLeftAligned("INT")).toBe(false) + expect(isLeftAligned("DOUBLE")).toBe(false) + expect(isLeftAligned("TIMESTAMP")).toBe(false) + expect(isLeftAligned("BOOLEAN")).toBe(false) + }) +}) + +describe("isTimestampColumn", () => { + it("is true only for exact TIMESTAMP (case-insensitive)", () => { + expect(isTimestampColumn("TIMESTAMP")).toBe(true) + expect(isTimestampColumn("timestamp")).toBe(true) + expect(isTimestampColumn("DATE")).toBe(false) + expect(isTimestampColumn("")).toBe(false) + }) +}) + +describe("formatColumnType", () => { + it("lowercases non-array types", () => { + expect(formatColumnType(col("x", "INT"))).toBe("int") + expect(formatColumnType(col("x", "TIMESTAMP"))).toBe("timestamp") + }) + + it("renders 1-D arrays as elemType[]", () => { + expect( + formatColumnType(col("x", "ARRAY", { dim: 1, elemType: "DOUBLE" })), + ).toBe("double[]") + }) + + it("renders 2-D arrays as elemType[][]", () => { + expect( + formatColumnType(col("x", "ARRAY", { dim: 2, elemType: "DOUBLE" })), + ).toBe("double[][]") + }) + + it("renders dim>2 arrays with numeric dim form", () => { + expect( + formatColumnType(col("x", "ARRAY", { dim: 3, elemType: "double" })), + ).toBe("ARRAY(DOUBLE,3)") + }) + + it("falls back to 'unknown' when elemType is missing", () => { + expect(formatColumnType(col("x", "ARRAY", { dim: 1 }))).toBe("unknown[]") + }) +}) + +describe("formatCellValue", () => { + it("returns 'null' for null", () => { + expect(formatCellValue(null)).toBe("null") + }) + + it("returns 'true'/'false' for booleans", () => { + expect(formatCellValue(true)).toBe("true") + expect(formatCellValue(false)).toBe("false") + }) + + it("returns string of number by default", () => { + expect(formatCellValue(42)).toBe("42") + expect(formatCellValue(3.14)).toBe("3.14") + }) + + it("adds .0 for integer-valued FLOAT/DOUBLE", () => { + expect(formatCellValue(5, col("x", "FLOAT"))).toBe("5.0") + expect(formatCellValue(5, col("x", "DOUBLE"))).toBe("5.0") + }) + + it("does not alter non-integer float values", () => { + expect(formatCellValue(5.2, col("x", "FLOAT"))).toBe("5.2") + }) + + it("does not apply float suffix to non-float types", () => { + expect(formatCellValue(5, col("x", "INT"))).toBe("5") + }) + + it("formats 1-D array values (.0 on every integer, matching grid.js)", () => { + expect( + formatCellValue( + [1, 2, 3] as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "INT" }), + ), + ).toBe("ARRAY[1.0,2.0,3.0]") + }) + + it("adds .0 to integer elements of float arrays", () => { + expect( + formatCellValue( + [1, 2] as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "DOUBLE" }), + ), + ).toBe("ARRAY[1.0,2.0]") + }) + + it("renders null arrays as 'null'", () => { + expect( + formatCellValue(null, col("x", "ARRAY", { dim: 1, elemType: "INT" })), + ).toBe("null") + }) + + it("truncates array content when columnWidth is tight", () => { + // columnWidth=200 → maxArrayTextLength = ceil(200/8.3) = 25, minus 7 + // overhead = 18 chars of content, which is less than a 100-element + // integer array stringified. + const longArray = Array.from({ length: 100 }, (_, i) => i) + const out = formatCellValue( + longArray as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "INT" }), + 200, + ) + expect(out.startsWith("ARRAY[")).toBe(true) + expect(out.endsWith("]")).toBe(true) + expect(out).toContain("...") + }) + + it("leaves array untruncated when columnWidth leaves ≤3 chars of content", () => { + // columnWidth=80 → maxContentLength drops to 3; the truncation branch is + // skipped (guard: maxContentLength > 3) and the full array is returned. + const longArray = Array.from({ length: 100 }, (_, i) => i) + const out = formatCellValue( + longArray as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "INT" }), + 80, + ) + expect(out).not.toContain("...") + expect(out).toContain("99") + }) + + it("does not truncate when columnWidth is absent", () => { + const longArray = Array.from({ length: 50 }, (_, i) => i) + const out = formatCellValue( + longArray as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "INT" }), + ) + expect(out).not.toContain("...") + }) +}) + +describe("formatCellValueForCopy", () => { + it("returns 'null' for null", () => { + expect(formatCellValueForCopy(null)).toBe("null") + }) + + it("returns the same as formatCellValue for primitives", () => { + expect(formatCellValueForCopy(true)).toBe("true") + expect(formatCellValueForCopy(42)).toBe("42") + expect(formatCellValueForCopy("hi")).toBe("hi") + }) + + it("returns the full array without truncation", () => { + const longArray = Array.from({ length: 100 }, (_, i) => i) + const out = formatCellValueForCopy( + longArray as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "INT" }), + ) + expect(out.startsWith("ARRAY[")).toBe(true) + expect(out.endsWith("]")).toBe(true) + expect(out).not.toContain("...") + expect(out).toContain("0.0,1.0,2.0") + expect(out).toContain("99.0") + }) + + it("preserves float suffix in copy form", () => { + const out = formatCellValueForCopy( + [1, 2] as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "DOUBLE" }), + ) + expect(out).toBe("ARRAY[1.0,2.0]") + }) + + it("unescapes HTML entities so copy matches the displayed text", () => { + // Given a string value carrying HTML entities, as the grid displays it + // unescaped + // When formatting it for copy + const out = formatCellValueForCopy("a&b<c>d", col("x", "VARCHAR")) + + // Then the copied text is unescaped, matching the cell + expect(out).toBe("a&bd") + }) +}) + +describe("computeColumnWidths", () => { + it("returns one entry per column", () => { + const columns = [col("a", "INT"), col("b", "STRING"), col("c", "DOUBLE")] + const widths = computeColumnWidths(columns, [], 1000) + expect(widths).toHaveLength(3) + }) + + it("respects the MIN_COLUMN_WIDTH floor", () => { + const widths = computeColumnWidths([col("a", "INT")], [], 1000) + expect(widths[0]).toBeGreaterThanOrEqual(60) + }) + + it("clamps at maxWidth (containerWidth * 0.8)", () => { + const longValue = "x".repeat(1000) + const widths = computeColumnWidths( + [col("long", "STRING")], + [[longValue]], + 1000, + ) + expect(widths[0]).toBeLessThanOrEqual(800) + }) + + it("widens for longer data values", () => { + const widthShort = computeColumnWidths( + [col("a", "STRING")], + [["hi"]], + 1000, + )[0] + const widthLong = computeColumnWidths( + [col("a", "STRING")], + [["hello world this is longer"]], + 1000, + )[0] + expect(widthLong).toBeGreaterThan(widthShort) + }) + + it("includes header + type length when sizing", () => { + const narrow = computeColumnWidths([col("x", "INT")], [[1]], 1000)[0] + const wide = computeColumnWidths( + [col("extremely_long_column_name", "INT")], + [[1]], + 1000, + )[0] + expect(wide).toBeGreaterThan(narrow) + }) + + it("handles empty dataset", () => { + const widths = computeColumnWidths([col("a", "INT")], [], 1000) + expect(widths).toHaveLength(1) + expect(widths[0]).toBeGreaterThanOrEqual(60) + }) +}) diff --git a/src/components/ResultGrid/inlineGridUtils.ts b/src/components/ResultGrid/inlineGridUtils.ts new file mode 100644 index 000000000..49d23d6d2 --- /dev/null +++ b/src/components/ResultGrid/inlineGridUtils.ts @@ -0,0 +1,168 @@ +import type { ColumnDefinition } from "../../utils/questdb/types" +import { unescapeHtml } from "../../utils/escapeHtml" + +const CELL_WIDTH_MULTIPLIER = 9.6 +const ARRAY_CELL_WIDTH_MULTIPLIER = 8.3 +const MIN_COLUMN_WIDTH = 60 +const MAX_WIDTH_RATIO = 0.8 + +// Non-text horizontal space a header cell reserves but a data cell doesn't: cell +// padding + flex gap + the always-rendered (visibility:hidden) sm copy button. +const HEADER_PADDING_PX = 32 +const HEADER_GAP_PX = 6 +const HEADER_COPY_BUTTON_PX = 32 +const HEADER_CHROME_PX = + HEADER_PADDING_PX + HEADER_GAP_PX + HEADER_COPY_BUTTON_PX + +const LEFT_ALIGNED_TYPES = new Set(["STRING", "SYMBOL", "VARCHAR", "ARRAY"]) + +const FLOAT_TYPES = new Set(["FLOAT", "DOUBLE"]) + +const isArrayColumn = (col: ColumnDefinition): boolean => col.type === "ARRAY" + +const getCellWidth = (textLength: number, isArray = false): number => { + const multiplier = isArray + ? ARRAY_CELL_WIDTH_MULTIPLIER + : CELL_WIDTH_MULTIPLIER + return Math.max(MIN_COLUMN_WIDTH, Math.ceil(textLength * multiplier)) +} + +const getArrayString = (value: unknown): string => { + const json = JSON.stringify(value, (_, val: unknown) => { + if (typeof val === "number" && Number.isInteger(val)) { + return val.toString() + ".0" + } + return val + }) + return json.replace(/"/g, "") +} + +const wrapArray = (content: string, dim: number): string => + `ARRAY${"[".repeat(dim)}${content}${"]".repeat(dim)}` + +const arrayContent = (value: unknown, dim: number): string => + getArrayString(value).slice(dim, -dim) + +const formatArrayFull = (value: unknown, col: ColumnDefinition): string => { + if (value === null) return "null" + const dim = col.dim ?? 1 + return wrapArray(arrayContent(value, dim), dim) +} + +const formatArrayValue = ( + value: unknown, + col: ColumnDefinition, + columnWidth?: number, +): string => { + if (value === null) return "null" + const dim = col.dim ?? 1 + const content = arrayContent(value, dim) + const full = wrapArray(content, dim) + + if (!columnWidth) return full + + const maxArrayTextLength = Math.ceil( + columnWidth / ARRAY_CELL_WIDTH_MULTIPLIER, + ) + const maxContentLength = maxArrayTextLength - (dim * 2 + "ARRAY".length) + + if (content.length > maxContentLength && maxContentLength > 3) { + return wrapArray(`${content.slice(0, maxContentLength)}...`, dim) + } + + return full +} + +export const computeColumnWidths = ( + columns: ColumnDefinition[], + dataset: (boolean | string | number | null)[][], + containerWidth: number, +): number[] => { + const maxWidth = containerWidth * MAX_WIDTH_RATIO + const maxTextLenRegular = Math.ceil(maxWidth / CELL_WIDTH_MULTIPLIER) + const maxTextLenArray = Math.ceil(maxWidth / ARRAY_CELL_WIDTH_MULTIPLIER) + + return columns.map((col, colIdx) => { + const isArray = isArrayColumn(col) + const maxTextLen = isArray ? maxTextLenArray : maxTextLenRegular + const headerTextLen = Math.max( + col.name.length, + formatColumnType(col).length, + ) + const headerTextPx = Math.ceil(headerTextLen * CELL_WIDTH_MULTIPLIER) + let width = Math.max(MIN_COLUMN_WIDTH, headerTextPx + HEADER_CHROME_PX) + + for (const row of dataset) { + const val = row[colIdx] + let displayLen: number + if (isArray) { + const formatted = formatArrayValue(val, col) + displayLen = Math.min(formatted.length, maxTextLen) + } else { + const formatted = formatCellValue(val, col) + displayLen = Math.min(formatted.length, maxTextLen) + } + width = Math.max(width, getCellWidth(displayLen, isArray)) + if (width >= maxWidth) { + width = maxWidth + break + } + } + return Math.min(width, maxWidth) + }) +} + +export const isLeftAligned = (type: string): boolean => + LEFT_ALIGNED_TYPES.has(type.toUpperCase()) + +export const isTimestampColumn = (type: string): boolean => + type.toUpperCase() === "TIMESTAMP" + +export const formatColumnType = (col: ColumnDefinition): string => { + if (col.type !== "ARRAY") { + return col.type.toLowerCase() + } + const dim = col.dim ?? 1 + const elemType = col.elemType ?? "unknown" + if (dim > 2) { + return `ARRAY(${elemType.toUpperCase()},${dim})` + } + return elemType.toLowerCase() + "[]".repeat(dim) +} + +export const formatCellValue = ( + value: boolean | string | number | null, + col?: ColumnDefinition, + columnWidth?: number, +): string => { + if (value === null) return "null" + if (typeof value === "boolean") return value ? "true" : "false" + + if (col && isArrayColumn(col)) { + return formatArrayValue(value, col, columnWidth) + } + + if ( + col && + typeof value === "number" && + FLOAT_TYPES.has(col.type.toUpperCase()) && + Number.isInteger(value) + ) { + return value.toFixed(1) + } + + return String(value) +} + +export const formatCellValueForCopy = ( + value: boolean | string | number | null, + col?: ColumnDefinition, +): string => { + if (value === null) return "null" + + if (col && isArrayColumn(col)) { + return unescapeHtml(formatArrayFull(value, col)) + } + + return unescapeHtml(formatCellValue(value, col)) +} diff --git a/src/components/ResultGrid/resultPageMarkdown.test.ts b/src/components/ResultGrid/resultPageMarkdown.test.ts new file mode 100644 index 000000000..200ba1815 --- /dev/null +++ b/src/components/ResultGrid/resultPageMarkdown.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest" +import type { ColumnDefinition } from "../../utils/questdb/types" +import { buildResultPageMarkdown } from "./resultPageMarkdown" + +const col = ( + name: string, + type: string, + extra: Partial = {}, +): ColumnDefinition => ({ name, type, ...extra }) + +describe("buildResultPageMarkdown", () => { + it("renders a pipe-aligned table padded to the widest cell per column", () => { + // Given a two-column result and two loaded rows + const columns = [col("symbol", "SYMBOL"), col("price", "DOUBLE")] + const rows = [ + ["BTC", 65000], + ["ETH", 3200], + ] + + // When building the markdown + const md = buildResultPageMarkdown(columns, rows) + + // Then each column is padded to fit its header and values + expect(md).toBe( + [ + "| symbol | price |", + "| ------ | ------- |", + "| BTC | 65000.0 |", + "| ETH | 3200.0 |", + ].join("\n"), + ) + }) + + it("formats nulls and integer-valued floats like the grid does", () => { + // Given a float column with a null and an integer-valued float + const columns = [col("x", "DOUBLE")] + const rows = [[null], [1]] + + // When building the markdown + const md = buildResultPageMarkdown(columns, rows) + + // Then null renders as "null" and the float keeps its ".0" + expect(md).toBe(["| x |", "| ---- |", "| null |", "| 1.0 |"].join("\n")) + }) + + it("renders a QUERY PLAN result as a fenced code block", () => { + // Given a single QUERY PLAN column with two plan lines + const columns = [col("QUERY PLAN", "STRING")] + const rows = [["Async JIT Filter"], [" Row forward scan"]] + + // When building the markdown + const md = buildResultPageMarkdown(columns, rows) + + // Then it is a fenced block, one line per row, no table pipes + expect(md).toBe( + ["```", "Async JIT Filter", " Row forward scan", "```"].join("\n"), + ) + }) + + it("returns the header and separator only when no rows are loaded", () => { + // Given columns but an empty (unloaded) page + const columns = [col("a", "INT"), col("b", "INT")] + const rows: (string | number | boolean | null)[][] = [] + + // When building the markdown + const md = buildResultPageMarkdown(columns, rows) + + // Then only the header and separator are emitted + expect(md).toBe(["| a | b |", "| - | - |"].join("\n")) + }) + + it("unescapes HTML entities so exported text matches the grid", () => { + // Given a string column whose value carries HTML entities + const columns = [col("note", "VARCHAR")] + const rows = [["price>100"]] + + // When building the markdown + const md = buildResultPageMarkdown(columns, rows) + + // Then the cell is unescaped, matching what the grid displays + expect(md).toBe( + ["| note |", "| --------- |", "| price>100 |"].join("\n"), + ) + }) + + it("returns an empty string when there are no columns", () => { + // Given an empty result + // When building the markdown + // Then the output is empty + expect(buildResultPageMarkdown([], [])).toBe("") + }) +}) diff --git a/src/components/ResultGrid/resultPageMarkdown.ts b/src/components/ResultGrid/resultPageMarkdown.ts new file mode 100644 index 000000000..032441efe --- /dev/null +++ b/src/components/ResultGrid/resultPageMarkdown.ts @@ -0,0 +1,53 @@ +import type { ColumnDefinition } from "../../utils/questdb/types" +import { unescapeHtml } from "../../utils/escapeHtml" +import type { ResultGridRow } from "./types" +import { formatCellValueForCopy } from "./inlineGridUtils" + +const isQueryPlanResult = (columns: ColumnDefinition[]): boolean => + columns.length === 1 && columns[0].name === "QUERY PLAN" + +const buildQueryPlanMarkdown = (rows: ResultGridRow[]): string => { + const lines = ["```"] + for (const row of rows) { + const cell = row[0] + if (cell === null || cell === undefined) continue + lines.push(unescapeHtml(String(cell))) + } + lines.push("```") + return lines.join("\n") +} + +const renderRow = (cells: string[], widths: number[]): string => + `| ${cells.map((cell, i) => cell.padEnd(widths[i])).join(" | ")} |` + +const buildTableMarkdown = ( + columns: ColumnDefinition[], + rows: ResultGridRow[], +): string => { + const headers = columns.map((column) => column.name) + const widths = headers.map((header) => header.length) + + const formattedRows = rows.map((row) => + columns.map((column, i) => { + const text = formatCellValueForCopy(row[i] ?? null, column) + widths[i] = Math.max(widths[i], text.length) + return text + }), + ) + + const headerRow = renderRow(headers, widths) + const separator = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |` + const dataRows = formattedRows.map((cells) => renderRow(cells, widths)) + + return [headerRow, separator, ...dataRows].join("\n") +} + +export const buildResultPageMarkdown = ( + columns: ColumnDefinition[], + rows: ResultGridRow[], +): string => { + if (columns.length === 0) return "" + return isQueryPlanResult(columns) + ? buildQueryPlanMarkdown(rows) + : buildTableMarkdown(columns, rows) +} diff --git a/src/components/ResultGrid/styles.ts b/src/components/ResultGrid/styles.ts new file mode 100644 index 000000000..b2d9c34f8 --- /dev/null +++ b/src/components/ResultGrid/styles.ts @@ -0,0 +1,396 @@ +import styled, { css, keyframes } from "styled-components" +import { color } from "../../utils" +import { Button } from ".." +import { CopyButton } from "../CopyButton" +import { HEADER_HEIGHT, ROW_HEIGHT } from "./dimensions" + +export type DatasetRow = (boolean | string | number | null)[] + +export { HEADER_HEIGHT, ROW_HEIGHT } + +export const ResultWrapper = styled.div` + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +` + +export const SuccessMessage = styled.div` + padding: 0.6rem 0.8rem; + color: ${color("green")}; + font-size: ${({ theme }) => theme.fontSize.sm}; + background: ${color("backgroundDarker")}; +` + +export const TabBarWrapper = styled.div` + display: flex; + flex-shrink: 0; + overflow-x: auto; + gap: 0; + height: 4rem; + border-top: 1px solid ${color("backgroundDarker")}; + + &::-webkit-scrollbar { + height: 0; + } +` + +export const TabLabel = styled.span` + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +` + +export const Tab = styled.button<{ $active: boolean }>` + display: flex; + align-items: center; + padding: 0.5rem 1rem; + border: none; + background: transparent; + color: ${color("gray2")}; + cursor: pointer; + max-width: 20rem; + min-width: 15rem; + border-bottom: 2px solid transparent; + + border-right: 1px solid ${color("selection")}; + flex-shrink: 0; + gap: 0.8rem; + overflow: hidden; + position: relative; + transition: all 0.2s ease; + + ${({ $active }) => + $active && + css` + color: ${color("foreground")}; + background: ${color("selection")}; + border-bottom: 2px solid ${color("pinkPrimary")}; + `} + + ${({ $active }) => + !$active && + css` + &:hover { + background: ${color("selectionDarker")}; + border-bottom: 2px solid ${color("selection")}; + } + `} +` + +export const TabStatusIcon = styled.span<{ $success: boolean }>` + display: flex; + align-items: center; + flex-shrink: 0; + color: ${({ $success }) => ($success ? color("green") : color("red"))}; +` + +export const TabSpinner = styled.span` + display: flex; + align-items: center; + flex-shrink: 0; + animation: tab-spin 3s linear infinite; + + @keyframes tab-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + svg { + width: 18px; + height: 18px; + } +` + +export const CancelledIcon = styled.span` + display: flex; + align-items: center; + flex-shrink: 0; + color: ${color("gray2")}; +` + +export const CancelButton = styled(Button)` + padding: 1.2rem 0.6rem; +` + +export const NotificationContainer = styled.div` + border-top: 1px solid ${color("backgroundDarker")}; + border-bottom: 1px solid ${color("backgroundDarker")}; +` + +export const LiveRegion = styled.div` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +` + +export const GridContainer = styled.div` + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + outline: none; + font-size: ${({ theme }) => theme.fontSize.xs}; + position: relative; +` + +export const ScrollContainer = styled.div<{ $scrollable: boolean }>` + flex: 1; + overflow: ${({ $scrollable }) => ($scrollable ? "auto" : "hidden")}; +` + +export const HeaderRow = styled.div<{ $shadowBottom: boolean }>` + display: flex; + background: ${color("backgroundDarker")}; + border-bottom: 1px solid ${color("selection")}; + flex-shrink: 0; + height: ${HEADER_HEIGHT}px; + box-shadow: ${({ $shadowBottom }) => + $shadowBottom ? "0 2px 4px rgba(0, 0, 0, 0.3)" : "none"}; + transition: box-shadow 0.15s; +` + +export const HeaderCell = styled.div<{ $align: string; $frozen?: boolean }>` + position: relative; + flex-shrink: 0; + padding: 0.5rem 1rem; + display: flex; + flex-direction: column; + justify-content: center; + user-select: none; + text-align: ${({ $align }) => $align}; + border-right: 1px solid ${color("selection")}; + /* Sticky-left: opaque background so scrolled-under headers don't show through. */ + ${({ $frozen }) => + $frozen && + css` + background: ${color("backgroundDarker")}; + justify-content: flex-start; + `} + + &:hover .header-copy-btn, + .header-copy-btn[data-copied="true"] { + visibility: visible; + } +` + +export const HeaderNameRow = styled.div<{ $align: string }>` + display: flex; + align-items: center; + flex-direction: ${({ $align }) => + $align === "right" ? "row-reverse" : "row"}; + justify-content: flex-start; + gap: 6px; +` + +export const HeaderName = styled.span` + color: ${color("cyan")}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + font-size: 1.4rem; +` + +export const HeaderType = styled.span` + color: ${color("gray2")}; + font-size: 1rem; + white-space: nowrap; + text-transform: lowercase; +` + +export const StyledCopyButton = styled(CopyButton)` + visibility: hidden; + flex-shrink: 0; + padding: 0; + + &:hover { + background: transparent !important; + } +` + +export const ColResizer = styled.div` + position: absolute; + right: -10px; + top: 0; + bottom: 0; + width: 20px; + cursor: col-resize; + touch-action: none; + user-select: none; + z-index: 2; + + &::after { + content: ""; + position: absolute; + left: 50%; + top: 25%; + transform: translateX(-50%); + width: 5px; + height: 50%; + border-radius: 2px; + background: transparent; + transition: background 0.1s; + } + + &:hover::after { + background: ${color("cyan")}; + } +` + +export const ResizeGhost = styled.div` + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: ${color("cyan")}; + pointer-events: none; + z-index: 4; +` + +export const Row = styled.div<{ $hover: boolean; $active: boolean }>` + display: flex; + height: ${ROW_HEIGHT}px; + + ${({ $active }) => + $active && + css` + background: ${color("selection")}; + `} + + ${({ $hover, $active }) => + $hover && + !$active && + css` + background: ${color("selectionDarker")}; + `} +` + +const pulseAnim = keyframes` + 0% { box-shadow: #8be9fd 0 0 0 1px; } + 75% { box-shadow: rgba(241, 250, 140, 0) 0 0 0 16px; } +` + +export const Cell = styled.div<{ + $isNull: boolean + $isTimestamp: boolean + $isActive: boolean + $isPulsing: boolean + $frozen?: boolean +}>` + flex-shrink: 0; + height: ${ROW_HEIGHT}px; + display: flex; + align-items: center; + padding: 0 0.6rem; + overflow: hidden; + font-size: 1.3rem; + color: ${({ $isNull, $isTimestamp }) => + $isNull ? "#939393" : $isTimestamp ? color("green") : color("foreground")}; + border-right: 1px solid ${color("selection")}; + border-bottom: 1px solid ${color("selection")}; + box-sizing: border-box; + /* contain: layout, not paint — paint would clip the copy-pulse glow. */ + contain: layout; + + /* Sticky-left: opaque background hides scrolled-under cells. */ + ${({ $frozen }) => + $frozen && + css` + background: ${color("background")}; + `} + + ${({ $isActive }) => + $isActive && + css` + background: ${color("tableSelection")}; + box-shadow: inset 0 0 0 1px ${color("cyan")}; + border-radius: 0.4rem; + `} + + ${({ $isPulsing }) => + $isPulsing && + css` + animation: ${pulseAnim} 1s ease-out; + `} +` + +export const CellText = styled.div` + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +` + +export const FrozenShadow = styled.div` + position: absolute; + top: 0; + bottom: 0; + width: 6px; + background: linear-gradient(to right, rgba(0, 0, 0, 0.25), transparent); + pointer-events: none; + z-index: 3; +` + +// Narrow so the adjacent column's resizer stays reachable. +export const FreezeHandle = styled.div<{ + $dragging?: boolean + $flush?: boolean +}>` + position: absolute; + top: 0; + bottom: 0; + width: 8px; + margin-left: -4px; + cursor: col-resize; + touch-action: none; + user-select: none; + z-index: 5; + + &::after { + content: ""; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 2px; + transform: translateX(-50%); + background: transparent; + transition: background 0.1s; + } + + /* With nothing frozen the handle sits flush against the grid's left edge: no + centering margin (so it isn't clipped), and the indicator aligns to the + edge so it matches the drag ghost's 0-frozen position at x=0. */ + ${({ $flush }) => + $flush && + css` + margin-left: 0; + &::after { + left: 0; + transform: none; + } + `} + + /* While dragging, the ResizeGhost is the only indicator — the handle's own + hover bar would otherwise show as a redundant second ghost. */ + ${({ $dragging }) => + !$dragging && + css` + &:hover::after { + background: ${color("cyan")}; + } + `} +` diff --git a/src/components/ResultGrid/types.ts b/src/components/ResultGrid/types.ts new file mode 100644 index 000000000..b71e0d357 --- /dev/null +++ b/src/components/ResultGrid/types.ts @@ -0,0 +1,44 @@ +import type { + ColumnDefinition, + Timings, + Explain, +} from "../../utils/questdb/types" + +// Neutral DQL-result shape the grid reads from, free of feature-specific +// coupling so it stays reusable. +export type DqlQueryResult = { + columns: ColumnDefinition[] + dataset: (boolean | string | number | null)[][] + count: number + query: string + timestamp?: number + timings?: Timings + explain?: Explain +} + +export type ResultGridRow = (boolean | string | number | null)[] + +export type ResultGridDataSource = { + columns: ColumnDefinition[] + rowCount: number + designatedTimestamp: number + getRow: (index: number) => ResultGridRow | undefined + sampleRows: ResultGridRow[] + onVisibleRowsChange?: (range: { + firstIndex: number + lastIndex: number + direction: number + }) => void +} + +export const inMemoryDataSource = ( + columns: ColumnDefinition[], + dataset: ResultGridRow[], + designatedTimestamp = -1, +): ResultGridDataSource => ({ + columns, + rowCount: dataset.length, + designatedTimestamp, + getRow: (index) => dataset[index], + sampleRows: dataset, +}) diff --git a/src/components/ResultGrid/useContainerWidth.ts b/src/components/ResultGrid/useContainerWidth.ts new file mode 100644 index 000000000..4e6deb778 --- /dev/null +++ b/src/components/ResultGrid/useContainerWidth.ts @@ -0,0 +1,23 @@ +import { useEffect, useLayoutEffect, useState, type RefObject } from "react" + +export const useContainerWidth = (ref: RefObject): number => { + const [width, setWidth] = useState(800) + + useLayoutEffect(() => { + const measured = ref.current?.getBoundingClientRect().width + // A 0 width (not laid out yet) would collapse every column to zero. + if (measured) setWidth(measured) + }, [ref]) + + useEffect(() => { + if (!ref.current) return + const observer = new ResizeObserver(([entry]) => { + const measured = entry.contentRect.width + if (measured) setWidth(measured) + }) + observer.observe(ref.current) + return () => observer.disconnect() + }, [ref]) + + return width +} diff --git a/src/components/ResultGrid/useGridKeyboardNav.ts b/src/components/ResultGrid/useGridKeyboardNav.ts new file mode 100644 index 000000000..7057acf81 --- /dev/null +++ b/src/components/ResultGrid/useGridKeyboardNav.ts @@ -0,0 +1,189 @@ +import { useCallback, useState } from "react" +import type { ColumnDefinition } from "../../utils/questdb/types" +import { copyToClipboard } from "../../utils/copyToClipboard" +import { toast } from "../Toast" +import { formatCellValueForCopy } from "./inlineGridUtils" + +export type CellCoord = { row: number; col: number } + +type ScrollContext = { + scrollElement: HTMLElement + rowHeight: number + headerHeight: number + getColumnOffset: (col: number) => number + getColumnWidth: (col: number) => number +} + +const scrollCellIntoView = (cell: CellCoord, ctx: ScrollContext) => { + const { + scrollElement, + rowHeight, + headerHeight, + getColumnOffset, + getColumnWidth, + } = ctx + + const cellTop = headerHeight + cell.row * rowHeight + const cellBottom = cellTop + rowHeight + const viewTop = scrollElement.scrollTop + headerHeight + const viewBottom = scrollElement.scrollTop + scrollElement.clientHeight + + if (cellTop < viewTop) { + scrollElement.scrollTop = cellTop - headerHeight + } else if (cellBottom > viewBottom) { + scrollElement.scrollTop = cellBottom - scrollElement.clientHeight + } + + const cellLeft = getColumnOffset(cell.col) + const cellRight = cellLeft + getColumnWidth(cell.col) + const viewLeft = scrollElement.scrollLeft + const viewRight = scrollElement.scrollLeft + scrollElement.clientWidth + + if (cellLeft < viewLeft) { + scrollElement.scrollLeft = cellLeft + } else if (cellRight > viewRight) { + scrollElement.scrollLeft = cellRight - scrollElement.clientWidth + } +} + +export const useGridKeyboardNav = ( + rowCount: number, + colCount: number, + getData: (row: number, col: number) => boolean | string | number | null, + getColumn: (col: number) => ColumnDefinition | undefined, + scrollContextRef: React.RefObject, + onCopy?: () => void, +) => { + const [focusedCell, setFocusedCell] = useState(null) + const [copyPulse, setCopyPulse] = useState(null) + + const moveTo = useCallback( + (row: number, col: number) => { + const next = { row, col } + setFocusedCell(next) + if (scrollContextRef.current) { + scrollCellIntoView(next, scrollContextRef.current) + } + }, + [scrollContextRef], + ) + + const onCellClick = useCallback((row: number, col: number) => { + setFocusedCell({ row, col }) + }, []) + + const onBlur = useCallback(() => { + setFocusedCell(null) + }, []) + + const copyCell = useCallback( + (row: number, col: number) => { + const value = getData(row, col) + const text = formatCellValueForCopy(value, getColumn(col)) + onCopy?.() + void copyToClipboard(text).then(() => { + toast.success("Copied to clipboard") + setCopyPulse({ row, col }) + setTimeout(() => setCopyPulse(null), 1000) + }) + }, + [getData, getColumn, onCopy], + ) + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!focusedCell) return + + const { row, col } = focusedCell + + switch (e.key) { + case "ArrowUp": + e.preventDefault() + if (e.metaKey || e.ctrlKey) { + moveTo(0, col) + } else if (row > 0) { + moveTo(row - 1, col) + } + break + case "ArrowDown": + e.preventDefault() + if (e.metaKey || e.ctrlKey) { + moveTo(rowCount - 1, col) + } else if (row < rowCount - 1) { + moveTo(row + 1, col) + } + break + case "ArrowLeft": + e.preventDefault() + if (col > 0) moveTo(row, col - 1) + break + case "ArrowRight": + e.preventDefault() + if (col < colCount - 1) moveTo(row, col + 1) + break + case "Home": + e.preventDefault() + if (e.metaKey || e.ctrlKey) { + moveTo(0, 0) + } else { + moveTo(row, 0) + } + break + case "End": + e.preventDefault() + if (e.metaKey || e.ctrlKey) { + moveTo(rowCount - 1, colCount - 1) + } else { + moveTo(row, colCount - 1) + } + break + case "PageUp": { + e.preventDefault() + const ctx = scrollContextRef.current + if (ctx) { + const pageRows = Math.floor( + (ctx.scrollElement.clientHeight - ctx.headerHeight) / + ctx.rowHeight, + ) + moveTo(Math.max(0, row - pageRows), col) + } + break + } + case "PageDown": { + e.preventDefault() + const ctx = scrollContextRef.current + if (ctx) { + const pageRows = Math.floor( + (ctx.scrollElement.clientHeight - ctx.headerHeight) / + ctx.rowHeight, + ) + moveTo(Math.min(rowCount - 1, row + pageRows), col) + } + break + } + case "c": + if (e.metaKey || e.ctrlKey) { + e.preventDefault() + copyCell(row, col) + } + break + case "Insert": + if (e.ctrlKey) { + e.preventDefault() + copyCell(row, col) + } + break + } + }, + [focusedCell, rowCount, colCount, copyCell, moveTo, scrollContextRef], + ) + + return { + focusedCell, + setFocusedCell, + copyPulse, + onCellClick, + onKeyDown, + onBlur, + } +} diff --git a/src/components/ResultGrid/useScrollShadows.ts b/src/components/ResultGrid/useScrollShadows.ts new file mode 100644 index 000000000..841906dd3 --- /dev/null +++ b/src/components/ResultGrid/useScrollShadows.ts @@ -0,0 +1,27 @@ +import { useCallback, useRef, useState, type RefObject } from "react" + +// Tracks whether `ref` is scrolled past its top/left edge, so the header and +// frozen-column shadows can show. +export const useScrollShadows = (ref: RefObject) => { + const [scrolledDown, setScrolledDown] = useState(false) + const [shadowLeft, setShadowLeft] = useState(false) + const scrolledDownRef = useRef(false) + const shadowLeftRef = useRef(false) + + const handleScroll = useCallback(() => { + const el = ref.current + if (!el) return + const down = el.scrollTop > 0 + const left = el.scrollLeft > 0 + if (down !== scrolledDownRef.current) { + scrolledDownRef.current = down + setScrolledDown(down) + } + if (left !== shadowLeftRef.current) { + shadowLeftRef.current = left + setShadowLeft(left) + } + }, [ref]) + + return { scrolledDown, shadowLeft, handleScroll } +} diff --git a/src/components/ResultGrid/virtualRowMapping.test.ts b/src/components/ResultGrid/virtualRowMapping.test.ts new file mode 100644 index 000000000..d8166d1d4 --- /dev/null +++ b/src/components/ResultGrid/virtualRowMapping.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest" +import { + LEAP_TAIL_ROWS, + MAX_VIRTUAL_ROWS, + toAbsoluteIndex, +} from "./virtualRowMapping" + +describe("toAbsoluteIndex", () => { + it("is identity when the result fits within the height cap", () => { + expect(toAbsoluteIndex(0, 100)).toBe(0) + expect(toAbsoluteIndex(42, 100)).toBe(42) + expect(toAbsoluteIndex(99, 100)).toBe(99) + expect(toAbsoluteIndex(7, MAX_VIRTUAL_ROWS)).toBe(7) + }) + + it("keeps the head rows 1:1 when the result exceeds the cap", () => { + const rowCount = MAX_VIRTUAL_ROWS * 3 + const headCount = MAX_VIRTUAL_ROWS - LEAP_TAIL_ROWS + + expect(toAbsoluteIndex(0, rowCount)).toBe(0) + expect(toAbsoluteIndex(headCount - 1, rowCount)).toBe(headCount - 1) + }) + + it("maps the tail rows onto the end of the result", () => { + const rowCount = MAX_VIRTUAL_ROWS * 3 + const headCount = MAX_VIRTUAL_ROWS - LEAP_TAIL_ROWS + + // The first tail row jumps to the last LEAP_TAIL_ROWS window of the result. + expect(toAbsoluteIndex(headCount, rowCount)).toBe(rowCount - LEAP_TAIL_ROWS) + // The very last virtual row resolves to the very last real row. + expect(toAbsoluteIndex(MAX_VIRTUAL_ROWS - 1, rowCount)).toBe(rowCount - 1) + }) +}) diff --git a/src/components/ResultGrid/virtualRowMapping.ts b/src/components/ResultGrid/virtualRowMapping.ts new file mode 100644 index 000000000..f6e270a3c --- /dev/null +++ b/src/components/ResultGrid/virtualRowMapping.ts @@ -0,0 +1,18 @@ +import { ROW_HEIGHT } from "./dimensions" + +// Browsers cap element height (~17.9M px in Firefox); stay well under it. +export const MAX_CANVAS_PX = 10_000_000 +export const MAX_VIRTUAL_ROWS = Math.floor(MAX_CANVAS_PX / ROW_HEIGHT) +// Past the cap, the last LEAP_TAIL_ROWS rows jump to the result's tail so the +// end stays reachable; the head scrolls 1:1. +export const LEAP_TAIL_ROWS = 1000 + +export const toAbsoluteIndex = ( + virtualIndex: number, + rowCount: number, +): number => { + if (rowCount <= MAX_VIRTUAL_ROWS) return virtualIndex + const headCount = MAX_VIRTUAL_ROWS - LEAP_TAIL_ROWS + if (virtualIndex < headCount) return virtualIndex + return rowCount - (MAX_VIRTUAL_ROWS - virtualIndex) +} diff --git a/src/js/console/grid.js b/src/js/console/grid.js index b8695f67f..4d7bba855 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -26,6 +26,7 @@ import { unescapeHtml } from "../../utils/escapeHtml" import { toast } from "../../components" import { trackEvent } from "../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" +import { buildResultPageMarkdown } from "../../components/ResultGrid/resultPageMarkdown" const hashString = (str) => { let hash = 0 @@ -792,92 +793,16 @@ export function grid(rootElement, _paginationFn, id) { } } - function getQueryPlanAsMarkdown() { - let lines = ["```"] - rows.forEach((row) => { - if (row.style.display === "flex") { - const col = row.querySelector(".qg-c") - if (col) { - lines.push(col.textContent) - } - } - }) - lines.push("```") - return lines.join("\n") - } - - function getResultSetGridAsMarkdown() { - // first, we get a starting width, based on the column header - // this is necessary to get a properly formatted, pipe-aligned table - - // format is: title\ntype\ntitle2\ntype2 - // therefore we need to skip alternate entries - let header_splits = header.innerText.split(/\n/).filter((elem, index) => { - return index % 2 === 0 - }) - let column_widths = Array(header_splits.length).fill(0) - - for (const [i, header_split] of header_splits.entries()) { - // tslint:disable-next-line:no-bitwise - column_widths[i] = Math.max(column_widths[i], header_split.length) - } - - // then we loop over our rows to check how wide it needs to be according to the data - for (const row of rows) { - let row_splits = row.innerText.split(/\n/) - for (const [i, row_split] of row_splits.entries()) { - column_widths[i] = Math.max(column_widths[i], row_split.length) - } - } - - // now we know the widths, we need to construct the header row - let header_row_builder = ["|"] - - for (const [i, header_split] of header_splits.entries()) { - header_row_builder.push(header_split.padEnd(column_widths[i])) - header_row_builder.push("|") - } - - let header_row = header_row_builder.join(" ") - - // now we need the pad row - let pad_row_builder = ["|"] - for (const [i, header_split] of header_splits.entries()) { - pad_row_builder.push("-".repeat(column_widths[i])) - pad_row_builder.push("|") - } - - let pad_row = pad_row_builder.join(" ") - - let data_rows_builder = [] - - for (const row of rows) { - let row_splits = row.innerText.split(/\n/) - - // sometimes we get arrays like this: [""] in rows - // this usually happens at the end of the result set - // we don't want them... - if (row_splits.length === 1 && row_splits[0] === "") { - continue - } - let data_row_builder = ["|"] - - for (const [i, row_split] of row_splits.entries()) { - data_row_builder.push(row_split.padEnd(column_widths[i])) - data_row_builder.push("|") + // Copies the loaded page window (data[loPage..hiPage] — what was fetched from + // the server and is being viewed), not just the rows currently in the DOM. + function getResultAsMarkdown() { + const loadedRows = [] + for (let page = loPage; page <= hiPage; page++) { + if (data[page] && data[page].length) { + loadedRows.push(...data[page]) } - data_rows_builder.push(data_row_builder.join(" ")) } - - let data_rows = data_rows_builder.join("\n") - - return [header_row, pad_row, data_rows].join("\n") - } - - function getResultAsMarkdown() { - return header.innerText === "QUERY PLAN\nstring" - ? getQueryPlanAsMarkdown() - : getResultSetGridAsMarkdown() + return buildResultPageMarkdown(columns, loadedRows) } function colFreezeToggle() { diff --git a/src/providers/LocalStorageProvider/index.tsx b/src/providers/LocalStorageProvider/index.tsx index 7f53aa549..1008f1d40 100644 --- a/src/providers/LocalStorageProvider/index.tsx +++ b/src/providers/LocalStorageProvider/index.tsx @@ -22,10 +22,16 @@ * ******************************************************************************/ -import React, { createContext, useState, useContext, useCallback } from "react" +import React, { + createContext, + useState, + useContext, + useCallback, + useEffect, +} from "react" import { getValue, setValue } from "../../utils/localStorage" import { StoreKey } from "../../utils/localStorage/types" -import { parseInteger } from "./utils" +import { parseInteger, parseBoolean } from "./utils" import { AiAssistantSettings, LocalConfig, @@ -46,6 +52,7 @@ const defaultConfig: LocalConfig = { resultsSplitterBasis: 350, exampleQueriesVisited: false, autoRefreshTables: true, + useNewGrid: true, aiAssistantSettings: DEFAULT_AI_ASSISTANT_SETTINGS, leftPanelState: { type: LeftPanelType.DATASOURCES, @@ -62,6 +69,7 @@ type ContextProps = { updateSettings: (key: StoreKey, value: SettingsType) => void exampleQueriesVisited: boolean autoRefreshTables: boolean + useNewGrid: boolean leftPanelState: LeftPanelState updateLeftPanelState: (state: LeftPanelState) => void aiAssistantSettings: AiAssistantSettings @@ -77,6 +85,7 @@ const defaultValues: ContextProps = { updateSettings: (_key: StoreKey, _value: SettingsType) => undefined, exampleQueriesVisited: false, autoRefreshTables: true, + useNewGrid: true, leftPanelState: defaultConfig.leftPanelState, updateLeftPanelState: (_state: LeftPanelState) => undefined, aiAssistantSettings: defaultConfig.aiAssistantSettings, @@ -120,6 +129,35 @@ export const LocalStorageProvider = ({ : defaultConfig.autoRefreshTables, ) + const getInitialNewGrid = (): boolean => { + const param = new URLSearchParams(window.location.search).get("useNewGrid") + if (param === "1" || param === "0") { + const value = param === "1" + setValue(StoreKey.USE_NEW_GRID, String(value)) + return value + } + return parseBoolean( + getValue(StoreKey.USE_NEW_GRID), + defaultConfig.useNewGrid, + ) + } + + const [useNewGrid, setUseNewGrid] = useState(getInitialNewGrid) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + if (!params.has("useNewGrid")) return + params.delete("useNewGrid") + const query = params.toString() + window.history.replaceState( + {}, + "", + window.location.pathname + + (query ? `?${query}` : "") + + window.location.hash, + ) + }, []) + const getLeftPanelState = (): LeftPanelState => { const stored = getValue(StoreKey.LEFT_PANEL_STATE) if (stored) { @@ -213,6 +251,9 @@ export const LocalStorageProvider = ({ case StoreKey.AUTO_REFRESH_TABLES: setAutoRefreshTables(value === "true") break + case StoreKey.USE_NEW_GRID: + setUseNewGrid(value === "true") + break case StoreKey.AI_ASSISTANT_SETTINGS: setAiAssistantSettings(getAiAssistantSettings()) break @@ -229,6 +270,7 @@ export const LocalStorageProvider = ({ updateSettings, exampleQueriesVisited, autoRefreshTables, + useNewGrid, leftPanelState, updateLeftPanelState, aiAssistantSettings, diff --git a/src/providers/LocalStorageProvider/types.ts b/src/providers/LocalStorageProvider/types.ts index 48e55986d..144476236 100644 --- a/src/providers/LocalStorageProvider/types.ts +++ b/src/providers/LocalStorageProvider/types.ts @@ -39,6 +39,7 @@ export type LocalConfig = { resultsSplitterBasis: number exampleQueriesVisited: boolean autoRefreshTables: boolean + useNewGrid: boolean leftPanelState: LeftPanelState aiAssistantSettings: AiAssistantSettings aiChatPanelWidth: number diff --git a/src/scenes/Result/ResultGridAdapter.tsx b/src/scenes/Result/ResultGridAdapter.tsx new file mode 100644 index 000000000..e0eb8792a --- /dev/null +++ b/src/scenes/Result/ResultGridAdapter.tsx @@ -0,0 +1,186 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState, +} from "react" +import styled from "styled-components" +import { + ResultGrid, + buildResultPageMarkdown, + type ResultGridHandle, + type ResultGridRow, +} from "../../components/ResultGrid" +import type { ColumnDefinition } from "../../utils/questdb/types" +import type { IQuestDBGrid } from "../../js/console/grid" +import { usePagedDataSource, type PaginationFn } from "./usePagedDataSource" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" +import { + loadColumnLayout, + saveColumnLayout, + removeColumnLayout, +} from "./columnLayoutStore" + +const AdapterRoot = styled.div<{ $visible: boolean }>` + display: ${({ $visible }) => ($visible ? "flex" : "none")}; + flex-direction: column; + flex: 1; + width: 100%; + min-height: 0; +` + +// Narrows IQuestDBGrid.setData's `any` to the fields we read. +type DqlResultInput = { + columns: ColumnDefinition[] + dataset: ResultGridRow[] + count: number + query: string + timestamp?: number +} + +type ResultGridAdapterProps = { + isFocused?: boolean + paginationFn?: PaginationFn +} + +// Backs the legacy IQuestDBGrid surface the console's Result scene drives with +// the neutral React ResultGrid and a server-paged data source. +export const ResultGridAdapter = forwardRef< + IQuestDBGrid, + ResultGridAdapterProps +>(({ isFocused = true, paginationFn }, ref) => { + const { dataSource, setResult, getSQL, getLoadedRows, hasData } = + usePagedDataSource(paginationFn) + + const [visible, setVisible] = useState(true) + const [runToken, setRunToken] = useState(0) + const [restoredSizing, setRestoredSizing] = useState>( + {}, + ) + const [restoredOrder, setRestoredOrder] = useState([]) + const [restoredPinned, setRestoredPinned] = useState([]) + + const rootRef = useRef(null) + const gridImperativeRef = useRef(null) + // Current result's columns, for keying the persisted layout on commit. + const columnsRef = useRef([]) + const listenersRef = useRef void>>>( + new Map(), + ) + + const emit = useCallback((name: string, detail?: unknown) => { + const set = listenersRef.current.get(name) + if (!set) return + const event = new CustomEvent(name, { detail }) + set.forEach((fn) => fn(event)) + }, []) + + const handleSizingCommit = useCallback((sizing: Record) => { + saveColumnLayout(columnsRef.current, { columnSizing: sizing }) + }, []) + + const handleOrderCommit = useCallback((order: string[]) => { + saveColumnLayout(columnsRef.current, { columnOrder: order }) + }, []) + + const handleResetLayout = useCallback(() => { + removeColumnLayout(columnsRef.current) + }, []) + + const handlePinnedColumnsCommit = useCallback( + (pinnedLeft: string[]) => { + saveColumnLayout(columnsRef.current, { pinnedColumns: pinnedLeft }) + emit("freeze.state", { freezeLeft: pinnedLeft.length }) + }, + [emit], + ) + + useImperativeHandle( + ref, + (): IQuestDBGrid => ({ + setData: (incoming: DqlResultInput) => { + setResult({ + columns: incoming.columns, + dataset: incoming.dataset, + count: incoming.count, + query: incoming.query, + timestamp: incoming.timestamp, + }) + setRunToken((token) => token + 1) + columnsRef.current = incoming.columns + const layout = loadColumnLayout(incoming.columns) + setRestoredSizing(layout?.columnSizing ?? {}) + setRestoredOrder(layout?.columnOrder ?? []) + const pinnedLeft = layout?.pinnedColumns ?? [] + setRestoredPinned(pinnedLeft) + // Sync the toolbar's freeze button to the restored layout. + emit("freeze.state", { freezeLeft: pinnedLeft.length }) + }, + + getSQL: () => getSQL(), + + focus: () => { + rootRef.current?.querySelector('[role="grid"]')?.focus() + }, + + show: () => setVisible(true), + + hide: () => setVisible(false), + + // No-op: React + the grid's ResizeObserver handle layout. + render: () => undefined, + + addEventListener: ( + eventName: string, + fn: (event: CustomEvent) => void, + ) => { + const set = listenersRef.current.get(eventName) ?? new Set() + set.add(fn) + listenersRef.current.set(eventName, set) + }, + + clearCustomLayout: () => gridImperativeRef.current?.resetLayout(), + shuffleFocusedColumnToFront: () => + gridImperativeRef.current?.shuffleFocusedColumnToFront(), + toggleFreezeLeft: () => gridImperativeRef.current?.toggleFreezeLeft(), + + getResultAsMarkdown: () => + buildResultPageMarkdown(columnsRef.current, getLoadedRows()), + }), + [setResult, getSQL, getLoadedRows, emit], + ) + + return ( + + {hasData && ( + emit("yield.focus")} + onResetLayout={handleResetLayout} + onSelectionChange={(hasSelection) => + emit("selection.change", { hasSelection }) + } + onCellCopy={() => void trackEvent(ConsoleEvent.GRID_CELL_COPY)} + onColumnCopy={() => void trackEvent(ConsoleEvent.GRID_COLUMN_COPY)} + /> + )} + + ) +}) + +ResultGridAdapter.displayName = "ResultGridAdapter" diff --git a/src/scenes/Result/columnLayoutStore.ts b/src/scenes/Result/columnLayoutStore.ts new file mode 100644 index 000000000..091f0669a --- /dev/null +++ b/src/scenes/Result/columnLayoutStore.ts @@ -0,0 +1,71 @@ +import type { ColumnDefinition } from "../../utils/questdb/types" + +const STORAGE_KEY = "result.grid.layout" +const LRU_MAX = 50 + +export type ColumnLayout = { + columnSizing?: Record + columnOrder?: string[] + pinnedColumns?: string[] +} + +type LayoutStore = Record + +const hashColumnSet = (columns: ColumnDefinition[]): string => { + const str = JSON.stringify( + columns.map((c) => ({ name: c.name, type: c.type })), + ) + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash + char) | 0 + } + return (hash >>> 0).toString(36) +} + +const readStore = (): LayoutStore => { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") as LayoutStore + } catch { + return {} + } +} + +const writeStore = (store: LayoutStore): void => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)) + } catch { + // ignore quota / serialization errors — persistence is best-effort + } +} + +export const loadColumnLayout = ( + columns: ColumnDefinition[], +): ColumnLayout | null => { + if (!columns.length) return null + return readStore()[hashColumnSet(columns)] ?? null +} + +export const removeColumnLayout = (columns: ColumnDefinition[]): void => { + if (!columns.length) return + const store = readStore() + delete store[hashColumnSet(columns)] + writeStore(store) +} + +export const saveColumnLayout = ( + columns: ColumnDefinition[], + layout: ColumnLayout, +): void => { + if (!columns.length) return + const key = hashColumnSet(columns) + const store = readStore() + const existing = store[key] + delete store[key] + store[key] = { ...existing, ...layout } + const keys = Object.keys(store) + for (let i = 0; i < keys.length - LRU_MAX; i++) { + delete store[keys[i]] + } + writeStore(store) +} diff --git a/src/scenes/Result/index.tsx b/src/scenes/Result/index.tsx index 2fb40f8ed..9d678dc73 100644 --- a/src/scenes/Result/index.tsx +++ b/src/scenes/Result/index.tsx @@ -23,7 +23,13 @@ ******************************************************************************/ import $ from "jquery" -import React, { useContext, useEffect, useRef, useState } from "react" +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react" import { useDispatch, useSelector } from "react-redux" import styled from "styled-components" import { Download2, Refresh } from "@styled-icons/remix-line" @@ -46,7 +52,7 @@ import { Tooltip, } from "../../components" import { actions, selectors } from "../../store" -import { color, ErrorResult, QueryRawResult, RawErrorResult } from "../../utils" +import { color, ErrorResult, RawErrorResult } from "../../utils" import * as QuestDB from "../../utils/questdb" import { ResultViewMode } from "scenes/Console/types" import type { IQuestDBGrid } from "../../js/console/grid.js" @@ -62,6 +68,9 @@ import { useQueryExecutionState } from "../../hooks/useQueryExecutionState" import { API_VERSION } from "../../consts" import { trackEvent } from "../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" +import { useLocalStorage } from "../../providers/LocalStorageProvider" +import { ResultGridAdapter } from "./ResultGridAdapter" +import { type PaginationFn } from "./usePagedDataSource" const Root = styled.div` display: flex; @@ -150,40 +159,47 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const result = useSelector(selectors.query.getResult) const { active: activeQueryExecution } = useQueryExecutionState() const activeSidebar = useSelector(selectors.console.getActiveSidebar) - const gridRef = useRef() + const gridRef = useRef(null) const [gridFreezeLeftState, setGridFreezeLeftState] = useState(0) const [gridHasSelection, setGridHasSelection] = useState(false) const [downloadMenuActive, setDownloadMenuActive] = useState(false) + const { useNewGrid } = useLocalStorage() const dispatch = useDispatch() - useEffect(() => { - const _grid = grid( - document.getElementById("grid"), - async function (sql, lo, hi, rendererFn: (data: QueryRawResult) => void) { - try { - const result = await quest.queryRaw(sql, { - limit: `${lo},${hi}`, - nm: true, - }) - if (result.type === QuestDB.Type.DQL) { - rendererFn(result) - } - } catch (err) { - // Order of actions is important - dispatch( - actions.query.addNotification({ - query: `${sql}@${LINE_NUMBER_HARD_LIMIT + 1}-${LINE_NUMBER_HARD_LIMIT + 1}`, - content: {(err as ErrorResult).error}, - sideContent: , - type: NotificationType.ERROR, - updateActiveNotification: true, - }), - ) - dispatch(actions.query.stopRunning()) + // Shared by both grids. On a failed fetch it surfaces a notification and never + // calls the renderer, leaving the failing page unloaded. + const paginationFn = useCallback( + async (sql, lo, hi, rendererFn) => { + try { + const result = await quest.queryRaw(sql, { + limit: `${lo},${hi}`, + nm: true, + }) + if (result.type === QuestDB.Type.DQL) { + rendererFn(result) } - }, - ) - gridRef.current = _grid + } catch (err) { + // Order of actions is important + dispatch( + actions.query.addNotification({ + query: `${sql}@${LINE_NUMBER_HARD_LIMIT + 1}-${LINE_NUMBER_HARD_LIMIT + 1}`, + content: {(err as ErrorResult).error}, + sideContent: , + type: NotificationType.ERROR, + updateActiveNotification: true, + }), + ) + dispatch(actions.query.stopRunning()) + } + }, + [], + ) + + useEffect(() => { + if (!useNewGrid) { + gridRef.current = grid(document.getElementById("grid"), paginationFn) + } + quickVis( $("#quick-vis"), window.bus as unknown as ReturnType, @@ -191,23 +207,26 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { questExecution, ) - _grid.addEventListener( - "selection.change", - function (event: CustomEvent<{ hasSelection: boolean }>) { - setGridHasSelection(event.detail.hasSelection) - }, - ) - - _grid.addEventListener("yield.focus", function () { - eventBus.publish(EventType.MSG_EDITOR_FOCUS) - }) - - _grid.addEventListener( - "freeze.state", - function (event: CustomEvent<{ freezeLeft: number }>) { - setGridFreezeLeftState(event.detail.freezeLeft) - }, - ) + const _grid = gridRef.current + if (_grid) { + _grid.addEventListener( + "selection.change", + function (event: CustomEvent<{ hasSelection: boolean }>) { + setGridHasSelection(event.detail.hasSelection) + }, + ) + + _grid.addEventListener("yield.focus", function () { + eventBus.publish(EventType.MSG_EDITOR_FOCUS) + }) + + _grid.addEventListener( + "freeze.state", + function (event: CustomEvent<{ freezeLeft: number }>) { + setGridFreezeLeftState(event.detail.freezeLeft) + }, + ) + } }, []) useEffect(() => { @@ -218,10 +237,9 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { }, [result]) useEffect(() => { - const grid = document.getElementById("grid") const chart = document.getElementById("quick-vis") - if (!grid || !chart) { + if (!chart) { return } @@ -242,9 +260,10 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const gridActions = [ { - tooltipText: "Copy result to Markdown", + tooltipText: "Copy current page to Markdown", trigger: (