From bde8310ef67081b1247c2fb60071398dd23342b1 Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Mon, 28 Sep 2026 14:34:02 +0300 Subject: [PATCH] feat: add extensible query execution panel Add ordered typed tabs, custom content, severity-based message titles and automatic selection until manual navigation. Preserve visited tab state and pause inactive progress views. Move QueryResults to modules and remove its unused widget entrypoint. Add Query Tracker examples, API documentation and selection, lifecycle and export checks. --- README.md | 14 +- package.json | 30 +- src/index.ts | 1 + src/modules/QueryProgress/QueryProgress.tsx | 10 +- .../QueryResults/QueryResults.scss | 0 .../QueryResults/QueryResults.stories.tsx | 2 +- .../QueryResults/QueryResults.tsx | 27 +- .../QueryResults/i18n/dicts.ts | 0 .../QueryResults/i18n/en.json | 0 .../QueryResults/i18n/index.ts | 0 .../QueryResults/i18n/ru.json | 0 .../QueryResults/index.ts | 0 .../internal/QueryResultsSchema.tsx | 0 .../internal/QueryStatisticsTable.tsx | 2 +- .../internal/QueryStatisticsToolbar.tsx | 2 +- src/modules/index.ts | 2 + src/types/queryExecutionPanel.ts | 59 ++++ src/types/queryGraph.ts | 2 + src/types/queryResults.ts | 20 ++ .../QueryExecutionPanel.scss | 41 +++ .../QueryExecutionPanel.tsx | 149 +++++++++ src/widgets/QueryExecutionPanel/README.md | 135 ++++++++ .../helpers/getMessagesSeverity.ts | 16 + .../helpers/useActiveTab.ts | 61 ++++ src/widgets/QueryExecutionPanel/i18n/dicts.ts | 4 + src/widgets/QueryExecutionPanel/i18n/en.json | 17 + src/widgets/QueryExecutionPanel/i18n/index.ts | 4 + src/widgets/QueryExecutionPanel/i18n/ru.json | 17 + src/widgets/QueryExecutionPanel/index.ts | 7 + .../internal/QueryExecutionTabContent.tsx | 39 +++ .../story/QueryExecutionPanel.stories.scss | 17 + .../story/QueryExecutionPanel.stories.tsx | 202 +++++++++++ .../story/queryTrackerAdapter.tsx | 65 ++++ src/widgets/index.ts | 4 +- tests/components/QueryExecutionPanel.test.tsx | 315 ++++++++++++++++++ tests/components/QueryProgress.test.tsx | 19 ++ tests/tree-shaking/check.mjs | 60 +++- 37 files changed, 1286 insertions(+), 57 deletions(-) rename src/{widgets => modules}/QueryResults/QueryResults.scss (100%) rename src/{widgets => modules}/QueryResults/QueryResults.stories.tsx (99%) rename src/{widgets => modules}/QueryResults/QueryResults.tsx (77%) rename src/{widgets => modules}/QueryResults/i18n/dicts.ts (100%) rename src/{widgets => modules}/QueryResults/i18n/en.json (100%) rename src/{widgets => modules}/QueryResults/i18n/index.ts (100%) rename src/{widgets => modules}/QueryResults/i18n/ru.json (100%) rename src/{widgets => modules}/QueryResults/index.ts (100%) rename src/{widgets => modules}/QueryResults/internal/QueryResultsSchema.tsx (100%) create mode 100644 src/types/queryExecutionPanel.ts create mode 100644 src/widgets/QueryExecutionPanel/QueryExecutionPanel.scss create mode 100644 src/widgets/QueryExecutionPanel/QueryExecutionPanel.tsx create mode 100644 src/widgets/QueryExecutionPanel/README.md create mode 100644 src/widgets/QueryExecutionPanel/helpers/getMessagesSeverity.ts create mode 100644 src/widgets/QueryExecutionPanel/helpers/useActiveTab.ts create mode 100644 src/widgets/QueryExecutionPanel/i18n/dicts.ts create mode 100644 src/widgets/QueryExecutionPanel/i18n/en.json create mode 100644 src/widgets/QueryExecutionPanel/i18n/index.ts create mode 100644 src/widgets/QueryExecutionPanel/i18n/ru.json create mode 100644 src/widgets/QueryExecutionPanel/index.ts create mode 100644 src/widgets/QueryExecutionPanel/internal/QueryExecutionTabContent.tsx create mode 100644 src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.scss create mode 100644 src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.tsx create mode 100644 src/widgets/QueryExecutionPanel/story/queryTrackerAdapter.tsx create mode 100644 tests/components/QueryExecutionPanel.test.tsx 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`),