Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/dashboards-multi-select-tag-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@hyperdx/app': patch
---

feat(dashboards): multi-select tag filter on Dashboards and Saved Searches; each item renders once

The single-tag dropdown on the Dashboards and Saved Searches list pages
is now a multi-select chip filter. Selecting multiple chips returns the
union of matching items (OR semantics), and each item renders exactly
once in the grid even when it carries multiple selected tags. URL state
moves to `?tags=a,b`; existing `?tag=foo` links continue to load and
migrate onto the new state on mount.
99 changes: 52 additions & 47 deletions packages/app/src/components/Dashboards/DashboardsListPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import Router from 'next/router';
import { useQueryState } from 'nuqs';
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
import {
ActionIcon,
Anchor,
Expand All @@ -11,9 +11,8 @@ import {
Flex,
Group,
Menu,
Select,
MultiSelect,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
Expand Down Expand Up @@ -46,7 +45,6 @@ import {
import { useFavorites } from '@/favorites';
import { useBrandDisplayName } from '@/theme/ThemeProvider';
import { useConfirm } from '@/useConfirm';
import { groupByTags } from '@/utils/groupByTags';

import { withAppNav } from '../../layout';

Expand Down Expand Up @@ -83,12 +81,31 @@ export default function DashboardsListPage() {
const createDashboard = useCreateDashboard();
const deleteDashboard = useDeleteDashboard();
const [search, setSearch] = useState('');
const [tagFilter, setTagFilter] = useQueryState('tag');
const [selectedTags, setSelectedTags] = useQueryState(
'tags',
parseAsArrayOf(parseAsString)
.withDefault([])
.withOptions({ history: 'replace' }),
);
const [legacyTag, setLegacyTag] = useQueryState('tag');
const [viewMode, setViewMode] = useLocalStorage<'grid' | 'list'>({
key: 'dashboardsViewMode',
defaultValue: 'grid',
});

// Backward compat for shared links / bookmarks built before multi-select.
// `?tag=foo` becomes `?tags=foo` once on mount. Modern `?tags=...` wins
// when both are present.
useEffect(() => {
if (legacyTag) {
if (selectedTags.length === 0) {
setSelectedTags([legacyTag]);
}
setLegacyTag(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const { data: favorites } = useFavorites();
const favoritedDashboards = useMemo(() => {
if (!dashboards || !favorites?.length) return [];
Expand All @@ -114,8 +131,8 @@ export default function DashboardsListPage() {
const filteredDashboards = useMemo(() => {
if (!dashboards) return [];
let result = dashboards;
if (tagFilter) {
result = result.filter(d => d.tags.includes(tagFilter));
if (selectedTags.length > 0) {
result = result.filter(d => d.tags.some(t => selectedTags.includes(t)));
}
if (search.trim()) {
const q = search.toLowerCase();
Expand All @@ -126,12 +143,7 @@ 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],
);
}, [dashboards, search, selectedTags]);

const handleCreate = useCallback(() => {
createDashboard.mutate(
Expand Down Expand Up @@ -252,14 +264,16 @@ export default function DashboardsListPage() {
miw={100}
/>
{allTags.length > 0 && (
<Select
placeholder="Filter by tag"
<MultiSelect
placeholder="Filter by tags"
data={allTags}
value={tagFilter}
onChange={v => setTagFilter(v)}
value={selectedTags}
onChange={setSelectedTags}
clearable
searchable
style={{ maxWidth: 200 }}
style={{ flex: 1, maxWidth: 400 }}
miw={200}
data-testid="tag-filter"
/>
)}
</Group>
Expand Down Expand Up @@ -347,7 +361,7 @@ export default function DashboardsListPage() {
<EmptyState
icon={<IconLayoutGrid size={32} />}
title={
search || tagFilter
search || selectedTags.length > 0
? 'No matching dashboards yet'
: 'No dashboards yet'
}
Expand Down Expand Up @@ -413,34 +427,25 @@ export default function DashboardsListPage() {
</Table.Tbody>
</Table>
) : (
<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)}
statusIcon={
<AlertStatusIcon alerts={getDashboardAlerts(d.tiles)} />
}
resourceId={d.id}
resourceType="dashboard"
updatedAt={d.updatedAt}
updatedBy={d.updatedBy?.name || d.updatedBy?.email}
/>
))}
</SimpleGrid>
</div>
<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)}
statusIcon={
<AlertStatusIcon alerts={getDashboardAlerts(d.tiles)} />
}
resourceId={d.id}
resourceType="dashboard"
updatedAt={d.updatedAt}
updatedBy={d.updatedBy?.name || d.updatedBy?.email}
/>
))}
</Stack>
</SimpleGrid>
)}
</Container>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import React from 'react';
import { screen, within } from '@testing-library/react';

import DashboardsListPage from '../DashboardsListPage';

const mockSetSelectedTags = jest.fn();
const mockSetLegacyTag = jest.fn();
const mockUseDashboards = jest.fn();
const mockUseFavorites = jest.fn();
const mockUseCreateDashboard = jest.fn();
const mockUseDeleteDashboard = jest.fn();
const mockUseConfirm = jest.fn();
const mockUseBrandDisplayName = jest.fn();

let mockSelectedTags: string[] = [];
let mockLegacyTag: string | null = null;

jest.mock('next/router', () => ({
__esModule: true,
default: { push: jest.fn() },
}));

jest.mock('next/head', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

jest.mock('next/link', () => ({
__esModule: true,
default: ({
children,
href,
}: {
children: React.ReactNode;
href: string;
}) => <a href={href}>{children}</a>,
}));

jest.mock('@/layout', () => ({
withAppNav: (component: unknown) => component,
}));

jest.mock('@/config', () => ({
IS_K8S_DASHBOARD_ENABLED: false,
}));

jest.mock('nuqs', () => ({
parseAsString: 'parseAsString',
parseAsArrayOf: () => ({
withDefault: () => ({
withOptions: () => 'parseAsArrayOfString',
}),
}),
useQueryState: (key: string) => {
if (key === 'tags') return [mockSelectedTags, mockSetSelectedTags];
if (key === 'tag') return [mockLegacyTag, mockSetLegacyTag];
return [null, jest.fn()];
},
}));

jest.mock('@/dashboard', () => ({
useDashboards: () => mockUseDashboards(),
useCreateDashboard: () => mockUseCreateDashboard(),
useDeleteDashboard: () => mockUseDeleteDashboard(),
}));

jest.mock('@/favorites', () => ({
useFavorites: () => mockUseFavorites(),
useToggleFavorite: () => ({
isFavorited: false,
toggleFavorite: jest.fn(),
}),
}));

jest.mock('@/theme/ThemeProvider', () => ({
useBrandDisplayName: () => mockUseBrandDisplayName(),
}));

jest.mock('@/useConfirm', () => ({
useConfirm: () => mockUseConfirm(),
}));

const dashboard = (
id: string,
name: string,
tags: string[],
): {
id: string;
name: string;
tags: string[];
tiles: never[];
updatedAt: string;
updatedBy?: { name?: string; email?: string };
createdBy?: { name?: string; email?: string };
} => ({
id,
name,
tags,
tiles: [],
updatedAt: '2026-05-01T00:00:00.000Z',
updatedBy: { name: 'tester' },
createdBy: { name: 'tester' },
});

const seedDashboards = [
dashboard('d-untagged', 'Untagged dash', []),
dashboard('d-checkout', 'Checkout dash', ['checkout']),
dashboard('d-multi', 'Multi dash', ['checkout', 'payments']),
dashboard('d-payments', 'Payments dash', ['payments']),
];

beforeEach(() => {
mockSelectedTags = [];
mockLegacyTag = null;
mockSetSelectedTags.mockClear();
mockSetLegacyTag.mockClear();
mockUseDashboards.mockReturnValue({
data: seedDashboards,
isLoading: false,
isError: false,
});
mockUseFavorites.mockReturnValue({ data: [] });
mockUseCreateDashboard.mockReturnValue({
mutate: jest.fn(),
isPending: false,
});
mockUseDeleteDashboard.mockReturnValue({ mutate: jest.fn() });
mockUseConfirm.mockReturnValue(jest.fn());
mockUseBrandDisplayName.mockReturnValue('HyperDX');
});

describe('DashboardsListPage', () => {
it('renders each multi-tagged dashboard exactly once when filtering by two tags (OR semantics)', () => {
mockSelectedTags = ['checkout', 'payments'];

renderWithMantine(<DashboardsListPage />);

const grid = screen.getByTestId('dashboards-list-page');

// The three tagged dashboards match (one tagged checkout, one
// tagged payments, one tagged both). The untagged dashboard is
// filtered out. The multi-tagged dashboard renders exactly once.
expect(within(grid).getAllByText('Checkout dash')).toHaveLength(1);
expect(within(grid).getAllByText('Multi dash')).toHaveLength(1);
expect(within(grid).getAllByText('Payments dash')).toHaveLength(1);
expect(within(grid).queryByText('Untagged dash')).toBeNull();
});

it('migrates legacy `?tag=foo` URLs onto the new `?tags=[foo]` state on mount', () => {
mockLegacyTag = 'checkout';
mockSelectedTags = [];

renderWithMantine(<DashboardsListPage />);

expect(mockSetSelectedTags).toHaveBeenCalledWith(['checkout']);
expect(mockSetLegacyTag).toHaveBeenCalledWith(null);
});

it('shows the no-matches empty state when chips have any selection but match nothing', () => {
mockSelectedTags = ['does-not-exist'];

renderWithMantine(<DashboardsListPage />);

expect(screen.getByText('No matching dashboards yet')).toBeInTheDocument();
});
});
Loading
Loading