feat: add reusable EmptyState component and adopt across pages (#2017)

This commit is contained in:
Elizabet Oliveira 2026-04-06 21:26:59 +01:00 committed by GitHub
parent 7d1a8e549a
commit 800689acba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 357 additions and 196 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Add reusable EmptyState component and adopt it across pages for consistent empty/no-data states

View file

@ -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
<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"
/>
```
**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

View file

@ -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[] }) {
<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} />
@ -480,12 +486,21 @@ 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">
<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} />}
@ -503,18 +518,28 @@ export default function AlertsPage() {
</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>
) : (
<EmptyState
h="100%"
icon={<IconBell size={32} />}
title="No alerts created yet"
description={
<>
Alerts can be created from{' '}
<Anchor component={Link} href="/dashboards">
dashboard charts
</Anchor>{' '}
and{' '}
<Anchor component={Link} href="/search">
saved searches
</Anchor>
.
</>
}
/>
)}
</div>
</div>
);

View file

@ -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 ? (
<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

View file

@ -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';
@ -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"
@ -162,7 +148,7 @@ function DBServiceMapPage() {
Go to Team Settings
</Button>
)}
</Flex>
</EmptyState>
</Box>
);
}

View file

@ -16,16 +16,12 @@ import {
Anchor,
Box,
Button,
Card,
Code,
Divider,
Flex,
Group,
Stack,
Paper,
Stepper,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconDeviceLaptop,
@ -35,6 +31,7 @@ import {
} from '@tabler/icons-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import EmptyState from '@/components/EmptyState';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { TimePicker } from '@/components/TimePicker';
import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery';
@ -389,7 +386,11 @@ export default function SessionsPage() {
const targetSession = sessions.find(s => s.sessionId === selectedSession?.id);
return (
<div className="SessionsPage" data-testid="sessions-page">
<div
className="SessionsPage"
data-testid="sessions-page"
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
>
<Head>
<title>Client Sessions - {brandName}</title>
</Head>
@ -423,7 +424,7 @@ export default function SessionsPage() {
}
/>
)}
<Box p="sm">
<Box p="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<form
data-testid="sessions-search-form"
onSubmit={e => {
@ -475,7 +476,13 @@ export default function SessionsPage() {
) : (
<>
{!sessions.length ? (
<Flex
align="center"
justify="center"
style={{ flex: 1, minHeight: 0 }}
>
<SessionSetupInstructions />
</Flex>
) : (
<div style={{ minHeight: 0 }} className="mt-4">
<SessionCardList
@ -499,22 +506,18 @@ SessionsPage.getLayout = withAppNav;
function SessionSetupInstructions() {
const brandName = useBrandDisplayName();
return (
<EmptyState
icon={<IconDeviceLaptop size={32} />}
title="Set up session replays"
description={
<>
<Card w={500} mx="auto" mt="xl" p="xl" withBorder>
<Stack gap="lg">
<Stack align="center" gap="xs">
<ThemeIcon size={56} radius="xl" variant="light" color="gray">
<IconDeviceLaptop size={32} />
</ThemeIcon>
<Title order={3} fw={600}>
Set up session replays
</Title>
<Text size="sm" c="dimmed" ta="center">
Follow these steps to start recording and viewing session replays
with the {brandName} Otel Collector.
</Text>
</Stack>
<Divider />
Follow these steps to start recording and viewing session replays with
the {brandName} Otel Collector.
</>
}
maw={600}
>
<Paper withBorder radius="md" p="xl">
<Stepper active={-1} orientation="vertical" size="md">
<Stepper.Step
label={
@ -524,9 +527,8 @@ function SessionSetupInstructions() {
}
description={
<>
Go to Team Settings, click <Code>Add Source</Code> under
Sources section, and select <Code>Session</Code> as the source
type.
Go to Team Settings, click <Code>Add Source</Code> under Sources
section, and select <Code>Session</Code> as the source type.
</>
}
/>
@ -552,6 +554,7 @@ function SessionSetupInstructions() {
href="https://clickhouse.com/docs/use-cases/observability/clickstack/sdks/browser"
target="_blank"
rel="noopener noreferrer"
size="xs"
>
{brandName} Browser Integration
</Anchor>{' '}
@ -560,8 +563,7 @@ function SessionSetupInstructions() {
}
/>
</Stepper>
</Stack>
</Card>
</>
</Paper>
</EmptyState>
);
}

View file

@ -38,7 +38,6 @@ import {
ActionIcon,
Box,
Button,
Center,
Divider,
Flex,
Group,
@ -87,6 +86,7 @@ import { AlertChannelForm, getAlertReferenceLines } from '@/components/Alerts';
import ChartSQLPreview from '@/components/ChartSQLPreview';
import DBTableChart from '@/components/DBTableChart';
import { DBTimeChart } from '@/components/DBTimeChart';
import EmptyState from '@/components/EmptyState';
import SearchWhereInput, {
getStoredLanguage,
} from '@/components/SearchInput/SearchWhereInput';
@ -1583,14 +1583,11 @@ export default function EditTimeChartForm({
</Flex>
</ErrorBoundary>
{!queryReady && activeTab !== 'markdown' ? (
<Paper shadow="xs" p="xl">
<Center mih={400}>
<Text size="sm">
Please start by selecting a database, table, and timestamp column
above and then click the play button to query data.
</Text>
</Center>
</Paper>
<EmptyState
description="Please start by defining your chart above and then click the play button to query data."
variant="card"
fullWidth
/>
) : undefined}
{queryReady && queriedConfig != null && activeTab === 'table' && (
<div className="flex-grow-1 d-flex flex-column" style={{ height: 400 }}>

View file

@ -32,6 +32,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';
@ -181,12 +182,21 @@ export default function DashboardsListPage() {
);
return (
<div data-testid="dashboards-list-page">
<div
data-testid="dashboards-list-page"
style={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}
>
<Head>
<title>Dashboards - {brandName}</title>
</Head>
<PageHeader>Dashboards</PageHeader>
<Container maw={1200} py="lg" px="lg">
<Container
maw={1200}
py="lg"
px="lg"
w="100%"
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
>
<Text fw={500} size="sm" c="dimmed" mb="sm">
Preset Dashboards
</Text>
@ -332,13 +342,19 @@ export default function DashboardsListPage() {
Failed to load dashboards. Please try refreshing the page.
</Text>
) : filteredDashboards.length === 0 ? (
<Stack align="center" gap="sm" py="xl">
<IconLayoutGrid size={40} opacity={0.3} />
<Text size="sm" c="dimmed" ta="center">
{search || tagFilter
? `No matching dashboards yet.`
: 'No dashboards yet.'}
</Text>
<Flex
align="center"
justify="center"
style={{ flex: 1, minHeight: 0 }}
>
<EmptyState
icon={<IconLayoutGrid size={32} />}
title={
search || tagFilter
? 'No matching dashboards yet'
: 'No dashboards yet'
}
>
<Group>
<Button
component={Link}
@ -359,7 +375,8 @@ export default function DashboardsListPage() {
New Dashboard
</Button>
</Group>
</Stack>
</EmptyState>
</Flex>
) : viewMode === 'list' ? (
<Table highlightOnHover>
<Table.Thead>

View file

@ -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<BoxProps, 'children'>;
type EmptyStateCardProps = EmptyStateBaseProps & {
variant: 'card';
fullWidth?: boolean;
} & Omit<PaperProps, 'children'>;
type EmptyStateProps = EmptyStateDefaultProps | EmptyStateCardProps;
export default function EmptyState({
icon,
title,
description,
children,
variant = 'default',
fullWidth = false,
...restProps
}: EmptyStateProps) {
const inner = (
<Stack align="center" gap="xs">
{icon && (
<ThemeIcon size={56} radius="xl" variant="light" color="gray">
{icon}
</ThemeIcon>
)}
{title && (
<Title order={3} fw={600} size="xl" maw={600}>
{title}
</Title>
)}
{description && (
<Text size="sm" c="dimmed" ta="center" maw={600}>
{description}
</Text>
)}
{children}
</Stack>
);
if (variant === 'card') {
const paperProps = restProps as Omit<PaperProps, 'children'>;
return (
<Paper
withBorder
radius="md"
w="100%"
maw={fullWidth ? undefined : 600}
mx={fullWidth ? undefined : 'auto'}
p="xl"
{...paperProps}
>
<Center mih={100}>{inner}</Center>
</Paper>
);
}
const boxProps = restProps as Omit<BoxProps, 'children'>;
return (
<Center mih={100} mx="auto" {...boxProps}>
{inner}
</Center>
);
}

View file

@ -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 (
<div data-testid="saved-searches-list-page">
<div
data-testid="saved-searches-list-page"
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
>
<Head>
<title>Saved Searches - {brandName}</title>
</Head>
<PageHeader>Saved Searches</PageHeader>
<Container maw={1200} py="lg" px="lg">
<Container
maw={1200}
py="lg"
px="lg"
w="100%"
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
>
{favoritedSavedSearches.length > 0 && (
<>
<Text fw={500} size="sm" c="dimmed" mb="sm">
@ -218,13 +228,19 @@ export default function SavedSearchesListPage() {
Failed to load saved searches. Please try refreshing the page.
</Text>
) : filteredSavedSearches.length === 0 ? (
<Stack align="center" gap="sm" py="xl">
<IconTable size={40} opacity={0.3} />
<Text size="sm" c="dimmed" ta="center">
{search || tagFilter
? 'No matching saved searches.'
: 'No saved searches yet.'}
</Text>
<Flex
align="center"
justify="center"
style={{ flex: 1, minHeight: 0 }}
>
<EmptyState
icon={<IconTable size={32} />}
title={
search || tagFilter
? 'No matching saved searches yet'
: 'No saved searches yet'
}
>
<Button
variant="primary"
leftSection={<IconTable size={16} />}
@ -233,7 +249,8 @@ export default function SavedSearchesListPage() {
>
New Search
</Button>
</Stack>
</EmptyState>
</Flex>
) : viewMode === 'list' ? (
<Table highlightOnHover>
<Table.Thead>

View file

@ -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() {

View file

@ -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() {