diff --git a/src/helpers/useDebouncedValue.ts b/src/helpers/useDebouncedValue.ts new file mode 100644 index 0000000..d1316e7 --- /dev/null +++ b/src/helpers/useDebouncedValue.ts @@ -0,0 +1,14 @@ +import {useEffect, useState} from 'react'; + +/** Delays a changing value without duplicating timer lifecycle handling in consumers. */ +export function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeoutId = setTimeout(() => setDebouncedValue(value), delay); + + return () => clearTimeout(timeoutId); + }, [delay, value]); + + return debouncedValue; +} diff --git a/src/index.ts b/src/index.ts index f37e5fd..3203425 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,4 @@ export * from './types/tutorial'; export * from './types/navigation'; export * from './types/pathEditor'; export * from './types/errorTree'; +export * from './types/queryStatistics'; diff --git a/src/modules/QueryStatistics/QueryStatistics.scss b/src/modules/QueryStatistics/QueryStatistics.scss new file mode 100644 index 0000000..c6b7f40 --- /dev/null +++ b/src/modules/QueryStatistics/QueryStatistics.scss @@ -0,0 +1,3 @@ +.qp-query-statistics { + min-width: 0; +} diff --git a/src/modules/QueryStatistics/QueryStatistics.tsx b/src/modules/QueryStatistics/QueryStatistics.tsx new file mode 100644 index 0000000..6f1c7b2 --- /dev/null +++ b/src/modules/QueryStatistics/QueryStatistics.tsx @@ -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, right: Set) { + 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) => { + 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 ( +
+ updateExpandedIds(new Set(groupIds))} + onCollapseAll={() => updateExpandedIds(new Set())} + /> + +
+ ); +} diff --git a/src/modules/QueryStatistics/helpers.ts b/src/modules/QueryStatistics/helpers.ts new file mode 100644 index 0000000..04c7444 --- /dev/null +++ b/src/modules/QueryStatistics/helpers.ts @@ -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; +}; + +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) { + 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(); + const filterItems = (items: QueryStatisticsItem[]): QueryStatisticsItem[] => + items.flatMap((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, + level = 0, +): QueryStatisticsRow[] { + return data.flatMap((item) => [ + {item, level}, + ...(isQueryStatisticsGroup(item) && expandedIds.has(item.id) + ? flattenQueryStatistics(item.children, expandedIds, level + 1) + : []), + ]); +} diff --git a/src/modules/QueryStatistics/i18n/dicts.ts b/src/modules/QueryStatistics/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/modules/QueryStatistics/i18n/dicts.ts @@ -0,0 +1,4 @@ +import en from './en.json'; +import ru from './ru.json'; + +export default {en, ru}; diff --git a/src/modules/QueryStatistics/i18n/en.json b/src/modules/QueryStatistics/i18n/en.json new file mode 100644 index 0000000..b9606ea --- /dev/null +++ b/src/modules/QueryStatistics/i18n/en.json @@ -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" +} diff --git a/src/modules/QueryStatistics/i18n/index.ts b/src/modules/QueryStatistics/i18n/index.ts new file mode 100644 index 0000000..72b98b4 --- /dev/null +++ b/src/modules/QueryStatistics/i18n/index.ts @@ -0,0 +1,4 @@ +import {addI18Keysets} from '../../../i18n'; +import dicts from './dicts'; + +export default addI18Keysets('qp:query-statistics', dicts); diff --git a/src/modules/QueryStatistics/i18n/ru.json b/src/modules/QueryStatistics/i18n/ru.json new file mode 100644 index 0000000..b165690 --- /dev/null +++ b/src/modules/QueryStatistics/i18n/ru.json @@ -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": "Единица измерения" +} diff --git a/src/modules/QueryStatistics/index.ts b/src/modules/QueryStatistics/index.ts new file mode 100644 index 0000000..4336712 --- /dev/null +++ b/src/modules/QueryStatistics/index.ts @@ -0,0 +1,13 @@ +export {QueryStatistics} from './QueryStatistics'; +export type { + QueryStatisticsColumn, + QueryStatisticsColumnConfig, + QueryStatisticsExtraColumn, + QueryStatisticsExtraColumnContext, + QueryStatisticsFormatValueContext, + QueryStatisticsGroup, + QueryStatisticsItem, + QueryStatisticsMetric, + QueryStatisticsProps, + QueryStatisticsValues, +} from '../../types/queryStatistics'; diff --git a/src/modules/QueryStatistics/internal/MetricCell.scss b/src/modules/QueryStatistics/internal/MetricCell.scss new file mode 100644 index 0000000..d28f388 --- /dev/null +++ b/src/modules/QueryStatistics/internal/MetricCell.scss @@ -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); } +} diff --git a/src/modules/QueryStatistics/internal/MetricCell.tsx b/src/modules/QueryStatistics/internal/MetricCell.tsx new file mode 100644 index 0000000..b0eecfe --- /dev/null +++ b/src/modules/QueryStatistics/internal/MetricCell.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { + ChartLine, + ChevronDown, + ChevronRight, + CircleQuestion, + FolderArrowRight, +} from '@gravity-ui/icons'; +import {Button, Flex, Icon, Tooltip} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; + +import type {QueryStatisticsItem} from '../../../types/queryStatistics'; +import {type QueryStatisticsRow, isQueryStatisticsGroup} from '../helpers'; +import i18n from '../i18n'; + +import './MetricCell.scss'; + +const block = cn('qp-query-statistics-metric-cell'); + +function MetricInfo({item}: {item: QueryStatisticsItem}) { + const hasInfo = Boolean(item.description || item.unit); + const content = ( + + {item.name} + {hasInfo && } + + ); + + if (!hasInfo) return content; + + return }>{content}; +} + +function MetricTooltip({item}: {item: QueryStatisticsItem}) { + return ( + + {item.description && ( + + {i18n('field_description')} + {item.description} + + )} + {item.unit && ( + + {i18n('field_unit')} + {item.unit} + + )} + + ); +} + +function Indent({level}: {level: number}) { + if (!level) return null; + return ( +