diff --git a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx index 986dde6433..b6f69982dc 100644 --- a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx @@ -103,6 +103,7 @@ export default function QueryEditor(props: QueryEditorProps) { const [lastUsedQueryAction, setLastUsedQueryAction] = useSetting( LAST_USED_QUERY_ACTION_KEY, ); + const [lastExecutedQueryText, setLastExecutedQueryText] = React.useState(''); const [sendQuery] = queryApi.useUseSendQueryMutation(); @@ -140,6 +141,7 @@ export default function QueryEditor(props: QueryEditorProps) { const query = text ?? input; setLastUsedQueryAction(QUERY_ACTIONS.execute); + setLastExecutedQueryText(query); if (!isEqual(lastQueryExecutionSettings, querySettings)) { resetBanner(); setLastQueryExecutionSettings(querySettings); @@ -172,7 +174,7 @@ export default function QueryEditor(props: QueryEditorProps) { const handleGetExplainQueryClick = useEventHandler(() => { setLastUsedQueryAction(QUERY_ACTIONS.explain); - + setLastExecutedQueryText(input); if (!isEqual(lastQueryExecutionSettings, querySettings)) { resetBanner(); setLastQueryExecutionSettings(querySettings); @@ -368,6 +370,7 @@ export default function QueryEditor(props: QueryEditorProps) { tenantName={tenantName} path={path} showPreview={showPreview} + queryText={lastExecutedQueryText} /> @@ -386,6 +389,7 @@ interface ResultProps { tenantName: string; path: string; showPreview?: boolean; + queryText: string; } function Result({ resultVisibilityState, @@ -397,6 +401,7 @@ function Result({ tenantName, path, showPreview, + queryText, }: ResultProps) { if (showPreview) { return ; @@ -412,6 +417,7 @@ function Result({ isResultsCollapsed={resultVisibilityState.collapsed} onExpandResults={onExpandResultHandler} onCollapseResults={onCollapseResultHandler} + queryText={queryText} /> ); } diff --git a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx index 86ffb04a10..522c561bb7 100644 --- a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx +++ b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx @@ -27,7 +27,7 @@ import {isQueryCancelledError} from '../utils/isQueryCancelledError'; import {Ast} from './components/Ast/Ast'; import {Graph} from './components/Graph/Graph'; -import {PlanToSvgButton} from './components/PlanToSvgButton/PlanToSvgButton'; +import {QueryInfoDropdown} from './components/QueryInfoDropdown/QueryInfoDropdown'; import {QueryJSONViewer} from './components/QueryJSONViewer/QueryJSONViewer'; import {QueryResultError} from './components/QueryResultError/QueryResultError'; import {ResultSetsViewer} from './components/ResultSetsViewer/ResultSetsViewer'; @@ -83,6 +83,7 @@ interface ExecuteResultProps { tenantName: string; onCollapseResults: VoidFunction; onExpandResults: VoidFunction; + queryText?: string; } export function QueryResultViewer({ @@ -91,6 +92,7 @@ export function QueryResultViewer({ isResultsCollapsed, theme, tenantName, + queryText, onCollapseResults, onExpandResults, }: ExecuteResultProps) { @@ -180,6 +182,26 @@ export function QueryResultViewer({ ); }; + const renderQueryInfoDropdown = () => { + if (isLoading || isQueryCancelledError(error)) { + return null; + } + + return ( + + ); + }; + const renderStubMessage = () => { return ( ) : null} - {data?.plan && useShowPlanToSvg && isExecute ? ( - - ) : null} ); }; @@ -278,6 +297,7 @@ export function QueryResultViewer({ const renderRightControls = () => { return (
+ {renderQueryInfoDropdown()} {renderClipboardButton()} (null); - const [blobUrl, setBlobUrl] = React.useState(null); - const [getPlanToSvg, {isLoading}] = planToSvgApi.useLazyPlanToSvgQueryQuery(); - - 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); - return url; - }) - .catch((err) => { - setError(prepareCommonErrorMessage(err)); - return null; - }); - }, [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 () => { - if (blobUrl) { - URL.revokeObjectURL(blobUrl); - } - }; - }, [blobUrl]); - - 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/components/QueryInfoDropdown/QueryInfoDropdown.scss b/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/QueryInfoDropdown.scss new file mode 100644 index 0000000000..8633ee369b --- /dev/null +++ b/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/QueryInfoDropdown.scss @@ -0,0 +1,17 @@ +.query-info-dropdown { + &__menu-item { + align-items: start; + } + + &__menu-item-content { + display: flex; + flex-direction: column; + + padding: var(--g-spacing-1) 0; + } + + &__icon { + margin-top: var(--g-spacing-2); + margin-right: var(--g-spacing-2); + } +} diff --git a/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/QueryInfoDropdown.tsx b/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/QueryInfoDropdown.tsx new file mode 100644 index 0000000000..4172633a1a --- /dev/null +++ b/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/QueryInfoDropdown.tsx @@ -0,0 +1,60 @@ +import {Ellipsis} from '@gravity-ui/icons'; +import type {ButtonProps} from '@gravity-ui/uikit'; +import {ActionTooltip, Button, DropdownMenu} from '@gravity-ui/uikit'; + +import i18n from '../../i18n'; + +import {b} from './shared'; +import type {QueryResultsInfo} from './useQueryInfoMenuItems'; +import {useQueryInfoMenuItems} from './useQueryInfoMenuItems'; + +import './QueryInfoDropdown.scss'; + +export interface QueryInfoDropdownProps { + queryResultsInfo: QueryResultsInfo; + database: string; + hasPlanToSvg: boolean; + error?: unknown; +} + +export function QueryInfoDropdown({ + queryResultsInfo, + database, + hasPlanToSvg, + error, +}: QueryInfoDropdownProps) { + const {isLoading, items} = useQueryInfoMenuItems({ + queryResultsInfo, + database, + hasPlanToSvg, + error, + }); + + const renderSwitcher = (props: ButtonProps) => { + return ( + + + + ); + }; + + if (!items.length) { + return null; + } + + return ( + + ); +} diff --git a/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/shared.ts b/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/shared.ts new file mode 100644 index 0000000000..01411f723a --- /dev/null +++ b/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/shared.ts @@ -0,0 +1,2 @@ +import {cn} from '../../../../../../utils/cn'; +export const b = cn('query-info-dropdown'); diff --git a/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/useQueryInfoMenuItems.tsx b/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/useQueryInfoMenuItems.tsx new file mode 100644 index 0000000000..858c5ae8f0 --- /dev/null +++ b/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/useQueryInfoMenuItems.tsx @@ -0,0 +1,179 @@ +import React from 'react'; + +import {ArrowDownToLine, ArrowUpRightFromSquare} from '@gravity-ui/icons'; +import type {DropdownMenuItem} from '@gravity-ui/uikit'; +import {Text} from '@gravity-ui/uikit'; + +import {planToSvgApi} from '../../../../../../store/reducers/planToSvg'; +import type {QueryPlan, ScriptPlan, TKqpStatsQuery} from '../../../../../../types/api/query'; +import createToast from '../../../../../../utils/createToast'; +import {prepareCommonErrorMessage} from '../../../../../../utils/errors'; +import {parseQueryError} from '../../../../../../utils/query'; +import i18n from '../../i18n'; + +import {b} from './shared'; +import {downloadFile} from './utils'; + +export interface MenuItemContentProps { + title: string; + description: string; +} + +export function MenuItemContent({title, description}: MenuItemContentProps) { + return ( +
+ {title} + + {description} + +
+ ); +} + +export interface QueryResultsInfo { + ast?: string; + stats?: TKqpStatsQuery; + queryText?: string; + plan?: QueryPlan | ScriptPlan; +} + +export interface DiagnosticsData extends QueryResultsInfo { + database: string; + error?: ReturnType; +} + +export interface UseQueryInfoMenuItemsProps { + queryResultsInfo: QueryResultsInfo; + database: string; + hasPlanToSvg: boolean; + error?: unknown; +} + +export function useQueryInfoMenuItems({ + queryResultsInfo, + database, + hasPlanToSvg, + error, +}: UseQueryInfoMenuItemsProps) { + const [blobUrl, setBlobUrl] = React.useState(null); + const [getPlanToSvg, {isLoading}] = planToSvgApi.useLazyPlanToSvgQueryQuery(); + + React.useEffect(() => { + return () => { + if (blobUrl) { + URL.revokeObjectURL(blobUrl); + } + }; + }, [blobUrl]); + + const items = React.useMemo(() => { + const menuItems: DropdownMenuItem[][] = []; + + const plan = queryResultsInfo.plan; + if (plan && hasPlanToSvg) { + const handleGetSvg = () => { + 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); + return url; + }) + .catch((err) => { + const errorMessage = prepareCommonErrorMessage(err); + createToast({ + title: i18n('text_error-plan-svg', {error: errorMessage}), + name: 'plan-svg-error', + type: 'error', + }); + return null; + }); + }; + + const handleOpenInNewTab = () => { + handleGetSvg().then((url) => { + if (url) { + window.open(url, '_blank'); + } + }); + }; + + const handleDownload = () => { + handleGetSvg().then((url) => { + if (url) { + downloadFile(url, 'query-plan.svg'); + } + }); + }; + + menuItems.push([ + { + text: ( + + ), + icon: , + action: handleOpenInNewTab, + className: b('menu-item'), + }, + { + text: ( + + ), + icon: , + action: handleDownload, + className: b('menu-item'), + }, + ]); + } + + if (queryResultsInfo) { + const handleDiagnosticsDownload = () => { + const parsedError = error ? parseQueryError(error) : undefined; + const diagnosticsData: DiagnosticsData = { + ...queryResultsInfo, + database, + ...(parsedError && {error: parsedError}), + }; + + const blob = new Blob([JSON.stringify(diagnosticsData, null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + downloadFile(url, `query-diagnostics-${new Date().getTime()}.json`); + URL.revokeObjectURL(url); + }; + + menuItems.push([ + { + text: ( + + ), + icon: , + action: handleDiagnosticsDownload, + className: b('menu-item'), + }, + ]); + } + + return menuItems; + }, [queryResultsInfo, hasPlanToSvg, blobUrl, getPlanToSvg, database, error]); + + return { + isLoading, + items, + }; +} diff --git a/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/utils.ts b/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/utils.ts new file mode 100644 index 0000000000..0eb2f5c08a --- /dev/null +++ b/src/containers/Tenant/Query/QueryResult/components/QueryInfoDropdown/utils.ts @@ -0,0 +1,8 @@ +export function downloadFile(url: string, filename: string) { + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} diff --git a/src/containers/Tenant/Query/QueryResult/i18n/en.json b/src/containers/Tenant/Query/QueryResult/i18n/en.json index c9ae630222..b57bc9afda 100644 --- a/src/containers/Tenant/Query/QueryResult/i18n/en.json +++ b/src/containers/Tenant/Query/QueryResult/i18n/en.json @@ -11,8 +11,12 @@ "trace": "Trace", "title.truncated": "Truncated", "title.result": "Result", - "text_plan-svg": "Execution plan", - "text_open-new-tab": "Open in new tab", - "text_download": "Download", + "tooltip_actions": "Actions", + "text_open-execution-plan": "Open Execution Plan", + "text_open-execution-plan_description": "New tab", + "text_download": "Download Execution Plan", + "text_download_description": "SVG", + "text_diagnostics": "Download Diagnostics", + "text_diagnostics_description": "JSON", "text_error-plan-svg": "Error: {{error}}" } diff --git a/src/utils/errors/index.ts b/src/utils/errors/index.ts index ec584db6ad..e63fa7b8d6 100644 --- a/src/utils/errors/index.ts +++ b/src/utils/errors/index.ts @@ -28,6 +28,8 @@ export function prepareCommonErrorMessage(err: unknown): string { const responseError = err as IResponseError; if (responseError.data?.message) { return responseError.data.message; + } else if (typeof responseError.data === 'string') { + return responseError.data; } } diff --git a/tests/suites/tenant/queryEditor/planToSvg.test.ts b/tests/suites/tenant/queryEditor/planToSvg.test.ts index f5c4abf6ad..cca33d4254 100644 --- a/tests/suites/tenant/queryEditor/planToSvg.test.ts +++ b/tests/suites/tenant/queryEditor/planToSvg.test.ts @@ -37,18 +37,18 @@ test.describe('Test Plan to SVG functionality', async () => { expect(status).toBe('Completed'); }).toPass(); - // 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(); + // 4. Check if dropdown button appears and click it to open menu + const dropdownButton = page.locator('.query-info-dropdown__query-info-switcher-wrapper'); + await expect(dropdownButton).toBeVisible(); + await dropdownButton.click(); // 5. Verify dropdown menu items are visible - const openInNewTabOption = page.locator('text="Open in new tab"'); - const downloadOption = page.locator('text="Download"'); + const openInNewTabOption = page.locator('text="Open Execution Plan"'); + const downloadPlanOption = page.locator('text="Download Execution Plan"'); await expect(openInNewTabOption).toBeVisible(); - await expect(downloadOption).toBeVisible(); + await expect(downloadPlanOption).toBeVisible(); - // 6. Click "Open in new tab" option + // 6. Click "Open Execution Plan" option await openInNewTabOption.click(); await page.waitForTimeout(1000); // Wait for new tab to open @@ -73,16 +73,16 @@ test.describe('Test Plan to SVG functionality', async () => { 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(); + // 4. Click dropdown button to open menu + const dropdownButton = page.locator('.query-info-dropdown__query-info-switcher-wrapper'); + await dropdownButton.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(); + // 6. Click download execution plan option + const downloadPlanOption = page.locator('text="Download Execution Plan"'); + await downloadPlanOption.click(); // 7. Wait for download to start and verify filename const download = await downloadPromise; @@ -110,36 +110,34 @@ test.describe('Test Plan to SVG functionality', async () => { route.fulfill({ status: 500, contentType: 'application/json', - body: JSON.stringify({message: 'Failed to generate SVG'}), + body: 'Failed to generate SVG', }); }); - // 5. Click execution plan button to open dropdown - const executionPlanButton = page.locator('button:has-text("Execution plan")'); - await executionPlanButton.click(); + // 5. Click dropdown button to open menu + const dropdownButton = page.locator('.query-info-dropdown__query-info-switcher-wrapper'); + await dropdownButton.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(); + // 6. Click "Open Execution Plan" option and wait for error state + const openExecutionPlanOption = page.locator('text="Open Execution Plan"'); + await openExecutionPlanOption.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/); + // 8. Verify error toast appears + const errorToast = page.locator('.g-toast.g-toast_theme_danger'); + await expect(errorToast).toBeVisible(); - // 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'); + // 9. Verify error message in toast + const toastTitle = errorToast.locator('.g-toast__title'); + await expect(toastTitle).toContainText('Error'); - // 10. Verify dropdown is disabled after error - await executionPlanButton.click(); - await expect(openInNewTabOption).not.toBeVisible(); - await expect(page.locator('text="Download"')).not.toBeVisible(); + // 10. Verify dropdown is still enabled and functional + await dropdownButton.click(); + await expect(openExecutionPlanOption).toBeVisible(); + await expect(page.locator('text="Download Execution Plan"')).toBeVisible(); }); test('Statistics setting becomes disabled when execution plan experiment is enabled', async ({