feat: Add favorites for dashboads and saved searches (#2021)

## Summary

This PR adds per-user favorites for dashboards and saved searches. Users can favorite dashboards or saved searches to see them at the top of the relevant listing page and in the sidebar.

The favorites are persisted in Mongo or (in local mode) in local storage.

### Screenshots or video

https://github.com/user-attachments/assets/7cc273df-9fd8-4abb-bed3-5df742442ab3

### How to test locally or on Vercel

The local mode favorites can be tested in vercel preview. The mongodb-backed favorites can be tested locally.

### References



- Linear Issue: Closes HDX-3455
- Related PRs:
This commit is contained in:
Drew Davis 2026-03-31 17:53:02 -04:00 committed by GitHub
parent 308da30bb7
commit 53ba1e397f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1158 additions and 67 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Add favoriting for dashboards and saved searches

View file

@ -11,6 +11,7 @@ import { appErrorHandler } from './middleware/error';
import routers from './routers/api';
import clickhouseProxyRouter from './routers/api/clickhouseProxy';
import connectionsRouter from './routers/api/connections';
import favoritesRouter from './routers/api/favorites';
import savedSearchRouter from './routers/api/savedSearch';
import sourcesRouter from './routers/api/sources';
import externalRoutersV2 from './routers/external-api/v2';
@ -94,6 +95,7 @@ app.use('/webhooks', isUserAuthenticated, routers.webhooksRouter);
app.use('/connections', isUserAuthenticated, connectionsRouter);
app.use('/sources', isUserAuthenticated, sourcesRouter);
app.use('/saved-search', isUserAuthenticated, savedSearchRouter);
app.use('/favorites', isUserAuthenticated, favoritesRouter);
app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter);
// ---------------------------------------------------------------------

View file

@ -0,0 +1,32 @@
import Favorite, { IFavorite } from '@/models/favorite';
export function getFavorites(userId: string, teamId: string) {
return Favorite.find({ user: userId, team: teamId });
}
export function addFavorite(
userId: string,
teamId: string,
resourceType: IFavorite['resourceType'],
resourceId: string,
) {
return Favorite.findOneAndUpdate(
{ user: userId, team: teamId, resourceType, resourceId },
{ user: userId, team: teamId, resourceType, resourceId },
{ upsert: true, new: true },
);
}
export function removeFavorite(
userId: string,
teamId: string,
resourceType: IFavorite['resourceType'],
resourceId: string,
) {
return Favorite.deleteOne({
user: userId,
team: teamId,
resourceType,
resourceId,
});
}

View file

@ -0,0 +1,48 @@
import mongoose, { Schema } from 'mongoose';
import type { ObjectId } from '.';
export interface IFavorite {
_id: ObjectId;
user: ObjectId;
team: ObjectId;
resourceType: 'dashboard' | 'savedSearch';
resourceId: string;
createdAt: Date;
updatedAt: Date;
}
const favoriteSchema = new Schema<IFavorite>(
{
user: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'User',
},
team: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'Team',
},
resourceType: {
type: String,
required: true,
enum: ['dashboard', 'savedSearch'],
},
resourceId: {
type: String,
required: true,
},
},
{
timestamps: true,
toJSON: { virtuals: true },
},
);
favoriteSchema.index(
{ team: 1, user: 1, resourceType: 1, resourceId: 1 },
{ unique: true },
);
export default mongoose.model<IFavorite>('Favorite', favoriteSchema);

View file

@ -0,0 +1,112 @@
import mongoose from 'mongoose';
import { getLoggedInAgent, getServer } from '@/fixtures';
describe('favorites router', () => {
const server = getServer();
let agent: Awaited<ReturnType<typeof getLoggedInAgent>>['agent'];
beforeAll(async () => {
await server.start();
});
beforeEach(async () => {
const result = await getLoggedInAgent(server);
agent = result.agent;
});
afterEach(async () => {
await server.clearDBs();
});
afterAll(async () => {
await server.stop();
});
it('can add a dashboard favorite', async () => {
const resourceId = new mongoose.Types.ObjectId().toString();
const res = await agent
.put('/favorites')
.send({ resourceType: 'dashboard', resourceId })
.expect(200);
expect(res.body.resourceType).toBe('dashboard');
expect(res.body.resourceId).toBe(resourceId);
});
it('can add a savedSearch favorite', async () => {
const resourceId = new mongoose.Types.ObjectId().toString();
const res = await agent
.put('/favorites')
.send({ resourceType: 'savedSearch', resourceId })
.expect(200);
expect(res.body.resourceType).toBe('savedSearch');
expect(res.body.resourceId).toBe(resourceId);
});
it('can list favorites', async () => {
const id1 = new mongoose.Types.ObjectId().toString();
const id2 = new mongoose.Types.ObjectId().toString();
await agent
.put('/favorites')
.send({ resourceType: 'dashboard', resourceId: id1 })
.expect(200);
await agent
.put('/favorites')
.send({ resourceType: 'savedSearch', resourceId: id2 })
.expect(200);
const res = await agent.get('/favorites').expect(200);
expect(res.body).toHaveLength(2);
expect(res.body.map((f: any) => f.resourceId).sort()).toEqual(
[id1, id2].sort(),
);
});
it('can remove a favorite', async () => {
const resourceId = new mongoose.Types.ObjectId().toString();
await agent
.put('/favorites')
.send({ resourceType: 'dashboard', resourceId })
.expect(200);
await agent.delete(`/favorites/dashboard/${resourceId}`).expect(204);
const res = await agent.get('/favorites').expect(200);
expect(res.body).toHaveLength(0);
});
it('adding duplicate favorite is idempotent', async () => {
const resourceId = new mongoose.Types.ObjectId().toString();
await agent
.put('/favorites')
.send({ resourceType: 'dashboard', resourceId })
.expect(200);
await agent
.put('/favorites')
.send({ resourceType: 'dashboard', resourceId })
.expect(200);
const res = await agent.get('/favorites').expect(200);
expect(res.body).toHaveLength(1);
});
it('removing non-existent favorite returns 204', async () => {
const resourceId = new mongoose.Types.ObjectId().toString();
await agent.delete(`/favorites/dashboard/${resourceId}`).expect(204);
});
it('rejects invalid resourceType', async () => {
const resourceId = new mongoose.Types.ObjectId().toString();
await agent
.put('/favorites')
.send({ resourceType: 'invalid', resourceId })
.expect(400);
});
it('rejects invalid resourceId', async () => {
await agent
.put('/favorites')
.send({ resourceType: 'dashboard', resourceId: 'not-an-objectid' })
.expect(400);
});
});

View file

@ -0,0 +1,81 @@
import express from 'express';
import { z } from 'zod';
import { validateRequest } from 'zod-express-middleware';
import {
addFavorite,
getFavorites,
removeFavorite,
} from '@/controllers/favorite';
import { getNonNullUserWithTeam } from '@/middleware/auth';
import { objectIdSchema } from '@/utils/zod';
const router = express.Router();
const resourceTypeSchema = z.enum(['dashboard', 'savedSearch']);
router.get('/', async (req, res, next) => {
try {
const { teamId, userId } = getNonNullUserWithTeam(req);
const favorites = await getFavorites(userId.toString(), teamId.toString());
return res.json(favorites);
} catch (e) {
next(e);
}
});
router.put(
'/',
validateRequest({
body: z.object({
resourceType: resourceTypeSchema,
resourceId: objectIdSchema,
}),
}),
async (req, res, next) => {
try {
const { teamId, userId } = getNonNullUserWithTeam(req);
const favorite = await addFavorite(
userId.toString(),
teamId.toString(),
req.body.resourceType,
req.body.resourceId,
);
return res.json(favorite);
} catch (e) {
next(e);
}
},
);
router.delete(
'/:resourceType/:resourceId',
validateRequest({
params: z.object({
resourceType: resourceTypeSchema,
resourceId: objectIdSchema,
}),
}),
async (req, res, next) => {
try {
const { teamId, userId } = getNonNullUserWithTeam(req);
await removeFavorite(
userId.toString(),
teamId.toString(),
req.params.resourceType,
req.params.resourceId,
);
return res.status(204).send();
} catch (e) {
next(e);
}
},
);
export default router;

View file

@ -86,6 +86,7 @@ import EditTimeChartForm from '@/components/DBEditTimeChartForm';
import DBNumberChart from '@/components/DBNumberChart';
import DBTableChart from '@/components/DBTableChart';
import { DBTimeChart } from '@/components/DBTimeChart';
import { FavoriteButton } from '@/components/FavoriteButton';
import FullscreenPanelModal from '@/components/FullscreenPanelModal';
import SectionHeader from '@/components/SectionHeader';
import { TimePicker } from '@/components/TimePicker';
@ -1559,7 +1560,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
<Text fz="sm" c="dimmed">
<Text fz="sm" c="dimmed" maw={500} truncate="end">
{dashboard?.name ?? 'Untitled'}
</Text>
</Breadcrumbs>
@ -1578,6 +1579,12 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
}}
/>
<Group gap="xs">
{!isLocalDashboard && dashboard?.id && (
<FavoriteButton
resourceType="dashboard"
resourceId={dashboard.id}
/>
)}
{!isLocalDashboard && dashboard?.id && (
<Tags
allowCreate

View file

@ -82,6 +82,7 @@ import { ContactSupportText } from '@/components/ContactSupportText';
import { DBSearchPageFilters } from '@/components/DBSearchPageFilters';
import { DBTimeChart } from '@/components/DBTimeChart';
import { ErrorBoundary } from '@/components/Error/ErrorBoundary';
import { FavoriteButton } from '@/components/FavoriteButton';
import { InputControlled } from '@/components/InputControlled';
import OnboardingModal from '@/components/OnboardingModal';
import SearchWhereInput, {
@ -1582,7 +1583,7 @@ function DBSearchPage() {
<Anchor component={Link} href="/search/list" fz="sm" c="dimmed">
Saved Searches
</Anchor>
<Text fz="sm" c="dimmed">
<Text fz="sm" c="dimmed" maw={400} truncate="end">
{savedSearch.name}
</Text>
</Breadcrumbs>
@ -1599,6 +1600,10 @@ function DBSearchPage() {
</Stack>
<Group gap="xs">
<FavoriteButton
resourceType="savedSearch"
resourceId={savedSearch.id}
/>
<Tags
allowCreate
values={savedSearch.tags || []}

View file

@ -63,7 +63,16 @@ export function EditablePageName({
</form>
) : (
<div className="d-flex align-items-center" style={{ minWidth: 100 }}>
<Title fw={400} order={3}>
<Title
fw={400}
maw={500}
order={3}
style={{
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
}}
>
{name}
</Title>
{hovered && (

View file

@ -298,18 +298,23 @@ export const AppNavLink = ({
</Badge>
)}
{!isCollapsed && onToggle && (
<button
type="button"
data-testid={`${testId}-toggle`}
className={styles.navItemToggle}
onClick={handleToggleClick}
<Tooltip
label={isExpanded ? 'Hide Favorites' : 'Show Favorites'}
position="right"
>
{isExpanded ? (
<IconChevronUp size={14} className="text-muted-hover" />
) : (
<IconChevronDown size={14} className="text-muted-hover" />
)}
</button>
<button
type="button"
data-testid={`${testId}-toggle`}
className={styles.navItemToggle}
onClick={handleToggleClick}
>
{isExpanded ? (
<IconChevronUp size={14} className="text-muted-hover" />
) : (
<IconChevronDown size={14} className="text-muted-hover" />
)}
</button>
</Tooltip>
)}
</Link>
);

View file

@ -1,12 +1,13 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import Link from 'next/link';
import Router, { useRouter } from 'next/router';
import cx from 'classnames';
import HyperDX from '@hyperdx/browser';
import { AlertState } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
Badge,
Collapse,
Group,
ScrollArea,
Text,
@ -15,6 +16,7 @@ import { useDisclosure, useLocalStorage } from '@mantine/hooks';
import {
IconArrowBarToLeft,
IconBell,
IconBellFilled,
IconChartDots,
IconDeviceFloppy,
IconDeviceLaptop,
@ -26,9 +28,13 @@ import {
import api from '@/api';
import { IS_LOCAL_MODE } from '@/config';
import { Dashboard, useDashboards } from '@/dashboard';
import { useFavorites } from '@/favorites';
import InstallInstructionModal from '@/InstallInstructionsModal';
import OnboardingChecklist from '@/OnboardingChecklist';
import { useSavedSearches } from '@/savedSearch';
import { useLogomark, useWordmark } from '@/theme/ThemeProvider';
import type { SavedSearch } from '@/types';
import { UserPreferencesModal } from '@/UserPreferencesModal';
import { useUserPreferences } from '@/useUserPreferences';
import { useWindowSize } from '@/utils';
@ -109,6 +115,57 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
}
}, []);
const { data: savedSearches } = useSavedSearches();
const { data: dashboards } = useDashboards();
const { data: favorites } = useFavorites();
const favoritedSavedSearchIds = useMemo(() => {
if (!favorites) return new Set<string>();
return new Set(
favorites
.filter(f => f.resourceType === 'savedSearch')
.map(f => f.resourceId),
);
}, [favorites]);
const favoritedSavedSearches = useMemo(() => {
if (!savedSearches) return [];
return savedSearches
.filter(s => favoritedSavedSearchIds.has(s.id))
.sort((a, b) => a.name.localeCompare(b.name));
}, [favoritedSavedSearchIds, savedSearches]);
const favoritedDashboardIds = useMemo(() => {
if (!favorites) return new Set<string>();
return new Set(
favorites
.filter(f => f.resourceType === 'dashboard')
.map(f => f.resourceId),
);
}, [favorites]);
const favoritedDashboards = useMemo(() => {
if (!dashboards) return [];
return dashboards
.filter(d => favoritedDashboardIds.has(d.id))
.sort((a, b) => a.name.localeCompare(b.name));
}, [dashboards, favoritedDashboardIds]);
const [isSavedSearchExpanded, setIsSavedSearchExpanded] =
useLocalStorage<boolean>({
key: 'isSavedSearchExpanded',
defaultValue: true,
});
const [isDashboardsExpanded, setIsDashboardsExpanded] =
useLocalStorage<boolean>({
key: 'isDashboardsExpanded',
defaultValue: true,
});
const router = useRouter();
const { pathname, query } = router;
@ -144,6 +201,58 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
}
}, [meData]);
const renderSavedSearchLink = useCallback(
(savedSearch: SavedSearch) => (
<Link
href={`/search/${savedSearch.id}`}
key={savedSearch.id}
tabIndex={0}
className={cx(
styles.subMenuItem,
savedSearch.id === query.savedSearchId && styles.subMenuItemActive,
)}
title={savedSearch.name}
>
<Group gap={2}>
<div className="text-truncate">{savedSearch.name}</div>
{Array.isArray(savedSearch.alerts) &&
savedSearch.alerts.length > 0 ? (
savedSearch.alerts.some(a => a.state === AlertState.ALERT) ? (
<IconBellFilled
size={14}
className="float-end text-danger ms-1"
aria-label="Has Alerts and is in ALERT state"
/>
) : (
<IconBell
size={14}
className="float-end ms-1"
aria-label="Has Alerts and is in OK state"
/>
)
) : null}
</Group>
</Link>
),
[query.savedSearchId],
);
const renderDashboardLink = useCallback(
(dashboard: Dashboard) => (
<Link
href={`/dashboards/${dashboard.id}`}
key={dashboard.id}
tabIndex={0}
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]: dashboard.id === query.dashboardId,
})}
>
<div className="text-truncate">{dashboard.name}</div>
</Link>
),
[query.dashboardId],
);
const [
UserPreferencesOpen,
{ close: closeUserPreferences, open: openUserPreferences },
@ -158,6 +267,48 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
{ open: openInstallInstructions, close: closeInstallInstructions },
] = useDisclosure(false);
const isSavedSearchActive = useMemo(() => {
if (!pathname?.startsWith('/search/')) return false;
if (
typeof query.savedSearchId === 'string' &&
favoritedSavedSearchIds.has(query.savedSearchId)
) {
return !isSavedSearchExpanded;
}
return true;
}, [
favoritedSavedSearchIds,
isSavedSearchExpanded,
pathname,
query.savedSearchId,
]);
const isDashboardsActive = useMemo(() => {
const isDashboardsPathname =
pathname?.startsWith('/dashboards/') ||
pathname === '/services' ||
pathname === '/clickhouse' ||
pathname === '/kubernetes';
if (!isDashboardsPathname) return false;
if (
typeof query.dashboardId === 'string' &&
favoritedDashboardIds.has(query.dashboardId)
) {
return !isDashboardsExpanded;
}
return true;
}, [
pathname,
query.dashboardId,
favoritedDashboardIds,
isDashboardsExpanded,
]);
return (
<AppNavContext.Provider value={{ isCollapsed, pathname }}>
{fixed && (
@ -174,6 +325,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
onHide={closeInstallInstructions}
/>
<div
data-testid="app-nav"
className={cx(styles.nav, {
[styles.navFixed]: fixed,
[styles.navCollapsed]: isCollapsed,
@ -241,8 +393,18 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
label="Saved Searches"
href="/search/list"
icon={<IconDeviceFloppy size={16} />}
isActive={pathname?.startsWith('/search/')}
isActive={isSavedSearchActive}
isExpanded={isSavedSearchExpanded}
onToggle={() => setIsSavedSearchExpanded(!isSavedSearchExpanded)}
/>
{!isCollapsed && !!favoritedSavedSearches.length && (
<Collapse in={isSavedSearchExpanded}>
<div className={styles.subMenu}>
{favoritedSavedSearches.map(renderSavedSearchLink)}
</div>
</Collapse>
)}
{/* Simple nav links from config */}
{NAV_LINKS.filter(link => !link.cloudOnly || !IS_LOCAL_MODE).map(
link => (
@ -261,19 +423,17 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
label="Dashboards"
href="/dashboards/list"
icon={<IconLayoutGrid size={16} />}
isActive={isDashboardsActive}
isExpanded={isDashboardsExpanded}
onToggle={() => setIsDashboardsExpanded(!isDashboardsExpanded)}
/>
{!isCollapsed && (
<Text size="xs" px="lg" py="xs" fw="lighter" fs="italic">
Saved searches and dashboards have moved! Try the{' '}
<Anchor component={Link} href="/search/list">
Saved Searches
</Anchor>{' '}
or{' '}
<Anchor component={Link} href="/dashboards/list">
Dashboards
</Anchor>{' '}
page.
</Text>
{!isCollapsed && !!favoritedDashboards.length && (
<Collapse in={isDashboardsExpanded}>
<div className={styles.subMenu}>
{favoritedDashboards.map(renderDashboardLink)}
</div>
</Collapse>
)}
{/* Help */}

View file

@ -5,6 +5,7 @@ import Router from 'next/router';
import { useQueryState } from 'nuqs';
import {
ActionIcon,
Box,
Button,
Container,
Flex,
@ -29,6 +30,7 @@ import {
IconUpload,
} from '@tabler/icons-react';
import { FavoriteButton } from '@/components/FavoriteButton';
import { ListingCard } from '@/components/ListingCard';
import { ListingRow } from '@/components/ListingListRow';
import { PageHeader } from '@/components/PageHeader';
@ -38,6 +40,7 @@ import {
useDashboards,
useDeleteDashboard,
} from '@/dashboard';
import { useFavorites } from '@/favorites';
import { useBrandDisplayName } from '@/theme/ThemeProvider';
import { useConfirm } from '@/useConfirm';
@ -78,6 +81,21 @@ export default function DashboardsListPage() {
defaultValue: 'grid',
});
const { data: favorites } = useFavorites();
const favoritedDashboards = useMemo(() => {
if (!dashboards || !favorites?.length) return [];
const favoritedDashboardIds = new Set(
favorites
.filter(f => f.resourceType === 'dashboard')
.map(f => f.resourceId),
);
return dashboards
.filter(d => favoritedDashboardIds.has(d.id))
.sort((a, b) => a.name.localeCompare(b.name));
}, [dashboards, favorites]);
const allTags = useMemo(() => {
if (!dashboards) return [];
const tags = new Set<string>();
@ -161,6 +179,32 @@ export default function DashboardsListPage() {
))}
</SimpleGrid>
{favoritedDashboards.length > 0 && (
<>
<Text fw={500} size="sm" c="dimmed" mb="sm">
Favorites
</Text>
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
mb="xl"
data-testid="favorite-dashboards-section"
>
{favoritedDashboards.map(d => (
<ListingCard
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)}
resourceId={d.id}
resourceType="dashboard"
/>
))}
</SimpleGrid>
</>
)}
<Text fw={500} size="sm" c="dimmed" mb="sm">
Team Dashboards
</Text>
@ -295,6 +339,7 @@ export default function DashboardsListPage() {
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={20} />
<Table.Th>Name</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th w={50} />
@ -309,6 +354,15 @@ export default function DashboardsListPage() {
href={`/dashboards/${d.id}`}
tags={d.tags}
onDelete={handleDelete}
leftSection={
<Box ps={4}>
<FavoriteButton
resourceType="dashboard"
resourceId={d.id}
size="xs"
/>
</Box>
}
/>
))}
</Table.Tbody>
@ -323,6 +377,8 @@ export default function DashboardsListPage() {
tags={d.tags}
description={`${d.tiles.length} ${d.tiles.length === 1 ? 'tile' : 'tiles'}`}
onDelete={() => handleDelete(d.id)}
resourceId={d.id}
resourceType="dashboard"
/>
))}
</SimpleGrid>

View file

@ -0,0 +1,46 @@
import { ActionIcon, Tooltip } from '@mantine/core';
import { IconStar, IconStarFilled } from '@tabler/icons-react';
import { type Favorite, useToggleFavorite } from '@/favorites';
export function FavoriteButton({
resourceType,
resourceId,
size = 'sm',
}: {
resourceType: Favorite['resourceType'];
resourceId: string;
size?: 'sm' | 'xs';
}) {
const { isFavorited, toggleFavorite } = useToggleFavorite(
resourceType,
resourceId,
);
const iconSize = size === 'sm' ? 16 : 14;
return (
<Tooltip label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}>
<ActionIcon
variant="subtle"
size={size}
onClick={e => {
e.preventDefault();
e.stopPropagation();
toggleFavorite();
}}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
data-testid="favorite-button"
>
{isFavorited ? (
<IconStarFilled
size={iconSize}
color="var(--mantine-color-yellow-5)"
/>
) : (
<IconStar size={iconSize} />
)}
</ActionIcon>
</Tooltip>
);
}

View file

@ -2,6 +2,9 @@ import Link from 'next/link';
import { ActionIcon, Badge, Card, Group, Menu, Text } from '@mantine/core';
import { IconDots, IconTrash } from '@tabler/icons-react';
import { FavoriteButton } from '@/components/FavoriteButton';
import { Favorite } from '@/favorites';
export function ListingCard({
name,
href,
@ -9,6 +12,8 @@ export function ListingCard({
tags,
onDelete,
statusIcon,
resourceId,
resourceType,
}: {
name: string;
href: string;
@ -16,6 +21,8 @@ export function ListingCard({
tags?: string[];
onDelete?: () => void;
statusIcon?: React.ReactNode;
resourceId?: string;
resourceType?: Favorite['resourceType'];
}) {
return (
<Card
@ -27,7 +34,7 @@ export function ListingCard({
style={{ cursor: 'pointer', textDecoration: 'none' }}
>
<Group justify="space-between" mb="xs" wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Text
fw={500}
lineClamp={1}
@ -37,6 +44,13 @@ export function ListingCard({
{name}
</Text>
{statusIcon}
{resourceId && resourceType && (
<FavoriteButton
resourceType={resourceType}
resourceId={resourceId}
size="xs"
/>
)}
</Group>
{onDelete && (
<Menu position="bottom-end" withinPortal>

View file

@ -8,14 +8,14 @@ export function ListingRow({
href,
tags,
onDelete,
statusIcon,
leftSection,
}: {
id: string;
name: string;
href: string;
tags?: string[];
onDelete: (id: string) => void;
statusIcon?: React.ReactNode;
onDelete?: (id: string) => void;
leftSection?: React.ReactNode;
}) {
return (
<Table.Tr
@ -33,12 +33,12 @@ export function ListingRow({
}
}}
>
{leftSection != null && <Table.Td px={0}>{leftSection}</Table.Td>}
<Table.Td>
<Group gap={4} wrap="nowrap">
<Text fw={500} size="sm">
<Text fw={500} size="sm" maw={500} truncate="end">
{name}
</Text>
{statusIcon}
</Group>
</Table.Td>
<Table.Td>
@ -51,29 +51,31 @@ export function ListingRow({
</Group>
</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(id);
}}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
{onDelete && (
<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(id);
}}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
);

View file

@ -28,9 +28,11 @@ import {
IconTable,
} from '@tabler/icons-react';
import { FavoriteButton } from '@/components/FavoriteButton';
import { ListingCard } from '@/components/ListingCard';
import { ListingRow } from '@/components/ListingListRow';
import { PageHeader } from '@/components/PageHeader';
import { useFavorites } from '@/favorites';
import { useDeleteSavedSearch, useSavedSearches } from '@/savedSearch';
import { useBrandDisplayName } from '@/theme/ThemeProvider';
import type { SavedSearchWithEnhancedAlerts } from '@/types';
@ -74,6 +76,21 @@ export default function SavedSearchesListPage() {
defaultValue: 'grid',
});
const { data: favorites } = useFavorites();
const favoritedSavedSearches = useMemo(() => {
if (!savedSearches || !favorites?.length) return [];
const favoritedSavedSearchIds = new Set(
favorites
.filter(f => f.resourceType === 'savedSearch')
.map(f => f.resourceId),
);
return savedSearches
.filter(s => favoritedSavedSearchIds.has(s.id))
.sort((a, b) => a.name.localeCompare(b.name));
}, [savedSearches, favorites]);
const allTags = useMemo(() => {
if (!savedSearches) return [];
const tags = new Set<string>();
@ -132,6 +149,36 @@ export default function SavedSearchesListPage() {
</Head>
<PageHeader>Saved Searches</PageHeader>
<Container maw={1200} py="lg" px="lg">
{favoritedSavedSearches.length > 0 && (
<>
<Text fw={500} size="sm" c="dimmed" mb="sm">
Favorites
</Text>
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
mb="xl"
data-testid="favorite-saved-searches-section"
>
{favoritedSavedSearches.map(s => (
<ListingCard
key={s.id}
name={s.name}
href={`/search/${s.id}`}
tags={s.tags}
onDelete={() => handleDelete(s.id)}
statusIcon={<AlertStatusIcon alerts={s.alerts} />}
resourceId={s.id}
resourceType="savedSearch"
/>
))}
</SimpleGrid>
</>
)}
<Text fw={500} size="sm" c="dimmed" mb="sm">
All Saved Searches
</Text>
<Flex justify="space-between" align="center" mb="lg" gap="sm">
<Group gap="xs" style={{ flex: 1 }}>
<TextInput
@ -213,6 +260,7 @@ export default function SavedSearchesListPage() {
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Name</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th w={50} />
@ -227,7 +275,16 @@ export default function SavedSearchesListPage() {
href={`/search/${s.id}`}
tags={s.tags}
onDelete={handleDelete}
statusIcon={<AlertStatusIcon alerts={s.alerts} />}
leftSection={
<Group gap={0} ps={4} justify="space-between" wrap="nowrap">
<FavoriteButton
resourceType="savedSearch"
resourceId={s.id}
size="xs"
/>
<AlertStatusIcon alerts={s.alerts} />
</Group>
}
/>
))}
</Table.Tbody>
@ -242,6 +299,8 @@ export default function SavedSearchesListPage() {
tags={s.tags}
onDelete={() => handleDelete(s.id)}
statusIcon={<AlertStatusIcon alerts={s.alerts} />}
resourceId={s.id}
resourceType="savedSearch"
/>
))}
</SimpleGrid>

View file

@ -0,0 +1,103 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { hdxServer } from './api';
import { IS_LOCAL_MODE } from './config';
import { createEntityStore } from './localStore';
export type Favorite = {
id: string;
resourceType: 'dashboard' | 'savedSearch';
resourceId: string;
};
const localFavorites = createEntityStore<Favorite>('hdx-local-favorites');
async function fetchFavorites(): Promise<Favorite[]> {
if (IS_LOCAL_MODE) {
return localFavorites.getAll();
}
return hdxServer('favorites').json<Favorite[]>();
}
export function useFavorites() {
return useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
});
}
function useAddFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
resourceType: Favorite['resourceType'];
resourceId: string;
}) => {
if (IS_LOCAL_MODE) {
return Promise.resolve(localFavorites.create(data));
}
return hdxServer('favorites', {
method: 'PUT',
json: data,
}).json<Favorite>();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['favorites'] });
},
});
}
function useRemoveFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
resourceType: Favorite['resourceType'];
resourceId: string;
}) => {
if (IS_LOCAL_MODE) {
const all = localFavorites.getAll();
const match = all.find(
f =>
f.resourceType === data.resourceType &&
f.resourceId === data.resourceId,
);
if (match) {
localFavorites.delete(match.id);
}
return Promise.resolve();
}
return hdxServer(`favorites/${data.resourceType}/${data.resourceId}`, {
method: 'DELETE',
}).json<void>();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['favorites'] });
},
});
}
export function useToggleFavorite(
resourceType: Favorite['resourceType'],
resourceId: string,
) {
const { data: favorites } = useFavorites();
const addFavorite = useAddFavorite();
const removeFavorite = useRemoveFavorite();
const isFavorited =
favorites?.some(
f => f.resourceType === resourceType && f.resourceId === resourceId,
) ?? false;
const toggleFavorite = () => {
if (isFavorited) {
removeFavorite.mutate({ resourceType, resourceId });
} else {
addFavorite.mutate({ resourceType, resourceId });
}
};
return { isFavorited, toggleFavorite };
}

View file

@ -0,0 +1,301 @@
import { DashboardPage } from '../page-objects/DashboardPage';
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
import { SavedSearchesListPage } from '../page-objects/SavedSearchesListPage';
import { getApiUrl, getSources } from '../utils/api-helpers';
import { expect, test } from '../utils/base-test';
/**
* Helper to create a saved search via the API.
*/
async function createSavedSearchViaApi(
page: import('@playwright/test').Page,
overrides: Record<string, unknown> = {},
) {
const API_URL = getApiUrl();
const logSources = await getSources(page, 'log');
const sourceId = logSources[0]._id;
const defaults = {
name: `E2E Saved Search ${Date.now()}`,
select: 'Timestamp, Body',
where: '',
whereLanguage: 'lucene',
source: sourceId,
tags: [] as string[],
};
const body = { ...defaults, ...overrides };
const response = await page.request.post(`${API_URL}/saved-search`, {
data: body,
});
if (!response.ok()) {
throw new Error(
`Failed to create saved search: ${response.status()} ${await response.text()}`,
);
}
return response.json();
}
test.describe(
'Dashboard Favorites',
{ tag: ['@dashboard', '@full-stack'] },
() => {
let dashboardsListPage: DashboardsListPage;
let dashboardPage: DashboardPage;
test.beforeEach(async ({ page }) => {
dashboardsListPage = new DashboardsListPage(page);
dashboardPage = new DashboardPage(page);
});
test('should favorite and unfavorite a dashboard in grid view', async () => {
const ts = Date.now();
const name = `E2E Fav Dashboard ${ts}`;
await test.step('Create a dashboard', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
await dashboardPage.editDashboardName(name);
});
await test.step('Navigate to listing and verify item is not favorited', async () => {
await dashboardsListPage.goto();
await expect(
dashboardsListPage.getFavoritedDashboardCard(name),
).toBeHidden();
});
await test.step('Favorite the dashboard', async () => {
await dashboardsListPage.toggleFavoriteOnCard(name);
});
await test.step('Verify the dashboard appears in the favorites section', async () => {
await expect(dashboardsListPage.getFavoritesSection()).toBeVisible();
await expect(
dashboardsListPage.getFavoritedDashboardCard(name),
).toBeVisible();
});
await test.step('Unfavorite the dashboard from the favorites section', async () => {
await dashboardsListPage.toggleFavoriteOnFavoritedCard(name);
});
await test.step('Verify the dashboard is removed from favorites', async () => {
await expect(
dashboardsListPage.getFavoritedDashboardCard(name),
).toBeHidden();
});
});
test('should favorite a dashboard in list view', async () => {
const ts = Date.now();
const name = `E2E Fav List Dashboard ${ts}`;
await test.step('Create a dashboard', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
await dashboardPage.editDashboardName(name);
});
await test.step('Switch to list view and favorite the dashboard', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.switchToListView();
await dashboardsListPage.toggleFavoriteOnRow(name);
});
await test.step('Verify the favorites section appears with the dashboard', async () => {
await expect(dashboardsListPage.getFavoritesSection()).toBeVisible();
await expect(
dashboardsListPage.getFavoritedDashboardCard(name),
).toBeVisible();
});
});
test('should persist favorites across page reloads', async () => {
const ts = Date.now();
const name = `E2E Persist Fav Dashboard ${ts}`;
await test.step('Create and favorite a dashboard', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
await dashboardPage.editDashboardName(name);
await dashboardsListPage.goto();
await dashboardsListPage.toggleFavoriteOnCard(name);
await expect(
dashboardsListPage.getFavoritedDashboardCard(name),
).toBeVisible();
});
await test.step('Reload the page and verify favorite persists', async () => {
await dashboardsListPage.goto();
await expect(dashboardsListPage.getFavoritesSection()).toBeVisible();
await expect(
dashboardsListPage.getFavoritedDashboardCard(name),
).toBeVisible();
});
});
test('should show favorited dashboard in sidebar and navigate to it', async ({
page,
}) => {
const ts = Date.now();
const name = `E2E Sidebar Dashboard ${ts}`;
await test.step('Create a dashboard', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
await dashboardPage.editDashboardName(name);
});
await test.step('Favorite the dashboard', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.toggleFavoriteOnCard(name);
});
await test.step('Verify the sidebar shows the favorited dashboard', async () => {
const sidebar = page.getByTestId('app-nav');
const sidebarLink = sidebar
.locator('a[href^="/dashboards/"]')
.filter({ hasText: name });
await expect(sidebarLink).toBeVisible();
});
await test.step('Click the sidebar link and verify navigation', async () => {
const sidebar = page.getByTestId('app-nav');
const sidebarLink = sidebar
.locator('a[href^="/dashboards/"]')
.filter({ hasText: name });
await sidebarLink.click();
await page.waitForURL('**/dashboards/**');
expect(page.url()).toContain('/dashboards/');
});
});
},
);
test.describe(
'Saved Search Favorites',
{ tag: ['@search', '@full-stack'] },
() => {
let savedSearchesListPage: SavedSearchesListPage;
test.beforeEach(async ({ page }) => {
savedSearchesListPage = new SavedSearchesListPage(page);
});
test('should favorite and unfavorite a saved search in grid view', async ({
page,
}) => {
const ts = Date.now();
const name = `E2E Fav Search ${ts}`;
await test.step('Create a saved search via API', async () => {
await createSavedSearchViaApi(page, { name });
});
await test.step('Navigate to listing and verify item is not favorited', async () => {
await savedSearchesListPage.goto();
await expect(
savedSearchesListPage.getFavoritedSearchCard(name),
).toBeHidden();
});
await test.step('Favorite the saved search', async () => {
await savedSearchesListPage.toggleFavoriteOnCard(name);
});
await test.step('Verify the saved search appears in the favorites section', async () => {
await expect(savedSearchesListPage.getFavoritesSection()).toBeVisible();
await expect(
savedSearchesListPage.getFavoritedSearchCard(name),
).toBeVisible();
});
await test.step('Unfavorite the saved search from the favorites section', async () => {
await savedSearchesListPage.toggleFavoriteOnFavoritedCard(name);
});
await test.step('Verify the saved search is removed from favorites', async () => {
await expect(
savedSearchesListPage.getFavoritedSearchCard(name),
).toBeHidden();
});
});
test('should favorite a saved search in list view', async ({ page }) => {
const ts = Date.now();
const name = `E2E Fav List Search ${ts}`;
await test.step('Create a saved search via API', async () => {
await createSavedSearchViaApi(page, { name });
});
await test.step('Switch to list view and favorite the saved search', async () => {
await savedSearchesListPage.goto();
await savedSearchesListPage.switchToListView();
await savedSearchesListPage.toggleFavoriteOnRow(name);
});
await test.step('Verify the favorites section appears with the saved search', async () => {
await expect(savedSearchesListPage.getFavoritesSection()).toBeVisible();
await expect(
savedSearchesListPage.getFavoritedSearchCard(name),
).toBeVisible();
});
});
test('should persist favorites across page reloads', async ({ page }) => {
const ts = Date.now();
const name = `E2E Persist Fav Search ${ts}`;
await test.step('Create and favorite a saved search', async () => {
await createSavedSearchViaApi(page, { name });
await savedSearchesListPage.goto();
await savedSearchesListPage.toggleFavoriteOnCard(name);
await expect(
savedSearchesListPage.getFavoritedSearchCard(name),
).toBeVisible();
});
await test.step('Reload the page and verify favorite persists', async () => {
await savedSearchesListPage.goto();
await expect(savedSearchesListPage.getFavoritesSection()).toBeVisible();
await expect(
savedSearchesListPage.getFavoritedSearchCard(name),
).toBeVisible();
});
});
test('should show favorited saved search in sidebar and navigate to it', async ({
page,
}) => {
const ts = Date.now();
const name = `E2E Sidebar Search ${ts}`;
await test.step('Create a saved search via API', async () => {
await createSavedSearchViaApi(page, { name });
});
await test.step('Favorite the saved search', async () => {
await savedSearchesListPage.goto();
await savedSearchesListPage.toggleFavoriteOnCard(name);
});
await test.step('Verify the sidebar shows the favorited saved search', async () => {
const sidebar = page.getByTestId('app-nav');
const sidebarLink = sidebar
.locator('a[href^="/search/"]')
.filter({ hasText: name });
await expect(sidebarLink).toBeVisible();
});
await test.step('Click the sidebar link and verify navigation', async () => {
const sidebar = page.getByTestId('app-nav');
const sidebarLink = sidebar
.locator('a[href^="/search/"]')
.filter({ hasText: name });
await sidebarLink.click();
await page.waitForURL('**/search/**');
expect(page.url()).toContain('/search/');
});
});
},
);

View file

@ -84,19 +84,15 @@ export class DashboardsListPage {
async deleteDashboardFromCard(name: string) {
const card = this.getDashboardCard(name);
// Click the menu button (three dots) within the card
await card.getByRole('button').click();
await card.locator('[data-variant="secondary"]').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 row.locator('[data-variant="secondary"]').click();
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
// Confirm deletion
await this.confirmConfirmButton.click();
}
@ -126,4 +122,27 @@ export class DashboardsListPage {
getNoMatchesState() {
return this.pageContainer.getByText('No matching dashboards yet.');
}
getFavoritesSection() {
return this.page.getByTestId('favorite-dashboards-section');
}
async toggleFavoriteOnCard(name: string) {
const card = this.getDashboardCard(name);
await card.getByTestId('favorite-button').click();
}
async toggleFavoriteOnRow(name: string) {
const row = this.getDashboardRow(name);
await row.getByTestId('favorite-button').click();
}
getFavoritedDashboardCard(name: string) {
return this.getFavoritesSection().locator('a').filter({ hasText: name });
}
async toggleFavoriteOnFavoritedCard(name: string) {
const card = this.getFavoritedDashboardCard(name);
await card.getByTestId('favorite-button').click();
}
}

View file

@ -61,14 +61,14 @@ export class SavedSearchesListPage {
async deleteSavedSearchFromCard(name: string) {
const card = this.getSavedSearchCard(name);
await card.getByRole('button').click();
await card.locator('[data-variant="secondary"]').click();
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
await this.confirmConfirmButton.click();
}
async deleteSavedSearchFromRow(name: string) {
const row = this.getSavedSearchRow(name);
await row.getByRole('button').click();
await row.locator('[data-variant="secondary"]').click();
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
await this.confirmConfirmButton.click();
}
@ -94,4 +94,27 @@ export class SavedSearchesListPage {
getNoMatchesState() {
return this.pageContainer.getByText('No matching saved searches.');
}
getFavoritesSection() {
return this.page.getByTestId('favorite-saved-searches-section');
}
async toggleFavoriteOnCard(name: string) {
const card = this.getSavedSearchCard(name);
await card.getByTestId('favorite-button').click();
}
async toggleFavoriteOnRow(name: string) {
const row = this.getSavedSearchRow(name);
await row.getByTestId('favorite-button').click();
}
getFavoritedSearchCard(name: string) {
return this.getFavoritesSection().locator('a').filter({ hasText: name });
}
async toggleFavoriteOnFavoritedCard(name: string) {
const card = this.getFavoritedSearchCard(name);
await card.getByTestId('favorite-button').click();
}
}