diff --git a/.changeset/dashboards-multi-select-tag-filter.md b/.changeset/dashboards-multi-select-tag-filter.md new file mode 100644 index 0000000000..80825a0b37 --- /dev/null +++ b/.changeset/dashboards-multi-select-tag-filter.md @@ -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. diff --git a/packages/app/src/components/Dashboards/DashboardsListPage.tsx b/packages/app/src/components/Dashboards/DashboardsListPage.tsx index 3692c873c1..7dbfdeea1f 100644 --- a/packages/app/src/components/Dashboards/DashboardsListPage.tsx +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -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, @@ -11,9 +11,8 @@ import { Flex, Group, Menu, - Select, + MultiSelect, SimpleGrid, - Stack, Table, Text, TextInput, @@ -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'; @@ -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 []; @@ -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(); @@ -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( @@ -252,14 +264,16 @@ export default function DashboardsListPage() { miw={100} /> {allTags.length > 0 && ( - setTagFilter(v)} + value={selectedTags} + onChange={setSelectedTags} clearable searchable - style={{ maxWidth: 200 }} + style={{ flex: 1, maxWidth: 400 }} + miw={200} + data-testid="tag-filter" /> )} @@ -238,7 +252,7 @@ export default function SavedSearchesListPage() { } title={ - search || tagFilter + search || selectedTags.length > 0 ? 'No matching saved searches yet' : 'No saved searches yet' } @@ -292,31 +306,22 @@ export default function SavedSearchesListPage() { ) : ( - - {tagGroups.map(group => ( -
- - {group.tag} - - - {group.items.map(s => ( - handleDelete(s.id)} - statusIcon={} - resourceId={s.id} - resourceType="savedSearch" - updatedAt={s.updatedAt} - updatedBy={s.updatedBy?.name || s.updatedBy?.email} - /> - ))} - -
+ + {filteredSavedSearches.map(s => ( + handleDelete(s.id)} + statusIcon={} + resourceId={s.id} + resourceType="savedSearch" + updatedAt={s.updatedAt} + updatedBy={s.updatedBy?.name || s.updatedBy?.email} + /> ))} -
+ )} diff --git a/packages/app/src/components/SavedSearches/__tests__/SavedSearchesListPage.test.tsx b/packages/app/src/components/SavedSearches/__tests__/SavedSearchesListPage.test.tsx new file mode 100644 index 0000000000..d38ec4afb3 --- /dev/null +++ b/packages/app/src/components/SavedSearches/__tests__/SavedSearchesListPage.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { screen, within } from '@testing-library/react'; + +import SavedSearchesListPage from '../SavedSearchesListPage'; + +const mockSetSelectedTags = jest.fn(); +const mockSetLegacyTag = jest.fn(); +const mockUseSavedSearches = jest.fn(); +const mockUseFavorites = jest.fn(); +const mockUseDeleteSavedSearch = 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('@/layout', () => ({ + withAppNav: (component: unknown) => component, +})); + +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('@/savedSearch', () => ({ + useSavedSearches: () => mockUseSavedSearches(), + useDeleteSavedSearch: () => mockUseDeleteSavedSearch(), +})); + +jest.mock('@/favorites', () => ({ + useFavorites: () => mockUseFavorites(), + useToggleFavorite: () => ({ + isFavorited: false, + toggleFavorite: jest.fn(), + }), +})); + +jest.mock('@/theme/ThemeProvider', () => ({ + useBrandDisplayName: () => mockUseBrandDisplayName(), +})); + +jest.mock('@/useConfirm', () => ({ + useConfirm: () => mockUseConfirm(), +})); + +const savedSearch = ( + id: string, + name: string, + tags: string[], +): { + id: string; + name: string; + tags: string[]; + alerts: never[]; + updatedAt: string; + updatedBy?: { name?: string; email?: string }; + createdBy?: { name?: string; email?: string }; +} => ({ + id, + name, + tags, + alerts: [], + updatedAt: '2026-05-01T00:00:00.000Z', + updatedBy: { name: 'tester' }, + createdBy: { name: 'tester' }, +}); + +const seedSavedSearches = [ + savedSearch('s-untagged', 'Untagged search', []), + savedSearch('s-checkout', 'Checkout search', ['checkout']), + savedSearch('s-multi', 'Multi search', ['checkout', 'payments']), + savedSearch('s-payments', 'Payments search', ['payments']), +]; + +beforeEach(() => { + mockSelectedTags = []; + mockLegacyTag = null; + mockSetSelectedTags.mockClear(); + mockSetLegacyTag.mockClear(); + mockUseSavedSearches.mockReturnValue({ + data: seedSavedSearches, + isLoading: false, + isError: false, + }); + mockUseFavorites.mockReturnValue({ data: [] }); + mockUseDeleteSavedSearch.mockReturnValue({ mutate: jest.fn() }); + mockUseConfirm.mockReturnValue(jest.fn()); + mockUseBrandDisplayName.mockReturnValue('HyperDX'); +}); + +describe('SavedSearchesListPage', () => { + it('renders each multi-tagged saved search exactly once when filtering by two tags (OR semantics)', () => { + mockSelectedTags = ['checkout', 'payments']; + + renderWithMantine(); + + const grid = screen.getByTestId('saved-searches-list-page'); + + expect(within(grid).getAllByText('Checkout search')).toHaveLength(1); + expect(within(grid).getAllByText('Multi search')).toHaveLength(1); + expect(within(grid).getAllByText('Payments search')).toHaveLength(1); + expect(within(grid).queryByText('Untagged search')).toBeNull(); + }); + + it('migrates legacy `?tag=foo` URLs onto the new `?tags=[foo]` state on mount', () => { + mockLegacyTag = 'checkout'; + mockSelectedTags = []; + + renderWithMantine(); + + 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(); + + expect( + screen.getByText('No matching saved searches yet'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/utils/__tests__/groupByTags.test.ts b/packages/app/src/utils/__tests__/groupByTags.test.ts deleted file mode 100644 index d95d92372f..0000000000 --- a/packages/app/src/utils/__tests__/groupByTags.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -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]] }]); - }); -}); diff --git a/packages/app/src/utils/groupByTags.ts b/packages/app/src/utils/groupByTags.ts deleted file mode 100644 index e300be020d..0000000000 --- a/packages/app/src/utils/groupByTags.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type TagGroup = { tag: string; items: T[] }; - -const UNTAGGED_GROUP_TAG = 'Untagged'; - -export function groupByTags( - items: T[], - tagFilter: string | null, -): TagGroup[] { - const tagMap = new Map(); - 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[] = []; - - 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; -} diff --git a/packages/app/tests/e2e/page-objects/DashboardsListPage.ts b/packages/app/tests/e2e/page-objects/DashboardsListPage.ts index b1aaa74008..7f303316b5 100644 --- a/packages/app/tests/e2e/page-objects/DashboardsListPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardsListPage.ts @@ -110,16 +110,23 @@ export class DashboardsListPage { } getTagFilterSelect() { - return this.page.getByPlaceholder('Filter by tag'); + // Mantine's MultiSelect hides the placeholder once chips are present, + // so target the stable data-testid on the wrapper instead. + return this.page.getByTestId('tag-filter'); } async selectTagFilter(tag: string) { await this.getTagFilterSelect().click(); await this.page.getByRole('option', { name: tag, exact: true }).click(); + // MultiSelect keeps the dropdown open after a selection; close it so + // subsequent UI interactions (e.g. clicking a card) are not blocked. + await this.page.keyboard.press('Escape'); } async clearTagFilter() { - // The Mantine Select clear button is a sibling button next to the textbox + // The Mantine MultiSelect clear button is a sibling button next to the + // chip area; CSS-target it directly since Mantine v9's + // ComboboxClearButton carries aria-hidden="true". const select = this.getTagFilterSelect(); await select.locator('..').locator('button').click(); } diff --git a/packages/app/tests/e2e/page-objects/SavedSearchesListPage.ts b/packages/app/tests/e2e/page-objects/SavedSearchesListPage.ts index 39aa60d068..76d3b96d3d 100644 --- a/packages/app/tests/e2e/page-objects/SavedSearchesListPage.ts +++ b/packages/app/tests/e2e/page-objects/SavedSearchesListPage.ts @@ -74,12 +74,17 @@ export class SavedSearchesListPage { } getTagFilterSelect() { - return this.page.getByPlaceholder('Filter by tag'); + // Mantine's MultiSelect hides the placeholder once chips are present, + // so target the stable data-testid on the wrapper instead. + return this.page.getByTestId('tag-filter'); } async selectTagFilter(tag: string) { await this.getTagFilterSelect().click(); await this.page.getByRole('option', { name: tag, exact: true }).click(); + // MultiSelect keeps the dropdown open after a selection; close it so + // subsequent UI interactions (e.g. clicking a card) are not blocked. + await this.page.keyboard.press('Escape'); } async clearTagFilter() {