From c5c30b72fdd14acb6e9f6bd42b26acd98790694d Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Tue, 22 Sep 2026 16:42:34 +0300 Subject: [PATCH] feat: extend QueriesNavigation integration API --- README.md | 54 ++++++ src/components/Breadcrumbs/Breadcrumbs.tsx | 34 +++- .../NavigationDetail/NavigationDetail.tsx | 8 + .../NavigationHeader/NavigationHeader.tsx | 18 +- .../NavigationItemsList.tsx | 5 +- .../internal/useParentRow.ts | 4 +- src/types/navigation.ts | 17 ++ .../QueriesNavigation.stories.tsx | 111 ++++++++++- .../QueriesNavigation/QueriesNavigation.tsx | 10 +- tests/components/Breadcrumbs.test.tsx | 135 +++++++++++++ tests/components/NavigationHeader.test.tsx | 182 ++++++++++++++++++ tests/components/NavigationItemsList.test.tsx | 126 ++++++++++++ vitest.config.ts | 5 + 13 files changed, 696 insertions(+), 13 deletions(-) create mode 100644 tests/components/Breadcrumbs.test.tsx create mode 100644 tests/components/NavigationHeader.test.tsx create mode 100644 tests/components/NavigationItemsList.test.tsx diff --git a/README.md b/README.md index e5c4416..bbf176c 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,60 @@ and routing. Browse interactive examples in [Storybook](https://preview.gravity-ui.com/querieskit/). +### QueriesNavigation + +`QueriesNavigation` renders cluster and path navigation, an optional detail panel, and +application-provided header actions. Applications remain responsible for loading data, routing, +favorites, and item-specific operations. + +```tsx +import {Button, DropdownMenu, Flex} from '@gravity-ui/uikit'; +import {QueriesNavigation} from '@gravity-ui/querieskit'; + + + cluster ? `/navigation/${cluster}${path ?? ''}` : '/navigation', + renderActions: ({location, actions: headerActions}) => ( + + ({ + text: action.title, + disabled: action.disabled, + hidden: action.hidden, + action: () => action.onClick(location), + }))} + /> + + + ), + }} +/>; +``` + +Without `renderActions`, actions keep the standard button rendering. A custom renderer receives +the unfiltered action array and owns handling of `hidden`, `disabled`, and clicks; returning +`null` intentionally leaves the action area empty. In a detail panel the array contains the +panel actions (or header actions when panel actions are absent), followed by actions supplied by +the resolved detail config. + +`getBreadcrumbHref` turns every breadcrumb, including the cluster root and current item, into a +real link. Plain clicks continue through `onUpdate`, while modified and middle clicks keep native +browser behavior. URL construction and router integration belong to the application. Breadcrumb +segments are derived from normalized single-slash paths; the parser does not preserve a Cypress +`//` prefix. + +`parentRow.showDuringSearch` defaults to `false`. Set it to `true` to keep the parent navigation +row available while search results are filtered, including when the result list is empty. + ## Widgets | Widget | Description | diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx index 592f0b0..346ed18 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -4,7 +4,7 @@ import FolderTreeIcon from '@gravity-ui/icons/svgs/folder-tree.svg'; import PencilIcon from '@gravity-ui/icons/svgs/pencil.svg'; import cn from 'bem-cn-lite'; import {parsePathSegments} from './helpers/parsePathSegments'; -import {NavigationLocation} from '../../types/navigation'; +import type {GetNavigationBreadcrumbHref, NavigationLocation} from '../../types/navigation'; import type {LoadPathSuggestions} from '../../types/pathEditor'; import {PathEditor} from '../PathEditor'; import i18n from './i18n'; @@ -15,6 +15,7 @@ export type BreadcrumbsProps = { hideResetButton?: boolean; className?: string; onUpdate: (location: NavigationLocation) => void; + getBreadcrumbHref?: GetNavigationBreadcrumbHref; onLoadSuggestions?: LoadPathSuggestions; }; @@ -24,6 +25,7 @@ export const Breadcrumbs: FC = ({ location, hideResetButton, onUpdate, + getBreadcrumbHref, onLoadSuggestions, className, }) => { @@ -83,16 +85,34 @@ export const Breadcrumbs: FC = ({ {items.map((item, index) => { const isLast = index === items.length - 1; + const itemLocation = {cluster, path: item.path}; + const href = getBreadcrumbHref?.(itemLocation); return ( onUpdate({cluster, path: item.path}) - } + href={href} + disabled={isLast && !href} + onClick={(event) => { + const isPlainLeftClick = + event.button === 0 && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey; + + if (href && !isPlainLeftClick) { + return; + } + + if (href) { + event.preventDefault(); + } + + if (!isLast) { + onUpdate(itemLocation); + } + }} > {item.title} diff --git a/src/modules/NavigationDetail/NavigationDetail.tsx b/src/modules/NavigationDetail/NavigationDetail.tsx index 8945149..f982d74 100644 --- a/src/modules/NavigationDetail/NavigationDetail.tsx +++ b/src/modules/NavigationDetail/NavigationDetail.tsx @@ -4,9 +4,11 @@ import {Flex} from '@gravity-ui/uikit'; import {NavigationHeader} from '../NavigationHeader'; import {SearchWithButtons} from '../../components/SearchWithButtons'; import type { + GetNavigationBreadcrumbHref, NavigationDetailConfig, NavigationHeaderAction, NavigationLocation, + RenderNavigationHeaderActions, } from '../../types/navigation'; import type {LoadPathSuggestions} from '../../types/pathEditor'; import {NavigationDetailTabs} from './internal/NavigationDetailTabs'; @@ -22,6 +24,8 @@ export type NavigationDetailProps = { onUpdate: (location: NavigationLocation) => void; onLoadSuggestions?: LoadPathSuggestions; actions?: NavigationHeaderAction[]; + renderActions?: RenderNavigationHeaderActions; + getBreadcrumbHref?: GetNavigationBreadcrumbHref; activeTab?: string; onTabUpdate?: (tab: string) => void; search?: string; @@ -35,6 +39,8 @@ export const NavigationDetail: React.FC = ({ onUpdate, onLoadSuggestions, actions, + renderActions, + getBreadcrumbHref, activeTab: activeTabProp, onTabUpdate, search: searchProp, @@ -93,6 +99,8 @@ export const NavigationDetail: React.FC = ({ diff --git a/src/modules/NavigationHeader/NavigationHeader.tsx b/src/modules/NavigationHeader/NavigationHeader.tsx index fc2ee4c..a3161c0 100644 --- a/src/modules/NavigationHeader/NavigationHeader.tsx +++ b/src/modules/NavigationHeader/NavigationHeader.tsx @@ -2,12 +2,19 @@ import React, {FC} from 'react'; import {Flex} from '@gravity-ui/uikit'; import {Breadcrumbs} from '../../components/Breadcrumbs'; import {NavigationActionButtons} from '../../components/NavigationActionButtons'; -import type {NavigationHeaderAction, NavigationLocation} from '../../types/navigation'; +import type { + GetNavigationBreadcrumbHref, + NavigationHeaderAction, + NavigationLocation, + RenderNavigationHeaderActions, +} from '../../types/navigation'; import type {LoadPathSuggestions} from '../../types/pathEditor'; export type NavigationHeaderProps = { location: NavigationLocation; actions?: NavigationHeaderAction[]; + renderActions?: RenderNavigationHeaderActions; + getBreadcrumbHref?: GetNavigationBreadcrumbHref; onUpdate: (location: NavigationLocation) => void; onLoadSuggestions?: LoadPathSuggestions; className?: string; @@ -16,6 +23,8 @@ export type NavigationHeaderProps = { export const NavigationHeader: FC = ({ location, actions, + renderActions, + getBreadcrumbHref, onUpdate, onLoadSuggestions, className, @@ -25,9 +34,14 @@ export const NavigationHeader: FC = ({ - + {renderActions ? ( + renderActions({location, actions: actions ?? []}) + ) : ( + + )} ); }; diff --git a/src/modules/NavigationItemsList/NavigationItemsList.tsx b/src/modules/NavigationItemsList/NavigationItemsList.tsx index 5057b8f..14b0f70 100644 --- a/src/modules/NavigationItemsList/NavigationItemsList.tsx +++ b/src/modules/NavigationItemsList/NavigationItemsList.tsx @@ -2,6 +2,7 @@ import React from 'react'; import cn from 'bem-cn-lite'; import type { NavigationItem, + NavigationParentRowConfig, NavigationSortOrder, RenderNavigationItem, } from '../../types/navigation'; @@ -19,6 +20,7 @@ export type NavigationItemsListProps items: T[]; path?: string; search?: string; + parentRow?: NavigationParentRowConfig; sort?: NavigationSortOrder; onSortUpdate?: (sort: NavigationSortOrder) => void; titleLabel: string; @@ -36,6 +38,7 @@ export const NavigationItemsList = ({ items, path, search, + parentRow: parentRowConfig, sort, onSortUpdate, titleLabel, @@ -48,7 +51,7 @@ export const NavigationItemsList = ({ onItemClick, className, }: NavigationItemsListProps) => { - const parentRow = useParentRow(path, search); + const parentRow = useParentRow(path, search, parentRowConfig?.showDuringSearch); const rows = (parentRow ? [parentRow as T, ...items] : items) as T[]; diff --git a/src/modules/NavigationItemsList/internal/useParentRow.ts b/src/modules/NavigationItemsList/internal/useParentRow.ts index 3269779..c7b3bf5 100644 --- a/src/modules/NavigationItemsList/internal/useParentRow.ts +++ b/src/modules/NavigationItemsList/internal/useParentRow.ts @@ -5,8 +5,10 @@ import {getParentPath} from '../../../helpers/getParentPath'; export function useParentRow( path: string | undefined, search: string | undefined, + showDuringSearch = false, ): NavigationItem | undefined { - const parentPath = path && !search ? getParentPath(path) : undefined; + const parentPath = + path && path !== '/' && (!search || showDuringSearch) ? getParentPath(path) : undefined; return useMemo(() => { if (!parentPath) { diff --git a/src/types/navigation.ts b/src/types/navigation.ts index c4ea788..a780fcf 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -19,6 +19,17 @@ export type NavigationAction = { export type NavigationHeaderAction = NavigationAction; +export type NavigationHeaderActionsRenderContext = { + location: NavigationLocation; + actions: NavigationHeaderAction[]; +}; + +export type RenderNavigationHeaderActions = ( + context: NavigationHeaderActionsRenderContext, +) => ReactNode; + +export type GetNavigationBreadcrumbHref = (location: NavigationLocation) => string | undefined; + export type NavigationCluster = { id: string; title: string; @@ -193,9 +204,15 @@ export type NavigationListStateConfig = { export type NavigationHeaderConfig = { actions?: NavigationHeaderAction[]; + renderActions?: RenderNavigationHeaderActions; + getBreadcrumbHref?: GetNavigationBreadcrumbHref; onLoadSuggestions?: LoadPathSuggestions; }; +export type NavigationParentRowConfig = { + showDuringSearch?: boolean; +}; + export type NavigationDetailPanelConfig = { openedItem?: TItem; onItemOpen?: (item: TItem) => void; diff --git a/src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx b/src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx index 0ab1501..a0a4d04 100644 --- a/src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx +++ b/src/widgets/QueriesNavigation/QueriesNavigation.stories.tsx @@ -17,7 +17,15 @@ import FileArrowRightOutIcon from '@gravity-ui/icons/svgs/file-arrow-right-out.s import ArrowUpRightFromSquareIcon from '@gravity-ui/icons/svgs/arrow-up-right-from-square.svg'; import CodeIcon from '@gravity-ui/icons/svgs/code.svg'; import GearIcon from '@gravity-ui/icons/svgs/gear.svg'; -import {Flex, Icon, Label, Text} from '@gravity-ui/uikit'; +import { + Button, + DropdownMenu, + type DropdownMenuItem, + Flex, + Icon, + Label, + Text, +} from '@gravity-ui/uikit'; import {ClusterRow, NavigationItemRow} from '../../components'; import {SCHEMA_COLUMNS} from '../../modules/NavigationSchema/story/mockData'; import {PREVIEW_COLUMNS, PREVIEW_ROWS} from '../../modules/NavigationPreview/story/mockData'; @@ -400,6 +408,104 @@ const CustomDetailResolverStory = () => { ); }; +const CustomHeaderActionsStory = () => { + const {location, onUpdate, items} = useNavigationStoryState({ + cluster: 'northstar', + path: '/home', + }); + + return ( +
+ { + const menuItems = actions + .filter(({hidden}) => !hidden) + .map(({title, disabled, onClick}) => ({ + text: title, + disabled, + action: () => onClick(actionLocation), + })); + const href = `/navigation/${actionLocation.cluster}${actionLocation.path ?? ''}`; + + return ( + + ( + + )} + /> + + + ); + }, + }} + /> +
+ ); +}; + +const LinkedBreadcrumbsStory = () => { + const {location, onUpdate, items} = useNavigationStoryState({ + cluster: 'northstar', + path: '/home', + }); + const [openedItem, setOpenedItem] = useState(undefined); + + return ( +
+ + cluster ? `/navigation/${cluster}${path ?? ''}` : '/navigation', + }} + detail={{ + openedItem, + onItemOpen: setOpenedItem, + onClose: () => setOpenedItem(undefined), + }} + /> +
+ ); +}; + +const ParentRowDuringSearchStory = () => { + const {location, onUpdate} = useLocationState({cluster: 'northstar', path: '/home'}); + const [search, setSearch] = useState('cluster'); + const items = getItemsForPath(location.path).filter(({title}) => title.includes(search)); + + return ( +
+ ( +
+ +
+ )} + /> +
+ ); +}; + export const ClustersToItems: Story = {render: () => }; export const Loading: Story = {render: () => }; export const Empty: Story = {render: () => }; @@ -407,3 +513,6 @@ export const EmptySearch: Story = {render: () => }; export const Error: Story = {render: () => }; export const CustomRows: Story = {render: () => }; export const CustomDetailResolver: Story = {render: () => }; +export const CustomHeaderActions: Story = {render: () => }; +export const LinkedBreadcrumbs: Story = {render: () => }; +export const ParentRowDuringSearch: Story = {render: () => }; diff --git a/src/widgets/QueriesNavigation/QueriesNavigation.tsx b/src/widgets/QueriesNavigation/QueriesNavigation.tsx index 64403f2..49394f9 100644 --- a/src/widgets/QueriesNavigation/QueriesNavigation.tsx +++ b/src/widgets/QueriesNavigation/QueriesNavigation.tsx @@ -14,6 +14,7 @@ import type { NavigationItem, NavigationListStateConfig, NavigationLocation, + NavigationParentRowConfig, NavigationSearchConfig, NavigationSortConfig, RenderNavigationCluster, @@ -39,6 +40,7 @@ export type QueriesNavigationProps< sort?: NavigationSortConfig; listState?: NavigationListStateConfig; detail?: NavigationDetailPanelConfig; + parentRow?: NavigationParentRowConfig; renderClusterItem?: RenderNavigationCluster; renderNavigationItem?: RenderNavigationItem; onClusterClick?: (cluster: TCluster) => void; @@ -65,6 +67,7 @@ export const QueriesNavigation = < sort, listState, detail, + parentRow, renderClusterItem, renderNavigationItem, onClusterClick, @@ -72,7 +75,7 @@ export const QueriesNavigation = < className, }: QueriesNavigationProps) => { const {loading, error, hasMore, onLoadMore} = listState ?? {}; - const {actions, onLoadSuggestions} = header ?? {}; + const {actions, renderActions, getBreadcrumbHref, onLoadSuggestions} = header ?? {}; const {value: searchValue, onUpdate: onSearchUpdate} = search ?? {}; const {value: sortValue, onUpdate: onSortUpdate} = sort ?? {}; const { @@ -138,6 +141,8 @@ export const QueriesNavigation = < config={body.config} location={{cluster: location.cluster, path: body.item.path}} actions={resolvedDetailActions} + renderActions={renderActions} + getBreadcrumbHref={getBreadcrumbHref} onUpdate={handleNavigate} onLoadSuggestions={onLoadSuggestions} search={detailSearch} @@ -154,6 +159,8 @@ export const QueriesNavigation = < @@ -180,6 +187,7 @@ export const QueriesNavigation = < items={items} path={location.path} search={searchValue} + parentRow={parentRow} sort={sortValue} onSortUpdate={onSortUpdate} titleLabel={i18n('title_name')} diff --git a/tests/components/Breadcrumbs.test.tsx b/tests/components/Breadcrumbs.test.tsx new file mode 100644 index 0000000..be67897 --- /dev/null +++ b/tests/components/Breadcrumbs.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment jsdom + +import React, {act} from 'react'; +import type {Root} from 'react-dom/client'; +import {createRoot} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {Breadcrumbs} from '../../src/components/Breadcrumbs/Breadcrumbs'; + +class ResizeObserverMock { + disconnect = vi.fn(); + observe = vi.fn(); + unobserve = vi.fn(); +} + +describe('Breadcrumbs links', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + const renderBreadcrumbs = ( + onUpdate = vi.fn(), + getBreadcrumbHref: (location: {cluster?: string; path?: string}) => string | undefined = ({ + cluster, + path, + }) => `#/navigation/${cluster}${path ?? ''}`, + path = '/home', + ) => { + act(() => { + root.render( + , + ); + }); + }; + + it('resolves links for root, collapsed ancestors, and the current location', () => { + const getBreadcrumbHref = vi.fn( + ({cluster, path}) => `#/navigation/${cluster}${path ?? ''}`, + ); + + renderBreadcrumbs(vi.fn(), getBreadcrumbHref, '/one/two/three'); + + expect(getBreadcrumbHref.mock.calls.map(([location]) => location)).toEqual([ + {cluster: 'test', path: undefined}, + {cluster: 'test', path: '/one'}, + {cluster: 'test', path: '/one/two'}, + {cluster: 'test', path: '/one/two/three'}, + ]); + expect(container.querySelector('a[href="#/navigation/test"]')).not.toBeNull(); + expect(container.querySelector('a[href="#/navigation/test/one/two/three"]')).not.toBeNull(); + }); + + it('handles a plain left click on an ancestor exactly once', () => { + const onUpdate = vi.fn(); + renderBreadcrumbs(onUpdate); + const rootLink = container.querySelector('a[href="#/navigation/test"]'); + const event = new MouseEvent('click', {bubbles: true, cancelable: true, button: 0}); + + act(() => rootLink?.dispatchEvent(event)); + + expect(event.defaultPrevented).toBe(true); + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledWith({cluster: 'test', path: undefined}); + }); + + it('preserves modified and middle-click browser behavior', () => { + const onUpdate = vi.fn(); + renderBreadcrumbs(onUpdate); + const rootLink = container.querySelector('a[href="#/navigation/test"]'); + + for (const modifier of ['ctrlKey', 'metaKey', 'shiftKey', 'altKey'] as const) { + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + button: 0, + [modifier]: true, + }); + act(() => rootLink?.dispatchEvent(event)); + expect(event.defaultPrevented).toBe(false); + } + + const middleClick = new MouseEvent('auxclick', { + bubbles: true, + cancelable: true, + button: 1, + }); + act(() => rootLink?.dispatchEvent(middleClick)); + + expect(middleClick.defaultPrevented).toBe(false); + expect(onUpdate).not.toHaveBeenCalled(); + }); + + it('does not navigate again on a plain click of the current link', () => { + const onUpdate = vi.fn(); + renderBreadcrumbs(onUpdate); + const currentLink = container.querySelector( + 'a[href="#/navigation/test/home"]', + ); + const event = new MouseEvent('click', {bubbles: true, cancelable: true, button: 0}); + + act(() => currentLink?.dispatchEvent(event)); + + expect(event.defaultPrevented).toBe(true); + expect(onUpdate).not.toHaveBeenCalled(); + expect(currentLink?.getAttribute('aria-current')).toBe('page'); + }); + + it('keeps the previous non-link behavior when href is unavailable', () => { + const onUpdate = vi.fn(); + renderBreadcrumbs(onUpdate, () => undefined); + const rootItem = Array.from(container.querySelectorAll('[role="link"]')).find( + (item) => item.textContent === 'test', + ); + + expect(rootItem?.tagName).toBe('SPAN'); + act(() => rootItem?.dispatchEvent(new MouseEvent('click', {bubbles: true, button: 0}))); + expect(onUpdate).toHaveBeenCalledWith({cluster: 'test', path: undefined}); + }); +}); diff --git a/tests/components/NavigationHeader.test.tsx b/tests/components/NavigationHeader.test.tsx new file mode 100644 index 0000000..2224684 --- /dev/null +++ b/tests/components/NavigationHeader.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment jsdom + +import React, {act} from 'react'; +import type {Root} from 'react-dom/client'; +import {createRoot} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {NavigationHeader} from '../../src/modules/NavigationHeader/NavigationHeader'; +import {NavigationDetail} from '../../src/modules/NavigationDetail/NavigationDetail'; +import {QueriesNavigation} from '../../src/widgets/QueriesNavigation/QueriesNavigation'; +import type {NavigationHeaderAction} from '../../src/types/navigation'; + +vi.mock('../../src/components/Breadcrumbs', () => ({ + Breadcrumbs: () =>
, +})); + +const LOCATION = {cluster: 'test', path: '/home'}; + +const makeAction = (id: string): NavigationHeaderAction => ({ + id, + title: id, + content: id, + onClick: vi.fn(), +}); + +describe('NavigationHeader actions', () => { + 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(); + }); + + it('keeps rendering standard action buttons by default', () => { + const action = makeAction('standard-action'); + + act(() => { + root.render( + , + ); + }); + + const button = container.querySelector( + 'button[aria-label="standard-action"]', + ); + expect(button).not.toBeNull(); + + act(() => button?.click()); + expect(action.onClick).toHaveBeenCalledWith(LOCATION); + }); + + it('renders custom actions directly and treats null as an empty result', () => { + const action = makeAction('custom-action'); + const renderActions = vi.fn(({location, actions}) => ( + + {actions[0]?.title} + + )); + + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('a[href="/custom"]')?.textContent).toBe('custom-action'); + expect(container.querySelector('button[aria-label="custom-action"]')).toBeNull(); + expect(renderActions).toHaveBeenCalledWith({location: LOCATION, actions: [action]}); + + act(() => { + root.render( + null} + onUpdate={vi.fn()} + />, + ); + }); + + expect(container.querySelector('button[aria-label="custom-action"]')).toBeNull(); + }); + + it('passes an empty array to the custom renderer', () => { + const renderActions = vi.fn(() => null); + + act(() => { + root.render( + , + ); + }); + + expect(renderActions).toHaveBeenCalledWith({location: LOCATION, actions: []}); + }); + + it('passes merged detail actions in their existing order', () => { + const inheritedAction = makeAction('inherited'); + const detailAction = makeAction('detail'); + const renderActions = vi.fn(() => null); + + act(() => { + root.render( + , + ); + }); + + expect(renderActions).toHaveBeenCalledWith({ + location: LOCATION, + actions: [inheritedAction, detailAction], + }); + }); + + it('uses detail action precedence and the opened item location in the widget', () => { + const headerAction = makeAction('header'); + const panelAction = makeAction('panel'); + const configAction = makeAction('config'); + const renderActions = vi.fn(() => null); + const firstItem = {path: '/home/first', title: 'first'}; + const secondItem = {path: '/home/second', title: 'second'}; + const resolve = () => ({ + tabs: [{id: 'content', title: 'Content', content: 'content'}], + actions: [configAction], + }); + + act(() => { + root.render( + , + ); + }); + + expect(renderActions).toHaveBeenLastCalledWith({ + location: {cluster: 'test', path: firstItem.path}, + actions: [panelAction, configAction], + }); + + act(() => { + root.render( + , + ); + }); + + expect(renderActions).toHaveBeenLastCalledWith({ + location: {cluster: 'test', path: secondItem.path}, + actions: [headerAction, configAction], + }); + }); +}); diff --git a/tests/components/NavigationItemsList.test.tsx b/tests/components/NavigationItemsList.test.tsx new file mode 100644 index 0000000..5aa8fdc --- /dev/null +++ b/tests/components/NavigationItemsList.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom + +import React, {act} from 'react'; +import type {Root} from 'react-dom/client'; +import {createRoot} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {NavigationItemsList} from '../../src/modules/NavigationItemsList/NavigationItemsList'; +import type {NavigationItem} from '../../src/types/navigation'; + +type LazyListProps = { + items: NavigationItem[]; + isEmpty?: boolean; + emptyContent?: React.ReactNode; + renderItem: (item: NavigationItem, isActive: boolean, index: number) => React.ReactNode; + onItemClick?: (item: NavigationItem, index: number) => void; +}; + +const lazyListState = vi.hoisted(() => ({props: undefined as LazyListProps | undefined})); + +vi.mock('../../src/components/LazyList', () => ({ + LazyList: (props: LazyListProps) => { + lazyListState.props = props; + return
{props.isEmpty ? props.emptyContent : null}
; + }, +})); + +const ITEM: NavigationItem = {path: '/home/item', title: 'item'}; + +describe('NavigationItemsList parent row', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + lazyListState.props = undefined; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const renderList = ( + props: Partial>> = {}, + ) => { + act(() => { + root.render( + , + ); + }); + }; + + it('keeps the parent hidden during search by default', () => { + renderList({search: 'item'}); + expect(lazyListState.props?.items).toEqual([ITEM]); + + renderList({search: 'item', parentRow: {showDuringSearch: false}}); + expect(lazyListState.props?.items).toEqual([ITEM]); + }); + + it('shows the parent before results and marks custom render data', () => { + const renderRowItem = vi.fn(() => null); + const onItemClick = vi.fn(); + renderList({ + search: 'item', + parentRow: {showDuringSearch: true}, + renderRowItem, + onItemClick, + }); + + const parent = lazyListState.props?.items[0]; + expect(parent).toMatchObject({path: '/home', hasChildren: true}); + expect(lazyListState.props?.items[1]).toBe(ITEM); + if (!parent) { + throw new Error('Expected a parent row'); + } + + lazyListState.props?.renderItem(parent, false, 0); + expect(renderRowItem).toHaveBeenCalledWith({ + item: parent, + index: 0, + isActive: false, + isParentRow: true, + }); + + lazyListState.props?.onItemClick?.(parent, 0); + expect(onItemClick).toHaveBeenCalledTimes(1); + expect(onItemClick).toHaveBeenCalledWith(parent); + }); + + it('shows the parent once alongside an empty search state', () => { + const onItemClick = vi.fn(); + renderList({ + items: [], + search: 'missing', + parentRow: {showDuringSearch: true}, + emptyContent:
, + renderRowItem: ({isParentRow}) => + isParentRow ?
: null, + onItemClick, + }); + + expect(container.querySelectorAll('[data-testid="parent-row-content"]')).toHaveLength(1); + expect(container.querySelector('[data-testid="empty-content"]')).not.toBeNull(); + + const parentContainer = container.querySelector('.qp-navigation-items-list__parent-row'); + act(() => parentContainer?.dispatchEvent(new MouseEvent('click', {bubbles: true}))); + expect(onItemClick).toHaveBeenCalledTimes(1); + }); + + it('does not create a parent row at the cluster root', () => { + renderList({path: '/', search: 'item', parentRow: {showDuringSearch: true}}); + expect(lazyListState.props?.items).toEqual([ITEM]); + + renderList({path: undefined, search: undefined}); + expect(lazyListState.props?.items).toEqual([ITEM]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index fb00deb..4eed655 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,10 @@ import {defineConfig} from 'vitest/config'; export default defineConfig({ test: { include: ['tests/**/*.test.{ts,tsx}'], + server: { + deps: { + inline: ['@gravity-ui/uikit'], + }, + }, }, });