mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: add reusable EmptyState component and adopt across pages (#2017)
This commit is contained in:
parent
7d1a8e549a
commit
800689acba
12 changed files with 357 additions and 196 deletions
5
.changeset/empty-state-component.md
Normal file
5
.changeset/empty-state-component.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add reusable EmptyState component and adopt it across pages for consistent empty/no-data states
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
85
packages/app/src/components/EmptyState.tsx
Normal file
85
packages/app/src/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue