From 2486a61b560acb6fe75e3463fc44d3aefb1eaa51 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:46:20 +0200 Subject: [PATCH 01/14] refactor(app): move DBDashboardPage behind barrel Co-authored-by: Cursor --- .../{ => DBDashboardPage}/DBDashboardPage.tsx | 54 +++++++++---------- packages/app/src/DBDashboardPage/index.ts | 1 + 2 files changed, 28 insertions(+), 27 deletions(-) rename packages/app/src/{ => DBDashboardPage}/DBDashboardPage.tsx (98%) create mode 100644 packages/app/src/DBDashboardPage/index.ts diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx similarity index 98% rename from packages/app/src/DBDashboardPage.tsx rename to packages/app/src/DBDashboardPage/DBDashboardPage.tsx index 286a51e133..6376ad3ab4 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -119,45 +119,45 @@ import useDashboardContainers, { } from '@/hooks/useDashboardContainers'; import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; -import ChartContainer from './components/charts/ChartContainer'; +import ChartContainer from '@/components/charts/ChartContainer'; import DBHeatmapChart, { toHeatmapChartConfig, -} from './components/DBHeatmapChart'; -import { DBPieChart } from './components/DBPieChart'; -import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; -import OnboardingModal from './components/OnboardingModal'; +} from '@/components/DBHeatmapChart'; +import { DBPieChart } from '@/components/DBPieChart'; +import DBSqlRowTableWithSideBar from '@/components/DBSqlRowTableWithSidebar'; +import OnboardingModal from '@/components/OnboardingModal'; import SearchWhereInput, { getStoredLanguage, -} from './components/SearchInput/SearchWhereInput'; -import { Tags } from './components/Tags'; -import useDashboardFilters from './hooks/useDashboardFilters'; -import { useDashboardRefresh } from './hooks/useDashboardRefresh'; -import useTileSelection from './hooks/useTileSelection'; -import { useBrandDisplayName } from './theme/ThemeProvider'; -import { parseAsJsonEncoded, parseAsStringEncoded } from './utils/queryParsers'; +} from '@/components/SearchInput/SearchWhereInput'; +import { Tags } from '@/components/Tags'; +import useDashboardFilters from '@/hooks/useDashboardFilters'; +import { useDashboardRefresh } from '@/hooks/useDashboardRefresh'; +import useTileSelection from '@/hooks/useTileSelection'; +import { useBrandDisplayName } from '@/theme/ThemeProvider'; +import { parseAsJsonEncoded, parseAsStringEncoded } from '@/utils/queryParsers'; import { buildEventsSearchUrl, buildTableRowSearchUrl, DEFAULT_CHART_CONFIG, -} from './ChartUtils'; -import { useConnections } from './connection'; -import { useDashboard } from './dashboard'; -import DashboardFilters from './DashboardFilters'; -import DashboardFiltersModal from './DashboardFiltersModal'; -import { EditablePageName } from './EditablePageName'; -import { GranularityPickerControlled } from './GranularityPicker'; -import HDXMarkdownChart from './HDXMarkdownChart'; -import { withAppNav } from './layout'; +} from '@/ChartUtils'; +import { useConnections } from '@/connection'; +import { useDashboard } from '@/dashboard'; +import DashboardFilters from '@/DashboardFilters'; +import DashboardFiltersModal from '@/DashboardFiltersModal'; +import { EditablePageName } from '@/EditablePageName'; +import { GranularityPickerControlled } from '@/GranularityPicker'; +import HDXMarkdownChart from '@/HDXMarkdownChart'; +import { withAppNav } from '@/layout'; import { getFirstTimestampValueExpression, useSource, useSources, -} from './source'; -import { parseTimeQuery, useNewTimeQuery } from './timeQuery'; -import { useConfirm } from './useConfirm'; -import { FormatTime } from './useFormatTime'; -import { getMetricTableName } from './utils'; -import { useZIndex, ZIndexContext } from './zIndex'; +} from '@/source'; +import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; +import { useConfirm } from '@/useConfirm'; +import { FormatTime } from '@/useFormatTime'; +import { getMetricTableName } from '@/utils'; +import { useZIndex, ZIndexContext } from '@/zIndex'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; diff --git a/packages/app/src/DBDashboardPage/index.ts b/packages/app/src/DBDashboardPage/index.ts new file mode 100644 index 0000000000..cb3af3fecf --- /dev/null +++ b/packages/app/src/DBDashboardPage/index.ts @@ -0,0 +1 @@ +export { default } from './DBDashboardPage'; From 584d0d5e5934e8727f0cc41e90cab2c631cc4f51 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:47:37 +0200 Subject: [PATCH 02/14] refactor(app): extract dashboard heatmap tile Co-authored-by: Cursor --- .../src/DBDashboardPage/DBDashboardPage.tsx | 145 +----------------- .../app/src/DBDashboardPage/HeatmapTile.tsx | 144 +++++++++++++++++ 2 files changed, 147 insertions(+), 142 deletions(-) create mode 100644 packages/app/src/DBDashboardPage/HeatmapTile.tsx diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx index 6376ad3ab4..b7118c7444 100644 --- a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -33,7 +33,6 @@ import { } from '@hyperdx/common-utils/dist/guards'; import { AlertState, - BuilderChartConfigWithDateRange, ChartConfigWithDateRange, DashboardContainer as DashboardContainerSchema, DashboardFilter, @@ -46,7 +45,6 @@ import { SearchConditionLanguage, SourceKind, SQLInterval, - TSource, } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, @@ -60,8 +58,6 @@ import { Menu, Modal, Paper, - Popover, - Portal, Stack, Text, Tooltip, @@ -82,7 +78,6 @@ import { IconPlayerPlay, IconPlus, IconRefresh, - IconSearch, IconSquaresDiagonal, IconTags, IconTrash, @@ -120,9 +115,6 @@ import useDashboardContainers, { import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; import ChartContainer from '@/components/charts/ChartContainer'; -import DBHeatmapChart, { - toHeatmapChartConfig, -} from '@/components/DBHeatmapChart'; import { DBPieChart } from '@/components/DBPieChart'; import DBSqlRowTableWithSideBar from '@/components/DBSqlRowTableWithSidebar'; import OnboardingModal from '@/components/OnboardingModal'; @@ -135,11 +127,7 @@ import { useDashboardRefresh } from '@/hooks/useDashboardRefresh'; import useTileSelection from '@/hooks/useTileSelection'; import { useBrandDisplayName } from '@/theme/ThemeProvider'; import { parseAsJsonEncoded, parseAsStringEncoded } from '@/utils/queryParsers'; -import { - buildEventsSearchUrl, - buildTableRowSearchUrl, - DEFAULT_CHART_CONFIG, -} from '@/ChartUtils'; +import { buildTableRowSearchUrl, DEFAULT_CHART_CONFIG } from '@/ChartUtils'; import { useConnections } from '@/connection'; import { useDashboard } from '@/dashboard'; import DashboardFilters from '@/DashboardFilters'; @@ -159,138 +147,11 @@ import { FormatTime } from '@/useFormatTime'; import { getMetricTableName } from '@/utils'; import { useZIndex, ZIndexContext } from '@/zIndex'; +import { HeatmapTile } from './HeatmapTile'; + import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; -function HeatmapTile({ - keyPrefix, - chartId, - title, - toolbar, - queriedConfig, - source, - dateRange, -}: { - keyPrefix: string; - chartId: string; - title: React.ReactNode; - toolbar: React.ReactNode[]; - queriedConfig: BuilderChartConfigWithDateRange; - source: TSource | undefined; - dateRange: [Date, Date]; -}) { - const { heatmapConfig, scaleType } = toHeatmapChartConfig(queriedConfig); - - const [clickPos, setClickPos] = useState<{ x: number; y: number } | null>( - null, - ); - const containerRef = useRef(null); - - const eventDeltasUrl = useMemo(() => { - if (!source) return null; - const url = buildEventsSearchUrl({ - source, - config: queriedConfig, - dateRange, - }); - if (!url) return null; - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}mode=delta`; - }, [source, queriedConfig, dateRange]); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - if (!eventDeltasUrl) return; - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - setClickPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); - }, - [eventDeltasUrl], - ); - - const dismiss = useCallback(() => setClickPos(null), []); - - return ( -
- - {clickPos != null && eventDeltasUrl != null && ( - <> - -
{ - e.stopPropagation(); - e.preventDefault(); - dismiss(); - }} - onMouseDown={e => e.stopPropagation()} - /> - - { - if (!opened) dismiss(); - }} - position="bottom-start" - offset={4} - withinPortal - closeOnEscape - withArrow - shadow="md" - > - -
- - e.stopPropagation()} - onMouseDown={e => e.stopPropagation()} - > - - - - View in Event Deltas - - - - - - )} -
- ); -} - const ReactGridLayout = WidthProvider(RGL); type MoveTarget = { diff --git a/packages/app/src/DBDashboardPage/HeatmapTile.tsx b/packages/app/src/DBDashboardPage/HeatmapTile.tsx new file mode 100644 index 0000000000..99ff29ad2c --- /dev/null +++ b/packages/app/src/DBDashboardPage/HeatmapTile.tsx @@ -0,0 +1,144 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import { + BuilderChartConfigWithDateRange, + TSource, +} from '@hyperdx/common-utils/dist/types'; +import { Group, Popover, Portal } from '@mantine/core'; +import { IconSearch } from '@tabler/icons-react'; + +import { buildEventsSearchUrl } from '@/ChartUtils'; +import DBHeatmapChart, { + toHeatmapChartConfig, +} from '@/components/DBHeatmapChart'; + +type HeatmapTileProps = { + keyPrefix: string; + chartId: string; + title: React.ReactNode; + toolbar: React.ReactNode[]; + queriedConfig: BuilderChartConfigWithDateRange; + source: TSource | undefined; + dateRange: [Date, Date]; +}; + +export function HeatmapTile({ + keyPrefix, + chartId, + title, + toolbar, + queriedConfig, + source, + dateRange, +}: HeatmapTileProps) { + const { heatmapConfig, scaleType } = toHeatmapChartConfig(queriedConfig); + + const [clickPos, setClickPos] = useState<{ x: number; y: number } | null>( + null, + ); + const containerRef = useRef(null); + + const eventDeltasUrl = useMemo(() => { + if (!source) return null; + const url = buildEventsSearchUrl({ + source, + config: queriedConfig, + dateRange, + }); + if (!url) return null; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}mode=delta`; + }, [source, queriedConfig, dateRange]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!eventDeltasUrl) return; + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + setClickPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + }, + [eventDeltasUrl], + ); + + const dismiss = useCallback(() => setClickPos(null), []); + + return ( +
+ + {clickPos != null && eventDeltasUrl != null && ( + <> + +
{ + e.stopPropagation(); + e.preventDefault(); + dismiss(); + }} + onMouseDown={e => e.stopPropagation()} + /> + + { + if (!opened) dismiss(); + }} + position="bottom-start" + offset={4} + withinPortal + closeOnEscape + withArrow + shadow="md" + > + +
+ + e.stopPropagation()} + onMouseDown={e => e.stopPropagation()} + > + + + + View in Event Deltas + + + + + + )} +
+ ); +} From f8202af496d623f853da4101b7e39e18c8b3b363 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:50:06 +0200 Subject: [PATCH 03/14] refactor(app): extract dashboard tile component Co-authored-by: Cursor --- .../src/DBDashboardPage/DBDashboardPage.tsx | 708 +---------------- .../app/src/DBDashboardPage/DashboardTile.tsx | 718 ++++++++++++++++++ 2 files changed, 726 insertions(+), 700 deletions(-) create mode 100644 packages/app/src/DBDashboardPage/DashboardTile.tsx diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx index b7118c7444..66f52e78e2 100644 --- a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -1,49 +1,24 @@ -import { - ForwardedRef, - forwardRef, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import Head from 'next/head'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { formatDistanceToNow, formatRelative } from 'date-fns'; +import { formatDistanceToNow } from 'date-fns'; import produce from 'immer'; -import { pick } from 'lodash'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; import { ErrorBoundary } from 'react-error-boundary'; import RGL, { WidthProvider } from 'react-grid-layout'; import { useForm, useWatch } from 'react-hook-form'; import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; -import { - convertToDashboardTemplate, - displayTypeSupportsBuilderAlerts, - displayTypeSupportsRawSqlAlerts, -} from '@hyperdx/common-utils/dist/core/utils'; -import { - displayTypeRequiresSource, - isBuilderChartConfig, - isBuilderSavedChartConfig, - isRawSqlChartConfig, - isRawSqlSavedChartConfig, -} from '@hyperdx/common-utils/dist/guards'; +import { convertToDashboardTemplate } from '@hyperdx/common-utils/dist/core/utils'; +import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards'; import { AlertState, - ChartConfigWithDateRange, DashboardContainer as DashboardContainerSchema, DashboardFilter, - DisplayType, Filter, - getSampleWeightExpression, - isLogSource, - isTraceSource, SearchCondition, SearchConditionLanguage, - SourceKind, SQLInterval, } from '@hyperdx/common-utils/dist/types'; import { @@ -54,27 +29,19 @@ import { Button, Flex, Group, - Indicator, Menu, Modal, Paper, - Stack, Text, Tooltip, } from '@mantine/core'; -import { useHotkeys } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { - IconArrowsMaximize, - IconBell, IconChartBar, - IconCopy, - IconCornerDownRight, IconDeviceFloppy, IconDotsVertical, IconDownload, IconFilterEdit, - IconPencil, IconPlayerPlay, IconPlus, IconRefresh, @@ -83,7 +50,6 @@ import { IconTrash, IconUpload, IconX, - IconZoomExclamation, } from '@tabler/icons-react'; import { ContactSupportText } from '@/components/ContactSupportText'; @@ -97,11 +63,7 @@ import { type DragHandleProps, } from '@/components/DashboardDndContext'; import EditTimeChartForm from '@/components/DBEditTimeChartForm'; -import DBNumberChart from '@/components/DBNumberChart'; -import DBTableChart from '@/components/DBTableChart'; -import { DBTimeChart } from '@/components/DBTimeChart'; import { FavoriteButton } from '@/components/FavoriteButton'; -import FullscreenPanelModal from '@/components/FullscreenPanelModal'; import { TimePicker } from '@/components/TimePicker'; import { Dashboard, @@ -114,9 +76,6 @@ import useDashboardContainers, { } from '@/hooks/useDashboardContainers'; import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; -import ChartContainer from '@/components/charts/ChartContainer'; -import { DBPieChart } from '@/components/DBPieChart'; -import DBSqlRowTableWithSideBar from '@/components/DBSqlRowTableWithSidebar'; import OnboardingModal from '@/components/OnboardingModal'; import SearchWhereInput, { getStoredLanguage, @@ -127,41 +86,28 @@ import { useDashboardRefresh } from '@/hooks/useDashboardRefresh'; import useTileSelection from '@/hooks/useTileSelection'; import { useBrandDisplayName } from '@/theme/ThemeProvider'; import { parseAsJsonEncoded, parseAsStringEncoded } from '@/utils/queryParsers'; -import { buildTableRowSearchUrl, DEFAULT_CHART_CONFIG } from '@/ChartUtils'; +import { DEFAULT_CHART_CONFIG } from '@/ChartUtils'; import { useConnections } from '@/connection'; import { useDashboard } from '@/dashboard'; import DashboardFilters from '@/DashboardFilters'; import DashboardFiltersModal from '@/DashboardFiltersModal'; import { EditablePageName } from '@/EditablePageName'; import { GranularityPickerControlled } from '@/GranularityPicker'; -import HDXMarkdownChart from '@/HDXMarkdownChart'; import { withAppNav } from '@/layout'; -import { - getFirstTimestampValueExpression, - useSource, - useSources, -} from '@/source'; +import { useSources } from '@/source'; import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; import { useConfirm } from '@/useConfirm'; import { FormatTime } from '@/useFormatTime'; import { getMetricTableName } from '@/utils'; import { useZIndex, ZIndexContext } from '@/zIndex'; -import { HeatmapTile } from './HeatmapTile'; +import { DashboardTile, type MoveTarget } from './DashboardTile'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; const ReactGridLayout = WidthProvider(RGL); -type MoveTarget = { - containerId: string; - tabId?: string; - label: string; - // For tabs: all tabs in order with the target tab ID - allTabs?: { id: string; title: string }[]; -}; - const tileToLayoutItem = (chart: Tile): RGL.Layout => ({ i: chart.id, x: chart.x, @@ -179,644 +125,6 @@ const whereLanguageParser = parseAsString.withDefault( typeof window !== 'undefined' ? (getStoredLanguage() ?? 'lucene') : 'lucene', ); -const Tile = forwardRef( - ( - { - chart, - dateRange, - onDuplicateClick, - onEditClick, - onDeleteClick, - onUpdateChart, - onMoveToGroup, - moveTargets, - granularity, - onTimeRangeSelect, - filters, - - // Properties forwarded by grid layout - className, - style, - onMouseDown, - onMouseUp, - onTouchEnd, - children, - isHighlighted, - isSelected, - onSelect, - }: { - chart: Tile; - dateRange: [Date, Date]; - onDuplicateClick: () => void; - onEditClick: () => void; - onAddAlertClick?: () => void; - onDeleteClick: () => void; - onUpdateChart?: (chart: Tile) => void; - onMoveToGroup?: (containerId: string | undefined, tabId?: string) => void; - moveTargets?: MoveTarget[]; - onSettled?: () => void; - granularity: SQLInterval | undefined; - onTimeRangeSelect: (start: Date, end: Date) => void; - filters?: Filter[]; - - // Properties forwarded by grid layout - className?: string; - style?: React.CSSProperties; - onMouseDown?: (e: React.MouseEvent) => void; - onMouseUp?: (e: React.MouseEvent) => void; - onTouchEnd?: (e: React.TouchEvent) => void; - children?: React.ReactNode; // Resizer tooltip - isHighlighted?: boolean; - isSelected?: boolean; - onSelect?: (tileId: string) => void; - }, - ref: ForwardedRef, - ) => { - const [isFullscreen, setIsFullscreen] = useState(false); - const [isFocused, setIsFocused] = useState(false); - - useEffect(() => { - if (isHighlighted) { - document - .getElementById(`chart-${chart.id}`) - ?.scrollIntoView({ behavior: 'smooth' }); - } - }, [chart.id, isHighlighted]); - - // YouTube-style 'f' key shortcut for fullscreen toggle - useHotkeys([['f', () => isFocused && setIsFullscreen(prev => !prev)]]); - - const [queriedConfig, setQueriedConfig] = useState< - ChartConfigWithDateRange | undefined - >(undefined); - - const { data: source, isFetched: isSourceFetched } = useSource({ - id: chart.config.source, - }); - - const isSourceMissing = - !!chart.config.source && isSourceFetched && source == null; - const isSourceUnset = - !!chart.config && - isBuilderSavedChartConfig(chart.config) && - displayTypeRequiresSource(chart.config.displayType) && - !chart.config.source; - - useEffect(() => { - if (isRawSqlSavedChartConfig(chart.config)) { - // Some raw SQL charts don't have a source - if (!chart.config.source) { - setQueriedConfig({ - ...chart.config, - dateRange, - granularity, - filters, - }); - } else if (source != null) { - setQueriedConfig({ - ...chart.config, - // Populate these columns from the source to support Lucene-based filters and metric table macros - ...pick(source, [ - 'implicitColumnExpression', - 'from', - 'metricTables', - ]), - sampleWeightExpression: getSampleWeightExpression(source), - dateRange, - granularity, - filters, - }); - } - - return; - } - - if (source != null && isBuilderSavedChartConfig(chart.config)) { - const isMetricSource = source.kind === SourceKind.Metric; - - // TODO: will need to update this when we allow for multiple metrics per chart - const firstSelect = chart.config.select[0]; - const metricType = - isMetricSource && typeof firstSelect !== 'string' - ? firstSelect?.metricType - : undefined; - const tableName = getMetricTableName(source, metricType); - if (source.connection) { - setQueriedConfig({ - ...chart.config, - connection: source.connection, - dateRange, - granularity, - timestampValueExpression: source.timestampValueExpression, - from: { - databaseName: source.from?.databaseName || 'default', - tableName: tableName || '', - }, - implicitColumnExpression: - isLogSource(source) || isTraceSource(source) - ? source.implicitColumnExpression - : undefined, - sampleWeightExpression: getSampleWeightExpression(source), - filters, - metricTables: isMetricSource ? source.metricTables : undefined, - }); - } - } - }, [source, chart, dateRange, granularity, filters]); - - const [hovered, setHovered] = useState(false); - - const alert = chart.config.alert; - const alertIndicatorColor = useMemo(() => { - if (!alert) { - return 'transparent'; - } - if (alert.state === AlertState.OK) { - return 'green'; - } - if (alert.silenced?.at) { - return 'yellow'; - } - return 'red'; - }, [alert]); - - const alertTooltip = useMemo(() => { - if (!alert) { - return 'Add alert'; - } - let tooltip = `Has alert and is in ${alert.state} state`; - if (alert.silenced?.at) { - const silencedAt = new Date(alert.silenced.at); - // eslint-disable-next-line no-restricted-syntax - tooltip += `. Ack'd ${formatRelative(silencedAt, new Date())}`; - } - return tooltip; - }, [alert]); - - const filterWarning = useMemo(() => { - const doFiltersExist = !!filters?.filter( - f => (f.type === 'lucene' || f.type === 'sql') && f.condition.trim(), - )?.length; - const doLuceneFiltersExist = !!filters?.filter( - f => f.type === 'lucene' && f.condition.trim(), - )?.length; - - if ( - !doFiltersExist || - !queriedConfig || - !isRawSqlChartConfig(queriedConfig) - ) - return null; - - const isMissingSourceForFiltering = !queriedConfig.source; - const isMissingFiltersMacro = - !queriedConfig.sqlTemplate.includes('$__filters'); - const isMetricsSourceWithLuceneFilter = - source?.kind === SourceKind.Metric && doLuceneFiltersExist; - - if ( - !isMissingSourceForFiltering && - !isMissingFiltersMacro && - !isMetricsSourceWithLuceneFilter - ) - return null; - - const message = isMissingFiltersMacro - ? 'Filters are not applied because the SQL does not include the required $__filters macro' - : isMetricsSourceWithLuceneFilter - ? 'Lucene filters are not applied because they are not supported for metrics sources.' - : 'Filters are not applied because no Source is set for this chart'; - - return ( - - - - ); - }, [filters, queriedConfig, source]); - - const hoverToolbar = useMemo(() => { - const isRawSql = isRawSqlSavedChartConfig(chart.config); - const displayTypeSupportsAlerts = isRawSql - ? displayTypeSupportsRawSqlAlerts(chart.config.displayType) - : displayTypeSupportsBuilderAlerts(chart.config.displayType); - return ( - e.stopPropagation()} - key="hover-toolbar" - my={4} // Margin to ensure that the Alert Indicator doesn't clip on non-Line/Bar display types - style={{ visibility: hovered ? 'visible' : 'hidden' }} - > - {displayTypeSupportsAlerts && ( - +} - mr={4} - > - - - - - - - )} - - - - - { - e.stopPropagation(); - setIsFullscreen(true); - }} - title="View Fullscreen (f)" - > - - - - - - {onMoveToGroup && moveTargets && moveTargets.length > 0 && ( - - - - - - - - - - Move to Group - {chart.containerId && ( - onMoveToGroup(undefined)}> - (Ungrouped) - - )} - {moveTargets - .filter( - t => - !( - t.containerId === chart.containerId && - t.tabId === chart.tabId - ), - ) - .map(t => ( - onMoveToGroup(t.containerId, t.tabId)} - > - {t.allTabs ? ( - - {t.allTabs.map((tab, i) => ( - - {i > 0 && ( - - {' | '} - - )} - - {tab.title} - - - ))} - - ) : ( - t.label - )} - - ))} - - - )} - - - - - ); - }, [ - alert, - alertIndicatorColor, - alertTooltip, - moveTargets, - chart.config, - chart.id, - chart.containerId, - chart.tabId, - hovered, - onDeleteClick, - onDuplicateClick, - onEditClick, - onMoveToGroup, - ]); - - const title = useMemo( - () => ( - - {chart.config.name} - - ), - [chart.config.name], - ); - - // Render chart content (used in both tile and fullscreen views) - const renderChartContent = useCallback( - (hideToolbar: boolean = false, isFullscreenView: boolean = false) => { - const toolbar = hideToolbar - ? [filterWarning] - : [hoverToolbar, filterWarning]; - const keyPrefix = isFullscreenView ? 'fullscreen' : 'tile'; - - // Markdown charts may not have queriedConfig, if config.source is not set - const effectiveMarkdownConfig = queriedConfig ?? chart.config; - - return ( - - An error occurred while rendering the chart. -
- } - > - {isSourceMissing ? ( - - - - The data source for this tile no longer exists. Edit the - tile to select a new source. - - - - ) : isSourceUnset ? ( - - - - The data source for this tile is not set. Edit the tile to - select a data source. - - - - ) : ( - <> - {(queriedConfig?.displayType === DisplayType.Line || - queriedConfig?.displayType === DisplayType.StackedBar) && ( - { - onUpdateChart?.({ - ...chart, - config: { - ...chart.config, - displayType, - }, - }); - }} - /> - )} - {queriedConfig?.displayType === DisplayType.Table && ( - - - buildTableRowSearchUrl({ - row, - source, - config: queriedConfig, - dateRange: dateRange, - }) - : undefined - } - /> - - )} - {queriedConfig?.displayType === DisplayType.Number && ( - - )} - {queriedConfig?.displayType === DisplayType.Pie && ( - - )} - {queriedConfig?.displayType === DisplayType.Heatmap && - isBuilderChartConfig(queriedConfig) && ( - - )} - {effectiveMarkdownConfig?.displayType === - DisplayType.Markdown && - 'markdown' in effectiveMarkdownConfig && ( - - )} - {queriedConfig?.displayType === DisplayType.Search && - isBuilderChartConfig(queriedConfig) && - isBuilderSavedChartConfig(chart.config) && ( - - - - )} - - )} - - ); - }, - [ - hoverToolbar, - queriedConfig, - title, - chart, - onTimeRangeSelect, - onUpdateChart, - source, - dateRange, - filterWarning, - isSourceMissing, - isSourceUnset, - ], - ); - - return ( - <> -
{ - setHovered(true); - setIsFocused(true); - }} - onMouseLeave={() => { - setHovered(false); - setIsFocused(false); - }} - key={chart.id} - ref={ref} - style={{ - ...style, - ...(isSelected - ? { - outline: '2px solid var(--color-outline-focus)', - outlineOffset: -2, - } - : {}), - }} - onClick={e => { - if (e.shiftKey && onSelect) { - e.preventDefault(); - onSelect(chart.id); - } - }} - onMouseDown={onMouseDown} - onMouseUp={onMouseUp} - onTouchEnd={onTouchEnd} - > - {hovered && ( -
- )} -
e.stopPropagation()} - > - {renderChartContent()} -
- {children} -
- - {/* Fullscreen Modal */} - setIsFullscreen(false)} - > - {isFullscreen && renderChartContent(true, true)} - - - ); - }, -); - const EditTileModal = ({ dashboardId, chart, @@ -1451,7 +759,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const renderTileComponent = useCallback( (chart: Tile) => ( - void; + onEditClick: () => void; + onAddAlertClick?: () => void; + onDeleteClick: () => void; + onUpdateChart?: (chart: Tile) => void; + onMoveToGroup?: (containerId: string | undefined, tabId?: string) => void; + moveTargets?: MoveTarget[]; + onSettled?: () => void; + granularity: SQLInterval | undefined; + onTimeRangeSelect: (start: Date, end: Date) => void; + filters?: Filter[]; + + // Properties forwarded by grid layout + className?: string; + style?: React.CSSProperties; + onMouseDown?: (e: React.MouseEvent) => void; + onMouseUp?: (e: React.MouseEvent) => void; + onTouchEnd?: (e: React.TouchEvent) => void; + children?: React.ReactNode; // Resizer tooltip + isHighlighted?: boolean; + isSelected?: boolean; + onSelect?: (tileId: string) => void; +}; + +export const DashboardTile = forwardRef( + ( + { + chart, + dateRange, + onDuplicateClick, + onEditClick, + onDeleteClick, + onUpdateChart, + onMoveToGroup, + moveTargets, + granularity, + onTimeRangeSelect, + filters, + + // Properties forwarded by grid layout + className, + style, + onMouseDown, + onMouseUp, + onTouchEnd, + children, + isHighlighted, + isSelected, + onSelect, + }: DashboardTileProps, + ref: ForwardedRef, + ) => { + const [isFullscreen, setIsFullscreen] = useState(false); + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + if (isHighlighted) { + document + .getElementById(`chart-${chart.id}`) + ?.scrollIntoView({ behavior: 'smooth' }); + } + }, [chart.id, isHighlighted]); + + // YouTube-style 'f' key shortcut for fullscreen toggle + useHotkeys([['f', () => isFocused && setIsFullscreen(prev => !prev)]]); + + const [queriedConfig, setQueriedConfig] = useState< + ChartConfigWithDateRange | undefined + >(undefined); + + const { data: source, isFetched: isSourceFetched } = useSource({ + id: chart.config.source, + }); + + const isSourceMissing = + !!chart.config.source && isSourceFetched && source == null; + const isSourceUnset = + !!chart.config && + isBuilderSavedChartConfig(chart.config) && + displayTypeRequiresSource(chart.config.displayType) && + !chart.config.source; + + useEffect(() => { + if (isRawSqlSavedChartConfig(chart.config)) { + // Some raw SQL charts don't have a source + if (!chart.config.source) { + setQueriedConfig({ + ...chart.config, + dateRange, + granularity, + filters, + }); + } else if (source != null) { + setQueriedConfig({ + ...chart.config, + // Populate these columns from the source to support Lucene-based filters and metric table macros + ...pick(source, [ + 'implicitColumnExpression', + 'from', + 'metricTables', + ]), + sampleWeightExpression: getSampleWeightExpression(source), + dateRange, + granularity, + filters, + }); + } + + return; + } + + if (source != null && isBuilderSavedChartConfig(chart.config)) { + const isMetricSource = source.kind === SourceKind.Metric; + + // TODO: will need to update this when we allow for multiple metrics per chart + const firstSelect = chart.config.select[0]; + const metricType = + isMetricSource && typeof firstSelect !== 'string' + ? firstSelect?.metricType + : undefined; + const tableName = getMetricTableName(source, metricType); + if (source.connection) { + setQueriedConfig({ + ...chart.config, + connection: source.connection, + dateRange, + granularity, + timestampValueExpression: source.timestampValueExpression, + from: { + databaseName: source.from?.databaseName || 'default', + tableName: tableName || '', + }, + implicitColumnExpression: + isLogSource(source) || isTraceSource(source) + ? source.implicitColumnExpression + : undefined, + sampleWeightExpression: getSampleWeightExpression(source), + filters, + metricTables: isMetricSource ? source.metricTables : undefined, + }); + } + } + }, [source, chart, dateRange, granularity, filters]); + + const [hovered, setHovered] = useState(false); + + const alert = chart.config.alert; + const alertIndicatorColor = useMemo(() => { + if (!alert) { + return 'transparent'; + } + if (alert.state === AlertState.OK) { + return 'green'; + } + if (alert.silenced?.at) { + return 'yellow'; + } + return 'red'; + }, [alert]); + + const alertTooltip = useMemo(() => { + if (!alert) { + return 'Add alert'; + } + let tooltip = `Has alert and is in ${alert.state} state`; + if (alert.silenced?.at) { + const silencedAt = new Date(alert.silenced.at); + // eslint-disable-next-line no-restricted-syntax + tooltip += `. Ack'd ${formatRelative(silencedAt, new Date())}`; + } + return tooltip; + }, [alert]); + + const filterWarning = useMemo(() => { + const doFiltersExist = !!filters?.filter( + f => (f.type === 'lucene' || f.type === 'sql') && f.condition.trim(), + )?.length; + const doLuceneFiltersExist = !!filters?.filter( + f => f.type === 'lucene' && f.condition.trim(), + )?.length; + + if ( + !doFiltersExist || + !queriedConfig || + !isRawSqlChartConfig(queriedConfig) + ) + return null; + + const isMissingSourceForFiltering = !queriedConfig.source; + const isMissingFiltersMacro = + !queriedConfig.sqlTemplate.includes('$__filters'); + const isMetricsSourceWithLuceneFilter = + source?.kind === SourceKind.Metric && doLuceneFiltersExist; + + if ( + !isMissingSourceForFiltering && + !isMissingFiltersMacro && + !isMetricsSourceWithLuceneFilter + ) + return null; + + const message = isMissingFiltersMacro + ? 'Filters are not applied because the SQL does not include the required $__filters macro' + : isMetricsSourceWithLuceneFilter + ? 'Lucene filters are not applied because they are not supported for metrics sources.' + : 'Filters are not applied because no Source is set for this chart'; + + return ( + + + + ); + }, [filters, queriedConfig, source]); + + const hoverToolbar = useMemo(() => { + const isRawSql = isRawSqlSavedChartConfig(chart.config); + const displayTypeSupportsAlerts = isRawSql + ? displayTypeSupportsRawSqlAlerts(chart.config.displayType) + : displayTypeSupportsBuilderAlerts(chart.config.displayType); + return ( + e.stopPropagation()} + key="hover-toolbar" + my={4} // Margin to ensure that the Alert Indicator doesn't clip on non-Line/Bar display types + style={{ visibility: hovered ? 'visible' : 'hidden' }} + > + {displayTypeSupportsAlerts && ( + +} + mr={4} + > + + + + + + + )} + + + + + { + e.stopPropagation(); + setIsFullscreen(true); + }} + title="View Fullscreen (f)" + > + + + + + + {onMoveToGroup && moveTargets && moveTargets.length > 0 && ( + + + + + + + + + + Move to Group + {chart.containerId && ( + onMoveToGroup(undefined)}> + (Ungrouped) + + )} + {moveTargets + .filter( + t => + !( + t.containerId === chart.containerId && + t.tabId === chart.tabId + ), + ) + .map(t => ( + onMoveToGroup(t.containerId, t.tabId)} + > + {t.allTabs ? ( + + {t.allTabs.map((tab, i) => ( + + {i > 0 && ( + + {' | '} + + )} + + {tab.title} + + + ))} + + ) : ( + t.label + )} + + ))} + + + )} + + + + + ); + }, [ + alert, + alertIndicatorColor, + alertTooltip, + moveTargets, + chart.config, + chart.id, + chart.containerId, + chart.tabId, + hovered, + onDeleteClick, + onDuplicateClick, + onEditClick, + onMoveToGroup, + ]); + + const title = useMemo( + () => ( + + {chart.config.name} + + ), + [chart.config.name], + ); + + // Render chart content (used in both tile and fullscreen views) + const renderChartContent = useCallback( + (hideToolbar: boolean = false, isFullscreenView: boolean = false) => { + const toolbar = hideToolbar + ? [filterWarning] + : [hoverToolbar, filterWarning]; + const keyPrefix = isFullscreenView ? 'fullscreen' : 'tile'; + + // Markdown charts may not have queriedConfig, if config.source is not set + const effectiveMarkdownConfig = queriedConfig ?? chart.config; + + return ( + + An error occurred while rendering the chart. +
+ } + > + {isSourceMissing ? ( + + + + The data source for this tile no longer exists. Edit the + tile to select a new source. + + + + ) : isSourceUnset ? ( + + + + The data source for this tile is not set. Edit the tile to + select a data source. + + + + ) : ( + <> + {(queriedConfig?.displayType === DisplayType.Line || + queriedConfig?.displayType === DisplayType.StackedBar) && ( + { + onUpdateChart?.({ + ...chart, + config: { + ...chart.config, + displayType, + }, + }); + }} + /> + )} + {queriedConfig?.displayType === DisplayType.Table && ( + + + buildTableRowSearchUrl({ + row, + source, + config: queriedConfig, + dateRange: dateRange, + }) + : undefined + } + /> + + )} + {queriedConfig?.displayType === DisplayType.Number && ( + + )} + {queriedConfig?.displayType === DisplayType.Pie && ( + + )} + {queriedConfig?.displayType === DisplayType.Heatmap && + isBuilderChartConfig(queriedConfig) && ( + + )} + {effectiveMarkdownConfig?.displayType === + DisplayType.Markdown && + 'markdown' in effectiveMarkdownConfig && ( + + )} + {queriedConfig?.displayType === DisplayType.Search && + isBuilderChartConfig(queriedConfig) && + isBuilderSavedChartConfig(chart.config) && ( + + + + )} + + )} + + ); + }, + [ + hoverToolbar, + queriedConfig, + title, + chart, + onTimeRangeSelect, + onUpdateChart, + source, + dateRange, + filterWarning, + isSourceMissing, + isSourceUnset, + ], + ); + + return ( + <> +
{ + setHovered(true); + setIsFocused(true); + }} + onMouseLeave={() => { + setHovered(false); + setIsFocused(false); + }} + key={chart.id} + ref={ref} + style={{ + ...style, + ...(isSelected + ? { + outline: '2px solid var(--color-outline-focus)', + outlineOffset: -2, + } + : {}), + }} + onClick={e => { + if (e.shiftKey && onSelect) { + e.preventDefault(); + onSelect(chart.id); + } + }} + onMouseDown={onMouseDown} + onMouseUp={onMouseUp} + onTouchEnd={onTouchEnd} + > + {hovered && ( +
+ )} +
e.stopPropagation()} + > + {renderChartContent()} +
+ {children} +
+ + {/* Fullscreen Modal */} + setIsFullscreen(false)} + > + {isFullscreen && renderChartContent(true, true)} + + + ); + }, +); From 816f175517e8af1465bc33b3063e04c262d58ac2 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:51:04 +0200 Subject: [PATCH 04/14] refactor(app): extract dashboard tile editor modal Co-authored-by: Cursor --- .../src/DBDashboardPage/DBDashboardPage.tsx | 84 +---------------- .../app/src/DBDashboardPage/EditTileModal.tsx | 89 +++++++++++++++++++ 2 files changed, 90 insertions(+), 83 deletions(-) create mode 100644 packages/app/src/DBDashboardPage/EditTileModal.tsx diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx index 66f52e78e2..9ec5884a81 100644 --- a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -30,7 +30,6 @@ import { Flex, Group, Menu, - Modal, Paper, Text, Tooltip, @@ -62,7 +61,6 @@ import { DashboardDndProvider, type DragHandleProps, } from '@/components/DashboardDndContext'; -import EditTimeChartForm from '@/components/DBEditTimeChartForm'; import { FavoriteButton } from '@/components/FavoriteButton'; import { TimePicker } from '@/components/TimePicker'; import { @@ -99,9 +97,9 @@ import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; import { useConfirm } from '@/useConfirm'; import { FormatTime } from '@/useFormatTime'; import { getMetricTableName } from '@/utils'; -import { useZIndex, ZIndexContext } from '@/zIndex'; import { DashboardTile, type MoveTarget } from './DashboardTile'; +import { EditTileModal } from './EditTileModal'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; @@ -125,86 +123,6 @@ const whereLanguageParser = parseAsString.withDefault( typeof window !== 'undefined' ? (getStoredLanguage() ?? 'lucene') : 'lucene', ); -const EditTileModal = ({ - dashboardId, - chart, - onClose, - onSave, - isSaving, - dateRange, -}: { - dashboardId?: string; - chart: Tile | undefined; - onClose: () => void; - dateRange: [Date, Date]; - isSaving?: boolean; - onSave: (chart: Tile) => void; -}) => { - const contextZIndex = useZIndex(); - const modalZIndex = contextZIndex + 10; - const confirm = useConfirm(); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - - useEffect(() => { - if (chart != null) { - setHasUnsavedChanges(false); - } - }, [chart]); - - const handleClose = useCallback(() => { - if (isSaving) return; - if (hasUnsavedChanges) { - confirm( - 'You have unsaved changes. Discard them and close the editor?', - 'Discard', - ).then(ok => { - if (ok) { - // Reset dirty state before closing so any re-invocation of - // handleClose (e.g. from Mantine focus management after the - // confirm modal closes) doesn't re-show the confirm dialog. - setHasUnsavedChanges(false); - onClose(); - } - }); - } else { - onClose(); - } - }, [confirm, isSaving, hasUnsavedChanges, onClose]); - - return ( - - {chart != null && ( - - { - onSave({ - ...chart, - config: config, - }); - }} - onClose={handleClose} - onDirtyChange={setHasUnsavedChanges} - isDashboardForm - autoRun - /> - - )} - - ); -}; - const updateLayout = (newLayout: RGL.Layout[]) => { return (dashboard: Dashboard) => { for (const chart of dashboard.tiles) { diff --git a/packages/app/src/DBDashboardPage/EditTileModal.tsx b/packages/app/src/DBDashboardPage/EditTileModal.tsx new file mode 100644 index 0000000000..ea6a92cba8 --- /dev/null +++ b/packages/app/src/DBDashboardPage/EditTileModal.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Modal } from '@mantine/core'; + +import EditTimeChartForm from '@/components/DBEditTimeChartForm'; +import { type Tile } from '@/dashboard'; +import { useConfirm } from '@/useConfirm'; +import { useZIndex, ZIndexContext } from '@/zIndex'; + +type EditTileModalProps = { + dashboardId?: string; + chart: Tile | undefined; + onClose: () => void; + dateRange: [Date, Date]; + isSaving?: boolean; + onSave: (chart: Tile) => void; +}; + +export function EditTileModal({ + dashboardId, + chart, + onClose, + onSave, + isSaving, + dateRange, +}: EditTileModalProps) { + const contextZIndex = useZIndex(); + const modalZIndex = contextZIndex + 10; + const confirm = useConfirm(); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + useEffect(() => { + if (chart != null) { + setHasUnsavedChanges(false); + } + }, [chart]); + + const handleClose = useCallback(() => { + if (isSaving) return; + if (hasUnsavedChanges) { + confirm( + 'You have unsaved changes. Discard them and close the editor?', + 'Discard', + ).then(ok => { + if (ok) { + // Reset dirty state before closing so any re-invocation of + // handleClose (e.g. from Mantine focus management after the + // confirm modal closes) doesn't re-show the confirm dialog. + setHasUnsavedChanges(false); + onClose(); + } + }); + } else { + onClose(); + } + }, [confirm, isSaving, hasUnsavedChanges, onClose]); + + return ( + + {chart != null && ( + + { + onSave({ + ...chart, + config: config, + }); + }} + onClose={handleClose} + onDirtyChange={setHasUnsavedChanges} + isDashboardForm + autoRun + /> + + )} + + ); +} From 9fe0adff8a0d53c6326b75ea47fd5e41a98db9b5 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:51:44 +0200 Subject: [PATCH 05/14] refactor(app): extract dashboard container row Co-authored-by: Cursor --- .../src/DBDashboardPage/DBDashboardPage.tsx | 120 +---------------- .../DBDashboardPage/DashboardContainerRow.tsx | 124 ++++++++++++++++++ 2 files changed, 127 insertions(+), 117 deletions(-) create mode 100644 packages/app/src/DBDashboardPage/DashboardContainerRow.tsx diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx index 9ec5884a81..eeb5457781 100644 --- a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -52,11 +52,7 @@ import { } from '@tabler/icons-react'; import { ContactSupportText } from '@/components/ContactSupportText'; -import DashboardContainer from '@/components/DashboardContainer'; -import { - EmptyContainerPlaceholder, - SortableContainerWrapper, -} from '@/components/DashboardDndComponents'; +import { SortableContainerWrapper } from '@/components/DashboardDndComponents'; import { DashboardDndProvider, type DragHandleProps, @@ -69,9 +65,7 @@ import { useCreateDashboard, useDeleteDashboard, } from '@/dashboard'; -import useDashboardContainers, { - TabDeleteAction, -} from '@/hooks/useDashboardContainers'; +import useDashboardContainers from '@/hooks/useDashboardContainers'; import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; import OnboardingModal from '@/components/OnboardingModal'; @@ -98,6 +92,7 @@ import { useConfirm } from '@/useConfirm'; import { FormatTime } from '@/useFormatTime'; import { getMetricTableName } from '@/utils'; +import { DashboardContainerRow } from './DashboardContainerRow'; import { DashboardTile, type MoveTarget } from './DashboardTile'; import { EditTileModal } from './EditTileModal'; @@ -150,115 +145,6 @@ function downloadObjectAsJson(object: object, fileName = 'output') { downloadAnchorNode.remove(); } -function DashboardContainerRow({ - container, - containerTiles, - isCollapsed, - activeTabId, - alertingTabIds, - onToggleCollapse, - onToggleDefaultCollapsed, - onToggleCollapsible, - onToggleBordered, - onDeleteContainer, - onAddTile, - onAddTab, - onRenameTab, - onDeleteTab, - onRenameContainer, - onTabChange, - dragHandleProps, - makeLayoutChangeHandler, - tileToLayoutItem, - renderTileComponent, -}: { - container: DashboardContainerSchema; - containerTiles: Tile[]; - isCollapsed: boolean; - activeTabId: string | undefined; - alertingTabIds?: Set; - onToggleCollapse: () => void; - onToggleDefaultCollapsed: () => void; - onToggleCollapsible: () => void; - onToggleBordered: () => void; - onDeleteContainer: (action: 'ungroup' | 'delete') => void; - onAddTile: (containerId: string, tabId?: string) => void; - onAddTab: () => void; - onRenameTab: (tabId: string, newTitle: string) => void; - onDeleteTab: (tabId: string, action: TabDeleteAction) => void; - onRenameContainer: (newTitle: string) => void; - onTabChange: (tabId: string) => void; - dragHandleProps: DragHandleProps; - makeLayoutChangeHandler: (tiles: Tile[]) => (newLayout: RGL.Layout[]) => void; - tileToLayoutItem: (tile: Tile) => RGL.Layout; - renderTileComponent: (tile: Tile) => React.ReactNode; -}) { - const groupTabs = container.tabs ?? []; - const hasTabs = groupTabs.length >= 2; - // Tiles actually rendered inside RGL (active tab only for multi-tab - // containers). Handler must be built from these so RGL's `newLayout` and our - // `currentLayout` have matching sizes — otherwise every drag triggers a - // bogus diff + setDashboard write. - const visibleTiles = hasTabs - ? containerTiles.filter(t => t.tabId === activeTabId) - : containerTiles; - const layoutChangeHandler = useMemo( - () => makeLayoutChangeHandler(visibleTiles), - [makeLayoutChangeHandler, visibleTiles], - ); - - return ( - - onAddTile(container.id, hasTabs ? activeTabId : undefined) - } - activeTabId={activeTabId} - onTabChange={onTabChange} - onAddTab={onAddTab} - onRenameTab={onRenameTab} - onDeleteTab={onDeleteTab} - onRename={onRenameContainer} - dragHandleProps={dragHandleProps} - alertingTabIds={alertingTabIds} - > - {(currentTabId: string | undefined) => { - const visibleTiles = currentTabId - ? containerTiles.filter(t => t.tabId === currentTabId) - : containerTiles; - const visibleIsEmpty = visibleTiles.length === 0; - return ( - onAddTile(container.id, currentTabId)} - > - {visibleTiles.length > 0 && ( - - {visibleTiles.map(renderTileComponent)} - - )} - - ); - }} - - ); -} - function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const brandName = useBrandDisplayName(); const confirm = useConfirm(); diff --git a/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx b/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx new file mode 100644 index 0000000000..b60e132089 --- /dev/null +++ b/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx @@ -0,0 +1,124 @@ +import { useMemo } from 'react'; +import RGL, { WidthProvider } from 'react-grid-layout'; +import { DashboardContainer as DashboardContainerSchema } from '@hyperdx/common-utils/dist/types'; + +import DashboardContainer from '@/components/DashboardContainer'; +import { + EmptyContainerPlaceholder, +} from '@/components/DashboardDndComponents'; +import { type DragHandleProps } from '@/components/DashboardDndContext'; +import { type Tile } from '@/dashboard'; +import { type TabDeleteAction } from '@/hooks/useDashboardContainers'; + +const ReactGridLayout = WidthProvider(RGL); + +type DashboardContainerRowProps = { + container: DashboardContainerSchema; + containerTiles: Tile[]; + isCollapsed: boolean; + activeTabId: string | undefined; + alertingTabIds?: Set; + onToggleCollapse: () => void; + onToggleDefaultCollapsed: () => void; + onToggleCollapsible: () => void; + onToggleBordered: () => void; + onDeleteContainer: (action: 'ungroup' | 'delete') => void; + onAddTile: (containerId: string, tabId?: string) => void; + onAddTab: () => void; + onRenameTab: (tabId: string, newTitle: string) => void; + onDeleteTab: (tabId: string, action: TabDeleteAction) => void; + onRenameContainer: (newTitle: string) => void; + onTabChange: (tabId: string) => void; + dragHandleProps: DragHandleProps; + makeLayoutChangeHandler: (tiles: Tile[]) => (newLayout: RGL.Layout[]) => void; + tileToLayoutItem: (tile: Tile) => RGL.Layout; + renderTileComponent: (tile: Tile) => React.ReactNode; +}; + +export function DashboardContainerRow({ + container, + containerTiles, + isCollapsed, + activeTabId, + alertingTabIds, + onToggleCollapse, + onToggleDefaultCollapsed, + onToggleCollapsible, + onToggleBordered, + onDeleteContainer, + onAddTile, + onAddTab, + onRenameTab, + onDeleteTab, + onRenameContainer, + onTabChange, + dragHandleProps, + makeLayoutChangeHandler, + tileToLayoutItem, + renderTileComponent, +}: DashboardContainerRowProps) { + const groupTabs = container.tabs ?? []; + const hasTabs = groupTabs.length >= 2; + // Tiles actually rendered inside RGL (active tab only for multi-tab + // containers). Handler must be built from these so RGL's `newLayout` and our + // `currentLayout` have matching sizes - otherwise every drag triggers a + // bogus diff + setDashboard write. + const visibleTiles = hasTabs + ? containerTiles.filter(t => t.tabId === activeTabId) + : containerTiles; + const layoutChangeHandler = useMemo( + () => makeLayoutChangeHandler(visibleTiles), + [makeLayoutChangeHandler, visibleTiles], + ); + + return ( + + onAddTile(container.id, hasTabs ? activeTabId : undefined) + } + activeTabId={activeTabId} + onTabChange={onTabChange} + onAddTab={onAddTab} + onRenameTab={onRenameTab} + onDeleteTab={onDeleteTab} + onRename={onRenameContainer} + dragHandleProps={dragHandleProps} + alertingTabIds={alertingTabIds} + > + {(currentTabId: string | undefined) => { + const visibleTiles = currentTabId + ? containerTiles.filter(t => t.tabId === currentTabId) + : containerTiles; + const visibleIsEmpty = visibleTiles.length === 0; + return ( + onAddTile(container.id, currentTabId)} + > + {visibleTiles.length > 0 && ( + + {visibleTiles.map(renderTileComponent)} + + )} + + ); + }} + + ); +} From 6c0aff657ffbe406e15d7b2f3ff3e7ec6aa56eb0 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:52:48 +0200 Subject: [PATCH 06/14] refactor(app): extract dashboard header Co-authored-by: Cursor --- .../src/DBDashboardPage/DBDashboardPage.tsx | 262 ++++-------------- .../src/DBDashboardPage/DashboardHeader.tsx | 222 +++++++++++++++ 2 files changed, 274 insertions(+), 210 deletions(-) create mode 100644 packages/app/src/DBDashboardPage/DashboardHeader.tsx diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx index eeb5457781..2e000cb014 100644 --- a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -1,9 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import Head from 'next/head'; -import Link from 'next/link'; import { useRouter } from 'next/router'; -import { formatDistanceToNow } from 'date-fns'; import produce from 'immer'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; import { ErrorBoundary } from 'react-error-boundary'; @@ -23,12 +21,9 @@ import { } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, - Anchor, Box, - Breadcrumbs, Button, Flex, - Group, Menu, Paper, Text, @@ -37,18 +32,11 @@ import { import { notifications } from '@mantine/notifications'; import { IconChartBar, - IconDeviceFloppy, - IconDotsVertical, - IconDownload, IconFilterEdit, IconPlayerPlay, IconPlus, IconRefresh, IconSquaresDiagonal, - IconTags, - IconTrash, - IconUpload, - IconX, } from '@tabler/icons-react'; import { ContactSupportText } from '@/components/ContactSupportText'; @@ -57,7 +45,6 @@ import { DashboardDndProvider, type DragHandleProps, } from '@/components/DashboardDndContext'; -import { FavoriteButton } from '@/components/FavoriteButton'; import { TimePicker } from '@/components/TimePicker'; import { Dashboard, @@ -72,7 +59,6 @@ import OnboardingModal from '@/components/OnboardingModal'; import SearchWhereInput, { getStoredLanguage, } from '@/components/SearchInput/SearchWhereInput'; -import { Tags } from '@/components/Tags'; import useDashboardFilters from '@/hooks/useDashboardFilters'; import { useDashboardRefresh } from '@/hooks/useDashboardRefresh'; import useTileSelection from '@/hooks/useTileSelection'; @@ -83,16 +69,15 @@ import { useConnections } from '@/connection'; import { useDashboard } from '@/dashboard'; import DashboardFilters from '@/DashboardFilters'; import DashboardFiltersModal from '@/DashboardFiltersModal'; -import { EditablePageName } from '@/EditablePageName'; import { GranularityPickerControlled } from '@/GranularityPicker'; import { withAppNav } from '@/layout'; import { useSources } from '@/source'; import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; import { useConfirm } from '@/useConfirm'; -import { FormatTime } from '@/useFormatTime'; import { getMetricTableName } from '@/utils'; import { DashboardContainerRow } from './DashboardContainerRow'; +import { DashboardHeader } from './DashboardHeader'; import { DashboardTile, type MoveTarget } from './DashboardTile'; import { EditTileModal } from './EditTileModal'; @@ -989,200 +974,57 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }} /> - {isLocalDashboard ? ( - <> - - - Dashboards - - - Temporary Dashboard - - - - - - This is a temporary dashboard and can not be saved. - - - - - - ) : ( - - - - Dashboards - - - {dashboard?.name ?? 'Untitled'} - - - {!isLocalDashboard && dashboard && ( - - {dashboard.createdBy && ( - - Created by{' '} - {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} - - )} - {dashboard.updatedAt && ( - - - {dashboard.updatedBy - ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` - : ''} - - } - > - {`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`} - - )} - - )} - - )} - - { - if (dashboard != null) { - setDashboard({ - ...dashboard, - name: editedName, - }); - } - }} - /> - - {!isLocalDashboard && dashboard?.id && ( - - )} - {!isLocalDashboard && dashboard?.id && ( - - - - )} - {!isLocalDashboard /* local dashboards cant be "deleted" */ && ( - - - - - - - - - {hasTiles && ( - } - onClick={() => { - if (!sources || !dashboard) { - notifications.show({ - color: 'red', - message: 'Export Failed', - }); - return; - } - downloadObjectAsJson( - convertToDashboardTemplate( - dashboard, - // TODO: fix this type issue - sources, - connections, - ), - dashboard?.name, - ); - }} - > - Export Dashboard - - )} - } - onClick={() => { - if (dashboard && !dashboard.tiles.length) { - router.push( - `/dashboards/import?dashboardId=${dashboard.id}`, - ); - } else { - router.push('/dashboards/import'); - } - }} - > - {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} - - - } - onClick={handleSaveQuery} - > - {hasSavedQueryAndFilterDefaults - ? 'Update Default Query & Filters' - : 'Save Query & Filters as Default'} - - {hasSavedQueryAndFilterDefaults && ( - } - color="red" - onClick={handleRemoveSavedQuery} - > - Remove Default Query & Filters - - )} - - } - color="red" - onClick={() => - deleteDashboard.mutate(dashboard?.id ?? '', { - onSuccess: () => { - router.push('/dashboards'); - }, - }) - } - > - Delete Dashboard - - - - )} - - {/* */} - + { + if (dashboard != null) { + setDashboard({ + ...dashboard, + name: editedName, + }); + } + }} + onUpdateTags={handleUpdateTags} + onExportDashboard={() => { + if (!sources || !dashboard) { + notifications.show({ + color: 'red', + message: 'Export Failed', + }); + return; + } + downloadObjectAsJson( + convertToDashboardTemplate( + dashboard, + // TODO: fix this type issue + sources, + connections, + ), + dashboard?.name, + ); + }} + onImportDashboard={() => { + if (dashboard && !dashboard.tiles.length) { + router.push(`/dashboards/import?dashboardId=${dashboard.id}`); + } else { + router.push('/dashboards/import'); + } + }} + onSaveQuery={handleSaveQuery} + onRemoveSavedQuery={handleRemoveSavedQuery} + onDeleteDashboard={() => + deleteDashboard.mutate(dashboard?.id ?? '', { + onSuccess: () => { + router.push('/dashboards'); + }, + }) + } + /> void; + onRenameDashboard: (editedName: string) => void; + onUpdateTags: (newTags: string[]) => void; + onExportDashboard: () => void; + onImportDashboard: () => void; + onSaveQuery: () => void; + onRemoveSavedQuery: () => void; + onDeleteDashboard: () => void; +}; + +export function DashboardHeader({ + dashboard, + dashboardHash, + isLocalDashboard, + hasTiles, + hasSavedQueryAndFilterDefaults, + onCreateDashboard, + onRenameDashboard, + onUpdateTags, + onExportDashboard, + onImportDashboard, + onSaveQuery, + onRemoveSavedQuery, + onDeleteDashboard, +}: DashboardHeaderProps) { + return ( + <> + {isLocalDashboard ? ( + <> + + + Dashboards + + + Temporary Dashboard + + + + + + This is a temporary dashboard and can not be saved. + + + + + + ) : ( + + + + Dashboards + + + {dashboard?.name ?? 'Untitled'} + + + {!isLocalDashboard && dashboard && ( + + {dashboard.createdBy && ( + + Created by{' '} + {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} + + )} + {dashboard.updatedAt && ( + + + {dashboard.updatedBy + ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` + : ''} + + } + > + {`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`} + + )} + + )} + + )} + + + + {!isLocalDashboard && dashboard?.id && ( + + )} + {!isLocalDashboard && dashboard?.id && ( + + + + )} + {!isLocalDashboard /* local dashboards cant be "deleted" */ && ( + + + + + + + + + {hasTiles && ( + } + onClick={onExportDashboard} + > + Export Dashboard + + )} + } + onClick={onImportDashboard} + > + {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} + + + } + onClick={onSaveQuery} + > + {hasSavedQueryAndFilterDefaults + ? 'Update Default Query & Filters' + : 'Save Query & Filters as Default'} + + {hasSavedQueryAndFilterDefaults && ( + } + color="red" + onClick={onRemoveSavedQuery} + > + Remove Default Query & Filters + + )} + + } + color="red" + onClick={onDeleteDashboard} + > + Delete Dashboard + + + + )} + + {/* */} + + + ); +} From 1e21e03fd5eb5ab50758a05010fde200f45dbd00 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:54:22 +0200 Subject: [PATCH 07/14] refactor(app): extract dashboard toolbar Co-authored-by: Cursor --- .../src/DBDashboardPage/DBDashboardPage.tsx | 152 ++++-------------- .../src/DBDashboardPage/DashboardToolbar.tsx | 136 ++++++++++++++++ packages/app/src/DBDashboardPage/types.ts | 11 ++ 3 files changed, 180 insertions(+), 119 deletions(-) create mode 100644 packages/app/src/DBDashboardPage/DashboardToolbar.tsx create mode 100644 packages/app/src/DBDashboardPage/types.ts diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx index 2e000cb014..0f2aff6130 100644 --- a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -15,27 +15,13 @@ import { DashboardContainer as DashboardContainerSchema, DashboardFilter, Filter, - SearchCondition, - SearchConditionLanguage, SQLInterval, } from '@hyperdx/common-utils/dist/types'; -import { - ActionIcon, - Box, - Button, - Flex, - Menu, - Paper, - Text, - Tooltip, -} from '@mantine/core'; +import { Box, Button, Flex, Menu, Paper, Text } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { IconChartBar, - IconFilterEdit, - IconPlayerPlay, IconPlus, - IconRefresh, IconSquaresDiagonal, } from '@tabler/icons-react'; @@ -45,7 +31,6 @@ import { DashboardDndProvider, type DragHandleProps, } from '@/components/DashboardDndContext'; -import { TimePicker } from '@/components/TimePicker'; import { Dashboard, type Tile, @@ -56,9 +41,7 @@ import useDashboardContainers from '@/hooks/useDashboardContainers'; import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; import OnboardingModal from '@/components/OnboardingModal'; -import SearchWhereInput, { - getStoredLanguage, -} from '@/components/SearchInput/SearchWhereInput'; +import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput'; import useDashboardFilters from '@/hooks/useDashboardFilters'; import { useDashboardRefresh } from '@/hooks/useDashboardRefresh'; import useTileSelection from '@/hooks/useTileSelection'; @@ -69,7 +52,6 @@ import { useConnections } from '@/connection'; import { useDashboard } from '@/dashboard'; import DashboardFilters from '@/DashboardFilters'; import DashboardFiltersModal from '@/DashboardFiltersModal'; -import { GranularityPickerControlled } from '@/GranularityPicker'; import { withAppNav } from '@/layout'; import { useSources } from '@/source'; import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; @@ -78,7 +60,9 @@ import { getMetricTableName } from '@/utils'; import { DashboardContainerRow } from './DashboardContainerRow'; import { DashboardHeader } from './DashboardHeader'; +import { DashboardToolbar } from './DashboardToolbar'; import { DashboardTile, type MoveTarget } from './DashboardTile'; +import { DashboardQueryFormValues } from './types'; import { EditTileModal } from './EditTileModal'; import 'react-grid-layout/css/styles.css'; @@ -232,20 +216,17 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const [isLive, setIsLive] = useState(false); - const { control, setValue, getValues, handleSubmit } = useForm<{ - granularity: SQLInterval | 'auto'; - where: SearchCondition; - whereLanguage: SearchConditionLanguage; - }>({ - defaultValues: { - granularity: granularity ?? 'auto', - where: where ?? '', - whereLanguage: - (whereLanguage as SearchConditionLanguage) ?? - getStoredLanguage() ?? - 'lucene', - }, - }); + const { control, setValue, getValues, handleSubmit } = + useForm({ + defaultValues: { + granularity: granularity ?? 'auto', + where: where ?? '', + whereLanguage: + whereLanguage === 'sql' || whereLanguage === 'lucene' + ? whereLanguage + : (getStoredLanguage() ?? 'lucene'), + }, + }); const watchedGranularity = useWatch({ control, name: 'granularity' }); @@ -278,8 +259,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const onSubmit = useCallback(() => { onSearch(displayedTimeInputValue); handleSubmit(data => { - setWhere(data.where as SearchCondition); - setWhereLanguage((data.whereLanguage as SearchConditionLanguage) ?? null); + setWhere(data.where); + setWhereLanguage(data.whereLanguage ?? null); })(); }, [ displayedTimeInputValue, @@ -1025,89 +1006,22 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }) } /> - { - e.preventDefault(); - onSubmit(); - }} - > - - setValue('whereLanguage', lang) - } - label="WHERE" - enableHotkey - allowMultiline - minWidth={300} - data-testid="search-input" - /> - { - onSearch(range); - }} - /> - - - - - - - - - - - setShowFiltersModal(true)} - data-testid="edit-filters-button" - size="input-sm" - > - - - - - + setShowFiltersModal(true)} + /> ; + setValue: UseFormSetValue; + displayedTimeInputValue: string; + setDisplayedTimeInputValue: Dispatch>; + onSubmit: () => void; + onSearch: (range: string) => void; + isRefreshEnabled: boolean; + granularityOverride: SQLInterval | undefined; + isLive: boolean; + setIsLive: Dispatch>; + refresh: () => void; + manualRefreshCooloff: boolean; + onOpenFilters: () => void; +}; + +export function DashboardToolbar({ + tableConnections, + control, + setValue, + displayedTimeInputValue, + setDisplayedTimeInputValue, + onSubmit, + onSearch, + isRefreshEnabled, + granularityOverride, + isLive, + setIsLive, + refresh, + manualRefreshCooloff, + onOpenFilters, +}: DashboardToolbarProps) { + return ( + { + e.preventDefault(); + onSubmit(); + }} + > + + setValue('whereLanguage', lang) + } + label="WHERE" + enableHotkey + allowMultiline + minWidth={300} + data-testid="search-input" + /> + { + onSearch(range); + }} + /> + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/app/src/DBDashboardPage/types.ts b/packages/app/src/DBDashboardPage/types.ts new file mode 100644 index 0000000000..dbeec12833 --- /dev/null +++ b/packages/app/src/DBDashboardPage/types.ts @@ -0,0 +1,11 @@ +import { + SearchCondition, + SearchConditionLanguage, + SQLInterval, +} from '@hyperdx/common-utils/dist/types'; + +export type DashboardQueryFormValues = { + granularity: SQLInterval | 'auto'; + where: SearchCondition; + whereLanguage: SearchConditionLanguage; +}; From ef3f68b63d848ff738b663da763d244b85395075 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:55:31 +0200 Subject: [PATCH 08/14] refactor(app): extract dashboard grid Co-authored-by: Cursor --- .../src/DBDashboardPage/DBDashboardPage.tsx | 192 +++------------ .../app/src/DBDashboardPage/DashboardGrid.tsx | 232 ++++++++++++++++++ 2 files changed, 265 insertions(+), 159 deletions(-) create mode 100644 packages/app/src/DBDashboardPage/DashboardGrid.tsx diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx index 0f2aff6130..c65c9fb809 100644 --- a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -4,8 +4,7 @@ import Head from 'next/head'; import { useRouter } from 'next/router'; import produce from 'immer'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; -import { ErrorBoundary } from 'react-error-boundary'; -import RGL, { WidthProvider } from 'react-grid-layout'; +import RGL from 'react-grid-layout'; import { useForm, useWatch } from 'react-hook-form'; import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; import { convertToDashboardTemplate } from '@hyperdx/common-utils/dist/core/utils'; @@ -17,20 +16,10 @@ import { Filter, SQLInterval, } from '@hyperdx/common-utils/dist/types'; -import { Box, Button, Flex, Menu, Paper, Text } from '@mantine/core'; +import { Box, Text } from '@mantine/core'; import { notifications } from '@mantine/notifications'; -import { - IconChartBar, - IconPlus, - IconSquaresDiagonal, -} from '@tabler/icons-react'; import { ContactSupportText } from '@/components/ContactSupportText'; -import { SortableContainerWrapper } from '@/components/DashboardDndComponents'; -import { - DashboardDndProvider, - type DragHandleProps, -} from '@/components/DashboardDndContext'; import { Dashboard, type Tile, @@ -58,7 +47,7 @@ import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; import { useConfirm } from '@/useConfirm'; import { getMetricTableName } from '@/utils'; -import { DashboardContainerRow } from './DashboardContainerRow'; +import { DashboardGrid } from './DashboardGrid'; import { DashboardHeader } from './DashboardHeader'; import { DashboardToolbar } from './DashboardToolbar'; import { DashboardTile, type MoveTarget } from './DashboardTile'; @@ -68,8 +57,6 @@ import { EditTileModal } from './EditTileModal'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; -const ReactGridLayout = WidthProvider(RGL); - const tileToLayoutItem = (chart: Tile): RGL.Layout => ({ i: chart.id, x: chart.x, @@ -1028,149 +1015,36 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onSetFilterValue={setFilterValue} dateRange={searchedTimeRange} /> - {/* Selection indicator */} - {selectedTileIds.size > 0 && ( - - - - {selectedTileIds.size} tile{selectedTileIds.size > 1 ? 's' : ''}{' '} - selected - - - - - - )} - - {dashboard != null && dashboard.tiles != null ? ( - - An error occurred while rendering the dashboard. -
- } - > - - {ungroupedTiles.length > 0 && ( - - {ungroupedTiles.map(renderTileComponent)} - - )} - {containers.map(container => ( - - {(dragHandleProps: DragHandleProps) => ( - - handleToggleCollapse(container.id) - } - onToggleDefaultCollapsed={() => - handleToggleDefaultCollapsed(container.id) - } - onToggleCollapsible={() => - handleToggleCollapsible(container.id) - } - onToggleBordered={() => - handleToggleBordered(container.id) - } - onDeleteContainer={action => - handleDeleteContainer(container.id, action) - } - onAddTile={onAddTile} - onAddTab={() => { - const newTabId = handleAddTab(container.id); - if (newTabId) handleTabChange(container.id, newTabId); - }} - onRenameTab={(tabId, title) => - handleRenameTab(container.id, tabId, title) - } - onDeleteTab={(tabId, action) => - handleDeleteTab(container.id, tabId, action) - } - onRenameContainer={title => - handleRenameContainer(container.id, title) - } - onTabChange={tabId => - handleTabChange(container.id, tabId) - } - dragHandleProps={dragHandleProps} - makeLayoutChangeHandler={makeOnLayoutChange} - tileToLayoutItem={tileToLayoutItem} - renderTileComponent={renderTileComponent} - /> - )} - - ))} - - - ) : null} - - - - - - - } - onClick={() => onAddTile()} - > - New Tile - - - } - onClick={() => handleAddContainer()} - > - New Group - - - + setShowFiltersModal(false)} diff --git a/packages/app/src/DBDashboardPage/DashboardGrid.tsx b/packages/app/src/DBDashboardPage/DashboardGrid.tsx new file mode 100644 index 0000000000..0f9a8e6459 --- /dev/null +++ b/packages/app/src/DBDashboardPage/DashboardGrid.tsx @@ -0,0 +1,232 @@ +import { Dispatch, SetStateAction } from 'react'; +import RGL, { WidthProvider } from 'react-grid-layout'; +import { ErrorBoundary } from 'react-error-boundary'; +import { DashboardContainer as DashboardContainerSchema } from '@hyperdx/common-utils/dist/types'; +import { Box, Button, Flex, Menu, Paper, Text } from '@mantine/core'; +import { + IconChartBar, + IconPlus, + IconSquaresDiagonal, +} from '@tabler/icons-react'; + +import { SortableContainerWrapper } from '@/components/DashboardDndComponents'; +import { + DashboardDndProvider, + type DragHandleProps, +} from '@/components/DashboardDndContext'; +import { type Tile } from '@/dashboard'; +import { type TabDeleteAction } from '@/hooks/useDashboardContainers'; + +import { DashboardContainerRow } from './DashboardContainerRow'; + +const ReactGridLayout = WidthProvider(RGL); + +type DashboardGridProps = { + canRenderDashboard: boolean; + hasTiles: boolean; + containers: DashboardContainerSchema[]; + ungroupedTiles: Tile[]; + selectedTileIds: Set; + setSelectedTileIds: Dispatch>>; + onGroupSelected: () => void; + onReorderContainers: (fromIndex: number, toIndex: number) => void; + onUngroupedLayoutChange: (newLayout: RGL.Layout[]) => void; + renderTileComponent: (tile: Tile) => React.ReactNode; + tileToLayoutItem: (tile: Tile) => RGL.Layout; + tilesByContainerId: Map; + isContainerCollapsed: (container: DashboardContainerSchema) => boolean; + getActiveTabId: (container: DashboardContainerSchema) => string | undefined; + alertingTabIdsByContainer: Map>; + onToggleCollapse: (containerId: string) => void; + onToggleDefaultCollapsed: (containerId: string) => void; + onToggleCollapsible: (containerId: string) => void; + onToggleBordered: (containerId: string) => void; + onDeleteContainer: ( + containerId: string, + action: 'ungroup' | 'delete', + ) => void; + onAddTile: (containerId?: string, tabId?: string) => void; + onAddContainer: () => void; + onAddTab: (containerId: string) => string | undefined; + onRenameTab: (containerId: string, tabId: string, title: string) => void; + onDeleteTab: ( + containerId: string, + tabId: string, + action: TabDeleteAction, + ) => void; + onRenameContainer: (containerId: string, title: string) => void; + onTabChange: (containerId: string, tabId: string) => void; + makeLayoutChangeHandler: (tiles: Tile[]) => (newLayout: RGL.Layout[]) => void; +}; + +export function DashboardGrid({ + canRenderDashboard, + hasTiles, + containers, + ungroupedTiles, + selectedTileIds, + setSelectedTileIds, + onGroupSelected, + onReorderContainers, + onUngroupedLayoutChange, + renderTileComponent, + tileToLayoutItem, + tilesByContainerId, + isContainerCollapsed, + getActiveTabId, + alertingTabIdsByContainer, + onToggleCollapse, + onToggleDefaultCollapsed, + onToggleCollapsible, + onToggleBordered, + onDeleteContainer, + onAddTile, + onAddContainer, + onAddTab, + onRenameTab, + onDeleteTab, + onRenameContainer, + onTabChange, + makeLayoutChangeHandler, +}: DashboardGridProps) { + return ( + <> + {selectedTileIds.size > 0 && ( + + + + {selectedTileIds.size} tile{selectedTileIds.size > 1 ? 's' : ''}{' '} + selected + + + + + + )} + + {canRenderDashboard ? ( + + An error occurred while rendering the dashboard. +
+ } + > + + {ungroupedTiles.length > 0 && ( + + {ungroupedTiles.map(renderTileComponent)} + + )} + {containers.map(container => ( + + {(dragHandleProps: DragHandleProps) => ( + onToggleCollapse(container.id)} + onToggleDefaultCollapsed={() => + onToggleDefaultCollapsed(container.id) + } + onToggleCollapsible={() => + onToggleCollapsible(container.id) + } + onToggleBordered={() => onToggleBordered(container.id)} + onDeleteContainer={action => + onDeleteContainer(container.id, action) + } + onAddTile={onAddTile} + onAddTab={() => { + const newTabId = onAddTab(container.id); + if (newTabId) onTabChange(container.id, newTabId); + }} + onRenameTab={(tabId, title) => + onRenameTab(container.id, tabId, title) + } + onDeleteTab={(tabId, action) => + onDeleteTab(container.id, tabId, action) + } + onRenameContainer={title => + onRenameContainer(container.id, title) + } + onTabChange={tabId => onTabChange(container.id, tabId)} + dragHandleProps={dragHandleProps} + makeLayoutChangeHandler={makeLayoutChangeHandler} + tileToLayoutItem={tileToLayoutItem} + renderTileComponent={renderTileComponent} + /> + )} + + ))} + + + ) : null} + + + + + + + } + onClick={() => onAddTile()} + > + New Tile + + + } + onClick={onAddContainer} + > + New Group + + + + + ); +} From 8bf6739fb566fd2e6070507d38316b0d67fff61a Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:57:00 +0200 Subject: [PATCH 09/14] refactor(app): extract dashboard helpers Co-authored-by: Cursor --- .../src/DBDashboardPage/DBDashboardPage.tsx | 175 +++------------- .../app/src/DBDashboardPage/DashboardTile.tsx | 9 +- packages/app/src/DBDashboardPage/types.ts | 8 + packages/app/src/DBDashboardPage/utils.ts | 191 ++++++++++++++++++ 4 files changed, 232 insertions(+), 151 deletions(-) create mode 100644 packages/app/src/DBDashboardPage/utils.ts diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx index c65c9fb809..024f38cccc 100644 --- a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -6,11 +6,8 @@ import produce from 'immer'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; import RGL from 'react-grid-layout'; import { useForm, useWatch } from 'react-hook-form'; -import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; import { convertToDashboardTemplate } from '@hyperdx/common-utils/dist/core/utils'; -import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards'; import { - AlertState, DashboardContainer as DashboardContainerSchema, DashboardFilter, Filter, @@ -45,28 +42,28 @@ import { withAppNav } from '@/layout'; import { useSources } from '@/source'; import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; import { useConfirm } from '@/useConfirm'; -import { getMetricTableName } from '@/utils'; import { DashboardGrid } from './DashboardGrid'; import { DashboardHeader } from './DashboardHeader'; import { DashboardToolbar } from './DashboardToolbar'; -import { DashboardTile, type MoveTarget } from './DashboardTile'; -import { DashboardQueryFormValues } from './types'; +import { DashboardTile } from './DashboardTile'; +import { DashboardQueryFormValues, type MoveTarget } from './types'; +import { + buildMoveTargets, + downloadObjectAsJson, + getAlertingTabIdsByContainer, + getDashboardTableConnections, + getTilesByContainerId, + getUngroupedTiles, + hasLayoutChanged, + tileToLayoutItem, + updateLayout, +} from './utils'; import { EditTileModal } from './EditTileModal'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; -const tileToLayoutItem = (chart: Tile): RGL.Layout => ({ - i: chart.id, - x: chart.x, - y: chart.y, - w: chart.w, - h: chart.h, - minH: 1, - minW: 1, -}); - // TODO: This is a hack to set the default time range const defaultTimeRange = parseTimeQuery('Past 1h', false) as [Date, Date]; @@ -74,33 +71,6 @@ const whereLanguageParser = parseAsString.withDefault( typeof window !== 'undefined' ? (getStoredLanguage() ?? 'lucene') : 'lucene', ); -const updateLayout = (newLayout: RGL.Layout[]) => { - return (dashboard: Dashboard) => { - for (const chart of dashboard.tiles) { - const newChartLayout = newLayout.find(layout => layout.i === chart.id); - if (newChartLayout) { - chart.x = newChartLayout.x; - chart.y = newChartLayout.y; - chart.w = newChartLayout.w; - chart.h = newChartLayout.h; - } - } - }; -}; - -// Download an object to users computer as JSON using specified name -function downloadObjectAsJson(object: object, fileName = 'output') { - const dataStr = - 'data:text/json;charset=utf-8,' + - encodeURIComponent(JSON.stringify(object)); - const downloadAnchorNode = document.createElement('a'); - downloadAnchorNode.setAttribute('href', dataStr); - downloadAnchorNode.setAttribute('download', fileName + '.json'); - document.body.appendChild(downloadAnchorNode); // required for firefox - downloadAnchorNode.click(); - downloadAnchorNode.remove(); -} - function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const brandName = useBrandDisplayName(); const confirm = useConfirm(); @@ -124,29 +94,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const { data: connections } = useConnections(); const [highlightedTileId] = useQueryState('highlightedTileId'); - const tableConnections = useMemo(() => { - if (!dashboard) return []; - const tc: TableConnection[] = []; - - for (const { config } of dashboard.tiles) { - if (!isBuilderSavedChartConfig(config)) continue; - const source = sources?.find(v => v.id === config.source); - if (!source) continue; - // TODO: will need to update this when we allow for multiple metrics per chart - const firstSelect = config.select[0]; - const metricType = - typeof firstSelect !== 'string' ? firstSelect?.metricType : undefined; - const tableName = getMetricTableName(source, metricType); - if (!tableName) continue; - tc.push({ - databaseName: source.from.databaseName, - tableName: tableName, - connectionId: source.connection, - }); - } - - return tc; - }, [dashboard, sources]); + const tableConnections = useMemo( + () => getDashboardTableConnections({ dashboard, sources }), + [dashboard, sources], + ); const [granularity, setGranularity] = useQueryState( 'granularity', @@ -447,32 +398,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); // Valid move targets: groups and individual tabs within groups - const moveTargetContainers = useMemo(() => { - const targets: MoveTarget[] = []; - for (const c of containers) { - const cTabs = c.tabs ?? []; - if (cTabs.length >= 2) { - for (const tab of cTabs) { - targets.push({ - containerId: c.id, - tabId: tab.id, - label: tab.title, - allTabs: cTabs.map(t => ({ id: t.id, title: t.title })), - }); - } - } else if (cTabs.length === 1) { - // 1-tab group: show just the group name, target the single tab - targets.push({ - containerId: c.id, - tabId: cTabs[0].id, - label: cTabs[0].title, - }); - } else { - targets.push({ containerId: c.id, label: c.title }); - } - } - return targets; - }, [containers]); + const moveTargetContainers = useMemo( + () => buildMoveTargets(containers), + [containers], + ); const hasContainers = containers.length > 0; const allTiles = useMemo(() => dashboard?.tiles ?? [], [dashboard?.tiles]); @@ -630,24 +559,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { (gridTiles: Tile[]) => (newLayout: RGL.Layout[]) => { if (!dashboard) return; const currentLayout = gridTiles.map(tileToLayoutItem); - let hasDiff = false; - if (newLayout.length !== currentLayout.length) { - hasDiff = true; - } else { - for (const curr of newLayout) { - const old = currentLayout.find(l => l.i === curr.i); - if ( - old?.x !== curr.x || - old?.y !== curr.y || - old?.h !== curr.h || - old?.w !== curr.w - ) { - hasDiff = true; - break; - } - } - } - if (hasDiff) { + if (hasLayoutChanged({ currentLayout, newLayout })) { setDashboard(produce(dashboard, updateLayout(newLayout))); } }, @@ -802,41 +714,18 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }; // Orphaned tiles (containerId not matching any container) render as ungrouped. - const tilesByContainerId = useMemo(() => { - const map = new Map(); - for (const c of containers) { - map.set( - c.id, - allTiles.filter(t => t.containerId === c.id), - ); - } - return map; - }, [containers, allTiles]); - - const alertingTabIdsByContainer = useMemo(() => { - const map = new Map>(); - for (const container of containers) { - const tiles = tilesByContainerId.get(container.id) ?? []; - const firstTabId = container.tabs?.[0]?.id; - const alerting = new Set(); - for (const tile of tiles) { - if (tile.config.alert?.state === AlertState.ALERT) { - const attributedTabId = tile.tabId ?? firstTabId; - if (attributedTabId) alerting.add(attributedTabId); - } - } - if (alerting.size > 0) map.set(container.id, alerting); - } - return map; - }, [containers, tilesByContainerId]); + const tilesByContainerId = useMemo( + () => getTilesByContainerId({ containers, allTiles }), + [containers, allTiles], + ); + + const alertingTabIdsByContainer = useMemo( + () => getAlertingTabIdsByContainer({ containers, tilesByContainerId }), + [containers, tilesByContainerId], + ); const ungroupedTiles = useMemo( - () => - hasContainers - ? allTiles.filter( - t => !t.containerId || !tilesByContainerId.has(t.containerId), - ) - : allTiles, + () => getUngroupedTiles({ hasContainers, allTiles, tilesByContainerId }), [hasContainers, allTiles, tilesByContainerId], ); diff --git a/packages/app/src/DBDashboardPage/DashboardTile.tsx b/packages/app/src/DBDashboardPage/DashboardTile.tsx index 9c444e4ef1..17a6454de3 100644 --- a/packages/app/src/DBDashboardPage/DashboardTile.tsx +++ b/packages/app/src/DBDashboardPage/DashboardTile.tsx @@ -68,14 +68,7 @@ import { import { getMetricTableName } from '@/utils'; import { HeatmapTile } from './HeatmapTile'; - -export type MoveTarget = { - containerId: string; - tabId?: string; - label: string; - // For tabs: all tabs in order with the target tab ID - allTabs?: { id: string; title: string }[]; -}; +import { type MoveTarget } from './types'; type DashboardTileProps = { chart: Tile; diff --git a/packages/app/src/DBDashboardPage/types.ts b/packages/app/src/DBDashboardPage/types.ts index dbeec12833..1e03f6c4e0 100644 --- a/packages/app/src/DBDashboardPage/types.ts +++ b/packages/app/src/DBDashboardPage/types.ts @@ -9,3 +9,11 @@ export type DashboardQueryFormValues = { where: SearchCondition; whereLanguage: SearchConditionLanguage; }; + +export type MoveTarget = { + containerId: string; + tabId?: string; + label: string; + // For tabs: all tabs in order with the target tab ID + allTabs?: { id: string; title: string }[]; +}; diff --git a/packages/app/src/DBDashboardPage/utils.ts b/packages/app/src/DBDashboardPage/utils.ts new file mode 100644 index 0000000000..9750519da2 --- /dev/null +++ b/packages/app/src/DBDashboardPage/utils.ts @@ -0,0 +1,191 @@ +import RGL from 'react-grid-layout'; +import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; +import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards'; +import { + AlertState, + DashboardContainer as DashboardContainerSchema, + TSource, +} from '@hyperdx/common-utils/dist/types'; + +import { Dashboard, type Tile } from '@/dashboard'; +import { getMetricTableName } from '@/utils'; + +import { MoveTarget } from './types'; + +export const tileToLayoutItem = (chart: Tile): RGL.Layout => ({ + i: chart.id, + x: chart.x, + y: chart.y, + w: chart.w, + h: chart.h, + minH: 1, + minW: 1, +}); + +export const updateLayout = (newLayout: RGL.Layout[]) => { + return (dashboard: Dashboard) => { + for (const chart of dashboard.tiles) { + const newChartLayout = newLayout.find(layout => layout.i === chart.id); + if (newChartLayout) { + chart.x = newChartLayout.x; + chart.y = newChartLayout.y; + chart.w = newChartLayout.w; + chart.h = newChartLayout.h; + } + } + }; +}; + +export function hasLayoutChanged({ + currentLayout, + newLayout, +}: { + currentLayout: RGL.Layout[]; + newLayout: RGL.Layout[]; +}) { + if (newLayout.length !== currentLayout.length) { + return true; + } + + for (const curr of newLayout) { + const old = currentLayout.find(l => l.i === curr.i); + if ( + old?.x !== curr.x || + old?.y !== curr.y || + old?.h !== curr.h || + old?.w !== curr.w + ) { + return true; + } + } + + return false; +} + +export function downloadObjectAsJson(object: object, fileName = 'output') { + const dataStr = + 'data:text/json;charset=utf-8,' + + encodeURIComponent(JSON.stringify(object)); + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute('href', dataStr); + downloadAnchorNode.setAttribute('download', fileName + '.json'); + document.body.appendChild(downloadAnchorNode); // required for firefox + downloadAnchorNode.click(); + downloadAnchorNode.remove(); +} + +export function getDashboardTableConnections({ + dashboard, + sources, +}: { + dashboard: Dashboard | undefined; + sources: TSource[] | undefined; +}): TableConnection[] { + if (!dashboard) return []; + const tableConnections: TableConnection[] = []; + + for (const { config } of dashboard.tiles) { + if (!isBuilderSavedChartConfig(config)) continue; + const source = sources?.find(v => v.id === config.source); + if (!source) continue; + // TODO: will need to update this when we allow for multiple metrics per chart + const firstSelect = config.select[0]; + const metricType = + typeof firstSelect !== 'string' ? firstSelect?.metricType : undefined; + const tableName = getMetricTableName(source, metricType); + if (!tableName) continue; + tableConnections.push({ + databaseName: source.from.databaseName, + tableName: tableName, + connectionId: source.connection, + }); + } + + return tableConnections; +} + +export function buildMoveTargets( + containers: DashboardContainerSchema[], +): MoveTarget[] { + const targets: MoveTarget[] = []; + for (const container of containers) { + const tabs = container.tabs ?? []; + if (tabs.length >= 2) { + for (const tab of tabs) { + targets.push({ + containerId: container.id, + tabId: tab.id, + label: tab.title, + allTabs: tabs.map(t => ({ id: t.id, title: t.title })), + }); + } + } else if (tabs.length === 1) { + // 1-tab group: show just the group name, target the single tab + targets.push({ + containerId: container.id, + tabId: tabs[0].id, + label: tabs[0].title, + }); + } else { + targets.push({ containerId: container.id, label: container.title }); + } + } + return targets; +} + +export function getTilesByContainerId({ + containers, + allTiles, +}: { + containers: DashboardContainerSchema[]; + allTiles: Tile[]; +}): Map { + const map = new Map(); + for (const container of containers) { + map.set( + container.id, + allTiles.filter(tile => tile.containerId === container.id), + ); + } + return map; +} + +export function getUngroupedTiles({ + hasContainers, + allTiles, + tilesByContainerId, +}: { + hasContainers: boolean; + allTiles: Tile[]; + tilesByContainerId: Map; +}): Tile[] { + return hasContainers + ? allTiles.filter( + tile => + !tile.containerId || !tilesByContainerId.has(tile.containerId), + ) + : allTiles; +} + +export function getAlertingTabIdsByContainer({ + containers, + tilesByContainerId, +}: { + containers: DashboardContainerSchema[]; + tilesByContainerId: Map; +}): Map> { + const map = new Map>(); + for (const container of containers) { + const tiles = tilesByContainerId.get(container.id) ?? []; + const firstTabId = container.tabs?.[0]?.id; + const alerting = new Set(); + for (const tile of tiles) { + if (tile.config.alert?.state === AlertState.ALERT) { + const attributedTabId = tile.tabId ?? firstTabId; + if (attributedTabId) alerting.add(attributedTabId); + } + } + if (alerting.size > 0) map.set(container.id, alerting); + } + return map; +} From a06871b03d379248ac1cbf3dd71753825aecd1e7 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:57:30 +0200 Subject: [PATCH 10/14] test(app): cover dashboard helper utilities Co-authored-by: Cursor --- .../DBDashboardPage/__tests__/utils.test.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 packages/app/src/DBDashboardPage/__tests__/utils.test.ts diff --git a/packages/app/src/DBDashboardPage/__tests__/utils.test.ts b/packages/app/src/DBDashboardPage/__tests__/utils.test.ts new file mode 100644 index 0000000000..b3c0ad926a --- /dev/null +++ b/packages/app/src/DBDashboardPage/__tests__/utils.test.ts @@ -0,0 +1,175 @@ +import { AlertState } from '@hyperdx/common-utils/dist/types'; + +import { type Tile } from '@/dashboard'; + +import { + buildMoveTargets, + getAlertingTabIdsByContainer, + getTilesByContainerId, + getUngroupedTiles, + hasLayoutChanged, + tileToLayoutItem, +} from '../utils'; + +const makeTile = ({ + id, + containerId, + tabId, + isAlerting = false, +}: { + id: string; + containerId?: string; + tabId?: string; + isAlerting?: boolean; +}): Tile => + ({ + id, + x: 1, + y: 2, + w: 3, + h: 4, + containerId, + tabId, + config: { + alert: isAlerting ? { state: AlertState.ALERT } : undefined, + }, + }) as Tile; + +describe('DBDashboardPage utils', () => { + it('converts a tile to a react-grid-layout item', () => { + expect(tileToLayoutItem(makeTile({ id: 'tile-1' }))).toEqual({ + i: 'tile-1', + x: 1, + y: 2, + w: 3, + h: 4, + minH: 1, + minW: 1, + }); + }); + + it('detects changed layout dimensions and positions', () => { + const currentLayout = [tileToLayoutItem(makeTile({ id: 'tile-1' }))]; + + expect( + hasLayoutChanged({ + currentLayout, + newLayout: [{ ...currentLayout[0], x: currentLayout[0].x + 1 }], + }), + ).toBe(true); + + expect( + hasLayoutChanged({ + currentLayout, + newLayout: currentLayout, + }), + ).toBe(false); + }); + + it('builds move targets for plain, single-tab, and multi-tab groups', () => { + expect( + buildMoveTargets([ + { id: 'plain', title: 'Plain', collapsed: false }, + { + id: 'single', + title: 'Single', + collapsed: false, + tabs: [{ id: 'single-tab', title: 'Single Tab' }], + }, + { + id: 'multi', + title: 'Multi', + collapsed: false, + tabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + }, + ]), + ).toEqual([ + { containerId: 'plain', label: 'Plain' }, + { + containerId: 'single', + tabId: 'single-tab', + label: 'Single Tab', + }, + { + containerId: 'multi', + tabId: 'tab-a', + label: 'Tab A', + allTabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + }, + { + containerId: 'multi', + tabId: 'tab-b', + label: 'Tab B', + allTabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + }, + ]); + }); + + it('groups tiles by container and treats orphaned tiles as ungrouped', () => { + const containers = [{ id: 'group-1', title: 'Group 1', collapsed: false }]; + const allTiles = [ + makeTile({ id: 'grouped', containerId: 'group-1' }), + makeTile({ id: 'orphaned', containerId: 'deleted-group' }), + makeTile({ id: 'ungrouped' }), + ]; + + const tilesByContainerId = getTilesByContainerId({ + containers, + allTiles, + }); + + expect(tilesByContainerId.get('group-1')?.map(tile => tile.id)).toEqual([ + 'grouped', + ]); + expect( + getUngroupedTiles({ + hasContainers: true, + allTiles, + tilesByContainerId, + }).map(tile => tile.id), + ).toEqual(['orphaned', 'ungrouped']); + }); + + it('attributes alerting tiles to their tab or the first tab fallback', () => { + const containers = [ + { + id: 'group-1', + title: 'Group 1', + collapsed: false, + tabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + }, + ]; + const tilesByContainerId = getTilesByContainerId({ + containers, + allTiles: [ + makeTile({ id: 'fallback', containerId: 'group-1', isAlerting: true }), + makeTile({ + id: 'tabbed', + containerId: 'group-1', + tabId: 'tab-b', + isAlerting: true, + }), + makeTile({ id: 'quiet', containerId: 'group-1' }), + ], + }); + + expect( + getAlertingTabIdsByContainer({ + containers, + tilesByContainerId, + }).get('group-1'), + ).toEqual(new Set(['tab-a', 'tab-b'])); + }); +}); From ce18699ffbab98bfebb5a55731f3fc2874c3461f Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 15:58:14 +0200 Subject: [PATCH 11/14] test(app): cover dashboard grid actions Co-authored-by: Cursor --- .../__tests__/DashboardGrid.test.tsx | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/app/src/DBDashboardPage/__tests__/DashboardGrid.test.tsx diff --git a/packages/app/src/DBDashboardPage/__tests__/DashboardGrid.test.tsx b/packages/app/src/DBDashboardPage/__tests__/DashboardGrid.test.tsx new file mode 100644 index 0000000000..7c28371ff1 --- /dev/null +++ b/packages/app/src/DBDashboardPage/__tests__/DashboardGrid.test.tsx @@ -0,0 +1,84 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { DashboardGrid } from '../DashboardGrid'; + +const renderDashboardGrid = ( + overrides: Partial> = {}, +) => { + const onGroupSelected = jest.fn(); + const setSelectedTileIds = jest.fn(); + const onAddTile = jest.fn(); + const onAddContainer = jest.fn(); + + renderWithMantine( + , + ); + + return { + onGroupSelected, + setSelectedTileIds, + onAddTile, + onAddContainer, + }; +}; + +describe('DashboardGrid', () => { + it('shows selected tile actions and invokes callbacks', async () => { + const user = userEvent.setup(); + const { onGroupSelected, setSelectedTileIds } = renderDashboardGrid({ + selectedTileIds: new Set(['tile-1', 'tile-2']), + }); + + expect(screen.getByText('2 tiles selected')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Group' })); + expect(onGroupSelected).toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(setSelectedTileIds).toHaveBeenCalledWith(new Set()); + }); + + it('opens the add menu and invokes add callbacks', async () => { + const user = userEvent.setup(); + const { onAddTile, onAddContainer } = renderDashboardGrid(); + + await user.click(screen.getByTestId('add-dropdown-button')); + await user.click(await screen.findByText('New Tile')); + expect(onAddTile).toHaveBeenCalledWith(); + + await user.click(screen.getByTestId('add-dropdown-button')); + await user.click(await screen.findByText('New Group')); + expect(onAddContainer).toHaveBeenCalled(); + }); +}); From a69fdc83e523ad06e88f2cce92f5b05f5cf2f23d Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 16:01:45 +0200 Subject: [PATCH 12/14] style(app): format dashboard refactor files Co-authored-by: Cursor --- .../src/DBDashboardPage/DBDashboardPage.tsx | 25 +++++++++---------- .../DBDashboardPage/DashboardContainerRow.tsx | 4 +-- .../app/src/DBDashboardPage/DashboardGrid.tsx | 2 +- .../src/DBDashboardPage/DashboardHeader.tsx | 2 +- .../app/src/DBDashboardPage/DashboardTile.tsx | 5 +--- .../src/DBDashboardPage/DashboardToolbar.tsx | 2 +- packages/app/src/DBDashboardPage/utils.ts | 3 +-- 7 files changed, 18 insertions(+), 25 deletions(-) diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx index 024f38cccc..9fe0094156 100644 --- a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -16,37 +16,37 @@ import { import { Box, Text } from '@mantine/core'; import { notifications } from '@mantine/notifications'; +import { DEFAULT_CHART_CONFIG } from '@/ChartUtils'; import { ContactSupportText } from '@/components/ContactSupportText'; +import OnboardingModal from '@/components/OnboardingModal'; +import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput'; +import { useConnections } from '@/connection'; import { Dashboard, type Tile, useCreateDashboard, useDeleteDashboard, } from '@/dashboard'; +import { useDashboard } from '@/dashboard'; +import DashboardFilters from '@/DashboardFilters'; +import DashboardFiltersModal from '@/DashboardFiltersModal'; import useDashboardContainers from '@/hooks/useDashboardContainers'; -import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; - -import OnboardingModal from '@/components/OnboardingModal'; -import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput'; import useDashboardFilters from '@/hooks/useDashboardFilters'; import { useDashboardRefresh } from '@/hooks/useDashboardRefresh'; import useTileSelection from '@/hooks/useTileSelection'; -import { useBrandDisplayName } from '@/theme/ThemeProvider'; -import { parseAsJsonEncoded, parseAsStringEncoded } from '@/utils/queryParsers'; -import { DEFAULT_CHART_CONFIG } from '@/ChartUtils'; -import { useConnections } from '@/connection'; -import { useDashboard } from '@/dashboard'; -import DashboardFilters from '@/DashboardFilters'; -import DashboardFiltersModal from '@/DashboardFiltersModal'; import { withAppNav } from '@/layout'; import { useSources } from '@/source'; +import { useBrandDisplayName } from '@/theme/ThemeProvider'; import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; import { useConfirm } from '@/useConfirm'; +import { parseAsJsonEncoded, parseAsStringEncoded } from '@/utils/queryParsers'; +import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; import { DashboardGrid } from './DashboardGrid'; import { DashboardHeader } from './DashboardHeader'; -import { DashboardToolbar } from './DashboardToolbar'; import { DashboardTile } from './DashboardTile'; +import { DashboardToolbar } from './DashboardToolbar'; +import { EditTileModal } from './EditTileModal'; import { DashboardQueryFormValues, type MoveTarget } from './types'; import { buildMoveTargets, @@ -59,7 +59,6 @@ import { tileToLayoutItem, updateLayout, } from './utils'; -import { EditTileModal } from './EditTileModal'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; diff --git a/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx b/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx index b60e132089..933336c894 100644 --- a/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx +++ b/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx @@ -3,9 +3,7 @@ import RGL, { WidthProvider } from 'react-grid-layout'; import { DashboardContainer as DashboardContainerSchema } from '@hyperdx/common-utils/dist/types'; import DashboardContainer from '@/components/DashboardContainer'; -import { - EmptyContainerPlaceholder, -} from '@/components/DashboardDndComponents'; +import { EmptyContainerPlaceholder } from '@/components/DashboardDndComponents'; import { type DragHandleProps } from '@/components/DashboardDndContext'; import { type Tile } from '@/dashboard'; import { type TabDeleteAction } from '@/hooks/useDashboardContainers'; diff --git a/packages/app/src/DBDashboardPage/DashboardGrid.tsx b/packages/app/src/DBDashboardPage/DashboardGrid.tsx index 0f9a8e6459..ec6c5b66e6 100644 --- a/packages/app/src/DBDashboardPage/DashboardGrid.tsx +++ b/packages/app/src/DBDashboardPage/DashboardGrid.tsx @@ -1,6 +1,6 @@ import { Dispatch, SetStateAction } from 'react'; -import RGL, { WidthProvider } from 'react-grid-layout'; import { ErrorBoundary } from 'react-error-boundary'; +import RGL, { WidthProvider } from 'react-grid-layout'; import { DashboardContainer as DashboardContainerSchema } from '@hyperdx/common-utils/dist/types'; import { Box, Button, Flex, Menu, Paper, Text } from '@mantine/core'; import { diff --git a/packages/app/src/DBDashboardPage/DashboardHeader.tsx b/packages/app/src/DBDashboardPage/DashboardHeader.tsx index 735fb65ba1..5fa8cda88f 100644 --- a/packages/app/src/DBDashboardPage/DashboardHeader.tsx +++ b/packages/app/src/DBDashboardPage/DashboardHeader.tsx @@ -23,9 +23,9 @@ import { } from '@tabler/icons-react'; import { FavoriteButton } from '@/components/FavoriteButton'; +import { Tags } from '@/components/Tags'; import { Dashboard } from '@/dashboard'; import { EditablePageName } from '@/EditablePageName'; -import { Tags } from '@/components/Tags'; import { FormatTime } from '@/useFormatTime'; type DashboardHeaderProps = { diff --git a/packages/app/src/DBDashboardPage/DashboardTile.tsx b/packages/app/src/DBDashboardPage/DashboardTile.tsx index 17a6454de3..8378ee8c16 100644 --- a/packages/app/src/DBDashboardPage/DashboardTile.tsx +++ b/packages/app/src/DBDashboardPage/DashboardTile.tsx @@ -61,10 +61,7 @@ import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; import FullscreenPanelModal from '@/components/FullscreenPanelModal'; import { type Tile } from '@/dashboard'; -import { - getFirstTimestampValueExpression, - useSource, -} from '@/source'; +import { getFirstTimestampValueExpression, useSource } from '@/source'; import { getMetricTableName } from '@/utils'; import { HeatmapTile } from './HeatmapTile'; diff --git a/packages/app/src/DBDashboardPage/DashboardToolbar.tsx b/packages/app/src/DBDashboardPage/DashboardToolbar.tsx index 4dcabc8908..e41d0d4b7b 100644 --- a/packages/app/src/DBDashboardPage/DashboardToolbar.tsx +++ b/packages/app/src/DBDashboardPage/DashboardToolbar.tsx @@ -1,4 +1,5 @@ import { Dispatch, SetStateAction } from 'react'; +import { Control, UseFormSetValue } from 'react-hook-form'; import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; import { SQLInterval } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, Button, Flex, Tooltip } from '@mantine/core'; @@ -7,7 +8,6 @@ import { IconPlayerPlay, IconRefresh, } from '@tabler/icons-react'; -import { Control, UseFormSetValue } from 'react-hook-form'; import SearchWhereInput from '@/components/SearchInput/SearchWhereInput'; import { TimePicker } from '@/components/TimePicker'; diff --git a/packages/app/src/DBDashboardPage/utils.ts b/packages/app/src/DBDashboardPage/utils.ts index 9750519da2..1c1dd8e765 100644 --- a/packages/app/src/DBDashboardPage/utils.ts +++ b/packages/app/src/DBDashboardPage/utils.ts @@ -161,8 +161,7 @@ export function getUngroupedTiles({ }): Tile[] { return hasContainers ? allTiles.filter( - tile => - !tile.containerId || !tilesByContainerId.has(tile.containerId), + tile => !tile.containerId || !tilesByContainerId.has(tile.containerId), ) : allTiles; } From ec056b39c7083b635aec7f88cacf0fb6eeb1a6bb Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Thu, 7 May 2026 16:03:49 +0200 Subject: [PATCH 13/14] fix(app): clean dashboard refactor lint issues Co-authored-by: Cursor --- packages/app/src/DBDashboardPage/DashboardTile.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/app/src/DBDashboardPage/DashboardTile.tsx b/packages/app/src/DBDashboardPage/DashboardTile.tsx index 8378ee8c16..9e43f7a282 100644 --- a/packages/app/src/DBDashboardPage/DashboardTile.tsx +++ b/packages/app/src/DBDashboardPage/DashboardTile.tsx @@ -8,12 +8,12 @@ import { } from 'react'; import { formatRelative } from 'date-fns'; import { pick } from 'lodash'; +import { ErrorBoundary } from 'react-error-boundary'; import { displayTypeSupportsBuilderAlerts, displayTypeSupportsRawSqlAlerts, } from '@hyperdx/common-utils/dist/core/utils'; import { - displayTypeRequiresSource, isBuilderChartConfig, isBuilderSavedChartConfig, isRawSqlChartConfig, @@ -34,7 +34,6 @@ import { ActionIcon, Box, Flex, - Group, Indicator, Menu, Stack, @@ -61,12 +60,17 @@ import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; import FullscreenPanelModal from '@/components/FullscreenPanelModal'; import { type Tile } from '@/dashboard'; +import HDXMarkdownChart from '@/HDXMarkdownChart'; import { getFirstTimestampValueExpression, useSource } from '@/source'; import { getMetricTableName } from '@/utils'; import { HeatmapTile } from './HeatmapTile'; import { type MoveTarget } from './types'; +const displayTypeRequiresSource = ( + displayType: DisplayType | undefined, +): boolean => displayType !== DisplayType.Markdown; + type DashboardTileProps = { chart: Tile; dateRange: [Date, Date]; From 3064f83f14b380e8db88828cbe913632f5a0c0b8 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 15:40:23 +0000 Subject: [PATCH 14/14] fix: address deep review findings --- .../src/DBDashboardPage/DBDashboardPage.tsx | 4 +- .../DBDashboardPage/DashboardContainerRow.tsx | 2 +- .../app/src/DBDashboardPage/DashboardTile.tsx | 5 +- .../__tests__/DashboardTile.test.tsx | 344 ++++++++++++++++++ .../DBDashboardPage/__tests__/utils.test.ts | 179 ++++++++- 5 files changed, 526 insertions(+), 8 deletions(-) create mode 100644 packages/app/src/DBDashboardPage/__tests__/DashboardTile.test.tsx diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx index 9fe0094156..88cdded038 100644 --- a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -25,9 +25,9 @@ import { Dashboard, type Tile, useCreateDashboard, + useDashboard, useDeleteDashboard, } from '@/dashboard'; -import { useDashboard } from '@/dashboard'; import DashboardFilters from '@/DashboardFilters'; import DashboardFiltersModal from '@/DashboardFiltersModal'; import useDashboardContainers from '@/hooks/useDashboardContainers'; @@ -47,7 +47,7 @@ import { DashboardHeader } from './DashboardHeader'; import { DashboardTile } from './DashboardTile'; import { DashboardToolbar } from './DashboardToolbar'; import { EditTileModal } from './EditTileModal'; -import { DashboardQueryFormValues, type MoveTarget } from './types'; +import type { DashboardQueryFormValues, MoveTarget } from './types'; import { buildMoveTargets, downloadObjectAsJson, diff --git a/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx b/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx index 933336c894..4fe39321af 100644 --- a/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx +++ b/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx @@ -21,7 +21,7 @@ type DashboardContainerRowProps = { onToggleCollapsible: () => void; onToggleBordered: () => void; onDeleteContainer: (action: 'ungroup' | 'delete') => void; - onAddTile: (containerId: string, tabId?: string) => void; + onAddTile: (containerId?: string, tabId?: string) => void; onAddTab: () => void; onRenameTab: (tabId: string, newTitle: string) => void; onDeleteTab: (tabId: string, action: TabDeleteAction) => void; diff --git a/packages/app/src/DBDashboardPage/DashboardTile.tsx b/packages/app/src/DBDashboardPage/DashboardTile.tsx index 9e43f7a282..a7c260c496 100644 --- a/packages/app/src/DBDashboardPage/DashboardTile.tsx +++ b/packages/app/src/DBDashboardPage/DashboardTile.tsx @@ -14,6 +14,7 @@ import { displayTypeSupportsRawSqlAlerts, } from '@hyperdx/common-utils/dist/core/utils'; import { + displayTypeRequiresSource, isBuilderChartConfig, isBuilderSavedChartConfig, isRawSqlChartConfig, @@ -67,10 +68,6 @@ import { getMetricTableName } from '@/utils'; import { HeatmapTile } from './HeatmapTile'; import { type MoveTarget } from './types'; -const displayTypeRequiresSource = ( - displayType: DisplayType | undefined, -): boolean => displayType !== DisplayType.Markdown; - type DashboardTileProps = { chart: Tile; dateRange: [Date, Date]; diff --git a/packages/app/src/DBDashboardPage/__tests__/DashboardTile.test.tsx b/packages/app/src/DBDashboardPage/__tests__/DashboardTile.test.tsx new file mode 100644 index 0000000000..21149bdfa4 --- /dev/null +++ b/packages/app/src/DBDashboardPage/__tests__/DashboardTile.test.tsx @@ -0,0 +1,344 @@ +import { + AlertState, + DisplayType, + SourceKind, +} from '@hyperdx/common-utils/dist/types'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { type Tile } from '@/dashboard'; + +jest.mock('@/source', () => ({ + __esModule: true, + useSource: jest.fn(), + getFirstTimestampValueExpression: (s: string) => s, +})); + +jest.mock('@/components/charts/ChartContainer', () => ({ + __esModule: true, + default: ({ + title, + toolbarItems, + children, + }: { + title: React.ReactNode; + toolbarItems?: React.ReactNode[]; + children?: React.ReactNode; + }) => ( +
+
{title}
+
{toolbarItems}
+
{children}
+
+ ), +})); + +// Render toolbarPrefix/toolbarItems so the hover-toolbar (alert button, +// filter warning) is visible in the DOM for assertions. +jest.mock('@/components/DBNumberChart', () => ({ + __esModule: true, + default: ({ toolbarPrefix }: { toolbarPrefix?: React.ReactNode }) => ( +
{toolbarPrefix}
+ ), +})); +jest.mock('@/components/DBPieChart', () => ({ + __esModule: true, + DBPieChart: ({ toolbarPrefix }: { toolbarPrefix?: React.ReactNode }) => ( +
{toolbarPrefix}
+ ), +})); +jest.mock('@/components/DBSqlRowTableWithSidebar', () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock('@/components/DBTableChart', () => ({ + __esModule: true, + default: ({ toolbarPrefix }: { toolbarPrefix?: React.ReactNode }) => ( +
{toolbarPrefix}
+ ), +})); +jest.mock('@/components/DBTimeChart', () => ({ + __esModule: true, + DBTimeChart: ({ toolbarPrefix }: { toolbarPrefix?: React.ReactNode }) => ( +
{toolbarPrefix}
+ ), +})); +jest.mock('@/components/FullscreenPanelModal', () => ({ + __esModule: true, + default: ({ + opened, + children, + }: { + opened: boolean; + children: React.ReactNode; + }) => (opened ?
{children}
: null), +})); +jest.mock('@/HDXMarkdownChart', () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock('@/ChartUtils', () => ({ + __esModule: true, + buildTableRowSearchUrl: jest.fn(), +})); +jest.mock('../HeatmapTile', () => ({ + __esModule: true, + HeatmapTile: () =>
, +})); + +import { useSource } from '@/source'; + +import { DashboardTile } from '../DashboardTile'; + +const mockUseSource = useSource as jest.MockedFunction; + +const baseSource = { + id: 'src-1', + kind: SourceKind.Log, + name: 'Logs', + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'logs' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: '', + implicitColumnExpression: '', +} as any; + +const setSource = (source: typeof baseSource | undefined, isFetched = true) => { + mockUseSource.mockReturnValue({ + data: source, + isFetched, + } as any); +}; + +const baseProps = { + dateRange: [new Date('2024-01-01'), new Date('2024-01-02')] as [Date, Date], + onDuplicateClick: jest.fn(), + onEditClick: jest.fn(), + onDeleteClick: jest.fn(), + granularity: undefined, + onTimeRangeSelect: jest.fn(), +}; + +const makeTile = (overrides: Partial = {}): Tile => + ({ + id: 'tile-1', + x: 0, + y: 0, + w: 4, + h: 4, + config: { + name: 'My Tile', + source: 'src-1', + displayType: DisplayType.Line, + select: [], + where: '', + whereLanguage: 'sql', + ...((overrides as any).config ?? {}), + }, + ...overrides, + }) as Tile; + +const getIndicatorColor = (alertButton: HTMLElement) => { + // The button is wrapped in a Tooltip (target span) which is wrapped in + // Mantine's Indicator. The Indicator's outer element carries + // --indicator-color via inline style. + let el: HTMLElement | null = alertButton; + while (el) { + const value = el.style?.getPropertyValue?.('--indicator-color'); + if (value) return value; + el = el.parentElement; + } + return ''; +}; + +describe('DashboardTile', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('source state messaging', () => { + it('shows source-missing message when the source id resolves to null', () => { + setSource(undefined, true); + renderWithMantine( + , + ); + expect( + screen.getByText( + /The data source for this tile no longer exists\. Edit the tile to select a new source\./, + ), + ).toBeInTheDocument(); + }); + + it('shows source-unset message when the chart has no source and requires one', () => { + setSource(undefined, true); + const tile = makeTile({ + config: { + name: 'Unset', + displayType: DisplayType.Line, + select: [], + } as any, + }); + renderWithMantine(); + expect( + screen.getByText( + /The data source for this tile is not set\. Edit the tile to select a data source\./, + ), + ).toBeInTheDocument(); + }); + + it('does not show the source-unset message for a Markdown tile (sourceless display type)', () => { + setSource(undefined, true); + const tile = makeTile({ + config: { + name: 'Notes', + displayType: DisplayType.Markdown, + select: [], + markdown: 'hello', + } as any, + }); + renderWithMantine(); + expect( + screen.queryByText( + /The data source for this tile is not set\. Edit the tile to select a data source\./, + ), + ).not.toBeInTheDocument(); + }); + }); + + describe('alert indicator color', () => { + const tileWithAlert = (alert: any) => + makeTile({ + config: { + name: 'Alerted', + source: 'src-1', + displayType: DisplayType.Line, + select: [], + alert, + } as any, + }); + + it('uses transparent when no alert is configured', () => { + setSource(baseSource); + renderWithMantine( + , + ); + const btn = screen.getByTestId('tile-alerts-button-tile-1'); + expect(getIndicatorColor(btn)).toBe('transparent'); + }); + + it('uses green for AlertState.OK', () => { + setSource(baseSource); + renderWithMantine( + , + ); + const btn = screen.getByTestId('tile-alerts-button-tile-1'); + expect(getIndicatorColor(btn)).toMatch(/green/); + }); + + it('uses red for AlertState.ALERT without silence', () => { + setSource(baseSource); + renderWithMantine( + , + ); + const btn = screen.getByTestId('tile-alerts-button-tile-1'); + expect(getIndicatorColor(btn)).toMatch(/red/); + }); + + it('uses yellow when an alert is silenced', () => { + setSource(baseSource); + renderWithMantine( + , + ); + const btn = screen.getByTestId('tile-alerts-button-tile-1'); + expect(getIndicatorColor(btn)).toMatch(/yellow/); + }); + }); + + describe('filter warning', () => { + it('renders the warning icon when a raw SQL chart lacks the $__filters macro', () => { + setSource(baseSource); + const tile = makeTile({ + config: { + configType: 'sql', + name: 'Raw', + source: 'src-1', + displayType: DisplayType.Line, + select: '', + sqlTemplate: 'SELECT count() FROM logs', + } as any, + }); + const { container } = renderWithMantine( + , + ); + // tabler IconZoomExclamation renders an SVG with this class + expect( + container.querySelectorAll('.tabler-icon-zoom-exclamation').length, + ).toBeGreaterThan(0); + }); + + it('does not render the warning icon when no filters are set', () => { + setSource(baseSource); + const tile = makeTile({ + config: { + configType: 'sql', + name: 'Raw', + source: 'src-1', + displayType: DisplayType.Line, + select: '', + sqlTemplate: 'SELECT count() FROM logs', + } as any, + }); + const { container } = renderWithMantine( + , + ); + expect( + container.querySelectorAll('.tabler-icon-zoom-exclamation').length, + ).toBe(0); + }); + }); + + describe('f-hotkey fullscreen toggle', () => { + it('opens the fullscreen modal when the fullscreen toolbar button is clicked', async () => { + setSource(baseSource); + const user = userEvent.setup(); + const tile = makeTile(); + renderWithMantine(); + + // Modal is initially closed + expect(screen.queryByTestId('fullscreen-modal')).not.toBeInTheDocument(); + + // The fullscreen toolbar button and the `f` hotkey both flip the same + // `isFullscreen` state, so exercising the button covers that surface. + const fullscreenBtn = screen.getByTestId('tile-fullscreen-button-tile-1'); + await user.click(fullscreenBtn); + + expect(screen.getByTestId('fullscreen-modal')).toBeInTheDocument(); + }); + + it('does not open the fullscreen modal when the `f` hotkey fires without hovering the tile', async () => { + setSource(baseSource); + const user = userEvent.setup(); + const tile = makeTile(); + renderWithMantine(); + + await user.keyboard('f'); + expect(screen.queryByTestId('fullscreen-modal')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/app/src/DBDashboardPage/__tests__/utils.test.ts b/packages/app/src/DBDashboardPage/__tests__/utils.test.ts index b3c0ad926a..a7f597211b 100644 --- a/packages/app/src/DBDashboardPage/__tests__/utils.test.ts +++ b/packages/app/src/DBDashboardPage/__tests__/utils.test.ts @@ -1,14 +1,17 @@ import { AlertState } from '@hyperdx/common-utils/dist/types'; -import { type Tile } from '@/dashboard'; +import { type Dashboard, type Tile } from '@/dashboard'; import { buildMoveTargets, + downloadObjectAsJson, getAlertingTabIdsByContainer, + getDashboardTableConnections, getTilesByContainerId, getUngroupedTiles, hasLayoutChanged, tileToLayoutItem, + updateLayout, } from '../utils'; const makeTile = ({ @@ -172,4 +175,178 @@ describe('DBDashboardPage utils', () => { }).get('group-1'), ).toEqual(new Set(['tab-a', 'tab-b'])); }); + + it('updateLayout mutates tile coordinates by id', () => { + const dashboard = { + id: 'dash-1', + name: 'Test', + tiles: [ + { ...makeTile({ id: 'tile-1' }) }, + { ...makeTile({ id: 'tile-2' }) }, + ], + tags: [], + } as Dashboard; + + updateLayout([ + { i: 'tile-1', x: 10, y: 20, w: 5, h: 6 }, + { i: 'tile-missing', x: 99, y: 99, w: 99, h: 99 }, + ])(dashboard); + + expect(dashboard.tiles[0]).toMatchObject({ + id: 'tile-1', + x: 10, + y: 20, + w: 5, + h: 6, + }); + // Untouched tile keeps its original coords + expect(dashboard.tiles[1]).toMatchObject({ + id: 'tile-2', + x: 1, + y: 2, + w: 3, + h: 4, + }); + }); + + it('downloadObjectAsJson triggers a download via a temporary anchor', () => { + const click = jest.fn(); + const remove = jest.fn(); + const setAttribute = jest.fn(); + const fakeAnchor = { setAttribute, click, remove } as unknown as Element; + + const createElementSpy = jest + .spyOn(document, 'createElement') + .mockReturnValue(fakeAnchor as HTMLAnchorElement); + const appendChildSpy = jest + .spyOn(document.body, 'appendChild') + .mockReturnValue(fakeAnchor as Node); + + downloadObjectAsJson({ hello: 'world' }, 'my-file'); + + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(setAttribute).toHaveBeenCalledWith( + 'href', + `data:text/json;charset=utf-8,${encodeURIComponent( + JSON.stringify({ hello: 'world' }), + )}`, + ); + expect(setAttribute).toHaveBeenCalledWith('download', 'my-file.json'); + expect(appendChildSpy).toHaveBeenCalledWith(fakeAnchor); + expect(click).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledTimes(1); + + createElementSpy.mockRestore(); + appendChildSpy.mockRestore(); + }); + + describe('getDashboardTableConnections', () => { + it('returns [] when dashboard is undefined', () => { + expect( + getDashboardTableConnections({ + dashboard: undefined, + sources: [], + }), + ).toEqual([]); + }); + + it('skips tiles that are not builder configs', () => { + const dashboard = { + id: 'dash', + name: 'd', + tags: [], + tiles: [ + { + ...makeTile({ id: 'raw' }), + config: { configType: 'sql', source: 'src-1' } as any, + }, + ], + } as Dashboard; + expect(getDashboardTableConnections({ dashboard, sources: [] })).toEqual( + [], + ); + }); + + it('skips tiles whose source cannot be resolved', () => { + const dashboard = { + id: 'dash', + name: 'd', + tags: [], + tiles: [ + { + ...makeTile({ id: 'b' }), + config: { source: 'unknown', select: [] } as any, + }, + ], + } as Dashboard; + expect( + getDashboardTableConnections({ + dashboard, + sources: [{ id: 'src-1' } as any], + }), + ).toEqual([]); + }); + + it('skips tiles whose tableName cannot be resolved', () => { + const dashboard = { + id: 'dash', + name: 'd', + tags: [], + tiles: [ + { + ...makeTile({ id: 'b' }), + config: { source: 'src-1', select: [] } as any, + }, + ], + } as Dashboard; + // Source exists but has no `from.tableName`, so getMetricTableName falls + // back to source.from?.tableName which is undefined → skip. + expect( + getDashboardTableConnections({ + dashboard, + sources: [ + { + id: 'src-1', + kind: 'log', + from: { databaseName: 'default' }, + connection: 'conn-1', + } as any, + ], + }), + ).toEqual([]); + }); + + it('returns a TableConnection for each builder tile with a resolvable source/table', () => { + const dashboard = { + id: 'dash', + name: 'd', + tags: [], + tiles: [ + { + ...makeTile({ id: 'b' }), + config: { source: 'src-1', select: [] } as any, + }, + ], + } as Dashboard; + expect( + getDashboardTableConnections({ + dashboard, + sources: [ + { + id: 'src-1', + kind: 'log', + from: { databaseName: 'default', tableName: 'logs' }, + connection: 'conn-1', + } as any, + ], + }), + ).toEqual([ + { + databaseName: 'default', + tableName: 'logs', + connectionId: 'conn-1', + }, + ]); + }); + }); });