From a502c056e8802cd476733f4447a2f0168632b7bd Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 28 May 2026 19:53:40 +0100 Subject: [PATCH] feat(dashboard): migrate to PageLayout with sticky query toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the custom Dashboard page chrome with the shared `PageLayout` / `PageHeader` shell and introduces a reusable `stickyRow` slot on `PageHeader` so any page can pin a single row while the rest of the header scrolls away. On the dashboard this is used to keep the query toolbar (SQL/Lucene WHERE, time range, granularity, Live, refresh, edit filters, Run) visible while the user scrolls through tiles. The breadcrumbs, the editable dashboard name, dashboard actions (Favorite, Tags, Menu) and the "Created by … Updated …" meta all live in the scrolling chrome above; the meta moves to the right edge of the breadcrumbs row instead of taking its own body line. PageHeader changes: - New `stickyRow?: ReactNode` slot. When provided, the chrome `
` becomes non-sticky (and drops its bottom border / bottom padding so it reads as one block with the toolbar), and a sibling `
` renders the `stickyRow` at `position: sticky; top: 0`. Returning a Fragment of the two siblings means the sticky row's containing block is the page layout root, so it stays pinned for the full scroll length rather than being bounded by the chrome height. - `IntersectionObserver` tracks when the sticky row touches the scroll-container top and toggles a `.stickyRowAttached` class so the row has no `padding-top` while attached to chrome (tight spacing at rest) and the standard `--mantine-spacing-sm` top padding once stuck (breathing room from the viewport edge). The state is also exposed as `data-stuck` on the row for tests. - Pages without a `stickyRow` keep the existing fully-sticky header behavior — no migration needed elsewhere. Also drops a redundant `mt="sm"` from `DashboardFilters`, since the `PageLayout` `padded` content already provides top padding above it. Co-authored-by: Cursor --- .changeset/page-migrate-dashboard.md | 7 + packages/app/src/DBDashboardPage.tsx | 649 +++++++++--------- packages/app/src/DashboardFilters.tsx | 2 +- .../app/src/components/PageHeader.module.scss | 36 + packages/app/src/components/PageHeader.tsx | 121 +++- 5 files changed, 473 insertions(+), 342 deletions(-) create mode 100644 .changeset/page-migrate-dashboard.md diff --git a/.changeset/page-migrate-dashboard.md b/.changeset/page-migrate-dashboard.md new file mode 100644 index 0000000000..dc70c2ec0a --- /dev/null +++ b/.changeset/page-migrate-dashboard.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': patch +--- + +chore: migrate the custom Dashboard page to shared `PageLayout` / `PageHeader`. Breadcrumbs, the editable dashboard name, dashboard actions (Favorite, Tags, Menu), and the "Created by … Updated …" meta now live in a single page header, while the query toolbar (SQL/Lucene WHERE, time range, granularity, Live, refresh, edit filters, Run) is pinned to the top of the scroll container as a dedicated sticky row — the chrome above scrolls away and only the toolbar follows the user. The "Updated …" meta moves to the right side of the breadcrumbs row instead of sitting as a separate body line. + +`PageHeader` gains a `stickyRow` slot that any page can use to declare a single row that should be the only pinned element, with the rest of the header treated as scrolling chrome. Other pages are unaffected — a `PageHeader` without `stickyRow` keeps the existing fully-sticky behavior. diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 96cdae1d5b..1d892f9f98 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -116,6 +116,8 @@ import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; import { FavoriteButton } from '@/components/FavoriteButton'; import FullscreenPanelModal from '@/components/FullscreenPanelModal'; +import { PageHeader } from '@/components/PageHeader'; +import { PageLayout } from '@/components/PageLayout'; import { TimePicker } from '@/components/TimePicker'; import { parseTimeRangeInput } from '@/components/TimePicker/utils'; import { @@ -2212,8 +2214,308 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { dashboard?.savedQuery || dashboard?.savedFilterValues?.length, ); - return ( - + const dashboardMeta = + !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 })}.`} + + )} + + ) : null; + + const pageBreadcrumbs = ( + + + + Dashboards + + + {isLocalDashboard + ? 'Temporary Dashboard' + : (dashboard?.name ?? 'Untitled')} + + + {dashboardMeta} + + ); + + const dashboardName = ( + { + if (dashboard != null) { + setDashboard({ + ...dashboard, + name: editedName, + }); + } + }} + /> + ); + + const dashboardActions = !isLocalDashboard ? ( + + {dashboard?.id && ( + + )} + {dashboard?.id && ( + + + + )} + {/* local dashboards cant be "deleted" */} + + + + + + + + + {containers.length > 0 && ( + <> + View + + ) : ( + + ) + } + onClick={() => setTocVisible(v => !v)} + data-testid="toggle-toc-menu-item" + > + {tocVisible + ? 'Hide table of contents' + : 'Show table of contents'} + + } + onClick={handleCollapseAll} + disabled={collapsibleContainers.length === 0} + data-testid="collapse-all-sections-menu-item" + > + Collapse all sections + + } + onClick={handleExpandAll} + disabled={collapsibleContainers.length === 0} + data-testid="expand-all-sections-menu-item" + > + Expand all sections + + + + )} + {hasTiles && ( + } + onClick={() => { + if (!sources || !dashboard) { + notifications.show({ + color: 'red', + message: 'Export Failed', + }); + return; + } + downloadObjectAsJson( + convertToDashboardTemplate( + dashboard, + sources, + connections, + allDashboards ?? [], + ), + 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 + + + + + ) : null; + + const titleRow = ( + + {dashboardName} + {dashboardActions} + + ); + + const queryToolbar = ( + { + 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" + > + + + + + + ); + + // Extracted for the same reason as `KubernetesDashboardPage`: keeps the + // `` return shallow and prevents the tile-grid tree below + // from being wrapped in an extra indentation level. + const dashboardBody = ( + <> {dashboard?.name ? `${dashboard.name}` : 'Dashboard'} – {brandName} @@ -2255,321 +2557,23 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); }} /> - - {isLocalDashboard ? ( - <> - <Breadcrumbs mb="xs" mt="xs" fz="sm"> - <Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed"> - Dashboards - </Anchor> - <Text fz="sm" c="dimmed"> - Temporary Dashboard - </Text> - </Breadcrumbs> - <Paper my="lg" p="md" data-testid="temporary-dashboard-banner"> - <Flex justify="space-between" align="center"> - <Text size="sm"> - This is a temporary dashboard and can not be saved. - </Text> - <Button - variant="primary" - fw={400} - onClick={onCreateDashboard} - data-testid="create-dashboard-button" - > - Create New Saved Dashboard - </Button> - </Flex> - </Paper> - </> - ) : ( - <Group align="flex-start" mb="xs" mt="xs" justify="space-between"> - <Breadcrumbs fz="sm"> - <Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed"> - Dashboards - </Anchor> - <Text fz="sm" c="dimmed" maw={500} truncate="end" lh={1}> - {dashboard?.name ?? 'Untitled'} - </Text> - </Breadcrumbs> - {!isLocalDashboard && dashboard && ( - <Text size="xs" c="dimmed"> - {dashboard.createdBy && ( - <span> - Created by{' '} - {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} - </span> - )} - {dashboard.updatedAt && ( - <Tooltip - label={ - <> - <FormatTime value={dashboard.updatedAt} format="short" /> - {dashboard.updatedBy - ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` - : ''} - </> - } - > - <span>{`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`}</span> - </Tooltip> - )} + {isLocalDashboard && ( + <Paper mt="xs" mb="md" p="md" data-testid="temporary-dashboard-banner"> + <Flex justify="space-between" align="center"> + <Text size="sm"> + This is a temporary dashboard and can not be saved. </Text> - )} - </Group> - )} - <Flex mt="xs" mb="md" justify="space-between" align="flex-start"> - <EditablePageName - key={`${dashboardHash}`} - name={dashboard?.name ?? ''} - onSave={editedName => { - if (dashboard != null) { - setDashboard({ - ...dashboard, - name: editedName, - }); - } - }} - /> - <Group gap="xs"> - {!isLocalDashboard && dashboard?.id && ( - <FavoriteButton - resourceType="dashboard" - resourceId={dashboard.id} - /> - )} - {!isLocalDashboard && dashboard?.id && ( - <Tags - allowCreate - values={dashboard?.tags || []} - onChange={handleUpdateTags} + <Button + variant="primary" + fw={400} + onClick={onCreateDashboard} + data-testid="create-dashboard-button" > - <Button - variant="secondary" - px="xs" - size="xs" - style={{ flexShrink: 0 }} - > - <IconTags size={14} className="me-2" /> - {dashboard?.tags?.length || 0}{' '} - {dashboard?.tags?.length === 1 ? 'Tag' : 'Tags'} - </Button> - </Tags> - )} - {!isLocalDashboard /* local dashboards cant be "deleted" */ && ( - <Menu width={250}> - <Menu.Target> - <ActionIcon - variant="secondary" - size="input-xs" - data-testid="dashboard-menu-button" - > - <IconDotsVertical size={14} /> - </ActionIcon> - </Menu.Target> - - <Menu.Dropdown> - {containers.length > 0 && ( - <> - <Menu.Label>View</Menu.Label> - <Menu.Item - leftSection={ - tocVisible ? ( - <IconLayoutSidebarRightCollapse size={16} /> - ) : ( - <IconLayoutSidebarRightExpand size={16} /> - ) - } - onClick={() => setTocVisible(v => !v)} - data-testid="toggle-toc-menu-item" - > - {tocVisible - ? 'Hide table of contents' - : 'Show table of contents'} - </Menu.Item> - <Menu.Item - leftSection={<IconChevronsUp size={16} />} - onClick={handleCollapseAll} - disabled={collapsibleContainers.length === 0} - data-testid="collapse-all-sections-menu-item" - > - Collapse all sections - </Menu.Item> - <Menu.Item - leftSection={<IconChevronsDown size={16} />} - onClick={handleExpandAll} - disabled={collapsibleContainers.length === 0} - data-testid="expand-all-sections-menu-item" - > - Expand all sections - </Menu.Item> - <Menu.Divider /> - </> - )} - {hasTiles && ( - <Menu.Item - leftSection={<IconDownload size={16} />} - onClick={() => { - if (!sources || !dashboard) { - notifications.show({ - color: 'red', - message: 'Export Failed', - }); - return; - } - downloadObjectAsJson( - convertToDashboardTemplate( - dashboard, - sources, - connections, - allDashboards ?? [], - ), - dashboard?.name, - ); - }} - > - Export Dashboard - </Menu.Item> - )} - <Menu.Item - leftSection={<IconUpload size={16} />} - onClick={() => { - if (dashboard && !dashboard.tiles.length) { - router.push( - `/dashboards/import?dashboardId=${dashboard.id}`, - ); - } else { - router.push('/dashboards/import'); - } - }} - > - {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} - </Menu.Item> - <Menu.Divider /> - <Menu.Item - data-testid="save-default-query-filters-menu-item" - leftSection={<IconDeviceFloppy size={16} />} - onClick={handleSaveQuery} - > - {hasSavedQueryAndFilterDefaults - ? 'Update Default Query & Filters' - : 'Save Query & Filters as Default'} - </Menu.Item> - {hasSavedQueryAndFilterDefaults && ( - <Menu.Item - data-testid="remove-default-query-filters-menu-item" - leftSection={<IconX size={16} />} - color="red" - onClick={handleRemoveSavedQuery} - > - Remove Default Query & Filters - </Menu.Item> - )} - <Menu.Divider /> - <Menu.Item - leftSection={<IconTrash size={16} />} - color="red" - onClick={() => - deleteDashboard.mutate(dashboard?.id ?? '', { - onSuccess: () => { - router.push('/dashboards'); - }, - }) - } - > - Delete Dashboard - </Menu.Item> - </Menu.Dropdown> - </Menu> - )} - </Group> - {/* <Button variant="outline" size="sm"> - Save - </Button> */} - </Flex> - <Flex - gap="sm" - mt="sm" - wrap="wrap" - component="form" - onSubmit={e => { - e.preventDefault(); - onSubmit(); - }} - > - <SearchWhereInput - tableConnections={tableConnections} - control={control} - name="where" - onSubmit={onSubmit} - onLanguageChange={(lang: 'sql' | 'lucene') => - setValue('whereLanguage', lang) - } - label="WHERE" - enableHotkey - allowMultiline - minWidth={300} - data-testid="search-input" - /> - <TimePicker - inputValue={displayedTimeInputValue} - setInputValue={setDisplayedTimeInputValue} - onSearch={range => { - onSearch(range); - }} - /> - <GranularityPickerControlled control={control} name="granularity" /> - <Tooltip - withArrow - label={ - isRefreshEnabled - ? `Auto-refreshing with ${granularityOverride} interval` - : 'Enable auto-refresh' - } - fz="xs" - color="gray" - > - <Button - onClick={() => setIsLive(prev => !prev)} - size="sm" - variant={isLive ? 'primary' : 'secondary'} - title={isLive ? 'Disable auto-refresh' : 'Enable auto-refresh'} - > - Live - </Button> - </Tooltip> - <Tooltip withArrow label="Refresh dashboard" fz="xs" color="gray"> - <ActionIcon - onClick={refresh} - loading={manualRefreshCooloff} - disabled={manualRefreshCooloff} - variant="secondary" - title="Refresh dashboard" - size="input-sm" - > - <IconRefresh size={18} /> - </ActionIcon> - </Tooltip> - <Tooltip withArrow label="Edit Filters" fz="xs" color="gray"> - <ActionIcon - variant="secondary" - onClick={() => setShowFiltersModal(true)} - data-testid="edit-filters-button" - size="input-sm" - > - <IconFilterEdit size={18} /> - </ActionIcon> - </Tooltip> - <Button - data-testid="search-submit-button" - variant="primary" - type="submit" - leftSection={<IconPlayerPlay size={16} />} - style={{ flexShrink: 0 }} - > - Run - </Button> - </Flex> + Create New Saved Dashboard + </Button> + </Flex> + </Paper> + )} {shouldShowIgnoredFiltersBanner && ignoredFilterExpressions.length > 0 && ( <Alert @@ -2762,7 +2766,20 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onRemoveFilter={handleRemoveFilter} isLoading={isSavingDashboard || isFetchingDashboard} /> - </Box> + </> + ); + + return ( + <PageLayout + data-testid="dashboard-page" + header={ + <PageHeader breadcrumbs={pageBreadcrumbs} stickyRow={queryToolbar}> + {titleRow} + </PageHeader> + } + padded + content={dashboardBody} + /> ); } diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index 141362670b..78a29dfdb6 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -77,7 +77,7 @@ const DashboardFilters = ({ }); return ( - <Group mt="sm" align="start"> + <Group align="start"> {Object.values(filters).map(filter => { const queriedFilterValues = filterValuesById?.get(filter.id); const included = filterValues[filter.expression]?.included; diff --git a/packages/app/src/components/PageHeader.module.scss b/packages/app/src/components/PageHeader.module.scss index 47964b9227..3408b57ead 100644 --- a/packages/app/src/components/PageHeader.module.scss +++ b/packages/app/src/components/PageHeader.module.scss @@ -17,6 +17,42 @@ z-index: 100; } +/* Applied automatically when a `stickyRow` is provided: the chrome scrolls + away with the page and the `stickyRow` below becomes the only pinned bar. + The chrome's bottom border and bottom padding are also dropped — the + sticky row owns both the separator and the breathing room between page + chrome and scrolling content, so doubling up creates a visible gap. */ +.notSticky { + position: static; + top: auto; + z-index: auto; + border-bottom: none; + padding-block-end: 0; +} + +/* Sibling of `.header`. Pinned to the top of the scroll container; styled + to match the header (body background + bottom border) so chart/list + content scrolls cleanly underneath. Vertical padding adapts to the + "stuck" state via `.stickyRowAttached` — see below. */ +.stickyRow { + background-color: var(--color-bg-body); + border-bottom: 1px solid var(--color-border); + padding: var(--mantine-spacing-sm); + position: sticky; + top: 0; + z-index: 100; +} + +/* Applied when the sticky row sits directly under chrome AND is not + currently stuck. In that state we drop the row's `padding-top` so the + chrome and sticky row read as one continuous block (the chrome's own + bottom edge already provides spacing). As soon as the row sticks at + `top: 0`, this class is removed and the row's normal `sm` top padding + takes over, giving the toolbar breathing room from the viewport edge. */ +.stickyRowAttached { + padding-top: 0; +} + /* Breadcrumbs row + toolbar row inside one sticky header */ .headerStacked { align-items: stretch; diff --git a/packages/app/src/components/PageHeader.tsx b/packages/app/src/components/PageHeader.tsx index 3ce58dcbaf..6945556553 100644 --- a/packages/app/src/components/PageHeader.tsx +++ b/packages/app/src/components/PageHeader.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import styles from './PageHeader.module.scss'; @@ -45,6 +46,21 @@ export type PageHeaderProps = { children?: React.ReactNode; className?: string; 'data-testid'?: string; + /** + * Single-row content that should be the only sticky element on the + * page. When provided, the rest of the header (`breadcrumbs`, + * `title` / `leading` / `actions`, `children`) becomes non-sticky + * chrome that scrolls away, and this row is pinned to the top of the + * scroll container instead. + * + * Use this for pages that have a tall header (e.g. dashboards with + * breadcrumbs + an editable name + a query toolbar) where only a + * specific row — typically the controls users keep reaching for while + * scrolling, like the query toolbar — should remain visible. + * + * When omitted, the header behaves as a single sticky block. + */ + stickyRow?: React.ReactNode; }; export function PageHeader({ @@ -55,20 +71,36 @@ export function PageHeader({ children, className, 'data-testid': testId, + stickyRow, }: PageHeaderProps) { const hasToolbar = title != null || leading != null || actions != null; const hasBreadcrumbs = breadcrumbs != null; + const hasStickyRow = stickyRow != null; - if (!hasToolbar && !hasBreadcrumbs) { - return ( - <header - className={classNames(styles.header, className)} - data-testid={testId} - > - {children} - </header> + // Detect when the sticky row reaches `top: 0` of the scroll container. + // `IntersectionObserver` with `rootMargin: -1px` at the top fires the + // moment the row's top edge is clipped by the viewport (i.e. it has + // become stuck). We use this to swap the row's `padding-top` between + // "attached to chrome" (no top padding, tight at rest) and "stuck" + // (standard top padding so the toolbar isn't flush with the edge). + const stickyRowRef = useRef<HTMLDivElement>(null); + const [isStuck, setIsStuck] = useState(false); + useEffect(() => { + const node = stickyRowRef.current; + if (!node || !hasStickyRow) return; + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(entry.intersectionRatio < 1), + { threshold: [1], rootMargin: '-1px 0px 0px 0px' }, ); - } + observer.observe(node); + return () => observer.disconnect(); + }, [hasStickyRow]); + // When a `stickyRow` is provided, the chrome above it scrolls with the + // page. The chrome and the sticky row render as siblings (Fragment) so + // the sticky row's containing block is the page layout root rather + // than the chrome `<header>`, which lets it stay pinned for the full + // length of the page rather than only the height of the header. + const chromeStickyClass = hasStickyRow ? styles.notSticky : undefined; const toolbarInner = ( <> @@ -80,36 +112,75 @@ export function PageHeader({ </> ); - if (hasBreadcrumbs && hasToolbar) { - return ( + const chrome = + !hasToolbar && !hasBreadcrumbs ? ( + // Only render the chrome `<header>` if there's something to put in + // it. A page with only a `stickyRow` (no breadcrumbs, no title) + // skips the empty chrome entirely. + children != null ? ( + <header + className={classNames(styles.header, chromeStickyClass, className)} + data-testid={testId} + > + {children} + </header> + ) : null + ) : hasBreadcrumbs && hasToolbar ? ( <header - className={classNames(styles.header, styles.headerStacked, className)} + className={classNames( + styles.header, + styles.headerStacked, + chromeStickyClass, + className, + )} data-testid={testId} > <div className={styles.breadcrumbsRow}>{breadcrumbs}</div> <div className={styles.toolbarRow}>{toolbarInner}</div> </header> - ); - } - - if (hasBreadcrumbs && !hasToolbar) { - return ( + ) : hasBreadcrumbs && !hasToolbar ? ( <header - className={classNames(styles.header, styles.headerStacked, className)} + className={classNames( + styles.header, + styles.headerStacked, + chromeStickyClass, + className, + )} data-testid={testId} > <div className={styles.breadcrumbsRow}>{breadcrumbs}</div> {children} </header> + ) : ( + <header + className={classNames(styles.header, chromeStickyClass, className)} + data-testid={testId} + > + {toolbarInner} + </header> ); - } + + if (!hasStickyRow) return chrome; return ( - <header - className={classNames(styles.header, className)} - data-testid={testId} - > - {toolbarInner} - </header> + <> + {chrome} + <div + ref={stickyRowRef} + className={classNames( + styles.stickyRow, + // While the row is in its at-rest position directly under + // chrome, drop its `padding-top` so the chrome's bottom edge + // and the toolbar read as one continuous block. Once stuck, + // the standard padding kicks back in so the toolbar isn't + // flush against the viewport edge. + chrome != null && !isStuck && styles.stickyRowAttached, + )} + data-stuck={isStuck ? 'true' : undefined} + data-testid="page-header-sticky-row" + > + {stickyRow} + </div> + </> ); }