Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/empty-state-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

feat: Add reusable EmptyState component and adopt it across pages for consistent empty/no-data states
26 changes: 26 additions & 0 deletions agent_docs/code_style.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,32 @@ 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 |
| `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
<div className="text-center my-4 fs-8">No data</div>
<Text ta="center" c="dimmed">Nothing here</Text>

// ✅ GOOD - use the EmptyState component
<EmptyState
icon={<IconBell size={32} />}
title="No alerts created yet"
description="Create alerts from dashboard charts or saved searches."
variant="card"
/>
```

## Refactoring

- Edit files directly - don't create `component-v2.tsx` copies
Expand Down
82 changes: 50 additions & 32 deletions packages/app/src/AlertsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { notifications } from '@mantine/notifications';
import {
IconAlertTriangle,
IconBell,
IconBrandSlack,
IconChartLine,
IconCheck,
IconChevronRight,
Expand All @@ -33,6 +32,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';

Expand Down Expand Up @@ -463,7 +463,12 @@ function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) {
<IconCheck size={14} /> OK
</Group>
{okData.length === 0 && (
<div className="text-center my-4 fs-8">No alerts</div>
<EmptyState
variant="card"
icon={<IconBell size={32} />}
title="No alerts"
description="All alerts in OK state will appear here."
/>
)}
{okData.map((alert, index) => (
<AlertDetails key={index} alert={alert} />
Expand All @@ -480,41 +485,54 @@ export default function AlertsPage() {
const alerts = React.useMemo(() => data?.data || [], [data?.data]);

return (
<div data-testid="alerts-page" className="AlertsPage">
<div
data-testid="alerts-page"
className="AlertsPage"
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
>
<Head>
<title>Alerts - {brandName}</title>
</Head>
<PageHeader>Alerts</PageHeader>
<div className="my-4">
<Container maw={1500}>
<Alert
icon={<IconInfoCircleFilled size={16} />}
color="gray"
py="xs"
mt="md"
>
Alerts can be{' '}
<a
href="https://clickhouse.com/docs/use-cases/observability/clickstack/alerts"
target="_blank"
rel="noopener noreferrer"
<div className="my-4" style={{ flex: 1 }}>
{isLoading ? (
<div className="text-center my-4 fs-8">Loading...</div>
) : isError ? (
<div className="text-center my-4 fs-8">Error</div>
) : alerts?.length ? (
<Container maw={1500}>
<Alert
icon={<IconInfoCircleFilled size={16} />}
color="gray"
py="xs"
mt="md"
>
created
</a>{' '}
from dashboard charts and saved searches.
</Alert>
{isLoading ? (
<div className="text-center my-4 fs-8">Loading...</div>
) : isError ? (
<div className="text-center my-4 fs-8">Error</div>
) : alerts?.length ? (
<>
<AlertCardList alerts={alerts} />
</>
) : (
<div className="text-center my-4 fs-8">No alerts created yet</div>
)}
</Container>
Alerts can be{' '}
<a
href="https://clickhouse.com/docs/use-cases/observability/clickstack/alerts"
target="_blank"
rel="noopener noreferrer"
>
created
</a>{' '}
from dashboard charts and saved searches.
</Alert>
<AlertCardList alerts={alerts} />
</Container>
) : (
<EmptyState
h="100%"
icon={<IconBell size={32} />}
title="No alerts created yet"
description={
<>
Alerts can be created from{' '}
<Link href="/dashboards">dashboard charts</Link> and{' '}
<Link href="/search">saved searches</Link>.
</>
}
/>
)}
</div>
</div>
);
Expand Down
17 changes: 8 additions & 9 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ import {
Breadcrumbs,
Button,
Card,
Center,
Code,
Flex,
Grid,
Expand All @@ -71,6 +70,7 @@ import {
IconBolt,
IconPlayerPlay,
IconPlus,
IconStack2,
IconTags,
IconX,
} from '@tabler/icons-react';
Expand All @@ -81,6 +81,7 @@ import CodeMirror from '@uiw/react-codemirror';
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 { InputControlled } from '@/components/InputControlled';
import OnboardingModal from '@/components/OnboardingModal';
Expand Down Expand Up @@ -1782,14 +1783,12 @@ function DBSearchPage() {
className="bg-body"
>
{!queryReady ? (
<Paper shadow="xs" p="xl" h="100%">
<Center mih={100} h="100%">
<Text size="sm">
Please start by selecting a source and then click the play
button to query data.
</Text>
</Center>
</Paper>
<EmptyState
h="100%"
icon={<IconStack2 size={32} />}
title="No data to display"
description="Select a source and click the play button to query data."
/>
) : (
<>
<div
Expand Down
32 changes: 9 additions & 23 deletions packages/app/src/DBServiceMapPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,10 @@ import dynamic from 'next/dynamic';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useForm, useWatch } from 'react-hook-form';
import { SourceKind, TTraceSource } from '@hyperdx/common-utils/dist/types';
import {
Box,
Button,
Flex,
Group,
Modal,
Slider,
Text,
Title,
} from '@mantine/core';
import { Box, Button, Group, Modal, Slider, Text } from '@mantine/core';
import { IconConnection } from '@tabler/icons-react';

import EmptyState from '@/components/EmptyState';
import { IS_LOCAL_MODE } from '@/config';
import { withAppNav } from '@/layout';

Expand Down Expand Up @@ -128,20 +121,13 @@ function DBServiceMapPage() {
/>
</Modal>
)}
<Flex
direction="column"
align="center"
justify="center"
gap="sm"
<EmptyState
style={{ flex: 1 }}
icon={<IconConnection size={32} />}
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}
>
<Title size="sm" ta="center" c="var(--color-text-muted)">
No trace sources configured
</Title>
<Text size="xs" ta="center" c="var(--color-text-muted)" maw={400}>
The Service Map visualizes relationships between your services using
trace data. Configure a trace source to get started.
</Text>
{IS_LOCAL_MODE ? (
<Button
variant="primary"
Expand All @@ -162,7 +148,7 @@ function DBServiceMapPage() {
Go to Team Settings
</Button>
)}
</Flex>
</EmptyState>
</Box>
);
}
Expand Down
Loading
Loading