feat: Add saved searches listing page (#2012)

## Summary

This PR moves saved searches from the sidebar to a new Saved Search listing page, for consistency with the new dashboards listing page.

### Screenshots or video


https://github.com/user-attachments/assets/11afec45-2a50-4f52-aad7-9a441ac115f5



### How to test locally or on Vercel

This can be tested in the preview environment (but the alert indicator will only work if you run it locally, not in LOCAL_MODE).

### References



- Linear Issue: Closes HDX-3833 Closes HDX-2066 Closes HDX-2633
- Related PRs:
This commit is contained in:
Drew Davis 2026-03-31 08:39:11 -04:00 committed by GitHub
parent 05a1b76588
commit e5c7fdf924
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 920 additions and 554 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---
feat: Add saved searches listing page

View file

@ -0,0 +1,2 @@
import SavedSearchesListPage from '@/components/SavedSearches/SavedSearchesListPage';
export default SavedSearchesListPage;

View file

@ -113,6 +113,7 @@ import { useConnections } from './connection';
import { useDashboard } from './dashboard';
import DashboardFilters from './DashboardFilters';
import DashboardFiltersModal from './DashboardFiltersModal';
import { EditablePageName } from './EditablePageName';
import { GranularityPickerControlled } from './GranularityPicker';
import HDXMarkdownChart from './HDXMarkdownChart';
import { withAppNav } from './layout';
@ -755,68 +756,6 @@ const updateLayout = (newLayout: RGL.Layout[]) => {
};
};
function DashboardName({
name,
onSave,
}: {
name: string;
onSave: (name: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [editedName, setEditedName] = useState(name);
const { hovered, ref } = useHover();
return (
<Box
ref={ref}
pe="md"
onDoubleClick={() => setEditing(true)}
className="cursor-pointer"
title="Double click to edit"
>
{editing ? (
<form
className="d-flex align-items-center"
onSubmit={e => {
e.preventDefault();
onSave(editedName);
setEditing(false);
}}
>
<Input
type="text"
value={editedName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setEditedName(e.target.value)
}
placeholder="Dashboard Name"
/>
<Button ms="sm" variant="primary" type="submit">
Save Name
</Button>
</form>
) : (
<div className="d-flex align-items-center" style={{ minWidth: 100 }}>
<Title fw={400} order={3}>
{name}
</Title>
{hovered && (
<Button
ms="xs"
variant="subtle"
size="xs"
onClick={() => setEditing(true)}
>
<IconPencil size={14} />
</Button>
)}
</div>
)}
</Box>
);
}
// Download an object to users computer as JSON using specified name
function downloadObjectAsJson(object: object, fileName = 'output') {
const dataStr =
@ -1610,7 +1549,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
</Breadcrumbs>
)}
<Flex mt="xs" mb="md" justify="space-between" align="center">
<DashboardName
<EditablePageName
key={`${dashboardHash}`}
name={dashboard?.name ?? ''}
onSave={editedName => {

View file

@ -10,6 +10,7 @@ import {
} from 'react';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import Link from 'next/link';
import router from 'next/router';
import {
parseAsBoolean,
@ -42,7 +43,9 @@ import {
} from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
Box,
Breadcrumbs,
Button,
Card,
Center,
@ -135,6 +138,7 @@ import {
import api from './api';
import { LOCAL_STORE_CONNECTIONS_KEY } from './connection';
import { DBSearchPageAlertModal } from './DBSearchPageAlertModal';
import { EditablePageName } from './EditablePageName';
import { SearchConfig } from './types';
import searchPageStyles from '../styles/SearchPage.module.scss';
@ -1571,6 +1575,61 @@ function DBSearchPage() {
/>
)}
<OnboardingModal />
{savedSearch && (
<Group justify="space-between" align="flex-end" mt="lg" mx="xs">
<Stack gap={0}>
<Breadcrumbs fz="sm" mb="xs">
<Anchor component={Link} href="/search/list" fz="sm" c="dimmed">
Saved Searches
</Anchor>
<Text fz="sm" c="dimmed">
{savedSearch.name}
</Text>
</Breadcrumbs>
<EditablePageName
key={savedSearch.id}
name={savedSearch?.name ?? 'Untitled Search'}
onSave={editedName => {
updateSavedSearch.mutate({
id: savedSearch.id,
name: editedName,
});
}}
/>
</Stack>
<Group gap="xs">
<Tags
allowCreate
values={savedSearch.tags || []}
onChange={handleUpdateTags}
>
<Button
data-testid="tags-button"
variant="secondary"
size="xs"
style={{ flexShrink: 0 }}
>
<IconTags size={14} className="me-1" />
{savedSearch.tags?.length || 0}
</Button>
</Tags>
<SearchPageActionBar
onClickDeleteSavedSearch={() => {
deleteSavedSearch.mutate(savedSearch?.id ?? '', {
onSuccess: () => {
router.push('/search/list');
},
});
}}
onClickSaveAsNew={() => {
setSaveSearchModalState('create');
}}
/>
</Group>
</Group>
)}
<form
data-testid="search-form"
onSubmit={onFormSubmit}
@ -1649,39 +1708,6 @@ function DBSearchPage() {
Alerts
</Button>
)}
{!!savedSearch && (
<>
<Tags
allowCreate
values={savedSearch.tags || []}
onChange={handleUpdateTags}
>
<Button
data-testid="tags-button"
variant="secondary"
px="xs"
size="xs"
style={{ flexShrink: 0 }}
>
<IconTags size={14} className="me-1" />
{savedSearch.tags?.length || 0}
</Button>
</Tags>
<SearchPageActionBar
onClickDeleteSavedSearch={() => {
deleteSavedSearch.mutate(savedSearch?.id ?? '', {
onSuccess: () => {
router.push('/search');
},
});
}}
onClickRenameSavedSearch={() => {
setSaveSearchModalState('update');
}}
/>
</>
)}
</>
</Flex>
<SourceEditModal

View file

@ -0,0 +1,83 @@
import { useState } from 'react';
import { Box, Button, Input, Title } from '@mantine/core';
import { useHover } from '@mantine/hooks';
import { IconPencil } from '@tabler/icons-react';
export function EditablePageName({
name,
onSave,
}: {
name: string;
onSave: (name: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [editedName, setEditedName] = useState(name);
const { hovered, ref } = useHover();
const cancelEditing = () => {
setEditedName(name);
setEditing(false);
};
return (
<Box
ref={ref}
pe="md"
onDoubleClick={() => setEditing(true)}
className="cursor-pointer"
title="Double click to edit"
>
{editing ? (
<form
className="d-flex align-items-center"
onSubmit={e => {
e.preventDefault();
if (!editedName.trim()) return;
onSave(editedName);
setEditing(false);
}}
onKeyDown={e => {
if (e.key === 'Escape') {
cancelEditing();
}
}}
onBlur={e => {
if (!e.currentTarget.contains(e.relatedTarget)) {
cancelEditing();
}
}}
>
<Input
type="text"
value={editedName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setEditedName(e.target.value)
}
placeholder="Name"
autoFocus
/>
<Button ms="sm" variant="primary" type="submit">
Save Name
</Button>
</form>
) : (
<div className="d-flex align-items-center" style={{ minWidth: 100 }}>
<Title fw={400} order={3}>
{name}
</Title>
{hovered && (
<Button
ms="xs"
variant="subtle"
size="xs"
onClick={() => setEditing(true)}
>
<IconPencil size={14} />
</Button>
)}
</div>
)}
</Box>
);
}

View file

@ -245,6 +245,7 @@ export const AppNavLink = ({
isExpanded,
onToggle,
isBeta,
isActive,
}: {
className?: string;
label: React.ReactNode;
@ -253,6 +254,7 @@ export const AppNavLink = ({
isExpanded?: boolean;
onToggle?: () => void;
isBeta?: boolean;
isActive?: boolean;
}) => {
const { pathname, isCollapsed } = React.useContext(AppNavContext);
@ -268,7 +270,8 @@ export const AppNavLink = ({
// Check if current path matches this nav item
// Use exact match or startsWith to avoid partial matches (e.g., /search matching /search-settings)
const isActive = pathname === href || pathname?.startsWith(href + '/');
const defaultIsActive = pathname === href || pathname?.startsWith(href + '/');
const isActiveResolved = isActive ?? defaultIsActive;
return (
<Link
@ -276,7 +279,7 @@ export const AppNavLink = ({
href={href}
className={cx(
styles.navItem,
{ [styles.navItemActive]: isActive },
{ [styles.navItemActive]: isActiveResolved },
className,
)}
>

View file

@ -1,26 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect } from 'react';
import Link from 'next/link';
import Router, { useRouter } from 'next/router';
import cx from 'classnames';
import Fuse from 'fuse.js';
import {
NumberParam,
StringParam,
useQueryParam,
useQueryParams,
withDefault,
} from 'use-query-params';
import HyperDX from '@hyperdx/browser';
import { AlertState } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
Badge,
CloseButton,
Collapse,
Group,
Input,
Loader,
ScrollArea,
Text,
} from '@mantine/core';
@ -28,14 +15,10 @@ import { useDisclosure, useLocalStorage } from '@mantine/hooks';
import {
IconArrowBarToLeft,
IconBell,
IconBellFilled,
IconChartDots,
IconChevronDown,
IconChevronRight,
IconCommand,
IconDeviceFloppy,
IconDeviceLaptop,
IconLayoutGrid,
IconSearch,
IconSettings,
IconSitemap,
IconTable,
@ -45,9 +28,7 @@ import api from '@/api';
import { IS_LOCAL_MODE } from '@/config';
import InstallInstructionModal from '@/InstallInstructionsModal';
import OnboardingChecklist from '@/OnboardingChecklist';
import { useSavedSearches, useUpdateSavedSearch } from '@/savedSearch';
import { useLogomark, useWordmark } from '@/theme/ThemeProvider';
import type { SavedSearch } from '@/types';
import { UserPreferencesModal } from '@/UserPreferencesModal';
import { useUserPreferences } from '@/useUserPreferences';
import { useWindowSize } from '@/utils';
@ -68,8 +49,6 @@ import styles from './AppNav.module.scss';
const APP_VERSION =
process.env.NEXT_PUBLIC_APP_VERSION ?? packageJson.version ?? 'dev';
const UNTAGGED_SEARCHES_GROUP_NAME = 'Saved Searches';
// Navigation link configuration
type NavLinkConfig = {
id: string;
@ -109,234 +88,6 @@ const NAV_LINKS: NavLinkConfig[] = [
},
];
function SearchInput({
placeholder,
value,
onChange,
onEnterDown,
}: {
placeholder: string;
value: string;
onChange: (arg0: string) => void;
onEnterDown?: () => void;
}) {
const kbdShortcut = useMemo(() => {
return (
<div className={styles.shortcutHint}>
{window.navigator.platform?.toUpperCase().includes('MAC') ? (
<IconCommand size={8} />
) : (
<span className={styles.shortcutHintCtrl}>Ctrl</span>
)}
&nbsp;K
</div>
);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onEnterDown?.();
}
},
[onEnterDown],
);
return (
<Input
data-testid="nav-search-input"
placeholder={placeholder}
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(e.currentTarget.value)
}
leftSection={<IconSearch size={16} className="ps-1" />}
onKeyDown={handleKeyDown}
rightSection={
value ? (
<CloseButton
data-testid="nav-search-clear"
tabIndex={-1}
size="xs"
radius="xl"
onClick={() => onChange('')}
/>
) : (
kbdShortcut
)
}
mt={8}
mb="sm"
size="xs"
variant="filled"
radius="xl"
className={styles.searchInput}
/>
);
}
interface AppNavLinkItem {
id: string;
name: string;
tags?: string[];
}
type AppNavLinkGroup<T extends AppNavLinkItem> = {
name: string;
items: T[];
};
const AppNavGroupLabel = ({
name,
collapsed,
onClick,
}: {
name: string;
collapsed: boolean;
onClick: () => void;
}) => {
return (
<div className={styles.groupLabel} onClick={onClick}>
{collapsed ? (
<IconChevronRight size={14} />
) : (
<IconChevronDown size={14} />
)}
<div>{name}</div>
</div>
);
};
const AppNavLinkGroups = <T extends AppNavLinkItem>({
name,
groups,
renderLink,
onDragEnd,
forceExpandGroups = false,
}: {
name: string;
groups: AppNavLinkGroup<T>[];
renderLink: (item: T) => React.ReactNode;
onDragEnd?: (target: HTMLElement | null, newGroup: string | null) => void;
forceExpandGroups?: boolean;
}) => {
const [collapsedGroups, setCollapsedGroups] = useLocalStorage<
Record<string, boolean>
>({
key: `collapsedGroups-${name}`,
defaultValue: {},
});
const handleToggleGroup = useCallback(
(groupName: string) => {
setCollapsedGroups({
...collapsedGroups,
[groupName]: !collapsedGroups[groupName],
});
},
[collapsedGroups, setCollapsedGroups],
);
const [draggingOver, setDraggingOver] = useState<string | null>(null);
return (
<>
{groups.map(group => (
<div
key={group.name}
className={cx(draggingOver === group.name && styles.groupDragOver)}
onDragOver={e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDraggingOver(group.name);
}}
onDragEnd={e => {
e.preventDefault();
onDragEnd?.(e.target as HTMLElement, draggingOver);
setDraggingOver(null);
}}
>
<AppNavGroupLabel
onClick={() => handleToggleGroup(group.name)}
name={group.name}
collapsed={collapsedGroups[group.name]}
/>
<Collapse in={!collapsedGroups[group.name] || forceExpandGroups}>
{group.items.map(item => renderLink(item))}
</Collapse>
</div>
))}
</>
);
};
function useSearchableList<T extends AppNavLinkItem>({
items,
untaggedGroupName = 'Other',
}: {
items: T[];
untaggedGroupName?: string;
}) {
const fuse = useMemo(
() =>
new Fuse(items, {
keys: ['name'],
threshold: 0.2,
ignoreLocation: true,
}),
[items],
);
const [q, setQ] = useState('');
const filteredList = useMemo(() => {
if (q === '') {
return items;
}
return fuse.search(q).map(result => result.item);
}, [fuse, items, q]);
const groupedFilteredList = useMemo<AppNavLinkGroup<T>[]>(() => {
// group by tags
const groupedItems: Record<string, T[]> = {};
const untaggedItems: T[] = [];
filteredList.forEach(item => {
if (item.tags?.length) {
item.tags.forEach(tag => {
groupedItems[tag] = groupedItems[tag] ?? [];
groupedItems[tag].push(item);
});
} else {
untaggedItems.push(item);
}
});
if (untaggedItems.length) {
groupedItems[untaggedGroupName] = untaggedItems;
}
return Object.entries(groupedItems)
.map(([name, items]) => ({
name,
items,
}))
.sort((a, b) => {
if (a.name === untaggedGroupName) {
return 1;
}
if (b.name === untaggedGroupName) {
return -1;
}
return a.name.localeCompare(b.name);
});
}, [filteredList, untaggedGroupName]);
return {
filteredList,
groupedFilteredList,
q,
setQ,
};
}
export default function AppNav({ fixed = false }: { fixed?: boolean }) {
const wordmark = useWordmark();
const logomark = useLogomark({ size: 22 });
@ -358,33 +109,11 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
}
}, []);
const {
data: logViewsData,
isLoading: isLogViewsLoading,
refetch: refetchLogViews,
} = useSavedSearches();
const logViews = useMemo(() => logViewsData ?? [], [logViewsData]);
const updateLogView = useUpdateSavedSearch();
const router = useRouter();
const { pathname, query } = router;
const [timeRangeQuery] = useQueryParams({
from: withDefault(NumberParam, -1),
to: withDefault(NumberParam, -1),
});
const [inputTimeQuery] = useQueryParam('tq', withDefault(StringParam, ''), {
updateType: 'pushIn',
enableBatching: true,
});
const { data: meData } = api.useMe();
const [isSearchExpanded, setIsSearchExpanded] = useLocalStorage<boolean>({
key: 'isSearchExpanded',
defaultValue: true,
});
const { width } = useWindowSize();
const [isPreferCollapsed, setIsPreferCollapsed] = useLocalStorage<boolean>({
@ -415,95 +144,6 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
}
}, [meData]);
const {
q: searchesListQ,
setQ: setSearchesListQ,
filteredList: filteredSearchesList,
groupedFilteredList: groupedFilteredSearchesList,
} = useSearchableList({
items: logViews,
untaggedGroupName: UNTAGGED_SEARCHES_GROUP_NAME,
});
const savedSearchesResultsRef = useRef<HTMLDivElement>(null);
const renderLogViewLink = useCallback(
(savedSearch: SavedSearch) => (
<Link
href={`/search/${savedSearch.id}?${new URLSearchParams(
timeRangeQuery.from != -1 && timeRangeQuery.to != -1
? {
from: timeRangeQuery.from.toString(),
to: timeRangeQuery.to.toString(),
tq: inputTimeQuery,
}
: {},
).toString()}`}
key={savedSearch.id}
tabIndex={0}
className={cx(
styles.subMenuItem,
savedSearch.id === query.savedSearchId && styles.subMenuItemActive,
)}
title={savedSearch.name}
draggable
data-savedsearchid={savedSearch.id}
>
<Group gap={2}>
<div className="d-inline-block 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>
),
[
inputTimeQuery,
query.savedSearchId,
timeRangeQuery.from,
timeRangeQuery.to,
],
);
const handleLogViewDragEnd = useCallback(
(target: HTMLElement | null, name: string | null) => {
if (!target?.dataset.savedsearchid || name == null) {
return;
}
const logView = logViews.find(
lv => lv.id === target.dataset.savedsearchid,
);
if (logView?.tags?.includes(name)) {
return;
}
updateLogView.mutate(
{
id: target.dataset.savedsearchid,
tags: name === UNTAGGED_SEARCHES_GROUP_NAME ? [] : [name],
},
{
onSuccess: () => {
refetchLogViews();
},
},
);
},
[logViews, refetchLogViews, updateLogView],
);
const [
UserPreferencesOpen,
{ close: closeUserPreferences, open: openUserPreferences },
@ -593,54 +233,16 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
label="Search"
icon={<IconTable size={16} />}
href="/search"
isExpanded={isSearchExpanded}
onToggle={() => setIsSearchExpanded(!isSearchExpanded)}
isActive={pathname === '/search'}
/>
{!isCollapsed && (
<Collapse in={isSearchExpanded}>
<div className={styles.subMenu}>
{isLogViewsLoading ? (
<Loader variant="dots" mx="md" my="xs" size="sm" />
) : (
<>
<SearchInput
placeholder="Saved Searches"
value={searchesListQ}
onChange={setSearchesListQ}
onEnterDown={() => {
(
savedSearchesResultsRef?.current
?.firstChild as HTMLAnchorElement
)?.focus?.();
}}
/>
{logViews.length === 0 && (
<div className={styles.emptyMessage}>
No saved searches
</div>
)}
<div ref={savedSearchesResultsRef}>
<AppNavLinkGroups
name="saved-searches"
groups={groupedFilteredSearchesList}
renderLink={renderLogViewLink}
forceExpandGroups={!!searchesListQ}
onDragEnd={handleLogViewDragEnd}
/>
</div>
{searchesListQ && filteredSearchesList.length === 0 ? (
<div className={styles.emptyMessage}>
No results matching <i>{searchesListQ}</i>
</div>
) : null}
</>
)}
</div>
</Collapse>
)}
{/* Saved Searches */}
<AppNavLink
label="Saved Searches"
href="/search/list"
icon={<IconDeviceFloppy size={16} />}
isActive={pathname?.startsWith('/search/')}
/>
{/* Simple nav links from config */}
{NAV_LINKS.filter(link => !link.cloudOnly || !IS_LOCAL_MODE).map(
link => (
@ -662,11 +264,15 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
/>
{!isCollapsed && (
<Text size="xs" px="lg" py="xs" fw="lighter" fs="italic">
Saved dashboards have moved! Try the{' '}
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 page
</Anchor>
.
Dashboards
</Anchor>{' '}
page.
</Text>
)}

View file

@ -29,6 +29,8 @@ import {
IconUpload,
} from '@tabler/icons-react';
import { ListingCard } from '@/components/ListingCard';
import { ListingRow } from '@/components/ListingListRow';
import { PageHeader } from '@/components/PageHeader';
import { IS_K8S_DASHBOARD_ENABLED } from '@/config';
import {
@ -41,9 +43,6 @@ import { useConfirm } from '@/useConfirm';
import { withAppNav } from '../../layout';
import { DashboardCard } from './DashboardCard';
import { DashboardListRow } from './DashboardListRow';
const PRESET_DASHBOARDS = [
{
name: 'Services',
@ -158,7 +157,7 @@ export default function DashboardsListPage() {
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} mb="xl">
{PRESET_DASHBOARDS.map(p => (
<DashboardCard key={p.href} {...p} />
<ListingCard key={p.href} {...p} />
))}
</SimpleGrid>
@ -298,15 +297,17 @@ export default function DashboardsListPage() {
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th>Tiles</Table.Th>
<Table.Th w={50} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredDashboards.map(d => (
<DashboardListRow
<ListingRow
key={d.id}
dashboard={d}
id={d.id}
name={d.name}
href={`/dashboards/${d.id}`}
tags={d.tags}
onDelete={handleDelete}
/>
))}
@ -315,7 +316,7 @@ export default function DashboardsListPage() {
) : (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{filteredDashboards.map(d => (
<DashboardCard
<ListingCard
key={d.id}
name={d.name}
href={`/dashboards/${d.id}`}

View file

@ -2,18 +2,20 @@ import Link from 'next/link';
import { ActionIcon, Badge, Card, Group, Menu, Text } from '@mantine/core';
import { IconDots, IconTrash } from '@tabler/icons-react';
export function DashboardCard({
export function ListingCard({
name,
href,
description,
tags,
onDelete,
statusIcon,
}: {
name: string;
href: string;
description?: string;
tags?: string[];
onDelete?: () => void;
statusIcon?: React.ReactNode;
}) {
return (
<Card
@ -25,14 +27,17 @@ export function DashboardCard({
style={{ cursor: 'pointer', textDecoration: 'none' }}
>
<Group justify="space-between" mb="xs" wrap="nowrap">
<Text
fw={500}
lineClamp={1}
style={{ flex: 1, minWidth: 0 }}
title={name}
>
{name}
</Text>
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Text
fw={500}
lineClamp={1}
style={{ flex: 1, minWidth: 0 }}
title={name}
>
{name}
</Text>
{statusIcon}
</Group>
{onDelete && (
<Menu position="bottom-end" withinPortal>
<Menu.Target>

View file

@ -2,17 +2,21 @@ import Router from 'next/router';
import { ActionIcon, Badge, Group, Menu, Table, Text } from '@mantine/core';
import { IconDots, IconTrash } from '@tabler/icons-react';
import type { Dashboard } from '../../dashboard';
export function DashboardListRow({
dashboard,
export function ListingRow({
id,
name,
href,
tags,
onDelete,
statusIcon,
}: {
dashboard: Dashboard;
id: string;
name: string;
href: string;
tags?: string[];
onDelete: (id: string) => void;
statusIcon?: React.ReactNode;
}) {
const href = `/dashboards/${dashboard.id}`;
return (
<Table.Tr
style={{ cursor: 'pointer' }}
@ -30,24 +34,22 @@ export function DashboardListRow({
}}
>
<Table.Td>
<Text fw={500} size="sm">
{dashboard.name}
</Text>
<Group gap={4} wrap="nowrap">
<Text fw={500} size="sm">
{name}
</Text>
{statusIcon}
</Group>
</Table.Td>
<Table.Td>
<Group gap={4}>
{dashboard.tags.map(tag => (
{tags?.map(tag => (
<Badge key={tag} variant="light" size="xs">
{tag}
</Badge>
))}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{dashboard.tiles.length}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
@ -65,7 +67,7 @@ export function DashboardListRow({
leftSection={<IconTrash size={14} />}
onClick={e => {
e.stopPropagation();
onDelete(dashboard.id);
onDelete(id);
}}
>
Delete

View file

@ -0,0 +1,254 @@
import { useCallback, useMemo, useState } from 'react';
import Head from 'next/head';
import Router from 'next/router';
import { useQueryState } from 'nuqs';
import { AlertState } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Button,
Container,
Flex,
Group,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconBell,
IconBellFilled,
IconLayoutGrid,
IconList,
IconSearch,
IconTable,
} from '@tabler/icons-react';
import { ListingCard } from '@/components/ListingCard';
import { ListingRow } from '@/components/ListingListRow';
import { PageHeader } from '@/components/PageHeader';
import { useDeleteSavedSearch, useSavedSearches } from '@/savedSearch';
import { useBrandDisplayName } from '@/theme/ThemeProvider';
import type { SavedSearchWithEnhancedAlerts } from '@/types';
import { useConfirm } from '@/useConfirm';
import { withAppNav } from '../../layout';
function AlertStatusIcon({
alerts,
}: {
alerts?: SavedSearchWithEnhancedAlerts['alerts'];
}) {
if (!Array.isArray(alerts) || alerts.length === 0) return null;
const alertingCount = alerts.filter(a => a.state === AlertState.ALERT).length;
return (
<Tooltip
label={
alertingCount > 0
? `${alertingCount} alert${alertingCount > 1 ? 's' : ''} triggered`
: 'Alerts configured'
}
>
{alertingCount > 0 ? (
<IconBellFilled size={14} color="var(--mantine-color-red-filled)" />
) : (
<IconBell size={14} />
)}
</Tooltip>
);
}
export default function SavedSearchesListPage() {
const brandName = useBrandDisplayName();
const { data: savedSearches, isLoading, isError } = useSavedSearches();
const confirm = useConfirm();
const deleteSavedSearch = useDeleteSavedSearch();
const [search, setSearch] = useState('');
const [tagFilter, setTagFilter] = useQueryState('tag');
const [viewMode, setViewMode] = useLocalStorage<'grid' | 'list'>({
key: 'savedSearchesViewMode',
defaultValue: 'grid',
});
const allTags = useMemo(() => {
if (!savedSearches) return [];
const tags = new Set<string>();
savedSearches.forEach(s => s.tags.forEach(t => tags.add(t)));
return Array.from(tags).sort();
}, [savedSearches]);
const filteredSavedSearches = useMemo(() => {
if (!savedSearches) return [];
let result = savedSearches;
if (tagFilter) {
result = result.filter(s => s.tags.includes(tagFilter));
}
const trimmedSearch = search.trim();
if (trimmedSearch) {
const q = trimmedSearch.toLowerCase();
result = result.filter(
s =>
s.name.toLowerCase().includes(q) ||
s.tags.some(t => t.toLowerCase().includes(q)),
);
}
return result.slice().sort((a, b) => a.name.localeCompare(b.name));
}, [savedSearches, search, tagFilter]);
const handleDelete = useCallback(
async (id: string) => {
const confirmed = await confirm(
'Are you sure you want to delete this saved search? This action cannot be undone.',
'Delete',
{ variant: 'danger' },
);
if (!confirmed) return;
deleteSavedSearch.mutate(id, {
onSuccess: () => {
notifications.show({
message: 'Saved search deleted',
color: 'green',
});
},
onError: () => {
notifications.show({
message: 'Failed to delete saved search',
color: 'red',
});
},
});
},
[confirm, deleteSavedSearch],
);
return (
<div data-testid="saved-searches-list-page">
<Head>
<title>Saved Searches - {brandName}</title>
</Head>
<PageHeader>Saved Searches</PageHeader>
<Container maw={1200} py="lg" px="lg">
<Flex justify="space-between" align="center" mb="lg" gap="sm">
<Group gap="xs" style={{ flex: 1 }}>
<TextInput
placeholder="Search by name"
leftSection={<IconSearch size={16} />}
value={search}
onChange={e => setSearch(e.currentTarget.value)}
style={{ flex: 1, maxWidth: 400 }}
miw={100}
/>
{allTags.length > 0 && (
<Select
placeholder="Filter by tag"
data={allTags}
value={tagFilter}
onChange={v => setTagFilter(v)}
clearable
searchable
style={{ maxWidth: 200 }}
/>
)}
</Group>
<Group gap="xs" align="center">
<ActionIcon.Group>
<ActionIcon
variant={viewMode === 'grid' ? 'primary' : 'secondary'}
size="input-sm"
onClick={() => setViewMode('grid')}
aria-label="Grid view"
>
<IconLayoutGrid size={16} />
</ActionIcon>
<ActionIcon
variant={viewMode === 'list' ? 'primary' : 'secondary'}
size="input-sm"
onClick={() => setViewMode('list')}
aria-label="List view"
>
<IconList size={16} />
</ActionIcon>
</ActionIcon.Group>
<Button
variant="primary"
leftSection={<IconTable size={16} />}
onClick={() => Router.push('/search')}
data-testid="new-search-button"
>
New Search
</Button>
</Group>
</Flex>
{isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
Loading saved searches...
</Text>
) : isError ? (
<Text size="sm" c="red" ta="center" py="xl">
Failed to load saved searches. Please try refreshing the page.
</Text>
) : filteredSavedSearches.length === 0 ? (
<Stack align="center" gap="sm" py="xl">
<IconTable size={40} opacity={0.3} />
<Text size="sm" c="dimmed" ta="center">
{search || tagFilter
? 'No matching saved searches.'
: 'No saved searches yet.'}
</Text>
<Button
variant="primary"
leftSection={<IconTable size={16} />}
onClick={() => Router.push('/search')}
data-testid="empty-new-search-button"
>
New Search
</Button>
</Stack>
) : viewMode === 'list' ? (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th w={50} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredSavedSearches.map(s => (
<ListingRow
key={s.id}
id={s.id}
name={s.name}
href={`/search/${s.id}`}
tags={s.tags}
onDelete={handleDelete}
statusIcon={<AlertStatusIcon alerts={s.alerts} />}
/>
))}
</Table.Tbody>
</Table>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{filteredSavedSearches.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} />}
/>
))}
</SimpleGrid>
)}
</Container>
</div>
);
}
SavedSearchesListPage.getLayout = withAppNav;

View file

@ -1,17 +1,18 @@
import { ActionIcon, Menu } from '@mantine/core';
import { IconDotsVertical, IconForms, IconTrash } from '@tabler/icons-react';
import { IconCopy, IconDotsVertical, IconTrash } from '@tabler/icons-react';
export default function SearchPageActionBar({
onClickDeleteSavedSearch,
onClickRenameSavedSearch,
onClickSaveAsNew,
}: {
onClickDeleteSavedSearch: () => void;
onClickRenameSavedSearch: () => void;
onClickSaveAsNew: () => void;
}) {
return (
<Menu width={250}>
<Menu.Target>
<ActionIcon
data-testid="search-page-action-bar"
variant="secondary"
style={{ flexShrink: 0 }}
size="input-xs"
@ -21,18 +22,19 @@ export default function SearchPageActionBar({
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={onClickSaveAsNew}
>
Save as New Search
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={onClickDeleteSavedSearch}
>
Delete Saved Search
</Menu.Item>
<Menu.Item
leftSection={<IconForms size={16} />}
onClick={onClickRenameSavedSearch}
>
Rename Saved Search
</Menu.Item>
</Menu.Dropdown>
</Menu>
);

View file

@ -4,9 +4,43 @@ import {
DEFAULT_TRACES_SOURCE_NAME,
} from 'tests/e2e/utils/constants';
import { SavedSearchesListPage } from '../../page-objects/SavedSearchesListPage';
import { SearchPage } from '../../page-objects/SearchPage';
import { getApiUrl, getSources } from '../../utils/api-helpers';
import { expect, test } from '../../utils/base-test';
/**
* Helper to create a saved search via the API.
* Fetches the log source ID from the API to use as the source reference.
* Returns the created saved search object (including its id).
*/
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('Saved Search Functionality', () => {
let searchPage: SearchPage;
@ -618,4 +652,298 @@ test.describe('Saved Search Functionality', () => {
});
},
);
test(
'should save as new search from an existing saved search',
{},
async ({ page }) => {
/**
* This test verifies that "Save as New Search" creates a new saved search
* with the current configuration, navigating to a new URL while preserving
* the original saved search.
*
* Test flow:
* 1. Create a saved search with a custom WHERE and SELECT
* 2. Click "Save as New Search" from the action bar menu
* 3. Give the new search a different name and save
* 4. Verify navigation to a new saved search URL
* 5. Verify the new search preserved the original's configuration
* 6. Verify the original saved search still exists
*/
let originalUrl: string;
const originalName = 'Original Search';
const newName = 'Cloned Search';
const customSelect =
'Timestamp, Body, upper(ServiceName) as service_upper';
await test.step('Create a saved search with custom SELECT and WHERE', async () => {
await searchPage.setCustomSELECT(customSelect);
await searchPage.performSearch('SeverityText:info');
await searchPage.openSaveSearchModal();
await searchPage.savedSearchModal.saveSearchAndWaitForNavigation(
originalName,
);
originalUrl = page.url();
});
await test.step('Click Save as New Search from the action bar', async () => {
await searchPage.clickSaveAsNew();
});
await test.step('Save the new search with a different name', async () => {
await searchPage.savedSearchModal.saveSearchAndWaitForNavigation(
newName,
);
});
await test.step('Verify navigation to a new saved search URL', async () => {
await expect(page).not.toHaveURL(originalUrl);
await expect(page).toHaveURL(/\/search\/[a-f0-9]+/);
});
await test.step('Verify the new search preserved the SELECT', async () => {
await searchPage.table.waitForRowsToPopulate();
const selectEditor = searchPage.getSELECTEditor();
await expect(selectEditor).toContainText(
'upper(ServiceName) as service_upper',
);
});
await test.step('Verify the new search preserved the WHERE', async () => {
await expect(searchPage.input).toHaveValue('SeverityText:info');
});
await test.step('Verify the original saved search still exists', async () => {
await page.goto(originalUrl);
await expect(page.getByTestId('search-page')).toBeVisible();
await searchPage.table.waitForRowsToPopulate();
await expect(searchPage.input).toHaveValue('SeverityText:info');
const selectEditor = searchPage.getSELECTEditor();
await expect(selectEditor).toContainText(
'upper(ServiceName) as service_upper',
);
});
},
);
});
test.describe('Saved Searches Listing Page', { tag: ['@search'] }, () => {
let listPage: SavedSearchesListPage;
test.beforeEach(async ({ page }) => {
listPage = new SavedSearchesListPage(page);
});
test(
'should display the saved searches listing page',
{ tag: '@full-stack' },
async () => {
await listPage.goto();
await test.step('Verify the page container is visible', async () => {
await expect(listPage.pageContainer).toBeVisible();
});
await test.step('Verify the New Search button is visible', async () => {
await expect(listPage.newSearchButton).toBeVisible();
});
},
);
test(
'should navigate to search page when clicking New Search',
{ tag: '@full-stack' },
async ({ page }) => {
await listPage.goto();
await test.step('Click the New Search button', async () => {
await listPage.clickNewSearch();
});
await test.step('Verify navigation to the search page', async () => {
await expect(page).toHaveURL(/\/search\?/);
});
},
);
test(
'should search saved searches by name',
{ tag: '@full-stack' },
async ({ page }) => {
const ts = Date.now();
const uniqueName = `E2E Search Test ${ts}`;
await test.step('Create a saved search via API', async () => {
await createSavedSearchViaApi(page, { name: uniqueName });
});
await test.step('Navigate to the listing page', async () => {
await listPage.goto();
});
await test.step('Search for the unique name', async () => {
await listPage.searchSavedSearches(uniqueName);
});
await test.step('Verify the saved search appears in results', async () => {
await expect(listPage.getSavedSearchCard(uniqueName)).toBeVisible();
});
await test.step('Search for a non-existent name', async () => {
await listPage.searchSavedSearches(`nonexistent-search-xyz-${ts}`);
});
await test.step('Verify no matches state is shown', async () => {
await expect(listPage.getNoMatchesState()).toBeVisible();
});
},
);
test(
'should switch between grid and list views',
{ tag: '@full-stack' },
async ({ page }) => {
const ts = Date.now();
const uniqueName = `E2E View Toggle ${ts}`;
await test.step('Create a saved search via API', async () => {
await createSavedSearchViaApi(page, { name: uniqueName });
});
await test.step('Navigate to the listing page', async () => {
await listPage.goto();
});
await test.step('Verify grid view shows card', async () => {
await expect(listPage.getSavedSearchCard(uniqueName)).toBeVisible();
});
await test.step('Switch to list view', async () => {
await listPage.switchToListView();
});
await test.step('Verify the saved search appears in a table row', async () => {
await expect(listPage.getSavedSearchRow(uniqueName)).toBeVisible();
});
await test.step('Switch back to grid view', async () => {
await listPage.switchToGridView();
});
await test.step('Verify the card reappears', async () => {
await expect(listPage.getSavedSearchCard(uniqueName)).toBeVisible();
});
},
);
test(
'should delete a saved search from the card view',
{ tag: '@full-stack' },
async ({ page }) => {
const ts = Date.now();
const uniqueName = `E2E Delete Card ${ts}`;
await test.step('Create a saved search via API', async () => {
await createSavedSearchViaApi(page, { name: uniqueName });
});
await test.step('Navigate to the listing page', async () => {
await listPage.goto();
});
await test.step('Delete the saved search via the card menu', async () => {
await listPage.deleteSavedSearchFromCard(uniqueName);
});
await test.step('Verify the saved search is removed', async () => {
await expect(listPage.getSavedSearchCard(uniqueName)).toBeHidden();
});
await test.step('Verify the deletion notification appears', async () => {
await expect(page.getByText('Saved search deleted')).toBeVisible();
});
},
);
test(
'should delete a saved search from the list view',
{ tag: '@full-stack' },
async ({ page }) => {
const ts = Date.now();
const uniqueName = `E2E Delete Row ${ts}`;
await test.step('Create a saved search via API', async () => {
await createSavedSearchViaApi(page, { name: uniqueName });
});
await test.step('Navigate to the listing page', async () => {
await listPage.goto();
});
await test.step('Switch to list view', async () => {
await listPage.switchToListView();
});
await test.step('Delete the saved search via the row menu', async () => {
await listPage.deleteSavedSearchFromRow(uniqueName);
});
await test.step('Verify the saved search is removed', async () => {
await expect(listPage.getSavedSearchRow(uniqueName)).toBeHidden();
});
await test.step('Verify the deletion notification appears', async () => {
await expect(page.getByText('Saved search deleted')).toBeVisible();
});
},
);
test(
'should filter saved searches by tag',
{ tag: '@full-stack' },
async ({ page }) => {
const ts = Date.now();
const taggedName = `E2E Tagged Search ${ts}`;
const untaggedName = `E2E Untagged Search ${ts}`;
const tag = `e2e-tag-${ts}`;
await test.step('Create a saved search with a tag', async () => {
await createSavedSearchViaApi(page, {
name: taggedName,
tags: [tag],
});
});
await test.step('Create a saved search without the tag', async () => {
await createSavedSearchViaApi(page, { name: untaggedName });
});
await test.step('Navigate to the listing page and verify both are visible', async () => {
await listPage.goto();
await expect(listPage.getSavedSearchCard(taggedName)).toBeVisible();
await expect(listPage.getSavedSearchCard(untaggedName)).toBeVisible();
});
await test.step('Select the tag filter', async () => {
await listPage.selectTagFilter(tag);
});
await test.step('Verify only the tagged search is shown', async () => {
await expect(listPage.getSavedSearchCard(taggedName)).toBeVisible();
await expect(listPage.getSavedSearchCard(untaggedName)).toBeHidden();
});
await test.step('Clear the tag filter', async () => {
await listPage.clearTagFilter();
});
await test.step('Verify both searches are visible again', async () => {
await expect(listPage.getSavedSearchCard(taggedName)).toBeVisible();
await expect(listPage.getSavedSearchCard(untaggedName)).toBeVisible();
});
},
);
});

View file

@ -187,7 +187,7 @@ export class DashboardPage {
await defaultNameHeading.dblclick();
// Fill in new name
const nameInput = this.page.locator('input[placeholder="Dashboard Name"]');
const nameInput = this.page.locator('input[placeholder="Name"]');
await nameInput.fill(newName);
await this.page.keyboard.press('Enter');

View file

@ -0,0 +1,97 @@
/**
* SavedSearchesListPage - Page object for the saved searches listing page
* Encapsulates interactions with saved search browsing, search, filtering, and management
*/
import { Locator, Page } from '@playwright/test';
export class SavedSearchesListPage {
readonly page: Page;
readonly pageContainer: Locator;
readonly searchInput: Locator;
readonly newSearchButton: Locator;
readonly gridViewButton: Locator;
readonly listViewButton: Locator;
private readonly emptyNewSearchButton: Locator;
private readonly confirmConfirmButton: Locator;
constructor(page: Page) {
this.page = page;
this.pageContainer = page.getByTestId('saved-searches-list-page');
this.searchInput = page.getByPlaceholder('Search by name');
this.newSearchButton = page.getByTestId('new-search-button');
this.gridViewButton = page.getByRole('button', { name: 'Grid view' });
this.listViewButton = page.getByRole('button', { name: 'List view' });
this.emptyNewSearchButton = page.getByTestId('empty-new-search-button');
this.confirmConfirmButton = page.getByTestId('confirm-confirm-button');
}
async goto() {
await this.page.goto('/search/list', { waitUntil: 'networkidle' });
}
async searchSavedSearches(query: string) {
await this.searchInput.fill(query);
}
async clearSearch() {
await this.searchInput.clear();
}
async clickNewSearch() {
await this.newSearchButton.click();
await this.page.waitForURL(/\/search\?/);
}
async switchToGridView() {
await this.gridViewButton.click();
}
async switchToListView() {
await this.listViewButton.click();
}
getSavedSearchCard(name: string) {
return this.pageContainer.locator('a').filter({ hasText: name });
}
getSavedSearchRow(name: string) {
return this.pageContainer.locator('tr').filter({ hasText: name });
}
async deleteSavedSearchFromCard(name: string) {
const card = this.getSavedSearchCard(name);
await card.getByRole('button').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 this.page.getByRole('menuitem', { name: 'Delete' }).click();
await this.confirmConfirmButton.click();
}
getTagFilterSelect() {
return this.page.getByPlaceholder('Filter by tag');
}
async selectTagFilter(tag: string) {
await this.getTagFilterSelect().click();
await this.page.getByRole('option', { name: tag, exact: true }).click();
}
async clearTagFilter() {
const select = this.getTagFilterSelect();
await select.locator('..').locator('button').click();
}
getEmptyState() {
return this.pageContainer.getByText('No saved searches yet.');
}
getNoMatchesState() {
return this.pageContainer.getByText('No matching saved searches.');
}
}

View file

@ -186,6 +186,18 @@ export class SearchPage {
await button.click();
}
/**
* Click "Save as New Search" from the action bar menu on an existing saved search.
* Opens the save search modal in "create" mode for duplicating the current search.
*/
async clickSaveAsNew() {
// Click the action bar menu trigger (three dots icon next to the saved search name)
await this.page.getByTestId('search-page-action-bar').click();
await this.page
.getByRole('menuitem', { name: 'Save as New Search' })
.click();
}
/**
* Open the alerts creation modal for the current saved search
*/