From a96c486a69a33a88ffe1490b4a218e9dd6a2e236 Mon Sep 17 00:00:00 2001 From: astandrik Date: Mon, 9 Dec 2024 14:26:33 +0300 Subject: [PATCH 1/3] fix: execution plan svg not saving in chrome --- .../PlanToSvgButton/PlanToSvgButton.tsx | 64 +++++++++++++++---- .../Tenant/Query/QueryResult/i18n/en.json | 3 +- .../tenant/queryEditor/planToSvg.test.ts | 47 +++++++++++++- 3 files changed, 96 insertions(+), 18 deletions(-) diff --git a/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx b/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx index 1c2c50a8b4..3eb4cd5615 100644 --- a/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx @@ -1,7 +1,8 @@ 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} from '@gravity-ui/uikit'; import {planToSvgApi} from '../../../../../../store/reducers/planToSvg'; import type {QueryPlan, ScriptPlan} from '../../../../../../types/api/query'; @@ -24,20 +25,42 @@ 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)); + throw err; }); - }, [database, getPlanToSvg, plan]); + }, [database, getPlanToSvg, plan, blobUrl]); + + const handleOpenInNewTab = React.useCallback(() => { + handleGetSvg().then((url) => { + window.open(url, '_blank'); + }); + }, [handleGetSvg]); + + const handleDownload = React.useCallback(() => { + handleGetSvg().then((url) => { + const link = document.createElement('a'); + link.href = url; + link.download = 'query-plan.svg'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }); + }, [handleGetSvg]); React.useEffect(() => { return () => { @@ -47,21 +70,34 @@ 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/tests/suites/tenant/queryEditor/planToSvg.test.ts b/tests/suites/tenant/queryEditor/planToSvg.test.ts index e6752deeef..e75d2ddb25 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,58 @@ 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('Statistics setting becomes disabled when execution plan experiment is enabled', async ({ page, }) => { From 7b6351fc756a3864dad74ad35bcd69654353935a Mon Sep 17 00:00:00 2001 From: astandrik Date: Mon, 9 Dec 2024 15:02:24 +0300 Subject: [PATCH 2/3] fix: proper error handling --- .../PlanToSvgButton/PlanToSvgButton.tsx | 51 ++++++++++-------- .../tenant/queryEditor/planToSvg.test.ts | 53 +++++++++++++++++++ 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx b/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx index 3eb4cd5615..4e79f5fdf2 100644 --- a/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {ArrowDownToLine, ArrowUpRightFromSquare, ChevronDown} from '@gravity-ui/icons'; import type {ButtonProps} from '@gravity-ui/uikit'; -import {Button, DropdownMenu} 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'; @@ -40,26 +40,33 @@ export function PlanToSvgButton({plan, database}: PlanToSvgButtonProps) { return url; }) .catch((err) => { - setError(JSON.stringify(err)); - throw err; + const errorMessage = err.data?.message || err.message || JSON.stringify(err); + setError(errorMessage); + return null; }); }, [database, getPlanToSvg, plan, blobUrl]); const handleOpenInNewTab = React.useCallback(() => { handleGetSvg().then((url) => { - window.open(url, '_blank'); + if (url) { + window.open(url, '_blank'); + } }); + return; }, [handleGetSvg]); const handleDownload = React.useCallback(() => { handleGetSvg().then((url) => { const link = document.createElement('a'); - link.href = url; - link.download = 'query-plan.svg'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + if (url) { + link.href = url; + link.download = 'query-plan.svg'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } }); + return; }, [handleGetSvg]); React.useEffect(() => { @@ -85,19 +92,21 @@ export function PlanToSvgButton({plan, database}: PlanToSvgButtonProps) { const renderSwitcher = (props: ButtonProps) => { return ( - + + + ); }; - return ; + return ; } diff --git a/tests/suites/tenant/queryEditor/planToSvg.test.ts b/tests/suites/tenant/queryEditor/planToSvg.test.ts index e75d2ddb25..f5c4abf6ad 100644 --- a/tests/suites/tenant/queryEditor/planToSvg.test.ts +++ b/tests/suites/tenant/queryEditor/planToSvg.test.ts @@ -89,6 +89,59 @@ test.describe('Test Plan to SVG functionality', async () => { 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, }) => { From d2dbd72a7e2f9676427d21659eea943476bf07ba Mon Sep 17 00:00:00 2001 From: astandrik Date: Wed, 11 Dec 2024 22:03:38 +0300 Subject: [PATCH 3/3] fix: review --- .../PlanToSvgButton/PlanToSvgButton.tsx | 4 +- src/types/api/error.ts | 2 +- src/utils/errors/i18n/en.json | 3 ++ src/utils/errors/i18n/index.ts | 7 ++++ src/utils/errors/index.ts | 39 +++++++++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 src/utils/errors/i18n/en.json create mode 100644 src/utils/errors/i18n/index.ts create mode 100644 src/utils/errors/index.ts diff --git a/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx b/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx index 4e79f5fdf2..a593266ee1 100644 --- a/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/PlanToSvgButton/PlanToSvgButton.tsx @@ -6,6 +6,7 @@ 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) { @@ -40,8 +41,7 @@ export function PlanToSvgButton({plan, database}: PlanToSvgButtonProps) { return url; }) .catch((err) => { - const errorMessage = err.data?.message || err.message || JSON.stringify(err); - setError(errorMessage); + setError(prepareCommonErrorMessage(err)); return null; }); }, [database, getPlanToSvg, plan, blobUrl]); 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); +}