diff --git a/packages/browser-tests/.gitignore b/packages/browser-tests/.gitignore index cff0eba1e..ef2d09575 100644 --- a/packages/browser-tests/.gitignore +++ b/packages/browser-tests/.gitignore @@ -1,3 +1,4 @@ node_modules cypress/videos cypress/screenshots +cypress/downloads diff --git a/packages/browser-tests/cypress/integration/console/download.spec.js b/packages/browser-tests/cypress/integration/console/download.spec.js new file mode 100644 index 000000000..a4f54a732 --- /dev/null +++ b/packages/browser-tests/cypress/integration/console/download.spec.js @@ -0,0 +1,97 @@ +describe("download functionality", () => { + beforeEach(() => { + cy.loadConsoleWithAuth(); + }); + + it("should show download button with results", () => { + // When + cy.typeQuery("select x from long_sequence(10)"); + cy.runLine(); + + // Then + cy.getByDataHook("download-parquet-button").should("be.visible"); + cy.getByDataHook("download-dropdown-button").should("be.visible"); + cy.getByDataHook("download-csv-button").should("not.exist"); + + // When + cy.getByDataHook("download-dropdown-button").click(); + + // Then + cy.getByDataHook("download-csv-button").should("be.visible"); + }); + + it("should trigger CSV download", () => { + const query = "select x from long_sequence(10)"; + + // Given + cy.intercept("GET", "**/exp?*", (req) => { + req.reply({ + statusCode: 200, + body: null, + }); + }).as("exportRequest"); + + // When + cy.typeQuery(query); + cy.runLine(); + cy.getByDataHook("download-dropdown-button").click(); + cy.getByDataHook("download-csv-button").click(); + + // Then + cy.wait("@exportRequest").then((interception) => { + expect(interception.request.url).to.include("fmt=csv"); + expect(interception.request.url).to.include( + encodeURIComponent(query.replace(/\s+/g, " ")) + ); + }); + }); + + it("should trigger Parquet download", () => { + const query = "select x from long_sequence(10)"; + + // Given + cy.intercept("GET", "**/exp?*", (req) => { + req.reply({ + statusCode: 200, + body: null, + }); + }).as("exportRequest"); + + // When + cy.typeQuery(query); + cy.runLine(); + cy.getByDataHook("download-parquet-button").click(); + + // Then + cy.wait("@exportRequest").then((interception) => { + expect(interception.request.url).to.include("fmt=parquet"); + expect(interception.request.url).to.include("rmode=nodelay"); + expect(interception.request.url).to.include( + encodeURIComponent(query.replace(/\s+/g, " ")) + ); + }); + }); + + it("should show error toast on bad request", () => { + // Given + cy.intercept("GET", "**/exp?*", (req) => { + const url = new URL(req.url); + url.searchParams.set("fmt", "badformat"); + req.url = url.toString(); + }).as("badExportRequest"); + + // When + cy.typeQuery("select x from long_sequence(5)"); + cy.runLine(); + cy.getByDataHook("download-dropdown-button").click(); + cy.getByDataHook("download-csv-button").click(); + + // Then + cy.wait("@badExportRequest").then(() => { + cy.getByRole("alert").should( + "contain", + "An error occurred while downloading the file: unrecognised format [format=badformat]" + ); + }); + }); +}); diff --git a/packages/browser-tests/questdb b/packages/browser-tests/questdb index bdd0fbf67..93f248438 160000 --- a/packages/browser-tests/questdb +++ b/packages/browser-tests/questdb @@ -1 +1 @@ -Subproject commit bdd0fbf6784312962761566bba26665e5fb8ab6e +Subproject commit 93f248438f1854eacdfe592aafb4a8a9e0fc63d4 diff --git a/packages/web-console/serve-dist.js b/packages/web-console/serve-dist.js index a1a5fe2c9..dc8cb9b37 100644 --- a/packages/web-console/serve-dist.js +++ b/packages/web-console/serve-dist.js @@ -22,7 +22,8 @@ const server = http.createServer((req, res) => { reqPathName.startsWith("/settings") || reqPathName.startsWith("/warnings") || reqPathName.startsWith("/chk") || - reqPathName.startsWith("/imp") + reqPathName.startsWith("/imp") || + reqPathName.startsWith("/exp") ) { // proxy /exec requests to localhost:9000 const options = { diff --git a/packages/web-console/src/components/LoadingSpinner/index.tsx b/packages/web-console/src/components/LoadingSpinner/index.tsx new file mode 100644 index 000000000..161f142b4 --- /dev/null +++ b/packages/web-console/src/components/LoadingSpinner/index.tsx @@ -0,0 +1,24 @@ +import React from "react" +import styled, { useTheme } from "styled-components" +import { Loader3 } from "@styled-icons/remix-line" +import { Color } from "../../types" +import { spinAnimation } from "../../components/Animation" + +const StyledLoader = styled(Loader3)<{ $size: string, $color: Color }>` + width: ${({ $size }) => $size}; + height: ${({ $size }) => $size}; + color: ${({ $color, theme }) => $color ? theme.color[$color] : theme.color.pink}; + ${spinAnimation}; +` + +type Props = { + size?: string + color?: Color +} + +export const LoadingSpinner = ({ size = "18px", color = "pink" }: Props) => { + const theme = useTheme() + return ( + + ) +} diff --git a/packages/web-console/src/components/index.ts b/packages/web-console/src/components/index.ts index e8c085f35..dc2b67eb4 100644 --- a/packages/web-console/src/components/index.ts +++ b/packages/web-console/src/components/index.ts @@ -31,6 +31,7 @@ export * from "./Hooks" export * from "./IconWithTooltip" export * from "./Input" export * from "./Link" +export * from "./LoadingSpinner" export * from "./PaneContent" export * from "./PaneMenu" export * from "./PaneWrapper" diff --git a/packages/web-console/src/scenes/Result/index.tsx b/packages/web-console/src/scenes/Result/index.tsx index 09dc656fa..b46ffc730 100644 --- a/packages/web-console/src/scenes/Result/index.tsx +++ b/packages/web-console/src/scenes/Result/index.tsx @@ -32,12 +32,14 @@ import { HandPointLeft } from "@styled-icons/fa-regular" import { TableFreezeColumn } from "@styled-icons/fluentui-system-filled" import { Markdown } from "@styled-icons/bootstrap/Markdown" import { Check } from "@styled-icons/bootstrap/Check" +import { ArrowDownS } from "@styled-icons/remix-line" import { grid } from "../../js/console/grid" import { quickVis } from "../../js/console/quick-vis" import { PaneContent, PaneWrapper, PopperHover, + PopperToggle, PrimaryToggleButton, Text, Tooltip, @@ -46,7 +48,7 @@ import { actions, selectors } from "../../store" import { color, ErrorResult, QueryRawResult } from "../../utils" import * as QuestDB from "../../utils/questdb" import { ResultViewMode } from "scenes/Console/types" -import { Button } from "@questdb/react-components" +import { Button, Box } from "@questdb/react-components" import type { IQuestDBGrid } from "../../js/console/grid.js" import { eventBus } from "../../modules/EventBus" import { EventType } from "../../modules/EventBus/types" @@ -55,6 +57,8 @@ import { LINE_NUMBER_HARD_LIMIT } from "../Editor/Monaco" import { QueryInNotification } from "../Editor/Monaco/query-in-notification" import { NotificationType } from "../../store/Query/types" import { copyToClipboard } from "../../utils/copyToClipboard" +import { toast } from "../../components" +import { API_VERSION } from "../../consts" const Root = styled.div` display: flex; @@ -96,6 +100,39 @@ const TableFreezeColumnIcon = styled(TableFreezeColumn)` const RowCount = styled(Text)` margin-right: 1rem; + line-height: 1.285; +` + +const DownloadButton = styled(Button)` + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0 1rem; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +` + +const ArrowIcon = styled(ArrowDownS)<{ $open: boolean }>` + transform: ${({ $open }) => $open ? "rotate(180deg)" : "rotate(0deg)"}; +` + +const DownloadDropdownButton = styled(Button)` + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0 0.5rem; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +` + +const DownloadMenuItem = styled(Button)` + display: flex; + align-items: center; + gap: 1.2rem; + width: 100%; + height: 3rem; + padding: 0 1rem; + font-size: 1.4rem; ` const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { @@ -105,6 +142,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const activeSidebar = useSelector(selectors.console.getActiveSidebar) const gridRef = useRef() const [gridFreezeLeftState, setGridFreezeLeftState] = useState(0) + const [downloadMenuActive, setDownloadMenuActive] = useState(false) const dispatch = useDispatch() useEffect(() => { @@ -264,6 +302,48 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { } }, [result]) + const handleDownload = (format: "csv" | "parquet") => { + setDownloadMenuActive(false) + const sql = gridRef?.current?.getSQL() + if (!sql) { + toast.error("No SQL query found to download") + return + } + + const url = `exp?${QuestDB.Client.encodeParams({ + query: sql, + version: API_VERSION, + fmt: `${format}`, + filename: `questdb-query-${Date.now().toString()}`, + ...(format === "parquet" ? { rmode: "nodelay" } : {}), + })}` + + const iframe = document.createElement("iframe") + iframe.style.display = "none" + document.body.appendChild(iframe) + + iframe.onerror = (e) => { + toast.error(`An error occurred while downloading the file: ${e}`) + } + + iframe.onload = () => { + const content = iframe.contentDocument?.body?.textContent + if (content) { + let error = 'An error occurred while downloading the file' + try { + const contentJson = JSON.parse(content) + error += `: ${contentJson.error ?? content}` + } catch (_) { + error += `: ${content}` + } + toast.error(error) + } + document.body.removeChild(iframe) + } + + iframe.src = url + } + return ( @@ -285,25 +365,45 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { ))} - handleDownload("parquet")} + > + + + Download as Parquet + + + { - const sql = gridRef?.current?.getSQL() - if (sql) { - quest.exportQueryToCsv(sql) - } - }} + - - + + } > - Download result as a CSV file - + handleDownload("csv")} + > + Download as CSV + + diff --git a/packages/web-console/src/scenes/Search/SearchPanel.tsx b/packages/web-console/src/scenes/Search/SearchPanel.tsx index 7cb5c38c4..3f3391076 100644 --- a/packages/web-console/src/scenes/Search/SearchPanel.tsx +++ b/packages/web-console/src/scenes/Search/SearchPanel.tsx @@ -1,5 +1,4 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react' -import { Loader3 } from '@styled-icons/remix-line' import { Error as ErrorIcon } from '@styled-icons/boxicons-regular' import styled, { css } from 'styled-components' import { Input, Checkbox } from '@questdb/react-components' @@ -11,7 +10,7 @@ import { eventBus } from '../../modules/EventBus' import { EventType } from '../../modules/EventBus/types' import { useSearch } from '../../providers' import { db } from '../../store/db' -import { spinAnimation } from '../../components' +import { LoadingSpinner } from '../../components' import { color } from '../../utils' import { useEffectIgnoreFirst } from '../../components/Hooks/useEffectIgnoreFirst' import { SearchTimeoutError, SearchCancelledError, terminateSearchWorker } from '../../utils/textSearch' @@ -113,12 +112,6 @@ const SearchSummary = styled.div` font-size: 1.1rem; ` -const Loader = styled(Loader3)` - width: 2rem; - color: ${color("pink")}; - ${spinAnimation}; -` - const CheckboxWrapper = styled.div` display: flex; align-items: center; @@ -189,7 +182,7 @@ const DelayedLoader = () => { return ( - + Searching... ) diff --git a/packages/web-console/src/utils/questdb/client.ts b/packages/web-console/src/utils/questdb/client.ts index 40526ee87..37cde899c 100644 --- a/packages/web-console/src/utils/questdb/client.ts +++ b/packages/web-console/src/utils/questdb/client.ts @@ -25,7 +25,7 @@ import { Permission, SymbolColumnDetails, } from "./types" -import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState"; +import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState" export class Client { private _controllers: AbortController[] = [] @@ -453,29 +453,6 @@ export class Client { return { status: response.status, success: true } } - async exportQueryToCsv(query: string) { - try { - const response: Response = await fetch( - `exp?${Client.encodeParams({ query, version: API_VERSION })}`, - { headers: this.commonHeaders }, - ) - const blob = await response.blob() - const filename = response.headers - .get("Content-Disposition") - ?.split("=")[1] - const url = window.URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = filename - ? filename.replaceAll(`"`, "") - : `questdb-query-${new Date().getTime()}.csv` - a.click() - window.URL.revokeObjectURL(url) - } catch (error) { - throw error - } - } - async getLatestRelease() { try { const response: Response = await fetch(