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
14 changes: 14 additions & 0 deletions src/helpers/useDebouncedValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {useEffect, useState} from 'react';

/** Delays a changing value without duplicating timer lifecycle handling in consumers. */
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const timeoutId = setTimeout(() => setDebouncedValue(value), delay);

return () => clearTimeout(timeoutId);
}, [delay, value]);

return debouncedValue;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './types/tutorial';
export * from './types/navigation';
export * from './types/pathEditor';
export * from './types/errorTree';
export * from './types/queryStatistics';
3 changes: 3 additions & 0 deletions src/modules/QueryStatistics/QueryStatistics.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.qp-query-statistics {
min-width: 0;
}
125 changes: 125 additions & 0 deletions src/modules/QueryStatistics/QueryStatistics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import cn from 'bem-cn-lite';

import {useDebouncedValue} from '../../helpers/useDebouncedValue';
import type {QueryStatisticsColumn, QueryStatisticsProps} from '../../types/queryStatistics';
import {
collectQueryStatisticsGroupIds,
filterQueryStatistics,
flattenQueryStatistics,
} from './helpers';
import {QueryStatisticsTable} from './internal/QueryStatisticsTable';
import {QueryStatisticsToolbar} from './internal/QueryStatisticsToolbar';

import './QueryStatistics.scss';

const block = cn('qp-query-statistics');
const DEFAULT_VISIBLE_COLUMNS: QueryStatisticsColumn[] = ['min', 'max', 'avg', 'sum', 'count'];
const DEFAULT_SEARCH_DEBOUNCE_MS = 500;

function setsEqual(left: Set<string>, right: Set<string>) {
return left.size === right.size && Array.from(left).every((value) => right.has(value));
}

export function QueryStatistics({
data,
visibleColumns = DEFAULT_VISIBLE_COLUMNS,
columnConfig,
extraColumns,
className,
virtual = false,
fixedHeader = false,
formatValue,
search: controlledSearch,
defaultSearch = '',
onSearchUpdate,
expandedIds: controlledExpandedIds,
defaultExpandedIds,
onExpandedIdsChange,
searchDebounceMs = DEFAULT_SEARCH_DEBOUNCE_MS,
}: QueryStatisticsProps) {
const groupIds = useMemo(() => collectQueryStatisticsGroupIds(data), [data]);
const knownGroupIdsRef = useRef(new Set(groupIds));
const [uncontrolledExpandedIds, setUncontrolledExpandedIds] = useState(
() => new Set(defaultExpandedIds ?? groupIds),
);
const [uncontrolledSearch, setUncontrolledSearch] = useState(defaultSearch);
const search = controlledSearch ?? uncontrolledSearch;
const expandedIds = useMemo(
() => new Set(controlledExpandedIds ?? uncontrolledExpandedIds),
[controlledExpandedIds, uncontrolledExpandedIds],
);
const debouncedSearch = useDebouncedValue(search, searchDebounceMs);

const updateSearch = useCallback(
(value: string) => {
if (controlledSearch === undefined) setUncontrolledSearch(value);
onSearchUpdate?.(value);
},
[controlledSearch, onSearchUpdate],
);
const updateExpandedIds = useCallback(
(next: Set<string>) => {
if (controlledExpandedIds === undefined) setUncontrolledExpandedIds(next);
onExpandedIdsChange?.(Array.from(next));
},
[controlledExpandedIds, onExpandedIdsChange],
);

useEffect(() => {
const currentGroupIds = new Set(groupIds);
const previousGroupIds = knownGroupIdsRef.current;
const next = new Set(Array.from(expandedIds).filter((id) => currentGroupIds.has(id)));
groupIds.forEach((id) => {
if (!previousGroupIds.has(id)) next.add(id);
});
if (!setsEqual(expandedIds, next)) updateExpandedIds(next);
knownGroupIdsRef.current = currentGroupIds;
}, [expandedIds, groupIds, updateExpandedIds]);

const filtered = useMemo(
() => filterQueryStatistics(data, debouncedSearch),
[data, debouncedSearch],
);
const effectiveExpandedIds = useMemo(
() => new Set([...expandedIds, ...filtered.forcedExpandedIds]),
[expandedIds, filtered.forcedExpandedIds],
);
const rows = useMemo(
() => flattenQueryStatistics(filtered.data, effectiveExpandedIds),
[effectiveExpandedIds, filtered.data],
);
const toggleGroup = useCallback(
(id: string) => {
const next = new Set(expandedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
updateExpandedIds(next);
},
[expandedIds, updateExpandedIds],
);

return (
<div className={block(null, className)}>
<QueryStatisticsToolbar
search={search}
hasGroups={Boolean(groupIds.length)}
onSearchUpdate={updateSearch}
onExpandAll={() => updateExpandedIds(new Set(groupIds))}
onCollapseAll={() => updateExpandedIds(new Set())}
/>
<QueryStatisticsTable
rows={rows}
visibleColumns={visibleColumns}
columnConfig={columnConfig}
extraColumns={extraColumns}
expandedIds={effectiveExpandedIds}
fixedHeader={fixedHeader}
virtual={virtual}
formatValue={formatValue}
isSearching={Boolean(search)}
onToggleGroup={toggleGroup}
/>
</div>
);
}
96 changes: 96 additions & 0 deletions src/modules/QueryStatistics/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type {
QueryStatisticsGroup,
QueryStatisticsItem,
QueryStatisticsMetric,
} from '../../types/queryStatistics';

export type QueryStatisticsRow = {item: QueryStatisticsItem; level: number};

export type FilterQueryStatisticsResult = {
data: QueryStatisticsItem[];
forcedExpandedIds: Set<string>;
};

export function isQueryStatisticsGroup(item: QueryStatisticsItem): item is QueryStatisticsGroup {
return Array.isArray(item.children);
}

export function isQueryStatisticsMetric(item: QueryStatisticsItem): item is QueryStatisticsMetric {
return !isQueryStatisticsGroup(item);
}

export function getQueryStatisticsValue(
item: QueryStatisticsMetric,
column: keyof QueryStatisticsMetric['values'],
) {
if (column !== 'avg' || item.values.avg !== undefined) {
return item.values[column];
}

const {count, sum} = item.values;
return count === undefined || count === 0 || sum === undefined ? undefined : sum / count;
}

export function collectQueryStatisticsGroupIds(data: QueryStatisticsItem[]): string[] {
return data.flatMap((item) =>
isQueryStatisticsGroup(item) && item.children.length > 0
? [item.id, ...collectQueryStatisticsGroupIds(item.children)]
: [],
);
}

function addSubtreeGroupIds(item: QueryStatisticsGroup, target: Set<string>) {
if (item.children.length > 0) {
target.add(item.id);
}
item.children.forEach((child) => {
if (isQueryStatisticsGroup(child)) {
addSubtreeGroupIds(child, target);
}
});
}

function matchesSearch(item: QueryStatisticsItem, search: string) {
return [item.name, item.description, item.unit]
.filter(Boolean)
.some((value) => value?.toLocaleLowerCase().includes(search));
}

export function filterQueryStatistics(
data: QueryStatisticsItem[],
search: string,
): FilterQueryStatisticsResult {
const normalizedSearch = search.trim().toLocaleLowerCase();
if (!normalizedSearch) return {data, forcedExpandedIds: new Set()};

const forcedExpandedIds = new Set<string>();
const filterItems = (items: QueryStatisticsItem[]): QueryStatisticsItem[] =>
items.flatMap<QueryStatisticsItem>((item) => {
if (!isQueryStatisticsGroup(item)) {
return matchesSearch(item, normalizedSearch) ? [item] : [];
}
if (matchesSearch(item, normalizedSearch)) {
addSubtreeGroupIds(item, forcedExpandedIds);
return [item];
}
const children = filterItems(item.children);
if (!children.length) return [];
if (item.children.length > 0) forcedExpandedIds.add(item.id);
return [{...item, children}];
});

return {data: filterItems(data), forcedExpandedIds};
}

export function flattenQueryStatistics(
data: QueryStatisticsItem[],
expandedIds: Set<string>,
level = 0,
): QueryStatisticsRow[] {
return data.flatMap((item) => [
{item, level},
...(isQueryStatisticsGroup(item) && expandedIds.has(item.id)
? flattenQueryStatistics(item.children, expandedIds, level + 1)
: []),
]);
}
4 changes: 4 additions & 0 deletions src/modules/QueryStatistics/i18n/dicts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import en from './en.json';
import ru from './ru.json';

export default {en, ru};
16 changes: 16 additions & 0 deletions src/modules/QueryStatistics/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"action_collapse-all": "Collapse all",
"action_collapse-group": "Collapse group",
"action_expand-all": "Expand all",
"action_expand-group": "Expand group",
"field_avg": "Avg",
"field_count": "Count",
"field_description": "Description",
"field_last": "Last",
"field_max": "Max",
"field_metric": "Metric",
"field_min": "Min",
"field_search": "Search",
"field_sum": "Sum",
"field_unit": "Unit"
}
4 changes: 4 additions & 0 deletions src/modules/QueryStatistics/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {addI18Keysets} from '../../../i18n';
import dicts from './dicts';

export default addI18Keysets('qp:query-statistics', dicts);
16 changes: 16 additions & 0 deletions src/modules/QueryStatistics/i18n/ru.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"action_collapse-all": "Свернуть все",
"action_collapse-group": "Свернуть группу",
"action_expand-all": "Развернуть все",
"action_expand-group": "Развернуть группу",
"field_avg": "Среднее",
"field_count": "Количество",
"field_description": "Описание",
"field_last": "Последнее",
"field_max": "Максимум",
"field_metric": "Метрика",
"field_min": "Минимум",
"field_search": "Поиск",
"field_sum": "Сумма",
"field_unit": "Единица измерения"
}
13 changes: 13 additions & 0 deletions src/modules/QueryStatistics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export {QueryStatistics} from './QueryStatistics';
export type {
QueryStatisticsColumn,
QueryStatisticsColumnConfig,
QueryStatisticsExtraColumn,
QueryStatisticsExtraColumnContext,
QueryStatisticsFormatValueContext,
QueryStatisticsGroup,
QueryStatisticsItem,
QueryStatisticsMetric,
QueryStatisticsProps,
QueryStatisticsValues,
} from '../../types/queryStatistics';
12 changes: 12 additions & 0 deletions src/modules/QueryStatistics/internal/MetricCell.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.qp-query-statistics-metric-cell {
&__indent { display: inline-flex; flex-shrink: 0; }
&__indent-step { width: var(--g-spacing-10); flex-shrink: 0; }
&__row-button,
&__row-icon,
&__info-icon { flex-shrink: 0; }
&__name { min-width: 0; white-space: nowrap; }
&__info-icon { margin-inline-start: var(--g-spacing-1); color: var(--g-color-text-secondary); vertical-align: text-bottom; }
&__tooltip { display: grid; gap: var(--g-spacing-2); max-width: 24rem; }
&__tooltip-row { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--g-spacing-2); }
&__tooltip-label { color: var(--g-color-text-secondary); }
}
Loading
Loading