feat: Group Dashboards and Searches by Tag (#2033)

## Summary

This PR

1. Updates the Saved Searches and Dashboards pages to group objects by tag when in the card view
2. Fixes a display bug on the sidebar which resulted in favorited saved searches with long names and alerts configured to wrap
3. Adds the "Saved searches and dashboards have moved!" message back to the sidebar - it was inadvertently removed in a previous PR.

### Screenshots or video

<img width="1749" height="1024" alt="Screenshot 2026-04-01 at 3 46 42 PM" src="https://github.com/user-attachments/assets/b5f03bcb-7588-47cb-acc5-af56f0f9ddf4" />


### How to test locally or on Vercel

This can be tested in the preview environment by creating and tagging some saved searches and dashboards.

### References



- Linear Issue:
- Related PRs:
This commit is contained in:
Drew Davis 2026-04-02 13:24:53 -04:00 committed by GitHub
parent 0abce12242
commit bfc938118d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 196 additions and 37 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Group Dashboards and Searches by Tag

View file

@ -6,8 +6,10 @@ import HyperDX from '@hyperdx/browser';
import { AlertState } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
Badge,
Collapse,
Flex,
Group,
ScrollArea,
Text,
@ -213,22 +215,26 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
)}
title={savedSearch.name}
>
<Group gap={2}>
<Group gap={2} wrap="nowrap" align="center">
<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"
/>
<Flex flex={0}>
<IconBellFilled
size={14}
className="float-end text-danger ms-1"
aria-label="Has Alerts and is in ALERT state"
/>
</Flex>
) : (
<IconBell
size={14}
className="float-end ms-1"
aria-label="Has Alerts and is in OK state"
/>
<Flex flex={0}>
<IconBell
size={14}
className="float-end ms-1"
aria-label="Has Alerts and is in OK state"
/>
</Flex>
)
) : null}
</Group>
@ -436,6 +442,20 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
</Collapse>
)}
{!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>
)}
{/* Help */}
<AppNavHelpMenu version={APP_VERSION} />

View file

@ -44,6 +44,7 @@ import {
import { useFavorites } from '@/favorites';
import { useBrandDisplayName } from '@/theme/ThemeProvider';
import { useConfirm } from '@/useConfirm';
import { groupByTags } from '@/utils/groupByTags';
import { withAppNav } from '../../layout';
@ -121,6 +122,11 @@ export default function DashboardsListPage() {
return result.slice().sort((a, b) => a.name.localeCompare(b.name));
}, [dashboards, search, tagFilter]);
const tagGroups = useMemo(
() => groupByTags(filteredDashboards, tagFilter),
[filteredDashboards, tagFilter],
);
const handleCreate = useCallback(() => {
createDashboard.mutate(
{ name: 'My Dashboard', tiles: [], tags: [] },
@ -374,20 +380,29 @@ export default function DashboardsListPage() {
</Table.Tbody>
</Table>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{filteredDashboards.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"
/>
<Stack gap="lg">
{tagGroups.map(group => (
<div key={group.tag}>
<Text fw={500} size="sm" c="dimmed" mb="sm">
{group.tag}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{group.items.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>
</div>
))}
</SimpleGrid>
</Stack>
)}
</Container>
</div>

View file

@ -37,6 +37,7 @@ import { useDeleteSavedSearch, useSavedSearches } from '@/savedSearch';
import { useBrandDisplayName } from '@/theme/ThemeProvider';
import type { SavedSearchWithEnhancedAlerts } from '@/types';
import { useConfirm } from '@/useConfirm';
import { groupByTags } from '@/utils/groupByTags';
import { withAppNav } from '../../layout';
@ -116,6 +117,11 @@ export default function SavedSearchesListPage() {
return result.slice().sort((a, b) => a.name.localeCompare(b.name));
}, [savedSearches, search, tagFilter]);
const tagGroups = useMemo(
() => groupByTags(filteredSavedSearches, tagFilter),
[filteredSavedSearches, tagFilter],
);
const handleDelete = useCallback(
async (id: string) => {
const confirmed = await confirm(
@ -290,20 +296,29 @@ export default function SavedSearchesListPage() {
</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} />}
resourceId={s.id}
resourceType="savedSearch"
/>
<Stack gap="lg">
{tagGroups.map(group => (
<div key={group.tag}>
<Text fw={500} size="sm" c="dimmed" mb="sm">
{group.tag}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{group.items.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>
</div>
))}
</SimpleGrid>
</Stack>
)}
</Container>
</div>

View file

@ -0,0 +1,63 @@
import { groupByTags } from '../groupByTags';
type Item = { name: string; tags: string[] };
const item = (name: string, tags: string[]): Item => ({ name, tags });
describe('groupByTags', () => {
it('returns empty array for empty input', () => {
expect(groupByTags([], null)).toEqual([]);
});
it('groups items by tag alphabetically', () => {
const items = [
item('a', ['zeta']),
item('b', ['alpha']),
item('c', ['zeta']),
];
const groups = groupByTags(items, null);
expect(groups).toEqual([
{ tag: 'alpha', items: [items[1]] },
{ tag: 'zeta', items: [items[0], items[2]] },
]);
});
it('places untagged items in an "Untagged" group at the end', () => {
const items = [item('a', ['beta']), item('b', [])];
const groups = groupByTags(items, null);
expect(groups).toEqual([
{ tag: 'beta', items: [items[0]] },
{ tag: 'Untagged', items: [items[1]] },
]);
});
it('duplicates items with multiple tags into each group', () => {
const items = [item('a', ['beta', 'alpha'])];
const groups = groupByTags(items, null);
expect(groups).toEqual([
{ tag: 'alpha', items: [items[0]] },
{ tag: 'beta', items: [items[0]] },
]);
});
it('returns only the filtered tag group when tagFilter is set', () => {
const items = [
item('a', ['alpha', 'beta']),
item('b', ['beta']),
item('c', ['gamma']),
];
const groups = groupByTags(items, 'beta');
expect(groups).toEqual([{ tag: 'beta', items: [items[0], items[1]] }]);
});
it('returns empty array when tagFilter matches no items', () => {
const items = [item('a', ['alpha'])];
expect(groupByTags(items, 'nonexistent')).toEqual([]);
});
it('handles all items being untagged', () => {
const items = [item('a', []), item('b', [])];
const groups = groupByTags(items, null);
expect(groups).toEqual([{ tag: 'Untagged', items: [items[0], items[1]] }]);
});
});

View file

@ -0,0 +1,41 @@
export type TagGroup<T> = { tag: string; items: T[] };
const UNTAGGED_GROUP_TAG = 'Untagged';
export function groupByTags<T extends { tags: string[] }>(
items: T[],
tagFilter: string | null,
): TagGroup<T>[] {
const tagMap = new Map<string, T[]>();
const untagged: T[] = [];
for (const item of items) {
if (item.tags.length === 0) {
untagged.push(item);
} else {
for (const tag of item.tags) {
if (!tagMap.has(tag)) tagMap.set(tag, []);
tagMap.get(tag)!.push(item);
}
}
}
const groups: TagGroup<T>[] = [];
if (tagFilter) {
const filtered = tagMap.get(tagFilter);
if (filtered) {
groups.push({ tag: tagFilter, items: filtered });
}
} else {
const sortedTags = Array.from(tagMap.keys()).sort();
for (const tag of sortedTags) {
groups.push({ tag, items: tagMap.get(tag)! });
}
if (untagged.length > 0) {
groups.push({ tag: UNTAGGED_GROUP_TAG, items: untagged });
}
}
return groups;
}