Skip to content

Commit 189174a

Browse files
authored
fix: execution plan svg not saving in chrome (#1744)
1 parent b3d5897 commit 189174a

File tree

7 files changed

+218
-29
lines changed

7 files changed

+218
-29
lines changed
Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import React from 'react';
22

3-
import {ArrowUpRightFromSquare} from '@gravity-ui/icons';
4-
import {Button, Tooltip} from '@gravity-ui/uikit';
3+
import {ArrowDownToLine, ArrowUpRightFromSquare, ChevronDown} from '@gravity-ui/icons';
4+
import type {ButtonProps} from '@gravity-ui/uikit';
5+
import {Button, DropdownMenu, Tooltip} from '@gravity-ui/uikit';
56

67
import {planToSvgApi} from '../../../../../../store/reducers/planToSvg';
78
import type {QueryPlan, ScriptPlan} from '../../../../../../types/api/query';
9+
import {prepareCommonErrorMessage} from '../../../../../../utils/errors';
810
import i18n from '../../i18n';
911

1012
function getButtonView(error: string | null, isLoading: boolean) {
@@ -24,20 +26,48 @@ export function PlanToSvgButton({plan, database}: PlanToSvgButtonProps) {
2426
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
2527
const [getPlanToSvg, {isLoading}] = planToSvgApi.useLazyPlanToSvgQueryQuery();
2628

27-
const handleClick = React.useCallback(() => {
28-
getPlanToSvg({plan, database})
29+
const handleGetSvg = React.useCallback(() => {
30+
if (blobUrl) {
31+
return Promise.resolve(blobUrl);
32+
}
33+
34+
return getPlanToSvg({plan, database})
2935
.unwrap()
3036
.then((result) => {
3137
const blob = new Blob([result], {type: 'image/svg+xml'});
3238
const url = URL.createObjectURL(blob);
3339
setBlobUrl(url);
3440
setError(null);
35-
window.open(url, '_blank');
41+
return url;
3642
})
3743
.catch((err) => {
38-
setError(JSON.stringify(err));
44+
setError(prepareCommonErrorMessage(err));
45+
return null;
3946
});
40-
}, [database, getPlanToSvg, plan]);
47+
}, [database, getPlanToSvg, plan, blobUrl]);
48+
49+
const handleOpenInNewTab = React.useCallback(() => {
50+
handleGetSvg().then((url) => {
51+
if (url) {
52+
window.open(url, '_blank');
53+
}
54+
});
55+
return;
56+
}, [handleGetSvg]);
57+
58+
const handleDownload = React.useCallback(() => {
59+
handleGetSvg().then((url) => {
60+
const link = document.createElement('a');
61+
if (url) {
62+
link.href = url;
63+
link.download = 'query-plan.svg';
64+
document.body.appendChild(link);
65+
link.click();
66+
document.body.removeChild(link);
67+
}
68+
});
69+
return;
70+
}, [handleGetSvg]);
4171

4272
React.useEffect(() => {
4373
return () => {
@@ -47,21 +77,36 @@ export function PlanToSvgButton({plan, database}: PlanToSvgButtonProps) {
4777
};
4878
}, [blobUrl]);
4979

50-
return (
51-
<Tooltip
52-
content={error ? i18n('text_error-plan-svg', {error}) : i18n('text_open-plan-svg')}
53-
>
54-
<Button
55-
view={getButtonView(error, isLoading)}
56-
loading={isLoading}
57-
onClick={handleClick}
58-
disabled={isLoading}
59-
>
60-
{i18n('text_plan-svg')}
61-
<Button.Icon>
62-
<ArrowUpRightFromSquare />
63-
</Button.Icon>
64-
</Button>
65-
</Tooltip>
66-
);
80+
const items = [
81+
{
82+
text: i18n('text_open-new-tab'),
83+
icon: <ArrowUpRightFromSquare />,
84+
action: handleOpenInNewTab,
85+
},
86+
{
87+
text: i18n('text_download'),
88+
icon: <ArrowDownToLine />,
89+
action: handleDownload,
90+
},
91+
];
92+
93+
const renderSwitcher = (props: ButtonProps) => {
94+
return (
95+
<Tooltip content={error ? i18n('text_error-plan-svg', {error}) : i18n('text_plan-svg')}>
96+
<Button
97+
view={getButtonView(error, isLoading)}
98+
loading={isLoading}
99+
disabled={isLoading}
100+
{...props}
101+
>
102+
{i18n('text_plan-svg')}
103+
<Button.Icon>
104+
<ChevronDown />
105+
</Button.Icon>
106+
</Button>
107+
</Tooltip>
108+
);
109+
};
110+
111+
return <DropdownMenu renderSwitcher={renderSwitcher} items={items} disabled={Boolean(error)} />;
67112
}

src/containers/Tenant/Query/QueryResult/i18n/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"title.truncated": "Truncated",
1313
"title.result": "Result",
1414
"text_plan-svg": "Execution plan",
15-
"text_open-plan-svg": "Open execution plan in new window",
15+
"text_open-new-tab": "Open in new tab",
16+
"text_download": "Download",
1617
"text_error-plan-svg": "Error: {{error}}"
1718
}

src/types/api/error.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface NetworkError {
1818
description?: unknown;
1919
fileName?: unknown;
2020
lineNumber?: unknown;
21-
message?: 'Network Error';
21+
message: 'Network Error';
2222
name?: string;
2323
number?: unknown;
2424
stack?: string;

src/utils/errors/i18n/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"unknown-error": "An unknown error occurred"
3+
}

src/utils/errors/i18n/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {registerKeysets} from '../../i18n';
2+
3+
import en from './en.json';
4+
5+
const COMPONENT = 'ydb-errors';
6+
7+
export default registerKeysets(COMPONENT, {en});

src/utils/errors/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type {IResponseError} from '../../types/api/error';
2+
import {isNetworkError} from '../response';
3+
4+
import i18n from './i18n';
5+
6+
/**
7+
* Prepares a consistent error message from various error types
8+
* @param err - The error object to process
9+
* @returns A formatted error message string
10+
*/
11+
export function prepareCommonErrorMessage(err: unknown): string {
12+
// Handle string errors
13+
if (typeof err === 'string') {
14+
return err;
15+
}
16+
17+
// Handle null/undefined
18+
if (!err) {
19+
return i18n('unknown-error');
20+
}
21+
22+
// Handle NetworkError
23+
if (isNetworkError(err)) {
24+
return err.message;
25+
}
26+
27+
if (typeof err === 'object' && 'data' in err) {
28+
const responseError = err as IResponseError;
29+
if (responseError.data?.message) {
30+
return responseError.data.message;
31+
}
32+
}
33+
34+
if (err instanceof Error) {
35+
return err.message;
36+
}
37+
38+
return JSON.stringify(err);
39+
}

tests/suites/tenant/queryEditor/planToSvg.test.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ test.describe('Test Plan to SVG functionality', async () => {
2121
await tenantPage.goto(pageQueryParams);
2222
});
2323

24-
test('Plan to SVG experiment shows execution plan in new tab', async ({page}) => {
24+
test('Plan to SVG dropdown shows options and opens plan in new tab', async ({page}) => {
2525
const queryEditor = new QueryEditor(page);
2626

2727
// 1. Turn on Plan to SVG experiment
@@ -37,17 +37,111 @@ test.describe('Test Plan to SVG functionality', async () => {
3737
expect(status).toBe('Completed');
3838
}).toPass();
3939

40-
// 4. Check if Execution Plan button appears and click it
40+
// 4. Check if Execution Plan button appears and click it to open dropdown
4141
const executionPlanButton = page.locator('button:has-text("Execution plan")');
4242
await expect(executionPlanButton).toBeVisible();
4343
await executionPlanButton.click();
44+
45+
// 5. Verify dropdown menu items are visible
46+
const openInNewTabOption = page.locator('text="Open in new tab"');
47+
const downloadOption = page.locator('text="Download"');
48+
await expect(openInNewTabOption).toBeVisible();
49+
await expect(downloadOption).toBeVisible();
50+
51+
// 6. Click "Open in new tab" option
52+
await openInNewTabOption.click();
4453
await page.waitForTimeout(1000); // Wait for new tab to open
4554

46-
// 5. Verify we're taken to a new tab with SVG content
55+
// 7. Verify we're taken to a new tab with SVG content
4756
const svgElement = page.locator('svg').first();
4857
await expect(svgElement).toBeVisible();
4958
});
5059

60+
test('Plan to SVG download option triggers file download', async ({page}) => {
61+
const queryEditor = new QueryEditor(page);
62+
63+
// 1. Turn on Plan to SVG experiment
64+
await toggleExperiment(page, 'on', 'Execution plan');
65+
66+
// 2. Set query and run it
67+
await queryEditor.setQuery(testQuery);
68+
await queryEditor.clickRunButton();
69+
70+
// 3. Wait for query execution to complete
71+
await expect(async () => {
72+
const status = await queryEditor.getExecutionStatus();
73+
expect(status).toBe('Completed');
74+
}).toPass();
75+
76+
// 4. Click execution plan button to open dropdown
77+
const executionPlanButton = page.locator('button:has-text("Execution plan")');
78+
await executionPlanButton.click();
79+
80+
// 5. Setup download listener before clicking download
81+
const downloadPromise = page.waitForEvent('download');
82+
83+
// 6. Click download option
84+
const downloadOption = page.locator('text="Download"');
85+
await downloadOption.click();
86+
87+
// 7. Wait for download to start and verify filename
88+
const download = await downloadPromise;
89+
expect(download.suggestedFilename()).toBe('query-plan.svg');
90+
});
91+
92+
test('Plan to SVG handles API errors correctly', async ({page}) => {
93+
const queryEditor = new QueryEditor(page);
94+
95+
// 1. Turn on Plan to SVG experiment
96+
await toggleExperiment(page, 'on', 'Execution plan');
97+
98+
// 2. Set query and run it
99+
await queryEditor.setQuery(testQuery);
100+
await queryEditor.clickRunButton();
101+
102+
// 3. Wait for query execution to complete
103+
await expect(async () => {
104+
const status = await queryEditor.getExecutionStatus();
105+
expect(status).toBe('Completed');
106+
}).toPass();
107+
108+
// 4. Mock the plan2svg API to return an error
109+
await page.route('**/plan2svg**', (route) => {
110+
route.fulfill({
111+
status: 500,
112+
contentType: 'application/json',
113+
body: JSON.stringify({message: 'Failed to generate SVG'}),
114+
});
115+
});
116+
117+
// 5. Click execution plan button to open dropdown
118+
const executionPlanButton = page.locator('button:has-text("Execution plan")');
119+
await executionPlanButton.click();
120+
121+
// 6. Click "Open in new tab" option and wait for error state
122+
const openInNewTabOption = page.locator('text="Open in new tab"');
123+
await openInNewTabOption.click();
124+
await page.waitForTimeout(1000); // Wait for error to be processed
125+
126+
// 7. Close the dropdown
127+
await page.keyboard.press('Escape');
128+
129+
// 8. Verify error state
130+
await expect(executionPlanButton).toHaveClass(/flat-danger/);
131+
132+
// 9. Verify error tooltip
133+
await executionPlanButton.hover();
134+
await page.waitForTimeout(500); // Wait for tooltip animation
135+
const tooltipText = await page.textContent('.g-tooltip');
136+
expect(tooltipText).toContain('Error');
137+
expect(tooltipText).toContain('Failed to generate SVG');
138+
139+
// 10. Verify dropdown is disabled after error
140+
await executionPlanButton.click();
141+
await expect(openInNewTabOption).not.toBeVisible();
142+
await expect(page.locator('text="Download"')).not.toBeVisible();
143+
});
144+
51145
test('Statistics setting becomes disabled when execution plan experiment is enabled', async ({
52146
page,
53147
}) => {

0 commit comments

Comments
 (0)