Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<TRow>`. 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)


Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 51 additions & 1 deletion src/components/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,55 @@ export function DataTable<T>(props: DataTableProps<T>) {

const isEmpty = loaded && data.length === 0;
const displayIndices = settings?.displayIndices !== false;
const containerRef = React.useRef<HTMLDivElement>(null);
const tableRef = React.useRef<BaseDataTable<T>>(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) {
Expand All @@ -84,9 +133,10 @@ export function DataTable<T>(props: DataTableProps<T>) {
};

return (
<div className={block(null, className)}>
<div ref={containerRef} className={block(null, className)}>
<BaseDataTable
{...rest}
ref={tableRef}
columns={columns}
data={data}
settings={settings}
Expand Down
35 changes: 0 additions & 35 deletions src/components/QueryResultsTable/QueryResultsTable.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,41 +25,6 @@
display: inline-block;
}

&__cell {
display: block;
padding-right: var(--g-spacing-5);
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;

&:hover .qp-query-results-table__cell-actions,
&:focus-within .qp-query-results-table__cell-actions {
opacity: 1;
}

.unipika {
display: inline;
font: inherit;
}
}

&__cell-actions {
position: absolute;
top: var(--g-spacing-1);
right: var(--g-spacing-1);
opacity: 0;
}

&__show-more {
display: block;
margin-top: var(--g-spacing-1);
}

&__format-error {
color: var(--g-color-text-danger);
}

&__error {
display: block;
}
Expand Down
64 changes: 33 additions & 31 deletions src/components/QueryResultsTable/QueryResultsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React, {useMemo} from 'react';
import BaseDataTable, {type Column, type Settings} from '@gravity-ui/react-data-table';
import BaseDataTable, {type Column} from '@gravity-ui/react-data-table';
import {Text, Tooltip} from '@gravity-ui/uikit';
import cn from 'bem-cn-lite';
import {DataTable} from '../DataTable';
import type {EmptyContentVariant} from '../EmptyContent';
import type {QueryResultColumn, QueryResultFormatterSettings} from '../../types/queryResults';
import type {QueryResultsTableProps} from '../../types/queryResults';
import {formatQueryResultType, isQueryResultNumericType} from './helpers/formatQueryResultType';
import {QueryResultCell} from './internal/QueryResultCell';

Expand All @@ -14,23 +13,7 @@ import './QueryResultsTable.scss';
const block = cn('qp-query-results-table');
const DEFAULT_MAX_VISIBLE_LINES = 5;

export type QueryResultsTableProps<TRow extends Record<string, unknown>> = {
columns: Array<QueryResultColumn<TRow>>;
/** 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<TRow extends Record<string, unknown>>({
columns,
Expand All @@ -41,6 +24,10 @@ export function QueryResultsTable<TRow extends Record<string, unknown>>({
rowKey,
formatterSettings,
maxVisibleLines = DEFAULT_MAX_VISIBLE_LINES,
collapseAfterLines = maxVisibleLines,
maxInlineTextLength,
getCellOptions,
onCellPreview,
emptyVariant,
displayIndices = true,
stripedRows = true,
Expand All @@ -67,19 +54,34 @@ export function QueryResultsTable<TRow extends Record<string, unknown>>({
<span className={block('header')}>{column.header ?? column.name}</span>
</Tooltip>
),
render: ({row, index}) => (
<QueryResultCell
row={row}
value={row[column.name]}
index={index}
column={column}
formatterSettings={formatterSettings}
maxVisibleLines={maxVisibleLines}
/>
),
render: ({row, index}) =>
column.render ? (
column.render({row, index, column, value: row[column.name]})
) : (
<QueryResultCell
row={row}
value={row[column.name]}
index={index}
column={column}
formatterSettings={formatterSettings}
maxVisibleLines={maxVisibleLines}
collapseAfterLines={collapseAfterLines}
maxInlineTextLength={maxInlineTextLength}
getCellOptions={getCellOptions}
onCellPreview={onCellPreview}
/>
),
};
}),
[columns, formatterSettings, maxVisibleLines],
[
columns,
formatterSettings,
maxVisibleLines,
collapseAfterLines,
maxInlineTextLength,
getCellOptions,
onCellPreview,
],
);

if (errorContent) {
Expand Down
107 changes: 98 additions & 9 deletions src/components/QueryResultsTable/helpers/formatQueryResultValue.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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<typeof formatter.converters.yql>,
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};
Expand Down
10 changes: 9 additions & 1 deletion src/components/QueryResultsTable/i18n/en.json
Original file line number Diff line number Diff line change
@@ -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."
}
10 changes: 9 additions & 1 deletion src/components/QueryResultsTable/i18n/ru.json
Original file line number Diff line number Diff line change
@@ -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": "Не удалось открыть предпросмотр. Попробуйте ещё раз."
}
Loading
Loading