diff --git a/.changeset/sharp-rules-rest.md b/.changeset/sharp-rules-rest.md new file mode 100644 index 0000000000..8bcfa90652 --- /dev/null +++ b/.changeset/sharp-rules-rest.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: Display static value filters diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index d17bada2bb..e3a4966b12 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -6,6 +6,7 @@ import { getPendingFilterValuesVariables, isFilterVariableEnabled, isQueryExpressionFilter, + isStaticListFilter, } from '@hyperdx/common-utils/dist/filters'; import { ChartVariable, @@ -16,7 +17,7 @@ import { IconAlertTriangle, IconHelp, IconRefresh } from '@tabler/icons-react'; import { FilterLinkToggle } from './components/FilterLinkToggle'; import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect'; -import { useQueriedDashboardFilterValues } from './hooks/useQueriedDashboardFilterValues'; +import { useDashboardFilterValues } from './hooks/useDashboardFilterValues'; interface DashboardFilterSelectProps { filter: DashboardFilter; @@ -161,6 +162,8 @@ const DashboardFilterSelect = ({ // so a completed/empty/failed query stays interactive and the user can // still clear or adjust the selection. loading={isLoading} + // A static list renders in the order its author wrote it. + sort={!isStaticListFilter(filter)} onChange={onChange} data-testid={`dashboard-filter-select-${filter.name}`} /> @@ -198,27 +201,29 @@ const DashboardFilters = ({ // on, all of a source's facets are computed in a single groupUniqArrayIf scan. const [linked, setLinked] = useState(false); - const queriedFilters = useMemo( - () => filters.filter(f => isQueryExpressionFilter(f)), - [filters], - ); - const { data: filterValuesById, erroredFilterIds, filterErrorMessages, isFetching, - } = useQueriedDashboardFilterValues({ - filters: queriedFilters, + } = useDashboardFilterValues({ + filters, dateRange, variables, // Only narrow by sibling selections when linked. selectionByFilterId: linked ? selectionByFilterId : EMPTY_SELECTIONS, }); + // Linking narrows queried dropdowns by sibling selections; a static + // list can neither constrain nor be constrained, so it doesn't count. + const linkableFiltersCount = useMemo( + () => filters.filter(isQueryExpressionFilter).length, + [filters], + ); + return ( - {queriedFilters.map(filter => { + {filters.map(filter => { const queriedFilterValues = filterValuesById?.get(filter.id); const included = selectionByFilterId.get(filter.id)?.included; const selectedValues = included @@ -230,6 +235,11 @@ const DashboardFilters = ({ const isLoadingValues = queriedFilterValues ? queriedFilterValues.isLoading : isFetching; + // Only a queried dropdown has a values query that can await a + // variable's selection. + const pendingVariables = isQueryExpressionFilter(filter) + ? getPendingFilterValuesVariables(filter, variables) + : undefined; return ( onSetFilterValue(filter.id, values)} values={queriedFilterValues?.values} value={selectedValues} /> ); })} - {filters.length >= 2 && ( + {linkableFiltersCount >= 2 && ( {/* Spacer to align the toggle with the inputs (filters have a label row above). */} diff --git a/packages/app/src/components/VirtualMultiSelect/VirtualMultiSelect.tsx b/packages/app/src/components/VirtualMultiSelect/VirtualMultiSelect.tsx index 858f6b1332..ff3f9d07ca 100644 --- a/packages/app/src/components/VirtualMultiSelect/VirtualMultiSelect.tsx +++ b/packages/app/src/components/VirtualMultiSelect/VirtualMultiSelect.tsx @@ -24,6 +24,8 @@ type VirtualMultiSelectProps = { /** Show a "Loading…" empty state while values are being fetched. */ loading?: boolean; placeholder?: string; + /** Whether to sort options before rendering. True by default */ + sort?: boolean; values: string[]; onChange: (values: string[]) => void; 'data-testid'?: string; @@ -34,6 +36,7 @@ export function VirtualMultiSelect({ disabled, loading, placeholder, + sort = true, values, onChange, 'data-testid': dataTestId, @@ -43,8 +46,8 @@ export function VirtualMultiSelect({ const [search, setSearch] = useState(''); const sorted = useMemo(() => { - return data.toSorted((a, b) => a.localeCompare(b)); - }, [data]); + return sort ? data.toSorted((a, b) => a.localeCompare(b)) : data; + }, [data, sort]); const options = useMemo(() => { const searchLowerCase = search.trim().toLowerCase(); diff --git a/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx b/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx new file mode 100644 index 0000000000..113d897e9e --- /dev/null +++ b/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { + DashboardFilter, + SourceKind, + StaticListDashboardFilter, + TSource, +} from '@hyperdx/common-utils/dist/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useDashboardFilterValues } from '@/hooks/useDashboardFilterValues'; + +const mockGetKeyValues = jest.fn(); +let mockSourcesData: Partial[]; + +// Untyped factories, so partial mocks need no unsafe type assertions. +jest.mock('@/source', () => ({ + useSources: () => ({ data: mockSourcesData, isLoading: false }), +})); +jest.mock('../useMetadata', () => ({ + useMetadataWithSettings: () => ({ getKeyValues: mockGetKeyValues }), +})); +jest.mock('@hyperdx/common-utils/dist/core/materializedViews', () => ({ + optimizeGetKeyValuesCalls: jest + .fn() + .mockImplementation(async ({ keys, chartConfig }) => [ + { keys, chartConfig }, + ]), + optimizeFacetedKeyValuesConfig: jest + .fn() + .mockImplementation(async ({ chartConfig }) => chartConfig), +})); + +describe('useDashboardFilterValues', () => { + let queryClient: QueryClient; + let wrapper: React.ComponentType<{ children: React.ReactNode }>; + + // Deliberately not alphabetical: options must keep definition order. + const staticFilter: StaticListDashboardFilter = { + id: 'static1', + type: 'STATIC_LIST', + name: 'Tier', + options: ['low', 'medium', 'high'], + isBroadcastEnabled: false, + isVariableEnabled: true, + variableName: 'tier', + }; + + const queriedFilter: DashboardFilter = { + id: 'queried1', + type: 'QUERY_EXPRESSION', + name: 'Environment', + expression: 'environment', + source: 'logs-source', + }; + + const mockDateRange: [Date, Date] = [ + new Date('2024-01-01'), + new Date('2024-01-02'), + ]; + + beforeEach(() => { + mockSourcesData = [ + { + id: 'logs-source', + kind: SourceKind.Log, + name: 'Logs', + timestampValueExpression: 'timestamp', + connection: 'clickhouse-conn', + from: { + databaseName: 'telemetry', + tableName: 'logs', + }, + }, + ]; + mockGetKeyValues.mockReset(); + mockGetKeyValues.mockImplementation(({ keys }: { keys: string[] }) => + Promise.resolve( + keys.map(key => ({ key, value: ['production', 'staging'] })), + ), + ); + + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + wrapper = ({ children }) => ( + {children} + ); + }); + + it('returns static options in definition order without loading or fetching', () => { + const { result } = renderHook( + () => + useDashboardFilterValues({ + filters: [staticFilter], + dateRange: mockDateRange, + }), + { wrapper }, + ); + + // Static values resolve synchronously, and the queried hook's perpetual + // "loading" for an empty filter list must not leak through. + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(false); + expect(result.current.isError).toBe(false); + expect(result.current.data).toEqual( + new Map([ + ['static1', { values: ['low', 'medium', 'high'], isLoading: false }], + ]), + ); + expect(mockGetKeyValues).not.toHaveBeenCalled(); + }); + + it('merges static and queried values keyed by filter id', async () => { + const { result } = renderHook( + () => + useDashboardFilterValues({ + filters: [staticFilter, queriedFilter], + dateRange: mockDateRange, + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data).toEqual( + new Map([ + ['queried1', { values: ['production', 'staging'], isLoading: false }], + ['static1', { values: ['low', 'medium', 'high'], isLoading: false }], + ]), + ); + }); + + it('surfaces queried errors without implicating static filters', async () => { + mockGetKeyValues.mockRejectedValue(new Error('query failed')); + + const { result } = renderHook( + () => + useDashboardFilterValues({ + filters: [staticFilter, queriedFilter], + dateRange: mockDateRange, + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.erroredFilterIds).toEqual(new Set(['queried1'])); + expect(result.current.data.get('static1')).toEqual({ + values: ['low', 'medium', 'high'], + isLoading: false, + }); + }); +}); diff --git a/packages/app/src/hooks/useDashboardFilterValues.tsx b/packages/app/src/hooks/useDashboardFilterValues.tsx new file mode 100644 index 0000000000..7ecdc0c1b2 --- /dev/null +++ b/packages/app/src/hooks/useDashboardFilterValues.tsx @@ -0,0 +1,78 @@ +import { useMemo } from 'react'; +import { FilterSelection } from '@hyperdx/common-utils/dist/dashboardFilterValues'; +import { + isQueryExpressionFilter, + isStaticListFilter, +} from '@hyperdx/common-utils/dist/filters'; +import { + ChartVariable, + DashboardFilter, +} from '@hyperdx/common-utils/dist/types'; + +import { useQueriedDashboardFilterValues } from './useQueriedDashboardFilterValues'; +import { useStaticDashboardFilterValues } from './useStaticDashboardFilterValues'; + +export type DashboardFilterValuesResult = { + /** Dropdown values keyed by `filter.id` */ + data: ReadonlyMap; + /** Filter IDs whose values lookup failed, or never resolved. */ + erroredFilterIds: ReadonlySet; + /** Error messages, when known, for filters in `erroredFilterIds`. */ + filterErrorMessages: ReadonlyMap; + isLoading: boolean; + isFetching: boolean; + isError: boolean; +}; + +/** Dropdown values for a dashboard's filters. */ +export function useDashboardFilterValues({ + filters, + dateRange, + selectionByFilterId, + variables, +}: { + filters: DashboardFilter[]; + dateRange: [Date, Date]; + /** Each filter's current selection, keyed by `filter.id`. */ + selectionByFilterId?: ReadonlyMap; + /** The dashboard's variables and their current selections, if any */ + variables?: ChartVariable[]; +}): DashboardFilterValuesResult { + const [queriedFilters, staticFilters] = useMemo( + () => [ + filters.filter(isQueryExpressionFilter), + filters.filter(isStaticListFilter), + ], + [filters], + ); + + const queriedValues = useQueriedDashboardFilterValues({ + filters: queriedFilters, + dateRange, + selectionByFilterId, + variables, + }); + + const staticValues = useStaticDashboardFilterValues({ + filters: staticFilters, + }); + + const data = useMemo( + () => new Map([...queriedValues.data, ...staticValues.data]), + [queriedValues.data, staticValues.data], + ); + + // The queried hook reports isLoading/isFetching true while sources load even + // when it was handed no filters at all; only let it speak for itself when it + // actually has filters to resolve. + const hasQueriedFilters = queriedFilters.length > 0; + + return { + data, + erroredFilterIds: queriedValues.erroredFilterIds, + filterErrorMessages: queriedValues.filterErrorMessages, + isLoading: hasQueriedFilters && queriedValues.isLoading, + isFetching: hasQueriedFilters && queriedValues.isFetching, + isError: queriedValues.isError, + }; +} diff --git a/packages/app/src/hooks/useStaticDashboardFilterValues.tsx b/packages/app/src/hooks/useStaticDashboardFilterValues.tsx new file mode 100644 index 0000000000..f117b43244 --- /dev/null +++ b/packages/app/src/hooks/useStaticDashboardFilterValues.tsx @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; +import { StaticListDashboardFilter } from '@hyperdx/common-utils/dist/types'; + +/** Stable identities: static filters never error, so these never change. */ +const EMPTY_ERRORED_IDS: ReadonlySet = new Set(); +const EMPTY_ERROR_MESSAGES: ReadonlyMap = new Map(); + +export function useStaticDashboardFilterValues({ + filters, +}: { + filters: StaticListDashboardFilter[]; +}) { + const data = useMemo(() => { + const byId = new Map(); + for (const filter of filters) { + byId.set(filter.id, { values: filter.options, isLoading: false }); + } + return byId; + }, [filters]); + + return { + data, + erroredFilterIds: EMPTY_ERRORED_IDS, + filterErrorMessages: EMPTY_ERROR_MESSAGES, + isLoading: false, + isFetching: false, + isError: false, + }; +} diff --git a/packages/app/tests/e2e/features/dashboard-static-filters.spec.ts b/packages/app/tests/e2e/features/dashboard-static-filters.spec.ts new file mode 100644 index 0000000000..a33e514b74 --- /dev/null +++ b/packages/app/tests/e2e/features/dashboard-static-filters.spec.ts @@ -0,0 +1,111 @@ +/** + * STATIC_LIST dashboard filters render as dropdowns offering their authored + * options, and the selection reaches tiles as `$variableName`. + * + * The filter editor can't author static filters yet, so the dashboard is + * seeded through the external API — which also makes this `@full-stack`, along + * with the tile's real count query. + */ +import { DashboardPage } from '../page-objects/DashboardPage'; +import { getApiUrl, getSources, getUserAccessKey } from '../utils/api-helpers'; +import { expect, test } from '../utils/base-test'; + +test.describe( + 'Static list dashboard filters', + { tag: ['@dashboard', '@full-stack'] }, + () => { + const BASE_URL = `${getApiUrl()}/api/v2/dashboards`; + + // Deliberately not alphabetical: the dropdown must keep this order. + const OPTIONS = ['warn', 'error', 'debug', 'info']; + + test('offers options in definition order and feeds the selection to tiles', async ({ + page, + }) => { + test.setTimeout(120000); + + const dashboardPage = new DashboardPage(page); + await dashboardPage.goto(); + + const accessKey = await getUserAccessKey(page); + const [logSource] = await getSources(page, 'log'); + + await test.step('Seed a dashboard with a static filter via the external API', async () => { + const createResponse = await page.request.post(BASE_URL, { + headers: { + Authorization: `Bearer ${accessKey}`, + 'Content-Type': 'application/json', + }, + data: { + name: `Static filter test ${Date.now()}`, + tiles: [ + { + name: 'Log count', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'number', + sourceId: logSource._id, + select: [ + { + aggFn: 'count', + where: '$__filter(SeverityText, $sev)', + whereLanguage: 'sql', + }, + ], + }, + }, + ], + filters: [ + { + type: 'STATIC_LIST', + name: 'Severity', + options: OPTIONS, + variableName: 'sev', + }, + ], + }, + }); + expect(createResponse.ok()).toBeTruthy(); + const dashboardId = (await createResponse.json()).data.id; + await dashboardPage.gotoDashboard(dashboardId); + }); + + let unfilteredCount: string; + await test.step('The tile renders the unfiltered count ($__filter is a no-op unselected)', async () => { + await expect(dashboardPage.getNumberTileValue()).toHaveText(/\d/, { + timeout: 30000, + }); + unfilteredCount = await dashboardPage.getNumberTileValue().innerText(); + }); + + await test.step('The dropdown offers the options in definition order', async () => { + await dashboardPage.openFilterDropdown('Severity'); + await expect(page.getByRole('option')).toHaveText(OPTIONS); + await page.keyboard.press('Escape'); + }); + + await test.step('Selecting a value narrows the tile through $sev', async () => { + await dashboardPage.toggleFilterValue('Severity', 'error'); + // Seed logs spread severities evenly, so the error-only count is + // strictly below the total. + await expect(dashboardPage.getNumberTileValue()).not.toHaveText( + unfilteredCount, + { timeout: 30000 }, + ); + await expect( + dashboardPage.getFilterPill('Severity', 'error'), + ).toBeVisible(); + }); + + await test.step('The selection survives a reload via the URL state', async () => { + await page.reload(); + await expect( + dashboardPage.getFilterPill('Severity', 'error'), + ).toBeVisible({ timeout: 30000 }); + }); + }); + }, +);