diff --git a/.changeset/empty-state-component.md b/.changeset/empty-state-component.md new file mode 100644 index 000000000..779f7e58f --- /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 535ca9f87..78921be1a 100644 --- a/agent_docs/code_style.md +++ b/agent_docs/code_style.md @@ -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 +
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" +/> +``` + ## 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 2b0545a35..8f10b3f59 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -23,7 +23,6 @@ import { notifications } from '@mantine/notifications'; import { IconAlertTriangle, IconBell, - IconBrandSlack, IconChartLine, IconCheck, IconChevronRight, @@ -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'; @@ -463,7 +463,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 +485,54 @@ 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 717ae4092..c6b5fd01b 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'; @@ -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'; @@ -1782,14 +1783,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 000000000..04c147a0c --- /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 ad3d7c25e..08fc8a57a 100644 --- a/packages/app/src/components/SavedSearches/SavedSearchesListPage.tsx +++ b/packages/app/src/components/SavedSearches/SavedSearchesListPage.tsx @@ -11,7 +11,6 @@ import { Group, Select, SimpleGrid, - Stack, Table, Text, TextInput, @@ -28,6 +27,7 @@ import { IconTable, } from '@tabler/icons-react'; +import EmptyState from '@/components/EmptyState'; import { ListingCard } from '@/components/ListingCard'; import { ListingRow } from '@/components/ListingListRow'; import { PageHeader } from '@/components/PageHeader'; @@ -126,12 +126,21 @@ export default function SavedSearchesListPage() { ); return ( -
+
Saved Searches - {brandName} Saved Searches - + ) : filteredSavedSearches.length === 0 ? ( - - - - {search || tagFilter - ? 'No matching saved searches.' - : 'No saved searches yet.'} - - - + + + ) : viewMode === 'list' ? (