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});