Skip to content

fix: execution plan svg not saving in chrome #1744

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -24,20 +26,48 @@ export function PlanToSvgButton({plan, database}: PlanToSvgButtonProps) {
const [blobUrl, setBlobUrl] = React.useState<string | null>(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 () => {
Expand All @@ -47,21 +77,36 @@ export function PlanToSvgButton({plan, database}: PlanToSvgButtonProps) {
};
}, [blobUrl]);

return (
<Tooltip
content={error ? i18n('text_error-plan-svg', {error}) : i18n('text_open-plan-svg')}
>
<Button
view={getButtonView(error, isLoading)}
loading={isLoading}
onClick={handleClick}
disabled={isLoading}
>
{i18n('text_plan-svg')}
<Button.Icon>
<ArrowUpRightFromSquare />
</Button.Icon>
</Button>
</Tooltip>
);
const items = [
{
text: i18n('text_open-new-tab'),
icon: <ArrowUpRightFromSquare />,
action: handleOpenInNewTab,
},
{
text: i18n('text_download'),
icon: <ArrowDownToLine />,
action: handleDownload,
},
];

const renderSwitcher = (props: ButtonProps) => {
return (
<Tooltip content={error ? i18n('text_error-plan-svg', {error}) : i18n('text_plan-svg')}>
<Button
view={getButtonView(error, isLoading)}
loading={isLoading}
disabled={isLoading}
{...props}
>
{i18n('text_plan-svg')}
<Button.Icon>
<ChevronDown />
</Button.Icon>
</Button>
</Tooltip>
);
};

return <DropdownMenu renderSwitcher={renderSwitcher} items={items} disabled={Boolean(error)} />;
}
3 changes: 2 additions & 1 deletion src/containers/Tenant/Query/QueryResult/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
}
2 changes: 1 addition & 1 deletion src/types/api/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/utils/errors/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"unknown-error": "An unknown error occurred"
}
7 changes: 7 additions & 0 deletions src/utils/errors/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {registerKeysets} from '../../i18n';

import en from './en.json';

const COMPONENT = 'ydb-errors';

export default registerKeysets(COMPONENT, {en});
39 changes: 39 additions & 0 deletions src/utils/errors/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
100 changes: 97 additions & 3 deletions tests/suites/tenant/queryEditor/planToSvg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}) => {
Expand Down
Loading