mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
0abce12242
commit
bfc938118d
6 changed files with 196 additions and 37 deletions
5
.changeset/afraid-papayas-care.md
Normal file
5
.changeset/afraid-papayas-care.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Group Dashboards and Searches by Tag
|
||||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
63
packages/app/src/utils/__tests__/groupByTags.test.ts
Normal file
63
packages/app/src/utils/__tests__/groupByTags.test.ts
Normal 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]] }]);
|
||||
});
|
||||
});
|
||||
41
packages/app/src/utils/groupByTags.ts
Normal file
41
packages/app/src/utils/groupByTags.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue