diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index f04dc2c..8965777 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,6 +2,7 @@ import type {Decorator, Preview} from '@storybook/react'; import {ThemeProvider, configure} from '@gravity-ui/uikit'; import React from 'react'; +import '@gravity-ui/uikit/styles/fonts.css'; import '@gravity-ui/uikit/styles/styles.css'; import '@gravity-ui/unipika/dist/unipika.css'; import '@gravity-ui/illustrations/styles/styles.scss'; diff --git a/AGENTS.md b/AGENTS.md index 4152733..359422d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ For the full ownership rules, examples, and review checklist, see [`plans/styles ## i18n -Every widget/module/component with its own reusable scenario keeps its localization in an `i18n/` subfolder (`en.json`, `ru.json`, `dicts.ts`, `index.ts`), registers its keyset via [`addI18Keysets`](src/i18n/index.ts:11) with a name like `` `qp:widget-name` ``, and uses the typed `t`/`i18n(...)` function — see the example in [`QueriesHistory`](src/widgets/QueriesHistory/i18n/index.ts:1). +Every widget/module/component with its own reusable scenario keeps its localization in an `i18n/` subfolder (`en.json`, `ru.json`, `dicts.ts`, `index.ts`), registers its keyset via [`addI18Keysets`](src/i18n/index.ts:11) with a name like `` `qp:widget-name` ``, and uses the typed `t`/`i18n(...)` function — see the example in [`QueriesHistory`](src/modules/QueriesHistory/i18n/index.ts:1). Brief naming notes: diff --git a/README.md b/README.md index d1ee5ab..94b4815 100644 --- a/README.md +++ b/README.md @@ -57,24 +57,25 @@ Prefer widgets for product screens. Use modules and components when you need a c ## Usage -### Widget imports +### Individual imports -Widgets support both root and individual imports: +Widgets and modules support both root and individual imports: ```tsx import {SavedQueries} from '@gravity-ui/querieskit'; ``` -Alternatively, start directly from the widget's entrypoint: +Alternatively, start directly from the module's entrypoint: ```tsx -import {SavedQueries} from '@gravity-ui/querieskit/widgets/SavedQueries'; -import type {SavedQueriesProps} from '@gravity-ui/querieskit/widgets/SavedQueries'; +import {SavedQueries} from '@gravity-ui/querieskit/modules/SavedQueries'; +import type {SavedQueriesProps} from '@gravity-ui/querieskit/modules/SavedQueries'; ``` Individual entrypoints are available for `QueriesHistory`, `SavedQueries`, -`TutorialsHistory`, `QueriesNavigation`, `QueryExecutionPanel`, and `DashboardCharts`. -They expose each widget's existing public exports, including its props and helpers. +`TutorialsHistory`, `QueriesNavigation` under `modules/`, and `QueriesSidebar`, +`QueryExecutionPanel`, `DashboardCharts` under `widgets/`. +They expose each unit's existing public exports, including its props and helpers. Shared data types remain available from the package root. Individual imports limit the dependency graph the bundler needs to traverse. @@ -85,6 +86,13 @@ 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. +### QueriesSidebar + +Combines history, saved queries, navigation and tutorials in one panel. Supply +an ordered `tabs` array and an optional `header` for product selection or actions. +Use `hideTabs` with `activeTab` when navigation lives outside the sidebar. +See [the API and migration guide](src/widgets/QueriesSidebar/README.md). + ### QueryExecutionPanel A query execution panel with ordered, typed tabs, custom content, automatic tab diff --git a/package.json b/package.json index 2e6afdf..ac2467a 100644 --- a/package.json +++ b/package.json @@ -28,42 +28,42 @@ "./package.json": "./package.json", "./widgets/QueriesHistory": { "import": { - "types": "./build/esm/widgets/QueriesHistory/index.d.ts", - "default": "./build/esm/widgets/QueriesHistory/index.js" + "types": "./build/esm/modules/QueriesHistory/index.d.ts", + "default": "./build/esm/modules/QueriesHistory/index.js" }, "require": { - "types": "./build/cjs/widgets/QueriesHistory/index.d.ts", - "default": "./build/cjs/widgets/QueriesHistory/index.js" + "types": "./build/cjs/modules/QueriesHistory/index.d.ts", + "default": "./build/cjs/modules/QueriesHistory/index.js" } }, "./widgets/SavedQueries": { "import": { - "types": "./build/esm/widgets/SavedQueries/index.d.ts", - "default": "./build/esm/widgets/SavedQueries/index.js" + "types": "./build/esm/modules/SavedQueries/index.d.ts", + "default": "./build/esm/modules/SavedQueries/index.js" }, "require": { - "types": "./build/cjs/widgets/SavedQueries/index.d.ts", - "default": "./build/cjs/widgets/SavedQueries/index.js" + "types": "./build/cjs/modules/SavedQueries/index.d.ts", + "default": "./build/cjs/modules/SavedQueries/index.js" } }, "./widgets/TutorialsHistory": { "import": { - "types": "./build/esm/widgets/TutorialsHistory/index.d.ts", - "default": "./build/esm/widgets/TutorialsHistory/index.js" + "types": "./build/esm/modules/TutorialsHistory/index.d.ts", + "default": "./build/esm/modules/TutorialsHistory/index.js" }, "require": { - "types": "./build/cjs/widgets/TutorialsHistory/index.d.ts", - "default": "./build/cjs/widgets/TutorialsHistory/index.js" + "types": "./build/cjs/modules/TutorialsHistory/index.d.ts", + "default": "./build/cjs/modules/TutorialsHistory/index.js" } }, "./widgets/QueriesNavigation": { "import": { - "types": "./build/esm/widgets/QueriesNavigation/index.d.ts", - "default": "./build/esm/widgets/QueriesNavigation/index.js" + "types": "./build/esm/modules/QueriesNavigation/index.d.ts", + "default": "./build/esm/modules/QueriesNavigation/index.js" }, "require": { - "types": "./build/cjs/widgets/QueriesNavigation/index.d.ts", - "default": "./build/cjs/widgets/QueriesNavigation/index.js" + "types": "./build/cjs/modules/QueriesNavigation/index.d.ts", + "default": "./build/cjs/modules/QueriesNavigation/index.js" } }, "./widgets/DashboardCharts": { @@ -95,6 +95,56 @@ "types": "./build/cjs/widgets/QueryExecutionPanel/index.d.ts", "default": "./build/cjs/widgets/QueryExecutionPanel/index.js" } + }, + "./modules/QueriesHistory": { + "import": { + "types": "./build/esm/modules/QueriesHistory/index.d.ts", + "default": "./build/esm/modules/QueriesHistory/index.js" + }, + "require": { + "types": "./build/cjs/modules/QueriesHistory/index.d.ts", + "default": "./build/cjs/modules/QueriesHistory/index.js" + } + }, + "./modules/QueriesNavigation": { + "import": { + "types": "./build/esm/modules/QueriesNavigation/index.d.ts", + "default": "./build/esm/modules/QueriesNavigation/index.js" + }, + "require": { + "types": "./build/cjs/modules/QueriesNavigation/index.d.ts", + "default": "./build/cjs/modules/QueriesNavigation/index.js" + } + }, + "./modules/SavedQueries": { + "import": { + "types": "./build/esm/modules/SavedQueries/index.d.ts", + "default": "./build/esm/modules/SavedQueries/index.js" + }, + "require": { + "types": "./build/cjs/modules/SavedQueries/index.d.ts", + "default": "./build/cjs/modules/SavedQueries/index.js" + } + }, + "./modules/TutorialsHistory": { + "import": { + "types": "./build/esm/modules/TutorialsHistory/index.d.ts", + "default": "./build/esm/modules/TutorialsHistory/index.js" + }, + "require": { + "types": "./build/cjs/modules/TutorialsHistory/index.d.ts", + "default": "./build/cjs/modules/TutorialsHistory/index.js" + } + }, + "./widgets/QueriesSidebar": { + "import": { + "types": "./build/esm/widgets/QueriesSidebar/index.d.ts", + "default": "./build/esm/widgets/QueriesSidebar/index.js" + }, + "require": { + "types": "./build/cjs/widgets/QueriesSidebar/index.d.ts", + "default": "./build/cjs/widgets/QueriesSidebar/index.js" + } } }, "files": [ diff --git a/plans/i18n-rules.md b/plans/i18n-rules.md index 88762ff..7ef7da6 100644 --- a/plans/i18n-rules.md +++ b/plans/i18n-rules.md @@ -16,8 +16,8 @@ widgets/ index.ts ``` -- [`dicts.ts`](../src/widgets/QueriesHistory/i18n/dicts.ts:1) — собирает словари по языкам: `export default {en, ru}`. -- [`index.ts`](../src/widgets/QueriesHistory/i18n/index.ts:1) — регистрирует кейсет через [`addI18Keysets`](../src/i18n/index.ts:11) и экспортирует типизированную функцию `t`: +- [`dicts.ts`](../src/modules/QueriesHistory/i18n/dicts.ts:1) — собирает словари по языкам: `export default {en, ru}`. +- [`index.ts`](../src/modules/QueriesHistory/i18n/index.ts:1) — регистрирует кейсет через [`addI18Keysets`](../src/i18n/index.ts:11) и экспортирует типизированную функцию `t`: ```ts import {addI18Keysets} from '../../../i18n'; @@ -28,7 +28,7 @@ widgets/ ``` - Имя кейсета всегда начинается с префикса `qp:` (namespace библиотеки) — тип `addI18Keysets` требует `` `qp:${string}` ``. -- В компоненте используется как `i18n('key_name')`, см. [`QueriesHistory.tsx`](../src/widgets/QueriesHistory/QueriesHistory.tsx:41). +- В компоненте используется как `i18n('key_name')`, см. [`QueriesHistory.tsx`](../src/modules/QueriesHistory/QueriesHistory.tsx:41). - `modules` и `components`, у которых есть собственный переиспользуемый сценарий, также могут заводить свою папку `i18n/` по этой же схеме — правило применяется на всех трёх уровнях (`components` / `modules` / `widgets`). ## Кейсеты diff --git a/plans/queries-history-structure.md b/plans/queries-history-structure.md index a44817f..d350891 100644 --- a/plans/queries-history-structure.md +++ b/plans/queries-history-structure.md @@ -1,3 +1,5 @@ +> Historical plan: QueriesHistory now lives in modules and is composed by QueriesSidebar. See [current architecture](queries-sidebar-architecture.md). + # План трёхуровневой структуры QueriesKit ## Контекст @@ -129,7 +131,7 @@ src/ ## Предлагаемый порядок реализации 1. Создать папки [`src/components`](src/components), [`src/modules`](src/modules), [`src/widgets`](src/widgets) как публичные уровни библиотеки. -2. Перенести [`QueriesHistory`](src/components/QueriesHistory/QueriesHistory.tsx:28) и его i18n в [`src/widgets/QueriesHistory`](src/widgets/QueriesHistory). +2. Перенести [`QueriesHistory`](src/components/QueriesHistory/QueriesHistory.tsx:28) и его i18n в [`src/modules/QueriesHistory`](src/modules/QueriesHistory). 3. Перенести [`HistoryHeader`](src/components/QueriesHistory/HistoryHeader/HistoryHeader.tsx:13), [`HistoryList`](src/components/QueriesHistory/HistoryList.tsx:26) (вместе с [`HistoryRowContent`](src/components/QueriesHistory/HistoryRowContent.tsx:1) и [`prepareRowData`](src/components/QueriesHistory/helpers/prepareRowData.ts)), [`HistoryRow`](src/components/QueriesHistory/HistoryRow/HistoryRow.tsx:17) в [`src/modules`](src/modules). 4. Вынести [`QueryStatusIcon`](src/components/QueryStatusIcon/QueryStatusIcon.tsx:27), [`QueryDuration`](src/components/QueryDuration/QueryDuration.tsx) (вместе с хуком [`useQueryDuration`](src/components/QueryDuration/useQueryDuration.ts)) и [`HistoryGroupHeader`](src/components/HistoryGroupHeader/HistoryGroupHeader.tsx:1) в [`src/components`](src/components). 5. Оставить [`HistorySearch`](src/components/QueriesHistory/HistoryHeader/HistorySearch.tsx:16), [`HistoryFilter`](src/components/QueriesHistory/HistoryHeader/HistoryFilter.tsx:16), [`HistoryRowHeader`](src/components/QueriesHistory/HistoryRow/HistoryRowHeader.tsx:19), [`HistoryRowMenu`](src/components/QueriesHistory/HistoryRow/HistoryRowMenu.tsx) внутри соответствующих modules, пока нет явной потребности в самостоятельном использовании. diff --git a/plans/queries-navigation-architecture-review.md b/plans/queries-navigation-architecture-review.md index d1ddb7b..8130a29 100644 --- a/plans/queries-navigation-architecture-review.md +++ b/plans/queries-navigation-architecture-review.md @@ -5,10 +5,10 @@ Затронутые единицы: -- Виджет [`QueriesNavigation`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:51) + хелперы - [`createNavigationDetailResolver`](src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts:13), - [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:45), - [`createEmptyDetailConfig`](src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.ts:5). +- Виджет [`QueriesNavigation`](src/modules/QueriesNavigation/QueriesNavigation.tsx:51) + хелперы + [`createNavigationDetailResolver`](src/modules/QueriesNavigation/helpers/createNavigationDetailResolver.ts:13), + [`createTableDetailConfig`](src/modules/QueriesNavigation/helpers/createTableDetailConfig.tsx:45), + [`createEmptyDetailConfig`](src/modules/QueriesNavigation/helpers/createEmptyDetailConfig.ts:5). - Модули: [`NavigationDetail`](src/modules/NavigationDetail/NavigationDetail.tsx:39), [`NavigationHeader`](src/modules/NavigationHeader/NavigationHeader.tsx:15), [`ClustersList`](src/modules/ClustersList/ClustersList.tsx:23), @@ -41,7 +41,7 @@ раскладка/отступы задаются через `Flex gap` или `--g-spacing-*` в `.scss`. Инлайн-`gap:4` стоит заменить на `Flex gap` или CSS-класс с токеном. (Инлайн-стили и текстовые заглушки в сторибуках оставляем как есть — для демо это допустимо.) -- **Пустой `title: ''` в конфиге таба.** В [`createEmptyDetailConfig`](src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.ts:9) +- **Пустой `title: ''` в конфиге таба.** В [`createEmptyDetailConfig`](src/modules/QueriesNavigation/helpers/createEmptyDetailConfig.ts:9) создаётся таб с пустым заголовком — формально валидация i18n не срабатывает (строка не идёт через `t`), но семантически это «фейковый» таб только ради контейнера. См. п.4. @@ -105,7 +105,7 @@ Замечания: -- **Дженерик теряется в detail-резолверах.** В [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:19) +- **Дженерик теряется в detail-резолверах.** В [`createTableDetailConfig`](src/modules/QueriesNavigation/helpers/createTableDetailConfig.tsx:19) резолверы типизированы как `NavigationSchemaResolver`, но возвращают `NavigationSchemaConfig` без ``. Из-за этого `extraColumns`-типизация кастомных колонок в конфиге таблицы теряется. Стоит пробросить `TColumn`/`TRow` до `NavigationSchema data`. @@ -117,7 +117,7 @@ индексную сигнатуру `[key: string]: unknown`. Это ослабляет типизацию расширений (лучше решать через дженерик `T extends ...`, который и так есть). Возможная избыточность — см. п.6. - **`resolvedDetailActions = detailActions ?? actions`** в - [`QueriesNavigation`](src/widgets/QueriesNavigation/QueriesNavigation.tsx:86): fallback header-экшенов + [`QueriesNavigation`](src/modules/QueriesNavigation/QueriesNavigation.tsx:86): fallback header-экшенов на detail неочевиден. Стоит задокументировать поведение в типе `NavigationDetailPanelConfig`. ## 5. Дублирование кода @@ -164,7 +164,7 @@ - **Повторяющиеся `Loading/Empty/Error`-стори у 4 detail-модулей.** У Schema, Preview, Meta, View практически идентичные state-стори. Их можно оставить (они полезны для autodocs), но mock-данные - дублируются между сторибуками и [`QueriesNavigation.stories`](src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx:148) + дублируются между сторибуками и [`QueriesNavigation.stories`](src/modules/QueriesNavigation/QueriesNavigation.stories.tsx:148) (`TABLE_SCHEMA_COLUMNS`, `TABLE_PREVIEW_ROWS`, `TABLE_META_GROUPS`, `TABLE_VIEW_SECTIONS` повторяют данные модульных сторибуков). Кандидат на общий `story/mockData.ts` (как уже сделано в `DashboardCharts/story/mockData.ts` и `ChartEditor/story/mockData.ts`). @@ -189,7 +189,7 @@ и [`buildViewColumns`](src/modules/NavigationView/helpers/buildViewColumns.tsx:5) в один хелпер. 2. Вынести общий `isEmptyValue` в `src/helpers/`. 3. Пробросить дженерики `TColumn`/`TRow` в резолверах - [`createTableDetailConfig`](src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx:45). + [`createTableDetailConfig`](src/modules/QueriesNavigation/helpers/createTableDetailConfig.tsx:45). 4. Решить статус `ClusterRow`/`NavigationItemRow`: поднять в `components` либо убрать из публичного barrel. diff --git a/plans/queries-sidebar-architecture.md b/plans/queries-sidebar-architecture.md new file mode 100644 index 0000000..8116102 --- /dev/null +++ b/plans/queries-sidebar-architecture.md @@ -0,0 +1,25 @@ +# QueriesSidebar architecture + +`QueriesSidebar` is the product widget for the shared aside shown in the updated +design. `QueriesHistory`, `SavedQueries`, `QueriesNavigation`, `TutorialsHistory` +are public scenario modules. They remain useful independently and assemble their +existing lower-level modules and components. + +The widget owns the optional header slot, ordered icon tabs, controlled or +uncontrolled selection, and retained content panels. It imports concrete module +entrypoints. Modules do not import the widget. Public contracts live in `src/types`. + +`hideTabs` supports application-owned navigation: `activeTab` still selects content, +the header remains available, and the same DOM panels change from tab panels to +named regions. UIKit's public Tab component override supplies explicit IDs linking +the visible tabs to these panels, without changing their React identity. + +Each panel mounts its content on first visit. `ListActivityContext`, a shared helper +independent of widget types, pauses the pagination sentinel in retained hidden +panels. It does not change list keys or remove callbacks from module props. + +The modules keep their own styles, i18n, internal parts and helpers. The sidebar +owns only shell styles and section labels. Root exports are unchanged; new module +entrypoints are canonical and old widget entrypoints are package export aliases. + +See the [API and migration guide](../src/widgets/QueriesSidebar/README.md). diff --git a/src/components/LazyList/LazyList.scss b/src/components/LazyList/LazyList.scss index 0c3ebe4..2128e1c 100644 --- a/src/components/LazyList/LazyList.scss +++ b/src/components/LazyList/LazyList.scss @@ -1,4 +1,9 @@ .qp-lazy-list { + &__item { + // Widen the virtualized rows, keeping both axes in the list's scroll container. + min-width: var(--qp-lazy-list-item-min-width, 0px); + } + .g-list__item_selected:hover { background-color: var(--g-color-base-selection-hover); } diff --git a/src/components/LazyList/LazyList.tsx b/src/components/LazyList/LazyList.tsx index f65e2e6..8a3d8c0 100644 --- a/src/components/LazyList/LazyList.tsx +++ b/src/components/LazyList/LazyList.tsx @@ -98,6 +98,7 @@ export const LazyList = ({ > ref={listRef} className={block(null, className)} + itemClassName={block('item')} filterable={filterable} items={rows} itemHeight={getRowHeight} diff --git a/src/components/QueryResultsTable/QueryResultsTable.scss b/src/components/QueryResultsTable/QueryResultsTable.scss index c1956ff..aae6f6a 100644 --- a/src/components/QueryResultsTable/QueryResultsTable.scss +++ b/src/components/QueryResultsTable/QueryResultsTable.scss @@ -28,9 +28,9 @@ &__cell { display: block; padding-right: var(--g-spacing-5); - font-family: var(--g-font-family-monospace); - font-size: var(--g-text-code-2-font-size); - line-height: var(--g-text-code-2-line-height); + font-family: var(--g-text-body-font-family); + font-size: var(--g-text-body-1-font-size); + line-height: var(--g-text-body-1-line-height); overflow-wrap: anywhere; &:hover .qp-query-results-table__cell-actions, @@ -40,6 +40,7 @@ .unipika { display: inline; + font: inherit; } } diff --git a/src/helpers/ListActivityContext.ts b/src/helpers/ListActivityContext.ts new file mode 100644 index 0000000..27d51f8 --- /dev/null +++ b/src/helpers/ListActivityContext.ts @@ -0,0 +1,4 @@ +import {createContext} from 'react'; + +/** Pauses automatic list loading while a retained panel is hidden. */ +export const ListActivityContext = createContext(true); diff --git a/src/helpers/useLoadMoreSentinel.ts b/src/helpers/useLoadMoreSentinel.ts index ae7aa97..1b6f262 100644 --- a/src/helpers/useLoadMoreSentinel.ts +++ b/src/helpers/useLoadMoreSentinel.ts @@ -1,4 +1,5 @@ -import {useCallback, useEffect, useRef} from 'react'; +import {useCallback, useContext, useEffect, useRef} from 'react'; +import {ListActivityContext} from './ListActivityContext'; export function useLoadMoreSentinel( hasMore: boolean | undefined, @@ -6,6 +7,9 @@ export function useLoadMoreSentinel( loading?: boolean, itemsCount?: number, ) { + const active = useContext(ListActivityContext); + const activeRef = useRef(active); + activeRef.current = active; const observerRef = useRef(null); const loadRequestedRef = useRef(false); const hasMoreRef = useRef(hasMore); @@ -25,18 +29,19 @@ export function useLoadMoreSentinel( // Cached pages can add items without changing the loading state. loadRequestedRef.current = false; } - }, [loading, itemsCount]); + }, [loading, itemsCount, active]); return useCallback( (node: HTMLElement | null) => { observerRef.current?.disconnect(); - if (!node) { + if (!node || !active) { return; } observerRef.current = new IntersectionObserver((entries) => { if ( + activeRef.current && entries.some((entry) => entry.isIntersecting) && hasMoreRef.current && !loading && @@ -50,6 +55,6 @@ export function useLoadMoreSentinel( observerRef.current.observe(node); }, - [loading, preventRepeatedLoad], + [loading, preventRepeatedLoad, active], ); } diff --git a/src/index.ts b/src/index.ts index 1132470..5daf286 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,3 +13,8 @@ export * from './types/queryResults'; export * from './types/queryGraph'; export * from './types/queryTimeline'; export * from './types/queryExecutionPanel'; +export * from './types/queriesSidebar'; +export type {QueriesHistoryProps} from './types/queriesHistory'; +export type {QueriesNavigationProps} from './types/queriesNavigation'; +export type {SavedQueriesProps} from './types/savedQueriesProps'; +export type {TutorialsHistoryProps} from './types/tutorialsHistory'; diff --git a/src/modules/HistoryHeader/HistoryHeader.tsx b/src/modules/HistoryHeader/HistoryHeader.tsx index 253457b..07fd5e0 100644 --- a/src/modules/HistoryHeader/HistoryHeader.tsx +++ b/src/modules/HistoryHeader/HistoryHeader.tsx @@ -10,6 +10,7 @@ type Props = { fullSearchAvailable?: boolean; hasClear?: boolean; filter?: QueryListFilterConfig; + actions?: React.ReactNode; onUpdate: (data: {value: string; fullSearch: boolean}) => void; className?: string; }; @@ -20,6 +21,7 @@ export const HistoryHeader: FC = ({ fullSearchAvailable = true, hasClear, filter, + actions, onUpdate, className, }) => { @@ -59,7 +61,10 @@ export const HistoryHeader: FC = ({ ] : undefined } - endButtons={filter ? [] : undefined} + endButtons={[ + ...(filter ? [] : []), + ...(actions ? [{actions}] : []), + ]} /> ); }; diff --git a/src/modules/NavigationMeta/NavigationMeta.scss b/src/modules/NavigationMeta/NavigationMeta.scss index 691fd8c..ae40ae3 100644 --- a/src/modules/NavigationMeta/NavigationMeta.scss +++ b/src/modules/NavigationMeta/NavigationMeta.scss @@ -1,25 +1,23 @@ .qp-navigation-meta { - &__error { - display: block; - } + &__error { + display: block; + } - &__group-title { - display: block; - margin-bottom: var(--g-spacing-2); - } + &__group-title { + display: block; + margin-bottom: var(--g-spacing-2); + } - &__group-body { - width: 100%; - display: grid; - grid-gap: 12px; - grid-template-columns: 128px 1fr; - } + &__group-body { + --g-definition-list-item-gap: var(--g-spacing-3); + overflow-wrap: anywhere; + } - &__skeleton-row { - height: 24px; - } + &__skeleton-row { + height: 24px; + } - &__empty { - height: 100%; - } + &__empty { + height: 100%; + } } diff --git a/src/modules/NavigationMeta/NavigationMeta.tsx b/src/modules/NavigationMeta/NavigationMeta.tsx index 2bb560e..0c1f16f 100644 --- a/src/modules/NavigationMeta/NavigationMeta.tsx +++ b/src/modules/NavigationMeta/NavigationMeta.tsx @@ -1,5 +1,5 @@ import React, {useMemo} from 'react'; -import {Flex, Text} from '@gravity-ui/uikit'; +import {DefinitionList, Flex, Text} from '@gravity-ui/uikit'; import cn from 'bem-cn-lite'; import {EmptyContent} from '../../components/EmptyContent'; import {SkeletonRows} from '../../components/SkeletonRows'; @@ -65,14 +65,13 @@ export function NavigationMeta ) : null} -
- {group.items.map(({name, value}) => ( - <> - {name} -
{value}
- + + {group.items.map(({name, value}, itemIndex) => ( + + {value} + ))} -
+ ), )} diff --git a/src/modules/QueriesHistory/QueriesHistory.scss b/src/modules/QueriesHistory/QueriesHistory.scss new file mode 100644 index 0000000..5686a46 --- /dev/null +++ b/src/modules/QueriesHistory/QueriesHistory.scss @@ -0,0 +1,5 @@ +.qp-query-history { + --qp-lazy-list-item-min-width: 360px; + + height: 100%; +} diff --git a/src/modules/QueriesHistory/QueriesHistory.stories.data.ts b/src/modules/QueriesHistory/QueriesHistory.stories.data.ts new file mode 100644 index 0000000..18e7dfe --- /dev/null +++ b/src/modules/QueriesHistory/QueriesHistory.stories.data.ts @@ -0,0 +1,100 @@ +import type {QueryListItem} from '../../types/queryList'; +import type {QueryHistoryRow} from '../../types/history'; + +const now = Date.now(); +export const min = 60 * 1000; + +const QUERY = `use test; + +SELECT + "test_session" AS session_id, + "test_task" AS task_id, + SUBSTRING("test", 1, 1) AS truncated_char`; + +export const BASE_ITEMS: QueryListItem[] = [ + {header: 'Today', height: 28}, + { + id: 1, + title: 'Query 1', + status: 'completed', + engine: 'YQL', + mode: 'Validation', + startTime: now - 2 * min, + endTime: now - min, + query: QUERY, + height: 52, + }, + { + id: 2, + title: 'Query 2', + status: 'failed', + engine: 'YQL', + mode: 'Test', + startTime: now - 10 * min, + endTime: now - 9 * min, + query: QUERY, + height: 52, + }, + { + id: 3, + title: 'Query 3', + status: 'running', + engine: 'YQL', + mode: 'Test', + startTime: now - min, + query: QUERY, + height: 52, + }, + {header: 'Yesterday', height: 28}, + { + id: 4, + title: 'Query 4', + status: 'aborted', + engine: 'YQL', + mode: 'Validation', + startTime: now - 25 * 60 * min, + endTime: now - 24 * 60 * min, + query: 'SELECT 1', + height: 52, + }, + { + id: 5, + title: 'Query 5', + status: 'draft', + engine: 'YQL', + mode: 'Test', + startTime: now - 30 * 60 * min, + query: QUERY, + height: 52, + }, + { + id: 6, + title: 'Query 6', + status: 'draft', + engine: 'YQL', + mode: 'Test', + startTime: now - 30 * 60 * min, + query: QUERY, + height: 52, + }, + { + id: 7, + title: 'Query 7', + status: 'draft', + engine: 'YQL', + mode: 'Test', + startTime: now - 30 * 60 * min, + query: QUERY, + height: 52, + }, + { + id: 8, + title: 'Query 8', + status: 'draft', + engine: 'YQL', + mode: 'Test', + startTime: now - 30 * 60 * min, + query: QUERY, + height: 52, + }, +]; diff --git a/src/widgets/QueriesHistory/QueriesHistory.stories.tsx b/src/modules/QueriesHistory/QueriesHistory.stories.tsx similarity index 86% rename from src/widgets/QueriesHistory/QueriesHistory.stories.tsx rename to src/modules/QueriesHistory/QueriesHistory.stories.tsx index de08f65..ac1fb95 100644 --- a/src/widgets/QueriesHistory/QueriesHistory.stories.tsx +++ b/src/modules/QueriesHistory/QueriesHistory.stories.tsx @@ -1,3 +1,4 @@ +import {BASE_ITEMS, min} from './QueriesHistory.stories.data'; import React, {useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import {SegmentedRadioGroup, Text} from '@gravity-ui/uikit'; @@ -13,104 +14,6 @@ import { import {QueryHistoryRow} from '../../types/history'; import {action} from 'storybook/actions'; -const now = Date.now(); -const min = 60 * 1000; - -const QUERY = `use test; - -SELECT - "test_session" AS session_id, - "test_task" AS task_id, - SUBSTRING("test", 1, 1) AS truncated_char`; - -const BASE_ITEMS: QueryListItem[] = [ - {header: 'Today', height: 28}, - { - id: 1, - title: 'Query 1', - status: 'completed', - engine: 'YQL', - mode: 'Validation', - startTime: now - 2 * min, - endTime: now - min, - query: QUERY, - height: 52, - }, - { - id: 2, - title: 'Query 2', - status: 'failed', - engine: 'YQL', - mode: 'Test', - startTime: now - 10 * min, - endTime: now - 9 * min, - query: QUERY, - height: 52, - }, - { - id: 3, - title: 'Query 3', - status: 'running', - engine: 'YQL', - mode: 'Test', - startTime: now - min, - query: QUERY, - height: 52, - }, - {header: 'Yesterday', height: 28}, - { - id: 4, - title: 'Query 4', - status: 'aborted', - engine: 'YQL', - mode: 'Validation', - startTime: now - 25 * 60 * min, - endTime: now - 24 * 60 * min, - query: 'SELECT 1', - height: 52, - }, - { - id: 5, - title: 'Query 5', - status: 'draft', - engine: 'YQL', - mode: 'Test', - startTime: now - 30 * 60 * min, - query: QUERY, - height: 52, - }, - { - id: 6, - title: 'Query 6', - status: 'draft', - engine: 'YQL', - mode: 'Test', - startTime: now - 30 * 60 * min, - query: QUERY, - height: 52, - }, - { - id: 7, - title: 'Query 7', - status: 'draft', - engine: 'YQL', - mode: 'Test', - startTime: now - 30 * 60 * min, - query: QUERY, - height: 52, - }, - { - id: 8, - title: 'Query 8', - status: 'draft', - engine: 'YQL', - mode: 'Test', - startTime: now - 30 * 60 * min, - query: QUERY, - height: 52, - }, -]; - const LINK_ITEMS = BASE_ITEMS.map((item) => 'id' in item ? {...item, href: `/queries/${item.id}`} : item, ); @@ -228,7 +131,7 @@ const renderRouterLink: QueryListLinkRenderer = ({onClick, ...props}) => ( ); const meta: Meta = { - title: 'Widgets/QueriesHistory', + title: 'Modules/QueriesHistory', component: QueriesHistory, tags: ['autodocs'], parameters: { diff --git a/src/widgets/QueriesHistory/QueriesHistory.tsx b/src/modules/QueriesHistory/QueriesHistory.tsx similarity index 55% rename from src/widgets/QueriesHistory/QueriesHistory.tsx rename to src/modules/QueriesHistory/QueriesHistory.tsx index abcf230..c086f3d 100644 --- a/src/widgets/QueriesHistory/QueriesHistory.tsx +++ b/src/modules/QueriesHistory/QueriesHistory.tsx @@ -1,40 +1,12 @@ import React from 'react'; import cn from 'bem-cn-lite'; import {QueriesList} from '../../modules/QueriesList'; -import { - QueryListComparisonConfig, - QueryListEditingConfig, - QueryListFilterConfig, - QueryListItem, - QueryListLinkRenderer, - QueryListRowAction, - QueryListRowRenderData, - QueryListSearchConfig, - QueryListVisibleFieldsConfig, -} from '../../types/queryList'; -import {QueryHistoryRow} from '../../types/history'; +import type {QueryHistoryRow} from '../../types/history'; import {HistoryRowContent} from './internal/HistoryRowContent'; import './QueriesHistory.scss'; -export type QueriesHistoryProps = { - className?: string; - title?: string; - logo?: React.ReactNode; - search: QueryListSearchConfig; - filter?: QueryListFilterConfig; - items: QueryListItem[]; - selectedRowId?: T['id']; - editing?: QueryListEditingConfig; - comparison?: QueryListComparisonConfig; - visibleFields?: QueryListVisibleFieldsConfig; - renderRowItem?: (data: QueryListRowRenderData) => React.ReactNode; - getRowActions?: (item: T) => QueryListRowAction[]; - onListItemClick?: (item: QueryListItem) => void; - hasMore?: boolean; - loading?: boolean; - onLoadMore?: () => void; - renderLink?: QueryListLinkRenderer; -}; +export type {QueriesHistoryProps} from '../../types/queriesHistory'; +import type {QueriesHistoryProps} from '../../types/queriesHistory'; const block = cn('qp-query-history'); diff --git a/src/widgets/QueriesHistory/i18n/dicts.ts b/src/modules/QueriesHistory/i18n/dicts.ts similarity index 100% rename from src/widgets/QueriesHistory/i18n/dicts.ts rename to src/modules/QueriesHistory/i18n/dicts.ts diff --git a/src/widgets/QueriesHistory/i18n/en.json b/src/modules/QueriesHistory/i18n/en.json similarity index 100% rename from src/widgets/QueriesHistory/i18n/en.json rename to src/modules/QueriesHistory/i18n/en.json diff --git a/src/widgets/QueriesHistory/i18n/index.ts b/src/modules/QueriesHistory/i18n/index.ts similarity index 100% rename from src/widgets/QueriesHistory/i18n/index.ts rename to src/modules/QueriesHistory/i18n/index.ts diff --git a/src/widgets/QueriesHistory/i18n/ru.json b/src/modules/QueriesHistory/i18n/ru.json similarity index 100% rename from src/widgets/QueriesHistory/i18n/ru.json rename to src/modules/QueriesHistory/i18n/ru.json diff --git a/src/widgets/QueriesHistory/index.ts b/src/modules/QueriesHistory/index.ts similarity index 100% rename from src/widgets/QueriesHistory/index.ts rename to src/modules/QueriesHistory/index.ts diff --git a/src/widgets/QueriesHistory/internal/HistoryRowContent.tsx b/src/modules/QueriesHistory/internal/HistoryRowContent.tsx similarity index 100% rename from src/widgets/QueriesHistory/internal/HistoryRowContent.tsx rename to src/modules/QueriesHistory/internal/HistoryRowContent.tsx diff --git a/src/modules/QueriesList/QueriesList.tsx b/src/modules/QueriesList/QueriesList.tsx index 15b0c99..f745972 100644 --- a/src/modules/QueriesList/QueriesList.tsx +++ b/src/modules/QueriesList/QueriesList.tsx @@ -73,10 +73,10 @@ export const QueriesList = ({ className={block(null, className)} title={title} logo={logo} - actions={visibleFields && } header={ } search={search.value} fullSearch={search.fullSearch} fullSearchAvailable={fullSearchAvailable} diff --git a/src/widgets/QueriesNavigation/QueriesNavigation.scss b/src/modules/QueriesNavigation/QueriesNavigation.scss similarity index 100% rename from src/widgets/QueriesNavigation/QueriesNavigation.scss rename to src/modules/QueriesNavigation/QueriesNavigation.scss diff --git a/src/modules/QueriesNavigation/QueriesNavigation.stories.data.ts b/src/modules/QueriesNavigation/QueriesNavigation.stories.data.ts new file mode 100644 index 0000000..f2e41af --- /dev/null +++ b/src/modules/QueriesNavigation/QueriesNavigation.stories.data.ts @@ -0,0 +1,102 @@ +import type {NavigationCluster, NavigationItem, NavigationSortOrder} from '../../types/navigation'; + +export const CLUSTERS: NavigationCluster[] = [ + { + id: 'northstar', + title: 'Northstar', + color: 'white', + backgroundColor: 'rgba(218, 68, 83, 1)', + description: 'Production', + }, + { + id: 'cedar', + title: 'Cedar', + color: 'white', + backgroundColor: 'rgba(127, 130, 133, 1)', + description: 'Production', + }, + { + id: 'sequoia', + title: 'Sequoia', + color: 'white', + backgroundColor: 'rgba(215, 112, 173, 1)', + description: 'Production', + }, + { + id: 'pioneer-test', + title: 'Pioneer-Test', + color: 'white', + backgroundColor: 'rgba(150, 122, 220, 1)', + description: 'Testing', + }, + { + id: 'orbit', + title: 'Orbit', + color: 'white', + backgroundColor: 'rgba(233, 87, 63, 1)', + description: 'Production', + }, + { + id: 'lighthouse', + title: 'Lighthouse', + color: 'white', + backgroundColor: 'rgba(67, 68, 69, 1)', + description: 'Production', + }, + { + id: 'northstar-gnd', + title: 'Northstar-GND', + color: 'white', + backgroundColor: 'rgba(140, 193, 82, 1)', + description: 'tesdting', + }, + { + id: 'harbor', + title: 'Harbor', + color: 'white', + backgroundColor: 'rgba(55, 188, 155, 1)', + description: 'Production', + }, + { + id: 'cedar-gnd', + title: 'Cedar-GND', + color: 'white', + backgroundColor: 'rgba(140, 193, 82, 1)', + description: 'Prestable', + }, +]; + +const ITEM_NAMES: Array> = [ + {title: 'abcdapter', kind: 'folder', hasChildren: true}, + {title: 'access_control_object', kind: 'file'}, + {title: 'account_tree', kind: 'folder', hasChildren: true, disabled: true}, + {title: 'cell_balancers', kind: 'folder', hasChildren: true}, + {title: 'clusters', kind: 'folder', hasChildren: true}, + {title: 'doctors_table', kind: 'table'}, +]; + +const getPathDepth = (path: string | undefined): number => + (path ?? '').split('/').filter(Boolean).length; + +export const getItemsForPath = ( + path: string | undefined, + sort: NavigationSortOrder = 'asc', +): NavigationItem[] => { + if (getPathDepth(path) > 1) { + return []; + } + + return [...ITEM_NAMES] + .sort(({title: leftTitle}, {title: rightTitle}) => + sort === 'asc' + ? leftTitle.localeCompare(rightTitle) + : rightTitle.localeCompare(leftTitle), + ) + .map(({title, kind, hasChildren, disabled}) => ({ + path: `${path ?? ''}/${title}`, + title, + kind, + hasChildren, + disabled, + })); +}; diff --git a/src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx b/src/modules/QueriesNavigation/QueriesNavigation.stories.tsx similarity index 84% rename from src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx rename to src/modules/QueriesNavigation/QueriesNavigation.stories.tsx index a0a4d04..8208c79 100644 --- a/src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx +++ b/src/modules/QueriesNavigation/QueriesNavigation.stories.tsx @@ -1,3 +1,4 @@ +import {CLUSTERS, getItemsForPath} from './QueriesNavigation.stories.data'; import React, {useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import {QueriesNavigation} from './QueriesNavigation'; @@ -33,7 +34,7 @@ import {META_GROUPS} from '../../modules/NavigationMeta/story/mockData'; import {VIEW_COLUMNS, makeViewRows} from '../../modules/NavigationView/story/mockData'; const meta: Meta = { - title: 'Widgets/QueriesNavigation', + title: 'Modules/QueriesNavigation', component: QueriesNavigation, tags: ['autodocs'], parameters: { @@ -59,107 +60,6 @@ const defaultActions: NavigationHeaderAction[] = [ }, ]; -const CLUSTERS: NavigationCluster[] = [ - { - id: 'northstar', - title: 'Northstar', - color: 'white', - backgroundColor: 'rgba(218, 68, 83, 1)', - description: 'Production', - }, - { - id: 'cedar', - title: 'Cedar', - color: 'white', - backgroundColor: 'rgba(127, 130, 133, 1)', - description: 'Production', - }, - { - id: 'sequoia', - title: 'Sequoia', - color: 'white', - backgroundColor: 'rgba(215, 112, 173, 1)', - description: 'Production', - }, - { - id: 'pioneer-test', - title: 'Pioneer-Test', - color: 'white', - backgroundColor: 'rgba(150, 122, 220, 1)', - description: 'Testing', - }, - { - id: 'orbit', - title: 'Orbit', - color: 'white', - backgroundColor: 'rgba(233, 87, 63, 1)', - description: 'Production', - }, - { - id: 'lighthouse', - title: 'Lighthouse', - color: 'white', - backgroundColor: 'rgba(67, 68, 69, 1)', - description: 'Production', - }, - { - id: 'northstar-gnd', - title: 'Northstar-GND', - color: 'white', - backgroundColor: 'rgba(140, 193, 82, 1)', - description: 'tesdting', - }, - { - id: 'harbor', - title: 'Harbor', - color: 'white', - backgroundColor: 'rgba(55, 188, 155, 1)', - description: 'Production', - }, - { - id: 'cedar-gnd', - title: 'Cedar-GND', - color: 'white', - backgroundColor: 'rgba(140, 193, 82, 1)', - description: 'Prestable', - }, -]; - -const ITEM_NAMES: Array> = [ - {title: 'abcdapter', kind: 'folder', hasChildren: true}, - {title: 'access_control_object', kind: 'file'}, - {title: 'account_tree', kind: 'folder', hasChildren: true, disabled: true}, - {title: 'cell_balancers', kind: 'folder', hasChildren: true}, - {title: 'clusters', kind: 'folder', hasChildren: true}, - {title: 'doctors_table', kind: 'table'}, -]; - -const getPathDepth = (path: string | undefined): number => - (path ?? '').split('/').filter(Boolean).length; - -const getItemsForPath = ( - path: string | undefined, - sort: NavigationSortOrder = 'asc', -): NavigationItem[] => { - if (getPathDepth(path) > 1) { - return []; - } - - return [...ITEM_NAMES] - .sort(({title: leftTitle}, {title: rightTitle}) => - sort === 'asc' - ? leftTitle.localeCompare(rightTitle) - : rightTitle.localeCompare(leftTitle), - ) - .map(({title, kind, hasChildren, disabled}) => ({ - path: `${path ?? ''}/${title}`, - title, - kind, - hasChildren, - disabled, - })); -}; - export default meta; type Story = StoryObj; diff --git a/src/widgets/QueriesNavigation/QueriesNavigation.tsx b/src/modules/QueriesNavigation/QueriesNavigation.tsx similarity index 86% rename from src/widgets/QueriesNavigation/QueriesNavigation.tsx rename to src/modules/QueriesNavigation/QueriesNavigation.tsx index 49394f9..9fd1046 100644 --- a/src/widgets/QueriesNavigation/QueriesNavigation.tsx +++ b/src/modules/QueriesNavigation/QueriesNavigation.tsx @@ -9,16 +9,8 @@ import {SearchWithButtons} from '../../components/SearchWithButtons'; import type { NavigationCluster, NavigationDetailConfig, - NavigationDetailPanelConfig, - NavigationHeaderConfig, NavigationItem, - NavigationListStateConfig, NavigationLocation, - NavigationParentRowConfig, - NavigationSearchConfig, - NavigationSortConfig, - RenderNavigationCluster, - RenderNavigationItem, } from '../../types/navigation'; import {createEmptyDetailConfig} from './helpers/createEmptyDetailConfig'; import i18n from './i18n'; @@ -27,26 +19,8 @@ import './QueriesNavigation.scss'; const block = cn('qp-queries-navigation'); -export type QueriesNavigationProps< - TItem extends NavigationItem = NavigationItem, - TCluster extends NavigationCluster = NavigationCluster, -> = { - location: NavigationLocation; - onUpdate: (location: NavigationLocation) => void; - clusters?: TCluster[]; - items?: TItem[]; - header?: NavigationHeaderConfig; - search?: NavigationSearchConfig; - sort?: NavigationSortConfig; - listState?: NavigationListStateConfig; - detail?: NavigationDetailPanelConfig; - parentRow?: NavigationParentRowConfig; - renderClusterItem?: RenderNavigationCluster; - renderNavigationItem?: RenderNavigationItem; - onClusterClick?: (cluster: TCluster) => void; - onItemClick?: (item: TItem) => void; - className?: string; -}; +export type {QueriesNavigationProps} from '../../types/queriesNavigation'; +import type {QueriesNavigationProps} from '../../types/queriesNavigation'; type NavigationBody = | {type: 'loading'} diff --git a/src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.tsx b/src/modules/QueriesNavigation/helpers/createEmptyDetailConfig.tsx similarity index 100% rename from src/widgets/QueriesNavigation/helpers/createEmptyDetailConfig.tsx rename to src/modules/QueriesNavigation/helpers/createEmptyDetailConfig.tsx diff --git a/src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts b/src/modules/QueriesNavigation/helpers/createNavigationDetailResolver.ts similarity index 100% rename from src/widgets/QueriesNavigation/helpers/createNavigationDetailResolver.ts rename to src/modules/QueriesNavigation/helpers/createNavigationDetailResolver.ts diff --git a/src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx b/src/modules/QueriesNavigation/helpers/createTableDetailConfig.tsx similarity index 100% rename from src/widgets/QueriesNavigation/helpers/createTableDetailConfig.tsx rename to src/modules/QueriesNavigation/helpers/createTableDetailConfig.tsx diff --git a/src/widgets/QueriesNavigation/i18n/dicts.ts b/src/modules/QueriesNavigation/i18n/dicts.ts similarity index 100% rename from src/widgets/QueriesNavigation/i18n/dicts.ts rename to src/modules/QueriesNavigation/i18n/dicts.ts diff --git a/src/widgets/QueriesNavigation/i18n/en.json b/src/modules/QueriesNavigation/i18n/en.json similarity index 100% rename from src/widgets/QueriesNavigation/i18n/en.json rename to src/modules/QueriesNavigation/i18n/en.json diff --git a/src/widgets/QueriesNavigation/i18n/index.ts b/src/modules/QueriesNavigation/i18n/index.ts similarity index 100% rename from src/widgets/QueriesNavigation/i18n/index.ts rename to src/modules/QueriesNavigation/i18n/index.ts diff --git a/src/widgets/QueriesNavigation/i18n/ru.json b/src/modules/QueriesNavigation/i18n/ru.json similarity index 100% rename from src/widgets/QueriesNavigation/i18n/ru.json rename to src/modules/QueriesNavigation/i18n/ru.json diff --git a/src/widgets/QueriesNavigation/index.ts b/src/modules/QueriesNavigation/index.ts similarity index 100% rename from src/widgets/QueriesNavigation/index.ts rename to src/modules/QueriesNavigation/index.ts diff --git a/src/modules/QueryProgress/QueryProgress.scss b/src/modules/QueryProgress/QueryProgress.scss index abc4588..41f318d 100644 --- a/src/modules/QueryProgress/QueryProgress.scss +++ b/src/modules/QueryProgress/QueryProgress.scss @@ -6,48 +6,13 @@ &__toolbar { flex-shrink: 0; flex-wrap: wrap; + padding: var(--g-spacing-2) var(--g-spacing-4); } &__statuses { margin-left: auto; } - &__status { - display: inline-flex; - align-items: center; - gap: var(--g-spacing-1); - padding: var(--g-spacing-1) var(--g-spacing-2); - border-radius: var(--g-border-radius-s); - color: var(--g-color-text-secondary); - background: var(--g-color-base-background); - - &_status_not-started { - color: var(--g-color-text-primary); - box-shadow: inset 0 0 0 1px var(--g-color-line-generic); - } - - &_status_waiting { - color: var(--g-color-text-misc-heavy); - background: var(--g-color-base-misc-light); - } - &_status_running { - color: var(--g-color-text-info); - background: var(--g-color-base-info-light); - } - &_status_completed { - color: var(--g-color-text-positive); - background: var(--g-color-base-positive-light); - } - &_status_failed { - color: var(--g-color-text-danger); - background: var(--g-color-base-danger-light); - } - &_status_aborted { - color: var(--g-color-text-complementary); - background: var(--g-color-base-generic); - } - } - &__content, &__view { flex: 1; diff --git a/src/modules/QueryProgress/QueryProgress.tsx b/src/modules/QueryProgress/QueryProgress.tsx index 7688e22..2e5618b 100644 --- a/src/modules/QueryProgress/QueryProgress.tsx +++ b/src/modules/QueryProgress/QueryProgress.tsx @@ -7,7 +7,8 @@ import { CirclePlay, CircleXmark, } from '@gravity-ui/icons'; -import {Flex, Icon, Loader, SegmentedRadioGroup} from '@gravity-ui/uikit'; +import {Flex, Icon, Label, Loader, SegmentedRadioGroup} from '@gravity-ui/uikit'; +import type {LabelProps} from '@gravity-ui/uikit'; import cn from 'bem-cn-lite'; import type { @@ -46,6 +47,15 @@ const statusIcons = { aborted: CircleMinus, } satisfies Record; +const statusThemes: Record = { + 'not-started': 'clear', + waiting: 'normal', + running: 'info', + completed: 'success', + failed: 'danger', + aborted: 'normal', +}; + export function QueryProgress({ active = true, graphProps, @@ -85,7 +95,7 @@ export function QueryProgress({ ); return ( - + @@ -96,12 +106,17 @@ export function QueryProgress({ {view === 'graph' && ( - + {statuses.map((status) => ( - - - {i18n(`status_${status}`)}: {counts[status]} - + ))} )} diff --git a/src/modules/QueryResults/QueryResults.scss b/src/modules/QueryResults/QueryResults.scss index 68dbc33..1648ef4 100644 --- a/src/modules/QueryResults/QueryResults.scss +++ b/src/modules/QueryResults/QueryResults.scss @@ -3,6 +3,11 @@ &__toolbar { min-width: 0; + padding: var(--g-spacing-2) var(--g-spacing-2) var(--g-spacing-2) var(--g-spacing-4); + position: sticky; + top: 0; + z-index: 1; + background: var(--g-color-base-background); } &__views, @@ -21,5 +26,6 @@ &__content { min-width: 0; + padding-inline: var(--g-spacing-4); } } diff --git a/src/modules/QueryResults/QueryResults.tsx b/src/modules/QueryResults/QueryResults.tsx index 6c2d46b..46a928b 100644 --- a/src/modules/QueryResults/QueryResults.tsx +++ b/src/modules/QueryResults/QueryResults.tsx @@ -65,14 +65,14 @@ export function QueryResults>({ } return ( - + {title && {title}} - + diff --git a/src/modules/QueryStatistics/QueryStatistics.scss b/src/modules/QueryStatistics/QueryStatistics.scss index c6b7f40..5706a17 100644 --- a/src/modules/QueryStatistics/QueryStatistics.scss +++ b/src/modules/QueryStatistics/QueryStatistics.scss @@ -1,3 +1,4 @@ .qp-query-statistics { min-width: 0; + padding-inline: var(--g-spacing-4); } diff --git a/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.scss b/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.scss index ea822f1..545f57a 100644 --- a/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.scss +++ b/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.scss @@ -1 +1,8 @@ -.qp-query-statistics-toolbar { margin-block-end: var(--g-spacing-4); max-width: 34rem; } +.qp-query-statistics-toolbar { + padding-block: var(--g-spacing-2); + + &__search { + min-width: 0; + max-width: 320px; + } +} diff --git a/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.tsx b/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.tsx index 9e6eee5..7280f00 100644 --- a/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.tsx +++ b/src/modules/QueryStatistics/internal/QueryStatisticsToolbar.tsx @@ -1,9 +1,8 @@ import React from 'react'; import {ChevronsDown, ChevronsUp} from '@gravity-ui/icons'; -import {Button, Icon} from '@gravity-ui/uikit'; +import {Button, Flex, Icon, TextInput} from '@gravity-ui/uikit'; import cn from 'bem-cn-lite'; -import {SearchWithButtons} from '../../../components/SearchWithButtons'; import i18n from '../i18n'; import './QueryStatisticsToolbar.scss'; @@ -29,6 +28,7 @@ export function QueryStatisticsToolbar({ + + ); +} + +const meta: Meta = { + title: 'Widgets/QueriesSidebar', + component: QueriesSidebar, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {render: () => }; +export const ExternalNavigation: Story = {render: () => }; +export const Narrow: Story = {render: () => }; +export const CustomTabFirst: Story = {render: () => }; diff --git a/src/widgets/QueriesSidebar/QueriesSidebar.tsx b/src/widgets/QueriesSidebar/QueriesSidebar.tsx new file mode 100644 index 0000000..7ff454f --- /dev/null +++ b/src/widgets/QueriesSidebar/QueriesSidebar.tsx @@ -0,0 +1,127 @@ +import React, {useEffect, useId, useMemo} from 'react'; +import {BranchesRight, ClockArrowRotateLeft, CloudCheck, GraduationCap} from '@gravity-ui/icons'; +import {Flex, Icon, Tab, type TabComponentProps, TabList, TabProvider} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import type {QueryHistoryRow} from '../../types/history'; +import type {NavigationCluster, NavigationItem} from '../../types/navigation'; +import type {QueriesSidebarProps} from '../../types/queriesSidebar'; +import type {SavedQuery} from '../../types/savedQueries'; +import type {TutorialHistoryRow} from '../../types/tutorial'; +import {useActiveTab} from './helpers/useActiveTab'; +import {SidebarPanel} from './internal/SidebarPanel'; +import {SidebarTabButton} from './internal/SidebarTabButton'; +import {SidebarTabContent} from './internal/SidebarTabContent'; +import i18n from './i18n'; +import './QueriesSidebar.scss'; + +// UIKit 7.45 exposes TabComponentProps but its forwardRef declaration loses the generic. +const SidebarTab = Tab as unknown as React.ComponentType< + TabComponentProps +>; + +const block = cn('qp-queries-sidebar'); +const icons = { + history: ClockArrowRotateLeft, + saved: CloudCheck, + navigation: BranchesRight, + tutorials: GraduationCap, +}; + +export function QueriesSidebar< + THistory extends QueryHistoryRow = QueryHistoryRow, + TSaved extends SavedQuery = SavedQuery, + TNavigation extends NavigationItem = NavigationItem, + TCluster extends NavigationCluster = NavigationCluster, + TTutorial extends TutorialHistoryRow = TutorialHistoryRow, +>({ + header, + tabs: inputTabs, + activeTab, + defaultActiveTab, + onActiveTabChange, + hideTabs = false, + className, +}: QueriesSidebarProps) { + const id = useId(); + 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('QueriesSidebar: skipped empty or duplicate tab IDs', invalidIds); + } + }, [invalidIds]); + + const {selected, select} = useActiveTab({tabs, activeTab, defaultActiveTab, onActiveTabChange}); + const title = (tab: (typeof tabs)[number]) => + tab.type === 'custom' ? tab.title : i18n(`title_${tab.type}`); + + return ( + + {header !== null && header !== undefined && ( +
{header}
+ )} + + {!hideTabs && ( + + {tabs.map((tab) => ( + { + if (event.key === 'Enter' || event.key === ' ') { + // Avoid a second request from the button's native click. + event.preventDefault(); + select(tab.id); + } + }} + > + + + ))} + + )} + + {tabs.map((tab) => ( + + + + ))} +
+ ); +} diff --git a/src/widgets/QueriesSidebar/README.md b/src/widgets/QueriesSidebar/README.md new file mode 100644 index 0000000..2185802 --- /dev/null +++ b/src/widgets/QueriesSidebar/README.md @@ -0,0 +1,82 @@ +# QueriesSidebar + +`QueriesSidebar` combines independently usable modules into an ordered sidebar. +The application owns data fetching, routing, search values and other controlled +section state. The widget owns section selection and layout. + +```tsx +import {QueriesSidebar} from '@gravity-ui/querieskit/widgets/QueriesSidebar'; +import type {QueriesSidebarTab} from '@gravity-ui/querieskit/widgets/QueriesSidebar'; + +const tabs: QueriesSidebarTab[] = [ + {id: 'history', type: 'history', props: historyProps}, + {id: 'saved', type: 'saved', props: savedProps}, + {id: 'navigation', type: 'navigation', props: navigationProps}, + {id: 'tutorials', type: 'tutorials', props: tutorialProps}, +]; + +// The containing application supplies the sidebar's width and height. +} tabs={tabs} defaultActiveTab="history" />; + +// External navigation / router controls selection. No tab strip is rendered. +} tabs={tabs} hideTabs activeTab={section} />; +``` + +## Props and selection + +| Prop | Behavior | +| ------------------- | --------------------------------------------------------------------------------------------------------- | +| `tabs` | Ordered array; omit sections that should not be available | +| `header` | Optional React content above the tab strip, also shown with `hideTabs` | +| `activeTab` | Controlled selected ID; do not change between controlled and uncontrolled modes | +| `defaultActiveTab` | Initial uncontrolled ID; defaults to the first enabled tab | +| `onActiveTabChange` | User selection requests, or uncontrolled fallback changes; never echoes prop updates or initial selection | +| `hideTabs` | Defaults to `false`; hides only the tab strip, without resetting content | +| `className` | External layout class on the root | + +IDs must be non-empty, unique and stable. Keep a tab's type stable as well. +Invalid IDs are skipped, with development warnings. If selection is unavailable, +the first enabled tab is displayed; an empty or entirely disabled set displays +no content. In controlled mode this fallback does not rewrite application state. + +Built-in types are `history`, `saved`, `navigation` and `tutorials`; their `props` +use the corresponding module contract. Titles and icons are localized defaults. +The widget and tab types accept generic row types in the order history, saved, +navigation item, navigation cluster, tutorial. + +A `custom` tab has `id`, `title` (a string accessible name), `icon` (React content) +and `renderContent({active})`. All tab types accept `disabled`. + +## State and accessibility + +Sections mount on their first visit, then remain mounted while hidden. Local +state and scroll are retained when switching sections or toggling `hideTabs`. +Removing an ID from `tabs` discards its mounted content. Application-controlled +state continues to follow the supplied props. + +Hidden built-in sections pause automatic list pagination. Custom sections can +use `active` to pause their own fetching, subscriptions or timers. The sidebar +does not cancel requests already started by the application. + +Visible navigation uses icon tabs with names and tooltips. With `hideTabs`, panels +become named regions without references to missing tabs; hidden content cannot +receive focus. Header and tabs stay above the scrollable content. Use `header` +for a shared product selector; section `logo` props remain available for standalone +usage and should not duplicate that shared header. + +## Migration + +`QueriesHistory`, `SavedQueries`, `QueriesNavigation`, `TutorialsHistory` now live +in `src/modules` and are published at `@gravity-ui/querieskit/modules/`. +Their names, props, helpers and root exports are preserved. The previous explicit +`@gravity-ui/querieskit/widgets/` entrypoints remain compatibility aliases +to the same module files through `package.json`; no widget wrappers remain. + +Direct physical imports such as `@gravity-ui/querieskit/build/esm/widgets/` +must migrate to the new public module entrypoints. Prefer these entrypoints for +standalone sections: they do not import `QueriesSidebar` or unrelated sections. +The combined sidebar includes all four built-in renderers in its dependency graph. + +History places its visible-fields selector next to the search input. Saved queries +always display the available date, engine and author metadata; `SavedQueriesProps` +no longer accepts `visibleFields`. Remove that prop from saved-section configs. diff --git a/src/widgets/QueriesSidebar/helpers/useActiveTab.ts b/src/widgets/QueriesSidebar/helpers/useActiveTab.ts new file mode 100644 index 0000000..6c3db81 --- /dev/null +++ b/src/widgets/QueriesSidebar/helpers/useActiveTab.ts @@ -0,0 +1,36 @@ +import {useEffect, useRef, useState} from 'react'; +import type {QueriesSidebarTabBase} from '../../../types/queriesSidebar'; + +export function useActiveTab({ + tabs, + activeTab, + defaultActiveTab, + onActiveTabChange, +}: { + tabs: QueriesSidebarTabBase[]; + activeTab?: string; + defaultActiveTab?: 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 controlled = activeTab !== undefined; + const [selection, setSelection] = useState(defaultActiveTab); + const requested = controlled ? activeTab : selection; + const selected = available(requested) ? requested : first; + if (!controlled && selection !== selected) setSelection(selected); + + 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) || id === selected) return; + if (controlled) onActiveTabChange?.(id); + else setSelection(id); + }; + return {selected, select}; +} diff --git a/src/widgets/QueriesSidebar/i18n/dicts.ts b/src/widgets/QueriesSidebar/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/widgets/QueriesSidebar/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/QueriesSidebar/i18n/en.json b/src/widgets/QueriesSidebar/i18n/en.json new file mode 100644 index 0000000..415e9c9 --- /dev/null +++ b/src/widgets/QueriesSidebar/i18n/en.json @@ -0,0 +1,7 @@ +{ + "title_tabs": "Query sections", + "title_history": "History", + "title_saved": "Saved", + "title_navigation": "Navigation", + "title_tutorials": "Tutorials" +} diff --git a/src/widgets/QueriesSidebar/i18n/index.ts b/src/widgets/QueriesSidebar/i18n/index.ts new file mode 100644 index 0000000..adb797e --- /dev/null +++ b/src/widgets/QueriesSidebar/i18n/index.ts @@ -0,0 +1,4 @@ +import {addI18Keysets} from '../../../i18n'; +import dicts from './dicts'; + +export default addI18Keysets('qp:queries-sidebar', dicts); diff --git a/src/widgets/QueriesSidebar/i18n/ru.json b/src/widgets/QueriesSidebar/i18n/ru.json new file mode 100644 index 0000000..518ad26 --- /dev/null +++ b/src/widgets/QueriesSidebar/i18n/ru.json @@ -0,0 +1,7 @@ +{ + "title_tabs": "Разделы запросов", + "title_history": "История", + "title_saved": "Сохранённые", + "title_navigation": "Навигация", + "title_tutorials": "Примеры" +} diff --git a/src/widgets/QueriesSidebar/index.ts b/src/widgets/QueriesSidebar/index.ts new file mode 100644 index 0000000..c9e7e06 --- /dev/null +++ b/src/widgets/QueriesSidebar/index.ts @@ -0,0 +1,7 @@ +export {QueriesSidebar} from './QueriesSidebar'; +export type { + QueriesSidebarProps, + QueriesSidebarTab, + QueriesSidebarTabBase, + QueriesSidebarTabRenderContext, +} from '../../types/queriesSidebar'; diff --git a/src/widgets/QueriesSidebar/internal/SidebarPanel.tsx b/src/widgets/QueriesSidebar/internal/SidebarPanel.tsx new file mode 100644 index 0000000..2c464e2 --- /dev/null +++ b/src/widgets/QueriesSidebar/internal/SidebarPanel.tsx @@ -0,0 +1,20 @@ +import React, {useContext, useState} from 'react'; +import {ListActivityContext} from '../../../helpers/ListActivityContext'; + +export function SidebarPanel({ + active, + children, + ...props +}: React.HTMLAttributes & {active: boolean}) { + const parentActive = useContext(ListActivityContext); + const [visited, setVisited] = useState(active); + if (active && !visited) setVisited(true); + + return ( + + ); +} diff --git a/src/widgets/QueriesSidebar/internal/SidebarTabButton.tsx b/src/widgets/QueriesSidebar/internal/SidebarTabButton.tsx new file mode 100644 index 0000000..35e782f --- /dev/null +++ b/src/widgets/QueriesSidebar/internal/SidebarTabButton.tsx @@ -0,0 +1,21 @@ +import React, {forwardRef} from 'react'; + +type Props = React.ComponentPropsWithoutRef<'button'> & { + isMenuItem?: boolean; + 'data-sidebar-tab-id': string; + 'data-sidebar-panel-id': string; +}; + +// UIKit's TabPanel fixes its role and IDs. Use the public Tab component override +// to associate tabs with stable panels that can also become standalone regions. +export const SidebarTabButton = forwardRef(function SidebarTabButtonRoot( + { + 'data-sidebar-tab-id': tabId, + 'data-sidebar-panel-id': panelId, + isMenuItem: _isMenuItem, + ...props + }, + ref, +) { + return )} - {onClose && ( - - )} +
-
+ diff --git a/src/widgets/QueryExecutionPanel/README.md b/src/widgets/QueryExecutionPanel/README.md index 7c2109c..fdf9742 100644 --- a/src/widgets/QueryExecutionPanel/README.md +++ b/src/widgets/QueryExecutionPanel/README.md @@ -4,6 +4,13 @@ A panel for one query execution. Requires the application's Gravity UI styles an `ThemeProvider`. Data loading, backend normalization and panel placement belong to the application. +Use `loading` to display a centered loader, or `error` with `onRetry` to display +the loading error illustration and Refresh action. Loading takes precedence over +error during retries. These states keep the header available and preserve visited +tab contents while marking them inactive. They are independent of query execution +errors displayed by the Info tab. The error illustration requires +`@gravity-ui/illustrations/styles/styles.scss` in the application stylesheet setup. + ```tsx import {QueryExecutionPanel} from '@gravity-ui/querieskit'; import type {QueryExecutionTab} from '@gravity-ui/querieskit'; @@ -38,7 +45,8 @@ const tabs: QueryExecutionTab<{name: string}>[] = [ execution={{startedAt: formattedStartTime, author: }} expanded={expanded} onExpandedChange={setExpanded} - onClose={closePanel} + collapsed={collapsed} + onCollapsedChange={setCollapsed} />; ``` @@ -101,9 +109,19 @@ controlled and uncontrolled modes during the lifetime of an instance. 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; +The cross button collapses the panel to its header; it does not remove the panel. +It becomes a double-chevron button that restores the content. Selecting a tab also +reveals its content. Visited tabs retain their state and receive `active: false` +while collapsed. No tab is highlighted while the content is hidden. + +Use `collapsed` with `onCollapsedChange` for controlled state, or `defaultCollapsed` +for an initially collapsed panel that manages its own state. The deprecated +`onClose` is only a collapse notification: migrate handlers that unmount the panel +to `onCollapsedChange` instead. + +Providing `onExpandedChange` displays the separate fullscreen button, which requests +`!expanded` and reveals collapsed content. Collapsing an expanded panel also requests +`onExpandedChange(false)` to release the fullscreen layout. 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. diff --git a/src/widgets/QueryExecutionPanel/i18n/en.json b/src/widgets/QueryExecutionPanel/i18n/en.json index ec51e8b..d0937ed 100644 --- a/src/widgets/QueryExecutionPanel/i18n/en.json +++ b/src/widgets/QueryExecutionPanel/i18n/en.json @@ -13,5 +13,10 @@ "context_no-tabs": "No available tabs", "action_expand": "Expand panel", "action_collapse": "Collapse panel", - "action_close": "Close panel" + "context_loading": "Loading", + "title_loading-error": "Error loading", + "context_try-refreshing": "Try refreshing the panel", + "action_refresh": "Refresh", + "action_show-content": "Expand content", + "action_hide-content": "Collapse content" } diff --git a/src/widgets/QueryExecutionPanel/i18n/ru.json b/src/widgets/QueryExecutionPanel/i18n/ru.json index 6f74a27..9e304b6 100644 --- a/src/widgets/QueryExecutionPanel/i18n/ru.json +++ b/src/widgets/QueryExecutionPanel/i18n/ru.json @@ -13,5 +13,10 @@ "context_no-tabs": "Нет доступных вкладок", "action_expand": "Развернуть панель", "action_collapse": "Свернуть панель", - "action_close": "Закрыть панель" + "context_loading": "Загрузка", + "title_loading-error": "Ошибка загрузки", + "context_try-refreshing": "Попробуйте обновить панель", + "action_refresh": "Обновить", + "action_show-content": "Развернуть содержимое", + "action_hide-content": "Свернуть содержимое" } diff --git a/src/widgets/QueryExecutionPanel/internal/QueryExecutionState.scss b/src/widgets/QueryExecutionPanel/internal/QueryExecutionState.scss new file mode 100644 index 0000000..d38cd36 --- /dev/null +++ b/src/widgets/QueryExecutionPanel/internal/QueryExecutionState.scss @@ -0,0 +1,6 @@ +.qp-query-execution-state { + min-height: 100%; + box-sizing: border-box; + padding: var(--g-spacing-4); + text-align: center; +} diff --git a/src/widgets/QueryExecutionPanel/internal/QueryExecutionState.tsx b/src/widgets/QueryExecutionPanel/internal/QueryExecutionState.tsx new file mode 100644 index 0000000..e40c5ea --- /dev/null +++ b/src/widgets/QueryExecutionPanel/internal/QueryExecutionState.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import {Button, Flex, Loader, Text} from '@gravity-ui/uikit'; +import {UnableToDisplay} from '@gravity-ui/illustrations'; +import cn from 'bem-cn-lite'; +import i18n from '../i18n'; +import './QueryExecutionState.scss'; + +const block = cn('qp-query-execution-state'); + +export function QueryExecutionState({loading, onRetry}: {loading: boolean; onRetry?: () => void}) { + return ( + + {loading ? ( + + ) : ( + <> + + + {i18n('title_loading-error')} + {i18n('context_try-refreshing')} + + {onRetry && } + + )} + + ); +} diff --git a/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.scss b/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.scss index 7c2fb1e..59e518b 100644 --- a/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.scss +++ b/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.scss @@ -2,6 +2,24 @@ height: 640px; min-width: 0; + &__reference { + height: 100%; + // Application branding is scoped to the reference story. Semantic info + // tokens supply the blue palette for both light and dark themes. + --g-color-base-brand: var(--g-color-base-info-heavy); + --g-color-base-brand-hover: var(--g-color-base-info-heavy-hover); + --g-color-base-selection: var(--g-color-base-info-medium); + --g-color-base-selection-hover: var(--g-color-base-info-medium-hover); + --g-color-line-brand: var(--g-color-base-info-heavy); + --g-color-text-brand: var(--g-color-text-info); + --g-color-text-brand-heavy: var(--g-color-text-info-heavy); + --g-color-text-brand-contrast: var(--g-color-text-light-primary); + --g-color-text-link: var(--g-color-text-info); + --g-color-text-link-hover: var(--g-color-text-info-heavy); + --g-color-text-link-visited: var(--g-color-text-misc); + --g-color-text-link-visited-hover: var(--g-color-text-misc-heavy); + } + &__layout { height: 100%; background: var(--g-color-base-background); diff --git a/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.tsx b/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.tsx index 32bfd41..feddb77 100644 --- a/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.tsx +++ b/src/widgets/QueryExecutionPanel/story/QueryExecutionPanel.stories.tsx @@ -1,5 +1,5 @@ import React, {useState} from 'react'; -import type {Meta, StoryObj} from '@storybook/react'; +import type {Meta as StoryMeta, StoryObj} from '@storybook/react'; import {Button, Flex, Text, TextInput} from '@gravity-ui/uikit'; import {QueryExecutionPanel} from '../QueryExecutionPanel'; import type {QueryExecutionTab} from '../../../types/queryExecutionPanel'; @@ -9,6 +9,7 @@ import {lineSeriesMap} from '../../DashboardCharts/story/mockData'; import {demoEdges, demoNodes} from '../../../components/QueryGraph/QueryGraph.stories'; import {createQueryTrackerPanel} from './queryTrackerAdapter'; import type {ExampleQuery} from './queryTrackerAdapter'; +import {designTabs} from './designReference'; import './QueryExecutionPanel.stories.scss'; const results = { @@ -58,11 +59,11 @@ const allTabs: QueryExecutionTab[] = [ renderContent: ({active}) => , }, ]; -const meta: Meta = { +const meta: StoryMeta = { title: 'Widgets/QueryExecutionPanel', component: QueryExecutionPanel, tags: ['autodocs'], - args: {tabs: allTabs, execution: {startedAt: '28 Sep 2026, 12:07:27', author: 'admin'}}, + args: {tabs: designTabs, execution: {startedAt: '11 Dec 2025, 12:07:27', author: 'admin'}}, decorators: [ (Story) => (
@@ -74,6 +75,10 @@ const meta: Meta = { export default meta; type Story = StoryObj; export const Default: Story = {}; +export const Collapsed: Story = {args: {defaultCollapsed: true}}; +export const Statistics: Story = {args: {defaultActiveTab: 'statistics'}}; +export const Meta: Story = {args: {defaultActiveTab: 'meta'}}; +export const Info: Story = {args: {defaultActiveTab: 'info'}}; export const Progress: Story = {args: {defaultActiveTab: 'progress'}}; export const Charts: Story = {args: {defaultActiveTab: 'charts'}}; export const Narrow: Story = { @@ -87,12 +92,30 @@ export const Narrow: 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 Loading: Story = {args: {loading: true}}; +export const LoadingError: Story = { + render: function LoadingErrorExample(args) { + const [error, setError] = useState(true); + return setError(false)} />; + }, +}; +export const DesignReference: Story = { + decorators: [ + (Story) => ( +
+ +
+ ), + ], + render: function DesignReferenceExample(args) { + const [expanded, setExpanded] = useState(false); + return ( +
+ +
+ ); }, }; export const CustomOrder: Story = {args: {tabs: [allTabs[7], allTabs[1], allTabs[0], allTabs[5]]}}; @@ -130,6 +153,7 @@ export const DynamicMessages: Story = { }, }; export const Controlled: Story = { + args: {tabs: allTabs}, render: function ControlledExample(args) { const [activeTab, setActiveTab] = useState('result/0'); return ( @@ -181,21 +205,14 @@ export const QueryTrackerLifecycle: Story = { }, }; export const ApplicationLayout: Story = { + args: {tabs: allTabs}, render: function ApplicationLayoutExample(args) { const [expanded, setExpanded] = useState(false); - const [closed, setClosed] = useState(false); - return closed ? ( - - ) : ( + return (
- setClosed(true)} - /> +
); }, diff --git a/src/widgets/QueryExecutionPanel/story/designReference.tsx b/src/widgets/QueryExecutionPanel/story/designReference.tsx new file mode 100644 index 0000000..a3abf40 --- /dev/null +++ b/src/widgets/QueryExecutionPanel/story/designReference.tsx @@ -0,0 +1,199 @@ +import React, {useState} from 'react'; +import {ArrowDownToLine, ArrowUpFromLine, CircleCheck, Gear} from '@gravity-ui/icons'; +import {Button, Flex, Icon, Label} from '@gravity-ui/uikit'; +import type {QueryExecutionTab} from '../../../types/queryExecutionPanel'; +import type {QueryResultsProps} from '../../../types/queryResults'; +import {DashboardCharts} from '../../DashboardCharts'; +import type {DashboardChartsProps} from '../../DashboardCharts/types'; +import {demoEdges, demoNodes} from '../../../components/QueryGraph/QueryGraph.stories'; + +const result: QueryResultsProps> = { + columns: [ + {name: 'age', type: ['DataType', 'Int32'], width: 56}, + {name: 'ip', type: ['DataType', 'Utf8'], width: 160}, + {name: 'last_time_on_site', type: ['DataType', 'Double'], width: 128}, + {name: 'last_url', type: ['DataType', 'Utf8'], width: 400}, + {name: 'last_visit_time', type: ['DataType', 'Int64'], width: 128}, + {name: 'name', type: ['DataType', 'Utf8'], width: 128}, + {name: 'region', type: ['DataType', 'Int32'], width: 128}, + {name: 'user_agent', type: ['DataType', 'Utf8'], width: 128}, + ], + rows: ['Anya', 'Petr', 'Masha', 'Alena', 'Irina', 'Anna', 'Ivan'].map((name, index) => ({ + age: String([15, 25, 17, 5, 23, 13, 33][index]), + ip: ['95.106.17.32', '88.78.248.151', '93.94.183.63'][index % 3], + last_time_on_site: String([15.5, 5, 10.5, 22.5, 15, 19.5, 15.5][index]), + last_url: + index === 6 + ? 'https://maps.yandex.ru/?ll=37.671587%2C55.867321&z=15&text=search&sspn=0.105829%2C0.026809' + : 'https://yandex.ru/', + last_visit_time: String(1447027200 - index * 604800), + name, + region: String([213, 225, 1, 225, 2, 21, 125][index]), + user_agent: '', + })), + actions: ( + + + + + + ), +}; + +const chartItems: NonNullable = [0, 1].map((index) => ({ + id: `chart-${index}`, + chartData: { + title: {text: 'Push Process Errors'}, + legend: {enabled: false}, + xAxis: {type: 'datetime'}, + yAxis: [{min: 0}], + series: { + data: [ + { + type: 'line', + seriesId: `errors-${index}`, + name: 'Errors', + data: [ + 130, 100, 155, 175, 160, 100, 165, 180, 150, 190, 170, 125, 155, 40, 145, + 155, + ].map((y, i) => ({ + x: Date.UTC(2025, 11, 11, 1) + i * 20 * 60 * 1000, + y: y * 1e6, + })), + }, + ], + }, + }, +})); + +function ReferenceCharts() { + const [items, setItems] = useState(chartItems); + return ( + { + const series = item.chartData.series.data[0]; + return [series.seriesId, series]; + }), + ), + }} + chartItems={items} + onItemsChange={setItems} + defaultLayout={chartItems.map(({id}, i) => ({i: id, x: i * 2, y: 0, w: 2, h: 4}))} + /> + ); +} + +export const designTabs: QueryExecutionTab[] = [ + {id: 'result', type: 'result', props: result}, + {id: 'progress', type: 'progress', props: {graphProps: {nodes: demoNodes, edges: demoEdges}}}, + { + id: 'info', + type: 'info', + props: { + root: { + id: 'root', + severity: 'error', + message: 'Missing value for parameter: $h', + code: 2, + attributes: {parameter: '$h'}, + children: [ + { + id: 'child', + severity: 'error', + message: 'Missing value for parameter: $h', + code: 2, + attributes: {parameter: '$h'}, + }, + ], + }, + }, + }, + { + id: 'statistics', + type: 'statistics', + props: { + data: [ + { + id: 'group-1', + name: 'Value', + children: [ + { + id: 'group-2', + name: 'Value', + children: [{id: 'metric', name: 'Value', values: {}}], + }, + ], + }, + { + id: 'group-3', + name: 'Value', + children: [{id: 'metric-2', name: 'Value', values: {}}], + }, + { + id: 'group-4', + name: 'Value', + children: [{id: 'metric-3', name: 'Value', values: {}}], + }, + ], + defaultExpandedIds: ['group-1', 'group-2'], + }, + }, + { + id: 'meta', + type: 'meta', + props: { + data: { + groups: [ + { + items: [ + {name: 'ID', value: '0yui78900ghjkl'}, + { + name: 'Status', + value: ( + + ), + }, + {name: 'Execution mode', value: 'run'}, + {name: 'Created', value: '27.12.2024 18:56:32'}, + {name: 'Modified', value: '27.12.2024 18:56:32'}, + {name: 'Worker ID', value: '89d97cc9-43ba6556-9bb1c687-62567115'}, + {name: 'Worker PID', value: '587651'}, + { + name: 'Worker host', + value: 'yt-query-tracker-production-1.sas.yp-c.yandex.net:9028', + }, + { + name: 'Files', + value: ( + + ), + }, + ], + }, + ], + }, + }, + }, + { + id: 'charts', + type: 'charts', + renderContent: () => , + }, +]; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 1cfaf06..113a4f2 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -1,20 +1,10 @@ -export {QueriesHistory} from './QueriesHistory'; -export type {QueriesHistoryProps} from './QueriesHistory'; -export {SavedQueries} from './SavedQueries'; -export type {SavedQueriesProps} from './SavedQueries'; export {DashboardCharts} from './DashboardCharts'; -export {TutorialsHistory} from './TutorialsHistory'; -export type {TutorialsHistoryProps} from './TutorialsHistory'; -export { - QueriesNavigation, - createTableDetailConfig, - createNavigationDetailResolver, -} from './QueriesNavigation'; -export type { - QueriesNavigationProps, - CreateTableDetailConfigOptions, - NavigationPreviewResolver, - NavigationSchemaResolver, -} from './QueriesNavigation'; export {QueryExecutionPanel} from './QueryExecutionPanel'; export type {QueryExecutionPanelProps} from './QueryExecutionPanel'; +export {QueriesSidebar} from './QueriesSidebar'; +export type { + QueriesSidebarProps, + QueriesSidebarTab, + QueriesSidebarTabBase, + QueriesSidebarTabRenderContext, +} from './QueriesSidebar'; diff --git a/tests/components/NavigationHeader.test.tsx b/tests/components/NavigationHeader.test.tsx index 2224684..804082b 100644 --- a/tests/components/NavigationHeader.test.tsx +++ b/tests/components/NavigationHeader.test.tsx @@ -6,7 +6,7 @@ import {createRoot} from 'react-dom/client'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {NavigationHeader} from '../../src/modules/NavigationHeader/NavigationHeader'; import {NavigationDetail} from '../../src/modules/NavigationDetail/NavigationDetail'; -import {QueriesNavigation} from '../../src/widgets/QueriesNavigation/QueriesNavigation'; +import {QueriesNavigation} from '../../src/modules/QueriesNavigation/QueriesNavigation'; import type {NavigationHeaderAction} from '../../src/types/navigation'; vi.mock('../../src/components/Breadcrumbs', () => ({ diff --git a/tests/components/QueriesSidebar.test.tsx b/tests/components/QueriesSidebar.test.tsx new file mode 100644 index 0000000..b9853af --- /dev/null +++ b/tests/components/QueriesSidebar.test.tsx @@ -0,0 +1,260 @@ +// @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 {Tab, TabList, ThemeProvider, configure} from '@gravity-ui/uikit'; +import {QueriesSidebar} from '../../src/widgets/QueriesSidebar'; +import type {QueriesSidebarProps, QueriesSidebarTab} from '../../src/types/queriesSidebar'; +import {useLoadMoreSentinel} from '../../src/helpers/useLoadMoreSentinel'; + +vi.mock('../../src/modules/QueriesHistory', () => ({QueriesHistory: ListContent})); +vi.mock('../../src/modules/SavedQueries', () => ({SavedQueries: ListContent})); +vi.mock('../../src/modules/TutorialsHistory', () => ({TutorialsHistory: ListContent})); +vi.mock('../../src/modules/QueriesNavigation', () => ({ + QueriesNavigation: ({listState}: {listState: {onLoadMore?: () => void}}) => ( + + ), +})); + +function ListContent({onLoadMore}: {onLoadMore?: () => void}) { + const ref = useLoadMoreSentinel(true, onLoadMore, false, 1); + return ( +
+ +
+ ); +} +function Counter() { + const [count, setCount] = useState(0); + return ; +} +const custom = (id: string): QueriesSidebarTab => ({ + id, + type: 'custom', + title: id, + icon: *, + renderContent: ({active}) => ( +
+ +
+ ), +}); + +describe('QueriesSidebar', () => { + let container: HTMLDivElement; + let root: Root; + const changes = vi.fn(); + const observers: { + callback: IntersectionObserverCallback; + disconnect: ReturnType; + }[] = []; + function render(props: Partial = {}) { + act(() => + root.render( + + + + + , + ), + ); + } + const tab = (name: string) => + container.querySelector(`[role="tab"][aria-label="${name}"]`)!; + const panel = () => + container.querySelector( + '[role="tabpanel"]:not([hidden]), [role="region"]:not([hidden])', + )!; + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + configure({lang: 'en'}); + changes.mockClear(); + observers.length = 0; + vi.stubGlobal( + 'IntersectionObserver', + class { + disconnect = vi.fn(); + observe = vi.fn(); + constructor(callback: IntersectionObserverCallback) { + observers.push({callback, disconnect: this.disconnect}); + } + }, + ); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('selects defaults, switches by keyboard and falls back when a tab is disabled or removed', () => { + render({defaultActiveTab: 'b'}); + expect(tab('b').getAttribute('aria-selected')).toBe('true'); + expect(changes).not.toHaveBeenCalled(); + act(() => + tab('a').dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})), + ); + expect(changes.mock.calls).toEqual([['a']]); + render({tabs: [{...custom('a'), disabled: true}, custom('b')]}); + expect(tab('b').getAttribute('aria-selected')).toBe('true'); + expect(changes.mock.calls).toEqual([['a'], ['b']]); + act(() => tab('a').click()); + expect(changes).toHaveBeenCalledTimes(2); + render({tabs: [custom('c')]}); + expect(changes).toHaveBeenLastCalledWith('c'); + render({tabs: []}); + expect(panel()).toBeNull(); + render({tabs: [{...custom('c'), disabled: true}]}); + expect(panel()).toBeNull(); + }); + + it('uses external selection without echoing prop changes and leaves controlled selection to the caller', () => { + render({activeTab: 'b'}); + act(() => tab('a').click()); + expect(changes.mock.calls).toEqual([['a']]); + expect(tab('b').getAttribute('aria-selected')).toBe('true'); + render({activeTab: 'a'}); + render({activeTab: 'missing'}); + expect(tab('a').getAttribute('aria-selected')).toBe('true'); + expect(changes).toHaveBeenCalledTimes(1); + }); + + it('renders icons, supports arrow navigation and isolates nested tab selection', () => { + render(); + expect(tab('a').querySelector('[aria-hidden="true"]')?.textContent).toBe('*'); + act(() => tab('a').focus()); + act(() => + tab('a').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + code: 'ArrowRight', + bubbles: true, + }), + ), + ); + expect(document.activeElement).toBe(tab('b')); + act(() => tab('b').dispatchEvent(new KeyboardEvent('keydown', {key: ' ', bubbles: true}))); + expect(changes).toHaveBeenLastCalledWith('b'); + + const nestedChange = vi.fn(); + render({ + activeTab: 'nested', + tabs: [ + { + ...custom('nested'), + type: 'custom', + renderContent: () => ( + + Inner A + Inner B + + ), + }, + ], + }); + const innerTabs = Array.from(container.querySelectorAll('[role="tab"]')); + act(() => innerTabs.find((item) => item.textContent === 'Inner B')!.click()); + expect(nestedChange).toHaveBeenCalledWith('inner-b'); + expect(changes).toHaveBeenCalledTimes(1); + }); + + it('retains state, scroll and DOM when hiding tabs or switching externally, with valid ARIA associations', () => { + render({activeTab: 'a', header:
Product
}); + const first = panel(); + expect(tab('a').getAttribute('aria-controls')).toBe(first.id); + expect(first.getAttribute('aria-labelledby')).toBe(tab('a').id); + expect(container.querySelectorAll('button:not([role="tab"])')).toHaveLength(1); + first.scrollTop = 120; + act(() => first.querySelector('button')!.click()); + render({activeTab: 'a', hideTabs: true, header:
Product
}); + expect(panel()).toBe(first); + expect(panel().getAttribute('role')).toBe('region'); + expect(panel().getAttribute('aria-label')).toBe('a'); + expect(panel().hasAttribute('aria-labelledby')).toBe(false); + expect(container.querySelector('[role="tablist"]')).toBeNull(); + expect(container.textContent).toContain('Product'); + render({activeTab: 'b', hideTabs: true}); + expect(first.hidden).toBe(true); + expect(first.querySelector('[data-active]')!.getAttribute('data-active')).toBe('false'); + render({activeTab: 'a'}); + expect(panel()).toBe(first); + expect(panel().textContent).toContain('Count 1'); + expect(panel().scrollTop).toBe(120); + expect(changes).not.toHaveBeenCalled(); + render({activeTab: 'b', tabs: [custom('b')]}); + render({activeTab: 'a'}); + expect(panel()).not.toBe(first); + expect(panel().textContent).toContain('Count 0'); + }); + + it('supports hidden tabs in uncontrolled mode and filters invalid IDs', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render({ + hideTabs: true, + defaultActiveTab: 'b', + tabs: [custom(''), custom('a'), custom('a'), custom('b')], + }); + expect(panel().getAttribute('aria-label')).toBe('b'); + expect(container.querySelectorAll('[role="region"]')).toHaveLength(2); + expect(warn).toHaveBeenCalled(); + }); + + it.each(['history', 'saved', 'tutorials', 'navigation'] as const)( + 'pauses %s pagination, including queued observer callbacks, then resumes on return', + (type) => { + const load = vi.fn(); + const section: QueriesSidebarTab = + type === 'navigation' + ? { + id: 'list', + type, + props: { + location: {cluster: undefined, path: undefined}, + onUpdate: vi.fn(), + listState: {hasMore: true, onLoadMore: load}, + }, + } + : { + id: 'list', + type, + props: { + items: [], + search: {onUpdate: vi.fn()}, + hasMore: true, + onLoadMore: load, + }, + }; + const tabs = [section, custom('other')]; + const intersect = (index: number) => + act(() => + observers[index].callback( + [{isIntersecting: true}] as IntersectionObserverEntry[], + {} as IntersectionObserver, + ), + ); + render({tabs, activeTab: 'list'}); + const beforeHide = observers.length - 1; + intersect(beforeHide); + expect(load).toHaveBeenCalledTimes(1); + const first = panel(); + act(() => first.querySelector('button')!.click()); + render({tabs, activeTab: 'other'}); + expect(observers[beforeHide].disconnect).toHaveBeenCalled(); + intersect(beforeHide); + expect(load).toHaveBeenCalledTimes(1); + render({tabs, activeTab: 'list'}); + expect(panel()).toBe(first); + expect(panel().textContent).toContain('Count 1'); + intersect(observers.length - 1); + expect(load).toHaveBeenCalledTimes(2); + }, + ); +}); diff --git a/tests/components/QueryExecutionPanel.test.tsx b/tests/components/QueryExecutionPanel.test.tsx index 4c7cdc7..03566d8 100644 --- a/tests/components/QueryExecutionPanel.test.tsx +++ b/tests/components/QueryExecutionPanel.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom import React, {act, useState} from 'react'; -import {createRoot, type Root} from 'react-dom/client'; +import {type Root, createRoot} from 'react-dom/client'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {configure, ThemeProvider} from '@gravity-ui/uikit'; +import {ThemeProvider, configure} from '@gravity-ui/uikit'; import {QueryExecutionPanel} from '../../src/widgets/QueryExecutionPanel'; import type { QueryExecutionPanelProps, @@ -288,6 +288,36 @@ describe('QueryExecutionPanel', () => { click('Charts'); expect(panel().textContent).toContain('Count 1'); }); + it('preserves visited content through loading and retry, and pauses hidden content', () => { + const onRetry = vi.fn(); + render(); + act(() => panel().querySelector('button')!.click()); + render({loading: true, error: true, onRetry}); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(panel()).toBeNull(); + expect(container.querySelector('[data-active="false"]')).not.toBeNull(); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + render({error: true, onRetry}); + expect(container.querySelector('[role="alert"]')?.textContent).toContain('Error loading'); + act(() => container.querySelector('[role="alert"] button')!.click()); + expect(onRetry).toHaveBeenCalledOnce(); + render(); + expect(panel().textContent).toContain('Count 1'); + expect(selected()).toBe('a'); + expect(changes).not.toHaveBeenCalled(); + }); + it('does not mount unvisited content while loading', () => { + const renderContent = vi.fn(() => ); + const tabs: QueryExecutionTab[] = [ + {id: 'result', type: 'custom', title: 'Result', renderContent}, + ]; + render({tabs, loading: true}); + expect(renderContent).not.toHaveBeenCalled(); + render({tabs}); + expect(renderContent).toHaveBeenCalled(); + expect(panel().textContent).toContain('Count 0'); + }); it('renders metadata and delegates expand/close without remounting content', () => { const onClose = vi.fn(); const onExpandedChange = vi.fn(); @@ -308,8 +338,65 @@ describe('QueryExecutionPanel', () => { ); expect(onExpandedChange).toHaveBeenLastCalledWith(false); act(() => - container.querySelector('[aria-label="Close panel"]')!.click(), + container.querySelector('[aria-label="Collapse content"]')!.click(), ); expect(onClose).toHaveBeenCalledOnce(); }); + it('collapses to the header and restores the selected tab without losing its state', () => { + const onCollapsedChange = vi.fn(); + render({onCollapsedChange}); + click('b'); + act(() => panel().querySelector('button')!.click()); + act(() => + container.querySelector('[aria-label="Collapse content"]')!.click(), + ); + expect(panel()).toBeNull(); + expect(selected()).toBeUndefined(); + expect(container.querySelector('[role="tablist"]')).not.toBeNull(); + expect(container.querySelector('[data-active="true"]')).toBeNull(); + expect( + container.querySelector('.qp-query-execution-panel__content')?.hasAttribute('hidden'), + ).toBe(true); + expect(onCollapsedChange).toHaveBeenLastCalledWith(true); + act(() => + container.querySelector('[aria-label="Expand content"]')!.click(), + ); + expect(selected()).toBe('b'); + expect(panel().textContent).toContain('Count 1'); + expect(onCollapsedChange).toHaveBeenLastCalledWith(false); + }); + it('reveals a collapsed panel when selecting a tab, including with the keyboard', () => { + render({defaultCollapsed: true}); + expect(container.querySelector('[data-active]')).toBeNull(); + click('b'); + expect(selected()).toBe('b'); + expect(panel().textContent).toContain('Count 0'); + act(() => + container.querySelector('[aria-label="Collapse content"]')!.click(), + ); + act(() => + tab('c').dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})), + ); + expect(selected()).toBe('c'); + }); + it('keeps controlled collapse state until the application updates it and exits fullscreen', () => { + const onCollapsedChange = vi.fn(); + const onExpandedChange = vi.fn(); + render({collapsed: false, expanded: true, onCollapsedChange, onExpandedChange}); + act(() => + container.querySelector('[aria-label="Collapse content"]')!.click(), + ); + expect(onCollapsedChange).toHaveBeenCalledWith(true); + expect(onExpandedChange).toHaveBeenCalledWith(false); + expect(panel()).not.toBeNull(); + render({collapsed: true, expanded: false, onCollapsedChange, onExpandedChange}); + expect(panel()).toBeNull(); + act(() => + container.querySelector('[aria-label="Expand panel"]')!.click(), + ); + expect(onCollapsedChange).toHaveBeenLastCalledWith(false); + expect(onExpandedChange).toHaveBeenLastCalledWith(true); + render({collapsed: false, expanded: true, onCollapsedChange, onExpandedChange}); + expect(panel()).not.toBeNull(); + }); }); diff --git a/tests/components/QueryProgress.test.tsx b/tests/components/QueryProgress.test.tsx index aea5653..41db649 100644 --- a/tests/components/QueryProgress.test.tsx +++ b/tests/components/QueryProgress.test.tsx @@ -12,6 +12,7 @@ vi.mock('@gravity-ui/uikit', () => { Flex: ({children}: any) =>
{children}
, Icon: () => null, Loader: () => null, + Label: ({children}: {children: React.ReactNode}) => {children}, SegmentedRadioGroup: Group, }; }); diff --git a/tests/components/SavedQueries.test.tsx b/tests/components/SavedQueries.test.tsx index 55d5d5a..dc35459 100644 --- a/tests/components/SavedQueries.test.tsx +++ b/tests/components/SavedQueries.test.tsx @@ -2,9 +2,10 @@ import React, {act} from 'react'; import {type Root, createRoot} from 'react-dom/client'; -import {ThemeProvider} from '@gravity-ui/uikit'; +import {ThemeProvider, configure} from '@gravity-ui/uikit'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {SavedQueries, type SavedQueriesProps} from '../../src/widgets/SavedQueries'; +import {SavedQueries, type SavedQueriesProps} from '../../src/modules/SavedQueries'; +import {QueriesHistory} from '../../src/modules/QueriesHistory'; import type {SavedQuery} from '../../src/types/savedQueries'; import type {QueryListItem, QueryListRowRenderData} from '../../src/types/queryList'; @@ -116,6 +117,7 @@ describe('SavedQueries integration', () => { }; beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; + configure({lang: 'en'}); vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); IntersectionObserverMock.current = undefined; routerLink.mockClear(); @@ -129,6 +131,50 @@ describe('SavedQueries integration', () => { vi.unstubAllGlobals(); }); + it('places the history fields selector beside search and retains field visibility control', () => { + const renderHistory = (value?: 'engine'[]) => + act(() => + root.render( + + + , + ), + ); + renderHistory(['engine']); + const selector = container.querySelector('button[aria-label="Configure visible fields"]'); + const searchRow = container.querySelector('.qp-search-with-buttons'); + expect(selector).not.toBeNull(); + expect(searchRow?.contains(selector)).toBe(true); + expect(searchRow?.querySelector('input')).not.toBeNull(); + expect( + container.querySelectorAll('button[aria-label="Configure visible fields"]'), + ).toHaveLength(1); + expect(container.textContent).toContain('YQL'); + renderHistory([]); + expect(container.textContent).not.toContain('YQL'); + renderHistory(); + expect(container.querySelector('button[aria-label="Configure visible fields"]')).toBeNull(); + }); + it('connects initial loading, page loading and the load callback', () => { const onLoadMore = vi.fn(); render({items: [], loading: true, hasMore: true, onLoadMore}); @@ -185,15 +231,9 @@ describe('SavedQueries integration', () => { expect(container.querySelector('[data-testid="query"]')?.textContent).toContain( 'SELECT 1', ); - render({ - search, - renderLink: routerLink, - renderAuthor, - visibleFields: {fields: [], value: [], onChange: vi.fn()}, - }); - expect(container.textContent).not.toContain('Analytics'); - expect(container.textContent).not.toContain('SQL'); - expect(container.textContent).not.toContain('2026'); + expect(container.querySelector('.qp-fields-selector')).toBeNull(); + expect(container.querySelector('button[aria-label="Configure visible fields"]')).toBeNull(); + expect(container.textContent).toContain('2026'); render({search}); expect(container.querySelector('a')?.getAttribute('href')).toBe(FIRST.href); routerLink.mockClear(); diff --git a/tests/components/TutorialRows.test.tsx b/tests/components/TutorialRows.test.tsx index 562b521..a81dd1c 100644 --- a/tests/components/TutorialRows.test.tsx +++ b/tests/components/TutorialRows.test.tsx @@ -6,7 +6,7 @@ import {createRoot} from 'react-dom/client'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import type {QueryListLinkRenderer, QueryListRowRenderData} from '../../src/types/queryList'; import type {TutorialHistoryRow} from '../../src/types/tutorial'; -import {TutorialRowContent} from '../../src/widgets/TutorialsHistory/TutorialRowContent'; +import {TutorialRowContent} from '../../src/modules/TutorialsHistory/TutorialRowContent'; vi.mock('@gravity-ui/uikit', async () => { const ReactModule = await import('react'); diff --git a/tests/components/TutorialsHistory.test.tsx b/tests/components/TutorialsHistory.test.tsx index 12adf1f..b90fbf3 100644 --- a/tests/components/TutorialsHistory.test.tsx +++ b/tests/components/TutorialsHistory.test.tsx @@ -7,7 +7,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import type {RowsListProps} from '../../src/modules/RowsList'; import type {QueryListLinkRenderer, QueryListRowRenderData} from '../../src/types/queryList'; import type {TutorialHistoryRow} from '../../src/types/tutorial'; -import {TutorialsHistory} from '../../src/widgets/TutorialsHistory/TutorialsHistory'; +import {TutorialsHistory} from '../../src/modules/TutorialsHistory/TutorialsHistory'; const rowsListState = vi.hoisted(() => ({ mountCount: 0, @@ -38,10 +38,10 @@ vi.mock('../../src/modules/RowsList', async () => { }, }; }); -vi.mock('../../src/widgets/TutorialsHistory/i18n', () => ({ +vi.mock('../../src/modules/TutorialsHistory/i18n', () => ({ default: () => 'Tutorials', })); -vi.mock('../../src/widgets/TutorialsHistory/TutorialRowContent', () => ({ +vi.mock('../../src/modules/TutorialsHistory/TutorialRowContent', () => ({ TutorialRowContent: () => null, })); diff --git a/tests/tree-shaking/check.mjs b/tests/tree-shaking/check.mjs index ca0f9bb..05240e4 100644 --- a/tests/tree-shaking/check.mjs +++ b/tests/tree-shaking/check.mjs @@ -17,10 +17,12 @@ const widgets = [ 'QueryResults', 'DashboardCharts', 'QueryExecutionPanel', + 'QueriesSidebar', ]; -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 sections = new Set(widgets.slice(0, 4)); +const unitPath = (name) => + `${name === 'QueryResults' || sections.has(name) ? 'modules' : 'widgets'}/${name}`; +const historyWidgets = new Set([...widgets.slice(0, 3), 'QueriesSidebar']); const contributions = [ 'clickhouse/clickhouse.contribution.js', 'yql/yql.contribution.js', @@ -103,7 +105,7 @@ function checkTypes(specifier, widget, mode) { root, 'build', requireMode ? 'cjs' : 'esm', - specifier === packageName ? '' : entryPath(specifier), + specifier === packageName ? '' : unitPath(widget), 'index.d.ts', ); assert.equal(resolved.resolvedFileName, expected); @@ -135,6 +137,52 @@ const tabs: QueryExecutionTab[] = [ }}, ]; const props: QueryExecutionPanelProps = {tabs}; +`; + } + if (widget === 'QueriesSidebar') { + contents += ` +import type {QueriesSidebarProps, QueriesSidebarTab} from '${specifier}'; +import type {QueryHistoryRow, SavedQuery, NavigationItem, NavigationCluster, TutorialHistoryRow} from '${packageName}'; +type History = QueryHistoryRow & {custom: number}; +type Saved = SavedQuery & {custom: number}; +type Item = NavigationItem & {custom: number}; +type Cluster = NavigationCluster & {custom: number}; +type Tutorial = TutorialHistoryRow & {custom: number}; +const tabs: QueriesSidebarTab[] = [ + {id: 'h', type: 'history', props: {items: [], search: {onUpdate() {}}, getRowActions: (row) => { + const value: number = row.custom; + // @ts-expect-error Preserve extended history row types. + const invalid: string = row.custom; + return []; + }}}, + {id: 's', type: 'saved', props: {items: [], search: {onUpdate() {}}, renderAuthor: (row) => { + const value: number = row.custom; + // @ts-expect-error Preserve extended saved row types. + const invalid: string = row.custom; + return value; + }}}, + {id: 'n', type: 'navigation', props: { + location: {cluster: undefined, path: undefined}, onUpdate() {}, + onItemClick: (item) => { + const value: number = item.custom; + // @ts-expect-error Preserve extended navigation item types. + const invalid: string = item.custom; + }, + onClusterClick: (cluster) => { + const value: number = cluster.custom; + // @ts-expect-error Preserve extended cluster types. + const invalid: string = cluster.custom; + }, + }}, + {id: 't', type: 'tutorials', props: {items: [], search: {onUpdate() {}}, renderRowItem: ({item}) => { + if ('header' in item) return item.header; + const value: number = item.custom; + // @ts-expect-error Preserve extended tutorial types. + const invalid: string = item.custom; + return value; + }}}, +]; +const props: QueriesSidebarProps = {tabs, hideTabs: true, activeTab: 'h'}; `; } const host = ts.createCompilerHost(compilerOptions); @@ -157,10 +205,14 @@ const props: QueryExecutionPanelProps = {tabs}; } for (const widget of widgets) { - for (const specifier of [packageName, `${packageName}/${unitPath(widget)}`]) { + for (const specifier of [ + packageName, + `${packageName}/${unitPath(widget)}`, + ...(sections.has(widget) ? [`${packageName}/widgets/${widget}`] : []), + ]) { const esmEntry = fileURLToPath(import.meta.resolve(specifier)); const cjsEntry = require.resolve(specifier); - const suffix = specifier === packageName ? 'index.js' : `${entryPath(specifier)}/index.js`; + const suffix = specifier === packageName ? 'index.js' : `${unitPath(widget)}/index.js`; assert.equal(esmEntry, path.join(root, 'build/esm', suffix)); assert.equal(cjsEntry, path.join(root, 'build/cjs', suffix)); const result = await build({ @@ -194,7 +246,9 @@ for (const widget of widgets) { for (const other of widgets) { assert.equal( included.has(`build/esm/${unitPath(other)}/${other}.js`), - other === widget || (widget === 'QueryExecutionPanel' && other === 'QueryResults'), + other === widget || + (widget === 'QueriesSidebar' && sections.has(other)) || + (widget === 'QueryExecutionPanel' && other === 'QueryResults'), `${specifier}: unexpected inclusion/exclusion of ${other}`, ); // esbuild can extract CSS from unused root re-exports. An individual @@ -203,6 +257,7 @@ for (const widget of widgets) { assert.equal( included.has(`build/esm/${unitPath(other)}/${other}.css`), other === widget || + (widget === 'QueriesSidebar' && sections.has(other)) || (widget === 'QueryExecutionPanel' && other === 'QueryResults'), `${specifier}: unexpected inclusion/exclusion of ${other} CSS`, );