From 800689acba84eaad9ee9244c6b3cf0fddc4dc13f Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Mon, 6 Apr 2026 21:26:59 +0100 Subject: [PATCH] feat: add reusable EmptyState component and adopt across pages (#2017) --- .changeset/empty-state-component.md | 5 + agent_docs/code_style.md | 28 ++++ packages/app/src/AlertsPage.tsx | 89 +++++++---- packages/app/src/DBSearchPage.tsx | 17 +- packages/app/src/DBServiceMapPage.tsx | 32 ++-- packages/app/src/SessionsPage.tsx | 146 +++++++++--------- .../src/components/DBEditTimeChartForm.tsx | 15 +- .../Dashboards/DashboardsListPage.tsx | 77 +++++---- packages/app/src/components/EmptyState.tsx | 85 ++++++++++ .../SavedSearches/SavedSearchesListPage.tsx | 51 ++++-- .../e2e/page-objects/DashboardsListPage.ts | 4 +- .../e2e/page-objects/SavedSearchesListPage.ts | 4 +- 12 files changed, 357 insertions(+), 196 deletions(-) create mode 100644 .changeset/empty-state-component.md create mode 100644 packages/app/src/components/EmptyState.tsx diff --git a/.changeset/empty-state-component.md b/.changeset/empty-state-component.md new file mode 100644 index 00000000..779f7e58 --- /dev/null +++ b/.changeset/empty-state-component.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: Add reusable EmptyState component and adopt it across pages for consistent empty/no-data states diff --git a/agent_docs/code_style.md b/agent_docs/code_style.md index 535ca9f8..fd1c3c92 100644 --- a/agent_docs/code_style.md +++ b/agent_docs/code_style.md @@ -93,6 +93,34 @@ The project uses Mantine UI with **custom variants** defined in `packages/app/sr This pattern cannot be enforced by ESLint and requires manual code review. +### EmptyState Component (REQUIRED) + +**Use `EmptyState` (`@/components/EmptyState`) for all empty/no-data states.** Do not create ad-hoc inline empty states. + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `icon` | `ReactNode` | — | Icon in the theme circle (hidden if not provided) | +| `title` | `string` | — | Heading text (headline style — no trailing period) | +| `description` | `ReactNode` | — | Subtext below the title | +| `children` | `ReactNode` | — | Actions (buttons, links) below description | +| `variant` | `"default" \| "card"` | `"default"` | `"card"` wraps in a bordered Paper | + +```tsx +// ❌ BAD - ad-hoc inline empty states +
No data
+Nothing here + +// ✅ GOOD - use the EmptyState component +} + title="No alerts created yet" + description="Create alerts from dashboard charts or saved searches." + variant="card" +/> +``` + +**Title copy**: Treat `title` as a short headline (like `Title` in the UI). Do **not** end it with a period. Use `description` for full sentences, which should use normal punctuation including a trailing period when appropriate. Match listing pages (e.g. dashboards and saved searches use parallel phrasing such as “No matching … yet” / “No … yet” without dots). + ## Refactoring - Edit files directly - don't create `component-v2.tsx` copies diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx index 2b0545a3..79b7e046 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -11,6 +11,7 @@ import { } from '@hyperdx/common-utils/dist/types'; import { Alert, + Anchor, Badge, Button, Container, @@ -23,7 +24,6 @@ import { notifications } from '@mantine/notifications'; import { IconAlertTriangle, IconBell, - IconBrandSlack, IconChartLine, IconCheck, IconChevronRight, @@ -33,6 +33,7 @@ import { } from '@tabler/icons-react'; import { useQueryClient } from '@tanstack/react-query'; +import EmptyState from '@/components/EmptyState'; import { ErrorBoundary } from '@/components/Error/ErrorBoundary'; import { PageHeader } from '@/components/PageHeader'; @@ -463,7 +464,12 @@ function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { OK {okData.length === 0 && ( -
No alerts
+ } + title="No alerts" + description="All alerts in OK state will appear here." + /> )} {okData.map((alert, index) => ( @@ -480,41 +486,60 @@ export default function AlertsPage() { const alerts = React.useMemo(() => data?.data || [], [data?.data]); return ( -
+
Alerts - {brandName} Alerts -
- - } - color="gray" - py="xs" - mt="md" - > - Alerts can be{' '} - + {isLoading ? ( +
Loading...
+ ) : isError ? ( +
Error
+ ) : alerts?.length ? ( + + } + color="gray" + py="xs" + mt="md" > - created -
{' '} - from dashboard charts and saved searches. -
- {isLoading ? ( -
Loading...
- ) : isError ? ( -
Error
- ) : alerts?.length ? ( - <> - - - ) : ( -
No alerts created yet
- )} -
+ Alerts can be{' '} + + created + {' '} + from dashboard charts and saved searches. + + + + ) : ( + } + title="No alerts created yet" + description={ + <> + Alerts can be created from{' '} + + dashboard charts + {' '} + and{' '} + + saved searches + + . + + } + /> + )}
); diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 9f7a646d..dfdb7e61 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -48,7 +48,6 @@ import { Breadcrumbs, Button, Card, - Center, Code, Flex, Grid, @@ -71,6 +70,7 @@ import { IconBolt, IconPlayerPlay, IconPlus, + IconStack2, IconTags, IconX, } from '@tabler/icons-react'; @@ -82,6 +82,7 @@ import { ActiveFilterPills } from '@/components/ActiveFilterPills'; import { ContactSupportText } from '@/components/ContactSupportText'; import { DBSearchPageFilters } from '@/components/DBSearchPageFilters'; import { DBTimeChart } from '@/components/DBTimeChart'; +import EmptyState from '@/components/EmptyState'; import { ErrorBoundary } from '@/components/Error/ErrorBoundary'; import { FavoriteButton } from '@/components/FavoriteButton'; import { InputControlled } from '@/components/InputControlled'; @@ -1797,14 +1798,12 @@ function DBSearchPage() { className="bg-body" > {!queryReady ? ( - -
- - Please start by selecting a source and then click the play - button to query data. - -
-
+ } + title="No data to display" + description="Select a source and click the play button to query data." + /> ) : ( <>
)} - } + title="No trace sources configured" + description="The Service Map visualizes relationships between your services using trace data. Configure a trace source to get started." + maw={600} > - - No trace sources configured - - - The Service Map visualizes relationships between your services using - trace data. Configure a trace source to get started. - {IS_LOCAL_MODE ? ( - - - + + } + title={ + search || tagFilter + ? 'No matching dashboards yet' + : 'No dashboards yet' + } + > + + + + + + ) : viewMode === 'list' ? ( diff --git a/packages/app/src/components/EmptyState.tsx b/packages/app/src/components/EmptyState.tsx new file mode 100644 index 00000000..04c147a0 --- /dev/null +++ b/packages/app/src/components/EmptyState.tsx @@ -0,0 +1,85 @@ +import { ReactNode } from 'react'; +import { + type BoxProps, + Center, + Paper, + type PaperProps, + Stack, + Text, + ThemeIcon, + Title, +} from '@mantine/core'; + +type EmptyStateBaseProps = { + icon?: ReactNode; + title?: string; + description?: ReactNode; + children?: ReactNode; +}; + +type EmptyStateDefaultProps = EmptyStateBaseProps & { + variant?: 'default'; + fullWidth?: never; +} & Omit; + +type EmptyStateCardProps = EmptyStateBaseProps & { + variant: 'card'; + fullWidth?: boolean; +} & Omit; + +type EmptyStateProps = EmptyStateDefaultProps | EmptyStateCardProps; + +export default function EmptyState({ + icon, + title, + description, + children, + variant = 'default', + fullWidth = false, + ...restProps +}: EmptyStateProps) { + const inner = ( + + {icon && ( + + {icon} + + )} + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + {children} + + ); + + if (variant === 'card') { + const paperProps = restProps as Omit; + return ( + +
{inner}
+
+ ); + } + + const boxProps = restProps as Omit; + return ( +
+ {inner} +
+ ); +} diff --git a/packages/app/src/components/SavedSearches/SavedSearchesListPage.tsx b/packages/app/src/components/SavedSearches/SavedSearchesListPage.tsx index e34f7d56..fbbcfede 100644 --- a/packages/app/src/components/SavedSearches/SavedSearchesListPage.tsx +++ b/packages/app/src/components/SavedSearches/SavedSearchesListPage.tsx @@ -25,6 +25,7 @@ import { } from '@tabler/icons-react'; import { AlertStatusIcon } from '@/components/AlertStatusIcon'; +import EmptyState from '@/components/EmptyState'; import { FavoriteButton } from '@/components/FavoriteButton'; import { ListingCard } from '@/components/ListingCard'; import { ListingRow } from '@/components/ListingListRow'; @@ -121,12 +122,21 @@ export default function SavedSearchesListPage() { ); return ( -
+
Saved Searches - {brandName} Saved Searches - + {favoritedSavedSearches.length > 0 && ( <> @@ -218,22 +228,29 @@ export default function SavedSearchesListPage() { Failed to load saved searches. Please try refreshing the page. ) : filteredSavedSearches.length === 0 ? ( - - - - {search || tagFilter - ? 'No matching saved searches.' - : 'No saved searches yet.'} - - - + + + ) : viewMode === 'list' ? (
diff --git a/packages/app/tests/e2e/page-objects/DashboardsListPage.ts b/packages/app/tests/e2e/page-objects/DashboardsListPage.ts index bc126bf5..ca65c59c 100644 --- a/packages/app/tests/e2e/page-objects/DashboardsListPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardsListPage.ts @@ -125,11 +125,11 @@ export class DashboardsListPage { } getEmptyState() { - return this.pageContainer.getByText('No dashboards yet.'); + return this.pageContainer.getByText('No dashboards yet'); } getNoMatchesState() { - return this.pageContainer.getByText('No matching dashboards yet.'); + return this.pageContainer.getByText('No matching dashboards yet'); } getFavoritesSection() { diff --git a/packages/app/tests/e2e/page-objects/SavedSearchesListPage.ts b/packages/app/tests/e2e/page-objects/SavedSearchesListPage.ts index c7cd5b94..39aa60d0 100644 --- a/packages/app/tests/e2e/page-objects/SavedSearchesListPage.ts +++ b/packages/app/tests/e2e/page-objects/SavedSearchesListPage.ts @@ -88,11 +88,11 @@ export class SavedSearchesListPage { } getEmptyState() { - return this.pageContainer.getByText('No saved searches yet.'); + return this.pageContainer.getByText('No saved searches yet'); } getNoMatchesState() { - return this.pageContainer.getByText('No matching saved searches.'); + return this.pageContainer.getByText('No matching saved searches yet'); } getFavoritesSection() {