mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
308da30bb7
commit
53ba1e397f
20 changed files with 1158 additions and 67 deletions
7
.changeset/hip-goats-taste.md
Normal file
7
.changeset/hip-goats-taste.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add favoriting for dashboards and saved searches
|
||||
|
|
@ -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);
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
32
packages/api/src/controllers/favorite.ts
Normal file
32
packages/api/src/controllers/favorite.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
48
packages/api/src/models/favorite.ts
Normal file
48
packages/api/src/models/favorite.ts
Normal 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);
|
||||
112
packages/api/src/routers/api/__tests__/favorites.test.ts
Normal file
112
packages/api/src/routers/api/__tests__/favorites.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
packages/api/src/routers/api/favorites.ts
Normal file
81
packages/api/src/routers/api/favorites.ts
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 || []}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
46
packages/app/src/components/FavoriteButton.tsx
Normal file
46
packages/app/src/components/FavoriteButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
103
packages/app/src/favorites.ts
Normal file
103
packages/app/src/favorites.ts
Normal 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 };
|
||||
}
|
||||
301
packages/app/tests/e2e/features/favorites.spec.ts
Normal file
301
packages/app/tests/e2e/features/favorites.spec.ts
Normal 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/');
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue