diff --git a/README.md b/README.md index 6644ef0..d1ee5ab 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Prefer widgets for product screens. Use modules and components when you need a c ### Widget imports -All six widgets support both root and individual imports: +Widgets support both root and individual imports: ```tsx import {SavedQueries} from '@gravity-ui/querieskit'; @@ -73,7 +73,7 @@ import type {SavedQueriesProps} from '@gravity-ui/querieskit/widgets/SavedQuerie ``` Individual entrypoints are available for `QueriesHistory`, `SavedQueries`, -`TutorialsHistory`, `QueriesNavigation`, `QueryResults`, and `DashboardCharts`. +`TutorialsHistory`, `QueriesNavigation`, `QueryExecutionPanel`, and `DashboardCharts`. They expose each widget's existing public exports, including its props and helpers. Shared data types remain available from the package root. @@ -85,6 +85,16 @@ Some bundlers, including esbuild, retain CSS from unused root re-exports even wh their JavaScript is removed. Individual widget imports avoid introducing those unrelated styles. +### QueryExecutionPanel + +A query execution panel with ordered, typed tabs, custom content, automatic tab +selection until manual navigation, message severity titles, and application-owned +expand/close actions. See [the API and integration guide](src/widgets/QueryExecutionPanel/README.md) +and the QueryExecutionPanel Storybook examples. + +`QueryResults` is a module, available from the package root or through +`@gravity-ui/querieskit/modules/QueryResults`. + ### QueriesHistory Ready-made query history sidebar: search, filters, editable titles, row actions, and optional compare mode. diff --git a/package.json b/package.json index 4c91d6e..021aa90 100644 --- a/package.json +++ b/package.json @@ -66,16 +66,6 @@ "default": "./build/cjs/widgets/QueriesNavigation/index.js" } }, - "./widgets/QueryResults": { - "import": { - "types": "./build/esm/widgets/QueryResults/index.d.ts", - "default": "./build/esm/widgets/QueryResults/index.js" - }, - "require": { - "types": "./build/cjs/widgets/QueryResults/index.d.ts", - "default": "./build/cjs/widgets/QueryResults/index.js" - } - }, "./widgets/DashboardCharts": { "import": { "types": "./build/esm/widgets/DashboardCharts/index.d.ts", @@ -85,6 +75,26 @@ "types": "./build/cjs/widgets/DashboardCharts/index.d.ts", "default": "./build/cjs/widgets/DashboardCharts/index.js" } + }, + "./modules/QueryResults": { + "import": { + "types": "./build/esm/modules/QueryResults/index.d.ts", + "default": "./build/esm/modules/QueryResults/index.js" + }, + "require": { + "types": "./build/cjs/modules/QueryResults/index.d.ts", + "default": "./build/cjs/modules/QueryResults/index.js" + } + }, + "./widgets/QueryExecutionPanel": { + "import": { + "types": "./build/esm/widgets/QueryExecutionPanel/index.d.ts", + "default": "./build/esm/widgets/QueryExecutionPanel/index.js" + }, + "require": { + "types": "./build/cjs/widgets/QueryExecutionPanel/index.d.ts", + "default": "./build/cjs/widgets/QueryExecutionPanel/index.js" + } } }, "files": [ diff --git a/src/index.ts b/src/index.ts index 04f4771..1132470 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,3 +12,4 @@ export * from './types/queryStatistics'; export * from './types/queryResults'; export * from './types/queryGraph'; export * from './types/queryTimeline'; +export * from './types/queryExecutionPanel'; diff --git a/src/modules/QueryProgress/QueryProgress.tsx b/src/modules/QueryProgress/QueryProgress.tsx index df94d0a..7688e22 100644 --- a/src/modules/QueryProgress/QueryProgress.tsx +++ b/src/modules/QueryProgress/QueryProgress.tsx @@ -47,6 +47,7 @@ const statusIcons = { } satisfies Record; export function QueryProgress({ + active = true, graphProps, timelineProps, view: controlledView, @@ -112,7 +113,10 @@ export function QueryProgress({ hidden={view !== 'graph'} > }> - + )} @@ -125,7 +129,9 @@ export function QueryProgress({ diff --git a/src/widgets/QueryResults/QueryResults.scss b/src/modules/QueryResults/QueryResults.scss similarity index 100% rename from src/widgets/QueryResults/QueryResults.scss rename to src/modules/QueryResults/QueryResults.scss diff --git a/src/widgets/QueryResults/QueryResults.stories.tsx b/src/modules/QueryResults/QueryResults.stories.tsx similarity index 99% rename from src/widgets/QueryResults/QueryResults.stories.tsx rename to src/modules/QueryResults/QueryResults.stories.tsx index e9f3451..b57de51 100644 --- a/src/widgets/QueryResults/QueryResults.stories.tsx +++ b/src/modules/QueryResults/QueryResults.stories.tsx @@ -72,7 +72,7 @@ const rows: Row[] = [ ]; const meta: Meta = { - title: 'Widgets/QueryResults', + title: 'Modules/QueryResults', component: QueryResults, tags: ['autodocs'], parameters: {layout: 'padded'}, diff --git a/src/widgets/QueryResults/QueryResults.tsx b/src/modules/QueryResults/QueryResults.tsx similarity index 77% rename from src/widgets/QueryResults/QueryResults.tsx rename to src/modules/QueryResults/QueryResults.tsx index c22e826..6c2d46b 100644 --- a/src/widgets/QueryResults/QueryResults.tsx +++ b/src/modules/QueryResults/QueryResults.tsx @@ -2,12 +2,7 @@ import React, {useCallback, useState} from 'react'; import {Flex, SegmentedRadioGroup, Text} from '@gravity-ui/uikit'; import cn from 'bem-cn-lite'; import {QueryResultsTable} from '../../components/QueryResultsTable'; -import type { - QueryResultColumn, - QueryResultFormatterSettings, - QueryResultsSchemaRenderContext, - QueryResultsView, -} from '../../types/queryResults'; +import type {QueryResultsProps, QueryResultsView} from '../../types/queryResults'; import {QueryResultsSchema} from './internal/QueryResultsSchema'; import i18n from './i18n'; @@ -15,25 +10,7 @@ import './QueryResults.scss'; const block = cn('qp-query-results'); -export type QueryResultsProps> = { - columns: Array>; - /** Values use the YQL wire representation consumed by @gravity-ui/unipika. */ - rows: TRow[]; - totalRows?: number; - loading?: boolean; - errorContent?: React.ReactNode; - rowKey?: (row: TRow, index: number) => string | number; - formatterSettings?: QueryResultFormatterSettings; - maxVisibleLines?: number; - title?: React.ReactNode; - toolbarContent?: React.ReactNode; - actions?: React.ReactNode; - view?: QueryResultsView; - defaultView?: QueryResultsView; - onViewChange?: (view: QueryResultsView) => void; - renderSchema?: (context: QueryResultsSchemaRenderContext) => React.ReactNode; - className?: string; -}; +export type {QueryResultsProps} from '../../types/queryResults'; export function QueryResults>({ columns, diff --git a/src/widgets/QueryResults/i18n/dicts.ts b/src/modules/QueryResults/i18n/dicts.ts similarity index 100% rename from src/widgets/QueryResults/i18n/dicts.ts rename to src/modules/QueryResults/i18n/dicts.ts diff --git a/src/widgets/QueryResults/i18n/en.json b/src/modules/QueryResults/i18n/en.json similarity index 100% rename from src/widgets/QueryResults/i18n/en.json rename to src/modules/QueryResults/i18n/en.json diff --git a/src/widgets/QueryResults/i18n/index.ts b/src/modules/QueryResults/i18n/index.ts similarity index 100% rename from src/widgets/QueryResults/i18n/index.ts rename to src/modules/QueryResults/i18n/index.ts diff --git a/src/widgets/QueryResults/i18n/ru.json b/src/modules/QueryResults/i18n/ru.json similarity index 100% rename from src/widgets/QueryResults/i18n/ru.json rename to src/modules/QueryResults/i18n/ru.json diff --git a/src/widgets/QueryResults/index.ts b/src/modules/QueryResults/index.ts similarity index 100% rename from src/widgets/QueryResults/index.ts rename to src/modules/QueryResults/index.ts diff --git a/src/widgets/QueryResults/internal/QueryResultsSchema.tsx b/src/modules/QueryResults/internal/QueryResultsSchema.tsx similarity index 100% rename from src/widgets/QueryResults/internal/QueryResultsSchema.tsx rename to src/modules/QueryResults/internal/QueryResultsSchema.tsx diff --git a/src/modules/QueryStatistics/internal/QueryStatisticsTable.tsx b/src/modules/QueryStatistics/internal/QueryStatisticsTable.tsx index 8e4a2c1..71f8fd2 100644 --- a/src/modules/QueryStatistics/internal/QueryStatisticsTable.tsx +++ b/src/modules/QueryStatistics/internal/QueryStatisticsTable.tsx @@ -1,6 +1,6 @@ import React, {useMemo} from 'react'; import BaseDataTable, {type Column} from '@gravity-ui/react-data-table'; -import {DataTable} from '../../../components'; +import {DataTable} from '../../../components/DataTable'; import type { QueryStatisticsColumn, QueryStatisticsExtraColumn, diff --git a/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.tsx b/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.tsx index 2f9c8d1..9e6eee5 100644 --- a/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.tsx +++ b/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.tsx @@ -3,7 +3,7 @@ import {ChevronsDown, ChevronsUp} from '@gravity-ui/icons'; import {Button, Icon} from '@gravity-ui/uikit'; import cn from 'bem-cn-lite'; -import {SearchWithButtons} from '../../../components'; +import {SearchWithButtons} from '../../../components/SearchWithButtons'; import i18n from '../i18n'; import './QueryStatisticsToolbar.scss'; diff --git a/src/modules/index.ts b/src/modules/index.ts index 276060d..f9296cf 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -51,3 +51,5 @@ export type {NavigationMetaProps, NavigationMetaViewConfig} from './NavigationMe export {NavigationView, buildViewColumns} from './NavigationView'; export type {NavigationViewProps, NavigationViewViewConfig} from './NavigationView'; export {QueryTimeline} from './QueryTimeline'; +export {QueryResults} from './QueryResults'; +export type {QueryResultsProps} from './QueryResults'; diff --git a/src/types/queryExecutionPanel.ts b/src/types/queryExecutionPanel.ts new file mode 100644 index 0000000..61e88ea --- /dev/null +++ b/src/types/queryExecutionPanel.ts @@ -0,0 +1,59 @@ +import type {ReactNode} from 'react'; +import type {NavigationMetaProps} from '../modules/NavigationMeta'; +import type {ErrorTreeProps} from './errorTree'; +import type {NavigationMetaItem} from './navigation'; +import type {QueryProgressProps} from './queryGraph'; +import type {QueryResultsProps} from './queryResults'; +import type {QueryStatisticsProps} from './queryStatistics'; + +export type QueryExecutionTabBase = { + /** Unique, non-empty and stable within one execution. */ + id: string; + disabled?: boolean; +}; + +export type QueryExecutionTabRenderContext = {active: boolean}; + +export type QueryExecutionTab< + TRow extends Record = Record, + TMetaItem extends NavigationMetaItem = NavigationMetaItem, +> = QueryExecutionTabBase & + ( + | {type: 'result'; title?: ReactNode; props: QueryResultsProps} + | {type: 'progress'; title?: ReactNode; props: QueryProgressProps} + | {type: 'info'; props?: ErrorTreeProps} + | {type: 'statistics'; title?: ReactNode; props: QueryStatisticsProps} + | {type: 'meta'; title?: ReactNode; props: NavigationMetaProps} + | { + type: 'charts'; + title?: ReactNode; + renderContent: (context: QueryExecutionTabRenderContext) => ReactNode; + } + | { + type: 'custom'; + title: ReactNode; + renderContent: (context: QueryExecutionTabRenderContext) => ReactNode; + } + ); + +export type QueryExecutionPanelProps< + TRow extends Record = Record, + TMetaItem extends NavigationMetaItem = NavigationMetaItem, +> = { + /** Array order is display order. Keep each tab's id and type stable. */ + tabs: QueryExecutionTab[]; + /** Do not switch between controlled and uncontrolled modes after mounting. */ + activeTab?: string; + defaultActiveTab?: string; + /** Followed until the first manual selection in uncontrolled mode. */ + preferredActiveTab?: string; + /** Called on changes after initialization; never called with an empty selection. */ + onActiveTabChange?: (id: string) => void; + /** Values are formatted by the application, including its timezone. */ + execution?: {startedAt?: ReactNode; author?: ReactNode}; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; + onClose?: () => void; + emptyContent?: ReactNode; + className?: string; +}; diff --git a/src/types/queryGraph.ts b/src/types/queryGraph.ts index b3be35f..d4fc79c 100644 --- a/src/types/queryGraph.ts +++ b/src/types/queryGraph.ts @@ -82,6 +82,8 @@ export type QueryGraphProps = { export type QueryProgressView = 'graph' | 'timeline'; export type QueryProgressProps = { + /** Pauses both views while preserving their state. */ + active?: boolean; graphProps: QueryGraphProps; timelineProps?: QueryTimelineProps; view?: QueryProgressView; diff --git a/src/types/queryResults.ts b/src/types/queryResults.ts index 37f970d..8c94548 100644 --- a/src/types/queryResults.ts +++ b/src/types/queryResults.ts @@ -46,3 +46,23 @@ export type QueryResultsView = 'result' | 'schema'; export type QueryResultsSchemaRenderContext> = { columns: Array>; }; + +export type QueryResultsProps> = { + columns: Array>; + /** Values use the YQL wire representation consumed by @gravity-ui/unipika. */ + rows: TRow[]; + totalRows?: number; + loading?: boolean; + errorContent?: ReactNode; + rowKey?: (row: TRow, index: number) => string | number; + formatterSettings?: QueryResultFormatterSettings; + maxVisibleLines?: number; + title?: ReactNode; + toolbarContent?: ReactNode; + actions?: ReactNode; + view?: QueryResultsView; + defaultView?: QueryResultsView; + onViewChange?: (view: QueryResultsView) => void; + renderSchema?: (context: QueryResultsSchemaRenderContext) => ReactNode; + className?: string; +}; diff --git a/src/widgets/QueryExecutionPanel/QueryExecutionPanel.scss b/src/widgets/QueryExecutionPanel/QueryExecutionPanel.scss new file mode 100644 index 0000000..549d2f6 --- /dev/null +++ b/src/widgets/QueryExecutionPanel/QueryExecutionPanel.scss @@ -0,0 +1,41 @@ +.qp-query-execution-panel { + min-width: 0; + min-height: 0; + height: 100%; + + &__header { + flex-shrink: 0; + border-bottom: 1px solid var(--g-color-line-generic); + } + + &__tabs { + flex: 1 1 20rem; + flex-wrap: nowrap; + overflow-x: auto; + min-width: 0; + --g-tabs-border-width: 0; + } + + &__details { + margin-left: auto; + min-width: 0; + overflow-wrap: anywhere; + } + + &__actions { + flex-shrink: 0; + } + + &__content { + flex: 1; + min-height: 0; + min-width: 0; + overflow: auto; + padding: var(--g-spacing-3); + } + + &__panel { + min-width: 0; + height: 100%; + } +} diff --git a/src/widgets/QueryExecutionPanel/QueryExecutionPanel.tsx b/src/widgets/QueryExecutionPanel/QueryExecutionPanel.tsx new file mode 100644 index 0000000..e72325e --- /dev/null +++ b/src/widgets/QueryExecutionPanel/QueryExecutionPanel.tsx @@ -0,0 +1,149 @@ +import React, {useEffect, useMemo} from 'react'; +import {ChevronsCollapseUpRight, ChevronsExpandUpRight, Xmark} from '@gravity-ui/icons'; +import {Button, Flex, Icon, Tab, TabList, TabPanel, TabProvider, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import type {NavigationMetaItem} from '../../types/navigation'; +import type {QueryExecutionPanelProps, QueryExecutionTab} from '../../types/queryExecutionPanel'; +import {getMessagesSeverity} from './helpers/getMessagesSeverity'; +import {useActiveTab} from './helpers/useActiveTab'; +import {QueryExecutionTabContent} from './internal/QueryExecutionTabContent'; +import i18n from './i18n'; +import './QueryExecutionPanel.scss'; + +const block = cn('qp-query-execution-panel'); + +export function QueryExecutionPanel< + TRow extends Record = Record, + TMetaItem extends NavigationMetaItem = NavigationMetaItem, +>({ + tabs: inputTabs, + activeTab, + defaultActiveTab, + preferredActiveTab, + onActiveTabChange, + execution, + expanded = false, + onExpandedChange, + onClose, + emptyContent, + className, +}: QueryExecutionPanelProps) { + const {tabs, invalidIds} = useMemo(() => { + const seen = new Set(); + const invalid: string[] = []; + const valid = inputTabs.filter((tab) => { + if (!tab.id.trim() || seen.has(tab.id)) { + invalid.push(tab.id); + return false; + } + seen.add(tab.id); + return true; + }); + return {tabs: valid, invalidIds: invalid}; + }, [inputTabs]); + + useEffect(() => { + if (process.env.NODE_ENV !== 'production' && invalidIds.length) { + console.warn('QueryExecutionPanel: skipped empty or duplicate tab IDs', invalidIds); + } + }, [invalidIds]); + + const {selected, select} = useActiveTab({ + tabs, + activeTab, + defaultActiveTab, + preferredActiveTab, + onActiveTabChange, + }); + const expandLabel = i18n(expanded ? 'action_collapse' : 'action_expand'); + + const getTitle = (tab: QueryExecutionTab) => { + if (tab.type === 'info') return i18n(`title_${getMessagesSeverity(tab.props?.root)}`); + if (tab.type === 'custom') return tab.title; + return tab.title ?? i18n(`title_${tab.type}`); + }; + + return ( + + + + + {tabs.map((tab) => ( + { + if (event.key === 'Enter' || event.key === ' ') { + // Avoid a second selection from the button's native click. + event.preventDefault(); + select(tab.id); + } + }} + > + {getTitle(tab)} + + ))} + + + {execution?.startedAt !== undefined && execution.startedAt !== null && ( + {execution.startedAt} + )} + {execution?.author !== undefined && execution.author !== null && ( + + {i18n('context_by')} {execution.author} + + )} + + {onExpandedChange && ( + + )} + {onClose && ( + + )} + + + +
+ {tabs.map((tab) => ( + + ))} + {selected === undefined && + (emptyContent ?? {i18n('context_no-tabs')})} +
+
+
+ ); +} diff --git a/src/widgets/QueryExecutionPanel/README.md b/src/widgets/QueryExecutionPanel/README.md new file mode 100644 index 0000000..7c2109c --- /dev/null +++ b/src/widgets/QueryExecutionPanel/README.md @@ -0,0 +1,135 @@ +# QueryExecutionPanel + +A panel for one query execution. Requires the application's Gravity UI styles and +`ThemeProvider`. Data loading, backend normalization and panel placement belong to +the application. + +```tsx +import {QueryExecutionPanel} from '@gravity-ui/querieskit'; +import type {QueryExecutionTab} from '@gravity-ui/querieskit'; + +const tabs: QueryExecutionTab<{name: string}>[] = [ + { + id: 'result/0', + type: 'result', + title: 'Result 1', + props: { + columns: [{name: 'name', type: ['DataType', 'Utf8']}], + rows: [{name: 'Example'}], + }, + }, + { + id: 'messages', + type: 'info', + props: {root: {id: 'root', severity: 'info', message: 'Completed'}}, + }, + { + id: 'notes', + type: 'custom', + title: 'Notes', + renderContent: ({active}) => , + }, +]; + +}} + expanded={expanded} + onExpandedChange={setExpanded} + onClose={closePanel} +/>; +``` + +## Tabs + +Array order is display order. Any number of tabs can share a type, but every tab +must have a unique, non-empty ID. IDs are not reserved. Invalid IDs are skipped, +with a development warning; the first duplicate wins. Remove a configuration to +hide a tab, or set `disabled` to retain its header without allowing selection. + +| Type | Configuration | Default title | +| ------------ | --------------------------------------- | ----------------------------- | +| `result` | `props: QueryResultsProps` | Result | +| `progress` | `props: QueryProgressProps` | Progress | +| `info` | `props?: ErrorTreeProps` | Computed from the entire tree | +| `statistics` | `props: QueryStatisticsProps` | Statistics | +| `meta` | `props: NavigationMetaProps` | Meta | +| `charts` | `renderContent({active})` | Charts | +| `custom` | `title`, `renderContent({active})` | Required | + +Standard types other than Info accept an optional `title: ReactNode`. Info uses +Error > Warning > Info, including collapsed descendants. Without props, it shows +an empty message state. Its ID never changes when its title changes. + +Charts is a content slot. Pass `` from the application; +the panel does not import the dashboard. Custom content receives `active` so it +can pause subscriptions, animations or polling while hidden. Keep these components +mounted when inactive if their local state must survive. + +Panels mount their content on first activation and keep it mounted afterward. +Disabling a visited tab preserves its content; removal unmounts it. IDs and types +should stay stable. Changing a type resets that tab's content. Changing the +panel's React `key` to a new execution ID resets all content and selection state. + +## Selection + +Without `activeTab`, the panel chooses an available `defaultActiveTab`, then +`preferredActiveTab`, then the first enabled tab. The default is used only at +initialization. Subsequent changes to the available preferred ID are followed +until the first manual selection, including selecting the current tab again. + +If the selected tab disappears or becomes disabled, the panel selects the +available preferred tab or the first enabled tab. This fallback does not restore +automatic following after manual selection. An unavailable preferred ID does not +replace a valid current selection. + +`onActiveTabChange` reports actual changes after initialization, including automatic +transitions. No callback is emitted for an empty selection. With no enabled tabs, +`emptyContent` is rendered, or the localized default empty state. + +With `activeTab`, the parent owns selection. User selection requests are reported +through `onActiveTabChange`; the panel waits for new props. `defaultActiveTab` and +`preferredActiveTab` are ignored. If the external ID is unavailable, the first +enabled tab is displayed temporarily without a callback. Do not switch between +controlled and uncontrolled modes during the lifetime of an instance. + +## Header and layout + +`execution.startedAt` and `execution.author` are optional React nodes. Format dates +and timezones in the application. The panel localizes its own labels in English +and Russian. + +Providing `onClose` displays the close button. Providing `onExpandedChange` displays +the expand/collapse button, which requests `!expanded`. Both actions are delegated +to the application. Change CSS/layout on an existing parent to expand the panel; +do not conditionally move it between different React subtrees. The ApplicationLayout +story demonstrates this without losing tab state. Use `className` for external +positioning and give the container a height for graph/timeline layouts. + +## Query Tracker integration + +The application constructs `tabs` and `preferredActiveTab` separately. See +[the example adapter](./story/queryTrackerAdapter.tsx) and the QueryTrackerLifecycle +story. Its input is already normalized; no YTsaurus package is required. + +For the Query Tracker policy, build the array in this order: + +1. Error (`type: 'info'`) when FAILED. +2. One Result per result set when COMPLETED. +3. Progress if a valid single-progress plan has nodes or edges. +4. One Charts tab per available chart configuration when COMPLETED with results. +5. Statistics when COMPLETED with statistics from valid single progress. +6. Meta whenever a query exists. + +With no query, pass an empty array. Normalize `isSingleProgress`, result count, +plan/statistics fields and chart feature availability before calling the example +adapter. Prefer the first available ID among error, first result, progress and +meta. The widget has no knowledge of these backend fields or statuses. + +## Individual imports + +Import QueryResults from `@gravity-ui/querieskit/modules/QueryResults` or the package +root. Import QueryExecutionPanel directly from +`@gravity-ui/querieskit/widgets/QueryExecutionPanel` to avoid unrelated entrypoints. diff --git a/src/widgets/QueryExecutionPanel/helpers/getMessagesSeverity.ts b/src/widgets/QueryExecutionPanel/helpers/getMessagesSeverity.ts new file mode 100644 index 0000000..e044a11 --- /dev/null +++ b/src/widgets/QueryExecutionPanel/helpers/getMessagesSeverity.ts @@ -0,0 +1,16 @@ +import type {ErrorTreeItem, ErrorTreeSeverity} from '../../../types/errorTree'; + +export function getMessagesSeverity(root?: ErrorTreeItem): ErrorTreeSeverity { + let severity: ErrorTreeSeverity = 'info'; + const pending = root ? [root] : []; + while (pending.length) { + const item = pending.pop(); + if (!item) continue; + if (item.severity === 'error') return 'error'; + if (item.severity === 'warning') severity = 'warning'; + if (item.children) { + for (const child of item.children) pending.push(child); + } + } + return severity; +} diff --git a/src/widgets/QueryExecutionPanel/helpers/useActiveTab.ts b/src/widgets/QueryExecutionPanel/helpers/useActiveTab.ts new file mode 100644 index 0000000..6ec86f7 --- /dev/null +++ b/src/widgets/QueryExecutionPanel/helpers/useActiveTab.ts @@ -0,0 +1,61 @@ +import {useEffect, useRef, useState} from 'react'; +import type {QueryExecutionTabBase} from '../../../types/queryExecutionPanel'; + +export function useActiveTab({ + tabs, + activeTab, + defaultActiveTab, + preferredActiveTab, + onActiveTabChange, +}: { + tabs: QueryExecutionTabBase[]; + activeTab?: string; + defaultActiveTab?: string; + preferredActiveTab?: string; + onActiveTabChange?: (id: string) => void; +}) { + const available = (id?: string) => tabs.some((tab) => tab.id === id && !tab.disabled); + const first = tabs.find((tab) => !tab.disabled)?.id; + const preferred = available(preferredActiveTab) ? preferredActiveTab : undefined; + const controlled = activeTab !== undefined; + const [selection, setSelection] = useState(() => ({ + id: available(defaultActiveTab) ? defaultActiveTab : (preferred ?? first), + manual: false, + preferred, + })); + + // Reconcile during rendering so removed/disabled panels are never briefly active. + let next = selection; + if (!controlled) { + let id = selection.id; + if (!selection.manual && preferred !== selection.preferred && preferred !== undefined) { + id = preferred; + } else if (!available(id)) { + id = preferred ?? first; + } + if (id !== selection.id || preferred !== selection.preferred) { + next = {...selection, id, preferred}; + setSelection(next); + } + } + + let selected = next.id; + if (controlled) selected = available(activeTab) ? activeTab : first; + const notified = useRef(selected); + useEffect(() => { + if (notified.current === selected) return; + notified.current = selected; + if (!controlled && selected !== undefined) onActiveTabChange?.(selected); + }, [controlled, selected, onActiveTabChange]); + + const select = (id: string) => { + if (!available(id)) return; + if (controlled) { + if (id !== selected) onActiveTabChange?.(id); + } else { + // Selecting the already-active tab also locks out automatic navigation. + setSelection((current) => ({...current, id, manual: true})); + } + }; + return {selected, select}; +} diff --git a/src/widgets/QueryExecutionPanel/i18n/dicts.ts b/src/widgets/QueryExecutionPanel/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/widgets/QueryExecutionPanel/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/widgets/QueryExecutionPanel/i18n/en.json b/src/widgets/QueryExecutionPanel/i18n/en.json new file mode 100644 index 0000000..ec51e8b --- /dev/null +++ b/src/widgets/QueryExecutionPanel/i18n/en.json @@ -0,0 +1,17 @@ +{ + "title_tabs": "Query execution", + "title_result": "Result", + "title_progress": "Progress", + "title_info": "Info", + "title_warning": "Warning", + "title_error": "Error", + "title_statistics": "Statistics", + "title_meta": "Meta", + "title_charts": "Charts", + "context_by": "by", + "context_no-messages": "No messages", + "context_no-tabs": "No available tabs", + "action_expand": "Expand panel", + "action_collapse": "Collapse panel", + "action_close": "Close panel" +} diff --git a/src/widgets/QueryExecutionPanel/i18n/index.ts b/src/widgets/QueryExecutionPanel/i18n/index.ts new file mode 100644 index 0000000..0b0204c --- /dev/null +++ b/src/widgets/QueryExecutionPanel/i18n/index.ts @@ -0,0 +1,4 @@ +import {addI18Keysets} from '../../../i18n'; +import dicts from './dicts'; + +export default addI18Keysets('qp:query-execution-panel', dicts); diff --git a/src/widgets/QueryExecutionPanel/i18n/ru.json b/src/widgets/QueryExecutionPanel/i18n/ru.json new file mode 100644 index 0000000..6f74a27 --- /dev/null +++ b/src/widgets/QueryExecutionPanel/i18n/ru.json @@ -0,0 +1,17 @@ +{ + "title_tabs": "Выполнение запроса", + "title_result": "Результат", + "title_progress": "Прогресс", + "title_info": "Информация", + "title_warning": "Предупреждение", + "title_error": "Ошибка", + "title_statistics": "Статистика", + "title_meta": "Метаданные", + "title_charts": "Графики", + "context_by": "Автор:", + "context_no-messages": "Нет сообщений", + "context_no-tabs": "Нет доступных вкладок", + "action_expand": "Развернуть панель", + "action_collapse": "Свернуть панель", + "action_close": "Закрыть панель" +} diff --git a/src/widgets/QueryExecutionPanel/index.ts b/src/widgets/QueryExecutionPanel/index.ts new file mode 100644 index 0000000..78e88f3 --- /dev/null +++ b/src/widgets/QueryExecutionPanel/index.ts @@ -0,0 +1,7 @@ +export {QueryExecutionPanel} from './QueryExecutionPanel'; +export type { + QueryExecutionPanelProps, + QueryExecutionTab, + QueryExecutionTabBase, + QueryExecutionTabRenderContext, +} from '../../types/queryExecutionPanel'; diff --git a/src/widgets/QueryExecutionPanel/internal/QueryExecutionTabContent.tsx b/src/widgets/QueryExecutionPanel/internal/QueryExecutionTabContent.tsx new file mode 100644 index 0000000..ba2be98 --- /dev/null +++ b/src/widgets/QueryExecutionPanel/internal/QueryExecutionTabContent.tsx @@ -0,0 +1,39 @@ +import React, {useState} from 'react'; +import {Text} from '@gravity-ui/uikit'; +import {ErrorTree} from '../../../components/ErrorTree'; +import {NavigationMeta} from '../../../modules/NavigationMeta'; +import {QueryProgress} from '../../../modules/QueryProgress'; +import {QueryResults} from '../../../modules/QueryResults'; +import {QueryStatistics} from '../../../modules/QueryStatistics'; +import type {NavigationMetaItem} from '../../../types/navigation'; +import type {QueryExecutionTab} from '../../../types/queryExecutionPanel'; +import i18n from '../i18n'; + +export function QueryExecutionTabContent< + TRow extends Record, + TMetaItem extends NavigationMetaItem, +>({tab, active}: {tab: QueryExecutionTab; active: boolean}) { + const [visited, setVisited] = useState(active); + if (active && !visited) setVisited(true); + if (!active && !visited) return null; + + switch (tab.type) { + case 'result': + return ; + case 'progress': + return ; + case 'statistics': + return ; + case 'meta': + return ; + case 'info': + return tab.props ? ( + + ) : ( + {i18n('context_no-messages')} + ); + case 'charts': + case 'custom': + return tab.renderContent({active}); + } +} diff --git a/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.scss b/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.scss new file mode 100644 index 0000000..7c2fb1e --- /dev/null +++ b/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.scss @@ -0,0 +1,17 @@ +.qp-query-execution-panel-story { + height: 640px; + min-width: 0; + + &__layout { + height: 100%; + background: var(--g-color-base-background); + + &_expanded { + position: fixed; + inset: 0; + z-index: 10; + padding: var(--g-spacing-4); + box-sizing: border-box; + } + } +} diff --git a/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.tsx b/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.tsx new file mode 100644 index 0000000..32bfd41 --- /dev/null +++ b/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.tsx @@ -0,0 +1,202 @@ +import React, {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Button, Flex, Text, TextInput} from '@gravity-ui/uikit'; +import {QueryExecutionPanel} from '../QueryExecutionPanel'; +import type {QueryExecutionTab} from '../../../types/queryExecutionPanel'; +import type {ErrorTreeSeverity} from '../../../types/errorTree'; +import {DashboardCharts} from '../../DashboardCharts'; +import {lineSeriesMap} from '../../DashboardCharts/story/mockData'; +import {demoEdges, demoNodes} from '../../../components/QueryGraph/QueryGraph.stories'; +import {createQueryTrackerPanel} from './queryTrackerAdapter'; +import type {ExampleQuery} from './queryTrackerAdapter'; +import './QueryExecutionPanel.stories.scss'; + +const results = { + columns: [{name: 'name', type: ['DataType', 'Utf8'] as const}], + rows: [{name: 'Query result'}], +}; +const progress = {graphProps: {nodes: demoNodes, edges: demoEdges}}; +const metaProps = {data: {groups: [{items: [{name: 'Engine', value: 'YQL'}]}]}}; +function Notes({active}: {active: boolean}) { + const [value, setValue] = useState(''); + return ( + + + Active: {String(active)} + + ); +} +const allTabs: QueryExecutionTab[] = [ + {id: 'result/0', type: 'result', title: 'Result 1', props: results}, + { + id: 'result/1', + type: 'result', + title: 'Result 2', + props: {...results, rows: [{name: 'Another result'}]}, + }, + {id: 'progress', type: 'progress', props: progress}, + { + id: 'info', + type: 'info', + props: {root: {id: 'root', severity: 'info', message: 'Query completed'}}, + }, + { + id: 'statistics', + type: 'statistics', + props: {data: [{id: 'rows', name: 'Rows', values: {count: 2}}]}, + }, + {id: 'meta', type: 'meta', props: metaProps}, + { + id: 'charts', + type: 'charts', + renderContent: () => , + }, + { + id: 'notes', + type: 'custom', + title: 'Notes', + renderContent: ({active}) => , + }, +]; +const meta: Meta = { + title: 'Widgets/QueryExecutionPanel', + component: QueryExecutionPanel, + tags: ['autodocs'], + args: {tabs: allTabs, execution: {startedAt: '28 Sep 2026, 12:07:27', author: 'admin'}}, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const Progress: Story = {args: {defaultActiveTab: 'progress'}}; +export const Charts: Story = {args: {defaultActiveTab: 'charts'}}; +export const Narrow: Story = { + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export const Empty: Story = {args: {tabs: []}}; +export const EmptyInfo: Story = {args: {tabs: [{id: 'info', type: 'info'}]}}; +export const Loading: Story = { + args: { + tabs: [ + {id: 'result', type: 'result', props: {...results, rows: [], loading: true}}, + {...allTabs[2], disabled: true}, + ], + }, +}; +export const CustomOrder: Story = {args: {tabs: [allTabs[7], allTabs[1], allTabs[0], allTabs[5]]}}; + +export const DynamicMessages: Story = { + render: function DynamicMessagesExample() { + const [severity, setSeverity] = useState('info'); + return ( + + + {(['info', 'warning', 'error'] as const).map((level) => ( + + ))} + + + + ); + }, +}; +export const Controlled: Story = { + render: function ControlledExample(args) { + const [activeTab, setActiveTab] = useState('result/0'); + return ( + + Selected: {activeTab} + + + ); + }, +}; +export const QueryTrackerLifecycle: Story = { + render: function QueryTrackerLifecycleExample() { + const [status, setStatus] = useState('RUNNING'); + const [executionId, setExecutionId] = useState(1); + const query: ExampleQuery = { + id: String(executionId), + status, + results: [results, results], + progress, + meta: metaProps, + error: {root: {id: 'error', severity: 'error', message: 'Execution failed'}}, + statistics: {data: [{id: 'rows', name: 'Rows', values: {count: 2}}]}, + charts: [{dataSource: {line: lineSeriesMap}}], + }; + return ( + + + Open Meta manually, then complete the query: your selection is preserved. + + + + + + + + + ); + }, +}; +export const ApplicationLayout: Story = { + render: function ApplicationLayoutExample(args) { + const [expanded, setExpanded] = useState(false); + const [closed, setClosed] = useState(false); + return closed ? ( + + ) : ( +
+ setClosed(true)} + /> +
+ ); + }, +}; diff --git a/src/widgets/QueryExecutionPanel/story/queryTrackerAdapter.tsx b/src/widgets/QueryExecutionPanel/story/queryTrackerAdapter.tsx new file mode 100644 index 0000000..ce936ac --- /dev/null +++ b/src/widgets/QueryExecutionPanel/story/queryTrackerAdapter.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import {DashboardCharts} from '../../DashboardCharts'; +import type {DashboardChartsProps} from '../../DashboardCharts/types'; +import type {ErrorTreeProps} from '../../../types/errorTree'; +import type {QueryExecutionTab} from '../../../types/queryExecutionPanel'; +import type {QueryProgressProps} from '../../../types/queryGraph'; +import type {QueryResultsProps} from '../../../types/queryResults'; +import type {QueryStatisticsProps} from '../../../types/queryStatistics'; +import type {NavigationMetaProps} from '../../../modules/NavigationMeta'; + +// Application example only. Backend responses are normalized before reaching this adapter. +export type ExampleQuery = { + id: string; + status: 'RUNNING' | 'COMPLETED' | 'FAILED'; + error?: ErrorTreeProps; + results: QueryResultsProps>[]; + progress?: QueryProgressProps; + statistics?: QueryStatisticsProps; + charts?: DashboardChartsProps[]; + meta: NavigationMetaProps; +}; + +export function createQueryTrackerPanel(query?: ExampleQuery): { + tabs: QueryExecutionTab[]; + preferredActiveTab?: string; +} { + if (!query) return {tabs: []}; + const tabs: QueryExecutionTab[] = []; + if (query.status === 'FAILED') tabs.push({id: 'error', type: 'info', props: query.error}); + if (query.status === 'COMPLETED') { + query.results.forEach((props, index) => + tabs.push({ + id: `result/${index}`, + type: 'result', + title: `Result ${index + 1}`, + props, + }), + ); + } + const plan = query.progress?.graphProps; + if (query.progress && plan && (plan.nodes.length || plan.edges.length)) { + tabs.push({id: 'progress', type: 'progress', props: query.progress}); + } + if (query.status === 'COMPLETED') { + if (query.results.length) { + query.charts?.forEach((props, index) => + tabs.push({ + id: `chart/${index}`, + type: 'charts', + title: `Charts ${index + 1}`, + renderContent: () => , + }), + ); + } + if (query.statistics) + tabs.push({id: 'statistics', type: 'statistics', props: query.statistics}); + } + tabs.push({id: 'meta', type: 'meta', props: query.meta}); + return { + tabs, + preferredActiveTab: ['error', 'result/0', 'progress', 'meta'].find((id) => + tabs.some((tab) => tab.id === id), + ), + }; +} diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 7479bf2..1cfaf06 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -5,8 +5,6 @@ export type {SavedQueriesProps} from './SavedQueries'; export {DashboardCharts} from './DashboardCharts'; export {TutorialsHistory} from './TutorialsHistory'; export type {TutorialsHistoryProps} from './TutorialsHistory'; -export {QueryResults} from './QueryResults'; -export type {QueryResultsProps} from './QueryResults'; export { QueriesNavigation, createTableDetailConfig, @@ -18,3 +16,5 @@ export type { NavigationPreviewResolver, NavigationSchemaResolver, } from './QueriesNavigation'; +export {QueryExecutionPanel} from './QueryExecutionPanel'; +export type {QueryExecutionPanelProps} from './QueryExecutionPanel'; diff --git a/tests/components/QueryExecutionPanel.test.tsx b/tests/components/QueryExecutionPanel.test.tsx new file mode 100644 index 0000000..4c7cdc7 --- /dev/null +++ b/tests/components/QueryExecutionPanel.test.tsx @@ -0,0 +1,315 @@ +// @vitest-environment jsdom +import React, {act, useState} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {configure, ThemeProvider} from '@gravity-ui/uikit'; +import {QueryExecutionPanel} from '../../src/widgets/QueryExecutionPanel'; +import type { + QueryExecutionPanelProps, + QueryExecutionTab, +} from '../../src/types/queryExecutionPanel'; +import {getMessagesSeverity} from '../../src/widgets/QueryExecutionPanel/helpers/getMessagesSeverity'; + +vi.mock('../../src/modules/QueryResults', () => ({ + QueryResults: ({rows}: {rows: unknown[]}) =>
Rows: {rows.length}
, +})); +vi.mock('../../src/modules/QueryProgress', () => ({ + QueryProgress: ({active}: {active: boolean}) =>
, +})); +vi.mock('../../src/modules/QueryStatistics', () => ({ + QueryStatistics: () =>
Statistics content
, +})); +vi.mock('../../src/modules/NavigationMeta', () => ({ + NavigationMeta: () =>
Meta content
, +})); +vi.mock('../../src/components/ErrorTree', () => ({ErrorTree: () => })); + +function Counter() { + const [count, setCount] = useState(0); + return ; +} +const custom = (id: string): QueryExecutionTab => ({ + id, + type: 'custom', + title: id, + renderContent: ({active}) => ( +
+ +
+ ), +}); + +describe('QueryExecutionPanel', () => { + let container: HTMLDivElement; + let root: Root; + const changes = vi.fn(); + function render(props: Partial = {}, key = 'execution') { + act(() => + root.render( + + + + + , + ), + ); + } + function tab(title: string) { + const item = Array.from(container.querySelectorAll('[role="tab"]')).find( + (node) => node.textContent === title, + ); + expect(item, `tab ${title}`).toBeDefined(); + return item!; + } + function click(title: string) { + act(() => tab(title).click()); + } + function selected() { + return container.querySelector('[role="tab"][aria-selected="true"]')?.textContent; + } + function panel() { + return container.querySelector('[role="tabpanel"]:not([hidden])')!; + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + configure({lang: 'en'}); + changes.mockClear(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it('honors the initial default, follows preferred changes, and locks after manual selection', () => { + render({defaultActiveTab: 'a', preferredActiveTab: 'b'}); + expect(selected()).toBe('a'); + expect(changes).not.toHaveBeenCalled(); + render({defaultActiveTab: 'a', preferredActiveTab: 'b'}); + expect(selected()).toBe('a'); + render({preferredActiveTab: 'c'}); + expect(selected()).toBe('c'); + expect(changes.mock.calls).toEqual([['c']]); + click('b'); + render({preferredActiveTab: 'a'}); + expect(selected()).toBe('b'); + expect(changes.mock.calls).toEqual([['c'], ['b']]); + }); + it('locks even when the active tab is selected with the keyboard', () => { + render({preferredActiveTab: 'a'}); + act(() => + tab('a').dispatchEvent( + new KeyboardEvent('keydown', {key: 'Enter', code: 'Enter', bubbles: true}), + ), + ); + render({preferredActiveTab: 'b'}); + expect(selected()).toBe('a'); + expect(changes).not.toHaveBeenCalled(); + }); + it('uses accessible tab and panel associations and keyboard navigation', () => { + render(); + const first = tab('a'); + expect(first.getAttribute('aria-controls')).toBe(panel().id); + expect(panel().getAttribute('aria-labelledby')).toBe(first.id); + act(() => { + first.focus(); + first.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + code: 'ArrowRight', + bubbles: true, + }), + ); + }); + expect(document.activeElement).toBe(tab('b')); + act(() => + tab('b').dispatchEvent( + new KeyboardEvent('keydown', {key: 'Enter', code: 'Enter', bubbles: true}), + ), + ); + expect(selected()).toBe('b'); + expect(container.querySelectorAll('[role="tabpanel"][hidden]')).toHaveLength(2); + }); + it('follows a preferred tab when it becomes available, but retains a valid selection when it disappears', () => { + render({tabs: [custom('a')], preferredActiveTab: 'b'}); + expect(selected()).toBe('a'); + render({preferredActiveTab: 'b'}); + expect(selected()).toBe('b'); + render({preferredActiveTab: 'missing'}); + expect(selected()).toBe('b'); + }); + it('falls back after removal/disable without restoring automatic navigation', () => { + render(); + click('b'); + render({ + tabs: [custom('a'), {...custom('b'), disabled: true}, custom('c')], + preferredActiveTab: 'c', + }); + expect(selected()).toBe('c'); + render({preferredActiveTab: 'a'}); + expect(selected()).toBe('c'); + render({tabs: [custom('a')], preferredActiveTab: 'missing'}); + expect(selected()).toBe('a'); + expect(changes.mock.calls).toEqual([['b'], ['c'], ['a']]); + }); + it('handles empty/all-disabled tabs and ignores invalid IDs', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render({tabs: [custom(''), custom('a'), custom('a'), {...custom('b'), disabled: true}]}); + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(2); + expect(warn).toHaveBeenCalled(); + click('b'); + expect(selected()).toBe('a'); + render({tabs: [{...custom('a'), disabled: true}], emptyContent:
Unavailable
}); + expect(selected()).toBeUndefined(); + expect(container.textContent).toContain('Unavailable'); + render({tabs: []}); + expect(container.textContent).toContain('No available tabs'); + expect(changes).not.toHaveBeenCalled(); + render({tabs: [custom('b')]}); + expect(changes.mock.calls).toEqual([['b']]); + }); + it('leaves controlled selection to its parent and falls back without callbacks', () => { + render({activeTab: 'b', preferredActiveTab: 'a'}); + click('c'); + expect(selected()).toBe('b'); + expect(changes.mock.calls).toEqual([['c']]); + render({activeTab: 'c'}); + expect(selected()).toBe('c'); + render({activeTab: 'missing', preferredActiveTab: 'c'}); + expect(selected()).toBe('a'); + expect(changes.mock.calls).toEqual([['c']]); + }); + it('requests controlled keyboard selection once and cancels the native button click', () => { + render({activeTab: 'a'}); + const event = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + cancelable: true, + }); + act(() => tab('b').dispatchEvent(event)); + expect(event.defaultPrevented).toBe(true); + expect(changes.mock.calls).toEqual([['b']]); + expect(selected()).toBe('a'); + }); + + it('mounts lazily and keeps state through reorder, rename and disabling', () => { + render(); + expect(container.querySelectorAll('[data-active]')).toHaveLength(1); + act(() => panel().querySelector('button')!.click()); + click('b'); + expect(container.querySelectorAll('[data-active]')).toHaveLength(2); + expect(container.querySelector('[hidden] [data-active="false"]')).not.toBeNull(); + render({tabs: [custom('b'), {...custom('a'), title: 'Renamed', disabled: true}]}); + render({tabs: [custom('b'), {...custom('a'), title: 'Renamed'}]}); + click('Renamed'); + expect(panel().textContent).toContain('Count 1'); + render({tabs: [custom('b')]}); + render({tabs: [custom('a'), custom('b')]}); + click('a'); + expect(panel().textContent).toContain('Count 0'); + }); + it('remounts changed types and resets on a new execution', () => { + render(); + act(() => panel().querySelector('button')!.click()); + const chart: QueryExecutionTab = { + ...custom('a'), + type: 'charts', + renderContent: () => , + }; + render({tabs: [chart]}); + expect(panel().textContent).toContain('Count 0'); + render({preferredActiveTab: 'b'}, 'new-execution'); + expect(selected()).toBe('b'); + expect(changes).not.toHaveBeenCalled(); + }); + it('computes severity across the tree and changes the title without remounting Info', () => { + const info = (severity: 'info' | 'warning' | 'error'): QueryExecutionTab => ({ + id: 'messages', + type: 'info', + props: { + root: { + id: 'root', + severity: 'info', + message: 'root', + children: [{id: 'child', severity, message: 'child'}], + }, + }, + }); + render({tabs: [info('info')]}); + act(() => panel().querySelector('button')!.click()); + render({tabs: [info('warning')]}); + expect(selected()).toBe('Warning'); + render({tabs: [info('error')]}); + expect(selected()).toBe('Error'); + expect(panel().textContent).toContain('Count 1'); + expect(changes).not.toHaveBeenCalled(); + render({tabs: [{id: 'messages', type: 'info'}]}); + expect(selected()).toBe('Info'); + expect(panel().textContent).toContain('No messages'); + expect(getMessagesSeverity()).toBe('info'); + }); + it('renders multiple result/chart tabs in array order and pauses hidden progress', () => { + const tabs: QueryExecutionTab[] = [ + { + id: 'result/1', + type: 'result', + title: 'Second', + props: {columns: [], rows: [{id: 1}]}, + }, + {id: 'result/0', type: 'result', title: 'First', props: {columns: [], rows: []}}, + {id: 'p', type: 'progress', props: {graphProps: {nodes: [], edges: []}}}, + {id: 'chart/0', type: 'charts', renderContent: () => }, + {id: 'chart/1', type: 'charts', title: 'Chart 2', renderContent: () => }, + ]; + render({tabs}); + expect( + Array.from(container.querySelectorAll('[role="tab"]'), (node) => node.textContent), + ).toEqual(['Second', 'First', 'Progress', 'Charts', 'Chart 2']); + expect(panel().textContent).toContain('Rows: 1'); + click('First'); + expect(panel().textContent).toContain('Rows: 0'); + click('Progress'); + expect(container.querySelector('[data-progress="true"]')).not.toBeNull(); + click('Charts'); + expect(container.querySelector('[data-progress="false"]')).not.toBeNull(); + act(() => panel().querySelector('button')!.click()); + click('Chart 2'); + expect(panel().textContent).toContain('Count 0'); + click('Charts'); + expect(panel().textContent).toContain('Count 1'); + }); + it('renders metadata and delegates expand/close without remounting content', () => { + const onClose = vi.fn(); + const onExpandedChange = vi.fn(); + render({ + execution: {startedAt: '12:00', author: admin}, + onClose, + onExpandedChange, + }); + act(() => panel().querySelector('button')!.click()); + act(() => + container.querySelector('[aria-label="Expand panel"]')!.click(), + ); + expect(onExpandedChange).toHaveBeenCalledWith(true); + render({expanded: true, onClose, onExpandedChange}); + expect(panel().textContent).toContain('Count 1'); + act(() => + container.querySelector('[aria-label="Collapse panel"]')!.click(), + ); + expect(onExpandedChange).toHaveBeenLastCalledWith(false); + act(() => + container.querySelector('[aria-label="Close panel"]')!.click(), + ); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/components/QueryProgress.test.tsx b/tests/components/QueryProgress.test.tsx index 558638e..aea5653 100644 --- a/tests/components/QueryProgress.test.tsx +++ b/tests/components/QueryProgress.test.tsx @@ -45,6 +45,25 @@ it('opens both lazy views through controlled changes and preserves mounted shell ); expect(container.querySelector('[data-graph="false"]')).not.toBeNull(); expect(container.querySelector('[data-timeline="true"]')).not.toBeNull(); + await act(async () => + root.render(), + ); + expect(container.querySelector('[data-graph="false"]')).not.toBeNull(); + expect(container.querySelector('[data-timeline="false"]')).not.toBeNull(); + await act(async () => + root.render(), + ); + expect(container.querySelector('[data-graph="false"]')).not.toBeNull(); + await act(async () => + root.render( + , + ), + ); + expect(container.querySelector('[data-timeline="false"]')).not.toBeNull(); } finally { act(() => root.unmount()); } diff --git a/tests/tree-shaking/check.mjs b/tests/tree-shaking/check.mjs index af8f67a..ca0f9bb 100644 --- a/tests/tree-shaking/check.mjs +++ b/tests/tree-shaking/check.mjs @@ -16,7 +16,10 @@ const widgets = [ 'QueriesNavigation', 'QueryResults', 'DashboardCharts', + 'QueryExecutionPanel', ]; +const unitPath = (name) => `${name === 'QueryResults' ? 'modules' : 'widgets'}/${name}`; +const entryPath = (specifier) => specifier.slice(packageName.length + 1); const historyWidgets = new Set(widgets.slice(0, 3)); const contributions = [ 'clickhouse/clickhouse.contribution.js', @@ -37,7 +40,7 @@ const barrels = new Set( ), ); const pending = [ - ...widgets.map((name) => path.join(root, 'src/widgets', name, 'index.ts')), + ...widgets.map((name) => path.join(root, 'src', unitPath(name), 'index.ts')), path.join(root, 'src/modules/QueryTimeline/index.ts'), path.join(root, 'src/modules/QueryProgress/index.ts'), ]; @@ -100,13 +103,40 @@ function checkTypes(specifier, widget, mode) { root, 'build', requireMode ? 'cjs' : 'esm', - specifier === packageName ? '' : `widgets/${widget}`, + specifier === packageName ? '' : entryPath(specifier), 'index.d.ts', ); assert.equal(resolved.resolvedFileName, expected); - const contents = + let contents = `export {${widget}} from '${specifier}';\n` + (specifier === packageName ? '' : `export type {${widget}Props} from '${specifier}';`); + if (widget === 'QueryExecutionPanel') { + contents += ` +import type {QueryExecutionPanelProps, QueryExecutionTab} from '${specifier}'; +type Row = {count: number}; +type Meta = {name: string; value: string; custom: string}; +const tabs: QueryExecutionTab[] = [ + {id: 'r', type: 'result', props: { + rows: [{count: 1}], columns: [{name: 'count', type: ['DataType', 'Int32'], render: ({row}) => { + const value: number = row.count; + // @ts-expect-error The row must retain its concrete type. + const invalid: string = row.count; + return value; + }}], + }}, + {id: 'm', type: 'meta', props: { + data: {groups: [{items: [{name: 'n', value: 'v', custom: 'c'}]}]}, + view: {render: (data) => { + const value: string = data.groups[0].items[0].custom; + // @ts-expect-error Metadata must retain its concrete type. + const invalid: number = data.groups[0].items[0].custom; + return value; + }}, + }}, +]; +const props: QueryExecutionPanelProps = {tabs}; +`; + } const host = ts.createCompilerHost(compilerOptions); const originalGetSourceFile = host.getSourceFile.bind(host); host.getSourceFile = (name, ...args) => @@ -127,10 +157,10 @@ function checkTypes(specifier, widget, mode) { } for (const widget of widgets) { - for (const specifier of [packageName, `${packageName}/widgets/${widget}`]) { + for (const specifier of [packageName, `${packageName}/${unitPath(widget)}`]) { const esmEntry = fileURLToPath(import.meta.resolve(specifier)); const cjsEntry = require.resolve(specifier); - const suffix = specifier === packageName ? 'index.js' : `widgets/${widget}/index.js`; + const suffix = specifier === packageName ? 'index.js' : `${entryPath(specifier)}/index.js`; assert.equal(esmEntry, path.join(root, 'build/esm', suffix)); assert.equal(cjsEntry, path.join(root, 'build/cjs', suffix)); const result = await build({ @@ -163,25 +193,29 @@ for (const widget of widgets) { const hasDependency = (name) => imports.some((p) => p === name || p.startsWith(name + '/')); for (const other of widgets) { assert.equal( - included.has(`build/esm/widgets/${other}/${other}.js`), - other === widget, + included.has(`build/esm/${unitPath(other)}/${other}.js`), + other === widget || (widget === 'QueryExecutionPanel' && other === 'QueryResults'), `${specifier}: unexpected inclusion/exclusion of ${other}`, ); // esbuild can extract CSS from unused root re-exports. An individual // entrypoint must not even introduce the other widgets' styles. if (specifier !== packageName) { assert.equal( - included.has(`build/esm/widgets/${other}/${other}.css`), - other === widget, + included.has(`build/esm/${unitPath(other)}/${other}.css`), + other === widget || + (widget === 'QueryExecutionPanel' && other === 'QueryResults'), `${specifier}: unexpected inclusion/exclusion of ${other} CSS`, ); } } assert( - !hasDependency('@gravity-ui/timeline'), + widget === 'QueryExecutionPanel' || !hasDependency('@gravity-ui/timeline'), `${specifier}: unrelated timeline dependency`, ); - assert(!hasDependency('@gravity-ui/graph'), `${specifier}: unrelated graph dependency`); + assert( + widget === 'QueryExecutionPanel' || !hasDependency('@gravity-ui/graph'), + `${specifier}: unrelated graph dependency`, + ); assert.equal( hasDependency('monaco-editor'), historyWidgets.has(widget), @@ -204,7 +238,7 @@ for (const widget of widgets) { ); } assert( - included.has(`build/esm/widgets/${widget}/${widget}.css`), + included.has(`build/esm/${unitPath(widget)}/${widget}.css`), `${specifier}: missing CSS`, ); assert( @@ -213,7 +247,7 @@ for (const widget of widgets) { ); // QueriesHistory currently gets its visible translations from shared components. const localization = - widget === 'QueriesHistory' ? 'components/FieldsSelector' : `widgets/${widget}`; + widget === 'QueriesHistory' ? 'components/FieldsSelector' : unitPath(widget); for (const language of ['en', 'ru']) { assert( included.has(`build/esm/${localization}/i18n/${language}.json`),