diff --git a/e2e/questdb b/e2e/questdb index b8cf42f11..f3788a1b6 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit b8cf42f11631c207df3fe7939b87e61b7b966210 +Subproject commit f3788a1b64a82f87b1414a0dc1da0bbc9f9a460a diff --git a/e2e/tests/console/notebookCellControls.spec.js b/e2e/tests/console/notebookCellControls.spec.js new file mode 100644 index 000000000..396ed8f39 --- /dev/null +++ b/e2e/tests/console/notebookCellControls.spec.js @@ -0,0 +1,175 @@ +/// + +// Cell controls that only a real browser exercises: the keyboard pane resize +// and the Stop control of a first run or first chart fetch. + +const HELD_QUERY = /\/exec\?.*long_sequence/ +const CHART_SQL = + "select timestamp_sequence(0, 1000000) ts, x from long_sequence(50)" + +const setCellSql = (sql) => { + cy.focusNotebookCell() + cy.withFocusedEditor((editor) => editor.setValue(sql)) +} + +const runFocusedCell = () => + cy.withFocusedEditor((editor) => editor.getAction("notebook-run").run()) + +const editorSeparator = () => + cy.get('[role="separator"][aria-label="Resize editor pane"]') + +const valueNow = ($separator) => Number($separator.attr("aria-valuenow")) + +const readStoredEditorHeight = (win) => + new Cypress.Promise((resolve, reject) => { + const openRequest = win.indexedDB.open("web-console") + openRequest.onerror = () => reject(openRequest.error) + openRequest.onsuccess = () => { + const database = openRequest.result + const fail = (error) => { + database.close() + reject(error) + } + const transaction = database.transaction( + ["editor_settings", "buffers"], + "readonly", + ) + const activeBufferRequest = transaction + .objectStore("editor_settings") + .index("key") + .get("activeBufferId") + activeBufferRequest.onerror = () => fail(activeBufferRequest.error) + activeBufferRequest.onsuccess = () => { + const bufferRequest = transaction + .objectStore("buffers") + .get(activeBufferRequest.result?.value) + bufferRequest.onerror = () => fail(bufferRequest.error) + bufferRequest.onsuccess = () => { + database.close() + resolve(bufferRequest.result?.notebookViewState?.cells[0]?.topHeight) + } + } + } + }) + +// The notebook writes a resize to IndexedDB after a debounce; polling the +// stored value is the only signal that the write landed. +const waitForStoredEditorHeight = (height) => + cy.window().then((win) => { + const deadline = Date.now() + 8000 + const poll = () => + readStoredEditorHeight(win).then((stored) => { + if (stored === height) return + if (Date.now() > deadline) { + throw new Error(`stored editor height is ${stored}, not ${height}`) + } + return Cypress.Promise.delay(100).then(poll) + }) + return poll() + }) + +// Holds the first matching request open until the test releases it, so the +// cell stays in its first run while the test drives the Stop control. The +// release must follow within Cypress's command timeout, or the held handler +// itself fails the test. +const holdFirstExec = () => { + const held = {} + cy.intercept( + { url: HELD_QUERY, times: 1 }, + () => + new Cypress.Promise((resolve) => { + held.release = resolve + }), + ) + return held +} + +const awaitHeld = (held) => + cy.wrap(null).should(() => { + expect(held.release, "the exec request is held").to.be.a("function") + }) + +const clickStop = (label) => { + cy.getByDataHook("cell-stop-button") + .should("have.attr", "aria-label", label) + .click() +} + +describe("notebook cell controls", () => { + beforeEach(() => { + cy.loadConsoleWithAuth() + cy.createNotebook() + }) + + describe("keyboard resize", () => { + it("stores the height on key release and keeps it across a reload", () => { + // Given a cell whose result splits it into an editor and a result pane + setCellSql("select 1") + runFocusedCell() + editorSeparator().should("have.attr", "aria-valuenow") + let before + editorSeparator().then(($separator) => { + before = valueNow($separator) + }) + + // When the editor pane is grown by three keyboard steps + editorSeparator().focus() + cy.realPress("ArrowDown") + cy.realPress("ArrowDown") + cy.realPress("ArrowDown") + + // Then the pane follows the keys and the release stores the height + editorSeparator().should(($grown) => { + expect(valueNow($grown)).to.eq(before + 30) + }) + cy.then(() => waitForStoredEditorHeight(before + 30)) + + // And the stored height survives a reload + cy.reload() + editorSeparator().should(($restored) => { + expect(valueNow($restored)).to.eq(before + 30) + }) + }) + }) + + describe("Stop", () => { + it("cancels a first run and reports it in the result", () => { + // Given a cell whose first run the server has not answered yet + const held = holdFirstExec() + setCellSql("select x from long_sequence(100)") + runFocusedCell() + awaitHeld(held) + + // When the run is stopped + clickStop("Stop run") + cy.then(() => held.release()) + + // Then the result reports the cancelled run and Stop is gone + cy.get("[data-notebook-cell]") + .contains("Cancelled by user") + .should("be.visible") + cy.getByDataHook("cell-stop-button").should("not.exist") + }) + + it("cancels a first chart fetch and retries it", () => { + // Given a draw cell whose first fetch the server has not answered yet + const held = holdFirstExec() + setCellSql(CHART_SQL) + cy.get("[data-notebook-cell] button[aria-label='Draw']").click() + awaitHeld(held) + + // When the fetch is stopped + clickStop("Stop chart loading") + cy.then(() => held.release()) + + // Then the canvas offers a retry instead of a spinner + cy.getByDataHook("draw-canvas-cancelled").should("be.visible") + cy.getByDataHook("cell-stop-button").should("not.exist") + + // When the fetch is retried, the chart loads from an unheld request + cy.getByDataHook("draw-canvas-retry").click() + cy.getByDataHook("draw-canvas-cancelled").should("not.exist") + cy.getByDataHook("draw-canvas").find("canvas").should("be.visible") + }) + }) +}) diff --git a/e2e/tests/console/result_charts.spec.js b/e2e/tests/console/result_charts.spec.js index b2cf38bc0..d9329b267 100644 --- a/e2e/tests/console/result_charts.spec.js +++ b/e2e/tests/console/result_charts.spec.js @@ -52,4 +52,34 @@ describe("questdb charts", () => { .invoke("height") .should("be.gt", 0) }) + + it("keeps reset zoom available after the viewport widens", () => { + // Given a narrow viewport with a dense chart that shows the zoom slider + cy.viewport(760, 900) + cy.typeQueryDirectly( + "SELECT rnd_timestamp(to_timestamp('2024-07-19:00:00:00.000000', 'yyyy-MM-dd:HH:mm:ss.SSSUUU'), to_timestamp('2024-07-20:00:00:00.000000', 'yyyy-MM-dd:HH:mm:ss.SSSUUU'), 0), x FROM long_sequence(500);", + ) + cy.clickRunIconInLine(1) + cy.getByDataHook("chart-panel-button").should("be.visible").click() + + // When the user drags the slider to zoom in + cy.getByDataHook("result-chart") + .find("canvas") + .should("be.visible") + .then(($canvas) => { + const canvas = $canvas[0] + const sliderY = canvas.getBoundingClientRect().height - 44 + + cy.wrap(canvas).realMouseDown({ x: 54, y: sliderY }) + cy.wrap(canvas).realMouseMove(130, sliderY) + cy.wrap(canvas).realMouseUp({ x: 130, y: sliderY }) + }) + + // Then reset zoom appears and survives the viewport widening + cy.getByDataHook("result-chart-reset-zoom").should("be.visible") + + cy.viewport(1280, 900) + cy.getByDataHook("result-chart-reset-zoom").should("be.visible").click() + cy.getByDataHook("result-chart-reset-zoom").should("not.exist") + }) }) diff --git a/src/components/DropdownMenu/index.tsx b/src/components/DropdownMenu/index.tsx index c37089522..7e8134074 100644 --- a/src/components/DropdownMenu/index.tsx +++ b/src/components/DropdownMenu/index.tsx @@ -1,6 +1,6 @@ import React from "react" import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu" -import { CaretRightIcon } from "@phosphor-icons/react" +import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react" import styled, { css } from "styled-components" import { menuContainerStyles, @@ -76,6 +76,42 @@ const Item = React.forwardRef< Item.displayName = "DropdownMenuItem" +const StyledCheckboxItem = styled(RadixDropdownMenu.CheckboxItem)` + ${menuItemStyles} +` + +const CheckSlot = styled.span` + display: inline-flex; + width: 1.6rem; + margin-left: auto; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.color.contentAccent}; +` + +type CheckboxItemProps = React.ComponentPropsWithoutRef< + typeof RadixDropdownMenu.CheckboxItem +> & { + icon?: React.ReactNode +} + +const CheckboxItem = React.forwardRef< + React.ElementRef, + CheckboxItemProps +>(({ icon, children, ...props }, ref) => ( + + {icon != null && {icon}} + {children} + + + + + + +)) + +CheckboxItem.displayName = "DropdownMenuCheckboxItem" + const StyledSubTrigger = styled(RadixDropdownMenu.SubTrigger)` ${menuItemStyles} justify-content: space-between; @@ -120,6 +156,8 @@ export const DropdownMenu = { Item, + CheckboxItem, + Sub: RadixDropdownMenu.Sub, SubTrigger, diff --git a/src/components/SegmentedControl/index.tsx b/src/components/SegmentedControl/index.tsx index 7e7fbe89e..5f07d66f6 100644 --- a/src/components/SegmentedControl/index.tsx +++ b/src/components/SegmentedControl/index.tsx @@ -51,8 +51,7 @@ const SegmentedControlRoot = styled.div` /* The liquid track clips content at its rounded edge, so the standard outside button outline is not visible here. Draw the keyboard ring inside - every direct action instead, including auxiliary actions such as - maximize/reset that live beside the segments. */ + each segment instead. */ && > button:focus-visible, && > button[aria-pressed="true"]:focus-visible { outline: none; @@ -69,15 +68,7 @@ const FilterDefinitions = styled.svg` pointer-events: none; ` -const GlassSelection = styled.div` - position: absolute; - top: 0; - left: 0; - width: 0; - height: 0; - z-index: 2; - opacity: 0; - pointer-events: none; +export const glassLens = css` background: ${({ theme }) => theme.color.glassSurface}; border: 1px solid ${({ theme }) => theme.color.glassBorder}; border-bottom-width: 2px; @@ -89,6 +80,18 @@ const GlassSelection = styled.div` : `0 3px 9px ${theme.color.shadowSoft}`}; backdrop-filter: blur(6px) saturate(145%); -webkit-backdrop-filter: blur(5px) saturate(150%); +` + +const GlassSelection = styled.div` + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + z-index: 2; + opacity: 0; + pointer-events: none; + ${glassLens} transition: opacity 100ms ease; will-change: transform, width; ` diff --git a/src/components/index.ts b/src/components/index.ts index 4473ed7f0..5f9ce3d9e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -61,7 +61,13 @@ export * from "./PopperHover" export * from "./PopperToggle" export * from "./Popover" export * from "./SelectMenu" -export * from "./SegmentedControl" +export { + SegmentedControl, + SegmentedControlButton, + type SegmentedControlActiveTone, + type SegmentedControlSize, + type SegmentedControlTone, +} from "./SegmentedControl" export * from "./SelectableCardButton" export * from "./SetupAIAssistant" export * from "./Spinner" diff --git a/src/consts/shared-definitions.json b/src/consts/shared-definitions.json index 16c95af3c..2562998e2 100644 --- a/src/consts/shared-definitions.json +++ b/src/consts/shared-definitions.json @@ -184,7 +184,7 @@ "surfaces": ["ai", "mcp"], "mutatesNotebook": true, "createsNotebook": true, - "description": "Duplicate a notebook tab. Copies every cell (SQL, mode, chart config) and the layout into a NEW notebook labelled \"