diff --git a/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx b/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx index 1c2c50a8b4..a593266ee1 100644 --- a/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import {ArrowUpRightFromSquare} from '@gravity-ui/icons'; -import {Button, Tooltip} from '@gravity-ui/uikit'; +import {ArrowDownToLine, ArrowUpRightFromSquare, ChevronDown} from '@gravity-ui/icons'; +import type {ButtonProps} from '@gravity-ui/uikit'; +import {Button, DropdownMenu, Tooltip} from '@gravity-ui/uikit'; import {planToSvgApi} from '../../../../../../store/reducers/planToSvg'; import type {QueryPlan, ScriptPlan} from '../../../../../../types/api/query'; +import {prepareCommonErrorMessage} from '../../../../../../utils/errors'; import i18n from '../../i18n'; function getButtonView(error: string | null, isLoading: boolean) { @@ -24,20 +26,48 @@ export function PlanToSvgButton({plan, database}: PlanToSvgButtonProps) { const [blobUrl, setBlobUrl] = React.useState(null); const [getPlanToSvg, {isLoading}] = planToSvgApi.useLazyPlanToSvgQueryQuery(); - const handleClick = React.useCallback(() => { - getPlanToSvg({plan, database}) + const handleGetSvg = React.useCallback(() => { + if (blobUrl) { + return Promise.resolve(blobUrl); + } + + return getPlanToSvg({plan, database}) .unwrap() .then((result) => { const blob = new Blob([result], {type: 'image/svg+xml'}); const url = URL.createObjectURL(blob); setBlobUrl(url); setError(null); - window.open(url, '_blank'); + return url; }) .catch((err) => { - setError(JSON.stringify(err)); + setError(prepareCommonErrorMessage(err)); + return null; }); - }, [database, getPlanToSvg, plan]); + }, [database, getPlanToSvg, plan, blobUrl]); + + const handleOpenInNewTab = React.useCallback(() => { + handleGetSvg().then((url) => { + if (url) { + window.open(url, '_blank'); + } + }); + return; + }, [handleGetSvg]); + + const handleDownload = React.useCallback(() => { + handleGetSvg().then((url) => { + const link = document.createElement('a'); + if (url) { + link.href = url; + link.download = 'query-plan.svg'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + }); + return; + }, [handleGetSvg]); React.useEffect(() => { return () => { @@ -47,21 +77,36 @@ export function PlanToSvgButton({plan, database}: PlanToSvgButtonProps) { }; }, [blobUrl]); - return ( - - - - ); + const items = [ + { + text: i18n('text_open-new-tab'), + icon: , + action: handleOpenInNewTab, + }, + { + text: i18n('text_download'), + icon: , + action: handleDownload, + }, + ]; + + const renderSwitcher = (props: ButtonProps) => { + return ( + + + + ); + }; + + return ; } diff --git a/src/containers/Tenant/Query/QueryResult/i18n/en.json b/src/containers/Tenant/Query/QueryResult/i18n/en.json index c8a4730c0b..c9ae630222 100644 --- a/src/containers/Tenant/Query/QueryResult/i18n/en.json +++ b/src/containers/Tenant/Query/QueryResult/i18n/en.json @@ -12,6 +12,7 @@ "title.truncated": "Truncated", "title.result": "Result", "text_plan-svg": "Execution plan", - "text_open-plan-svg": "Open execution plan in new window", + "text_open-new-tab": "Open in new tab", + "text_download": "Download", "text_error-plan-svg": "Error: {{error}}" } diff --git a/src/types/api/error.ts b/src/types/api/error.ts index 3b7086745d..ffc48c7f4b 100644 --- a/src/types/api/error.ts +++ b/src/types/api/error.ts @@ -18,7 +18,7 @@ export interface NetworkError { description?: unknown; fileName?: unknown; lineNumber?: unknown; - message?: 'Network Error'; + message: 'Network Error'; name?: string; number?: unknown; stack?: string; diff --git a/src/utils/errors/i18n/en.json b/src/utils/errors/i18n/en.json new file mode 100644 index 0000000000..d436ff784b --- /dev/null +++ b/src/utils/errors/i18n/en.json @@ -0,0 +1,3 @@ +{ + "unknown-error": "An unknown error occurred" +} diff --git a/src/utils/errors/i18n/index.ts b/src/utils/errors/i18n/index.ts new file mode 100644 index 0000000000..263b8057a8 --- /dev/null +++ b/src/utils/errors/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-errors'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/utils/errors/index.ts b/src/utils/errors/index.ts new file mode 100644 index 0000000000..ec584db6ad --- /dev/null +++ b/src/utils/errors/index.ts @@ -0,0 +1,39 @@ +import type {IResponseError} from '../../types/api/error'; +import {isNetworkError} from '../response'; + +import i18n from './i18n'; + +/** + * Prepares a consistent error message from various error types + * @param err - The error object to process + * @returns A formatted error message string + */ +export function prepareCommonErrorMessage(err: unknown): string { + // Handle string errors + if (typeof err === 'string') { + return err; + } + + // Handle null/undefined + if (!err) { + return i18n('unknown-error'); + } + + // Handle NetworkError + if (isNetworkError(err)) { + return err.message; + } + + if (typeof err === 'object' && 'data' in err) { + const responseError = err as IResponseError; + if (responseError.data?.message) { + return responseError.data.message; + } + } + + if (err instanceof Error) { + return err.message; + } + + return JSON.stringify(err); +} diff --git a/tests/suites/tenant/queryEditor/planToSvg.test.ts b/tests/suites/tenant/queryEditor/planToSvg.test.ts index e6752deeef..f5c4abf6ad 100644 --- a/tests/suites/tenant/queryEditor/planToSvg.test.ts +++ b/tests/suites/tenant/queryEditor/planToSvg.test.ts @@ -21,7 +21,7 @@ test.describe('Test Plan to SVG functionality', async () => { await tenantPage.goto(pageQueryParams); }); - test('Plan to SVG experiment shows execution plan in new tab', async ({page}) => { + test('Plan to SVG dropdown shows options and opens plan in new tab', async ({page}) => { const queryEditor = new QueryEditor(page); // 1. Turn on Plan to SVG experiment @@ -37,17 +37,111 @@ test.describe('Test Plan to SVG functionality', async () => { expect(status).toBe('Completed'); }).toPass(); - // 4. Check if Execution Plan button appears and click it + // 4. Check if Execution Plan button appears and click it to open dropdown const executionPlanButton = page.locator('button:has-text("Execution plan")'); await expect(executionPlanButton).toBeVisible(); await executionPlanButton.click(); + + // 5. Verify dropdown menu items are visible + const openInNewTabOption = page.locator('text="Open in new tab"'); + const downloadOption = page.locator('text="Download"'); + await expect(openInNewTabOption).toBeVisible(); + await expect(downloadOption).toBeVisible(); + + // 6. Click "Open in new tab" option + await openInNewTabOption.click(); await page.waitForTimeout(1000); // Wait for new tab to open - // 5. Verify we're taken to a new tab with SVG content + // 7. Verify we're taken to a new tab with SVG content const svgElement = page.locator('svg').first(); await expect(svgElement).toBeVisible(); }); + test('Plan to SVG download option triggers file download', async ({page}) => { + const queryEditor = new QueryEditor(page); + + // 1. Turn on Plan to SVG experiment + await toggleExperiment(page, 'on', 'Execution plan'); + + // 2. Set query and run it + await queryEditor.setQuery(testQuery); + await queryEditor.clickRunButton(); + + // 3. Wait for query execution to complete + await expect(async () => { + const status = await queryEditor.getExecutionStatus(); + expect(status).toBe('Completed'); + }).toPass(); + + // 4. Click execution plan button to open dropdown + const executionPlanButton = page.locator('button:has-text("Execution plan")'); + await executionPlanButton.click(); + + // 5. Setup download listener before clicking download + const downloadPromise = page.waitForEvent('download'); + + // 6. Click download option + const downloadOption = page.locator('text="Download"'); + await downloadOption.click(); + + // 7. Wait for download to start and verify filename + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('query-plan.svg'); + }); + + test('Plan to SVG handles API errors correctly', async ({page}) => { + const queryEditor = new QueryEditor(page); + + // 1. Turn on Plan to SVG experiment + await toggleExperiment(page, 'on', 'Execution plan'); + + // 2. Set query and run it + await queryEditor.setQuery(testQuery); + await queryEditor.clickRunButton(); + + // 3. Wait for query execution to complete + await expect(async () => { + const status = await queryEditor.getExecutionStatus(); + expect(status).toBe('Completed'); + }).toPass(); + + // 4. Mock the plan2svg API to return an error + await page.route('**/plan2svg**', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({message: 'Failed to generate SVG'}), + }); + }); + + // 5. Click execution plan button to open dropdown + const executionPlanButton = page.locator('button:has-text("Execution plan")'); + await executionPlanButton.click(); + + // 6. Click "Open in new tab" option and wait for error state + const openInNewTabOption = page.locator('text="Open in new tab"'); + await openInNewTabOption.click(); + await page.waitForTimeout(1000); // Wait for error to be processed + + // 7. Close the dropdown + await page.keyboard.press('Escape'); + + // 8. Verify error state + await expect(executionPlanButton).toHaveClass(/flat-danger/); + + // 9. Verify error tooltip + await executionPlanButton.hover(); + await page.waitForTimeout(500); // Wait for tooltip animation + const tooltipText = await page.textContent('.g-tooltip'); + expect(tooltipText).toContain('Error'); + expect(tooltipText).toContain('Failed to generate SVG'); + + // 10. Verify dropdown is disabled after error + await executionPlanButton.click(); + await expect(openInNewTabOption).not.toBeVisible(); + await expect(page.locator('text="Download"')).not.toBeVisible(); + }); + test('Statistics setting becomes disabled when execution plan experiment is enabled', async ({ page, }) => {