From 79e370f5f5236fc038439220f8d692520b18b081 Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Mon, 5 Oct 2026 15:33:38 +0300 Subject: [PATCH 1/2] feat(QueryResults): add standard cell rendering --- src/components/DataTable/DataTable.tsx | 52 +++- .../QueryResultsTable/QueryResultsTable.scss | 35 --- .../QueryResultsTable/QueryResultsTable.tsx | 64 ++-- .../helpers/formatQueryResultValue.ts | 107 ++++++- src/components/QueryResultsTable/i18n/en.json | 10 +- src/components/QueryResultsTable/i18n/ru.json | 10 +- src/components/QueryResultsTable/index.ts | 7 + .../internal/QueryResultCell.scss | 50 +++ .../internal/QueryResultCell.tsx | 186 +++++++++-- .../QueryResults/QueryResults.stories.tsx | 190 +++++++++++- src/modules/QueryResults/QueryResults.tsx | 8 + src/modules/QueryResults/README.md | 126 ++++++++ src/modules/QueryResults/index.ts | 7 + src/types/declarations.d.ts | 9 + src/types/queryResults.ts | 82 +++-- tests/components/DataTableResize.test.tsx | 188 ++++++++++++ tests/components/QueryExecutionPanel.test.tsx | 35 ++- .../components/QueryResultsFormatting.test.ts | 223 ++++++++++++++ tests/components/QueryResultsTable.test.tsx | 290 ++++++++++++++++++ 19 files changed, 1546 insertions(+), 133 deletions(-) create mode 100644 src/components/QueryResultsTable/internal/QueryResultCell.scss create mode 100644 src/modules/QueryResults/README.md create mode 100644 tests/components/DataTableResize.test.tsx create mode 100644 tests/components/QueryResultsFormatting.test.ts create mode 100644 tests/components/QueryResultsTable.test.tsx diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx index 9e16dcf..a035cef 100644 --- a/src/components/DataTable/DataTable.tsx +++ b/src/components/DataTable/DataTable.tsx @@ -74,6 +74,55 @@ export function DataTable(props: DataTableProps) { const isEmpty = loaded && data.length === 0; const displayIndices = settings?.displayIndices !== false; + const containerRef = React.useRef(null); + const tableRef = React.useRef>(null); + + React.useEffect(() => { + if (!settings?.stickyHead || !settings.syncHeadOnResize || !globalThis.ResizeObserver) { + return undefined; + } + + // Observe the data table, not the separately rendered sticky header: updating + // header widths must not schedule another resize of its own. + const table = containerRef.current?.querySelector('.data-table__box table'); + if (!table) { + return undefined; + } + + let frame: number | undefined; + let previousWidth: number | undefined; + let previousHeight: number | undefined; + const observer = new ResizeObserver(([entry]) => { + if (!entry) { + return; + } + const {width, height} = entry.contentRect; + if (width === previousWidth && height === previousHeight) { + return; + } + previousWidth = width; + previousHeight = height; + if (frame === undefined) { + frame = requestAnimationFrame(() => { + frame = undefined; + tableRef.current?.resize(); + }); + } + }); + observer.observe(table); + + return () => { + observer.disconnect(); + if (frame !== undefined) { + cancelAnimationFrame(frame); + } + }; + }, [ + settings?.stickyHead, + settings?.syncHeadOnResize, + settings?.dynamicRender, + settings?.dynamicRenderType, + ]); const renderEmptyRow = () => { if (loading && !loaded) { @@ -84,9 +133,10 @@ export function DataTable(props: DataTableProps) { }; return ( -
+
> = { - columns: Array>; - /** Values use the YQL wire representation consumed by @gravity-ui/unipika. */ - rows: TRow[]; - loading?: boolean; - loaded?: boolean; - errorContent?: React.ReactNode; - rowKey?: (row: TRow, index: number) => string | number; - formatterSettings?: QueryResultFormatterSettings; - maxVisibleLines?: number; - emptyVariant?: EmptyContentVariant; - displayIndices?: boolean; - stripedRows?: boolean; - /** Set to false when the surrounding layout does not need a sticky header. */ - stickyHead?: Settings['stickyHead'] | false; - className?: string; -}; +export type {QueryResultsTableProps} from '../../types/queryResults'; export function QueryResultsTable>({ columns, @@ -41,6 +24,10 @@ export function QueryResultsTable>({ rowKey, formatterSettings, maxVisibleLines = DEFAULT_MAX_VISIBLE_LINES, + collapseAfterLines = maxVisibleLines, + maxInlineTextLength, + getCellOptions, + onCellPreview, emptyVariant, displayIndices = true, stripedRows = true, @@ -67,19 +54,34 @@ export function QueryResultsTable>({ {column.header ?? column.name} ), - render: ({row, index}) => ( - - ), + render: ({row, index}) => + column.render ? ( + column.render({row, index, column, value: row[column.name]}) + ) : ( + + ), }; }), - [columns, formatterSettings, maxVisibleLines], + [ + columns, + formatterSettings, + maxVisibleLines, + collapseAfterLines, + maxInlineTextLength, + getCellOptions, + onCellPreview, + ], ); if (errorContent) { diff --git a/src/components/QueryResultsTable/helpers/formatQueryResultValue.ts b/src/components/QueryResultsTable/helpers/formatQueryResultValue.ts index 1200bc8..bd777f7 100644 --- a/src/components/QueryResultsTable/helpers/formatQueryResultValue.ts +++ b/src/components/QueryResultsTable/helpers/formatQueryResultValue.ts @@ -1,5 +1,9 @@ import unipika from '@gravity-ui/unipika'; -import type {QueryResultDataType, QueryResultFormatterSettings} from '../../../types/queryResults'; +import type { + QueryResultCellOptions, + QueryResultDataType, + QueryResultFormatterSettings, +} from '../../../types/queryResults'; const formatter = unipika(); @@ -12,25 +16,110 @@ const DEFAULT_FORMATTER_SETTINGS: QueryResultFormatterSettings = { }; export type FormattedQueryResultValue = - {html: string; text: string; error: false} | {html: ''; text: ''; error: true}; + | { + html: string; + text: string; + error: false; + isIncomplete: boolean; + tag?: string; + isTooLarge: boolean; + } + | {html: ''; text: ''; error: true}; + +function hasIncompleteNode(value: unknown): boolean { + if (!value || typeof value !== 'object') { + return false; + } + if ('$incomplete' in value && value.$incomplete === true) { + return true; + } + return Object.values(value).some(hasIncompleteNode); +} + +function exceedsInlineLimit(text: string, tag: string | undefined, limit: number | undefined) { + const isExempt = tag === 'url' || /^(audio|video|image)\//.test(tag ?? ''); + return !isExempt && limit !== undefined && text.length >= limit; +} + +function hasConverterLimits(settings: QueryResultFormatterSettings) { + return (settings.maxListSize ?? 0) > 0 || (settings.maxStringSize ?? 0) > 0; +} + +function resolveCompleteness( + input: [unknown, unknown], + node: ReturnType, + flags: {incomplete?: boolean}, + hasLimits: boolean, + copySettings: QueryResultFormatterSettings, +) { + const incompleteNode = hasIncompleteNode(node); + if (incompleteNode || !hasLimits) { + return { + isIncomplete: incompleteNode || Boolean(flags.incomplete), + fullNode: undefined, + }; + } + + const fullFlags: {incomplete?: boolean} = {}; + const fullNode = formatter.converters.yql(input, {...copySettings}, fullFlags); + // Limited converter flags also count omitted null fields and variant wrappers. + // Compare converted values for actual truncation, including tags that discard markers. + // With limits removed, converter flags report only server incompleteness. + return { + isIncomplete: + Boolean(fullFlags.incomplete) || JSON.stringify(node) !== JSON.stringify(fullNode), + fullNode, + }; +} export function formatQueryResultValue( value: unknown, type: QueryResultDataType, settings?: QueryResultFormatterSettings, + options?: QueryResultCellOptions, + maxInlineTextLength?: number, ): FormattedQueryResultValue { - const formatterSettings = {...DEFAULT_FORMATTER_SETTINGS, ...settings}; + const formatterSettings = { + ...DEFAULT_FORMATTER_SETTINGS, + ...settings, + ...options?.formatterSettings, + }; const input: [unknown, unknown] = [value, type]; try { + const flags: {incomplete?: boolean} = {}; + const node = formatter.converters.yql(input, {...formatterSettings}, flags); + const hasLimits = hasConverterLimits(formatterSettings); + const copySettings = { + ...formatterSettings, + maxListSize: undefined, + maxStringSize: undefined, + }; + const {isIncomplete, fullNode} = + options?.isIncomplete === undefined + ? resolveCompleteness(input, node, flags, hasLimits, copySettings) + : {isIncomplete: options.isIncomplete, fullNode: undefined}; + const tag = options?.tag ?? node.$tag; + const formattedText = formatter.format(node, {...formatterSettings, asHTML: false}); + const isTooLarge = exceedsInlineLimit(formattedText, tag, maxInlineTextLength); + let text = options?.copyText ?? formattedText; + + if (options?.copyText === undefined && !isIncomplete && hasLimits) { + // An explicit completeness override must still copy all available input. + const copyNode = fullNode ?? formatter.converters.yql(input, {...copySettings}); + text = formatter.format(copyNode, {...copySettings, asHTML: false}); + } + return { - html: formatter.formatFromYQL(input, {...formatterSettings, asHTML: true}), - text: formatter.formatFromYQL(input, { - ...formatterSettings, - asHTML: false, - maxStringSize: undefined, - }), + html: + isTooLarge || (isIncomplete && tag !== undefined) + ? '' + : formatter.format(node, {...formatterSettings, asHTML: true}), + text, error: false, + isIncomplete, + tag, + isTooLarge, }; } catch { return {html: '', text: '', error: true}; diff --git a/src/components/QueryResultsTable/i18n/en.json b/src/components/QueryResultsTable/i18n/en.json index 7e6a50f..d9baeda 100644 --- a/src/components/QueryResultsTable/i18n/en.json +++ b/src/components/QueryResultsTable/i18n/en.json @@ -1,5 +1,13 @@ { "action_show-more": "Show more", "action_show-less": "Show less", - "alert_format-value-error": "Unable to format value" + "alert_format-value-error": "Unable to format value", + "action_copy": "Copy", + "context_copied": "Copied", + "action_preview": "Preview", + "action_retry-preview": "Retry preview", + "alert_incomplete-tag": "Incomplete '{{tag}}' type", + "alert_incomplete-value": "Value is incomplete", + "context_value-too-large": "Value is too large to display inline", + "alert_preview-error": "Unable to preview value. Try again." } diff --git a/src/components/QueryResultsTable/i18n/ru.json b/src/components/QueryResultsTable/i18n/ru.json index c83771e..79bff24 100644 --- a/src/components/QueryResultsTable/i18n/ru.json +++ b/src/components/QueryResultsTable/i18n/ru.json @@ -1,5 +1,13 @@ { "action_show-more": "Показать больше", "action_show-less": "Показать меньше", - "alert_format-value-error": "Не удалось отформатировать значение" + "alert_format-value-error": "Не удалось отформатировать значение", + "action_copy": "Копировать", + "context_copied": "Скопировано", + "action_preview": "Предпросмотр", + "action_retry-preview": "Повторить предпросмотр", + "alert_incomplete-tag": "Неполное значение типа '{{tag}}'", + "alert_incomplete-value": "Значение получено не полностью", + "context_value-too-large": "Значение слишком большое для отображения в ячейке", + "alert_preview-error": "Не удалось открыть предпросмотр. Попробуйте ещё раз." } diff --git a/src/components/QueryResultsTable/index.ts b/src/components/QueryResultsTable/index.ts index b92167b..586959b 100644 --- a/src/components/QueryResultsTable/index.ts +++ b/src/components/QueryResultsTable/index.ts @@ -1,3 +1,10 @@ export {QueryResultsTable} from './QueryResultsTable'; export type {QueryResultsTableProps} from './QueryResultsTable'; export {formatQueryResultType} from './helpers/formatQueryResultType'; +export type { + QueryResultCellOptions, + QueryResultCellPreviewContext, + QueryResultCellRenderContext, + QueryResultCellSettings, + QueryResultFormatterSettings, +} from '../../types/queryResults'; diff --git a/src/components/QueryResultsTable/internal/QueryResultCell.scss b/src/components/QueryResultsTable/internal/QueryResultCell.scss new file mode 100644 index 0000000..96f4e96 --- /dev/null +++ b/src/components/QueryResultsTable/internal/QueryResultCell.scss @@ -0,0 +1,50 @@ +.qp-query-result-cell { + font-family: var(--g-text-body-font-family); + font-size: var(--g-text-body-1-font-size); + line-height: var(--g-text-body-1-line-height); + overflow-wrap: anywhere; + + &_copyable { + padding-right: var(--g-spacing-5); + } + + &__copy { + position: absolute; + top: var(--g-spacing-1); + right: var(--g-spacing-1); + } + + &__content { + .unipika { + font: inherit; + } + + &_collapsed { + max-height: calc(var(--query-result-visible-lines) * var(--g-text-body-1-line-height)); + overflow: hidden; + } + } + + &__actions { + opacity: 0; + } + + &:hover &__actions, + &:focus-within &__actions, + &_persistent &__actions { + opacity: 1; + } + + &__show-more { + display: block; + margin-top: var(--g-spacing-1); + } + + &__warning { + color: var(--g-color-text-warning); + } + + &__error { + color: var(--g-color-text-danger); + } +} diff --git a/src/components/QueryResultsTable/internal/QueryResultCell.tsx b/src/components/QueryResultsTable/internal/QueryResultCell.tsx index cdd9a1d..c1fd6a3 100644 --- a/src/components/QueryResultsTable/internal/QueryResultCell.tsx +++ b/src/components/QueryResultsTable/internal/QueryResultCell.tsx @@ -1,20 +1,30 @@ -import React, {useMemo, useState} from 'react'; -import {Button, ClipboardButton} from '@gravity-ui/uikit'; +import React, {useId, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {Button, ClipboardButton, Flex} from '@gravity-ui/uikit'; import cn from 'bem-cn-lite'; -import type {QueryResultColumn, QueryResultFormatterSettings} from '../../../types/queryResults'; -import {formatQueryResultValue} from '../helpers/formatQueryResultValue'; +import type { + QueryResultCellRenderContext, + QueryResultCellSettings, +} from '../../../types/queryResults'; +import { + type FormattedQueryResultValue, + formatQueryResultValue, +} from '../helpers/formatQueryResultValue'; import i18n from '../i18n'; -const block = cn('qp-query-results-table'); +import './QueryResultCell.scss'; -export type QueryResultCellProps> = { - row: TRow; - value: unknown; - index: number; - column: QueryResultColumn; - formatterSettings?: QueryResultFormatterSettings; - maxVisibleLines: number; -}; +const block = cn('qp-query-result-cell'); + +function cellMetadata(formatted: FormattedQueryResultValue) { + return formatted.error ? {isIncomplete: false, tag: undefined} : formatted; +} + +type QueryResultCellProps> = + QueryResultCellRenderContext & + QueryResultCellSettings & { + maxVisibleLines: number; + collapseAfterLines: number; + }; export function QueryResultCell>({ row, @@ -23,41 +33,155 @@ export function QueryResultCell>({ column, formatterSettings, maxVisibleLines, + collapseAfterLines, + maxInlineTextLength, + getCellOptions, + onCellPreview, }: QueryResultCellProps) { + const contentId = useId(); const [expanded, setExpanded] = useState(false); - const formatted = useMemo( - () => formatQueryResultValue(value, column.type, formatterSettings), - [column.type, formatterSettings, value], - ); + const [loading, setLoading] = useState(false); + const [previewError, setPreviewError] = useState(false); + const request = useRef(null); + const context = useMemo(() => ({row, value, index, column}), [row, value, index, column]); + const formatted = useMemo(() => { + try { + return formatQueryResultValue( + value, + column.type, + formatterSettings, + getCellOptions?.(context), + maxInlineTextLength, + ); + } catch { + return {error: true as const, html: '', text: ''}; + } + }, [value, column.type, formatterSettings, getCellOptions, context, maxInlineTextLength]); + const {isIncomplete, tag} = cellMetadata(formatted); + // YQL types are wire tuples: equivalent inline column definitions must not + // reset expansion or invalidate an in-flight preview on a parent render. + const columnTypeKey = JSON.stringify(column.type); - if (column.render) { - return <>{column.render({row, value, index, column})}; - } + useLayoutEffect(() => { + setExpanded(false); + setLoading(false); + setPreviewError(false); + request.current = null; + return () => { + request.current = null; + }; + }, [row, value, index, column.name, columnTypeKey, isIncomplete, tag]); + + const preview = async () => { + if (!onCellPreview || request.current) return; + const token = {}; + request.current = token; + setLoading(true); + setPreviewError(false); + try { + await onCellPreview({...context, isIncomplete, tag}); + } catch { + if (request.current === token) setPreviewError(true); + } finally { + if (request.current === token) { + request.current = null; + setLoading(false); + } + } + }; if (formatted.error) { - return {i18n('alert_format-value-error')}; + return ( + + {i18n('alert_format-value-error')} + + ); } - const lines = formatted.html.split('\n'); - const hasMore = lines.length > maxVisibleLines; - const html = expanded || !hasMore ? formatted.html : lines.slice(0, maxVisibleLines).join('\n'); + const incompleteTagged = isIncomplete && tag !== undefined; + const placeholder = incompleteTagged || formatted.isTooLarge; + const hasMore = + !placeholder && + formatted.html.split('\n').length > Math.max(maxVisibleLines, collapseAfterLines); + const previewLabel = i18n(previewError ? 'action_retry-preview' : 'action_preview'); + const canPreview = Boolean(onCellPreview && (isIncomplete || formatted.isTooLarge)); return ( - - - - - +
+ {incompleteTagged ? ( + {i18n('alert_incomplete-tag', {tag})} + ) : ( + <> + {isIncomplete && ( +
{i18n('alert_incomplete-value')}
+ )} + {formatted.isTooLarge ? ( + {i18n('context_value-too-large')} + ) : ( +
+ +
+ )} + + )} + + {!isIncomplete && ( + + )} + {canPreview && ( + + )} + + {previewError && ( +
+ {i18n('alert_preview-error')} +
+ )} {hasMore && ( )} - +
); } diff --git a/src/modules/QueryResults/QueryResults.stories.tsx b/src/modules/QueryResults/QueryResults.stories.tsx index b57de51..346a251 100644 --- a/src/modules/QueryResults/QueryResults.stories.tsx +++ b/src/modules/QueryResults/QueryResults.stories.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useRef, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import { ArrowDownToLine, @@ -8,10 +8,14 @@ import { Gear, LayoutColumns, } from '@gravity-ui/icons'; -import {Button, Flex, Icon, Link} from '@gravity-ui/uikit'; +import {Button, Dialog, Flex, Icon, Link} from '@gravity-ui/uikit'; import {action} from 'storybook/actions'; import {QueryResults} from './QueryResults'; -import type {QueryResultColumn, QueryResultsView} from '../../types/queryResults'; +import type { + QueryResultCellPreviewContext, + QueryResultColumn, + QueryResultsView, +} from '../../types/queryResults'; type Row = { age: number; @@ -153,3 +157,183 @@ export const CustomSchema: Story = { ), }, }; + +// All parity examples intentionally use the standard cell renderer. +type CellDemoRow = {id: string; value: unknown; loaded?: boolean}; +const listColumn: Array> = [ + {name: 'id', type: ['DataType', 'Utf8'], width: 220}, + {name: 'value', type: ['ListType', ['DataType', 'Int32']], width: 500}, +]; +const ytSettings = {maxVisibleLines: 5, collapseAfterLines: 8, maxInlineTextLength: 10000}; +const fullList = Array.from({length: 60}, (_, index) => index); + +export const StandardCellTypes: Story = { + render: () => ( + + ), +}; + +export const CollapseBoundaries: Story = { + render: () => ( + ({ + id: `${length + 2} formatted lines`, + value: Array.from({length}, (_, index) => index), + }))} + /> + ), +}; + +const InlinePreviewStory = () => { + const [demoRows, setDemoRows] = useState([ + {id: 'Load full list', value: fullList}, + {id: 'Fails once; retry', value: fullList}, + ]); + const attempts = useRef(new Set()); + return ( + row.id} + formatterSettings={{maxListSize: 50, maxStringSize: 1000}} + getCellOptions={({row, column}) => + column.name === 'value' && row.loaded + ? { + isIncomplete: false, + formatterSettings: {maxListSize: undefined, maxStringSize: undefined}, + } + : {} + } + onCellPreview={async ({row}) => { + await new Promise((resolve) => setTimeout(resolve, 800)); + if (row.id === 'Fails once; retry' && !attempts.current.has(row.id)) { + attempts.current.add(row.id); + throw new globalThis.Error('Demo request failed. Preview again to retry.'); + } + setDemoRows((current) => + current.map((item) => + item.id === row.id ? {...item, value: [...fullList], loaded: true} : item, + ), + ); + }} + /> + ); +}; + +export const InlinePreviewAndRetry: Story = {render: () => }; + +const ModalPreviewStory = () => { + const [preview, setPreview] = useState>(); + return ( + <> + + {...ytSettings} + columns={[ + {name: 'id', type: ['DataType', 'Utf8'], width: 240}, + {name: 'value', type: ['DataType', 'Utf8'], width: 500}, + ]} + rows={[ + {id: 'Large complete value', value: 'x'.repeat(10000)}, + {id: 'Incomplete tagged value', value: 'Partial payload'}, + ]} + getCellOptions={({row, column}) => + column.name === 'value' && row.id === 'Incomplete tagged value' + ? {isIncomplete: true, tag: 'document'} + : {} + } + onCellPreview={setPreview} + /> + setPreview(undefined)} + aria-labelledby="query-results-preview-title" + contentOverflow="auto" + > + + + + + {preview?.isIncomplete + ? 'The application can fetch the remaining data here.' + : 'This value is complete; the inline HTML limit does not truncate it.'} + +
{String(preview?.value ?? '')}
+
+
+
+ + ); +}; + +export const ExternalModalPreview: Story = {render: () => }; + +// Local media fixtures: a 32px PNG and a short silent WAV. No network is needed. +const imageFixture = + 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKklEQVR4nGMI6LlDU8QwasGoBaMWjFowasGoBaMWjFowasGoBaMWDBULAE1S4Fuc1eAmAAAAAElFTkSuQmCC'; +const audioFixture = + 'UklGRnQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YVAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA=='; +type MediaDemoRow = {image: string; audio: string}; +const InlineMediaPreviewStory = () => { + const [mediaRows, setMediaRows] = useState([{image: '', audio: ''}]); + return ( + ({ + isIncomplete: value === '', + ...(value === '' + ? {} + : {formatterSettings: {maxListSize: undefined, maxStringSize: undefined}}), + })} + onCellPreview={async ({column}) => { + await new Promise((resolve) => setTimeout(resolve, 800)); + const value = column.name === 'image' ? imageFixture : audioFixture; + setMediaRows((current) => [{...current[0], [column.name]: value}]); + }} + /> + ); +}; + +export const InlineMediaPreview: Story = {render: () => }; diff --git a/src/modules/QueryResults/QueryResults.tsx b/src/modules/QueryResults/QueryResults.tsx index 46a928b..8b729f5 100644 --- a/src/modules/QueryResults/QueryResults.tsx +++ b/src/modules/QueryResults/QueryResults.tsx @@ -21,6 +21,10 @@ export function QueryResults>({ rowKey, formatterSettings, maxVisibleLines, + collapseAfterLines, + maxInlineTextLength, + getCellOptions, + onCellPreview, title, toolbarContent, actions, @@ -60,6 +64,10 @@ export function QueryResults>({ rowKey={rowKey} formatterSettings={formatterSettings} maxVisibleLines={maxVisibleLines} + collapseAfterLines={collapseAfterLines} + maxInlineTextLength={maxInlineTextLength} + getCellOptions={getCellOptions} + onCellPreview={onCellPreview} /> ); } diff --git a/src/modules/QueryResults/README.md b/src/modules/QueryResults/README.md new file mode 100644 index 0000000..df6989b --- /dev/null +++ b/src/modules/QueryResults/README.md @@ -0,0 +1,126 @@ +# Standard result cells + +`QueryResults`, `QueryResultsTable`, and the `result` tab of `QueryExecutionPanel` +accept the same cell options. Values remain in YQL wire format; QueriesKit handles +Unipika conversion, HTML, copying, local expansion, and preview actions. +`column.render` replaces this entire pipeline, including `getCellOptions`. + +## YTsaurus integration + +Keep legacy metadata separately from the wire values. Map `$incomplete` to +`isIncomplete`, `$tagValue` to `tag`, and `$rawValue` to `copyText`; do not pass the +previously generated HTML as a value. The application decides whether URL copying +uses the label or the href by passing the desired literal `copyText`. + +```tsx +import {QueryResults} from '@gravity-ui/querieskit/modules/QueryResults'; +import type {QueryResultCellOptions, QueryResultCellPreviewContext} from '@gravity-ui/querieskit'; + +type Row = {id: string; value: unknown}; +type LegacyCellMetadata = { + $incomplete?: boolean; + $tagValue?: string; + $rawValue?: string; + loaded?: boolean; +}; + +function cellOptions(metadata: LegacyCellMetadata): QueryResultCellOptions { + return { + isIncomplete: metadata.$incomplete, + tag: metadata.$tagValue, + copyText: metadata.$rawValue, + ...(metadata.loaded + ? {formatterSettings: {maxListSize: undefined, maxStringSize: undefined}} + : {}), + }; +} + +// rows, columns, metadataByCell, and handlePreview belong to the application. + + columns={columns} + rows={rows} + rowKey={(row) => row.id} + maxVisibleLines={5} + collapseAfterLines={8} + maxInlineTextLength={10000} + formatterSettings={{maxListSize: 50, treatValAsData: true}} + getCellOptions={({row, column}) => cellOptions(metadataByCell[row.id]?.[column.name] ?? {})} + onCellPreview={handlePreview} +/>; +``` + +Enable `treatValAsData` for web-json wrappers such as +`{val: 'partial text', inc: true}` or `{val: 'AP8=', b64: true}`. Plain YQL +wire values remain supported. Supply the original YQL type in each column. + +## Preview ownership and lifecycle + +The callback has the signature +`(context: QueryResultCellPreviewContext) => void | Promise`. +Its context contains `row`, `value`, `index`, `column`, `isIncomplete`, and `tag`. +The application owns requests, cancellation, metadata, and modal windows: + +- For inline loading (for example, audio or images), fetch the remaining value, + replace the corresponding row immutably, set `isIncomplete: false`, and remove + formatter limits for that cell using explicit `undefined` as above. +- For modal preview, open an application-owned dialog. A complete large value + produces `isIncomplete: false`; no fetch is required unless the application + needs other data. +- Returning successfully without updating props leaves the original value in + place. QueriesKit does not cache a second copy of the data. +- While a returned Promise is pending, the cell prevents duplicate preview + calls. A thrown error or rejected Promise displays a local error and permits + retry. Resolve normally if the application already handled an error itself. +- Completion of an outdated request cannot update a replaced or unmounted + cell's UI. The application must still cancel requests or guard its own state + updates when switching datasets. + +Keep row keys stable. Use immutable row/value updates so the cell can reset +expansion and preview errors when its data changes. The `result` panel tab passes +these options through its existing `props` object. + +## Formatting and action rules + +Settings merge in this order: library defaults, table settings, cell settings. +An explicit `undefined` for `maxListSize` or `maxStringSize` removes that limit. +Omitted metadata preserves Unipika's automatic detection, including nested +incompleteness. Explicit `isIncomplete: false` overrides that detection, and +`copyText: ''` intentionally copies an empty string. + +Copying uses the complete available text, regardless of line collapsing or the +HTML length limit. Incomplete values cannot be copied. If explicitly overriding +converter-induced incompleteness, the copy text is reconstructed without list +and string limits; server-truncated data cannot be recovered locally. + +`maxVisibleLines` defaults to **5**. `collapseAfterLines` defaults to +`maxVisibleLines`; with **5/8**, up to eight lines remain visible and nine or more +collapse to five. Expanding a value is local and never calls preview. + +`maxInlineTextLength` has **no default limit**. At or above the configured text +length, a placeholder replaces HTML; complete values still support copying and +preview. URL and `audio/`, `video/`, `image/` tags bypass this limit. Explicit +`copyText` does not affect the length decision. + +Incomplete tagged values display a localized placeholder. Without +`onCellPreview`, the warning remains but no preview button appears. Actions are +available on hover and keyboard focus; placeholders and errors keep actions +visible. Conversion errors affect only their own cell. + +## Storybook verification + +`Modules/QueryResults` provides these examples, all without `column.render`: + +- **Standard Cell Types**: optional/null, binary, collections, URL, incomplete + strings and tagged images, plus a malformed collection isolated to one cell. +- **Collapse Boundaries**: five, eight, and nine formatted lines with 5/8 settings. +- **Inline Preview And Retry**: asynchronous row replacement, removal of the + 50-item formatter limit, a rejected request, and successful retry. +- **Inline Media Preview**: asynchronous image/audio replacement using local PNG + and WAV fixtures; no external network or modal window is needed. +- **External Modal Preview**: large complete text and incomplete tagged values, + with an application-owned dialog and unchanged table props after preview. + +In a browser, use Tab to reach actions and Enter/Space to activate them. Check +that expansion and row replacement update row height and keep the sticky header +aligned while scrolling. Check light and dark themes. DOM-only tests do not +verify this geometry. diff --git a/src/modules/QueryResults/index.ts b/src/modules/QueryResults/index.ts index 3ebaf13..6a1352b 100644 --- a/src/modules/QueryResults/index.ts +++ b/src/modules/QueryResults/index.ts @@ -1,2 +1,9 @@ export {QueryResults} from './QueryResults'; export type {QueryResultsProps} from './QueryResults'; +export type { + QueryResultCellOptions, + QueryResultCellPreviewContext, + QueryResultCellRenderContext, + QueryResultCellSettings, + QueryResultFormatterSettings, +} from '../../types/queryResults'; diff --git a/src/types/declarations.d.ts b/src/types/declarations.d.ts index 2fe4118..3efde42 100644 --- a/src/types/declarations.d.ts +++ b/src/types/declarations.d.ts @@ -16,9 +16,18 @@ declare module '*.svg' { declare module '@gravity-ui/unipika' { type UnipikaSettings = Record; + type UnipikaNode = {$tag?: string; $incomplete?: boolean; [key: string]: unknown}; type Unipika = { formatFromYQL(data: [unknown, unknown], settings?: UnipikaSettings): string; + format(data: UnipikaNode, settings?: UnipikaSettings): string; + converters: { + yql( + data: [unknown, unknown], + settings?: UnipikaSettings, + flags?: {incomplete?: boolean}, + ): UnipikaNode; + }; }; export default function createUnipika(settings?: UnipikaSettings): Unipika; diff --git a/src/types/queryResults.ts b/src/types/queryResults.ts index 8c94548..3aa225d 100644 --- a/src/types/queryResults.ts +++ b/src/types/queryResults.ts @@ -1,5 +1,6 @@ import type {ReactNode} from 'react'; -import type {AlignType} from '@gravity-ui/react-data-table'; +import type {EmptyContentVariant} from '../components/EmptyContent'; +import type {AlignType, Settings} from '@gravity-ui/react-data-table'; export type QueryResultDataTypeParameter = | string @@ -23,6 +24,8 @@ export type QueryResultFormatterSettings = { maxListSize?: number; maxStringSize?: number; compact?: boolean; + /** Interpret YQL wire envelopes such as {val, inc, b64}. */ + treatValAsData?: boolean; }; export type QueryResultColumn> = { @@ -41,28 +44,67 @@ export type QueryResultCellRenderContext> = column: QueryResultColumn; }; +export type QueryResultCellOptions = { + isIncomplete?: boolean; + tag?: string; + /** Literal clipboard text; an empty string is a valid override. */ + copyText?: string; + /** Overrides table settings. An explicit undefined removes a limit. */ + formatterSettings?: QueryResultFormatterSettings; +}; + +export type QueryResultCellPreviewContext> = + QueryResultCellRenderContext & {isIncomplete: boolean; tag?: string}; + +export type QueryResultCellSettings> = { + formatterSettings?: QueryResultFormatterSettings; + maxVisibleLines?: number; + /** Collapse only above this line count. Defaults to maxVisibleLines. */ + collapseAfterLines?: number; + /** Replace HTML when formatted text reaches this length. Unlimited by default. */ + maxInlineTextLength?: number; + getCellOptions?: (context: QueryResultCellRenderContext) => QueryResultCellOptions; + onCellPreview?: (context: QueryResultCellPreviewContext) => void | Promise; +}; + +export type QueryResultsTableProps> = + QueryResultCellSettings & { + columns: Array>; + /** Values use the YQL wire representation consumed by @gravity-ui/unipika. */ + rows: TRow[]; + loading?: boolean; + loaded?: boolean; + errorContent?: ReactNode; + rowKey?: (row: TRow, index: number) => string | number; + emptyVariant?: EmptyContentVariant; + displayIndices?: boolean; + stripedRows?: boolean; + /** Set to false when the surrounding layout does not need a sticky header. */ + stickyHead?: Settings['stickyHead'] | false; + className?: string; + }; + export type QueryResultsView = 'result' | 'schema'; export type QueryResultsSchemaRenderContext> = { columns: Array>; }; -export type QueryResultsProps> = { - columns: Array>; - /** Values use the YQL wire representation consumed by @gravity-ui/unipika. */ - rows: TRow[]; - totalRows?: number; - loading?: boolean; - errorContent?: ReactNode; - rowKey?: (row: TRow, index: number) => string | number; - formatterSettings?: QueryResultFormatterSettings; - maxVisibleLines?: number; - title?: ReactNode; - toolbarContent?: ReactNode; - actions?: ReactNode; - view?: QueryResultsView; - defaultView?: QueryResultsView; - onViewChange?: (view: QueryResultsView) => void; - renderSchema?: (context: QueryResultsSchemaRenderContext) => ReactNode; - className?: string; -}; +export type QueryResultsProps> = + QueryResultCellSettings & { + columns: Array>; + /** Values use the YQL wire representation consumed by @gravity-ui/unipika. */ + rows: TRow[]; + totalRows?: number; + loading?: boolean; + errorContent?: ReactNode; + rowKey?: (row: TRow, index: number) => string | number; + title?: ReactNode; + toolbarContent?: ReactNode; + actions?: ReactNode; + view?: QueryResultsView; + defaultView?: QueryResultsView; + onViewChange?: (view: QueryResultsView) => void; + renderSchema?: (context: QueryResultsSchemaRenderContext) => ReactNode; + className?: string; + }; diff --git a/tests/components/DataTableResize.test.tsx b/tests/components/DataTableResize.test.tsx new file mode 100644 index 0000000..b75eafc --- /dev/null +++ b/tests/components/DataTableResize.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment jsdom + +import React, {act} from 'react'; +import {createRoot} from 'react-dom/client'; +import type {Root} from 'react-dom/client'; +import BaseDataTable from '@gravity-ui/react-data-table'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {DataTable} from '../../src/components/DataTable/DataTable'; +import type {DataTableProps} from '../../src/components/DataTable/DataTable'; + +vi.mock('../../src/components/EmptyContent', () => ({EmptyContent: () => null})); + +describe('DataTable content resize synchronization', () => { + let container: HTMLDivElement; + let root: Root; + let notifyResize: ResizeObserverCallback; + let observe: ReturnType; + let disconnect: ReturnType; + let frames: Map; + let nextFrame: number; + + const columns = [{name: 'value'}]; + const data = [{value: 'First value'}]; + const settings = {stickyHead: BaseDataTable.FIXED, syncHeadOnResize: true}; + + function render(nextSettings = settings, nextData = data) { + act(() => { + root.render(); + }); + } + + function resize(width: number, height: number) { + notifyResize([{contentRect: {width, height}} as ResizeObserverEntry], {} as ResizeObserver); + } + + function flushFrame() { + act(() => { + const pending = [...frames.values()]; + frames.clear(); + pending.forEach((callback) => callback(0)); + }); + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + observe = vi.fn(); + disconnect = vi.fn(); + frames = new Map(); + nextFrame = 0; + vi.stubGlobal( + 'ResizeObserver', + class { + observe = observe; + disconnect = disconnect; + constructor(callback: ResizeObserverCallback) { + notifyResize = callback; + } + }, + ); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const id = ++nextFrame; + frames.set(id, callback); + return id; + }), + ); + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((id: number) => frames.delete(id)), + ); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('observes the real data table and coalesces content changes into one resize', () => { + const sync = vi.spyOn(BaseDataTable.prototype, 'resize'); + render(); + + expect(observe).toHaveBeenCalledOnce(); + expect(observe).toHaveBeenCalledWith(container.querySelector('.data-table__box table')); + expect(observe.mock.calls[0][0]).toBeInstanceOf(HTMLTableElement); + expect(observe.mock.calls[0][0]).not.toBe(container.querySelector('table')); + + vi.mocked(requestAnimationFrame).mockClear(); + resize(400, 100); + resize(400, 200); + expect(sync).not.toHaveBeenCalled(); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + flushFrame(); + expect(sync).toHaveBeenCalledOnce(); + + // Header synchronization that leaves the body size unchanged cannot loop. + vi.mocked(requestAnimationFrame).mockClear(); + resize(400, 200); + expect(requestAnimationFrame).not.toHaveBeenCalled(); + + render(settings, [{value: 'Updated value'}]); + resize(500, 200); + flushFrame(); + expect(sync).toHaveBeenCalledTimes(2); + expect(observe).toHaveBeenCalledOnce(); + }); + + it('cancels pending synchronization and disconnects when disabled', () => { + const sync = vi.spyOn(BaseDataTable.prototype, 'resize'); + render(); + resize(400, 100); + render({...settings, syncHeadOnResize: false}); + + expect(disconnect).toHaveBeenCalledOnce(); + expect(cancelAnimationFrame).toHaveBeenCalledOnce(); + flushFrame(); + expect(sync).not.toHaveBeenCalled(); + }); + + it('cancels pending synchronization and disconnects on unmount', () => { + const sync = vi.spyOn(BaseDataTable.prototype, 'resize'); + render(); + resize(400, 100); + act(() => root.render(null)); + + expect(disconnect).toHaveBeenCalledOnce(); + expect(cancelAnimationFrame).toHaveBeenCalledOnce(); + flushFrame(); + expect(sync).not.toHaveBeenCalled(); + }); + + it.each['settings']>([ + undefined, + {syncHeadOnResize: true}, + {stickyHead: BaseDataTable.FIXED}, + {stickyHead: BaseDataTable.FIXED, syncHeadOnResize: false}, + ])('does not observe unless sticky header and synchronization are enabled (%j)', (value) => { + act(() => { + root.render(); + }); + expect(observe).not.toHaveBeenCalled(); + }); + + it('supports environments without ResizeObserver', () => { + vi.stubGlobal('ResizeObserver', undefined); + expect(() => render()).not.toThrow(); + expect(observe).not.toHaveBeenCalled(); + }); + + it('observes the replacement table when the dynamic rendering mode changes', () => { + const sync = vi.spyOn(BaseDataTable.prototype, 'resize'); + function renderDynamic(dynamicRenderType: 'simple' | 'uniform') { + act(() => { + root.render( + , + ); + }); + } + + renderDynamic('simple'); + const firstTable = container.querySelector('.data-table__box table'); + resize(400, 100); + renderDynamic('uniform'); + const replacementTable = container.querySelector('.data-table__box table'); + + expect(replacementTable).toBeInstanceOf(HTMLTableElement); + expect(replacementTable).not.toBe(firstTable); + expect(disconnect).toHaveBeenCalledOnce(); + expect(observe).toHaveBeenLastCalledWith(replacementTable); + expect(observe).toHaveBeenCalledTimes(2); + + flushFrame(); + expect(sync).not.toHaveBeenCalled(); + resize(400, 200); + flushFrame(); + expect(sync).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/components/QueryExecutionPanel.test.tsx b/tests/components/QueryExecutionPanel.test.tsx index 03566d8..4018298 100644 --- a/tests/components/QueryExecutionPanel.test.tsx +++ b/tests/components/QueryExecutionPanel.test.tsx @@ -9,9 +9,15 @@ import type { QueryExecutionTab, } from '../../src/types/queryExecutionPanel'; import {getMessagesSeverity} from '../../src/widgets/QueryExecutionPanel/helpers/getMessagesSeverity'; +import type {QueryResultsProps} from '../../src/types/queryResults'; + +const resultProps = vi.hoisted(() => vi.fn()); vi.mock('../../src/modules/QueryResults', () => ({ - QueryResults: ({rows}: {rows: unknown[]}) =>
Rows: {rows.length}
, + QueryResults: (props: QueryResultsProps>) => { + resultProps(props); + return
Rows: {props.rows.length}
; + }, })); vi.mock('../../src/modules/QueryProgress', () => ({ QueryProgress: ({active}: {active: boolean}) =>
, @@ -80,6 +86,7 @@ describe('QueryExecutionPanel', () => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; configure({lang: 'en'}); changes.mockClear(); + resultProps.mockClear(); container = document.createElement('div'); document.body.append(container); root = createRoot(container); @@ -288,6 +295,32 @@ describe('QueryExecutionPanel', () => { click('Charts'); expect(panel().textContent).toContain('Count 1'); }); + it('forwards standard cell settings and callbacks unchanged to a result tab', () => { + const getCellOptions = vi.fn(() => ({isIncomplete: true, tag: 'image/png'})); + const onCellPreview = vi.fn(); + const formatterSettings = {treatValAsData: true, maxListSize: 50}; + const props: QueryResultsProps> = { + columns: [{name: 'value', type: ['DataType', 'String']}], + rows: [{value: {val: 'partial', inc: true}}], + getCellOptions, + onCellPreview, + maxVisibleLines: 5, + collapseAfterLines: 8, + maxInlineTextLength: 10000, + formatterSettings, + }; + render({tabs: [{id: 'result', type: 'result', props}]}); + + const forwarded = resultProps.mock.calls.at(-1)?.[0]; + expect(forwarded).toEqual(props); + expect(forwarded.getCellOptions).toBe(getCellOptions); + expect(forwarded.onCellPreview).toBe(onCellPreview); + expect(forwarded.formatterSettings).toBe(formatterSettings); + expect(forwarded.columns).toBe(props.columns); + expect(forwarded.rows).toBe(props.rows); + expect(getCellOptions).not.toHaveBeenCalled(); + expect(onCellPreview).not.toHaveBeenCalled(); + }); it('preserves visited content through loading and retry, and pauses hidden content', () => { const onRetry = vi.fn(); render(); diff --git a/tests/components/QueryResultsFormatting.test.ts b/tests/components/QueryResultsFormatting.test.ts new file mode 100644 index 0000000..0c75fc4 --- /dev/null +++ b/tests/components/QueryResultsFormatting.test.ts @@ -0,0 +1,223 @@ +import {describe, expect, it} from 'vitest'; +import {formatQueryResultValue} from '../../src/components/QueryResultsTable/helpers/formatQueryResultValue'; +import type {QueryResultDataType} from '../../src/types/queryResults'; + +const stringType: QueryResultDataType = ['DataType', 'String']; +const listType: QueryResultDataType = ['ListType', stringType]; + +function format(...args: Parameters) { + const result = formatQueryResultValue(...args); + expect(result.error).toBe(false); + if (result.error) { + throw new Error('Unexpected formatting error'); + } + return result; +} + +describe('query result formatting', () => { + it.each<[unknown, QueryResultDataType]>([ + ['hello', stringType], + [null, ['NullType']], + [['hello'], ['OptionalType', stringType]], + [[], ['OptionalType', stringType]], + [['one', 'two'], listType], + [[['one', 'two']], ['DictType', stringType, stringType]], + [ + ['one', 'two'], + ['TupleType', [stringType, stringType]], + ], + ])('formats YQL value %j', (value, type) => { + const result = format(value, type); + expect(result.html).not.toBe(''); + expect(result.text).not.toBe(''); + expect(result.isIncomplete).toBe(false); + }); + + it('supports binary wire values', () => { + const result = format({val: 'AAE=', b64: true}, stringType, {treatValAsData: true}); + expect(result.html).toContain('binary'); + expect(result.text).toContain('00'); + }); + + it('detects nested server incompleteness and local string/list truncation', () => { + expect( + format([{val: 'part', inc: true}], listType, {treatValAsData: true}).isIncomplete, + ).toBe(true); + expect(format(['abcdef'], listType, {maxStringSize: 3}).isIncomplete).toBe(true); + expect(format(['one', 'two'], listType, {maxListSize: 1}).isIncomplete).toBe(true); + }); + + it.each([1, 50])('keeps structs complete after omitting null fields at limit %i', (limit) => { + const fields: QueryResultDataType = [ + 'StructType', + [ + ...Array.from({length: limit}, (_, index): [string, QueryResultDataType] => [ + `empty${index}`, + ['OptionalType', stringType], + ]), + ['value', stringType], + ], + ]; + const value = [...Array.from({length: limit}, () => []), 'present']; + const result = format(value, fields, {maxListSize: limit}); + expect(result.isIncomplete).toBe(false); + expect(result.text).toContain('present'); + expect( + format(value, fields, {maxListSize: limit, omitStructNull: false}).isIncomplete, + ).toBe(true); + expect(format(value, fields, {maxListSize: limit, maxStringSize: 2}).isIncomplete).toBe( + true, + ); + }); + + it('does not treat a variant wrapper as a truncated list', () => { + const type: QueryResultDataType = ['VariantType', ['TupleType', [stringType, listType]]]; + expect(format(['0', 'complete'], type, {maxListSize: 1}).isIncomplete).toBe(false); + expect(format(['1', ['one', 'two']], type, {maxListSize: 1}).isIncomplete).toBe(true); + }); + + it.each([undefined, 1])('retains server incompleteness on structs at limit %s', (limit) => { + const type: QueryResultDataType = ['StructType', [['value', stringType]]]; + expect( + format({val: ['part'], inc: true}, type, { + treatValAsData: true, + maxListSize: limit, + }).isIncomplete, + ).toBe(true); + }); + + it('detects truncation and server incompleteness when tagged structs discard markers', () => { + const type: QueryResultDataType = [ + 'TaggedType', + 'url', + [ + 'StructType', + [ + ['href', stringType], + ['text', stringType], + ], + ], + ]; + expect(format(['https://example.com', 'Link'], type, {maxListSize: 1}).isIncomplete).toBe( + true, + ); + expect(format(['https://example.com', 'Link'], type, {maxStringSize: 3}).isIncomplete).toBe( + true, + ); + expect( + format([{val: 'https://example.com', inc: true}, 'Link'], type, { + treatValAsData: true, + maxListSize: 2, + }).isIncomplete, + ).toBe(true); + }); + + it('extracts optional tags and skips HTML for incomplete tagged data', () => { + const result = format( + [{val: 'part', inc: true}], + ['OptionalType', ['TaggedType', 'image/png', stringType]], + {treatValAsData: true}, + ); + expect(result).toMatchObject({isIncomplete: true, tag: 'image/png', html: ''}); + }); + + it('does not promote nested tags to the whole cell', () => { + expect( + format(['url'], ['ListType', ['TaggedType', 'url', stringType]]).tag, + ).toBeUndefined(); + }); + + it('lets explicit metadata override automatic metadata and preserves full copy', () => { + const result = format( + 'abcdef', + stringType, + {maxStringSize: 3}, + {isIncomplete: false, tag: 'custom'}, + ); + expect(result).toMatchObject({isIncomplete: false, tag: 'custom', text: '"abcdef"'}); + expect(result.html).toContain('>abc<'); + const list = format(['one', 'two'], listType, {maxListSize: 1}, {isIncomplete: false}); + expect(list.text).toContain('two'); + expect(list.html).not.toContain('two'); + }); + + it('lets cell settings remove inherited converter limits with undefined', () => { + const result = format( + ['abcdef', 'second'], + listType, + {maxStringSize: 2, maxListSize: 1}, + { + formatterSettings: {maxStringSize: undefined, maxListSize: undefined}, + }, + ); + expect(result.isIncomplete).toBe(false); + expect(result.text).toContain('abcdef'); + expect(result.text).toContain('second'); + }); + + it.each(['literal', ''])('copies explicit text %j literally', (copyText) => { + expect(format('value', stringType, undefined, {copyText}).text).toBe(copyText); + }); + + it('keeps URL rendering and supports raw link-copy overrides', () => { + const type: QueryResultDataType = [ + 'TaggedType', + 'url', + [ + 'StructType', + [ + ['href', stringType], + ['text', stringType], + ], + ], + ]; + const result = format(['https://example.com', 'Link'], type, undefined, {copyText: 'Link'}); + expect(result.html).toContain(' { + expect(format('x'.repeat(9997), stringType, undefined, undefined, 10000).isTooLarge).toBe( + false, + ); + const result = format('x'.repeat(9998), stringType, undefined, undefined, 10000); + expect(result).toMatchObject({isTooLarge: true, html: '', isIncomplete: false}); + expect(result.text).toHaveLength(10000); + expect(format('x'.repeat(9999), stringType, undefined, undefined, 10000).isTooLarge).toBe( + true, + ); + expect( + format('short', stringType, undefined, {copyText: 'x'.repeat(10000)}, 10000).isTooLarge, + ).toBe(false); + expect(format('x'.repeat(10000), stringType).isTooLarge).toBe(false); + }); + + it.each(['url', 'image/png', 'audio/wav', 'video/mp4'])( + 'exempts %s from HTML limits', + (tag) => { + const result = format( + 'https://example.com', + ['TaggedType', tag, stringType], + undefined, + undefined, + 1, + ); + expect(result.isTooLarge).toBe(false); + expect(result.html).not.toBe(''); + }, + ); + + it('isolates invalid YQL conversion errors', () => { + expect(formatQueryResultValue({}, ['UnknownType'])).toEqual({ + error: true, + html: '', + text: '', + }); + }); +}); diff --git a/tests/components/QueryResultsTable.test.tsx b/tests/components/QueryResultsTable.test.tsx new file mode 100644 index 0000000..26abe63 --- /dev/null +++ b/tests/components/QueryResultsTable.test.tsx @@ -0,0 +1,290 @@ +// @vitest-environment jsdom +import React, {act} from 'react'; +import {type Root, createRoot} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {ThemeProvider, configure} from '@gravity-ui/uikit'; +import {QueryResultsTable} from '../../src/components/QueryResultsTable'; +import {QueryResults} from '../../src/modules/QueryResults'; +import type {QueryResultsTableProps} from '../../src/types/queryResults'; + +const copied = vi.hoisted(() => vi.fn()); +vi.mock('@gravity-ui/uikit', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ClipboardButton: ({text, 'aria-label': label}: {text: string; 'aria-label': string}) => ( + + ), + }; +}); +type Row = {value: unknown}; +const columns: QueryResultsTableProps['columns'] = [ + {name: 'value', type: ['DataType', 'Utf8']}, +]; +const initialRows = [{value: 'hello'}]; +const incomplete = () => ({isIncomplete: true}); +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return {promise, resolve, reject}; +} + +describe('QueryResults standard cells', () => { + let container: HTMLDivElement; + let root: Root; + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + configure({lang: 'en'}); + copied.mockClear(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + const button = (name: string) => + Array.from(container.querySelectorAll('button')).find( + (node) => (node.getAttribute('aria-label') ?? node.textContent) === name, + ); + function render(props: Partial> = {}, module = false) { + act(() => + root.render( + + + {module ? ( + + ) : ( + + )} + + , + ), + ); + } + async function click(name: string) { + expect(button(name)).toBeDefined(); + await act(async () => { + button(name)!.click(); + }); + } + it.each([5, 8, 9])( + 'collapses %i lines at threshold eight and preserves copy', + async (lines) => { + const value = Array.from({length: lines}, (_, i) => `line ${i}`).join('\n'); + const preview = vi.fn(); + render({ + rows: [{value}], + maxVisibleLines: 5, + collapseAfterLines: 8, + onCellPreview: preview, + }); + expect(Boolean(button('Show more'))).toBe(lines > 8); + await click('Copy'); + const original = copied.mock.calls[0][0]; + expect(original).toContain('line 0'); + expect(original).toContain(`line ${lines - 1}`); + if (lines > 8) { + await click('Show more'); + expect(button('Show less')?.getAttribute('aria-expanded')).toBe('true'); + await click('Copy'); + expect(copied.mock.calls[1][0]).toBe(original); + await click('Show less'); + } + expect(preview).not.toHaveBeenCalled(); + }, + ); + it.each(['', 'href override'])( + 'copies literal override %j through QueryResults', + async (copyText) => { + render({getCellOptions: () => ({copyText})}, true); + await click('Copy'); + expect(copied).toHaveBeenCalledWith(copyText); + }, + ); + it('shows incomplete warning without a dead action and hides copy', () => { + render({getCellOptions: incomplete}); + expect(container.textContent).toContain('Value is incomplete'); + expect(button('Copy')).toBeUndefined(); + expect(button('Preview')).toBeUndefined(); + }); + it('keeps copy available when omitted null fields bring a struct below the list limit', async () => { + render({ + rows: [{value: [[], 'hello']}], + columns: [ + { + name: 'value', + type: [ + 'StructType', + [ + ['missing', ['OptionalType', ['DataType', 'String']]], + ['present', ['DataType', 'String']], + ], + ], + }, + ], + formatterSettings: {maxListSize: 1}, + onCellPreview: vi.fn(), + }); + + expect(container.textContent).not.toContain('Value is incomplete'); + expect(button('Preview')).toBeUndefined(); + await click('Copy'); + expect(copied.mock.calls[0][0]).toContain('hello'); + }); + it('replaces incomplete tagged data with warning and passes resolved context', async () => { + const preview = vi.fn(); + render({ + getCellOptions: () => ({isIncomplete: true, tag: 'image/png'}), + onCellPreview: preview, + }); + expect(container.textContent).toContain("Incomplete 'image/png' type"); + expect(container.textContent).not.toContain('hello'); + await click('Preview'); + expect(preview).toHaveBeenCalledWith({ + row: initialRows[0], + value: 'hello', + index: 0, + column: columns[0], + isIncomplete: true, + tag: 'image/png', + }); + expect(container.textContent).toContain('Incomplete'); + }); + it('offers preview and full copy for a complete large placeholder through the module', async () => { + const preview = vi.fn(); + render({maxInlineTextLength: 3, onCellPreview: preview}, true); + expect(container.textContent).toContain('too large'); + await click('Preview'); + expect(preview.mock.calls[0][0].isIncomplete).toBe(false); + await click('Copy'); + expect(copied.mock.calls[0][0]).toContain('hello'); + }); + it('locks synchronously against double clicks and retries rejected promises', async () => { + const pending = deferred(); + const preview = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue(undefined); + render({getCellOptions: incomplete, onCellPreview: preview}); + act(() => { + button('Preview')!.click(); + button('Preview')!.click(); + }); + expect(preview).toHaveBeenCalledTimes(1); + expect(button('Preview')!.disabled).toBe(true); + await act(async () => pending.reject(new Error('failed'))); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + 'Unable to preview', + ); + await click('Retry preview'); + expect(preview).toHaveBeenCalledTimes(2); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + it('handles synchronous callback throws and supports keyboard focus', async () => { + render({ + getCellOptions: incomplete, + onCellPreview: () => { + throw Error('sync'); + }, + }); + button('Preview')!.focus(); + expect(document.activeElement).toBe(button('Preview')); + await click('Preview'); + expect(button('Retry preview')).toBeDefined(); + }); + it('preserves a pending preview and its error when equivalent columns are recreated', async () => { + const pending = deferred(); + const preview = vi.fn().mockReturnValue(pending.promise); + const rerender = () => + render({ + columns: [{name: 'value', type: ['DataType', 'Utf8']}], + getCellOptions: incomplete, + onCellPreview: preview, + }); + + rerender(); + await click('Preview'); + rerender(); + expect(button('Preview')?.disabled).toBe(true); + await click('Preview'); + expect(preview).toHaveBeenCalledTimes(1); + await act(async () => pending.reject(Error('failed'))); + expect(button('Retry preview')).toBeDefined(); + rerender(); + expect(button('Retry preview')).toBeDefined(); + }); + it('preserves expansion for equivalent types but resets it when the type changes', async () => { + const rows = [{value: 'one\ntwo\nthree\nfour\nfive\nsix'}]; + const rerender = (type = 'Utf8') => + render({rows, columns: [{name: 'value', type: ['DataType', type]}]}); + + rerender(); + await click('Show more'); + rerender(); + expect(button('Show less')?.getAttribute('aria-expanded')).toBe('true'); + rerender('String'); + expect(button('Show more')?.getAttribute('aria-expanded')).toBe('false'); + }); + it.each(['resolve', 'reject'] as const)( + 'ignores stale %s after a new row starts another request', + async (completion) => { + const old = deferred(); + const next = deferred(); + const preview = vi + .fn() + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(next.promise); + render({getCellOptions: incomplete, onCellPreview: preview}); + await click('Preview'); + render({rows: [{value: 'new'}], getCellOptions: incomplete, onCellPreview: preview}); + await click('Preview'); + await act(async () => + completion === 'resolve' ? old.resolve() : old.reject(Error('stale')), + ); + expect(button('Preview')!.disabled).toBe(true); + expect(container.querySelector('[role="alert"]')).toBeNull(); + await act(async () => next.resolve()); + expect(button('Preview')!.disabled).toBe(false); + }, + ); + it('accepts loaded props and ignores rejection after unmount', async () => { + const pending = deferred(); + render({getCellOptions: incomplete, onCellPreview: () => pending.promise}); + await click('Preview'); + render({rows: [{value: 'loaded'}]}); + expect(button('Copy')).toBeDefined(); + expect(button('Preview')).toBeUndefined(); + act(() => root.render(null)); + await act(async () => pending.reject(Error('late'))); + expect(container.textContent).toBe(''); + }); + it('bypasses standard options and invalid value formatting for custom render', () => { + const options = vi.fn(() => { + throw Error('must not run'); + }); + render({ + columns: [{...columns[0], render: () => Custom}], + rows: [{value: Symbol('invalid')}], + getCellOptions: options, + }); + expect(container.textContent).toContain('Custom'); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(options).not.toHaveBeenCalled(); + }); + it('isolates malformed values within their cell', () => { + render({columns: [{name: 'value', type: ['UnsupportedType']}], rows: [{value: 'bad'}]}); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'Unable to format value', + ); + }); +}); From ee98cf39aeb26b9622f52e45d022c5bcf5f37b64 Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Mon, 5 Oct 2026 16:00:46 +0300 Subject: [PATCH 2/2] feat(NavigationPreview): unify result table rendering Share typed columns, cell actions, and table settings with QueryResults. Add per-item preview settings to the navigation detail factory and cover both modules with common behavior tests. Migration: NavigationPreview requires typed columns. Move tableColumns and extraColumns into data.columns, replace NavigationPreviewFormatterConfig with NavigationPreviewViewConfig, and remove buildPreviewColumns usage. NavigationView rows use a separate fifth factory generic. --- CHANGELOG.md | 11 ++ README.md | 4 + src/components/QueryResultsTable/index.ts | 1 + src/helpers/filterNavigationRows.ts | 4 +- .../NavigationPreview/NavigationPreview.scss | 25 --- .../NavigationPreview/NavigationPreview.tsx | 105 ++---------- src/modules/NavigationPreview/README.md | 95 +++++++++++ .../helpers/buildPreviewColumns.tsx | 11 -- .../helpers/filterPreviewRows.ts | 40 ++++- src/modules/NavigationPreview/index.ts | 1 - .../story/NavigationPreview.stories.tsx | 91 ++++++++--- .../NavigationPreview/story/mockData.ts | 5 +- .../helpers/createTableDetailConfig.tsx | 28 +++- src/modules/QueriesNavigation/index.ts | 1 + src/modules/QueryResults/QueryResults.tsx | 6 + src/modules/QueryResults/README.md | 10 +- src/modules/QueryResults/index.ts | 1 + src/modules/index.ts | 3 +- src/types/navigation.ts | 29 ++-- src/types/queryResults.ts | 20 ++- tests/components/NavigationPreview.test.tsx | 152 ++++++++++++++++++ tests/components/QueryResultsTable.test.tsx | 124 +++++++++++--- 22 files changed, 567 insertions(+), 200 deletions(-) create mode 100644 src/modules/NavigationPreview/README.md delete mode 100644 src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx create mode 100644 tests/components/NavigationPreview.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 458637f..86500fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### API migration + +- Unify `NavigationPreview` and `QueryResults` on `QueryResultsTable`. Preview columns now require YQL types; move `view.tableColumns` / `view.extraColumns` into `data.columns` and use `column.render` for custom cells. Remove `buildPreviewColumns` and replace `NavigationPreviewFormatterConfig` with the shared `NavigationPreviewViewConfig`. See the [migration guide](src/modules/NavigationPreview/README.md). +- Separate preview wire rows from `NavigationView` display rows; `createTableDetailConfig` accepts an independent fifth generic for view rows. + +### Features + +- Share cell and table settings across results and navigation, including preview callbacks, per-cell metadata, row keys, indices, stripes, and sticky headers. Add `resolvePreviewView` to `createTableDetailConfig`. + ## [2.0.2](https://github.com/gravity-ui/querieskit/compare/v2.0.1...v2.0.2) (2026-10-03) diff --git a/README.md b/README.md index 94b4815..a58a5c1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ React component library for building **query pages** — history, tutorials, editors, and related UI. Part of the [Gravity UI](https://gravity-ui.com) design system. +The library is under active development and its public API is not yet stable. +API changes may be released without a major version bump. Check the changelog +and migration notes before updating. + ## Install ```shell diff --git a/src/components/QueryResultsTable/index.ts b/src/components/QueryResultsTable/index.ts index 586959b..9eef1da 100644 --- a/src/components/QueryResultsTable/index.ts +++ b/src/components/QueryResultsTable/index.ts @@ -6,5 +6,6 @@ export type { QueryResultCellPreviewContext, QueryResultCellRenderContext, QueryResultCellSettings, + QueryResultsTableSettings, QueryResultFormatterSettings, } from '../../types/queryResults'; diff --git a/src/helpers/filterNavigationRows.ts b/src/helpers/filterNavigationRows.ts index 4a89433..bb98053 100644 --- a/src/helpers/filterNavigationRows.ts +++ b/src/helpers/filterNavigationRows.ts @@ -1,5 +1,3 @@ -import type {NavigationPreviewRow} from '../types/navigation'; - const stringifyCell = (value: unknown): string => { if (value === undefined || value === null) { return ''; @@ -13,7 +11,7 @@ const stringifyCell = (value: unknown): string => { return ''; }; -export function filterNavigationRows( +export function filterNavigationRows>( rows: TRow[], columns: string[], search?: string, diff --git a/src/modules/NavigationPreview/NavigationPreview.scss b/src/modules/NavigationPreview/NavigationPreview.scss index 8357b32..5c23af0 100644 --- a/src/modules/NavigationPreview/NavigationPreview.scss +++ b/src/modules/NavigationPreview/NavigationPreview.scss @@ -1,32 +1,7 @@ .qp-navigation-preview { - --data-table-border-color: var(--g-color-line-generic); - min-width: 0; &__error { display: block; } - - &__table { - overflow-x: auto; - } - - .data-table__row, - .data-table__head-row { - height: 40px; - } - - .data-table__box_sticky-head_moving .data-table__head-row { - height: auto; - } - - .data-table__sticky_head .data-table__head-row { - height: 40px; - } - - .data-table__th, - .data-table__td { - border-width: 0 0 1px; - vertical-align: middle; - } } diff --git a/src/modules/NavigationPreview/NavigationPreview.tsx b/src/modules/NavigationPreview/NavigationPreview.tsx index 696b1ea..c7dc46b 100644 --- a/src/modules/NavigationPreview/NavigationPreview.tsx +++ b/src/modules/NavigationPreview/NavigationPreview.tsx @@ -1,54 +1,16 @@ import React, {useMemo} from 'react'; import {Flex, Text} from '@gravity-ui/uikit'; import cn from 'bem-cn-lite'; -import type {Column} from '../../components/DataTable'; -import {DataTable} from '../../components/DataTable'; import {FieldsSearchToolbar} from '../../components/FieldsSearchToolbar'; import {QueryResultsTable} from '../../components/QueryResultsTable'; import {useVisibleColumns} from '../../helpers/useVisibleColumns'; -import type { - NavigationPreviewColumn, - NavigationPreviewConfig, - NavigationPreviewFormatterConfig, - NavigationPreviewRow, -} from '../../types/navigation'; -import type {QueryResultColumn} from '../../types/queryResults'; -import {buildPreviewColumns} from './helpers/buildPreviewColumns'; +import type {NavigationPreviewProps, NavigationPreviewRow} from '../../types/navigation'; import {filterPreviewRows} from './helpers/filterPreviewRows'; -import i18n from './i18n'; import './NavigationPreview.scss'; const block = cn('qp-navigation-preview'); -export type NavigationPreviewViewConfig = - NavigationPreviewFormatterConfig & { - tableColumns?: Array>; - extraColumns?: Array>; - }; - -export type NavigationPreviewProps = { - data: NavigationPreviewConfig; - view?: NavigationPreviewViewConfig; - search?: string; - onSearchUpdate?: (value: string) => void; - searchPlaceholder?: string; - visibleColumns?: string[]; - onVisibleColumnsChange?: (value: string[]) => void; - defaultVisibleColumns?: string[]; - hideToolbar?: boolean; - hideFieldsSelector?: boolean; - className?: string; -}; - -function getColumnName(column: NavigationPreviewColumn) { - return typeof column === 'string' ? column : column.name; -} - -function isQueryResultColumn( - column: NavigationPreviewColumn, -): column is QueryResultColumn { - return typeof column !== 'string'; -} +export type {NavigationPreviewProps, NavigationPreviewViewConfig} from '../../types/navigation'; export function NavigationPreview({ data, @@ -64,8 +26,7 @@ export function NavigationPreview) { const {columns, rows, loading, loaded, errorContent} = data; - const {tableColumns, extraColumns, formatterSettings, maxVisibleLines} = view ?? {}; - const columnNames = useMemo(() => columns.map(getColumnName), [columns]); + const columnNames = useMemo(() => columns.map((column) => column.name), [columns]); const [activeVisibleColumns, handleVisibleColumnsChange] = useVisibleColumns(columnNames, { value: visibleColumns, @@ -74,39 +35,21 @@ export function NavigationPreview columns.filter((column) => activeVisibleColumns.includes(getColumnName(column))), + () => columns.filter((column) => activeVisibleColumns.includes(column.name)), [columns, activeVisibleColumns], ); - const displayedColumnNames = useMemo( - () => displayedColumns.map(getColumnName), - [displayedColumns], - ); - const typedColumns = useMemo( - () => displayedColumns.filter(isQueryResultColumn), - [displayedColumns], - ); - const canUseQueryResultsTable = - !tableColumns && !extraColumns?.length && typedColumns.length === displayedColumns.length; - - const resolvedColumns = useMemo(() => { - if (tableColumns) { - return tableColumns; - } - return [...buildPreviewColumns(displayedColumnNames, i18n), ...(extraColumns ?? [])]; - }, [tableColumns, extraColumns, displayedColumnNames]); - const fieldsOptions = useMemo( () => columns.map((column) => ({ - id: getColumnName(column), - title: typeof column === 'string' ? column : (column.header ?? column.name), + id: column.name, + title: column.header ?? column.name, })), [columns], ); const filteredRows = useMemo( - () => filterPreviewRows(rows, displayedColumnNames, search), - [rows, displayedColumnNames, search], + () => filterPreviewRows(rows, displayedColumns, search, view), + [rows, displayedColumns, search, view], ); if (errorContent) { @@ -130,29 +73,15 @@ export function NavigationPreview )} - {canUseQueryResultsTable ? ( - - columns={typedColumns} - rows={filteredRows} - loading={loading} - loaded={loaded} - formatterSettings={formatterSettings} - maxVisibleLines={maxVisibleLines} - emptyVariant={search ? 'nothing-found' : 'no-data'} - displayIndices={false} - className={block('table')} - /> - ) : ( - - columns={resolvedColumns} - data={filteredRows} - loading={loading} - loaded={loaded} - emptyVariant={search ? 'nothing-found' : 'no-data'} - settings={{displayIndices: false}} - className={block('table')} - /> - )} + + {...view} + columns={displayedColumns} + rows={filteredRows} + loading={loading} + loaded={loaded} + emptyVariant={search ? 'nothing-found' : 'no-data'} + displayIndices={view?.displayIndices ?? false} + /> ); } diff --git a/src/modules/NavigationPreview/README.md b/src/modules/NavigationPreview/README.md new file mode 100644 index 0000000..812586e --- /dev/null +++ b/src/modules/NavigationPreview/README.md @@ -0,0 +1,95 @@ +# Navigation preview and result tables + +`NavigationPreview` always renders data using `QueryResultsTable`. Its `view` +accepts `QueryResultsTableSettings`, the same cell and table settings accepted +by `QueryResults`. Search and column selection remain navigation features. +See [standard result cells](../QueryResults/README.md) for formatting, copying, +expansion, incomplete values, and the preview lifecycle. + +Search uses the formatted text of visible standard cells, including wire +envelopes and nested collections, with the table and cell formatter settings. +Clipboard text overrides do not affect search. Custom-rendered columns retain +search by their raw string, number, or boolean values. The exported +`filterPreviewRows` helper accepts typed columns and optional cell settings as +its fourth argument; existing calls with string column names remain supported. + +## API migration + +This change requires consumer migration; there is no legacy table fallback. + +- Replace string columns with `QueryResultColumn` objects containing `name` + and the original YQL `type`. Types are required and are never inferred from data. +- Replace `view.tableColumns` with `data.columns`. Move `view.extraColumns` into + that same array, including a YQL `type` for every column. +- Put custom cell content in `column.render`. This overrides standard cell + formatting and actions only for that column; adjacent columns retain them. +- Remove imports and calls to `buildPreviewColumns`. Pass the typed column array + directly to `NavigationPreview` instead of constructing DataTable columns. +- Replace `NavigationPreviewFormatterConfig` with `NavigationPreviewViewConfig`, + which exposes the complete shared table settings. +- `NavigationPreviewRow` is now `Record`, allowing wire envelopes + such as `{val: 'partial', inc: true}`. `NavigationViewRow` retains `ReactNode` + values as a separate contract. When explicitly supplying factory generics, + its fifth generic now specifies the `NavigationView` row independently. + +For example, replace `columns: ['id', 'value']` with: + +```tsx +const columns: QueryResultColumn[] = [ + {name: 'id', type: ['DataType', 'Utf8']}, + {name: 'value', type: ['DataType', 'Utf8']}, +]; +``` + +Row indices remain off by default in navigation and on in results. Set +`displayIndices` explicitly to use the same presentation. Other shared options +include `rowKey`, `stripedRows`, `stickyHead`, `formatterSettings`, +`maxVisibleLines`, `collapseAfterLines`, `maxInlineTextLength`, `getCellOptions`, +and `onCellPreview`. + +## Direct and factory usage + +```tsx +import {NavigationPreview, createTableDetailConfig} from '@gravity-ui/querieskit'; +import type {QueryResultColumn, QueryResultsTableSettings} from '@gravity-ui/querieskit'; + +type Row = {id: string; value: unknown}; + +const columns: QueryResultColumn[] = [ + {name: 'id', type: ['DataType', 'Utf8']}, + {name: 'value', type: ['DataType', 'Utf8']}, +]; +const rows: Row[] = [{id: 'example', value: {val: 'partial text', inc: true}}]; + +const view: QueryResultsTableSettings = { + rowKey: (row) => row.id, + displayIndices: true, + formatterSettings: {treatValAsData: true}, + maxVisibleLines: 5, + collapseAfterLines: 8, + maxInlineTextLength: 10000, + // The application owns this handler, requests, and immutable row updates. + onCellPreview: handlePreview, +}; + + data={{columns, rows, loaded: true}} view={view} />; + +// The same settings are available through the standard detail factory. +const detailConfig = createTableDetailConfig({ + resolvePreview: () => ({columns, rows, loaded: true}), + resolvePreviewView: () => view, +}); +``` + +Both resolver callbacks receive the selected navigation item, so settings and +preview callbacks can depend on its identity. Preserve stable row keys and update +rows immutably after loading the full value. A rejected preview Promise displays +a local error and permits retry; opening a dialog remains application-owned. + +## Storybook verification + +`Modules/NavigationPreview` includes **Custom Columns**, demonstrating standard +cells next to custom rendering, and **Inline Preview And Retry**, demonstrating +partial wire objects, loading, retry, row replacement, and local expansion. +Verify column selection and search alongside these actions. Check that expanded +rows and sticky headers remain aligned when scrolling in light and dark themes. diff --git a/src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx b/src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx deleted file mode 100644 index c1c4722..0000000 --- a/src/modules/NavigationPreview/helpers/buildPreviewColumns.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type {Column} from '../../../components/DataTable'; -import {buildColumnsFromKeys} from '../../../helpers/buildColumnsFromKeys'; -import type {NavigationPreviewRow} from '../../../types/navigation'; -import type previewI18n from '../i18n'; - -export function buildPreviewColumns( - columns: string[], - i18n: typeof previewI18n, -): Array> { - return buildColumnsFromKeys(columns, i18n('value_empty')); -} diff --git a/src/modules/NavigationPreview/helpers/filterPreviewRows.ts b/src/modules/NavigationPreview/helpers/filterPreviewRows.ts index 9cfc849..37f0c60 100644 --- a/src/modules/NavigationPreview/helpers/filterPreviewRows.ts +++ b/src/modules/NavigationPreview/helpers/filterPreviewRows.ts @@ -1 +1,39 @@ -export {filterNavigationRows as filterPreviewRows} from '../../../helpers/filterNavigationRows'; +import {formatQueryResultValue} from '../../../components/QueryResultsTable/helpers/formatQueryResultValue'; +import {filterNavigationRows} from '../../../helpers/filterNavigationRows'; +import type {QueryResultCellSettings, QueryResultColumn} from '../../../types/queryResults'; + +export function filterPreviewRows>( + rows: TRow[], + columns: Array>, + search?: string, + settings?: QueryResultCellSettings, +): TRow[] { + const query = search?.trim().toLowerCase(); + if (!query) return rows; + + return rows.filter((row, index) => + columns.some((column) => { + // Preserve the public helper's string-column API and custom-renderer search. + if (typeof column === 'string' || column.render) { + const name = typeof column === 'string' ? column : column.name; + return filterNavigationRows([row], [name], query).length > 0; + } + + try { + const value = row[column.name]; + const options = settings?.getCellOptions?.({row, value, index, column}); + const formatted = formatQueryResultValue( + value, + column.type, + settings?.formatterSettings, + // Clipboard overrides are not the displayed value. + {...options, copyText: undefined}, + 0, + ); + return !formatted.error && formatted.text.toLowerCase().includes(query); + } catch { + return false; + } + }), + ); +} diff --git a/src/modules/NavigationPreview/index.ts b/src/modules/NavigationPreview/index.ts index 5172f61..e6d85eb 100644 --- a/src/modules/NavigationPreview/index.ts +++ b/src/modules/NavigationPreview/index.ts @@ -1,4 +1,3 @@ export {NavigationPreview} from './NavigationPreview'; export type {NavigationPreviewProps, NavigationPreviewViewConfig} from './NavigationPreview'; -export {buildPreviewColumns} from './helpers/buildPreviewColumns'; export {filterPreviewRows} from './helpers/filterPreviewRows'; diff --git a/src/modules/NavigationPreview/story/NavigationPreview.stories.tsx b/src/modules/NavigationPreview/story/NavigationPreview.stories.tsx index 7ed743f..211749c 100644 --- a/src/modules/NavigationPreview/story/NavigationPreview.stories.tsx +++ b/src/modules/NavigationPreview/story/NavigationPreview.stories.tsx @@ -1,9 +1,10 @@ -import React, {useState} from 'react'; +import React, {useRef, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import {Icon, Label} from '@gravity-ui/uikit'; import LockIcon from '@gravity-ui/icons/svgs/lock.svg'; import {NavigationPreview} from '..'; -import type {NavigationPreviewColumn, NavigationPreviewRow} from '../../../types/navigation'; +import type {NavigationPreviewRow} from '../../../types/navigation'; +import type {QueryResultColumn} from '../../../types/queryResults'; import {PREVIEW_COLUMNS, PREVIEW_ROWS} from './mockData'; const meta: Meta = { @@ -68,7 +69,7 @@ export const Error: Story = { }, }; -const TYPED_COLUMNS: Array> = [ +const TYPED_COLUMNS: Array> = [ {name: 'id', type: ['DataType', 'Uint64']}, {name: 'tags', type: ['ListType', ['DataType', 'Utf8']]}, { @@ -100,24 +101,74 @@ const CUSTOM_ROWS: CustomRow[] = PREVIEW_ROWS.map((row, index) => ({ lock: index % 2 === 0 ? 'shared' : undefined, })); +const CUSTOM_COLUMNS: Array> = [ + ...PREVIEW_COLUMNS, + { + name: 'lock', + type: ['OptionalType', ['DataType', 'Utf8']], + header: 'Lock', + render: ({row}) => + row.lock ? : '—', + }, +]; + export const CustomColumns: Story = { - args: { - data: {columns: PREVIEW_COLUMNS, rows: CUSTOM_ROWS, loaded: true}, - view: { - extraColumns: [ - { - name: 'lock', - header: 'Lock', - render: ({row}) => - (row as CustomRow).lock ? ( - - ) : ( - '—' + render: () => ( + + data={{columns: CUSTOM_COLUMNS, rows: CUSTOM_ROWS, loaded: true}} + /> + ), +}; + +type PreviewRow = {id: string; value: unknown; loaded?: boolean}; +const PREVIEW_VALUE_COLUMNS: Array> = [ + {name: 'id', type: ['DataType', 'Utf8']}, + {name: 'value', type: ['ListType', ['DataType', 'Int32']]}, +]; +const FULL_VALUE = Array.from({length: 12}, (_, index) => index); + +const InlinePreviewStory = () => { + const [rows, setRows] = useState([ + {id: 'Load full list', value: {val: [0, 1, 2], inc: true}}, + {id: 'Fails once; retry', value: {val: [0, 1, 2], inc: true}}, + {id: 'Expand locally', value: FULL_VALUE}, + ]); + const attempts = useRef(new Set()); + + return ( + + data={{columns: PREVIEW_VALUE_COLUMNS, rows, loaded: true}} + view={{ + rowKey: (row) => row.id, + displayIndices: true, + stripedRows: true, + formatterSettings: {treatValAsData: true, maxListSize: 5}, + maxVisibleLines: 3, + collapseAfterLines: 5, + getCellOptions: ({row, column}) => + column.name === 'value' && (row.loaded || row.id === 'Expand locally') + ? { + isIncomplete: false, + formatterSettings: {maxListSize: undefined}, + } + : {}, + onCellPreview: async ({row}) => { + await new Promise((resolve) => setTimeout(resolve, 800)); + if (row.id === 'Fails once; retry' && !attempts.current.has(row.id)) { + attempts.current.add(row.id); + throw new globalThis.Error('Demo request failed. Preview again to retry.'); + } + setRows((current) => + current.map((item) => + item.id === row.id + ? {...item, value: [...FULL_VALUE], loaded: true} + : item, ), + ); }, - ], - }, - }, + }} + /> + ); }; + +export const InlinePreviewAndRetry: Story = {render: () => }; diff --git a/src/modules/NavigationPreview/story/mockData.ts b/src/modules/NavigationPreview/story/mockData.ts index 664cbdb..026fb98 100644 --- a/src/modules/NavigationPreview/story/mockData.ts +++ b/src/modules/NavigationPreview/story/mockData.ts @@ -1,6 +1,7 @@ -import type {NavigationPreviewColumn, NavigationPreviewRow} from '../../../types/navigation'; +import type {NavigationPreviewRow} from '../../../types/navigation'; +import type {QueryResultColumn} from '../../../types/queryResults'; -export const PREVIEW_COLUMNS: Array> = [ +export const PREVIEW_COLUMNS: Array> = [ {name: 'id', type: ['DataType', 'Int32']}, {name: 'created_at', type: ['DataType', 'Utf8']}, {name: 'title', type: ['DataType', 'Utf8']}, diff --git a/src/modules/QueriesNavigation/helpers/createTableDetailConfig.tsx b/src/modules/QueriesNavigation/helpers/createTableDetailConfig.tsx index 8572705..3523b07 100644 --- a/src/modules/QueriesNavigation/helpers/createTableDetailConfig.tsx +++ b/src/modules/QueriesNavigation/helpers/createTableDetailConfig.tsx @@ -11,9 +11,11 @@ import type { NavigationMetaItem, NavigationPreviewConfig, NavigationPreviewRow, + NavigationPreviewViewConfig, NavigationSchemaColumn, NavigationSchemaConfig, NavigationViewConfig, + NavigationViewRow, } from '../../../types/navigation'; import i18n from '../i18n'; @@ -27,6 +29,11 @@ export type NavigationPreviewResolver< TRow extends NavigationPreviewRow = NavigationPreviewRow, > = (item: TItem) => NavigationPreviewConfig | undefined; +export type NavigationPreviewViewResolver< + TItem extends NavigationItem = NavigationItem, + TRow extends NavigationPreviewRow = NavigationPreviewRow, +> = (item: TItem) => NavigationPreviewViewConfig | undefined; + export type NavigationMetaResolver< TItem extends NavigationItem = NavigationItem, TMetaItem extends NavigationMetaItem = NavigationMetaItem, @@ -38,7 +45,7 @@ export type NavigationMetaRenderer = (item: TItem) => NavigationViewConfig | undefined; export type CreateTableDetailConfigOptions< @@ -46,12 +53,14 @@ export type CreateTableDetailConfigOptions< TColumn extends NavigationSchemaColumn = NavigationSchemaColumn, TRow extends NavigationPreviewRow = NavigationPreviewRow, TMetaItem extends NavigationMetaItem = NavigationMetaItem, + TViewRow extends NavigationViewRow = NavigationViewRow, > = { resolveSchema?: NavigationSchemaResolver; resolvePreview?: NavigationPreviewResolver; + resolvePreviewView?: NavigationPreviewViewResolver; resolveMeta?: NavigationMetaResolver; renderMeta?: NavigationMetaRenderer; - resolveView?: NavigationViewResolver; + resolveView?: NavigationViewResolver; }; export const createTableDetailConfig = < @@ -59,10 +68,18 @@ export const createTableDetailConfig = < TColumn extends NavigationSchemaColumn = NavigationSchemaColumn, TRow extends NavigationPreviewRow = NavigationPreviewRow, TMetaItem extends NavigationMetaItem = NavigationMetaItem, + TViewRow extends NavigationViewRow = NavigationViewRow, >( - options?: CreateTableDetailConfigOptions, + options?: CreateTableDetailConfigOptions, ): NavigationDetailConfigFactory => { - const {resolveSchema, resolvePreview, resolveMeta, renderMeta, resolveView} = options ?? {}; + const { + resolveSchema, + resolvePreview, + resolvePreviewView, + resolveMeta, + renderMeta, + resolveView, + } = options ?? {}; return (item): NavigationDetailConfig => ({ tabs: [ @@ -89,6 +106,7 @@ export const createTableDetailConfig = < return ( data={preview ?? {columns: [], rows: []}} + view={resolvePreviewView?.(item)} search={search} onSearchUpdate={onSearchUpdate} searchPlaceholder={searchPlaceholder} @@ -115,7 +133,7 @@ export const createTableDetailConfig = < renderContent: ({search, onSearchUpdate, searchPlaceholder}) => { const view = resolveView?.(item); return ( - + data={view ?? {sections: []}} search={search} onSearchUpdate={onSearchUpdate} diff --git a/src/modules/QueriesNavigation/index.ts b/src/modules/QueriesNavigation/index.ts index 3652663..f3d8bd1 100644 --- a/src/modules/QueriesNavigation/index.ts +++ b/src/modules/QueriesNavigation/index.ts @@ -6,6 +6,7 @@ export type { NavigationMetaRenderer, NavigationMetaResolver, NavigationPreviewResolver, + NavigationPreviewViewResolver, NavigationSchemaResolver, NavigationViewResolver, } from './helpers/createTableDetailConfig'; diff --git a/src/modules/QueryResults/QueryResults.tsx b/src/modules/QueryResults/QueryResults.tsx index 8b729f5..8d74ee3 100644 --- a/src/modules/QueryResults/QueryResults.tsx +++ b/src/modules/QueryResults/QueryResults.tsx @@ -19,6 +19,9 @@ export function QueryResults>({ loading, errorContent, rowKey, + displayIndices, + stripedRows, + stickyHead, formatterSettings, maxVisibleLines, collapseAfterLines, @@ -62,6 +65,9 @@ export function QueryResults>({ rows={rows} loading={loading} rowKey={rowKey} + displayIndices={displayIndices} + stripedRows={stripedRows} + stickyHead={stickyHead} formatterSettings={formatterSettings} maxVisibleLines={maxVisibleLines} collapseAfterLines={collapseAfterLines} diff --git a/src/modules/QueryResults/README.md b/src/modules/QueryResults/README.md index df6989b..b95a99c 100644 --- a/src/modules/QueryResults/README.md +++ b/src/modules/QueryResults/README.md @@ -1,10 +1,16 @@ # Standard result cells -`QueryResults`, `QueryResultsTable`, and the `result` tab of `QueryExecutionPanel` -accept the same cell options. Values remain in YQL wire format; QueriesKit handles +`QueryResults`, `QueryResultsTable`, `NavigationPreview.view`, and the `result` +tab of `QueryExecutionPanel` accept the same cell options. Values remain in YQL wire format; QueriesKit handles Unipika conversion, HTML, copying, local expansion, and preview actions. `column.render` replaces this entire pipeline, including `getCellOptions`. +`QueryResultsTableSettings` also shares `rowKey`, `displayIndices`, +`stripedRows`, and `stickyHead` between results and navigation. Navigation keeps +row indices off by default; results keep them on. See the +[NavigationPreview migration guide](../NavigationPreview/README.md) for typed +columns and settings passed directly or through `createTableDetailConfig`. + ## YTsaurus integration Keep legacy metadata separately from the wire values. Map `$incomplete` to diff --git a/src/modules/QueryResults/index.ts b/src/modules/QueryResults/index.ts index 6a1352b..f2208d6 100644 --- a/src/modules/QueryResults/index.ts +++ b/src/modules/QueryResults/index.ts @@ -5,5 +5,6 @@ export type { QueryResultCellPreviewContext, QueryResultCellRenderContext, QueryResultCellSettings, + QueryResultsTableSettings, QueryResultFormatterSettings, } from '../../types/queryResults'; diff --git a/src/modules/index.ts b/src/modules/index.ts index bde2211..e92cc29 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -44,7 +44,7 @@ export type { QueryStatisticsProps, QueryStatisticsValues, } from './QueryStatistics'; -export {NavigationPreview, buildPreviewColumns, filterPreviewRows} from './NavigationPreview'; +export {NavigationPreview, filterPreviewRows} from './NavigationPreview'; export type {NavigationPreviewProps, NavigationPreviewViewConfig} from './NavigationPreview'; export {NavigationMeta, buildMetaGroups} from './NavigationMeta'; export type {NavigationMetaProps, NavigationMetaViewConfig} from './NavigationMeta'; @@ -68,5 +68,6 @@ export type { QueriesNavigationProps, CreateTableDetailConfigOptions, NavigationPreviewResolver, + NavigationPreviewViewResolver, NavigationSchemaResolver, } from './QueriesNavigation'; diff --git a/src/types/navigation.ts b/src/types/navigation.ts index 3b0f201..3ffa7a2 100644 --- a/src/types/navigation.ts +++ b/src/types/navigation.ts @@ -1,5 +1,5 @@ -import {ReactNode} from 'react'; -import type {QueryResultColumn, QueryResultFormatterSettings} from './queryResults'; +import type {ReactNode} from 'react'; +import type {QueryResultColumn, QueryResultsTableSettings} from './queryResults'; import type {LoadPathSuggestions} from './pathEditor'; export type NavigationLocation = { @@ -115,10 +115,10 @@ export type NavigationSchemaConfig< export type NavigationCellValue = ReactNode; -export type NavigationPreviewRow = Record; +export type NavigationPreviewRow = Record; export type NavigationPreviewColumn = - string | QueryResultColumn; + QueryResultColumn; export type NavigationPreviewConfig = NavigationAsyncConfig & { @@ -126,13 +126,24 @@ export type NavigationPreviewConfig = + QueryResultsTableSettings; + +export type NavigationPreviewProps = { + data: NavigationPreviewConfig; + view?: NavigationPreviewViewConfig; + search?: string; + onSearchUpdate?: (value: string) => void; + searchPlaceholder?: string; + visibleColumns?: string[]; + onVisibleColumnsChange?: (value: string[]) => void; + defaultVisibleColumns?: string[]; + hideToolbar?: boolean; + hideFieldsSelector?: boolean; + className?: string; }; -export type NavigationViewRow = NavigationPreviewRow; +export type NavigationViewRow = Record; export type NavigationViewSectionAction = NavigationAction>; diff --git a/src/types/queryResults.ts b/src/types/queryResults.ts index 3aa225d..798ddc2 100644 --- a/src/types/queryResults.ts +++ b/src/types/queryResults.ts @@ -67,20 +67,25 @@ export type QueryResultCellSettings> = { onCellPreview?: (context: QueryResultCellPreviewContext) => void | Promise; }; -export type QueryResultsTableProps> = +/** Shared rendering and layout settings for every result table. */ +export type QueryResultsTableSettings> = QueryResultCellSettings & { + rowKey?: (row: TRow, index: number) => string | number; + displayIndices?: boolean; + stripedRows?: boolean; + /** Set to false when the surrounding layout does not need a sticky header. */ + stickyHead?: Settings['stickyHead'] | false; + }; + +export type QueryResultsTableProps> = + QueryResultsTableSettings & { columns: Array>; /** Values use the YQL wire representation consumed by @gravity-ui/unipika. */ rows: TRow[]; loading?: boolean; loaded?: boolean; errorContent?: ReactNode; - rowKey?: (row: TRow, index: number) => string | number; emptyVariant?: EmptyContentVariant; - displayIndices?: boolean; - stripedRows?: boolean; - /** Set to false when the surrounding layout does not need a sticky header. */ - stickyHead?: Settings['stickyHead'] | false; className?: string; }; @@ -91,14 +96,13 @@ export type QueryResultsSchemaRenderContext }; export type QueryResultsProps> = - QueryResultCellSettings & { + QueryResultsTableSettings & { columns: Array>; /** Values use the YQL wire representation consumed by @gravity-ui/unipika. */ rows: TRow[]; totalRows?: number; loading?: boolean; errorContent?: ReactNode; - rowKey?: (row: TRow, index: number) => string | number; title?: ReactNode; toolbarContent?: ReactNode; actions?: ReactNode; diff --git a/tests/components/NavigationPreview.test.tsx b/tests/components/NavigationPreview.test.tsx new file mode 100644 index 0000000..2620d65 --- /dev/null +++ b/tests/components/NavigationPreview.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +import React, {act} from 'react'; +import {type Root, createRoot} from 'react-dom/client'; +import {ThemeProvider, configure} from '@gravity-ui/uikit'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {NavigationPreview, filterPreviewRows} from '../../src/modules/NavigationPreview'; +import {createTableDetailConfig} from '../../src/modules/QueriesNavigation/helpers/createTableDetailConfig'; +import type {NavigationPreviewConfig} from '../../src/types/navigation'; + +const data: NavigationPreviewConfig = { + columns: [ + {name: 'name', type: ['DataType', 'Utf8']}, + {name: 'count', type: ['DataType', 'Int32']}, + ], + rows: [ + {name: 'Alpha record', count: 42}, + {name: 'Beta record', count: 7, hidden: 'alpha'}, + ], +}; + +describe('NavigationPreview integration', () => { + let container: HTMLDivElement; + let root: Root; + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + configure({lang: 'en'}); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + function render(content: React.ReactNode) { + act(() => root.render({content})); + } + it('searches displayed columns only and restores rows when cleared', () => { + const show = (search: string, visibleColumns = ['name', 'count']) => + render( + , + ); + show(' ALPHA '); + expect(container.textContent).toContain('Alpha record'); + expect(container.textContent).not.toContain('Beta record'); + show('42', ['name']); + expect(container.textContent).toContain('Nothing found'); + show('42'); + expect(container.textContent).toContain('Alpha record'); + show(''); + expect(container.textContent).toContain('Beta record'); + expect(container.textContent).toContain('Alpha record'); + }); + it('keeps the default visible columns and accepts controlled visibility updates', () => { + render(); + expect(container.querySelector('thead')?.textContent).toContain('name'); + expect(container.querySelector('thead')?.textContent).not.toContain('count'); + render(); + expect(container.querySelector('thead')?.textContent).toContain('count'); + expect(container.querySelector('thead')?.textContent).not.toContain('name'); + expect(container.textContent).not.toContain('Alpha record'); + }); + it.each([ + { + columns: [{name: 'value', type: ['DataType', 'Utf8']}], + rows: [{value: {val: 'Alpha record', inc: true}}, {value: {val: 'Beta record'}}], + }, + { + columns: [{name: 'value', type: ['ListType', ['DataType', 'Utf8']]}], + rows: [{value: [{val: 'Alpha record', inc: true}]}, {value: ['Beta record']}], + }, + ])('searches typed wire values and restores rows when cleared', (wireData) => { + const show = (search: string) => + render( + , + ); + show(' ALPHA '); + expect(container.textContent).toContain('Alpha record'); + expect(container.textContent).not.toContain('Beta record'); + show(''); + expect(container.textContent).toContain('Beta record'); + }); + it('uses cell formatting settings, ignores copy overrides and isolates invalid values', () => { + const rows = [{value: ['Alpha record']}, {value: 'invalid list'}]; + const columns: NavigationPreviewConfig['columns'] = [ + {name: 'value', type: ['ListType', ['DataType', 'Utf8']]}, + ]; + const settings = { + formatterSettings: {maxStringSize: 2}, + getCellOptions: () => ({ + formatterSettings: {maxStringSize: undefined}, + copyText: 'Clipboard only', + }), + }; + expect(filterPreviewRows(rows, columns, 'Alpha', settings)).toEqual([rows[0]]); + expect(filterPreviewRows(rows, columns, 'Clipboard', settings)).toEqual([]); + expect(filterPreviewRows(data.rows, ['name'], 'Alpha')).toEqual([data.rows[0]]); + }); + it('shows initial loading, empty data, unmatched search and error states', () => { + render(); + expect(container.querySelector('.g-skeleton')).not.toBeNull(); + expect(container.textContent).not.toContain('No data'); + render(); + expect(container.querySelector('.g-skeleton')).toBeNull(); + expect(container.textContent).toContain('No data'); + render(); + expect(container.textContent).toContain('Nothing found'); + render(); + expect(container.textContent).toBe('Preview failed'); + expect(container.querySelector('table')).toBeNull(); + }); + it('forwards preview settings and controlled search through the detail factory', async () => { + const item = {path: '/table', title: 'table'}; + const onCellPreview = vi.fn(); + const resolvePreviewView = vi.fn(() => ({ + getCellOptions: () => ({isIncomplete: true}), + onCellPreview, + displayIndices: true, + stickyHead: false as const, + })); + const config = createTableDetailConfig({ + resolvePreview: () => data, + resolvePreviewView, + })(item); + const tab = config.tabs.find(({id}) => id === 'preview'); + const onSearchUpdate = vi.fn(); + render( + tab?.renderContent?.({search: 'Beta', onSearchUpdate, searchPlaceholder: 'Find rows'}), + ); + expect(resolvePreviewView).toHaveBeenCalledWith(item); + expect(container.querySelector('input')?.placeholder).toBe('Find rows'); + expect(container.querySelector('input')?.value).toBe('Beta'); + expect(container.textContent).not.toContain('Alpha record'); + const preview = container.querySelector('[aria-label="Preview"]'); + expect(preview).not.toBeNull(); + await act(async () => preview?.click()); + expect(onCellPreview.mock.calls[0][0].row).toBe(data.rows[1]); + const input = container.querySelector('input'); + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( + input, + 'Alpha', + ); + input?.dispatchEvent(new Event('input', {bubbles: true})); + }); + expect(onSearchUpdate).toHaveBeenLastCalledWith('Alpha'); + }); +}); diff --git a/tests/components/QueryResultsTable.test.tsx b/tests/components/QueryResultsTable.test.tsx index 26abe63..172261e 100644 --- a/tests/components/QueryResultsTable.test.tsx +++ b/tests/components/QueryResultsTable.test.tsx @@ -5,6 +5,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {ThemeProvider, configure} from '@gravity-ui/uikit'; import {QueryResultsTable} from '../../src/components/QueryResultsTable'; import {QueryResults} from '../../src/modules/QueryResults'; +import {NavigationPreview} from '../../src/modules/NavigationPreview'; import type {QueryResultsTableProps} from '../../src/types/queryResults'; const copied = vi.hoisted(() => vi.fn()); @@ -35,7 +36,7 @@ function deferred() { return {promise, resolve, reject}; } -describe('QueryResults standard cells', () => { +describe.each(['table', 'results', 'navigation'] as const)('%s standard cells', (surface) => { let container: HTMLDivElement; let root: Root; beforeEach(() => { @@ -54,22 +55,43 @@ describe('QueryResults standard cells', () => { Array.from(container.querySelectorAll('button')).find( (node) => (node.getAttribute('aria-label') ?? node.textContent) === name, ); - function render(props: Partial> = {}, module = false) { + function render(props: Partial> = {}) { + const { + columns: resolvedColumns = columns, + rows = initialRows, + loading, + loaded, + errorContent, + emptyVariant: _emptyVariant, + className, + ...settings + } = props; + let content: React.ReactNode; + if (surface === 'navigation') { + content = ( + + ); + } else if (surface === 'results') { + content = ; + } else { + content = ( + + ); + } act(() => root.render( - - {module ? ( - - ) : ( - - )} - + {content} , ), ); @@ -106,14 +128,11 @@ describe('QueryResults standard cells', () => { expect(preview).not.toHaveBeenCalled(); }, ); - it.each(['', 'href override'])( - 'copies literal override %j through QueryResults', - async (copyText) => { - render({getCellOptions: () => ({copyText})}, true); - await click('Copy'); - expect(copied).toHaveBeenCalledWith(copyText); - }, - ); + it.each(['', 'href override'])('copies literal override %j', async (copyText) => { + render({getCellOptions: () => ({copyText})}); + await click('Copy'); + expect(copied).toHaveBeenCalledWith(copyText); + }); it('shows incomplete warning without a dead action and hides copy', () => { render({getCellOptions: incomplete}); expect(container.textContent).toContain('Value is incomplete'); @@ -163,9 +182,9 @@ describe('QueryResults standard cells', () => { }); expect(container.textContent).toContain('Incomplete'); }); - it('offers preview and full copy for a complete large placeholder through the module', async () => { + it('offers preview and full copy for a complete large placeholder', async () => { const preview = vi.fn(); - render({maxInlineTextLength: 3, onCellPreview: preview}, true); + render({maxInlineTextLength: 3, onCellPreview: preview}); expect(container.textContent).toContain('too large'); await click('Preview'); expect(preview.mock.calls[0][0].isIncomplete).toBe(false); @@ -281,6 +300,63 @@ describe('QueryResults standard cells', () => { expect(container.querySelector('[role="alert"]')).toBeNull(); expect(options).not.toHaveBeenCalled(); }); + it('renders object wire envelopes and detects server incompleteness', async () => { + const value = {val: 'part', inc: true}; + const preview = vi.fn(); + render({ + rows: [{value}], + formatterSettings: {treatValAsData: true}, + onCellPreview: preview, + }); + expect(container.textContent).toContain('Value is incomplete'); + expect(button('Copy')).toBeUndefined(); + await click('Preview'); + expect(preview.mock.calls[0][0].value).toBe(value); + expect(preview.mock.calls[0][0].isIncomplete).toBe(true); + }); + it('keeps standard behavior beside a custom column', async () => { + const preview = vi.fn(); + const options = + vi.fn['getCellOptions']>>(incomplete); + render({ + columns: [ + {...columns[0], name: 'custom', render: () => Custom neighbor}, + columns[0], + ], + getCellOptions: options, + onCellPreview: preview, + }); + expect(container.textContent).toContain('Custom neighbor'); + expect(container.textContent).toContain('Value is incomplete'); + expect(options.mock.calls.every(([context]) => context.column.name === 'value')).toBe(true); + await click('Preview'); + expect(preview.mock.calls[0][0].column).toBe(columns[0]); + }); + it('preserves index defaults and forwards shared table appearance settings', () => { + render(); + expect(container.querySelector('tbody tr')?.children.length).toBe( + surface === 'navigation' ? 1 : 2, + ); + expect(container.querySelector('.data-table_striped-rows')).not.toBeNull(); + render({displayIndices: true, stripedRows: false, stickyHead: false}); + expect(container.querySelector('.data-table_striped-rows')).toBeNull(); + expect(container.querySelector('.data-table__sticky_head')).toBeNull(); + expect(container.querySelector('tbody tr')?.children.length).toBe(2); + render({displayIndices: false, stripedRows: true, stickyHead: 'fixed'}); + expect(container.querySelector('.data-table_striped-rows')).not.toBeNull(); + expect(container.querySelector('.data-table__sticky_head')).not.toBeNull(); + expect(container.querySelector('tbody tr')?.children.length).toBe(1); + }); + it('preserves row identity by rowKey when rows are reordered', () => { + const first = {value: 'one\ntwo\nthree\nfour\nfive\nsix'}; + const second = {value: 'other'}; + const rowKey = (row: Row) => String(row.value); + render({rows: [first, second], rowKey}); + const firstRow = button('Show more')?.closest('tr'); + expect(firstRow).toBeDefined(); + render({rows: [second, first], rowKey}); + expect(button('Show more')?.closest('tr')).toBe(firstRow); + }); it('isolates malformed values within their cell', () => { render({columns: [{name: 'value', type: ['UnsupportedType']}], rows: [{value: 'bad'}]}); expect(container.querySelector('[role="alert"]')?.textContent).toBe(