diff --git a/.agents/skills/code-review-checklist/SKILL.md b/.agents/skills/code-review-checklist/SKILL.md
index a58a208..4bdfe71 100644
--- a/.agents/skills/code-review-checklist/SKILL.md
+++ b/.agents/skills/code-review-checklist/SKILL.md
@@ -80,6 +80,8 @@ Do not report style-only nits. Order findings from highest to lowest severity.
## Response Format
+Attach a rationale to every conclusion, including findings, existing-comment statuses, and a conclusion that no issues were found. Cite concrete evidence (file and line references, relevant contracts, or check results) and explain how it supports the conclusion. Distinguish verified facts from assumptions; for a no-findings conclusion, state the reviewed scope and verification limits rather than implying proof of correctness.
+
Lead with confirmed findings. For each finding include:
- **Location:** `path/to/file:line`
@@ -87,6 +89,7 @@ Lead with confirmed findings. For each finding include:
- **Finding:** concise description of the defect
- **Condition:** input or execution path that triggers it
- **Impact:** observable consequence
+- **Rationale:** concrete evidence and the causal link between the triggering condition and the impact
- **Recommendation:** specific fix or mitigation
For existing PR comments, also include **Status** using the vocabulary required by `github-pr-review`.
diff --git a/.agents/skills/github-pr-review/SKILL.md b/.agents/skills/github-pr-review/SKILL.md
index 40cf047..cc3d3ae 100644
--- a/.agents/skills/github-pr-review/SKILL.md
+++ b/.agents/skills/github-pr-review/SKILL.md
@@ -19,6 +19,8 @@ If the PR already contains comments from other reviewers:
1. **Relevance:** Determine whether each comment still applies. Mark it if the issue has already been fixed in newer commits.
2. **Validity:** Check each comment against the current checklist. Point out any comment that conflicts with the project's standards or the checklist.
+Attach a rationale to every conclusion using the checklist's evidence requirements. For each comment status, cite the current code, relevant change, or project rule that justifies marking it Confirmed, Fixed, or Outdated; the status alone is not an explanation.
+
## Response Format
Use the following format for every finding, whether new or an existing PR comment:
@@ -27,4 +29,5 @@ Use the following format for every finding, whether new or an existing PR commen
- **Status:** (New / Confirmed / Fixed / Outdated)
- **Severity:** (High / Medium / Low)
- **Issue:** A concise description of the problem based on the checklist.
+- **Rationale:** Concrete evidence with file and line references, relevant contracts, or check results, explaining why the issue and its status follow from that evidence.
- **Recommendation:** A concrete example of the corrected code.
diff --git a/AGENTS.md b/AGENTS.md
index 359422d..cf06c9b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,29 @@
Instructions for agents on the code structure of `@gravity-ui/querieskit`.
+## Multi-agent workflow
+
+- Proactively use subagents when independent tasks can run in parallel
+ and materially improve speed or review quality. No separate user
+ request is needed.
+- Use up to 3 subagents concurrently across the entire task.
+ Choose the number based on useful independent work.
+- Keep small, straightforward changes and tightly coupled work
+ in the main agent.
+- Give each subagent a bounded task, relevant context, expected output,
+ and explicit permission to edit or instructions to remain read-only.
+- For parallel implementation, assign non-overlapping file ownership.
+ Agree on shared props and public types before splitting dependent work.
+ Keep shared barrels and integration changes with the main agent.
+- Useful delegated tasks in this repository include:
+ - Checking API levels, import direction, public types, and barrel usage.
+ - Reviewing style ownership, Gravity UI usage, and i18n conventions.
+ - Investigating test failures or checking affected Storybook scenarios.
+- The main agent should continue complementary work while subagents run.
+ Avoid duplicating their tasks.
+- Before finishing, inspect delegated changes, reconcile findings,
+ and run the appropriate checks on the integrated result.
+
## Three levels of the public API
The library is organized into three levels of abstraction. Every new and existing widget must follow this structure:
diff --git a/src/components/EmptyContent/EmptyContent.tsx b/src/components/EmptyContent/EmptyContent.tsx
index e3aef1d..add029f 100644
--- a/src/components/EmptyContent/EmptyContent.tsx
+++ b/src/components/EmptyContent/EmptyContent.tsx
@@ -4,12 +4,13 @@ import {Folder, NoSearchResults} from '@gravity-ui/illustrations';
import cn from 'bem-cn-lite';
import i18n from './i18n';
import './EmptyContent.scss';
+import type {EmptyContentTextOverrides} from '../../types/listPanel';
const block = cn('qp-empty-content');
export type EmptyContentVariant = 'no-files' | 'no-clusters' | 'nothing-found' | 'no-data';
-export type EmptyContentProps = {
+export type EmptyContentProps = EmptyContentTextOverrides & {
variant: EmptyContentVariant;
className?: string;
};
@@ -40,8 +41,20 @@ const CONTENT_BY_VARIANT: Record = {
},
};
-export const EmptyContent: FC = ({variant, className}) => {
- const {icon: Icon, title, description} = CONTENT_BY_VARIANT[variant];
+export const EmptyContent: FC = ({
+ variant,
+ className,
+ title: titleOverride,
+ description: descriptionOverride,
+}) => {
+ const {
+ icon: Icon,
+ title: defaultTitle,
+ description: defaultDescription,
+ } = CONTENT_BY_VARIANT[variant];
+ const title = titleOverride === undefined ? defaultTitle : titleOverride;
+ const description =
+ descriptionOverride === undefined ? defaultDescription : descriptionOverride;
return (
diff --git a/src/components/HistoryFilter/HistoryFilter.tsx b/src/components/HistoryFilter/HistoryFilter.tsx
index 5537934..432f88f 100644
--- a/src/components/HistoryFilter/HistoryFilter.tsx
+++ b/src/components/HistoryFilter/HistoryFilter.tsx
@@ -3,7 +3,7 @@ import {Button, Flex, Icon, Popup} from '@gravity-ui/uikit';
import FunnelIcon from '@gravity-ui/icons/svgs/funnel.svg';
import ArrowRotateLeftIcon from '@gravity-ui/icons/svgs/arrow-rotate-left.svg';
import {useToggle} from '../../helpers/useToggle';
-import {QueryListFilterConfig} from '../../types/queryList';
+import type {QueryListFilterConfig} from '../../types/queryList';
import cn from 'bem-cn-lite';
import {SimpleForm} from '../SimpleForm';
import i18n from './i18n';
@@ -11,24 +11,29 @@ import './HistoryFilter.scss';
const block = cn('qp-history-filter');
-export const HistoryFilter: FC = ({
+export const HistoryFilter: FC = ({
fields = [],
values,
initialValues,
isChanged,
onApply,
onReset,
+ buttonView = 'normal',
}) => {
const [buttonElement, setButtonElement] = useState(null);
const [open, toggleOpen] = useToggle(false);
const valuesRef = useRef>(values ?? initialValues ?? {});
+ const normalView = isChanged ? 'action' : 'normal';
+ const flatView = isChanged ? 'flat-info' : 'flat';
return (
<>
diff --git a/src/components/NavigationItemRow/NavigationItemRow.tsx b/src/components/NavigationItemRow/NavigationItemRow.tsx
index d20895f..6e54929 100644
--- a/src/components/NavigationItemRow/NavigationItemRow.tsx
+++ b/src/components/NavigationItemRow/NavigationItemRow.tsx
@@ -14,9 +14,14 @@ export type NavigationItemRowProps = {
export const NavigationItemRow: FC = ({item}) => {
return (
- {item.icon ?? (
-
- )}
+
+ {item.icon ?? (
+
+ )}
+
{
+ if (value === undefined || value === null) {
+ return '';
+ }
+ if (typeof value === 'string') {
+ return value;
+ }
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+ return '';
+};
+
+export function filterNavigationRows(
+ rows: TRow[],
+ columns: string[],
+ search?: string,
+): TRow[] {
+ const query = search?.trim().toLowerCase();
+ if (!query) {
+ return rows;
+ }
+
+ return rows.filter((row) =>
+ columns.some((column) => stringifyCell(row[column]).toLowerCase().includes(query)),
+ );
+}
diff --git a/src/index.ts b/src/index.ts
index 5daf286..eca0311 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,7 @@ export * from './components';
export * from './modules';
export * from './widgets';
export * from './types/queryList';
+export * from './types/listPanel';
export * from './types/history';
export type {SavedQuery} from './types/savedQueries';
export * from './types/tutorial';
diff --git a/src/modules/HistoryHeader/HistoryHeader.scss b/src/modules/HistoryHeader/HistoryHeader.scss
new file mode 100644
index 0000000..5977d36
--- /dev/null
+++ b/src/modules/HistoryHeader/HistoryHeader.scss
@@ -0,0 +1,6 @@
+.qp-history-header {
+ &_variant_tutorials,
+ &_variant_panel {
+ --qp-search-with-buttons-inner-buttons-margin-right: 2px;
+ }
+}
diff --git a/src/modules/HistoryHeader/HistoryHeader.tsx b/src/modules/HistoryHeader/HistoryHeader.tsx
index 07fd5e0..74d9222 100644
--- a/src/modules/HistoryHeader/HistoryHeader.tsx
+++ b/src/modules/HistoryHeader/HistoryHeader.tsx
@@ -2,7 +2,13 @@ import React, {FC, useEffect, useState} from 'react';
import {FullSearchToggleButton} from './internal/FullSearchToggleButton';
import {HistoryFilter} from '../../components/HistoryFilter';
import {SearchWithButtons} from '../../components/SearchWithButtons';
-import {QueryListFilterConfig} from '../../types/queryList';
+import type {QueryListFilterConfig} from '../../types/queryList';
+import i18n from './i18n';
+import cn from 'bem-cn-lite';
+import type {HistoryPanelVariant} from '../../types/listPanel';
+import './HistoryHeader.scss';
+
+const block = cn('qp-history-header');
type Props = {
search?: string;
@@ -13,6 +19,7 @@ type Props = {
actions?: React.ReactNode;
onUpdate: (data: {value: string; fullSearch: boolean}) => void;
className?: string;
+ variant?: HistoryPanelVariant;
};
export const HistoryHeader: FC = ({
@@ -24,7 +31,9 @@ export const HistoryHeader: FC = ({
actions,
onUpdate,
className,
+ variant = 'default',
}) => {
+ const isPanel = variant !== 'default';
const [searchValue, setSearchValue] = useState(search || '');
const [isFullSearch, setFullSearch] = useState(fullSearchAvailable && Boolean(fullSearch));
@@ -46,7 +55,9 @@ export const HistoryHeader: FC = ({
return (
= ({
? [
,
@@ -62,7 +74,15 @@ export const HistoryHeader: FC = ({
: undefined
}
endButtons={[
- ...(filter ? [ ] : []),
+ ...(filter
+ ? [
+ ,
+ ]
+ : []),
...(actions ? [{actions} ] : []),
]}
/>
diff --git a/src/modules/HistoryHeader/i18n/dicts.ts b/src/modules/HistoryHeader/i18n/dicts.ts
new file mode 100644
index 0000000..4fa3a86
--- /dev/null
+++ b/src/modules/HistoryHeader/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/modules/HistoryHeader/i18n/en.json b/src/modules/HistoryHeader/i18n/en.json
new file mode 100644
index 0000000..866385b
--- /dev/null
+++ b/src/modules/HistoryHeader/i18n/en.json
@@ -0,0 +1,4 @@
+{
+ "field_search": "Search",
+ "action_full-search": "Search in query text"
+}
diff --git a/src/modules/HistoryHeader/i18n/index.ts b/src/modules/HistoryHeader/i18n/index.ts
new file mode 100644
index 0000000..2268b31
--- /dev/null
+++ b/src/modules/HistoryHeader/i18n/index.ts
@@ -0,0 +1,4 @@
+import {addI18Keysets} from '../../../i18n';
+import dicts from './dicts';
+
+export default addI18Keysets('qp:history-header', dicts);
diff --git a/src/modules/HistoryHeader/i18n/ru.json b/src/modules/HistoryHeader/i18n/ru.json
new file mode 100644
index 0000000..a541e9c
--- /dev/null
+++ b/src/modules/HistoryHeader/i18n/ru.json
@@ -0,0 +1,4 @@
+{
+ "field_search": "Поиск",
+ "action_full-search": "Искать в тексте запроса"
+}
diff --git a/src/modules/HistoryHeader/internal/FullSearchToggleButton.tsx b/src/modules/HistoryHeader/internal/FullSearchToggleButton.tsx
index 3215123..11656e3 100644
--- a/src/modules/HistoryHeader/internal/FullSearchToggleButton.tsx
+++ b/src/modules/HistoryHeader/internal/FullSearchToggleButton.tsx
@@ -1,16 +1,30 @@
import React, {FC} from 'react';
import {Button, Icon} from '@gravity-ui/uikit';
+import CodeIcon from '@gravity-ui/icons/svgs/code.svg';
import ChevronsExpandHorizontalIcon from '@gravity-ui/icons/svgs/chevrons-expand-horizontal.svg';
+import i18n from '../i18n';
type Props = {
active?: boolean;
onClick: () => void;
+ appearance?: 'default' | 'flat';
};
-export const FullSearchToggleButton: FC = ({active, onClick}) => {
+export const FullSearchToggleButton: FC = ({active, onClick, appearance = 'default'}) => {
+ const defaultView = active ? 'action' : 'normal';
return (
-
-
+
+
);
};
diff --git a/src/modules/HistoryLayout/HistoryLayout.scss b/src/modules/HistoryLayout/HistoryLayout.scss
index 4a9f5c6..437a380 100644
--- a/src/modules/HistoryLayout/HistoryLayout.scss
+++ b/src/modules/HistoryLayout/HistoryLayout.scss
@@ -4,4 +4,28 @@
&__header {
padding: 0 12px;
}
+
+ &_variant_tutorials,
+ &_variant_panel {
+ min-height: 0;
+
+ .qp-history-layout__header {
+ padding: 0;
+ flex-shrink: 0;
+ }
+
+ .qp-history-layout__service {
+ min-height: 32px;
+ padding: 2px 8px 2px 12px;
+ box-sizing: border-box;
+ }
+
+ .qp-history-layout__title {
+ padding: 8px 12px 2px;
+ }
+
+ .qp-history-layout__controls {
+ padding: 4px 8px 4px 12px;
+ }
+ }
}
diff --git a/src/modules/HistoryLayout/HistoryLayout.tsx b/src/modules/HistoryLayout/HistoryLayout.tsx
index e8437bb..9668577 100644
--- a/src/modules/HistoryLayout/HistoryLayout.tsx
+++ b/src/modules/HistoryLayout/HistoryLayout.tsx
@@ -1,6 +1,7 @@
import React, {FC, ReactNode} from 'react';
import {Flex, Text} from '@gravity-ui/uikit';
import cn from 'bem-cn-lite';
+import type {HistoryPanelVariant} from '../../types/listPanel';
import './HistoryLayout.scss';
const block = cn('qp-history-layout');
@@ -12,6 +13,7 @@ export type HistoryLayoutProps = {
header?: ReactNode;
footer?: ReactNode;
className?: string;
+ variant?: HistoryPanelVariant;
children: ReactNode;
};
@@ -23,18 +25,28 @@ export const HistoryLayout: FC = ({
footer,
className,
children,
+ variant = 'default',
}) => {
+ const isPanel = variant !== 'default';
return (
-
-
+
+
{(logo || actions) && (
-
+
{logo}
{actions}
)}
- {title !== null && {title} }
- {header}
+ {title !== null && (
+
+ {title}
+
+ )}
+ {isPanel && header ? {header}
: header}
{children}
{footer}
diff --git a/src/modules/NavigationDetail/NavigationDetail.scss b/src/modules/NavigationDetail/NavigationDetail.scss
index 23f3f79..2cecc9f 100644
--- a/src/modules/NavigationDetail/NavigationDetail.scss
+++ b/src/modules/NavigationDetail/NavigationDetail.scss
@@ -15,6 +15,7 @@
}
&__tabs {
+ min-width: 0;
overflow-x: auto;
}
diff --git a/src/modules/NavigationDetail/NavigationDetail.tsx b/src/modules/NavigationDetail/NavigationDetail.tsx
index fa16df4..7e0b7cd 100644
--- a/src/modules/NavigationDetail/NavigationDetail.tsx
+++ b/src/modules/NavigationDetail/NavigationDetail.tsx
@@ -107,12 +107,13 @@ export const NavigationDetail: React.FC = ({
/>
{hasTabs ? (
<>
-
+
+
+
{config.hasSearch && (
{
- if (value === undefined || value === null) {
- return '';
- }
- if (typeof value === 'string') {
- return value;
- }
- if (typeof value === 'number' || typeof value === 'boolean') {
- return String(value);
- }
- return '';
-};
-
-export function filterPreviewRows(
- rows: TRow[],
- columns: string[],
- search?: string,
-): TRow[] {
- const query = search?.trim().toLowerCase();
- if (!query) {
- return rows;
- }
-
- return rows.filter((row) =>
- columns.some((column) => stringifyCell(row[column]).toLowerCase().includes(query)),
- );
-}
+export {filterNavigationRows as filterPreviewRows} from '../../../helpers/filterNavigationRows';
diff --git a/src/modules/NavigationView/NavigationView.tsx b/src/modules/NavigationView/NavigationView.tsx
index 8f5b8f0..333f477 100644
--- a/src/modules/NavigationView/NavigationView.tsx
+++ b/src/modules/NavigationView/NavigationView.tsx
@@ -1,11 +1,13 @@
-import React from 'react';
+import React, {useState} from 'react';
import {Flex, Text} from '@gravity-ui/uikit';
import cn from 'bem-cn-lite';
import type {Column} from '../../components/DataTable';
import {EmptyContent} from '../../components/EmptyContent';
import {SkeletonRows} from '../../components/SkeletonRows';
+import {SearchWithButtons} from '../../components/SearchWithButtons';
import type {NavigationViewConfig, NavigationViewRow} from '../../types/navigation';
import {NavigationViewSectionItem} from './internal/NavigationViewSectionItem';
+import i18n from './i18n';
import './NavigationView.scss';
const block = cn('qp-navigation-view');
@@ -18,16 +20,31 @@ export type NavigationViewViewConfig = {
data: NavigationViewConfig;
view?: NavigationViewViewConfig;
+ search?: string;
+ onSearchUpdate?: (value: string) => void;
+ searchPlaceholder?: string;
className?: string;
};
export function NavigationView({
data,
view,
+ search: searchProp,
+ onSearchUpdate,
+ searchPlaceholder,
className,
}: NavigationViewProps) {
const {sections, loading, loaded, errorContent} = data;
const {tableColumns, extraColumns} = view ?? {};
+ const [searchState, setSearchState] = useState('');
+ const search = searchProp ?? searchState;
+
+ const handleSearchUpdate = (value: string) => {
+ if (searchProp === undefined) {
+ setSearchState(value);
+ }
+ onSearchUpdate?.(value);
+ };
if (errorContent) {
return (
@@ -37,26 +54,31 @@ export function NavigationView
- );
- }
-
- if (sections.length === 0) {
- return ;
+ content = ;
+ } else if (sections.length === 0) {
+ content = ;
+ } else {
+ content = sections.map((section) => (
+
+ key={section.id}
+ section={section}
+ search={search}
+ tableColumns={tableColumns}
+ extraColumns={extraColumns}
+ />
+ ));
}
return (
- {sections.map((section) => (
-
- key={section.id}
- section={section}
- tableColumns={tableColumns}
- extraColumns={extraColumns}
- />
- ))}
+
+ {content}
);
}
diff --git a/src/modules/NavigationView/i18n/en.json b/src/modules/NavigationView/i18n/en.json
index bb41253..b2ea5cf 100644
--- a/src/modules/NavigationView/i18n/en.json
+++ b/src/modules/NavigationView/i18n/en.json
@@ -1,3 +1,4 @@
{
- "value_empty": "—"
+ "value_empty": "—",
+ "field_search-placeholder": "Search"
}
diff --git a/src/modules/NavigationView/i18n/ru.json b/src/modules/NavigationView/i18n/ru.json
index bb41253..324ef76 100644
--- a/src/modules/NavigationView/i18n/ru.json
+++ b/src/modules/NavigationView/i18n/ru.json
@@ -1,3 +1,4 @@
{
- "value_empty": "—"
+ "value_empty": "—",
+ "field_search-placeholder": "Поиск"
}
diff --git a/src/modules/NavigationView/internal/NavigationViewSectionItem.tsx b/src/modules/NavigationView/internal/NavigationViewSectionItem.tsx
index 7dbb244..fa9777e 100644
--- a/src/modules/NavigationView/internal/NavigationViewSectionItem.tsx
+++ b/src/modules/NavigationView/internal/NavigationViewSectionItem.tsx
@@ -4,6 +4,7 @@ import cn from 'bem-cn-lite';
import type {Column} from '../../../components/DataTable';
import {DataTable} from '../../../components/DataTable';
import {NavigationActionButtons} from '../../../components/NavigationActionButtons';
+import {filterNavigationRows} from '../../../helpers/filterNavigationRows';
import type {NavigationViewRow, NavigationViewSection} from '../../../types/navigation';
import {buildViewColumns} from '../helpers/buildViewColumns';
import i18n from '../i18n';
@@ -12,12 +13,14 @@ const block = cn('qp-navigation-view');
export type NavigationViewSectionItemProps = {
section: NavigationViewSection;
+ search?: string;
tableColumns?: Array>;
extraColumns?: Array>;
};
export function NavigationViewSectionItem({
section,
+ search,
tableColumns,
extraColumns,
}: NavigationViewSectionItemProps) {
@@ -27,6 +30,10 @@ export function NavigationViewSectionItem(section.columns, i18n), ...(extraColumns ?? [])];
}, [tableColumns, extraColumns, section.columns]);
+ const rows = useMemo(
+ () => filterNavigationRows(section.rows, section.columns, search),
+ [section.rows, section.columns, search],
+ );
return (
columns={resolvedColumns}
- data={section.rows}
+ data={rows}
+ emptyVariant={search?.trim() ? 'nothing-found' : 'no-data'}
loading={section.loading}
loaded={section.loaded}
settings={{displayIndices: false}}
diff --git a/src/modules/NavigationView/story/NavigationView.stories.tsx b/src/modules/NavigationView/story/NavigationView.stories.tsx
index dd38c68..7394bf4 100644
--- a/src/modules/NavigationView/story/NavigationView.stories.tsx
+++ b/src/modules/NavigationView/story/NavigationView.stories.tsx
@@ -35,6 +35,22 @@ export const Default: Story = {
},
};
+const SearchStory = () => {
+ const [search, setSearch] = useState('field_2');
+
+ return (
+
+ );
+};
+
+export const Search: Story = {
+ render: () => ,
+};
+
export const Loading: Story = {
args: {
data: {sections: [], loading: true},
diff --git a/src/modules/QueriesHistory/QueriesHistory.tsx b/src/modules/QueriesHistory/QueriesHistory.tsx
index c086f3d..7868d9e 100644
--- a/src/modules/QueriesHistory/QueriesHistory.tsx
+++ b/src/modules/QueriesHistory/QueriesHistory.tsx
@@ -1,9 +1,11 @@
import React from 'react';
import cn from 'bem-cn-lite';
+import {EmptyContent} from '../../components/EmptyContent';
import {QueriesList} from '../../modules/QueriesList';
import type {QueryHistoryRow} from '../../types/history';
import {HistoryRowContent} from './internal/HistoryRowContent';
import './QueriesHistory.scss';
+import i18n from './i18n';
export type {QueriesHistoryProps} from '../../types/queriesHistory';
import type {QueriesHistoryProps} from '../../types/queriesHistory';
@@ -31,8 +33,17 @@ export const QueriesHistory = ({
}: QueriesHistoryProps) => {
return (
+ }
className={block(null, className)}
- title={title}
+ title={title || i18n('title_history')}
logo={logo}
search={search}
filter={filter}
diff --git a/src/modules/QueriesHistory/i18n/en.json b/src/modules/QueriesHistory/i18n/en.json
index 715aefd..15702b5 100644
--- a/src/modules/QueriesHistory/i18n/en.json
+++ b/src/modules/QueriesHistory/i18n/en.json
@@ -1,3 +1,5 @@
{
- "title_history": "History"
+ "title_history": "History",
+ "title_no-queries": "No queries",
+ "context_save-first-query": "Save your first query for quick access"
}
diff --git a/src/modules/QueriesHistory/i18n/ru.json b/src/modules/QueriesHistory/i18n/ru.json
index 07d64a8..716cafa 100644
--- a/src/modules/QueriesHistory/i18n/ru.json
+++ b/src/modules/QueriesHistory/i18n/ru.json
@@ -1,3 +1,5 @@
{
- "title_history": "История"
+ "title_history": "История",
+ "title_no-queries": "Нет запросов",
+ "context_save-first-query": "Сохраните первый запрос для быстрого доступа"
}
diff --git a/src/modules/QueriesList/QueriesList.scss b/src/modules/QueriesList/QueriesList.scss
index 9ac9e32..7d16a73 100644
--- a/src/modules/QueriesList/QueriesList.scss
+++ b/src/modules/QueriesList/QueriesList.scss
@@ -1,6 +1,26 @@
.qp-queries-list {
height: 100%;
+ &__body {
+ flex: 1 1 0;
+ min-height: 0;
+ overflow: hidden;
+
+ &_has-items {
+ margin-top: 8px;
+ }
+ }
+
+ &_panel {
+ min-height: 0;
+
+ .qp-queries-list__comparison-actions {
+ position: static;
+ width: auto;
+ flex-shrink: 0;
+ }
+ }
+
&__comparison-actions {
position: absolute;
left: 0;
diff --git a/src/modules/QueriesList/QueriesList.stories.tsx b/src/modules/QueriesList/QueriesList.stories.tsx
new file mode 100644
index 0000000..6b04b72
--- /dev/null
+++ b/src/modules/QueriesList/QueriesList.stories.tsx
@@ -0,0 +1,152 @@
+import React, {useState} from 'react';
+import type {Meta, StoryObj} from '@storybook/react';
+import {Flex, Text} from '@gravity-ui/uikit';
+import {action} from 'storybook/actions';
+import {QueriesList} from './QueriesList';
+import {QueriesHistory} from '../QueriesHistory';
+import {SavedQueries} from '../SavedQueries';
+import {TutorialsHistory} from '../TutorialsHistory';
+import type {QueryHistoryRow} from '../../types/history';
+import type {SavedQuery} from '../../types/savedQueries';
+import type {TutorialHistoryRow} from '../../types/tutorial';
+import type {QueryListSearchConfig} from '../../types/queryList';
+
+const meta: Meta = {
+ title: 'Modules/QueriesList',
+ component: QueriesList,
+ parameters: {layout: 'padded'},
+};
+export default meta;
+type Story = StoryObj;
+
+const lessons: TutorialHistoryRow[] = Array.from({length: 22}, (_, index) => ({
+ id: `lesson-${index + 1}`,
+ number: index + 1,
+ title: `Lesson ${index + 1}`,
+ query: 'SELECT 1;',
+ height: 28,
+}));
+const history: QueryHistoryRow[] = lessons.map((item) => ({
+ ...item,
+ height: 52,
+ status: 'completed',
+ engine: 'SQL',
+ mode: 'Validate',
+ startTime: '2026-04-29T12:00:00Z',
+ endTime: '2026-04-29T12:00:17Z',
+}));
+const saved: SavedQuery[] = lessons.map((item) => ({
+ ...item,
+ height: 52,
+ engine: 'SQL',
+ author: 'Anna',
+ savedAt: '2026-04-29T12:00:00Z',
+}));
+
+type ExampleProps = {
+ state?: 'default' | 'empty' | 'no-results' | 'loading' | 'comparison';
+ height?: number;
+};
+const PanelExamples = ({state = 'default', height = 720}: ExampleProps) => {
+ const [search, setSearch] = useState({
+ value: state === 'no-results' ? 'missing' : '',
+ fullSearch: false,
+ });
+ const [selectedRowId, setSelectedRowId] = useState('lesson-2');
+ const [comparedRowIds, setComparedRowIds] = useState<(string | number)[]>([]);
+ const config: QueryListSearchConfig = {...search, hasClear: true, onUpdate: setSearch};
+ const common = {
+ logo: YQL UI ,
+ search: config,
+ filter: {},
+ selectedRowId,
+ loading: state === 'loading',
+ onListItemClick: (item: {id: string | number} | {header: string}) => {
+ if ('id' in item) setSelectedRowId(item.id);
+ },
+ };
+ const comparison =
+ state === 'comparison'
+ ? {
+ enabled: true,
+ comparedRowIds,
+ onChange: (item: {id: string | number}, checked: boolean) =>
+ setComparedRowIds((ids) =>
+ checked ? [...ids, item.id] : ids.filter((id) => id !== item.id),
+ ),
+ onCompare: action('compare'),
+ onCancel: action('cancelComparison'),
+ }
+ : undefined;
+ const results = (items: T[]) => {
+ if (state === 'empty' || state === 'loading') return [];
+ return items.filter(
+ (item) =>
+ item.title.toLowerCase().includes(search.value.toLowerCase()) ||
+ (search.fullSearch &&
+ item.query?.toLowerCase().includes(search.value.toLowerCase())),
+ );
+ };
+ const historyResults = results(history);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const Panels: Story = {render: () => };
+export const EmptyPanels: Story = {render: () => };
+export const NoResults: Story = {render: () => };
+export const Loading: Story = {render: () => };
+export const Comparison: Story = {render: () => };
+export const ShortPanels: Story = {render: () => };
+export const Legacy: Story = {
+ render: () => (
+
+ {'title' in item ? item.title : item.header} }
+ />
+
+ ),
+};
+export const TutorialsAlias: Story = {
+ render: () => (
+
+ YQL UI}
+ items={lessons}
+ search={{onUpdate: action('search')}}
+ filter={{}}
+ renderRow={({item}) => {'title' in item ? item.title : item.header} }
+ />
+
+ ),
+};
diff --git a/src/modules/QueriesList/QueriesList.tsx b/src/modules/QueriesList/QueriesList.tsx
index f745972..c6d8514 100644
--- a/src/modules/QueriesList/QueriesList.tsx
+++ b/src/modules/QueriesList/QueriesList.tsx
@@ -1,8 +1,8 @@
-import React from 'react';
+import React, {useState} from 'react';
import cn from 'bem-cn-lite';
import {FieldsSelector} from '../../components/FieldsSelector';
import {useListKey} from '../../helpers/useListKey';
-import {
+import type {
QueryListComparisonConfig,
QueryListEditingConfig,
QueryListFilterConfig,
@@ -19,10 +19,18 @@ import {HistoryHeader} from '../HistoryHeader';
import {HistoryLayout} from '../HistoryLayout';
import {RowsList} from '../RowsList';
import './QueriesList.scss';
+import {EmptyContent} from '../../components/EmptyContent';
+import type {QueryListPanelOptions} from '../../types/listPanel';
const block = cn('qp-queries-list');
-export type QueriesListProps = {
+const getPanelEmptyContent = (hasSearchOrFilter: boolean, emptyContent: React.ReactNode) => {
+ if (hasSearchOrFilter) return ;
+ if (emptyContent === undefined) return ;
+ return emptyContent;
+};
+
+export type QueriesListProps = QueryListPanelOptions & {
className?: string;
title: React.ReactNode;
logo?: React.ReactNode;
@@ -60,30 +68,79 @@ export const QueriesList = ({
onLoadMore,
renderLink,
className,
+ variant = 'default',
+ emptyContent,
+ hideSearchWhenEmpty = false,
}: QueriesListProps) => {
+ const [localSearch, setLocalSearch] = useState(() => ({
+ value: search.value ?? '',
+ fullSearch: search.fullSearch ?? false,
+ }));
+ const searchValue = search.value ?? localSearch.value;
+ const fullSearch = search.fullSearch ?? localSearch.fullSearch;
+ const handleSearchUpdate: QueryListSearchConfig['onUpdate'] = (data) => {
+ setLocalSearch(data);
+ search.onUpdate(data);
+ };
const fullSearchAvailable = search.fullSearchAvailable !== false;
- const showSearchResults = Boolean(
- fullSearchAvailable && search.fullSearch && search.value?.trim(),
- );
+ const showSearchResults = Boolean(fullSearchAvailable && fullSearch && searchValue.trim());
const rowVariant = showSearchResults ? 'search' : 'default';
const listKey = useListKey(items, rowVariant, Boolean(onLoadMore));
+ const isPanel = variant !== 'default';
+ const hasSearchOrFilter = Boolean(searchValue.trim()) || filter?.isChanged === true;
+ const hideSearch = hideSearchWhenEmpty && !items.length && !loading && !hasSearchOrFilter;
+ const resolvedEmptyContent = isPanel
+ ? getPanelEmptyContent(hasSearchOrFilter, emptyContent)
+ : emptyContent;
+ const rows = (
+
+ );
+
return (
}
- search={search.value}
- fullSearch={search.fullSearch}
- fullSearchAvailable={fullSearchAvailable}
- hasClear={search.hasClear}
- filter={filter}
- onUpdate={search.onUpdate}
- />
+ !hideSearch && (
+
+ )
+ }
+ search={searchValue}
+ fullSearch={fullSearch}
+ fullSearchAvailable={fullSearchAvailable}
+ hasClear={search.hasClear}
+ filter={filter}
+ onUpdate={handleSearchUpdate}
+ />
+ )
}
footer={
comparison && (
@@ -94,23 +151,11 @@ export const QueriesList = ({
)
}
>
-
+ {isPanel ? (
+ 0})}>{rows}
+ ) : (
+ rows
+ )}
);
};
diff --git a/src/modules/QueriesNavigation/QueriesNavigation.scss b/src/modules/QueriesNavigation/QueriesNavigation.scss
index 9b1f7da..1e59950 100644
--- a/src/modules/QueriesNavigation/QueriesNavigation.scss
+++ b/src/modules/QueriesNavigation/QueriesNavigation.scss
@@ -9,6 +9,14 @@
flex-shrink: 0;
}
+ &__header {
+ min-height: 32px;
+ }
+
+ &__search {
+ margin-block: var(--g-spacing-1);
+ }
+
&__empty,
&__error {
padding: var(--g-spacing-2);
diff --git a/src/modules/QueriesNavigation/QueriesNavigation.tsx b/src/modules/QueriesNavigation/QueriesNavigation.tsx
index 99eff4d..8d54a86 100644
--- a/src/modules/QueriesNavigation/QueriesNavigation.tsx
+++ b/src/modules/QueriesNavigation/QueriesNavigation.tsx
@@ -129,7 +129,7 @@ export const QueriesNavigation = <
}
return (
-
+
{
+ renderContent: ({search, onSearchUpdate, searchPlaceholder}) => {
const view = resolveView?.(item);
- return data={view ?? {sections: []}} />;
+ return (
+
+ data={view ?? {sections: []}}
+ search={search}
+ onSearchUpdate={onSearchUpdate}
+ searchPlaceholder={searchPlaceholder}
+ />
+ );
},
},
],
diff --git a/src/modules/RowsList/RowsList.tsx b/src/modules/RowsList/RowsList.tsx
index 511adc6..d02273b 100644
--- a/src/modules/RowsList/RowsList.tsx
+++ b/src/modules/RowsList/RowsList.tsx
@@ -1,6 +1,6 @@
-import React from 'react';
+import React, {useMemo} from 'react';
import cn from 'bem-cn-lite';
-import {
+import type {
QueryListComparisonConfig,
QueryListEditingConfig,
QueryListItem,
@@ -16,10 +16,11 @@ import {LazyList} from '../../components/LazyList';
import {prepareRowData} from './helpers/prepareRowData';
import {SEARCH_ROW_HEIGHT} from '../../constants/row';
import './RowsList.scss';
+import type {ListEmptyContentProps} from '../../types/listPanel';
const block = cn('qp-rows-list');
-export type RowsListProps = {
+export type RowsListProps = ListEmptyContentProps & {
items: QueryListItem[];
selectedRowId?: T['id'];
rowVariant?: QueryListRowVariant;
@@ -47,6 +48,7 @@ export const RowsList = ({
getRowActions,
renderRow,
showFiltersHint,
+ emptyContent,
hasMore,
loading,
onLoadMore,
@@ -54,6 +56,10 @@ export const RowsList = ({
className,
onItemClick,
}: RowsListProps) => {
+ const listItems = useMemo(
+ () => items.map((item) => ('header' in item ? {...item, disabled: true} : item)),
+ [items],
+ );
const getItemHeight = (item: QueryListItem) =>
rowVariant === 'search' && !('header' in item) ? SEARCH_ROW_HEIGHT : item.height;
const selectedItemIndex =
@@ -74,7 +80,7 @@ export const RowsList = ({
return (
>
className={block(null, className)}
- items={items}
+ items={listItems}
itemHeight={getItemHeight}
renderItem={(item, isActive, index) =>
renderRow(
@@ -96,10 +102,14 @@ export const RowsList = ({
loading={loading}
onLoadMore={onLoadMore}
emptyContent={
-
+ emptyContent === undefined ? (
+
+ ) : (
+ emptyContent
+ )
}
onItemClick={handleItemClick}
/>
diff --git a/src/modules/SavedQueries/SavedQueries.tsx b/src/modules/SavedQueries/SavedQueries.tsx
index fc74571..b777e2d 100644
--- a/src/modules/SavedQueries/SavedQueries.tsx
+++ b/src/modules/SavedQueries/SavedQueries.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import cn from 'bem-cn-lite';
+import {EmptyContent} from '../../components/EmptyContent';
import {QueriesList} from '../../modules/QueriesList';
import type {SavedQuery} from '../../types/savedQueries';
import i18n from './i18n';
@@ -32,6 +33,15 @@ export const SavedQueries = ({
}: SavedQueriesProps) => {
return (
+ }
className={block(null, className)}
title={title || i18n('title_saved')}
logo={logo}
diff --git a/src/modules/SavedQueries/i18n/en.json b/src/modules/SavedQueries/i18n/en.json
index 4fb5093..b8ea98b 100644
--- a/src/modules/SavedQueries/i18n/en.json
+++ b/src/modules/SavedQueries/i18n/en.json
@@ -1,3 +1,5 @@
{
- "title_saved": "Saved"
+ "title_saved": "Saved",
+ "title_no-saved-queries": "No saved queries",
+ "context_save-first-query": "Save your first query for quick access"
}
diff --git a/src/modules/SavedQueries/i18n/ru.json b/src/modules/SavedQueries/i18n/ru.json
index 5f770df..60528f1 100644
--- a/src/modules/SavedQueries/i18n/ru.json
+++ b/src/modules/SavedQueries/i18n/ru.json
@@ -1,3 +1,5 @@
{
- "title_saved": "Сохранённые"
+ "title_saved": "Сохранённые",
+ "title_no-saved-queries": "Нет сохранённых запросов",
+ "context_save-first-query": "Сохраните первый запрос для быстрого доступа"
}
diff --git a/src/modules/TutorialRow/TutorialRow.scss b/src/modules/TutorialRow/TutorialRow.scss
index 9597998..ea1733e 100644
--- a/src/modules/TutorialRow/TutorialRow.scss
+++ b/src/modules/TutorialRow/TutorialRow.scss
@@ -6,4 +6,10 @@
&__content {
overflow: hidden;
}
+
+ &__number {
+ flex: 0 0 auto;
+ min-width: 24px;
+ white-space: nowrap;
+ }
}
diff --git a/src/modules/TutorialRow/TutorialRow.stories.tsx b/src/modules/TutorialRow/TutorialRow.stories.tsx
index 6de9ce8..13aac58 100644
--- a/src/modules/TutorialRow/TutorialRow.stories.tsx
+++ b/src/modules/TutorialRow/TutorialRow.stories.tsx
@@ -41,3 +41,11 @@ export const WithHref: Story = {
item: makeRow({href: '#'}),
},
};
+
+export const LongNumber: Story = {
+ args: {item: makeRow({id: 'lesson-1234', number: 1234})},
+};
+
+export const StringIdentifier: Story = {
+ args: {item: makeRow({id: 'tutorial-string-id'})},
+};
diff --git a/src/modules/TutorialRow/TutorialRow.tsx b/src/modules/TutorialRow/TutorialRow.tsx
index f883ab5..8567b64 100644
--- a/src/modules/TutorialRow/TutorialRow.tsx
+++ b/src/modules/TutorialRow/TutorialRow.tsx
@@ -18,11 +18,14 @@ export const TutorialRow = ({
renderLink,
}: TutorialRowProps) => {
const {href, id, title} = item;
+ const number = item.number ?? id;
return (
-
- {id}.
+
+
+ {String(number).padStart(2, '0')}.
+
{title}
diff --git a/src/modules/TutorialSearchRow/TutorialSearchRow.tsx b/src/modules/TutorialSearchRow/TutorialSearchRow.tsx
index 7ae536c..8491353 100644
--- a/src/modules/TutorialSearchRow/TutorialSearchRow.tsx
+++ b/src/modules/TutorialSearchRow/TutorialSearchRow.tsx
@@ -29,7 +29,7 @@ export const TutorialSearchRow = ({
className={block()}
header={
- {id}.
+ {String(item.number ?? id).padStart(2, '0')}.
{title}
}
diff --git a/src/modules/TutorialsHistory/TutorialsHistory.scss b/src/modules/TutorialsHistory/TutorialsHistory.scss
index 2b4badc..0e335d2 100644
--- a/src/modules/TutorialsHistory/TutorialsHistory.scss
+++ b/src/modules/TutorialsHistory/TutorialsHistory.scss
@@ -1,3 +1,3 @@
.qp-tutorials-history {
height: 100%;
-}
\ No newline at end of file
+}
diff --git a/src/modules/TutorialsHistory/TutorialsHistory.stories.tsx b/src/modules/TutorialsHistory/TutorialsHistory.stories.tsx
index 4d1d435..d0bffcb 100644
--- a/src/modules/TutorialsHistory/TutorialsHistory.stories.tsx
+++ b/src/modules/TutorialsHistory/TutorialsHistory.stories.tsx
@@ -38,6 +38,7 @@ const LINK_ITEMS = BASE_ITEMS.map((item) =>
const PAGINATION_PAGES: QueryListItem[][] = [
Array.from({length: 8}, (_, index) => ({
id: `basics-${index + 1}`,
+ number: index + 1,
title: `YQL basics: lesson ${index + 1}`,
query: QUERY,
href: `/tutorials/basics-${index + 1}`,
@@ -45,6 +46,7 @@ const PAGINATION_PAGES: QueryListItem[][] = [
})),
Array.from({length: 8}, (_, index) => ({
id: `advanced-${index + 1}`,
+ number: index + 9,
title: `Advanced YQL: lesson ${index + 1}`,
query: QUERY,
href: `/tutorials/advanced-${index + 1}`,
@@ -245,6 +247,58 @@ const CustomRowRendererStory = () => (
export const Default: Story = {render: () => };
export const Empty: Story = {render: () => };
+
+const DESIGN_ITEMS: QueryListItem[] = [
+ 'Select all columns',
+ 'Select specific columns',
+ 'Basic filter',
+ 'String filter',
+ 'Sort and get top N',
+ 'Basic aggregation',
+ 'Conditional values and UDF',
+ 'Named nodes and subqueries',
+ 'Save and insert results',
+ 'Join tables',
+ 'Window functions',
+ 'Containers',
+ 'Table concatenation',
+ 'Sampling',
+ 'Flatten by',
+ 'Lambda functions',
+ 'Yson and Json',
+ 'Regexp capture',
+ 'Inline Python',
+ 'Attached Javascript',
+ 'Statistics',
+ 'User Defined Aggregation Functions',
+].map((title, index) => ({id: `lesson-${index + 1}`, number: index + 1, title, height: 28}));
+
+/** Reference dimensions and content from the Tutorials Figma frame. */
+export const DesignReference: Story = {
+ args: {
+ items: DESIGN_ITEMS,
+ logo: YQL UI ,
+ search: {onUpdate: action('onSearchUpdate')},
+ filter: {fields: filterFields},
+ },
+ decorators: [
+ (StoryComponent) => (
+
+
+
+ ),
+ ],
+};
+
+export const DesignEmpty: Story = {
+ ...DesignReference,
+ args: {...DesignReference.args, items: [], filter: {fields: filterFields, isChanged: true}},
+};
+
+export const DesignSelected: Story = {
+ ...DesignReference,
+ args: {...DesignReference.args, selectedRowId: 'lesson-2'},
+};
export const InitialLoading: Story = {
args: {
title: 'Loading tutorials',
diff --git a/src/modules/TutorialsHistory/TutorialsHistory.tsx b/src/modules/TutorialsHistory/TutorialsHistory.tsx
index 09c34da..fe8beb6 100644
--- a/src/modules/TutorialsHistory/TutorialsHistory.tsx
+++ b/src/modules/TutorialsHistory/TutorialsHistory.tsx
@@ -1,10 +1,8 @@
import React from 'react';
-import {HistoryHeader} from '../../modules/HistoryHeader';
-import {HistoryLayout} from '../../modules/HistoryLayout';
-import {RowsList} from '../../modules/RowsList';
+import {QueriesList} from '../../modules/QueriesList';
+import {EmptyContent} from '../../components/EmptyContent';
import i18n from './i18n';
import type {TutorialHistoryRow} from '../../types/tutorial';
-import {useListKey} from '../../helpers/useListKey';
import {TutorialRowContent} from './TutorialRowContent';
import cn from 'bem-cn-lite';
import './TutorialsHistory.scss';
@@ -29,44 +27,25 @@ export const TutorialsHistory = ({
renderLink,
className,
}: TutorialsHistoryProps) => {
- const fullSearchAvailable = search.fullSearchAvailable !== false;
- const showSearchResults = Boolean(
- fullSearchAvailable && search.fullSearch && search.value?.trim(),
- );
- const rowVariant = showSearchResults ? 'search' : 'default';
- const listKey = useListKey(items, rowVariant, Boolean(onLoadMore));
-
return (
-
+ search={search}
+ filter={filter}
+ items={items}
+ selectedRowId={selectedRowId}
+ renderRow={(data) =>
+ renderRowItem ? renderRowItem(data) :
}
- >
-
- renderRowItem ? renderRowItem(data) :
- }
- showFiltersHint={Boolean(filter)}
- hasMore={hasMore}
- loading={loading}
- onLoadMore={onLoadMore}
- renderLink={renderLink}
- onItemClick={onListItemClick}
- />
-
+ emptyContent={ }
+ hasMore={hasMore}
+ loading={loading}
+ onLoadMore={onLoadMore}
+ renderLink={renderLink}
+ onListItemClick={onListItemClick}
+ />
);
};
diff --git a/src/modules/TutorialsHistory/i18n/en.json b/src/modules/TutorialsHistory/i18n/en.json
index 2f6391d..ba309fa 100644
--- a/src/modules/TutorialsHistory/i18n/en.json
+++ b/src/modules/TutorialsHistory/i18n/en.json
@@ -1,3 +1,4 @@
{
- "title_tutorials": "Tutorials"
+ "title_tutorials": "Tutorials",
+ "title_no-tutorials": "No tutorials"
}
diff --git a/src/modules/TutorialsHistory/i18n/ru.json b/src/modules/TutorialsHistory/i18n/ru.json
index 49b797f..666c296 100644
--- a/src/modules/TutorialsHistory/i18n/ru.json
+++ b/src/modules/TutorialsHistory/i18n/ru.json
@@ -1,3 +1,4 @@
{
- "title_tutorials": "Туториалы"
+ "title_tutorials": "Туториалы",
+ "title_no-tutorials": "Нет уроков"
}
diff --git a/src/types/listPanel.ts b/src/types/listPanel.ts
new file mode 100644
index 0000000..0f34a95
--- /dev/null
+++ b/src/types/listPanel.ts
@@ -0,0 +1,19 @@
+import type {ReactNode} from 'react';
+
+/** Tutorials is a compatibility alias for the shared panel appearance. */
+export type HistoryPanelVariant = 'default' | 'panel' | 'tutorials';
+
+export type ListEmptyContentProps = {
+ /** Undefined uses the default placeholder; null explicitly suppresses it. */
+ emptyContent?: ReactNode;
+};
+
+export type QueryListPanelOptions = ListEmptyContentProps & {
+ variant?: HistoryPanelVariant;
+ hideSearchWhenEmpty?: boolean;
+};
+
+export type EmptyContentTextOverrides = {
+ title?: ReactNode;
+ description?: ReactNode;
+};
diff --git a/src/types/tutorial.ts b/src/types/tutorial.ts
index 467ba8f..1c2753a 100644
--- a/src/types/tutorial.ts
+++ b/src/types/tutorial.ts
@@ -1,3 +1,6 @@
-import {QueryListRow} from './queryList';
+import type {QueryListRow} from './queryList';
-export type TutorialHistoryRow = QueryListRow;
+export type TutorialHistoryRow = QueryListRow & {
+ /** Stable lesson number, independent of its identifier and position in search results. */
+ number?: number;
+};
diff --git a/tests/components/ListPanels.test.tsx b/tests/components/ListPanels.test.tsx
new file mode 100644
index 0000000..3e5c1a1
--- /dev/null
+++ b/tests/components/ListPanels.test.tsx
@@ -0,0 +1,219 @@
+// @vitest-environment jsdom
+import React, {act, useState} from 'react';
+import {type Root, createRoot} from 'react-dom/client';
+import {ThemeProvider} from '@gravity-ui/uikit';
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {QueriesHistory} from '../../src/modules/QueriesHistory';
+import {SavedQueries} from '../../src/modules/SavedQueries';
+import {TutorialsHistory} from '../../src/modules/TutorialsHistory';
+import {QueriesList} from '../../src/modules/QueriesList';
+import {RowsList} from '../../src/modules/RowsList';
+import {HistoryHeader} from '../../src/modules/HistoryHeader';
+import {HistoryLayout} from '../../src/modules/HistoryLayout';
+import {EmptyContent} from '../../src/components/EmptyContent';
+import type {QueryListFilterConfig, QueryListSearchConfig} from '../../src/types/queryList';
+
+// The filter popup imports date-picker CSS outside Vitest's pipeline.
+vi.mock('../../src/components/HistoryFilter', () => ({HistoryFilter: () => null}));
+vi.mock('../../src/components/MonacoEditor', () => ({
+ MonacoEditor: () => null,
+ MonacoLanguage: {YQL: 'yql'},
+}));
+
+const search = {onUpdate: vi.fn()};
+const noopRow = () => null;
+const modules = [
+ {Component: QueriesHistory, title: 'No queries', hidesSearch: true},
+ {Component: SavedQueries, title: 'No saved queries', hidesSearch: true},
+ {Component: TutorialsHistory, title: 'No tutorials', hidesSearch: false},
+];
+
+describe('shared list panels', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+ const render = (content: React.ReactNode) =>
+ act(() => {
+ root.render({content} );
+ });
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ it.each(modules)(
+ '$title: keeps uncontrolled search available after no results',
+ ({Component}) => {
+ const onUpdate = vi.fn();
+ const Example = () => {
+ const [value, setValue] = useState('');
+ return (
+ Result row }
+ search={{
+ onUpdate: (data) => {
+ onUpdate(data);
+ setValue(data.value);
+ },
+ }}
+ />
+ );
+ };
+ render( );
+ const updateInput = (value: string) => {
+ const input = container.querySelector('input');
+ expect(input).not.toBeNull();
+ const setValue = Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ 'value',
+ )?.set;
+ if (!input || !setValue) throw new Error('Search input is unavailable');
+ act(() => {
+ setValue.call(input, value);
+ input.dispatchEvent(new Event('input', {bubbles: true}));
+ });
+ };
+ const toggle = container.querySelector('button[aria-label="Search in query text"]');
+ act(() => toggle?.dispatchEvent(new MouseEvent('click', {bubbles: true})));
+ updateInput('missing');
+ expect(container.textContent).toContain('Nothing found');
+ expect(container.querySelector('input')?.value).toBe('missing');
+ expect(onUpdate).toHaveBeenLastCalledWith({value: 'missing', fullSearch: true});
+ updateInput('still missing');
+ expect(onUpdate).toHaveBeenLastCalledWith({value: 'still missing', fullSearch: true});
+ updateInput('');
+ expect(container.querySelector('.qp-empty-content')).toBeNull();
+ expect(container.querySelector('input')?.value).toBe('');
+ expect(onUpdate).toHaveBeenLastCalledWith({value: '', fullSearch: true});
+ },
+ );
+
+ it.each(modules)(
+ '$title: distinguishes an empty catalogue from filtered results and loading',
+ ({Component, title, hidesSearch}) => {
+ const show = (filter?: QueryListFilterConfig, value = '', loading = false) =>
+ render(
+ ,
+ );
+ show({isChanged: false});
+ expect(container.textContent).toContain(title);
+ expect(container.textContent).not.toContain('Nothing found');
+ expect(Boolean(container.querySelector('input'))).toBe(!hidesSearch);
+
+ show(undefined, ' ');
+ expect(container.textContent).toContain(title);
+ show(undefined, 'select');
+ expect(container.textContent).toContain('Nothing found');
+ expect(container.querySelector('input')).not.toBeNull();
+ show({isChanged: true});
+ expect(container.textContent).toContain('Nothing found');
+ expect(container.querySelector('input')).not.toBeNull();
+
+ show(undefined, '', true);
+ expect(container.querySelector('.qp-list-spinner')).not.toBeNull();
+ expect(container.querySelector('.qp-empty-content')).toBeNull();
+ expect(container.querySelector('input')).not.toBeNull();
+ },
+ );
+
+ it('respects explicit null and custom empty content while keeping legacy fallbacks', () => {
+ render( );
+ expect(container.querySelector('.qp-empty-content')).toBeNull();
+ render(
+ Custom
}
+ showFiltersHint
+ />,
+ );
+ expect(container.textContent).toBe('Custom');
+ render( );
+ expect(container.textContent).toContain('Nothing found');
+ render( );
+ expect(container.textContent).toContain('No files');
+ });
+
+ it('preserves the default QueriesList appearance and supports panel empty overrides', () => {
+ render(
+ ,
+ );
+ expect(container.querySelector('.qp-queries-list_panel')).toBeNull();
+ expect(container.querySelector('.qp-history-layout__controls')).toBeNull();
+ expect(container.querySelector('input')?.getAttribute('placeholder')).toBeNull();
+ expect(container.textContent).toContain('Nothing found');
+ render(
+ ,
+ );
+ expect(container.textContent).toContain('No data');
+ render(
+ ,
+ );
+ expect(container.querySelector('.qp-empty-content')).toBeNull();
+ render(
+ ,
+ );
+ expect(container.textContent).toContain('Nothing found');
+ });
+
+ it('keeps the tutorials alias equivalent to panel and retains search mode interactions', () => {
+ const onUpdate = vi.fn();
+ for (const variant of ['panel', 'tutorials'] as const) {
+ render(
+ }
+ >
+ Body
+ ,
+ );
+ expect(container.querySelector('.qp-history-layout__controls')).not.toBeNull();
+ expect(container.querySelector('input')?.placeholder).toBe('Search');
+ const toggle = container.querySelector('button[aria-label="Search in query text"]');
+ expect(toggle?.classList.contains('g-button_view_flat-secondary')).toBe(true);
+ act(() => toggle?.dispatchEvent(new MouseEvent('click', {bubbles: true})));
+ expect(onUpdate).toHaveBeenLastCalledWith({value: 'select', fullSearch: true});
+ render(null);
+ }
+ });
+
+ it('allows placeholder text overrides including an explicitly hidden description', () => {
+ render( );
+ expect(container.textContent).toBe('Custom title');
+ render( );
+ expect(container.textContent).toContain('Try to change filters');
+ });
+});
diff --git a/tests/components/NavigationView.test.tsx b/tests/components/NavigationView.test.tsx
new file mode 100644
index 0000000..ac36eb6
--- /dev/null
+++ b/tests/components/NavigationView.test.tsx
@@ -0,0 +1,154 @@
+// @vitest-environment jsdom
+
+import React, {act} from 'react';
+import {ThemeProvider} from '@gravity-ui/uikit';
+import type {Root} from 'react-dom/client';
+import {createRoot} from 'react-dom/client';
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {NavigationView} from '../../src/modules/NavigationView';
+import {createTableDetailConfig} from '../../src/modules/QueriesNavigation/helpers/createTableDetailConfig';
+import type {NavigationViewConfig} from '../../src/types/navigation';
+
+function makeData(): NavigationViewConfig {
+ return {
+ loaded: true,
+ sections: [
+ {
+ id: 'primary',
+ title: 'Primary view',
+ columns: ['name', 'count'],
+ rows: [
+ {name: 'Alpha record', count: 42},
+ {name: 'Beta record', count: 7, hidden: 'alpha'},
+ ],
+ loaded: true,
+ defaultExpanded: true,
+ },
+ {
+ id: 'secondary',
+ title: 'Secondary view',
+ columns: ['description'],
+ rows: [{description: 'Alpha description'}, {description: 'Gamma description'}],
+ loaded: true,
+ defaultExpanded: true,
+ },
+ ],
+ };
+}
+
+describe('NavigationView search', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ function renderContent(content: React.ReactNode) {
+ act(() => root.render({content} ));
+ }
+
+ function updateSearch(value: string) {
+ const input = container.querySelector('input');
+ expect(input).not.toBeNull();
+ act(() => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
+ input,
+ value,
+ );
+ input?.dispatchEvent(new Event('input', {bubbles: true}));
+ });
+ }
+
+ it('filters each section by its columns and restores all rows when cleared', () => {
+ renderContent( );
+
+ updateSearch(' ALPHA ');
+ expect(container.textContent).toContain('Alpha record');
+ expect(container.textContent).toContain('Alpha description');
+ expect(container.textContent).not.toContain('Beta record');
+ expect(container.textContent).not.toContain('Gamma description');
+
+ updateSearch('42');
+ expect(container.textContent).toContain('Alpha record');
+ expect(container.textContent).not.toContain('Alpha description');
+
+ updateSearch('');
+ expect(container.textContent).toContain('Beta record');
+ expect(container.textContent).toContain('Gamma description');
+ });
+
+ it('keeps section headings and shows nothing-found for unmatched rows', () => {
+ renderContent( );
+
+ updateSearch('missing');
+
+ expect(container.textContent).toContain('Primary view');
+ expect(container.textContent).toContain('Secondary view');
+ expect(container.textContent).not.toContain('Alpha record');
+ expect(container.textContent?.match(/Nothing found/g)).toHaveLength(2);
+ });
+
+ it('forwards controlled search, its placeholder and updates through the View tab factory', () => {
+ const data = makeData();
+ const config = createTableDetailConfig({resolveView: () => data})({
+ path: '/table',
+ title: 'table',
+ });
+ const viewTab = config.tabs.find((tab) => tab.id === 'view');
+ const onSearchUpdate = vi.fn();
+ const render = (search: string) => {
+ renderContent(
+ viewTab?.renderContent?.({
+ search,
+ onSearchUpdate,
+ searchPlaceholder: 'Find view rows',
+ }),
+ );
+ };
+
+ render('Beta');
+ expect(container.querySelector('input')?.placeholder).toBe('Find view rows');
+ expect(container.querySelector('input')?.value).toBe('Beta');
+ expect(container.textContent).toContain('Beta record');
+ expect(container.textContent).not.toContain('Alpha record');
+
+ updateSearch('Alpha');
+ expect(onSearchUpdate).toHaveBeenLastCalledWith('Alpha');
+ expect(container.textContent).toContain('Beta record');
+
+ render('Alpha');
+ expect(container.textContent).toContain('Alpha record');
+ expect(container.textContent).not.toContain('Beta record');
+ });
+
+ it('preserves expansion and passes the original section to actions while filtering', () => {
+ const data = makeData();
+ const section = data.sections[0];
+ const onClick = vi.fn();
+ section.actions = [{id: 'open', title: 'Open view', content: 'Open', onClick}];
+ renderContent( );
+
+ const toggle = Array.from(container.querySelectorAll('button')).find((button) =>
+ button.textContent?.includes('Secondary view'),
+ );
+ expect(toggle?.getAttribute('aria-expanded')).toBe('true');
+ act(() => toggle?.click());
+ expect(toggle?.getAttribute('aria-expanded')).toBe('false');
+
+ updateSearch('Alpha');
+ expect(toggle?.getAttribute('aria-expanded')).toBe('false');
+ act(() => container.querySelector('[aria-label="Open view"]')?.click());
+ expect(onClick).toHaveBeenCalledWith(section);
+ expect(onClick.mock.calls[0][0]).toBe(section);
+ expect(section.rows).toHaveLength(2);
+ });
+});
diff --git a/tests/components/TutorialRows.test.tsx b/tests/components/TutorialRows.test.tsx
index a81dd1c..e65f4ea 100644
--- a/tests/components/TutorialRows.test.tsx
+++ b/tests/components/TutorialRows.test.tsx
@@ -95,6 +95,25 @@ describe('tutorial row links', () => {
expect(container.querySelector('a')?.getAttribute('href')).toBe(ITEM.href);
});
+ it('keeps the lesson number independent of its string identifier and result position', () => {
+ const item = {...ITEM, number: 7};
+
+ for (const variant of ['default', 'search'] as const) {
+ renderContent({item, variant, index: 0});
+ expect(container.textContent).toContain('07.');
+ expect(container.textContent).not.toContain(ITEM.id);
+ expect(container.querySelector('a')?.getAttribute('href')).toBe(ITEM.href);
+ }
+ });
+
+ it('pads legacy numeric identifiers and preserves larger numbers in the rendered text', () => {
+ renderContent({item: {...ITEM, id: 1}});
+ expect(container.textContent).toContain('01.');
+
+ renderContent({item: {...ITEM, id: 123}});
+ expect(container.textContent).toContain('123.');
+ });
+
it('renders a non-link row without calling renderLink when href is absent', () => {
const renderLink = vi.fn((props) => );
renderContent({item: {...ITEM, href: undefined}, renderLink});