From 0a12ddb2b67bb41230d5c7020d4064c5a7b74561 Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Fri, 25 Sep 2026 11:38:12 +0300 Subject: [PATCH 1/2] chore(Graph): up graph version --- package-lock.json | 14 ++++++++++---- package.json | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50870de..7d2bbcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@gravity-ui/charts": "^1.57.2", "@gravity-ui/dashkit": "^10.4.0", "@gravity-ui/date-components": "^3.4.2", - "@gravity-ui/graph": "1.11.3", + "@gravity-ui/graph": "1.12.1", "@gravity-ui/i18n": "^1.8.0", "@gravity-ui/illustrations": "^2.1.1", "@gravity-ui/react-data-table": "^2.2.1", @@ -5061,9 +5061,9 @@ } }, "node_modules/@gravity-ui/graph": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@gravity-ui/graph/-/graph-1.11.3.tgz", - "integrity": "sha512-GN/yBKExjLlCBs6dK89uJwTyIJiVWEUeUWM/cG5hHNcwT969yIk7Weyi1XPuMlHtX5UepEoFJYPzXrf7+1MHYw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@gravity-ui/graph/-/graph-1.12.1.tgz", + "integrity": "sha512-Nt4LNv2PRlXoh5n76f9eJLLzTvMOlNdn+O95AbS8qHG0p4SuQ6xXq8jul5LMou+cK8Tt6wHyDuUPDHzmaBy5/w==", "license": "MIT", "dependencies": { "@preact/signals-core": "^1.12.2", @@ -5077,8 +5077,14 @@ "yarn": "Please use npm instead of yarn to install dependencies" }, "peerDependencies": { + "@playwright/test": ">=1.58.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@playwright/test": { + "optional": true + } } }, "node_modules/@gravity-ui/i18n": { diff --git a/package.json b/package.json index df64b44..6fad2ba 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "@gravity-ui/charts": "^1.57.2", "@gravity-ui/dashkit": "^10.4.0", "@gravity-ui/date-components": "^3.4.2", - "@gravity-ui/graph": "1.11.3", + "@gravity-ui/graph": "1.12.1", "@gravity-ui/i18n": "^1.8.0", "@gravity-ui/illustrations": "^2.1.1", "@gravity-ui/react-data-table": "^2.2.1", From 2a2e67a47b1ec64afc660460f441d94ce3d97dab Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Fri, 25 Sep 2026 11:53:09 +0300 Subject: [PATCH 2/2] feat: extend SavedQueries integration API --- README.md | 38 +++ src/modules/SavedQueryRow/SavedQueryRow.tsx | 2 + .../SavedQuerySearchRow.tsx | 2 + .../SavedQueries/SavedQueries.stories.tsx | 136 ++++++++- src/widgets/SavedQueries/SavedQueries.tsx | 13 + tests/components/SavedQueries.test.tsx | 287 ++++++++++++++++++ 6 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 tests/components/SavedQueries.test.tsx diff --git a/README.md b/README.md index bbf176c..d246040 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,44 @@ function HistoryPanel() { } ``` +### SavedQueries + +Saved queries with search, filters, editable titles, comparison, configurable metadata, +custom author rendering, incremental loading, and custom links or rows. + +```tsx +import {SavedQueries, type QueryListLinkRenderer} from '@gravity-ui/querieskit'; + +const renderLink: QueryListLinkRenderer = ({href = '', ...props}) => ( + +); + +; +``` + +An empty list with `loading` shows the initial spinner; loading with existing items shows an +inline loader after them. Appending a page preserves the list instance and scroll position. +Keep `loading` controlled, including passing `false` between requests, to prevent repeated +load requests. The application owns the initial request, data, filtering, and routing. + +`renderLink` applies to both regular and full-text search rows with an `href`. Without it, +rows use regular `` elements. Rows without an `href`, or with editing or comparison enabled, +render without a link and do not call `renderLink`. + +`renderRowItem` receives the original item (including group headers), index, variant, activity, +editing/comparison data, visible fields, actions, and `renderLink`. It replaces the standard row; +the consumer renders group headers and applies links as needed. Returning `null` hides the row +contents. `renderAuthor` customizes the author in standard rows and respects field visibility. +Set `search.fullSearchAvailable` to `false` to disable full-text search and hide its toggle. + ### TutorialsHistory Tutorial list with search, optional filters, selectable rows, incremental loading, and custom diff --git a/src/modules/SavedQueryRow/SavedQueryRow.tsx b/src/modules/SavedQueryRow/SavedQueryRow.tsx index 0e083c8..5e7b644 100644 --- a/src/modules/SavedQueryRow/SavedQueryRow.tsx +++ b/src/modules/SavedQueryRow/SavedQueryRow.tsx @@ -24,6 +24,7 @@ export const SavedQueryRow = ({ editing, visibleFields, renderAuthor, + renderLink, }: SavedQueryRowProps) => { const {author, engine, href, savedAt} = item; const isEditing = Boolean(editing?.enabled); @@ -39,6 +40,7 @@ export const SavedQueryRow = ({ return ( diff --git a/src/modules/SavedQuerySearchRow/SavedQuerySearchRow.tsx b/src/modules/SavedQuerySearchRow/SavedQuerySearchRow.tsx index ca2343e..85cab90 100644 --- a/src/modules/SavedQuerySearchRow/SavedQuerySearchRow.tsx +++ b/src/modules/SavedQuerySearchRow/SavedQuerySearchRow.tsx @@ -21,6 +21,7 @@ export const SavedQuerySearchRow = ({ editing, visibleFields, renderAuthor, + renderLink, }: SavedQuerySearchRowProps) => { const {author, engine, href, query, savedAt} = item; const authorContent = @@ -34,6 +35,7 @@ export const SavedQuerySearchRow = ({ query={query} language={MonacoLanguage.YQL} href={href} + renderLink={renderLink} disabled={Boolean(editing?.enabled) || Boolean(comparison?.enabled)} className={block()} header={ diff --git a/src/widgets/SavedQueries/SavedQueries.stories.tsx b/src/widgets/SavedQueries/SavedQueries.stories.tsx index 16d3fe3..fe4b145 100644 --- a/src/widgets/SavedQueries/SavedQueries.stories.tsx +++ b/src/widgets/SavedQueries/SavedQueries.stories.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import {Text} from '@gravity-ui/uikit'; import {action} from 'storybook/actions'; @@ -6,10 +6,13 @@ import { QueryListFieldKey, QueryListFilterConfig, QueryListItem, + QueryListLinkRenderer, QueryListVisibleFieldsConfig, } from '../../types/queryList'; import {SavedQuery} from '../../types/savedQueries'; import {SavedQueries} from './SavedQueries'; +import {HistoryGroupHeader} from '../../components/HistoryGroupHeader'; +import {RowLink} from '../../components/RowLink'; const QUERY = `SELECT session_id, @@ -190,3 +193,134 @@ const EmptyStory = () => { export const Default: Story = {render: () => }; export const CustomAuthor: Story = {render: () => }; export const Empty: Story = {render: () => }; + +const LINK_ITEMS = BASE_ITEMS.map((item) => + 'header' in item ? item : {...item, href: `/saved-queries/${item.id}`}, +); + +const renderRouterLink: QueryListLinkRenderer = ({onClick, ...props}) => ( + { + onClick?.(event); + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + event.preventDefault(); + action('routerNavigate')(props.href); + }} + /> +); + +const PAGINATION_ITEMS: QueryListItem[] = Array.from({length: 24}, (_, index) => ({ + ...(BASE_ITEMS[0] as SavedQuery), + id: `saved-${index + 1}`, + title: `Saved report ${index + 1}`, + href: `/saved-queries/saved-${index + 1}`, +})); + +const PaginatedStory = () => { + const [count, setCount] = useState(8); + const [loading, setLoading] = useState(false); + const timeout = useRef(undefined); + useEffect(() => () => window.clearTimeout(timeout.current), []); + return ( +
+ { + if (loading || count >= PAGINATION_ITEMS.length) return; + action('onLoadMore')(count); + setLoading(true); + timeout.current = window.setTimeout(() => { + setCount((current) => current + 8); + setLoading(false); + }, 700); + }} + renderLink={renderRouterLink} + search={{onUpdate: action('onSearchUpdate')}} + /> +
+ ); +}; + +const RouterLinksStory = () => { + const [search, setSearch] = useState({value: 'SELECT', fullSearch: false}); + return ( +
+ +
+ ); +}; + +const CustomRowRendererStory = () => { + const [search, setSearch] = useState({value: 'SELECT', fullSearch: false}); + return ( +
+ + 'header' in item ? ( + + ) : ( + + {item.title} + {variant === 'search' &&
{item.query}
} +
+ ) + } + search={{...search, hasClear: true, onUpdate: setSearch}} + /> +
+ ); +}; + +const FullSearchUnavailableStory = () => { + const [search, setSearch] = useState({value: 'report', fullSearch: true}); + return ( +
+ { + action('onSearchUpdate')(data); + setSearch(data); + }, + }} + /> +
+ ); +}; + +export const InitialLoading: Story = { + args: {items: [], loading: true, search: {onUpdate: action('onSearchUpdate')}}, + decorators: [ + (StoryComponent) => ( +
+ +
+ ), + ], +}; +export const Paginated: Story = {render: () => }; +export const RouterLinks: Story = {render: () => }; +export const CustomRowRenderer: Story = {render: () => }; +export const FullSearchUnavailable: Story = {render: () => }; diff --git a/src/widgets/SavedQueries/SavedQueries.tsx b/src/widgets/SavedQueries/SavedQueries.tsx index d93904a..bafeeae 100644 --- a/src/widgets/SavedQueries/SavedQueries.tsx +++ b/src/widgets/SavedQueries/SavedQueries.tsx @@ -6,6 +6,7 @@ import type { QueryListEditingConfig, QueryListFilterConfig, QueryListItem, + QueryListLinkRenderer, QueryListRowAction, QueryListRowRenderData, QueryListSearchConfig, @@ -31,6 +32,10 @@ export type SavedQueriesProps = { renderRowItem?: (data: QueryListRowRenderData) => React.ReactNode; getRowActions?: (item: T) => QueryListRowAction[]; onListItemClick?: (item: QueryListItem) => void; + hasMore?: boolean; + loading?: boolean; + onLoadMore?: () => void; + renderLink?: QueryListLinkRenderer; }; const block = cn('qp-saved-queries'); @@ -49,6 +54,10 @@ export const SavedQueries = ({ renderRowItem, getRowActions, onListItemClick, + hasMore, + loading, + onLoadMore, + renderLink, className, }: SavedQueriesProps) => { return ( @@ -72,6 +81,10 @@ export const SavedQueries = ({ ) } onListItemClick={onListItemClick} + hasMore={hasMore} + loading={loading} + onLoadMore={onLoadMore} + renderLink={renderLink} /> ); }; diff --git a/tests/components/SavedQueries.test.tsx b/tests/components/SavedQueries.test.tsx new file mode 100644 index 0000000..55d5d5a --- /dev/null +++ b/tests/components/SavedQueries.test.tsx @@ -0,0 +1,287 @@ +// @vitest-environment jsdom + +import React, {act} 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 {SavedQueries, type SavedQueriesProps} from '../../src/widgets/SavedQueries'; +import type {SavedQuery} from '../../src/types/savedQueries'; +import type {QueryListItem, QueryListRowRenderData} from '../../src/types/queryList'; + +// Keep the widget, QueriesList, RowsList, LazyList and row implementations real. +// Replace virtualization (which requires browser measurements), Monaco and the +// unrelated filter popup (its date picker imports CSS outside Vitest's pipeline). +vi.mock('../../src/components/HistoryFilter', () => ({HistoryFilter: () => null})); +vi.mock('@gravity-ui/uikit', async (importOriginal) => { + const actual = await importOriginal(); + const ReactModule = await import('react'); + return { + ...actual, + List: ReactModule.forwardRef(function TestList( + { + items, + renderItem, + onItemClick, + selectedItemIndex, + }: { + items: QueryListItem[]; + renderItem: ( + item: QueryListItem, + active: boolean, + index: number, + ) => React.ReactNode; + onItemClick: (item: QueryListItem, index: number) => void; + selectedItemIndex?: number; + }, + ref, + ) { + ReactModule.useImperativeHandle(ref, () => ({ + refContainer: {current: {resetAfterIndex: () => undefined}}, + })); + return ( +
+ {items.map((item, index) => ( +
onItemClick(item, index)}> + {renderItem(item, index === selectedItemIndex, index)} +
+ ))} +
+ ); + }), + }; +}); +vi.mock('../../src/components/MonacoEditor', () => ({ + MonacoEditor: ({value}: {value?: string}) =>
{value}
, + MonacoLanguage: {YQL: 'yql'}, +})); + +type ExtendedSavedQuery = SavedQuery & {team: string}; +const HEADER = {header: 'Saved reports', height: 28}; +const FIRST: ExtendedSavedQuery = { + id: 'saved-one', + title: 'First report', + href: '/saved/one', + height: 52, + query: 'SELECT 1', + author: 'Anna', + engine: 'SQL', + team: 'Analytics', + savedAt: '2026-04-29T12:00:00.000Z', +}; +const SECOND: ExtendedSavedQuery = {...FIRST, id: 2, title: 'Second report', href: '/saved/two'}; + +class IntersectionObserverMock { + static current: IntersectionObserverMock | undefined; + private callback: IntersectionObserverCallback; + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + IntersectionObserverMock.current = this; + } + public observe() {} + public disconnect() {} + public intersect() { + this.callback([{isIntersecting: true} as IntersectionObserverEntry], this as never); + } +} + +const routerLink = vi.fn((props: React.ComponentPropsWithoutRef<'a'>) => ( +
+)); + +describe('SavedQueries integration', () => { + let container: HTMLDivElement; + let root: Root; + const render = (props: Partial> = {}) => { + act(() => + root.render( + + + , + ), + ); + }; + const click = (element: Element | null) => { + expect(element).not.toBeNull(); + act(() => element?.dispatchEvent(new MouseEvent('click', {bubbles: true}))); + }; + const updateInput = (input: HTMLInputElement | null, value: string) => { + expect(input).not.toBeNull(); + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( + input, + value, + ); + input?.dispatchEvent(new Event('input', {bubbles: true})); + }); + }; + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock); + IntersectionObserverMock.current = undefined; + routerLink.mockClear(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it('connects initial loading, page loading and the load callback', () => { + const onLoadMore = vi.fn(); + render({items: [], loading: true, hasMore: true, onLoadMore}); + expect(container.querySelector('.qp-list-spinner')).not.toBeNull(); + expect(container.querySelector('[data-testid="list"]')).toBeNull(); + render({loading: false, hasMore: true, onLoadMore}); + act(() => IntersectionObserverMock.current?.intersect()); + act(() => IntersectionObserverMock.current?.intersect()); + expect(onLoadMore).toHaveBeenCalledTimes(1); + render({loading: true, hasMore: true, onLoadMore}); + expect(container.textContent).toContain(FIRST.title); + expect(container.querySelector('.qp-lazy-list__sentinel_loading')).not.toBeNull(); + act(() => IntersectionObserverMock.current?.intersect()); + expect(onLoadMore).toHaveBeenCalledTimes(1); + render({items: [HEADER, FIRST, SECOND], loading: false, hasMore: false, onLoadMore}); + expect(container.querySelector('.qp-lazy-list__sentinel')).toBeNull(); + act(() => IntersectionObserverMock.current?.intersect()); + expect(onLoadMore).toHaveBeenCalledTimes(1); + render({items: [], loading: false}); + expect(container.querySelector('.qp-list-spinner')).toBeNull(); + expect(container.querySelector('.qp-empty-content')).not.toBeNull(); + }); + + it('preserves the list on append and resets it on replacement or variant change', () => { + const onLoadMore = vi.fn(); + render({onLoadMore, selectedRowId: FIRST.id}); + const initialList = container.querySelector('[data-testid="list"]'); + expect(initialList?.getAttribute('data-selected-index')).toBe('1'); + render({items: [HEADER, FIRST, SECOND], onLoadMore, selectedRowId: SECOND.id}); + expect(container.querySelector('[data-testid="list"]')).toBe(initialList); + expect(initialList?.getAttribute('data-selected-index')).toBe('2'); + render({items: [HEADER, SECOND], onLoadMore}); + const replacedList = container.querySelector('[data-testid="list"]'); + expect(replacedList).not.toBe(initialList); + render({ + items: [HEADER, SECOND], + onLoadMore, + search: {value: 'SELECT', fullSearch: true, onUpdate: vi.fn()}, + }); + expect(container.querySelector('[data-testid="list"]')).not.toBe(replacedList); + }); + + it.each([false, true])('renders router links and metadata with fullSearch=%s', (fullSearch) => { + const search = {value: 'SELECT', fullSearch, onUpdate: vi.fn()}; + const renderAuthor = vi.fn((item: ExtendedSavedQuery) => {item.team}); + render({search, renderLink: routerLink, renderAuthor}); + expect(container.querySelector('a[data-router-link]')?.getAttribute('href')).toBe( + FIRST.href, + ); + expect(container.textContent).toContain('Analytics'); + expect(container.textContent).toContain('SQL'); + expect(container.textContent).toContain(HEADER.header); + if (fullSearch) + expect(container.querySelector('[data-testid="query"]')?.textContent).toContain( + 'SELECT 1', + ); + render({ + search, + renderLink: routerLink, + renderAuthor, + visibleFields: {fields: [], value: [], onChange: vi.fn()}, + }); + expect(container.textContent).not.toContain('Analytics'); + expect(container.textContent).not.toContain('SQL'); + expect(container.textContent).not.toContain('2026'); + render({search}); + expect(container.querySelector('a')?.getAttribute('href')).toBe(FIRST.href); + routerLink.mockClear(); + render({search, items: [{...FIRST, href: undefined}], renderLink: routerLink}); + expect(container.querySelector('a')).toBeNull(); + expect(routerLink).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + 'disables links for editing and comparison with fullSearch=%s', + (fullSearch) => { + const search = {value: 'SELECT', fullSearch, onUpdate: vi.fn()}; + render({search, renderLink: routerLink, editing: {rowId: FIRST.id, onSubmit: vi.fn()}}); + expect(container.querySelector('a')).toBeNull(); + expect(routerLink).not.toHaveBeenCalled(); + const onChange = vi.fn(); + const onListItemClick = vi.fn(); + render({ + search, + renderLink: routerLink, + onListItemClick, + comparison: { + enabled: true, + comparedRowIds: [], + onChange, + onCancel: vi.fn(), + onCompare: vi.fn(), + }, + }); + expect(container.querySelector('a')).toBeNull(); + expect(routerLink).not.toHaveBeenCalled(); + click(container.querySelector('[data-row="1"]')); + expect(onChange).toHaveBeenCalledWith(FIRST, true); + expect(onListItemClick).not.toHaveBeenCalled(); + }, + ); + + it('keeps title editing and row clicks working', () => { + const onSubmit = vi.fn(); + render({renderLink: routerLink, editing: {rowId: FIRST.id, onSubmit}}); + const input = container.querySelector('.qp-saved-query-row input') as HTMLInputElement; + updateInput(input, 'Updated report'); + act(() => input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}))); + expect(onSubmit).toHaveBeenCalledWith(FIRST, 'Updated report'); + const onListItemClick = vi.fn(); + render({renderLink: routerLink, onListItemClick}); + click(container.querySelector('[data-row="1"]')); + expect(onListItemClick).toHaveBeenCalledTimes(1); + expect(onListItemClick.mock.calls[0][0]).toBe(FIRST); + }); + + it.each([false, true])('passes complete custom row data with fullSearch=%s', (fullSearch) => { + const renderRowItem = vi.fn((_data: QueryListRowRenderData) => null); + render({ + renderLink: routerLink, + renderRowItem, + selectedRowId: FIRST.id, + search: {value: 'SELECT', fullSearch, onUpdate: vi.fn()}, + }); + expect(renderRowItem).toHaveBeenCalledWith( + expect.objectContaining({ + item: FIRST, + index: 1, + isActive: true, + variant: fullSearch ? 'search' : 'default', + renderLink: routerLink, + }), + ); + expect(renderRowItem.mock.calls.find(([data]) => data.item === FIRST)?.[0].item).toBe( + FIRST, + ); + expect(renderRowItem).toHaveBeenCalledWith( + expect.objectContaining({item: HEADER, index: 0}), + ); + expect(container.querySelector('.qp-saved-query-row')).toBeNull(); + expect(container.querySelector('.qp-saved-query-search-row')).toBeNull(); + expect(routerLink).not.toHaveBeenCalled(); + }); + + it('honors fullSearchAvailable and normalizes search updates', () => { + const onUpdate = vi.fn(); + render({search: {value: 'SELECT', fullSearch: true, onUpdate}}); + expect(container.querySelector('.qp-search-with-buttons__inner-buttons')).not.toBeNull(); + render({search: {value: 'SELECT', fullSearch: true, fullSearchAvailable: false, onUpdate}}); + expect(container.querySelector('.qp-search-with-buttons__inner-buttons')).toBeNull(); + expect(container.querySelector('.qp-saved-query-row')).not.toBeNull(); + expect(container.querySelector('.qp-saved-query-search-row')).toBeNull(); + updateInput(container.querySelector('input'), 'report'); + expect(onUpdate).toHaveBeenLastCalledWith({value: 'report', fullSearch: false}); + }); +});