feat: Add dashboard listing page (#1971)

## Summary

This PR introduces a new dashboards listing page, which lists the available dashboards. Each individual dashboard is no longer listed in the sidebar. The new listing page supports searching by name and filtering by tag. This PR is a continuation of @elizabetdev's #1805, with some changes, additional tests, and refactorings.

This page does client-side sort and filter. There is no server-side pagination, filtering, or sorting. That is left as a future improvement, should it become necessary.

### Screenshots or video

<img width="2556" height="794" alt="Screenshot 2026-03-24 at 7 45 54 AM" src="https://github.com/user-attachments/assets/e4c5dba0-6cdf-4f2a-a5f3-2e4e00979729" />
<img width="2553" height="842" alt="Screenshot 2026-03-24 at 7 45 43 AM" src="https://github.com/user-attachments/assets/fc0f5270-d6d3-47ff-be03-762abd82a7d1" />
<img width="2544" height="862" alt="Screenshot 2026-03-24 at 7 45 34 AM" src="https://github.com/user-attachments/assets/4b1957c3-0e6e-4910-ac66-830734604759" />

### How to test locally or on Vercel

The listing page can be tested in vercel preview.

### References



- Linear Issue: Closes HDX-3565
- Related PRs:
This commit is contained in:
Drew Davis 2026-03-25 22:30:52 -04:00 committed by GitHub
parent c9ab6dd0f8
commit e21811cc47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1109 additions and 269 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Add dashboard listing page

View file

@ -0,0 +1,2 @@
import DashboardsListPage from '@/components/Dashboards/DashboardsListPage';
export default DashboardsListPage;

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import {
parseAsFloat,
parseAsStringEnum,
@ -16,7 +17,9 @@ import {
} from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
Box,
Breadcrumbs,
Button,
Grid,
Group,
@ -584,6 +587,14 @@ function ClickhousePage() {
return (
<Box p="sm" data-testid="clickhouse-dashboard-page">
<Breadcrumbs mb="xs" mt="xs" fz="sm">
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
<Text fz="sm" c="dimmed">
ClickHouse
</Text>
</Breadcrumbs>
<OnboardingModal requireSource={false} />
<Group justify="space-between">
<Group>

View file

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { StringParam, useQueryParam } from 'use-query-params';
@ -13,6 +14,8 @@ import {
SavedChartConfig,
} from '@hyperdx/common-utils/dist/types';
import {
Anchor,
Breadcrumbs,
Button,
Collapse,
Container,
@ -508,11 +511,16 @@ function DBDashboardImportPage() {
return (
<div>
<Head>
<title>Create a Dashboard - {brandName}</title>
<title>Import Dashboard - {brandName}</title>
</Head>
<PageHeader>
<div>Create Dashboard &gt; Import Dashboard</div>
</PageHeader>
<Breadcrumbs my="lg" ms="xs" fz="sm">
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
<Text fz="sm" c="dimmed">
Import
</Text>
</Breadcrumbs>
<div>
<Container>
<Stack gap="lg" mt="xl">

View file

@ -9,6 +9,7 @@ import {
} from 'react';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { formatRelative } from 'date-fns';
import produce from 'immer';
@ -38,11 +39,12 @@ import {
SearchConditionLanguage,
SourceKind,
SQLInterval,
TSource,
} from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
Box,
Breadcrumbs,
Button,
Flex,
Group,
@ -1567,17 +1569,42 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
);
}}
/>
{isLocalDashboard && (
<Paper my="lg" p="md" data-testid="temporary-dashboard-banner">
<Flex justify="space-between" align="center">
<Text size="sm">
This is a temporary dashboard and can not be saved.
{isLocalDashboard ? (
<>
<Breadcrumbs mb="xs" mt="xs" fz="sm">
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
<Text fz="sm" c="dimmed">
Temporary Dashboard
</Text>
<Button variant="primary" fw={400} onClick={onCreateDashboard}>
Create New Saved Dashboard
</Button>
</Flex>
</Paper>
</Breadcrumbs>
<Paper my="lg" p="md" data-testid="temporary-dashboard-banner">
<Flex justify="space-between" align="center">
<Text size="sm">
This is a temporary dashboard and can not be saved.
</Text>
<Button
variant="primary"
fw={400}
onClick={onCreateDashboard}
data-testid="create-dashboard-button"
>
Create New Saved Dashboard
</Button>
</Flex>
</Paper>
</>
) : (
<Breadcrumbs mb="xs" mt="xs" fz="sm">
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
<Text fz="sm" c="dimmed">
{dashboard?.name ?? 'Untitled'}
</Text>
</Breadcrumbs>
)}
<Flex mt="xs" mb="md" justify="space-between" align="center">
<DashboardName

View file

@ -18,8 +18,10 @@ import {
import {
ActionIcon,
Alert,
Anchor,
Badge,
Box,
Breadcrumbs,
Card,
Flex,
Grid,
@ -1245,6 +1247,14 @@ function KubernetesDashboardPage() {
return (
<Box data-testid="kubernetes-dashboard-page" p="sm">
<Breadcrumbs mb="xs" mt="xs" fz="sm">
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
<Text fz="sm" c="dimmed">
Kubernetes
</Text>
</Breadcrumbs>
<OnboardingModal requireSource={false} />
{metricSource && logSource && (
<PodDetailsSidePanel

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import {
parseAsString,
parseAsStringEnum,
@ -37,7 +38,9 @@ function pickSourceConfigFields(source: TSource) {
}
import {
ActionIcon,
Anchor,
Box,
Breadcrumbs,
Button,
Grid,
Group,
@ -1548,6 +1551,14 @@ function ServicesDashboardPage() {
return (
<Box p="sm" data-testid="services-dashboard-page">
<Breadcrumbs mb="sm" mt="xs" fz="sm">
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
<Text fz="sm" c="dimmed">
Services
</Text>
</Breadcrumbs>
<OnboardingModal requireSource={false} />
<ServiceDashboardEndpointSidePanel
service={service}

View file

@ -14,8 +14,8 @@ import HyperDX from '@hyperdx/browser';
import { AlertState } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
Badge,
Button,
CloseButton,
Collapse,
Group,
@ -42,17 +42,12 @@ import {
} from '@tabler/icons-react';
import api from '@/api';
import { IS_K8S_DASHBOARD_ENABLED, IS_LOCAL_MODE } from '@/config';
import {
useCreateDashboard,
useDashboards,
useUpdateDashboard,
} from '@/dashboard';
import { IS_LOCAL_MODE } from '@/config';
import InstallInstructionModal from '@/InstallInstructionsModal';
import OnboardingChecklist from '@/OnboardingChecklist';
import { useSavedSearches, useUpdateSavedSearch } from '@/savedSearch';
import { useLogomark, useWordmark } from '@/theme/ThemeProvider';
import type { SavedSearch, ServerDashboard } from '@/types';
import type { SavedSearch } from '@/types';
import { UserPreferencesModal } from '@/UserPreferencesModal';
import { useUserPreferences } from '@/useUserPreferences';
import { useWindowSize } from '@/utils';
@ -74,7 +69,6 @@ const APP_VERSION =
process.env.NEXT_PUBLIC_APP_VERSION ?? packageJson.version ?? 'dev';
const UNTAGGED_SEARCHES_GROUP_NAME = 'Saved Searches';
const UNTAGGED_DASHBOARDS_GROUP_NAME = 'Saved Dashboards';
// Navigation link configuration
type NavLinkConfig = {
@ -115,37 +109,6 @@ const NAV_LINKS: NavLinkConfig[] = [
},
];
function NewDashboardButton() {
const createDashboard = useCreateDashboard();
return (
<Button
data-testid="create-dashboard-button"
variant="transparent"
color="var(--color-text)"
py="0px"
px="sm"
fw={400}
onClick={() =>
createDashboard.mutate(
{
name: 'My Dashboard',
tiles: [],
tags: [],
},
{
onSuccess: data => {
Router.push(`/dashboards/${data.id}`);
},
},
)
}
>
<span className="pe-2">+</span> Create Dashboard
</Button>
);
}
function SearchInput({
placeholder,
value,
@ -402,16 +365,8 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
} = useSavedSearches();
const logViews = useMemo(() => logViewsData ?? [], [logViewsData]);
const updateDashboard = useUpdateDashboard();
const updateLogView = useUpdateSavedSearch();
const {
data: dashboardsData,
isLoading: isDashboardsLoading,
refetch: refetchDashboards,
} = useDashboards();
const dashboards = useMemo(() => dashboardsData ?? [], [dashboardsData]);
const router = useRouter();
const { pathname, query } = router;
@ -430,11 +385,6 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
key: 'isSearchExpanded',
defaultValue: true,
});
const [isDashboardsExpanded, setIsDashboardExpanded] =
useLocalStorage<boolean>({
key: 'isDashboardsExpanded',
defaultValue: true,
});
const { width } = useWindowSize();
const [isPreferCollapsed, setIsPreferCollapsed] = useLocalStorage<boolean>({
@ -475,24 +425,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
untaggedGroupName: UNTAGGED_SEARCHES_GROUP_NAME,
});
const {
q: dashboardsListQ,
setQ: setDashboardsListQ,
filteredList: filteredDashboardsList,
groupedFilteredList: groupedFilteredDashboardsList,
} = useSearchableList({
items: dashboards,
untaggedGroupName: UNTAGGED_DASHBOARDS_GROUP_NAME,
});
const [isDashboardsPresetsCollapsed, setDashboardsPresetsCollapsed] =
useLocalStorage<boolean>({
key: 'isDashboardsPresetsCollapsed',
defaultValue: false,
});
const savedSearchesResultsRef = useRef<HTMLDivElement>(null);
const dashboardsResultsRef = useRef<HTMLDivElement>(null);
const renderLogViewLink = useCallback(
(savedSearch: SavedSearch) => (
@ -571,50 +504,6 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
[logViews, refetchLogViews, updateLogView],
);
const renderDashboardLink = useCallback(
(dashboard: ServerDashboard) => (
<Link
href={`/dashboards/${dashboard.id}`}
key={dashboard.id}
tabIndex={0}
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]: dashboard.id === query.dashboardId,
})}
draggable
data-dashboardid={dashboard.id}
>
{dashboard.name}
</Link>
),
[query.dashboardId],
);
const handleDashboardDragEnd = useCallback(
(target: HTMLElement | null, name: string | null) => {
if (!target?.dataset.dashboardid || name == null) {
return;
}
const dashboard = dashboards.find(
d => d.id === target.dataset.dashboardid,
);
if (dashboard?.tags?.includes(name)) {
return;
}
updateDashboard.mutate(
{
id: target.dataset.dashboardid,
tags: name === UNTAGGED_DASHBOARDS_GROUP_NAME ? [] : [name],
},
{
onSuccess: () => {
refetchDashboards();
},
},
);
},
[dashboards, refetchDashboards, updateDashboard],
);
const [
UserPreferencesOpen,
{ close: closeUserPreferences, open: openUserPreferences },
@ -768,104 +657,17 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
{/* Dashboards */}
<AppNavLink
label="Dashboards"
href="/dashboards"
href="/dashboards/list"
icon={<IconLayoutGrid size={16} />}
isExpanded={isDashboardsExpanded}
onToggle={() => setIsDashboardExpanded(!isDashboardsExpanded)}
/>
{!isCollapsed && (
<Collapse in={isDashboardsExpanded}>
<div className={styles.subMenu}>
<NewDashboardButton />
{isDashboardsLoading && dashboardsData == null ? (
<Loader variant="dots" mx="md" my="xs" size="sm" />
) : (
<>
<SearchInput
placeholder="Saved Dashboards"
value={dashboardsListQ}
onChange={setDashboardsListQ}
onEnterDown={() => {
(
dashboardsResultsRef?.current
?.firstChild as HTMLAnchorElement
)?.focus?.();
}}
/>
<AppNavLinkGroups
name="dashboards"
groups={groupedFilteredDashboardsList}
renderLink={renderDashboardLink}
forceExpandGroups={!!dashboardsListQ}
onDragEnd={handleDashboardDragEnd}
/>
{dashboards.length === 0 && (
<div className={styles.emptyMessage}>
No saved dashboards
</div>
)}
{dashboardsListQ &&
filteredDashboardsList.length === 0 ? (
<div className={styles.emptyMessage}>
No results matching <i>{dashboardsListQ}</i>
</div>
) : null}
</>
)}
<AppNavGroupLabel
name="Presets"
collapsed={isDashboardsPresetsCollapsed}
onClick={() =>
setDashboardsPresetsCollapsed(
!isDashboardsPresetsCollapsed,
)
}
/>
<Collapse in={!isDashboardsPresetsCollapsed}>
<Link
href={`/clickhouse`}
tabIndex={0}
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]:
pathname.startsWith('/clickhouse'),
})}
data-testid="nav-link-clickhouse-dashboard"
>
ClickHouse
</Link>
<Link
href={`/services`}
tabIndex={0}
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]:
pathname.startsWith('/services'),
})}
data-testid="nav-link-services-dashboard"
>
Services
</Link>
{IS_K8S_DASHBOARD_ENABLED && (
<Link
href={`/kubernetes`}
tabIndex={0}
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]:
pathname.startsWith('/kubernetes'),
})}
data-testid="nav-link-k8s-dashboard"
>
Kubernetes
</Link>
)}
</Collapse>
</div>
</Collapse>
<Text size="xs" px="lg" py="xs" fw="lighter" fs="italic">
Saved dashboards have moved! Try the{' '}
<Anchor component={Link} href="/dashboards/list">
Dashboards page
</Anchor>
.
</Text>
)}
{/* Team Settings (Cloud only) */}

View file

@ -0,0 +1,80 @@
import Link from 'next/link';
import { ActionIcon, Badge, Card, Group, Menu, Text } from '@mantine/core';
import { IconDots, IconTrash } from '@tabler/icons-react';
export function DashboardCard({
name,
href,
description,
tags,
onDelete,
}: {
name: string;
href: string;
description?: string;
tags?: string[];
onDelete?: () => void;
}) {
return (
<Card
component={Link}
href={href}
withBorder
padding="lg"
radius="sm"
style={{ cursor: 'pointer', textDecoration: 'none' }}
>
<Group justify="space-between" mb="xs" wrap="nowrap">
<Text
fw={500}
lineClamp={1}
style={{ flex: 1, minWidth: 0 }}
title={name}
>
{name}
</Text>
{onDelete && (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="secondary"
size="sm"
onClick={e => e.preventDefault()}
>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={e => {
e.preventDefault();
onDelete();
}}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
{description && (
<Text size="sm" c="dimmed">
{description}
</Text>
)}
{tags && tags.length > 0 && (
<Group gap="xs" mt="xs">
{tags.map(tag => (
<Badge key={tag} variant="light" size="xs">
{tag}
</Badge>
))}
</Group>
)}
</Card>
);
}

View file

@ -0,0 +1,78 @@
import Router from 'next/router';
import { ActionIcon, Badge, Group, Menu, Table, Text } from '@mantine/core';
import { IconDots, IconTrash } from '@tabler/icons-react';
import type { Dashboard } from '../../dashboard';
export function DashboardListRow({
dashboard,
onDelete,
}: {
dashboard: Dashboard;
onDelete: (id: string) => void;
}) {
const href = `/dashboards/${dashboard.id}`;
return (
<Table.Tr
style={{ cursor: 'pointer' }}
onClick={e => {
if (e.metaKey || e.ctrlKey) {
window.open(href, '_blank');
} else {
Router.push(href);
}
}}
onAuxClick={e => {
if (e.button === 1) {
window.open(href, '_blank');
}
}}
>
<Table.Td>
<Text fw={500} size="sm">
{dashboard.name}
</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
{dashboard.tags.map(tag => (
<Badge key={tag} variant="light" size="xs">
{tag}
</Badge>
))}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{dashboard.tiles.length}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="secondary"
size="sm"
onClick={e => e.stopPropagation()}
>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={e => {
e.stopPropagation();
onDelete(dashboard.id);
}}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
);
}

View file

@ -0,0 +1,334 @@
import { useCallback, useMemo, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import Router from 'next/router';
import { useQueryState } from 'nuqs';
import {
ActionIcon,
Button,
Container,
Flex,
Group,
Menu,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
} from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconChevronDown,
IconDeviceFloppy,
IconLayoutGrid,
IconList,
IconPlus,
IconSearch,
IconUpload,
} from '@tabler/icons-react';
import { PageHeader } from '@/components/PageHeader';
import { IS_K8S_DASHBOARD_ENABLED } from '@/config';
import {
useCreateDashboard,
useDashboards,
useDeleteDashboard,
} from '@/dashboard';
import { useBrandDisplayName } from '@/theme/ThemeProvider';
import { useConfirm } from '@/useConfirm';
import { withAppNav } from '../../layout';
import { DashboardCard } from './DashboardCard';
import { DashboardListRow } from './DashboardListRow';
const PRESET_DASHBOARDS = [
{
name: 'Services',
href: '/services',
description: 'Monitor HTTP endpoints, latency, and error rates',
},
{
name: 'ClickHouse',
href: '/clickhouse',
description: 'ClickHouse cluster health and query performance',
},
...(IS_K8S_DASHBOARD_ENABLED
? [
{
name: 'Kubernetes',
href: '/kubernetes',
description: 'Kubernetes cluster monitoring and pod health',
},
]
: []),
];
export default function DashboardsListPage() {
const brandName = useBrandDisplayName();
const { data: dashboards, isLoading, isError } = useDashboards();
const confirm = useConfirm();
const createDashboard = useCreateDashboard();
const deleteDashboard = useDeleteDashboard();
const [search, setSearch] = useState('');
const [tagFilter, setTagFilter] = useQueryState('tag');
const [viewMode, setViewMode] = useLocalStorage<'grid' | 'list'>({
key: 'dashboardsViewMode',
defaultValue: 'grid',
});
const allTags = useMemo(() => {
if (!dashboards) return [];
const tags = new Set<string>();
dashboards.forEach(d => d.tags.forEach(t => tags.add(t)));
return Array.from(tags).sort();
}, [dashboards]);
const filteredDashboards = useMemo(() => {
if (!dashboards) return [];
let result = dashboards;
if (tagFilter) {
result = result.filter(d => d.tags.includes(tagFilter));
}
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter(
d =>
d.name.toLowerCase().includes(q) ||
d.tags.some(t => t.toLowerCase().includes(q)),
);
}
return result.slice().sort((a, b) => a.name.localeCompare(b.name));
}, [dashboards, search, tagFilter]);
const handleCreate = useCallback(() => {
createDashboard.mutate(
{ name: 'My Dashboard', tiles: [], tags: [] },
{
onSuccess: data => {
Router.push(`/dashboards/${data.id}`);
},
onError: () => {
notifications.show({
message: 'Failed to create dashboard',
color: 'red',
});
},
},
);
}, [createDashboard]);
const handleDelete = useCallback(
async (id: string) => {
const confirmed = await confirm(
'Are you sure you want to delete this dashboard? This action cannot be undone.',
'Delete',
{ variant: 'danger' },
);
if (!confirmed) return;
deleteDashboard.mutate(id, {
onSuccess: () => {
notifications.show({
message: 'Dashboard deleted',
color: 'green',
});
},
onError: () => {
notifications.show({
message: 'Failed to delete dashboard',
color: 'red',
});
},
});
},
[confirm, deleteDashboard],
);
return (
<div data-testid="dashboards-list-page">
<Head>
<title>Dashboards - {brandName}</title>
</Head>
<PageHeader>Dashboards</PageHeader>
<Container maw={1200} py="lg" px="lg">
<Text fw={500} size="sm" c="dimmed" mb="sm">
Preset Dashboards
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} mb="xl">
{PRESET_DASHBOARDS.map(p => (
<DashboardCard key={p.href} {...p} />
))}
</SimpleGrid>
<Text fw={500} size="sm" c="dimmed" mb="sm">
Team Dashboards
</Text>
<Flex justify="space-between" align="center" mb="lg" gap="sm">
<Group gap="xs" style={{ flex: 1 }}>
<TextInput
placeholder="Search by name"
leftSection={<IconSearch size={16} />}
value={search}
onChange={e => setSearch(e.currentTarget.value)}
style={{ flex: 1, maxWidth: 400 }}
miw={100}
/>
{allTags.length > 0 && (
<Select
placeholder="Filter by tag"
data={allTags}
value={tagFilter}
onChange={v => setTagFilter(v)}
clearable
searchable
style={{ maxWidth: 200 }}
/>
)}
</Group>
<Group gap="xs" align="center">
<ActionIcon.Group>
<ActionIcon
variant={viewMode === 'grid' ? 'primary' : 'secondary'}
size="input-sm"
onClick={() => setViewMode('grid')}
aria-label="Grid view"
>
<IconLayoutGrid size={16} />
</ActionIcon>
<ActionIcon
variant={viewMode === 'list' ? 'primary' : 'secondary'}
size="input-sm"
onClick={() => setViewMode('list')}
aria-label="List view"
>
<IconList size={16} />
</ActionIcon>
</ActionIcon.Group>
<Button
component={Link}
href="/dashboards/import"
variant="secondary"
leftSection={<IconUpload size={16} />}
data-testid="import-dashboard-button"
>
Import
</Button>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<Button
variant="primary"
leftSection={<IconPlus size={16} />}
rightSection={<IconChevronDown size={14} />}
loading={createDashboard.isPending}
data-testid="new-dashboard-button"
>
New Dashboard
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconDeviceFloppy size={14} />}
onClick={handleCreate}
data-testid="create-dashboard-button"
>
Saved Dashboard
<Text size="xs" c="dimmed">
Persisted for your team
</Text>
</Menu.Item>
<Menu.Item
component={Link}
href="/dashboards"
leftSection={<IconPlus size={14} />}
data-testid="temp-dashboard-button"
>
Temporary Dashboard
<Text size="xs" c="dimmed">
Lives in your browser only
</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Flex>
{isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
Loading dashboards...
</Text>
) : isError ? (
<Text size="sm" c="red" ta="center" py="xl">
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>
<Group>
<Button
component={Link}
href="/dashboards/import"
variant="secondary"
leftSection={<IconUpload size={16} />}
data-testid="empty-import-dashboard-button"
>
Import
</Button>
<Button
variant="primary"
leftSection={<IconPlus size={16} />}
onClick={handleCreate}
loading={createDashboard.isPending}
data-testid="empty-create-dashboard-button"
>
New Dashboard
</Button>
</Group>
</Stack>
) : viewMode === 'list' ? (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th>Tiles</Table.Th>
<Table.Th w={50} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredDashboards.map(d => (
<DashboardListRow
key={d.id}
dashboard={d}
onDelete={handleDelete}
/>
))}
</Table.Tbody>
</Table>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{filteredDashboards.map(d => (
<DashboardCard
key={d.id}
name={d.name}
href={`/dashboards/${d.id}`}
tags={d.tags}
description={`${d.tiles.length} ${d.tiles.length === 1 ? 'tile' : 'tiles'}`}
onDelete={() => handleDelete(d.id)}
/>
))}
</SimpleGrid>
)}
</Container>
</div>
);
}
DashboardsListPage.getLayout = withAppNav;

View file

@ -60,8 +60,6 @@ export type SearchConfig = {
orderBy?: string | null;
};
export type ServerDashboard = z.infer<typeof DashboardSchema>;
export type Session = {
errorCount: string;
maxTimestamp: string;

View file

@ -39,24 +39,9 @@ test.describe('Navigation', { tag: ['@core'] }, () => {
contentTestId: 'service-map-page',
},
{
testId: 'nav-link-dashboards',
href: '/dashboards',
contentTestId: 'dashboard-page',
},
{
testId: 'nav-link-clickhouse-dashboard',
href: '/clickhouse',
contentTestId: 'clickhouse-dashboard-page',
},
{
testId: 'nav-link-services-dashboard',
href: '/services',
contentTestId: 'services-dashboard-page',
},
{
testId: 'nav-link-k8s-dashboard',
href: '/kubernetes',
contentTestId: 'kubernetes-dashboard-page',
testId: 'nav-link-dashboards-list',
href: '/dashboards/list',
contentTestId: 'dashboards-list-page',
},
];

View file

@ -7,6 +7,7 @@
* legacy "series" format.
*/
import { DashboardPage, TileConfig } from '../page-objects/DashboardPage';
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
import { getApiUrl, getSources, getUserAccessKey } from '../utils/api-helpers';
import { expect, test } from '../utils/base-test';
@ -144,6 +145,7 @@ test.describe(
};
let dashboardPage: DashboardPage;
let dashboardListPage: DashboardsListPage;
let tiles: any;
await test.step('Create dashboard via external API', async () => {
@ -160,10 +162,11 @@ test.describe(
await test.step('Navigate to dashboard and verify tiles', async () => {
dashboardPage = new DashboardPage(page);
await dashboardPage.goto(); // Navigate to dashboards list
dashboardListPage = new DashboardsListPage(page);
await dashboardListPage.goto(); // Navigate to dashboards list
// Find and click on the created dashboard
await dashboardPage.goToDashboardByName(dashboardPayload.name);
await dashboardListPage.clickDashboard(dashboardPayload.name);
await expect(
page.getByRole('heading', { name: dashboardPayload.name }),
@ -305,11 +308,11 @@ test.describe(
expect(updateResponse.ok()).toBeTruthy();
// Navigate to dashboard through UI (via AppNav)
const dashboardPage = new DashboardPage(page);
await dashboardPage.goto(); // Navigate to dashboards list
const dashboardListPage = new DashboardsListPage(page);
await dashboardListPage.goto(); // Navigate to dashboards list
// Find and click on the updated dashboard
await dashboardPage.goToDashboardByName(updatedName);
await dashboardListPage.clickDashboard(updatedName);
await expect(
page.getByRole('heading', { name: updatedName }),
@ -373,8 +376,8 @@ test.describe(
expect(getResponse.status()).toBe(404);
// Verify dashboard is not present in UI
const dashboardPage = new DashboardPage(page);
await dashboardPage.goto(); // Navigate to dashboards list
const dashboardListPage = new DashboardsListPage(page);
await dashboardListPage.goto(); // Navigate to dashboards list
// First verify the kept dashboard is visible (ensures data has loaded)
const keptDashboardLink = page.locator(`text="${dashboardToKeep.name}"`);

View file

@ -6,6 +6,7 @@
* that partners and automation tools would use.
*/
import { DashboardPage, SeriesData } from '../page-objects/DashboardPage';
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
import { getApiUrl, getSources, getUserAccessKey } from '../utils/api-helpers';
import { expect, test } from '../utils/base-test';
@ -148,6 +149,7 @@ test.describe(
};
let dashboardPage: DashboardPage;
let dashboardsListPage: DashboardsListPage;
let tiles: any;
await test.step('Create dashboard via external API', async () => {
@ -164,10 +166,11 @@ test.describe(
await test.step('Navigate to dashboard and verify tiles', async () => {
dashboardPage = new DashboardPage(page);
await dashboardPage.goto(); // Navigate to dashboards list
dashboardsListPage = new DashboardsListPage(page);
await dashboardsListPage.goto(); // Navigate to dashboards list
// Find and click on the created dashboard
await dashboardPage.goToDashboardByName(dashboardPayload.name);
await dashboardsListPage.clickDashboard(dashboardPayload.name);
await expect(
page.getByRole('heading', { name: dashboardPayload.name }),
@ -305,11 +308,11 @@ test.describe(
expect(updateResponse.ok()).toBeTruthy();
// Navigate to dashboard through UI (via AppNav)
const dashboardPage = new DashboardPage(page);
await dashboardPage.goto(); // Navigate to dashboards list
const dashboardsListPage = new DashboardsListPage(page);
await dashboardsListPage.goto(); // Navigate to dashboards list
// Find and click on the updated dashboard
await dashboardPage.goToDashboardByName(updatedName);
await dashboardsListPage.clickDashboard(updatedName);
await expect(
page.getByRole('heading', { name: updatedName }),
@ -373,8 +376,8 @@ test.describe(
expect(getResponse.status()).toBe(404);
// Verify dashboard is not present in UI
const dashboardPage = new DashboardPage(page);
await dashboardPage.goto(); // Navigate to dashboards list
const dashboardsListPage = new DashboardsListPage(page);
await dashboardsListPage.goto(); // Navigate to dashboards list
// First verify the kept dashboard is visible (ensures data has loaded)
const keptDashboardLink = page.locator(`text="${dashboardToKeep.name}"`);

View file

@ -2,6 +2,7 @@ import { DisplayType } from '@hyperdx/common-utils/dist/types';
import { AlertsPage } from '../page-objects/AlertsPage';
import { DashboardPage } from '../page-objects/DashboardPage';
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
import { expect, test } from '../utils/base-test';
import {
DEFAULT_LOGS_SOURCE_NAME,
@ -10,9 +11,11 @@ import {
test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
let dashboardPage: DashboardPage;
let dashboardsListPage: DashboardsListPage;
test.beforeEach(async ({ page }) => {
dashboardPage = new DashboardPage(page);
dashboardsListPage = new DashboardsListPage(page);
await dashboardPage.goto();
});
@ -98,7 +101,7 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
});
await test.step('Verify dashboard appears in dashboards list', async () => {
await dashboardPage.goto();
await dashboardsListPage.goto();
// Look for our dashboard in the list
const dashboardLink = dashboardPage.page.locator(

View file

@ -0,0 +1,240 @@
import { DashboardPage } from '../page-objects/DashboardPage';
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
import { getApiUrl } from '../utils/api-helpers';
import { expect, test } from '../utils/base-test';
test.describe('Dashboards Listing Page', { tag: ['@dashboard'] }, () => {
let dashboardsListPage: DashboardsListPage;
let dashboardPage: DashboardPage;
test.beforeEach(async ({ page }) => {
dashboardsListPage = new DashboardsListPage(page);
dashboardPage = new DashboardPage(page);
});
test(
'should display the dashboards listing page with preset dashboards',
{ tag: '@full-stack' },
async () => {
await dashboardsListPage.goto();
await test.step('Verify the page container is visible', async () => {
await expect(dashboardsListPage.pageContainer).toBeVisible();
});
await test.step('Verify preset dashboard cards are visible', async () => {
await expect(
dashboardsListPage.getPresetDashboardCard('Services'),
).toBeVisible();
await expect(
dashboardsListPage.getPresetDashboardCard('ClickHouse'),
).toBeVisible();
await expect(
dashboardsListPage.getPresetDashboardCard('Kubernetes'),
).toBeVisible();
});
},
);
test(
'should create a new dashboard from the listing page',
{ tag: '@full-stack' },
async ({ page }) => {
await dashboardsListPage.goto();
await test.step('Click the New Dashboard button', async () => {
await dashboardsListPage.createNewDashboard();
});
await test.step('Verify navigation to the individual dashboard page', async () => {
await expect(page).toHaveURL(/\/dashboards\/.+/);
});
await test.step('Verify the dashboard name heading "My Dashboard" is visible', async () => {
await expect(
page.getByRole('heading', { name: 'My Dashboard', level: 3 }),
).toBeVisible();
});
},
);
test('should search dashboards by name', { tag: '@full-stack' }, async () => {
const ts = Date.now();
const uniqueName = `E2E Search Dashboard ${ts}`;
await test.step('Create a dashboard with a unique name', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
await dashboardPage.editDashboardName(uniqueName);
});
await test.step('Navigate to the dashboards listing page', async () => {
await dashboardsListPage.goto();
});
await test.step('Search for the unique dashboard name', async () => {
await dashboardsListPage.searchDashboards(uniqueName);
});
await test.step('Verify the dashboard appears in results', async () => {
await expect(
dashboardsListPage.getDashboardCard(uniqueName),
).toBeVisible();
});
await test.step('Search for a non-existent name', async () => {
await dashboardsListPage.searchDashboards(
`nonexistent-dashboard-xyz-${ts}`,
);
});
await test.step('Verify no matches state is shown', async () => {
await expect(dashboardsListPage.getNoMatchesState()).toBeVisible();
});
});
test(
'should switch between grid and list views',
{ tag: '@full-stack' },
async () => {
const ts = Date.now();
const uniqueName = `E2E View Toggle Dashboard ${ts}`;
await test.step('Create a dashboard with a unique name', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
await dashboardPage.editDashboardName(uniqueName);
});
await test.step('Navigate to the dashboards listing page', async () => {
await dashboardsListPage.goto();
});
await test.step('Verify grid view is the default and dashboard card is visible', async () => {
await expect(
dashboardsListPage.getDashboardCard(uniqueName),
).toBeVisible();
});
await test.step('Switch to list view', async () => {
await dashboardsListPage.switchToListView();
});
await test.step('Verify the dashboard appears in a table row', async () => {
await expect(
dashboardsListPage.getDashboardRow(uniqueName),
).toBeVisible();
});
await test.step('Switch back to grid view', async () => {
await dashboardsListPage.switchToGridView();
});
await test.step('Verify the dashboard card reappears', async () => {
await expect(
dashboardsListPage.getDashboardCard(uniqueName),
).toBeVisible();
});
},
);
test(
'should delete a dashboard from the listing page',
{ tag: '@full-stack' },
async ({ page }) => {
const ts = Date.now();
const uniqueName = `E2E Delete Dashboard ${ts}`;
await test.step('Create a dashboard with a unique name', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
await dashboardPage.editDashboardName(uniqueName);
});
await test.step('Navigate to the dashboards listing page', async () => {
await dashboardsListPage.goto();
});
await test.step('Delete the dashboard via the card menu', async () => {
await dashboardsListPage.deleteDashboardFromCard(uniqueName);
});
await test.step('Verify the dashboard is no longer visible', async () => {
await expect(
dashboardsListPage.getDashboardCard(uniqueName),
).toBeHidden();
});
await test.step('Verify the "Dashboard deleted" notification appears', async () => {
await expect(page.getByText('Dashboard deleted')).toBeVisible();
});
},
);
test(
'should filter dashboards by tag',
{ tag: '@full-stack' },
async ({ page }) => {
const ts = Date.now();
const taggedName = `E2E Tagged Dashboard ${ts}`;
const untaggedName = `E2E Untagged Dashboard ${ts}`;
const tag = `e2e-tag-${ts}`;
const API_URL = getApiUrl();
await test.step('Create a dashboard with a tag', async () => {
// Create dashboard
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
await dashboardPage.editDashboardName(taggedName);
// Extract dashboard ID from the URL and PATCH tags via API
const dashboardId = page.url().split('/dashboards/')[1]?.split('?')[0];
await page.request.patch(`${API_URL}/dashboards/${dashboardId}`, {
data: { tags: [tag] },
});
});
await test.step('Create a dashboard without the tag', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
await dashboardPage.editDashboardName(untaggedName);
});
await test.step('Navigate to the listing page and verify both are visible', async () => {
await dashboardsListPage.goto();
await expect(
dashboardsListPage.getDashboardCard(taggedName),
).toBeVisible();
await expect(
dashboardsListPage.getDashboardCard(untaggedName),
).toBeVisible();
});
await test.step('Select the tag filter', async () => {
await dashboardsListPage.selectTagFilter(tag);
});
await test.step('Verify only the tagged dashboard is shown', async () => {
await expect(
dashboardsListPage.getDashboardCard(taggedName),
).toBeVisible();
await expect(
dashboardsListPage.getDashboardCard(untaggedName),
).toBeHidden();
});
await test.step('Clear the tag filter', async () => {
await dashboardsListPage.clearTagFilter();
});
await test.step('Verify both dashboards are visible again', async () => {
await expect(
dashboardsListPage.getDashboardCard(taggedName),
).toBeVisible();
await expect(
dashboardsListPage.getDashboardCard(untaggedName),
).toBeVisible();
});
},
);
});

View file

@ -280,8 +280,8 @@ test.describe('Saved Search Functionality', () => {
});
await test.step('Navigate to dashboards page', async () => {
await page.goto('/dashboards');
await expect(page.getByTestId('dashboard-page')).toBeVisible();
await page.goto('/dashboards/list');
await expect(page.getByTestId('dashboards-list-page')).toBeVisible();
});
await test.step('Navigate back to saved search', async () => {

View file

@ -1,6 +1,6 @@
import { DashboardsListPage } from 'tests/e2e/page-objects/DashboardsListPage';
import type { Locator, Page } from '@playwright/test';
import { DashboardPage } from '../../page-objects/DashboardPage';
import { SearchPage } from '../../page-objects/SearchPage';
import { expect, test } from '../../utils/base-test';
@ -90,8 +90,9 @@ test.describe('Multiline Input', { tag: '@search' }, () => {
await searchPage.goto();
await searchPage.switchToSQLMode();
} else {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goto();
const dashboardsListPage = new DashboardsListPage(page);
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
// Dashboard uses Controller + SQL/SearchInputV2 directly (no where-language-switch wrapper)
await page.getByRole('textbox', { name: 'Query language' }).click();
await page.getByRole('option', { name: 'SQL', exact: true }).click();
@ -118,8 +119,9 @@ test.describe('Multiline Input', { tag: '@search' }, () => {
await searchPage.goto();
await searchPage.switchToLuceneMode();
} else {
const dashboardPage = new DashboardPage(page);
await dashboardPage.goto();
const dashboardsListPage = new DashboardsListPage(page);
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
// Dashboard has no where-language-switch wrapper; use Query language textbox directly
await page.getByRole('textbox', { name: 'Query language' }).click();
await page.getByRole('option', { name: 'Lucene', exact: true }).click();

View file

@ -0,0 +1,109 @@
import { DashboardPage } from '../page-objects/DashboardPage';
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
import { expect, test } from '../utils/base-test';
test.describe('Temporary Dashboard', { tag: ['@dashboard'] }, () => {
let dashboardPage: DashboardPage;
let dashboardsListPage: DashboardsListPage;
test.beforeEach(async ({ page }) => {
dashboardPage = new DashboardPage(page);
dashboardsListPage = new DashboardsListPage(page);
});
test(
'should navigate from listing page to temporary dashboard',
{ tag: '@full-stack' },
async ({ page }) => {
await dashboardsListPage.goto();
await test.step('Click New Dashboard and select Temporary Dashboard', async () => {
await dashboardsListPage.goToTempDashboard();
});
await test.step('Verify the temporary dashboard banner is visible', async () => {
await expect(dashboardPage.temporaryDashboardBanner).toBeVisible();
await expect(dashboardPage.temporaryDashboardBanner).toContainText(
'This is a temporary dashboard and can not be saved.',
);
});
},
);
test(
'should persist temporary dashboard content in URL params across navigation',
{},
async ({ page }) => {
await dashboardPage.goto();
await test.step('Verify the temporary dashboard banner is visible', async () => {
await expect(dashboardPage.temporaryDashboardBanner).toBeVisible();
});
await test.step('Add a tile to the temporary dashboard', async () => {
await dashboardPage.addTile();
await dashboardPage.chartEditor.createBasicChart('Temp Chart');
});
await test.step('Verify the tile and chart render', async () => {
await expect(dashboardPage.getTiles()).toHaveCount(1, {
timeout: 10000,
});
await expect(dashboardPage.getChartContainers()).toHaveCount(1, {
timeout: 10000,
});
});
let savedUrl: string;
await test.step('Verify the URL contains the dashboard query param', async () => {
savedUrl = page.url();
expect(savedUrl).toContain('dashboard=');
});
await test.step('Navigate away to /search', async () => {
await page.goto('/search');
await expect(page).toHaveURL(/\/search/);
});
await test.step('Navigate back and verify persistence', async () => {
await page.goto(savedUrl);
await expect(dashboardPage.getTiles()).toHaveCount(1, {
timeout: 10000,
});
await expect(dashboardPage.getChartContainers().first()).toBeVisible({
timeout: 10000,
});
});
},
);
test(
'should convert temporary dashboard to saved dashboard',
{ tag: '@full-stack' },
async ({ page }) => {
await dashboardPage.goto();
await test.step('Verify the temporary dashboard banner is visible', async () => {
await expect(dashboardPage.temporaryDashboardBanner).toBeVisible();
});
await test.step('Click Create New Saved Dashboard', async () => {
await dashboardPage.createButton.click();
});
await test.step('Verify navigation to a saved dashboard', async () => {
await expect(page).toHaveURL(/\/dashboards\/.+/, { timeout: 10000 });
});
await test.step('Verify the temporary banner is replaced by breadcrumbs', async () => {
await expect(dashboardPage.temporaryDashboardBanner).toBeHidden();
await expect(
page
.getByTestId('dashboard-page')
.getByRole('link', { name: 'Dashboards' }),
).toBeVisible();
});
},
);
});

View file

@ -142,7 +142,7 @@ export class DashboardPage {
}
/**
* Navigate to dashboards list
* Navigate to the temporary dashboards page
*/
async goto() {
await this.page.goto('/dashboards', { waitUntil: 'networkidle' });
@ -160,7 +160,7 @@ export class DashboardPage {
*/
async createNewDashboard() {
await this.createDashboardButton.click();
await this.page.waitForURL('**/dashboards**');
await this.page.waitForURL(/\/dashboards\/.+/);
}
async changeGranularity(granularity: string) {
@ -228,7 +228,7 @@ export class DashboardPage {
*/
async openNewTileEditor() {
await this.createDashboardButton.click();
await this.page.waitForURL('**/dashboards**');
await this.page.waitForURL(/\/dashboards\/.+/);
await this.addDropdownButton.click();
await this.addTileMenuItem.click();
await expect(this.chartEditor.nameInput).toBeVisible();

View file

@ -0,0 +1,129 @@
/**
* DashboardsListPage - Page object for the dashboards listing page
* Encapsulates interactions with dashboard browsing, search, filtering, and management
*/
import { expect, Locator, Page } from '@playwright/test';
export class DashboardsListPage {
readonly page: Page;
readonly pageContainer: Locator;
readonly searchInput: Locator;
readonly newDashboardButton: Locator;
readonly createDashboardButton: Locator;
readonly importDashboardButton: Locator;
readonly tempDashboardButton: Locator;
readonly gridViewButton: Locator;
readonly listViewButton: Locator;
private readonly emptyCreateDashboardButton: Locator;
private readonly emptyImportDashboardButton: Locator;
private readonly confirmConfirmButton: Locator;
constructor(page: Page) {
this.page = page;
this.pageContainer = page.getByTestId('dashboards-list-page');
this.searchInput = page.getByPlaceholder('Search by name');
this.newDashboardButton = page.getByTestId('new-dashboard-button');
this.createDashboardButton = page.getByTestId('create-dashboard-button');
this.importDashboardButton = page.getByTestId('import-dashboard-button');
this.tempDashboardButton = page.getByTestId('temp-dashboard-button');
this.gridViewButton = page.getByRole('button', { name: 'Grid view' });
this.listViewButton = page.getByRole('button', { name: 'List view' });
this.emptyCreateDashboardButton = page.getByTestId(
'empty-create-dashboard-button',
);
this.emptyImportDashboardButton = page.getByTestId(
'empty-import-dashboard-button',
);
this.confirmConfirmButton = page.getByTestId('confirm-confirm-button');
}
async goto() {
await this.page.goto('/dashboards/list', { waitUntil: 'networkidle' });
}
async searchDashboards(query: string) {
await this.searchInput.fill(query);
}
async clearSearch() {
await this.searchInput.clear();
}
async createNewDashboard() {
await this.newDashboardButton.click();
await this.createDashboardButton.click();
await this.page.waitForURL('**/dashboards/**');
}
async goToTempDashboard() {
await this.newDashboardButton.click();
await this.tempDashboardButton.click();
}
async switchToGridView() {
await this.gridViewButton.click();
}
async switchToListView() {
await this.listViewButton.click();
}
getDashboardCard(name: string) {
return this.pageContainer.locator('a').filter({ hasText: name });
}
getDashboardRow(name: string) {
return this.pageContainer.locator('tr').filter({ hasText: name });
}
async clickDashboard(name: string) {
await this.getDashboardCard(name).click();
await this.page.waitForURL('**/dashboards/**');
}
async deleteDashboardFromCard(name: string) {
const card = this.getDashboardCard(name);
// Click the menu button (three dots) within the card
await card.getByRole('button').click();
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
// Confirm deletion
await this.confirmConfirmButton.click();
}
async deleteDashboardFromRow(name: string) {
const row = this.getDashboardRow(name);
// Click the menu button within the row
await row.getByRole('button').click();
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
// Confirm deletion
await this.confirmConfirmButton.click();
}
getPresetDashboardCard(name: string) {
return this.pageContainer.locator('a').filter({ hasText: name });
}
getTagFilterSelect() {
return this.page.getByPlaceholder('Filter by tag');
}
async selectTagFilter(tag: string) {
await this.getTagFilterSelect().click();
await this.page.getByRole('option', { name: tag, exact: true }).click();
}
async clearTagFilter() {
// The Mantine Select clear button is a sibling button next to the textbox
const select = this.getTagFilterSelect();
await select.locator('..').locator('button').click();
}
getEmptyState() {
return this.pageContainer.getByText('No dashboards yet.');
}
getNoMatchesState() {
return this.pageContainer.getByText('No matching dashboards yet.');
}
}