diff --git a/README.md b/README.md index d246040..6644ef0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ npm install @gravity-ui/querieskit Your project must also provide: - `react` / `react-dom` (^18 or ^19) -- `@gravity-ui/uikit` (>=7) +- `@gravity-ui/uikit` (>=7.18.0) - `@gravity-ui/icons` (>=2) See `peerDependencies` in `package.json` for the exact ranges. @@ -295,3 +295,9 @@ Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. ## License MIT — see [LICENSE](LICENSE). + +### Execution timeline + +`QueryTimeline` provides a virtualized execution timeline with stages, filters and live +updates. It can also be passed to `QueryProgress` using `timelineProps`. See the +[QueryTimeline guide](src/modules/QueryTimeline/README.md) for the data contract and examples. diff --git a/package-lock.json b/package-lock.json index 7d2bbcc..0311de4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,18 +12,20 @@ "@gravity-ui/charts": "^1.57.2", "@gravity-ui/dashkit": "^10.4.0", "@gravity-ui/date-components": "^3.4.2", + "@gravity-ui/date-utils": "^2.7.1", "@gravity-ui/graph": "1.12.1", "@gravity-ui/i18n": "^1.8.0", "@gravity-ui/illustrations": "^2.1.1", "@gravity-ui/react-data-table": "^2.2.1", + "@gravity-ui/timeline": "1.32.2", "@gravity-ui/unipika": "^5.6.3", "bem-cn-lite": "^4.1.0", + "lodash": "^4.18.1", "monaco-editor": "^0.56.0" }, "devDependencies": { "@commitlint/config-conventional": "^20.5.3", "@gravity-ui/app-builder": "^0.49.0", - "@gravity-ui/date-utils": "^2.7.1", "@gravity-ui/eslint-config": "^4.3.2", "@gravity-ui/icons": "^2.20.0", "@gravity-ui/prettier-config": "^1.1.1", @@ -51,7 +53,7 @@ }, "peerDependencies": { "@gravity-ui/icons": ">=2.0.0", - "@gravity-ui/uikit": ">=7.0.0", + "@gravity-ui/uikit": ">=7.18.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } @@ -5145,6 +5147,45 @@ "react": ">=16.14.0" } }, + "node_modules/@gravity-ui/timeline": { + "version": "1.32.2", + "resolved": "https://registry.npmjs.org/@gravity-ui/timeline/-/timeline-1.32.2.tgz", + "integrity": "sha512-oQ7YmylmPmI5OEwO+Gc/ISUrc4uo5bOeRZzbp4K6ULuLJzKAMv3If8CC0o0jzQXuJwroI+zxUMOAgNCsr2gS6w==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.13", + "rbush": "^4.0.1" + }, + "peerDependencies": { + "@gravity-ui/uikit": "^7.18.0" + }, + "peerDependenciesMeta": { + "@gravity-ui/uikit": { + "optional": true + } + } + }, + "node_modules/@gravity-ui/timeline/node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/@gravity-ui/timeline/node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/@gravity-ui/timeline/node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, "node_modules/@gravity-ui/tsconfig": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@gravity-ui/tsconfig/-/tsconfig-1.0.0.tgz", diff --git a/package.json b/package.json index 6fad2ba..8ed783d 100644 --- a/package.json +++ b/package.json @@ -96,11 +96,12 @@ "lint:all": "eslint \"./src/**/*.{js,jsx,ts,tsx}\"", "storybook": "storybook dev -p 6006 --no-open", "build-storybook": "storybook build", - "test": "npm run test:timezones && npm run test:components", + "test": "npm run test:timezones && npm run test:components && npm run test:timeline", "test:timezones": "TZ=UTC vitest run tests/helpers/time.test.ts && TZ=Europe/Moscow vitest run tests/helpers/time.test.ts && TZ=Asia/Kolkata vitest run tests/helpers/time.test.ts && TZ=America/New_York vitest run tests/helpers/time.test.ts", "test:components": "vitest run tests/components", "prepublishOnly": "npm run build", - "test:tree-shaking": "npm run build && node tests/tree-shaking/check.mjs" + "test:tree-shaking": "npm run build && node tests/tree-shaking/check.mjs", + "test:timeline": "vitest run tests/helpers/queryTimeline.test.ts" }, "repository": { "type": "git", @@ -114,7 +115,6 @@ "devDependencies": { "@commitlint/config-conventional": "^20.5.3", "@gravity-ui/app-builder": "^0.49.0", - "@gravity-ui/date-utils": "^2.7.1", "@gravity-ui/eslint-config": "^4.3.2", "@gravity-ui/icons": "^2.20.0", "@gravity-ui/prettier-config": "^1.1.1", @@ -126,6 +126,7 @@ "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.64.0", "@typescript-eslint/parser": "^8.64.0", + "esbuild": "^0.28.1", "eslint": "^9.39.5", "eslint-import-resolver-typescript": "^4.4.5", "jsdom": "^26.1.0", @@ -137,12 +138,11 @@ "typescript": "^6.0.3", "vite": "^8.1.5", "vite-plugin-svgr": "^5.2.0", - "vitest": "^3.2.4", - "esbuild": "^0.28.1" + "vitest": "^3.2.4" }, "peerDependencies": { "@gravity-ui/icons": ">=2.0.0", - "@gravity-ui/uikit": ">=7.0.0", + "@gravity-ui/uikit": ">=7.18.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, @@ -150,12 +150,15 @@ "@gravity-ui/charts": "^1.57.2", "@gravity-ui/dashkit": "^10.4.0", "@gravity-ui/date-components": "^3.4.2", + "@gravity-ui/date-utils": "^2.7.1", "@gravity-ui/graph": "1.12.1", "@gravity-ui/i18n": "^1.8.0", "@gravity-ui/illustrations": "^2.1.1", "@gravity-ui/react-data-table": "^2.2.1", + "@gravity-ui/timeline": "1.32.2", "@gravity-ui/unipika": "^5.6.3", "bem-cn-lite": "^4.1.0", + "lodash": "^4.18.1", "monaco-editor": "^0.56.0" }, "overrides": { diff --git a/src/index.ts b/src/index.ts index c0001b8..04f4771 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,3 +11,4 @@ export * from './types/errorTree'; export * from './types/queryStatistics'; export * from './types/queryResults'; export * from './types/queryGraph'; +export * from './types/queryTimeline'; diff --git a/src/modules/QueryProgress/QueryProgress.scss b/src/modules/QueryProgress/QueryProgress.scss index 704cbb9..abc4588 100644 --- a/src/modules/QueryProgress/QueryProgress.scss +++ b/src/modules/QueryProgress/QueryProgress.scss @@ -56,17 +56,6 @@ } &__view_hidden { - visibility: hidden; - position: absolute; - pointer-events: none; - } - - &__timeline { - min-height: 360px; - display: flex; - align-items: center; - justify-content: center; - border: 1px dashed var(--g-color-line-generic); - border-radius: var(--g-border-radius-l); + display: none; } } diff --git a/src/modules/QueryProgress/QueryProgress.stories.tsx b/src/modules/QueryProgress/QueryProgress.stories.tsx index b520ea5..8a66499 100644 --- a/src/modules/QueryProgress/QueryProgress.stories.tsx +++ b/src/modules/QueryProgress/QueryProgress.stories.tsx @@ -2,13 +2,21 @@ import React from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import {demoEdges, demoNodes} from '../../components/QueryGraph/QueryGraph.stories'; +import {timelineItems, timelineStatuses} from '../QueryTimeline/QueryTimeline.stories'; import {QueryProgress} from './QueryProgress'; const meta: Meta = { title: 'Modules/QueryProgress', component: QueryProgress, tags: ['autodocs'], - args: {graphProps: {nodes: demoNodes, edges: demoEdges}}, + args: { + graphProps: {nodes: demoNodes, edges: demoEdges}, + timelineProps: { + items: timelineItems, + statuses: timelineStatuses, + now: Date.UTC(2026, 8, 25, 10, 1, 20), + }, + }, decorators: [ (Story) => (
@@ -22,4 +30,8 @@ export default meta; type Story = StoryObj; export const Default: Story = {}; -export const TimelinePlaceholder: Story = {args: {defaultView: 'timeline'}}; +export const Timeline: Story = {args: {defaultView: 'timeline'}}; + +export const WithoutTimelineData: Story = { + args: {defaultView: 'timeline', timelineProps: undefined}, +}; diff --git a/src/modules/QueryProgress/QueryProgress.tsx b/src/modules/QueryProgress/QueryProgress.tsx index b1c3ce8..df94d0a 100644 --- a/src/modules/QueryProgress/QueryProgress.tsx +++ b/src/modules/QueryProgress/QueryProgress.tsx @@ -1,4 +1,4 @@ -import React, {Suspense, useCallback, useMemo, useState} from 'react'; +import React, {Suspense, useCallback, useEffect, useMemo, useState} from 'react'; import { Circle, CircleCheck, @@ -7,7 +7,7 @@ import { CirclePlay, CircleXmark, } from '@gravity-ui/icons'; -import {Flex, Icon, Loader, SegmentedRadioGroup, Text} from '@gravity-ui/uikit'; +import {Flex, Icon, Loader, SegmentedRadioGroup} from '@gravity-ui/uikit'; import cn from 'bem-cn-lite'; import type { @@ -20,10 +20,14 @@ import i18n from './i18n'; import './QueryProgress.scss'; const QueryGraph = React.lazy(() => - import('../../components/QueryGraph/QueryGraph').then((module) => ({ + import('../../components/QueryGraph').then((module) => ({ default: module.QueryGraph, })), ); +const QueryTimeline = React.lazy(() => + import('../QueryTimeline').then((module) => ({default: module.QueryTimeline})), +); +const EMPTY_TIMELINE_ITEMS: NonNullable['items'] = []; const block = cn('qp-query-progress'); const statuses: QueryGraphNodeStatus[] = [ 'not-started', @@ -44,6 +48,7 @@ const statusIcons = { export function QueryProgress({ graphProps, + timelineProps, view: controlledView, defaultView = 'graph', onViewChange, @@ -52,6 +57,11 @@ export function QueryProgress({ const [uncontrolledView, setUncontrolledView] = useState(defaultView); const view = controlledView ?? uncontrolledView; const [hasOpenedGraph, setHasOpenedGraph] = useState(view === 'graph'); + const [hasOpenedTimeline, setHasOpenedTimeline] = useState(view === 'timeline'); + useEffect(() => { + if (view === 'graph') setHasOpenedGraph(true); + if (view === 'timeline') setHasOpenedTimeline(true); + }, [view]); const counts = useMemo(() => { const result = Object.fromEntries(statuses.map((status) => [status, 0])) as Record< QueryGraphNodeStatus, @@ -84,26 +94,40 @@ export function QueryProgress({ {i18n('tab_timeline')} - - {statuses.map((status) => ( - - - {i18n(`status_${status}`)}: {counts[status]} - - ))} - + {view === 'graph' && ( + + {statuses.map((status) => ( + + + {i18n(`status_${status}`)}: {counts[status]} + + ))} + + )}
- {hasOpenedGraph && ( -
+ {(hasOpenedGraph || view === 'graph') && ( + )} - {view === 'timeline' && ( -
- {i18n('context_timeline-unavailable')} + {(hasOpenedTimeline || view === 'timeline') && ( + )}
diff --git a/src/modules/QueryProgress/i18n/en.json b/src/modules/QueryProgress/i18n/en.json index 49234cb..07e8c89 100644 --- a/src/modules/QueryProgress/i18n/en.json +++ b/src/modules/QueryProgress/i18n/en.json @@ -1,7 +1,6 @@ { "tab_graph": "Graph", "tab_timeline": "Timeline", - "context_timeline-unavailable": "Timeline is not available yet", "status_not-started": "Not started", "status_waiting": "Waiting", "status_running": "Running", diff --git a/src/modules/QueryProgress/i18n/ru.json b/src/modules/QueryProgress/i18n/ru.json index 41eedc2..33fcf66 100644 --- a/src/modules/QueryProgress/i18n/ru.json +++ b/src/modules/QueryProgress/i18n/ru.json @@ -1,7 +1,6 @@ { "tab_graph": "Граф", "tab_timeline": "Таймлайн", - "context_timeline-unavailable": "Таймлайн пока недоступен", "status_not-started": "Не запущено", "status_waiting": "Ожидание", "status_running": "Выполняется", diff --git a/src/modules/QueryTimeline/QueryTimeline.scss b/src/modules/QueryTimeline/QueryTimeline.scss new file mode 100644 index 0000000..d7ece4d --- /dev/null +++ b/src/modules/QueryTimeline/QueryTimeline.scss @@ -0,0 +1,51 @@ +.qp-query-timeline { + height: 100%; + min-height: 160px; + min-width: 0; + + &__header { + display: grid; + grid-template-columns: var(--qp-timeline-list-width) minmax(0, 1fr); + flex-shrink: 0; + } + &__heading { + padding: var(--g-spacing-2); + align-self: center; + } + &__ruler { + min-width: 0; + overflow: hidden; + } + &__scroll { + flex: 1; + min-height: 0; + overflow: auto; + position: relative; + } + &__spacer { + position: relative; + min-width: calc(var(--qp-timeline-list-width) + 160px); + } + &__rows { + position: absolute; + top: 0; + bottom: 0; + width: var(--qp-timeline-list-width); + } + &__row { + position: absolute; + width: 100%; + } + &__canvas { + position: sticky; + top: 0; + margin-left: var(--qp-timeline-list-width); + border-left: 1px solid var(--g-color-line-generic); + box-sizing: border-box; + } + &__state { + flex: 1; + height: 100%; + min-height: 100px; + } +} diff --git a/src/modules/QueryTimeline/QueryTimeline.stories.tsx b/src/modules/QueryTimeline/QueryTimeline.stories.tsx new file mode 100644 index 0000000..877fc55 --- /dev/null +++ b/src/modules/QueryTimeline/QueryTimeline.stories.tsx @@ -0,0 +1,142 @@ +import React, {useMemo, useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Button, Flex, ThemeProvider} from '@gravity-ui/uikit'; +import type { + QueryTimelineItem, + QueryTimelineRange, + QueryTimelineStatus, +} from '../../types/queryTimeline'; +import {QueryTimeline} from './QueryTimeline'; + +const start = Date.UTC(2026, 8, 25, 10); +export const timelineStatuses: QueryTimelineStatus[] = [ + {id: 'queued', label: 'Queued', color: 'var(--g-color-base-neutral-heavy)'}, + {id: 'executing', label: 'Executing', color: 'var(--g-color-base-info-heavy)'}, + {id: 'done', label: 'Done', color: 'var(--g-color-base-positive-heavy)'}, + {id: 'error', label: 'Error', color: 'var(--g-color-base-danger-heavy)'}, +]; +export const timelineItems: QueryTimelineItem[] = [ + {id: 'prepare', label: 'Prepare', status: 'done', interval: {start, end: start + 8000}}, + { + id: 'fetch', + label: 'Fetch source', + status: 'done', + href: 'https://gravity-ui.com', + interval: {start: start + 8000, end: start + 50000}, + stages: [ + {id: 'connect', label: 'Connect', interval: {start: start + 8000, end: start + 15000}}, + {id: 'read', label: 'Read', interval: {start: start + 18000, end: start + 45000}}, + {id: 'decode', label: 'Decode', interval: {start: start + 40000, end: start + 50000}}, + ], + }, + { + id: 'aggregate', + label: 'Aggregate', + status: 'executing', + progress: {completed: 72, total: 100}, + interval: {start: start + 50000}, + stages: [{id: 'reduce', label: 'Reduce', interval: {start: start + 52000}}], + }, + {id: 'publish', label: 'Publish', status: 'queued'}, +]; + +const meta: Meta = { + title: 'Modules/QueryTimeline', + component: QueryTimeline, + tags: ['autodocs'], + args: {items: timelineItems, statuses: timelineStatuses, now: start + 80000, timeZone: 'UTC'}, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const CustomContent: Story = { + args: { + renderItemLabel: ({item, stage, defaultContent}) => ( + + {defaultContent} {!stage && '↗'} + + ), + renderEventPopup: ({item, defaultContent}) => ( + + {defaultContent} + ID: {item.id} + + ), + }, +}; +export const Live: Story = { + args: {now: undefined}, + render: function Render(args) { + const items = useMemo( + () => [ + { + id: 'live', + label: 'Live task', + status: 'executing', + interval: {start: Date.now() - 10000}, + }, + ], + [], + ); + return ; + }, +}; +export const ControlledRange: Story = { + render: function Render(args) { + const [range, setRange] = useState({from: start, to: start + 100000}); + return ( + + + + + ); + }, +}; +export const Loading: Story = {args: {loading: true}}; +export const Error: Story = {args: {errorContent: 'Could not load execution data'}}; +export const Empty: Story = {args: {items: []}}; +export const NoIntervals: Story = { + args: {items: [{id: 'pending', label: 'Waiting for execution', status: 'queued'}]}, +}; +export const InvalidData: Story = { + args: {items: [{id: 'bad', label: 'Invalid interval', interval: {start: 100, end: 0}}]}, +}; +export const Dark: Story = { + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; +export const FiveThousandRows: Story = { + args: { + items: Array.from({length: 1000}, (_, index) => ({ + id: `task-${index}`, + label: `Task ${index}`, + status: 'done', + interval: {start: start + index * 100, end: start + index * 100 + 20000}, + stages: Array.from({length: 4}, (_stage, stage) => ({ + id: `stage-${stage}`, + label: `Stage ${stage}`, + interval: { + start: start + index * 100 + stage * 5000, + end: start + index * 100 + (stage + 1) * 5000, + }, + })), + })), + }, +}; diff --git a/src/modules/QueryTimeline/QueryTimeline.tsx b/src/modules/QueryTimeline/QueryTimeline.tsx new file mode 100644 index 0000000..7090893 --- /dev/null +++ b/src/modules/QueryTimeline/QueryTimeline.tsx @@ -0,0 +1,353 @@ +import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {Flex, Text} from '@gravity-ui/uikit'; +import cn from 'bem-cn-lite'; +import type { + QueryTimelineProps, + QueryTimelineRange, + QueryTimelineStatus, +} from '../../types/queryTimeline'; +import { + createRows, + extent, + filterItems, + formatTimelineDuration, + getWindow, + hasOpenIntervals, + normalizeRange, + preserveScroll, + resolveInterval, + sameRange, + statusOptions, + validateTimeline, +} from './helpers/model'; +import {TimelinePlot} from './internal/TimelinePlot'; +import {TimelineItemRow} from './internal/TimelineItemRow'; +import {TimelineRangeSelector} from './internal/TimelineRangeSelector'; +import {TimelineState} from './internal/TimelineState'; +import {TimelineToolbar} from './internal/TimelineToolbar'; +import i18n from './i18n'; +import './QueryTimeline.scss'; + +const block = cn('qp-query-timeline'); +const emptyStatuses: readonly QueryTimelineStatus[] = []; + +export function QueryTimeline({ + items, + statuses = emptyStatuses, + range: controlledRange, + defaultRange, + onRangeChange, + bounds, + now: externalNow, + loading, + errorContent, + emptyContent, + onError, + className, + rowHeight = 32, + listWidth = 360, + active = true, + renderItemLabel, + renderItemStatus, + renderEventPopup, + formatDuration = formatTimelineDuration, + timeZone, + onItemClick, + onEventClick, + showSearch, + showStatusFilter, + showRangeSelector = true, +}: QueryTimelineProps) { + const [clock, setClock] = useState(Date.now); + const now = externalNow ?? clock; + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(); + const [expanded, setExpanded] = useState>(() => new Set()); + const [savedRange, setSavedRange] = useState(); + const [selectedIds, setSelectedIds] = useState([]); + const [scrollTop, setScrollTop] = useState(0); + const [height, setHeight] = useState(0); + const [scrollbarWidth, setScrollbarWidth] = useState(0); + const scrollRef = useRef(null); + const scrollFrame = useRef(undefined); + + const error = useMemo(() => { + try { + validateTimeline( + items, + statuses, + [controlledRange, defaultRange, bounds], + externalNow ?? Date.now(), + rowHeight, + listWidth, + ); + if (timeZone && !['default', 'system'].includes(timeZone)) { + new Intl.DateTimeFormat('en', {timeZone}).format(0); + } + return undefined; + } catch (cause) { + return cause instanceof Error ? cause : new Error(String(cause)); + } + }, [ + items, + statuses, + controlledRange, + defaultRange, + bounds, + externalNow, + rowHeight, + listWidth, + timeZone, + ]); + useEffect(() => { + if (error) onError?.(error); + }, [error, onError]); + const open = useMemo(() => !error && hasOpenIntervals(items), [error, items]); + useEffect(() => { + if (!active || externalNow !== undefined || !open || loading || errorContent) + return undefined; + setClock(Date.now()); + const timer = setInterval(() => setClock(Date.now()), 1000); + return () => clearInterval(timer); + }, [active, externalNow, open, loading, errorContent]); + + const fullRange = useMemo(() => { + if (error) return undefined; + const value = + bounds ?? + extent( + items.flatMap((item) => [ + item.interval ? resolveInterval(item.interval, now) : undefined, + ...(item.stages ?? []).map((stage) => resolveInterval(stage.interval, now)), + ]), + ); + return value ? normalizeRange(value) : undefined; + }, [bounds, error, items, now]); + const range = useMemo(() => { + if (error) return undefined; + const value = controlledRange ?? savedRange ?? defaultRange ?? fullRange; + return value ? normalizeRange(value) : undefined; + }, [error, controlledRange, savedRange, defaultRange, fullRange]); + useEffect(() => { + if (range && !savedRange && controlledRange === undefined) setSavedRange(range); + }, [range, savedRange, controlledRange]); + const lastNotified = useRef(undefined); + useEffect(() => { + lastNotified.current = undefined; + }, [range]); + const updateRange = useCallback( + (next: QueryTimelineRange) => { + if (!Number.isFinite(next.from) || !Number.isFinite(next.to) || next.to < next.from) + return; + const normalized = normalizeRange(next); + if (sameRange(range, normalized) || sameRange(lastNotified.current, normalized)) return; + lastNotified.current = normalized; + if (controlledRange === undefined) setSavedRange(normalized); + onRangeChange?.(normalized); + }, + [controlledRange, onRangeChange, range], + ); + + const filtered = useMemo( + () => (error ? [] : filterItems(items, search, statusFilter)), + [error, items, search, statusFilter], + ); + const rows = useMemo(() => createRows(filtered, expanded, now), [filtered, expanded, now]); + const statusMap = useMemo( + () => new Map(statuses.map((status) => [status.id, status])), + [statuses], + ); + const options = useMemo(() => statusOptions(items, statuses), [items, statuses]); + const expandable = useMemo( + () => filtered.filter((item) => item.stages?.length).map((item) => item.id), + [filtered], + ); + const allExpanded = expandable.length > 0 && expandable.every((id) => expanded.has(id)); + const toggle = (id: string) => + setExpanded((previous) => { + const next = new Set(previous); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const toggleAll = () => + setExpanded((previous) => { + const next = new Set(previous); + for (const id of expandable) { + if (allExpanded) next.delete(id); + else next.add(id); + } + return next; + }); + + const previousLayout = useRef({rows, rowHeight, search, statusFilter}); + useLayoutEffect(() => { + const previous = previousLayout.current; + const node = scrollRef.current; + if (!node) return; + const reset = previous.search !== search || previous.statusFilter !== statusFilter; + const next = reset + ? 0 + : preserveScroll(previous.rows, rows, scrollTop, height, previous.rowHeight, rowHeight); + previousLayout.current = {rows, rowHeight, search, statusFilter}; + node.scrollTop = next; + if (next !== scrollTop) setScrollTop(next); + }, [rows, rowHeight, search, statusFilter, height, scrollTop, active, loading]); + const showBody = !loading && !error && !errorContent && rows.length > 0; + useLayoutEffect(() => { + const node = scrollRef.current; + if (!node || !active || !showBody) return undefined; + const measure = () => { + setHeight(node.clientHeight); + setScrollbarWidth(node.offsetWidth - node.clientWidth); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, [active, showBody]); + useEffect( + () => () => { + if (scrollFrame.current !== undefined) cancelAnimationFrame(scrollFrame.current); + }, + [], + ); + const window = getWindow(rows.length, scrollTop, height, rowHeight); + const visibleRows = useMemo( + () => rows.slice(window.start, window.end), + [rows, window.start, window.end], + ); + const hasIntervals = rows.some((row) => row.interval); + + return ( + + 0} + allExpanded={allExpanded} + onToggleAll={toggleAll} + showSearch={showSearch} + showStatusFilter={showStatusFilter} + showRangeSelector={showRangeSelector} + /> + {showBody ? ( + +
+ + {i18n('field_status')} + +
+ {showRangeSelector && range && active && hasIntervals && ( + + )} +
+
+
{ + if (scrollFrame.current !== undefined) + cancelAnimationFrame(scrollFrame.current); + scrollFrame.current = requestAnimationFrame(() => { + scrollFrame.current = undefined; + setScrollTop(scrollRef.current?.scrollTop ?? 0); + }); + }} + > +
+
+ {visibleRows.map((row, index) => ( +
+ toggle(row.item.id)} + onFit={() => { + if (row.interval) + updateRange({ + from: row.interval.from - 500, + to: row.interval.to + 500, + }); + }} + renderItemLabel={renderItemLabel} + renderItemStatus={renderItemStatus} + onItemClick={onItemClick} + onEventClick={onEventClick} + formatDuration={formatDuration} + timeZone={timeZone} + /> +
+ ))} +
+
+ +
+
+
+
+ ) : ( + + + + )} +
+ ); +} diff --git a/src/modules/QueryTimeline/README.md b/src/modules/QueryTimeline/README.md new file mode 100644 index 0000000..2abc329 --- /dev/null +++ b/src/modules/QueryTimeline/README.md @@ -0,0 +1,105 @@ +# QueryTimeline + +A backend-independent execution timeline with a virtualized list, expandable stages, +status and name filters, live intervals, and a synchronized time range. + +```tsx +import {QueryTimeline} from '@gravity-ui/querieskit'; +import type {QueryTimelineItem} from '@gravity-ui/querieskit'; + +const items: QueryTimelineItem[] = [ + { + id: 'load', + label: 'Load source', + status: 'fetching', + interval: {start: Date.now() - 10_000}, + progress: {completed: 3, total: 5}, + stages: [ + { + id: 'connect', + label: 'Connect', + interval: {start: Date.now() - 10_000, end: Date.now() - 8_000}, + }, + ], + }, +]; + +
+ +
; +``` + +Use UIKit's `ThemeProvider` and global styles as described in the package README. +The parent must provide a bounded height. `rowHeight` defaults to 32 px (minimum 24); +`listWidth` defaults to 360 px (minimum 160). Row labels are single-line and truncated. + +## Data and customization + +- Timestamps are Unix milliseconds, including zero. An omitted `end` means the interval + is ongoing, regardless of its status. Without an interval, the parent extent is derived + from its stages; without either, the item remains in the list without a bar. +- Item IDs must be unique; stage IDs must be unique within their item. Input order is + preserved. Invalid timestamps, reversed intervals and duplicate IDs produce a localized + error state and report technical details through `onError`. +- Status IDs are arbitrary strings. `statuses` supplies labels, optional icons and colors. + Unknown statuses use their ID and a neutral color. Missing statuses display a dash. +- Stage colors accept ordinary CSS colors or whole-value CSS variables. Without a stage + color, tones of the parent status color distinguish stages. Gaps and overlaps retain + their actual time coordinates; overlapping segments follow array drawing order. +- `progress.fraction` takes precedence over `completed / total`; values are clamped to 0–1. +- `renderItemLabel`, `renderItemStatus` and `renderEventPopup` receive + `{item, stage?, defaultContent}`. `data` remains opaque and is returned unchanged. +- `href` makes the parent label a link. `onItemClick` receives the original item and React + mouse event; `onEventClick` receives `{item, stage?}`. The default actionable labels also + expose event actions to keyboard users. +- `formatDuration(milliseconds)` customizes duration strings; `timeZone` changes date + formatting without changing timestamps. The default duration format is `HH:mm:ss`, + or milliseconds for subsecond intervals. + +## Range, live updates and state + +The initial range is `range`, then `defaultRange`, then `bounds`, then the data extent. +`range` with `onRangeChange` supports controlled use. Without `range`, the module stores +the viewport internally. All ranges use `{from, to}` in milliseconds. The supported viewport +duration is 1 second to 15 years, with millisecond alignment; short ranges are centered and +expanded to one second. + +“Fit all” uses `bounds` or the entire data extent, even when rows are filtered. “Fit interval” +adds 500 ms on each side. Filtering and incoming data do not reset the viewport. The toolbar +owns zoom buttons so the date ruler and canvas retain exactly the same horizontal scale. + +When `now` is omitted, open intervals update every second. Supply `now` for external clocks +or deterministic snapshots. `active={false}` suspends the timer and destroys the canvas, +while preserving the filters, expanded IDs, selection, viewport and scroll position. +To reset the complete state for a different execution, change the React `key`. + +Name search matches parent labels. Status counts describe all parent items; stages do not +increment them. Expand/collapse all affects only filtered items. Both the DOM and canvas +use a shared virtual window with ten extra rows on either side. + +`showSearch`, `showStatusFilter` and `showRangeSelector` default to `true`. +`loading`, `errorContent` and `emptyContent` customize loading/error/empty scenarios. + +## QueryProgress integration + +Pass `timelineProps` alongside the existing `graphProps` to `QueryProgress`. The two views +retain independent state when switching tabs. Omitting `timelineProps` shows an empty +timeline, preserving compatibility with graph-only consumers. The application supplies +the timeline model independently of the graph; no backend adapter is included. + +## Compatibility + +This module uses `@gravity-ui/timeline` 1.32.2 and requires UIKit 7.18.0 or newer within the +Timeline package's supported peer range. QueriesKit's minimum UIKit version is now 7.18.0. +`@gravity-ui/date-utils` is a runtime dependency. `lodash` is declared explicitly because +Timeline 1.32.2 imports it but does not list it among its runtime dependencies. diff --git a/src/modules/QueryTimeline/helpers/model.ts b/src/modules/QueryTimeline/helpers/model.ts new file mode 100644 index 0000000..77f56fe --- /dev/null +++ b/src/modules/QueryTimeline/helpers/model.ts @@ -0,0 +1,205 @@ +import type { + QueryTimelineInterval, + QueryTimelineItem, + QueryTimelineRange, + QueryTimelineStage, + QueryTimelineStatus, +} from '../../../types/queryTimeline'; + +import i18n from '../i18n'; + +export const MIN_RANGE = 1000; +export const MAX_RANGE = 15 * 365 * 24 * 60 * 60 * 1000; +export const DEFAULT_COLOR = 'var(--g-color-base-neutral-heavy)'; + +export type TimelineRow = { + id: string; + item: QueryTimelineItem; + stage?: QueryTimelineStage; + interval?: QueryTimelineRange; + open: boolean; +}; + +export function validateTimeline( + items: readonly QueryTimelineItem[], + statuses: readonly QueryTimelineStatus[], + ranges: (QueryTimelineRange | undefined)[], + now: number, + rowHeight: number, + listWidth: number, +) { + const unique = (ids: string[], context: string) => { + if (new Set(ids).size !== ids.length) throw new Error(`Duplicate ${context} IDs`); + }; + const interval = (value: QueryTimelineInterval, context: string) => { + if ( + !Number.isFinite(value.start) || + Math.abs(value.start) > 8.64e15 - MAX_RANGE || + (value.end !== undefined && + (!Number.isFinite(value.end) || + Math.abs(value.end) > 8.64e15 - MAX_RANGE || + value.end < value.start)) + ) { + throw new Error(`Invalid interval: ${context}`); + } + }; + unique( + items.map((item) => item.id), + 'item', + ); + unique( + statuses.map((status) => status.id), + 'status', + ); + for (const item of items) { + if (item.interval) interval(item.interval, item.id); + unique( + (item.stages ?? []).map((stage) => stage.id), + `stage in ${item.id}`, + ); + for (const stage of item.stages ?? []) interval(stage.interval, `${item.id}/${stage.id}`); + } + for (const range of ranges) { + if (range) interval({start: range.from, end: range.to}, 'range'); + } + if (!Number.isFinite(now) || Math.abs(now) > 8.64e15 - MAX_RANGE) + throw new Error('Invalid current time'); + if (!Number.isFinite(rowHeight) || rowHeight < 24) throw new Error('rowHeight must be >= 24'); + if (!Number.isFinite(listWidth) || listWidth < 160) throw new Error('listWidth must be >= 160'); +} + +export function resolveInterval(interval: QueryTimelineInterval, now: number): QueryTimelineRange { + return {from: interval.start, to: interval.end ?? Math.max(interval.start, now)}; +} + +export function itemInterval(item: QueryTimelineItem, now: number): QueryTimelineRange | undefined { + if (item.interval) return resolveInterval(item.interval, now); + return extent((item.stages ?? []).map((stage) => resolveInterval(stage.interval, now))); +} + +export function extent(ranges: (QueryTimelineRange | undefined)[]): QueryTimelineRange | undefined { + let from = Infinity; + let to = -Infinity; + for (const range of ranges) { + if (range) { + from = Math.min(from, range.from); + to = Math.max(to, range.to); + } + } + return Number.isFinite(from) && Number.isFinite(to) ? {from, to} : undefined; +} + +export function normalizeRange(range: QueryTimelineRange): QueryTimelineRange { + const duration = Math.min(MAX_RANGE, Math.max(MIN_RANGE, range.to - range.from)); + const middle = range.from + (range.to - range.from) / 2; + return {from: Math.round(middle - duration / 2), to: Math.round(middle + duration / 2)}; +} + +export function sameRange(a?: QueryTimelineRange, b?: QueryTimelineRange) { + return a?.from === b?.from && a?.to === b?.to; +} + +export function hasOpenIntervals(items: readonly QueryTimelineItem[]) { + return items.some( + (item) => + (item.interval && item.interval.end === undefined) || + item.stages?.some((stage) => stage.interval.end === undefined), + ); +} + +export function filterItems(items: readonly QueryTimelineItem[], search: string, status?: string) { + const query = search.trim().toLowerCase(); + return items.filter( + (item) => + (!query || item.label.toLowerCase().includes(query)) && + (status === undefined || item.status === status), + ); +} + +export function statusOptions( + items: readonly QueryTimelineItem[], + statuses: readonly QueryTimelineStatus[], +) { + const counts = new Map(); + for (const item of items) { + if (item.status !== undefined) counts.set(item.status, (counts.get(item.status) ?? 0) + 1); + } + const labels = new Map(statuses.map((status) => [status.id, status.label])); + for (const id of counts.keys()) if (!labels.has(id)) labels.set(id, id); + return Array.from(labels, ([value, label]) => ({ + value, + content: `${label} (${counts.get(value) ?? 0})`, + })); +} + +export function createRows( + items: readonly QueryTimelineItem[], + expanded: ReadonlySet, + now: number, +): TimelineRow[] { + return items.flatMap((item) => { + const rows: TimelineRow[] = [ + { + id: JSON.stringify([item.id]), + item, + interval: itemInterval(item, now), + open: Boolean( + item.interval ? item.interval.end === undefined : hasOpenIntervals([item]), + ), + }, + ]; + if (expanded.has(item.id)) { + for (const stage of item.stages ?? []) { + rows.push({ + id: JSON.stringify([item.id, stage.id]), + item, + stage, + interval: resolveInterval(stage.interval, now), + open: stage.interval.end === undefined, + }); + } + } + return rows; + }); +} + +export function getWindow(count: number, scrollTop: number, height: number, rowHeight: number) { + return { + start: Math.max(0, Math.floor(scrollTop / rowHeight) - 10), + end: Math.min(count, Math.ceil((scrollTop + height) / rowHeight) + 10), + }; +} + +export function preserveScroll( + previous: readonly TimelineRow[], + next: readonly TimelineRow[], + scrollTop: number, + height: number, + previousRowHeight: number, + rowHeight: number, +) { + const anchor = previous[Math.floor(scrollTop / previousRowHeight)]; + const index = anchor ? next.findIndex((row) => row.id === anchor.id) : -1; + const desired = index < 0 ? scrollTop : index * rowHeight + (scrollTop % previousRowHeight); + return Math.max(0, Math.min(desired, next.length * rowHeight - height)); +} + +export function progressFraction(progress: QueryTimelineItem['progress']) { + if (!progress) return undefined; + const fraction = + progress.fraction ?? + (progress.total && progress.total > 0 && progress.completed !== undefined + ? progress.completed / progress.total + : undefined); + return fraction !== undefined && Number.isFinite(fraction) + ? Math.min(1, Math.max(0, fraction)) + : undefined; +} + +export function formatTimelineDuration(milliseconds: number) { + if (milliseconds < 1000) return i18n('value_milliseconds', {count: Math.round(milliseconds)}); + const seconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor(seconds / 60) % 60; + return [hours, minutes, seconds % 60].map((value) => String(value).padStart(2, '0')).join(':'); +} diff --git a/src/modules/QueryTimeline/i18n/dicts.ts b/src/modules/QueryTimeline/i18n/dicts.ts new file mode 100644 index 0000000..4fa3a86 --- /dev/null +++ b/src/modules/QueryTimeline/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/QueryTimeline/i18n/en.json b/src/modules/QueryTimeline/i18n/en.json new file mode 100644 index 0000000..4749cfb --- /dev/null +++ b/src/modules/QueryTimeline/i18n/en.json @@ -0,0 +1,22 @@ +{ + "field_search": "Filter by name", + "field_status": "Status", + "field_start": "Start", + "field_end": "End", + "field_duration": "Duration", + "title_timeline": "Execution timeline", + "action_fit-all": "Fit all", + "action_fit-item": "Fit interval", + "action_expand-all": "Expand all", + "action_collapse-all": "Collapse all", + "action_expand": "Expand stages", + "action_collapse": "Collapse stages", + "context_empty": "No timeline data", + "context_no-results": "No matching items", + "context_no-intervals": "No time intervals", + "alert_invalid-data": "Unable to display the timeline. Check the input data.", + "value_running": "In progress", + "action_zoom-in": "Zoom in", + "action_zoom-out": "Zoom out", + "value_milliseconds": "{{count}} ms" +} diff --git a/src/modules/QueryTimeline/i18n/index.ts b/src/modules/QueryTimeline/i18n/index.ts new file mode 100644 index 0000000..6744f24 --- /dev/null +++ b/src/modules/QueryTimeline/i18n/index.ts @@ -0,0 +1,5 @@ +import {addI18Keysets} from '../../../i18n'; +import dicts from './dicts'; + +// Labels and actions shared by the timeline's internal views. +export default addI18Keysets('qp:query-timeline', dicts); diff --git a/src/modules/QueryTimeline/i18n/ru.json b/src/modules/QueryTimeline/i18n/ru.json new file mode 100644 index 0000000..4d4561f --- /dev/null +++ b/src/modules/QueryTimeline/i18n/ru.json @@ -0,0 +1,22 @@ +{ + "field_search": "Поиск по названию", + "field_status": "Статус", + "field_start": "Начало", + "field_end": "Окончание", + "field_duration": "Длительность", + "title_timeline": "Временная диаграмма выполнения", + "action_fit-all": "Показать весь интервал", + "action_fit-item": "Показать интервал", + "action_expand-all": "Раскрыть всё", + "action_collapse-all": "Свернуть всё", + "action_expand": "Раскрыть стадии", + "action_collapse": "Свернуть стадии", + "context_empty": "Нет данных временной шкалы", + "context_no-results": "По фильтру ничего не найдено", + "context_no-intervals": "Нет временных интервалов", + "alert_invalid-data": "Не удалось отобразить временную шкалу. Проверьте входные данные.", + "value_running": "Выполняется", + "action_zoom-in": "Приблизить", + "action_zoom-out": "Отдалить", + "value_milliseconds": "{{count}} мс" +} diff --git a/src/modules/QueryTimeline/index.ts b/src/modules/QueryTimeline/index.ts new file mode 100644 index 0000000..cef72e6 --- /dev/null +++ b/src/modules/QueryTimeline/index.ts @@ -0,0 +1,11 @@ +export {QueryTimeline} from './QueryTimeline'; +export type { + QueryTimelineRange, + QueryTimelineInterval, + QueryTimelineStage, + QueryTimelineItem, + QueryTimelineStatus, + QueryTimelineEventContext, + QueryTimelineRenderContext, + QueryTimelineProps, +} from '../../types/queryTimeline'; diff --git a/src/modules/QueryTimeline/internal/TimelineCanvasView.scss b/src/modules/QueryTimeline/internal/TimelineCanvasView.scss new file mode 100644 index 0000000..8b5ee8f --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineCanvasView.scss @@ -0,0 +1,6 @@ +.qp-timeline-canvas-view { + width: 100%; + height: 100%; + min-width: 0; + overflow: hidden; +} diff --git a/src/modules/QueryTimeline/internal/TimelineCanvasView.tsx b/src/modules/QueryTimeline/internal/TimelineCanvasView.tsx new file mode 100644 index 0000000..c82a3e3 --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineCanvasView.tsx @@ -0,0 +1,198 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {ComponentType, TimelineState} from '@gravity-ui/timeline'; +import type {TimeLineConfig, TimelineMarker, TimelineSection} from '@gravity-ui/timeline'; +import {useTimeline, useTimelineEvent} from '@gravity-ui/timeline/react'; +import {EventPopup, GravityTimelineCanvas} from '@gravity-ui/timeline/react/uikit'; +import cn from 'bem-cn-lite'; +import type { + QueryTimelineProps, + QueryTimelineRange, + QueryTimelineStatus, +} from '../../../types/queryTimeline'; +import { + DEFAULT_COLOR, + MAX_RANGE, + MIN_RANGE, + type TimelineRow, + resolveInterval, + sameRange, +} from '../helpers/model'; +import {type QueryTimelineCanvasEvent, TimelineEventRenderer} from './TimelineEventRenderer'; +import {TimelineDetails} from './TimelineDetails'; +import i18n from '../i18n'; +import './TimelineCanvasView.scss'; + +const block = cn('qp-timeline-canvas-view'); +const renderer = new TimelineEventRenderer(); + +type Props = Pick & { + rows: TimelineRow[]; + offset: number; + rowHeight: number; + scrollTop: number; + range: QueryTimelineRange; + controlled: boolean; + onRangeChange: (range: QueryTimelineRange) => void; + statuses: ReadonlyMap; + formatDuration: (milliseconds: number) => string; + now: number; + selectedIds: string[]; + onSelectionChange: (ids: string[]) => void; +}; + +export function TimelineCanvasView(props: Props) { + const rootRef = useRef(null); + const latest = useRef(props); + latest.current = props; + const syncing = useRef(false); + const [config] = useState< + TimeLineConfig + >(() => ({ + settings: {start: props.range.from, end: props.range.to, axes: [], events: []}, + viewConfiguration: { + hideRuler: true, + font: 'var(--g-text-body-1-font)', + axes: {linePosition: 'between', color: {line: 'var(--g-color-line-generic)'}}, + grid: { + color: { + primaryMarkColor: 'var(--g-color-line-generic)', + secondaryMarkColor: 'var(--g-color-line-generic)', + boundaryMarkColor: 'var(--g-color-line-generic)', + }, + }, + camera: { + minRange: MIN_RANGE, + maxRange: MAX_RANGE, + interactions: { + verticalWheel: 'pass-through', + horizontalWheel: 'pan', + pinch: 'zoom', + }, + }, + }, + })); + const {timeline} = useTimeline(config); + + // Keep the engine stable; only publish data after its child canvas has initialized. + const synchronize = () => { + if (timeline.state !== TimelineState.READY) return; + const current = latest.current; + syncing.current = true; + try { + // Empty old events before replacing axes: old rows may no longer exist. + timeline.api.setEvents([]); + timeline.api.setAxes( + current.rows.map((row, index) => ({ + id: row.id, + tracksCount: 1, + top: (current.offset + index) * current.rowHeight, + height: current.rowHeight, + })), + ); + const interval = timeline.api.getInterval(); + if (interval.start !== current.range.from || interval.end !== current.range.to) { + timeline.api.setRange(current.range.from, current.range.to); + } + const minDuration = + ((current.range.to - current.range.from) * 4) / Math.max(1, timeline.api.width); + const events: QueryTimelineCanvasEvent[] = current.rows.flatMap((row) => { + if (!row.interval) return []; + const baseColor = + current.statuses.get(row.item.status ?? '')?.color ?? DEFAULT_COLOR; + const stageIndex = row.stage ? (row.item.stages ?? []).indexOf(row.stage) : 0; + const opacity = (index: number) => 1 - (index % 5) * 0.13; + // The engine indexes timestamps, not renderer hitboxes. Extend the indexed + // end to the visible minimum width. This also avoids its `to || end` at 0. + const end = Math.max(row.interval.to, row.interval.from + minDuration); + return [ + { + id: row.id, + axisId: row.id, + trackIndex: 0, + from: row.interval.from, + to: end === 0 ? Number.EPSILON : end, + row, + color: row.stage?.color ?? baseColor, + opacity: row.stage?.color ? 1 : opacity(stageIndex), + duration: current.formatDuration(row.interval.to - row.interval.from), + cursor: current.onEventClick ? 'pointer' : undefined, + segments: row.stage + ? [] + : (row.item.stages ?? []).map((stage, index) => ({ + ...resolveInterval(stage.interval, current.now), + color: stage.color ?? baseColor, + opacity: stage.color ? 1 : opacity(index), + })), + renderer, + }, + ]; + }); + timeline.api.setEvents(events, current.selectedIds); + timeline.api.setCanvasScrollTop(current.scrollTop); + } finally { + syncing.current = false; + } + }; + const synchronizeRef = useRef(synchronize); + synchronizeRef.current = synchronize; + useTimelineEvent(timeline, 'on-ready', () => synchronizeRef.current()); + useEffect(() => { + synchronizeRef.current(); + }, [props, timeline]); + useEffect(() => { + const observer = new ResizeObserver(() => synchronizeRef.current()); + if (rootRef.current) observer.observe(rootRef.current); + return () => observer.disconnect(); + }, [timeline]); + + const updateRange = (range: QueryTimelineRange) => { + if (syncing.current || timeline.state !== TimelineState.READY) return; + const current = latest.current; + if (!sameRange(current.range, range)) current.onRangeChange(range); + if (current.controlled) { + syncing.current = true; + try { + timeline.api.setRange(current.range.from, current.range.to); + } finally { + syncing.current = false; + } + } + }; + useTimelineEvent(timeline, 'on-range-change', updateRange); + useTimelineEvent(timeline, 'on-camera-change', updateRange); + useTimelineEvent(timeline, 'on-select-change', ({events}) => { + props.onSelectionChange(events.map((event) => event.id)); + }); + useTimelineEvent(timeline, 'on-click', ({canvasX, canvasY}) => { + const events = timeline.api.getComponent<{ + getTopEventAtPoint: (x: number, y: number) => QueryTimelineCanvasEvent | undefined; + render: () => void; + }>(ComponentType.Events); + const event = events?.getTopEventAtPoint(canvasX, canvasY); + if (event) props.onEventClick?.({item: event.row.item, stage: event.row.stage}); + }); + + return ( +
+ + { + const row = event.row; + const defaultContent = ( + + ); + return props.renderEventPopup + ? props.renderEventPopup({item: row.item, stage: row.stage, defaultContent}) + : defaultContent; + }} + /> +
+ ); +} diff --git a/src/modules/QueryTimeline/internal/TimelineDetails.scss b/src/modules/QueryTimeline/internal/TimelineDetails.scss new file mode 100644 index 0000000..29d387b --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineDetails.scss @@ -0,0 +1,5 @@ +.qp-timeline-details { + padding: var(--g-spacing-3); + max-width: 480px; + overflow-wrap: anywhere; +} diff --git a/src/modules/QueryTimeline/internal/TimelineDetails.tsx b/src/modules/QueryTimeline/internal/TimelineDetails.tsx new file mode 100644 index 0000000..0351754 --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineDetails.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import {Flex, Text} from '@gravity-ui/uikit'; +import {dateTimeParse} from '@gravity-ui/date-utils'; +import cn from 'bem-cn-lite'; +import type {QueryTimelineStatus} from '../../../types/queryTimeline'; +import type {TimelineRow} from '../helpers/model'; +import i18n from '../i18n'; +import './TimelineDetails.scss'; + +const block = cn('qp-timeline-details'); + +export function TimelineDetails({ + row, + status, + formatDuration, + timeZone, +}: { + row: TimelineRow; + status?: QueryTimelineStatus; + formatDuration: (milliseconds: number) => string; + timeZone?: string; +}) { + const date = (value: number) => + dateTimeParse(value, {timeZone})?.format('YYYY-MM-DD HH:mm:ss.SSS'); + return ( + + {row.stage?.label ?? row.item.label} + + {i18n('field_status')}: {status?.label ?? row.item.status ?? '—'} + + {row.interval && ( + + + {i18n('field_start')}: {date(row.interval.from)} + + + {i18n('field_end')}:{' '} + {row.open ? i18n('value_running') : date(row.interval.to)} + + + {i18n('field_duration')}:{' '} + {formatDuration(row.interval.to - row.interval.from)} + + + )} + + ); +} diff --git a/src/modules/QueryTimeline/internal/TimelineEventRenderer.ts b/src/modules/QueryTimeline/internal/TimelineEventRenderer.ts new file mode 100644 index 0000000..f56de10 --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineEventRenderer.ts @@ -0,0 +1,71 @@ +import {AbstractEventRenderer} from '@gravity-ui/timeline'; +import type {TimelineEvent} from '@gravity-ui/timeline'; +import type {TimelineRow} from '../helpers/model'; + +export type QueryTimelineCanvasEvent = TimelineEvent & { + row: TimelineRow; + duration: string; + segments: {from: number; to: number; color: string; opacity: number}[]; + opacity: number; +}; + +export class TimelineEventRenderer extends AbstractEventRenderer { + public render(...args: Parameters) { + const [ + ctx, + source, + selected, + x0, + x1, + y, + height, + config, + position, + hovered, + resolveColor, + resolveFont, + ] = args; + const event = source as QueryTimelineCanvasEvent; + const color = (value: string) => resolveColor?.(value) ?? value; + const h = Math.min(18, height - 8); + const top = y - h / 2; + const width = Math.max(4, x1 - x0); + ctx.save(); + ctx.font = resolveFont?.(config.font ?? 'inherit') ?? ctx.font; + ctx.fillStyle = color(event.color ?? 'var(--g-color-base-neutral-heavy)'); + ctx.globalAlpha = event.segments.length ? 0.18 : event.opacity; + ctx.fillRect(x0, top, width, h); + if (position && event.segments.length) { + ctx.beginPath(); + ctx.rect(x0, top, width, h); + ctx.clip(); + for (const segment of event.segments) { + const left = position(segment.from); + const right = position(segment.to); + ctx.fillStyle = color(segment.color); + ctx.globalAlpha = segment.opacity; + ctx.fillRect(left, top, Math.max(1, right - left), h); + } + } + ctx.restore(); + ctx.save(); + if (selected || hovered) { + ctx.strokeStyle = color( + selected ? 'var(--g-color-line-brand)' : 'var(--g-color-line-generic-active)', + ); + ctx.lineWidth = selected ? 2 : 1; + ctx.strokeRect(x0, top, width, h); + } + ctx.font = resolveFont?.(config.font ?? 'inherit') ?? ctx.font; + ctx.textBaseline = 'middle'; + ctx.fillStyle = color('var(--g-color-text-primary)'); + ctx.fillText(event.duration, Math.max(0, x0 + width) + 6, y); + ctx.restore(); + } + + public getHitbox(_event: TimelineEvent, x0: number, x1: number) { + this.hitboxResult.left = x0; + this.hitboxResult.right = Math.max(x0 + 4, x1); + return this.hitboxResult; + } +} diff --git a/src/modules/QueryTimeline/internal/TimelineItemRow.scss b/src/modules/QueryTimeline/internal/TimelineItemRow.scss new file mode 100644 index 0000000..d94d1be --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineItemRow.scss @@ -0,0 +1,32 @@ +.qp-timeline-item-row { + height: 100%; + padding-inline: var(--g-spacing-2); + box-sizing: border-box; + border-bottom: 1px solid var(--g-color-line-generic); + min-width: 0; + + &_stage { + padding-inline-start: var(--g-spacing-5); + } + &__status { + width: 35%; + min-width: 0; + flex-shrink: 0; + } + &__label { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + &__accessible-details { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } +} diff --git a/src/modules/QueryTimeline/internal/TimelineItemRow.tsx b/src/modules/QueryTimeline/internal/TimelineItemRow.tsx new file mode 100644 index 0000000..b7c40fa --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineItemRow.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import {Button, Flex, Icon, Link, Text} from '@gravity-ui/uikit'; +import {ChevronDown, ChevronRight, SquareDashed} from '@gravity-ui/icons'; +import cn from 'bem-cn-lite'; +import type { + QueryTimelineEventContext, + QueryTimelineProps, + QueryTimelineStatus, +} from '../../../types/queryTimeline'; +import {type TimelineRow, progressFraction} from '../helpers/model'; +import {TimelineDetails} from './TimelineDetails'; +import i18n from '../i18n'; +import './TimelineItemRow.scss'; + +const block = cn('qp-timeline-item-row'); + +export function TimelineItemRow({ + row, + status, + expanded, + onToggle, + onFit, + renderItemLabel, + renderItemStatus, + onItemClick, + onEventClick, + formatDuration, + timeZone, + className, +}: { + row: TimelineRow; + status?: QueryTimelineStatus; + expanded: boolean; + onToggle: () => void; + onFit: () => void; + formatDuration: (milliseconds: number) => string; + className?: string; +} & Pick< + QueryTimelineProps, + 'renderItemLabel' | 'renderItemStatus' | 'onItemClick' | 'onEventClick' | 'timeZone' +>) { + const {item, stage} = row; + const context = {item, stage}; + const fraction = progressFraction(item.progress); + const statusContent = stage ? null : ( + + {status?.icon} + + {status?.label ?? item.status ?? '—'} + {fraction === undefined ? '' : `: ${Math.floor(fraction * 100)}%`} + + + ); + const label = stage?.label ?? item.label; + const labelContent = ( + + ); + + return ( + +
+ {renderItemStatus + ? renderItemStatus({...context, defaultContent: statusContent}) + : statusContent} +
+
+ {renderItemLabel + ? renderItemLabel({...context, defaultContent: labelContent}) + : labelContent} +
+
+ +
+ + {!stage && Boolean(item.stages?.length) && ( + + )} +
+ ); +} + +function TimelineItemLabel({ + item, + stage, + onItemClick, + onEventClick, +}: QueryTimelineEventContext & Pick) { + const context = {item, stage}; + const label = stage?.label ?? item.label; + let labelContent: React.ReactNode = ( + + {label} + + ); + if ((!stage && onItemClick) || onEventClick) { + labelContent = ( + + ); + } + if (item.href && !stage) { + labelContent = ( + onItemClick?.(item, event)}> + {label} + + ); + } + + return labelContent; +} diff --git a/src/modules/QueryTimeline/internal/TimelinePlot.tsx b/src/modules/QueryTimeline/internal/TimelinePlot.tsx new file mode 100644 index 0000000..e5bbeb1 --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelinePlot.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import {Flex, Text} from '@gravity-ui/uikit'; +import type {QueryTimelineRange} from '../../../types/queryTimeline'; +import {TimelineCanvasView} from './TimelineCanvasView'; +import i18n from '../i18n'; + +type Props = Omit, 'range'> & { + range?: QueryTimelineRange; + active: boolean; + height: number; + hasIntervals: boolean; +}; + +export function TimelinePlot({range, active, height, hasIntervals, ...props}: Props) { + if (!hasIntervals) { + return ( + + {i18n('context_no-intervals')} + + ); + } + if (!active || height <= 0 || !range) return null; + return ; +} diff --git a/src/modules/QueryTimeline/internal/TimelineRangeSelector.tsx b/src/modules/QueryTimeline/internal/TimelineRangeSelector.tsx new file mode 100644 index 0000000..0bd06d5 --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineRangeSelector.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import {RangeDateSelection} from '@gravity-ui/date-components'; +import {dateTimeParse} from '@gravity-ui/date-utils'; +import type {QueryTimelineRange} from '../../../types/queryTimeline'; +import {MAX_RANGE, MIN_RANGE} from '../helpers/model'; + +export function TimelineRangeSelector({ + range, + onChange, + timeZone, +}: { + range: QueryTimelineRange; + onChange: (range: QueryTimelineRange) => void; + timeZone?: string; +}) { + const start = dateTimeParse(range.from, {timeZone}); + const end = dateTimeParse(range.to, {timeZone}); + if (!start?.isValid() || !end?.isValid()) return null; + // Scale buttons live in the toolbar: the ruler and canvas must have equal widths. + return ( + onChange({from: value.start.valueOf(), to: value.end.valueOf()})} + /> + ); +} diff --git a/src/modules/QueryTimeline/internal/TimelineState.tsx b/src/modules/QueryTimeline/internal/TimelineState.tsx new file mode 100644 index 0000000..5ba06df --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineState.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import {Loader, Text} from '@gravity-ui/uikit'; +import type {QueryTimelineProps} from '../../../types/queryTimeline'; +import i18n from '../i18n'; + +export function TimelineState({ + loading, + errorContent, + emptyContent, + invalid, + empty, +}: Pick & { + invalid: boolean; + empty: boolean; +}) { + if (loading) return ; + if (errorContent) return {errorContent}; + if (invalid) return {i18n('alert_invalid-data')}; + if (empty) return {emptyContent ?? i18n('context_empty')}; + return {i18n('context_no-results')}; +} diff --git a/src/modules/QueryTimeline/internal/TimelineToolbar.scss b/src/modules/QueryTimeline/internal/TimelineToolbar.scss new file mode 100644 index 0000000..069aa95 --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineToolbar.scss @@ -0,0 +1,7 @@ +.qp-timeline-toolbar { + flex-shrink: 0; + + &__search { + width: 260px; + } +} diff --git a/src/modules/QueryTimeline/internal/TimelineToolbar.tsx b/src/modules/QueryTimeline/internal/TimelineToolbar.tsx new file mode 100644 index 0000000..e48e086 --- /dev/null +++ b/src/modules/QueryTimeline/internal/TimelineToolbar.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import {Button, Flex, Icon, Select, TextInput} from '@gravity-ui/uikit'; +import {MagnifierMinus, MagnifierPlus} from '@gravity-ui/icons'; +import cn from 'bem-cn-lite'; +import type {QueryTimelineRange} from '../../../types/queryTimeline'; +import i18n from '../i18n'; +import './TimelineToolbar.scss'; + +const block = cn('qp-timeline-toolbar'); + +export function TimelineToolbar({ + search, + onSearchChange, + status, + onStatusChange, + options, + range, + fullRange, + onRangeChange, + canExpand, + allExpanded, + onToggleAll, + showSearch = true, + showStatusFilter = true, + showRangeSelector = true, +}: { + search: string; + onSearchChange: (value: string) => void; + status?: string; + onStatusChange: (value: string | undefined) => void; + options: {value: string; content: string}[]; + range?: QueryTimelineRange; + fullRange?: QueryTimelineRange; + onRangeChange: (value: QueryTimelineRange) => void; + canExpand: boolean; + allExpanded: boolean; + onToggleAll: () => void; + showSearch?: boolean; + showStatusFilter?: boolean; + showRangeSelector?: boolean; +}) { + return ( + + {showSearch && ( + + )} + {showStatusFilter && ( + onUpdate(event.target.value)} /> + ), + Select: ({value, onUpdate, options, hasClear, width, ...props}: any) => ( + + ), +})); +vi.mock('@gravity-ui/date-components', () => ({ + RangeDateSelection: () =>
, +})); + +const items: QueryTimelineProps['items'] = [ + { + id: 'first', + label: 'First', + status: 'custom', + interval: {start: 0, end: 5000}, + data: {source: 'application'}, + stages: [{id: 's', label: 'Stage', interval: {start: 1000, end: 2000}}], + }, + {id: 'second', label: 'Second', interval: {start: 2000, end: 6000}}, +]; + +describe('QueryTimeline', () => { + let container: HTMLDivElement; + let root: Root; + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(320); + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(960); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(960); + captured.canvas = undefined; + captured.mounts = 0; + captured.unmounts = 0; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + const render = (props: Partial = {}) => + act(() => root.render()); + const button = (label: string) => + Array.from(container.querySelectorAll('button')).find( + (node) => node.textContent === label || node.getAttribute('aria-label') === label, + )!; + const click = (node: Element) => + act(() => node.dispatchEvent(new MouseEvent('click', {bubbles: true}))); + const changeSearch = (value: string) => + act(() => { + const input = container.querySelector('input')!; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + value, + ); + input.dispatchEvent(new Event('input', {bubbles: true})); + }); + + it('keeps filtering, expanded rows and range across inactive periods', () => { + render(); + click(button('Expand all')); + expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(3); + click(button('Fit interval: Stage')); + expect(captured.canvas.range).toEqual({from: 500, to: 2500}); + render({active: false}); + expect(container.querySelector('canvas')).toBeNull(); + render(); + expect(captured.canvas.range).toEqual({from: 500, to: 2500}); + expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(3); + expect(captured.unmounts).toBe(1); + changeSearch(' First '); + expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(2); + expect(captured.canvas.range).toEqual({from: 500, to: 2500}); + }); + it('does not rebuild the canvas or reset the camera when data updates', () => { + render(); + const mounts = captured.mounts; + act(() => captured.canvas.onRangeChange({from: 1000, to: 3000})); + render({items: [...items, {id: 'new', label: 'New', interval: {start: 0, end: 90000}}]}); + expect(captured.canvas.range).toEqual({from: 1000, to: 3000}); + expect(captured.mounts).toBe(mounts); + click(button('Fit all')); + expect(captured.canvas.range).toEqual({from: 0, to: 90000}); + }); + it('treats a controlled range as authoritative and deduplicates callbacks', () => { + const onRangeChange = vi.fn(); + const range: QueryTimelineRange = {from: 0, to: 10000}; + render({range, onRangeChange}); + act(() => { + captured.canvas.onRangeChange({from: 1000, to: 3000}); + captured.canvas.onRangeChange({from: 1000, to: 3000}); + }); + expect(onRangeChange).toHaveBeenCalledTimes(1); + expect(captured.canvas.range).toEqual(range); + render({range: {from: 1000, to: 3000}, onRangeChange}); + expect(captured.canvas.range).toEqual({from: 1000, to: 3000}); + expect(onRangeChange).toHaveBeenCalledTimes(1); + }); + it('runs the live clock only while active and keeps the initial range', () => { + vi.useFakeTimers(); + vi.setSystemTime(10000); + const running = [{id: 'live', label: 'Live', interval: {start: 0}}]; + render({items: running}); + expect(captured.canvas.rows[0].interval.to).toBe(10000); + act(() => vi.advanceTimersByTime(2000)); + expect(captured.canvas.rows[0].interval.to).toBe(12000); + expect(captured.canvas.range).toEqual({from: 0, to: 10000}); + render({items: running, active: false}); + expect(vi.getTimerCount()).toBe(0); + act(() => vi.advanceTimersByTime(3000)); + render({items: running}); + expect(captured.canvas.rows[0].interval.to).toBe(15000); + expect(captured.canvas.range).toEqual({from: 0, to: 10000}); + render({items: running, now: 9000}); + expect(vi.getTimerCount()).toBe(0); + expect(captured.canvas.rows[0].interval.to).toBe(9000); + }); + it('virtualizes 5000 rows and passes global indices to the canvas', () => { + vi.useFakeTimers(); + const large = Array.from({length: 1000}, (_, index) => ({ + ...items[0], + id: String(index), + stages: Array.from({length: 4}, (_, stage) => ({ + id: String(stage), + label: String(stage), + interval: {start: 0, end: 1000}, + })), + })); + render({items: large}); + click(button('Expand all')); + expect(container.querySelectorAll('[role="listitem"]').length).toBeLessThanOrEqual(30); + const scroll = container.querySelector('.qp-query-timeline__scroll')!; + act(() => { + scroll.scrollTop = 32000; + scroll.dispatchEvent(new Event('scroll')); + vi.advanceTimersByTime(20); + }); + expect(captured.canvas.offset).toBe(990); + expect(captured.canvas.scrollTop).toBe(32000); + expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(30); + expect( + (container.querySelector('.qp-query-timeline__canvas') as HTMLElement).style.height, + ).toBe('320px'); + }); + it('keeps interval-less rows, distinguishes empty/filter/error states', () => { + render({items: [{id: 'pending', label: 'Pending'}]}); + expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(1); + expect(container.textContent).toContain('No time intervals'); + expect(container.querySelector('canvas')).toBeNull(); + changeSearch('missing'); + expect(container.textContent).toContain('No matching items'); + render({items: [], emptyContent: 'Custom empty'}); + expect(container.textContent).toContain('Custom empty'); + const onError = vi.fn(); + render({items: [{id: 'bad', label: 'Bad', interval: {start: NaN}}], onError}); + expect(onError).toHaveBeenCalledTimes(1); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + }); + it('passes original application data and default content to renderers', () => { + const renderItemLabel = vi.fn(({defaultContent}) => {defaultContent}); + const onItemClick = vi.fn(); + render({renderItemLabel, onItemClick}); + expect(renderItemLabel.mock.calls[0][0].item).toBe(items[0]); + click(button('First')); + expect(onItemClick.mock.calls[0][0].data).toBe(items[0].data); + }); + it('cleans up StrictMode clocks', () => { + vi.useFakeTimers(); + act(() => + root.render( + + + , + ), + ); + expect(vi.getTimerCount()).toBe(1); + act(() => root.render(null)); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/tests/components/TimelineCanvasView.test.tsx b/tests/components/TimelineCanvasView.test.tsx new file mode 100644 index 0000000..df67865 --- /dev/null +++ b/tests/components/TimelineCanvasView.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +import React, {act} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {TimelineState} from '@gravity-ui/timeline'; +import {TimelineCanvasView} from '../../src/modules/QueryTimeline/internal/TimelineCanvasView'; +import {createRows} from '../../src/modules/QueryTimeline/helpers/model'; + +const observed = vi.hoisted(() => ({timeline: undefined as any, instances: new Set()})); +vi.mock('@gravity-ui/timeline/react', async (getOriginal) => { + const original = await getOriginal(); + return { + ...original, + useTimeline: (config: any) => { + const result = original.useTimeline(config); + observed.timeline = result.timeline; + observed.instances.add(result.timeline); + return result; + }, + }; +}); +vi.mock('@gravity-ui/uikit', () => ({useThemeValue: () => 'light', Popup: () => null})); + +describe('TimelineCanvasView with the real timeline engine', () => { + let root: Root; + let container: HTMLDivElement; + const rows = createRows( + [{id: 'one', label: 'One', interval: {start: 0, end: 0}}], + new Set(), + 1000, + ); + const props = () => ({ + rows, + offset: 0, + rowHeight: 32, + scrollTop: 0, + range: {from: -500, to: 1500}, + controlled: false, + onRangeChange: vi.fn(), + statuses: new Map(), + formatDuration: String, + now: 1000, + selectedIds: [], + onSelectionChange: vi.fn(), + }); + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + observed.instances.clear(); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ); + vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue( + new DOMRect(0, 0, 600, 320), + ); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function ( + this: HTMLCanvasElement, + ) { + return new Proxy( + {canvas: this, measureText: () => ({width: 20})}, + { + get(target, property) { + return (target as any)[property] ?? (() => {}); + }, + }, + ) as any; + }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + it('updates in place, uses global row positions and gives zero events a finite hitbox', () => { + const initial = props(); + act(() => root.render()); + const timeline = observed.timeline; + expect(timeline.state).toBe(TimelineState.READY); + const component = Array.from((timeline.api as any).components.values()).find( + (value: any) => value.getSelectedEvents, + ) as any; + expect(component._events[0].from).toBe(0); + expect(component._events[0].to).toBeGreaterThan(0); + act(() => root.render()); + expect(observed.timeline).toBe(timeline); + expect(observed.instances.size).toBe(1); + expect(timeline.api.canvasScrollTop).toBe(3200); + expect(timeline.api.getEventPosition(component._events[0]).y0).toBe(16); + expect(initial.onRangeChange).not.toHaveBeenCalled(); + }); + it('reports gestures but suppresses synchronization feedback and restores controlled ranges', () => { + const initial = {...props(), controlled: true}; + act(() => root.render()); + act(() => observed.timeline.api.setRange(0, 3000)); + expect(initial.onRangeChange).toHaveBeenCalledExactlyOnceWith({from: 0, to: 3000}); + expect(observed.timeline.api.getInterval()).toEqual({start: -500, end: 1500}); + act(() => root.render()); + expect(initial.onRangeChange).toHaveBeenCalledTimes(1); + expect(observed.timeline.api.getInterval()).toEqual({start: 0, end: 5000}); + }); + it('survives StrictMode effect replay and destroys the engine on unmount', () => { + act(() => + root.render( + + + , + ), + ); + const timeline = observed.timeline; + expect(timeline.state).toBe(TimelineState.READY); + act(() => root.render(null)); + expect(timeline.state).toBe(TimelineState.INIT); + }); +}); diff --git a/tests/helpers/queryTimeline.test.ts b/tests/helpers/queryTimeline.test.ts new file mode 100644 index 0000000..dab14e9 --- /dev/null +++ b/tests/helpers/queryTimeline.test.ts @@ -0,0 +1,104 @@ +import {describe, expect, it} from 'vitest'; +import type {QueryTimelineItem} from '../../src/types/queryTimeline'; +import { + createRows, + extent, + filterItems, + getWindow, + hasOpenIntervals, + itemInterval, + normalizeRange, + preserveScroll, + progressFraction, + resolveInterval, + statusOptions, + validateTimeline, +} from '../../src/modules/QueryTimeline/helpers/model'; + +const items: QueryTimelineItem[] = [ + { + id: 'a', + label: 'First item', + status: 'custom', + interval: {start: 0, end: 1000}, + stages: [ + {id: 'a', label: 'First stage', interval: {start: 0, end: 200}}, + {id: 'b', label: 'Overlap', interval: {start: 100, end: 500}}, + {id: 'c', label: 'After a gap', interval: {start: 800, end: 1000}}, + ], + }, + {id: 'b', label: 'Second item', status: 'custom', interval: {start: 2000}}, + {id: 'c', label: 'Pending', status: 'unknown'}, +]; +const validate = (data: QueryTimelineItem[]) => validateTimeline(data, [], [], 3000, 32, 360); + +describe('timeline model', () => { + it('preserves timestamps, gaps, overlaps, order and distinct identities', () => { + const rows = createRows(items, new Set(['a']), 3000); + expect(rows.map((row) => row.interval)).toEqual([ + {from: 0, to: 1000}, + {from: 0, to: 200}, + {from: 100, to: 500}, + {from: 800, to: 1000}, + {from: 2000, to: 3000}, + undefined, + ]); + expect(new Set(rows.map((row) => row.id)).size).toBe(rows.length); + expect(rows[1].stage).toBe(items[0].stages![0]); + expect(rows[0].item).toBe(items[0]); + }); + it('filters parent labels and arbitrary statuses without counting stages', () => { + expect(filterItems(items, ' FIRST ', 'custom')).toEqual([items[0]]); + expect(filterItems(items, 'stage')).toEqual([]); + expect(statusOptions(items, [{id: 'custom', label: 'Custom'}])).toEqual([ + {value: 'custom', content: 'Custom (2)'}, + {value: 'unknown', content: 'unknown (1)'}, + ]); + }); + it('derives parent extent from stages but prefers an explicit interval', () => { + expect(itemInterval({...items[0], interval: undefined}, 3000)).toEqual({from: 0, to: 1000}); + expect(itemInterval({...items[0], interval: {start: 100, end: 900}}, 3000)).toEqual({ + from: 100, + to: 900, + }); + expect(extent([])).toBeUndefined(); + }); + it('accepts zero timestamps and zero duration, handles future open intervals', () => { + expect(() => + validate([{id: 'zero', label: '', interval: {start: 0, end: 0}}]), + ).not.toThrow(); + expect(resolveInterval({start: 5000}, 3000)).toEqual({from: 5000, to: 5000}); + expect(normalizeRange({from: 0, to: 0})).toEqual({from: -500, to: 500}); + expect(hasOpenIntervals(items)).toBe(true); + expect(hasOpenIntervals([items[0]])).toBe(false); + }); + it.each([NaN, Infinity, -Infinity])('rejects invalid timestamps: %s', (start) => { + expect(() => validate([{id: 'bad', label: '', interval: {start}}])).toThrow( + 'Invalid interval', + ); + }); + it('rejects reversed intervals and duplicate IDs', () => { + expect(() => validate([{id: 'bad', label: '', interval: {start: 10, end: 0}}])).toThrow(); + expect(() => validate([items[0], items[0]])).toThrow('Duplicate item'); + expect(() => + validate([{...items[0], stages: [items[0].stages![0], items[0].stages![0]]}]), + ).toThrow('Duplicate stage'); + }); + it('honors fraction precedence and clamps progress', () => { + expect(progressFraction({fraction: 0, completed: 10, total: 10})).toBe(0); + expect(progressFraction({completed: 5, total: 10})).toBe(0.5); + expect(progressFraction({completed: 20, total: 10})).toBe(1); + expect(progressFraction({fraction: -1})).toBe(0); + expect(progressFraction({total: 0})).toBeUndefined(); + }); + it('bounds the virtual window independently of total row count', () => { + expect(getWindow(5000, 32000, 320, 32)).toEqual({start: 990, end: 1020}); + expect(getWindow(3, 0, 320, 32)).toEqual({start: 0, end: 3}); + }); + it('preserves the top row when rows above it are collapsed', () => { + const expanded = createRows(items, new Set(['a']), 3000); + const collapsed = createRows(items, new Set(), 3000); + expect(preserveScroll(expanded, collapsed, 4 * 32 + 5, 32, 32, 32)).toBe(37); + expect(preserveScroll(expanded, [], 128, 320, 32, 32)).toBe(0); + }); +}); diff --git a/tests/tree-shaking/check.mjs b/tests/tree-shaking/check.mjs index 24b1919..af8f67a 100644 --- a/tests/tree-shaking/check.mjs +++ b/tests/tree-shaking/check.mjs @@ -36,7 +36,11 @@ const barrels = new Set( path.join(root, 'src', p), ), ); -const pending = widgets.map((name) => path.join(root, 'src/widgets', name, 'index.ts')); +const pending = [ + ...widgets.map((name) => path.join(root, 'src/widgets', name, 'index.ts')), + path.join(root, 'src/modules/QueryTimeline/index.ts'), + path.join(root, 'src/modules/QueryProgress/index.ts'), +]; const visited = new Set(); while (pending.length) { const file = pending.pop(); @@ -173,6 +177,10 @@ for (const widget of widgets) { ); } } + assert( + !hasDependency('@gravity-ui/timeline'), + `${specifier}: unrelated timeline dependency`, + ); assert(!hasDependency('@gravity-ui/graph'), `${specifier}: unrelated graph dependency`); assert.equal( hasDependency('monaco-editor'), @@ -216,3 +224,43 @@ for (const widget of widgets) { console.info(`✓ ${specifier} → ${widget}: JS, CSS, i18n, Monaco, exports and types`); } } + +// Timeline is independently importable, and its public types do not expose the engine. +for (const mode of ['bundler', 'import', 'require']) checkTypes(packageName, 'QueryTimeline', mode); +for (const format of ['esm', 'cjs']) { + const result = await build({ + absWorkingDir: root, + entryPoints: [path.join(root, `build/${format}/modules/QueryTimeline/index.js`)], + bundle: true, + write: false, + platform: 'browser', + format, + outfile: path.join(root, `build/tree-shaking/timeline-${format}.js`), + metafile: true, + external: [ + 'react', + 'react-dom', + '@gravity-ui/uikit', + '@gravity-ui/icons', + '@gravity-ui/date-components', + '@gravity-ui/date-utils', + ], + logLevel: 'silent', + }); + const included = Object.keys(result.metafile.inputs); + assert( + included.some((file) => file.includes('@gravity-ui/timeline/')), + `${format}: missing timeline engine`, + ); + assert( + !included.some((file) => file.includes('@gravity-ui/graph/')), + `${format}: unrelated graph engine`, + ); + assert( + result.outputFiles.some((file) => file.path.endsWith('.css')), + `${format}: missing timeline CSS`, + ); + console.info( + `✓ QueryTimeline ${format}: standalone consumer bundle, CSS and engine resolution`, + ); +} diff --git a/vitest.config.ts b/vitest.config.ts index 4eed655..a92fb53 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ include: ['tests/**/*.test.{ts,tsx}'], server: { deps: { - inline: ['@gravity-ui/uikit'], + inline: ['@gravity-ui/uikit', '@gravity-ui/timeline'], }, }, },