Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/shaggy-experts-collect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---

feat: Add filter templating to custom dashboard on-click
51 changes: 49 additions & 2 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Alert,
Anchor,
Box,
Breadcrumbs,
Expand All @@ -64,6 +65,7 @@ import {
import { useHotkeys } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconAlertTriangle,
IconArrowsMaximize,
IconBell,
IconChartBar,
Expand Down Expand Up @@ -1088,8 +1090,36 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const [showFiltersModal, setShowFiltersModal] = useState(false);

const filters = dashboard?.filters ?? [];
const { filterValues, setFilterValue, filterQueries, setFilterQueries } =
useDashboardFilters(filters);
const {
filterValues,
setFilterValue,
filterQueries,
setFilterQueries,
ignoredFilterExpressions,
} = useDashboardFilters(filters);

const dashboardReady =
!!dashboard?.id &&
router.isReady &&
(isLocalDashboard || !isFetchingDashboard);

// Warn when the URL has filter values that don't correspond to any declared
// dashboard filter — they'd otherwise be silently dropped, and users who
// arrive via a shared link, bookmark, or onClick action might not notice.
// Only consider URL filters ignored once the dashboard has finished loading
// so we don't flash the banner before `dashboard.filters` is available.
//
// Latched on dashboard load only — not on every URL change — so the banner
// doesn't flash while navigating between dashboards due to nuqs state changing
// before the next router state. Known limitation - when navigating to the current
// dashboard with new and invalid filters in the URL, the banner will not show up.
const [shouldShowIgnoredFiltersBanner, setShouldShowIgnoredFiltersBanner] =
useState<boolean>(false);
useEffect(() => {
if (!dashboardReady) return;
setShouldShowIgnoredFiltersBanner(ignoredFilterExpressions.length > 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard?.id, dashboardReady]);

const handleSaveFilter = (filter: DashboardFilter) => {
if (!dashboard) return;
Expand Down Expand Up @@ -2137,6 +2167,23 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
Run
</Button>
</Flex>
{shouldShowIgnoredFiltersBanner && (
<Alert
mt="sm"
color="yellow"
icon={<IconAlertTriangle size={16} />}
title="Some filters could not be applied"
data-testid="ignored-url-filters-banner"
withCloseButton
closeButtonLabel="Dismiss"
onClose={() => setShouldShowIgnoredFiltersBanner(false)}
>
No dashboard filter(s) found for{' '}
{ignoredFilterExpressions.length === 1 ? 'expression' : 'expressions'}{' '}
in the URL: {ignoredFilterExpressions.join(', ')}. Add a filter with a
matching expression to apply these filters.
</Alert>
)}
<DashboardFilters
filters={filters}
filterValues={filterValues}
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/DashboardFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { FilterState } from '@hyperdx/common-utils/dist/filters';
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
import { Group, MultiSelect } from '@mantine/core';
import { IconRefresh } from '@tabler/icons-react';

import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';
import { FilterState } from './searchFilters';

interface DashboardFilterSelectProps {
filter: DashboardFilter;
Expand Down
120 changes: 1 addition & 119 deletions packages/app/src/__tests__/searchFilters.test.ts
Original file line number Diff line number Diff line change
@@ -1,134 +1,16 @@
import { enableMapSet } from 'immer';
import { filtersToQuery } from '@hyperdx/common-utils/dist/filters';
import { act, renderHook } from '@testing-library/react';

import {
areFiltersEqual,
filtersToQuery,
parseQuery,
useSearchPageFilterState,
} from '../searchFilters';

enableMapSet();

describe('searchFilters', () => {
describe('filtersToQuery', () => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filtersToQuery and its tests have been moved to common-utils

it('should return empty string when no filters', () => {
const filters = {};
expect(filtersToQuery(filters)).toEqual([]);
});

it('should return query for one filter', () => {
const filters = {
a: { included: new Set<string>(['b']), excluded: new Set<string>() },
};
expect(filtersToQuery(filters)).toEqual([
{ type: 'sql', condition: "a IN ('b')" },
]);
});

it('should return query for multiple filters', () => {
const filters = {
a: { included: new Set<string>(['b']), excluded: new Set<string>() },
c: {
included: new Set<string>(['d', 'x']),
excluded: new Set<string>(),
},
};
expect(filtersToQuery(filters)).toEqual([
{ type: 'sql', condition: "a IN ('b')" },
{ type: 'sql', condition: "c IN ('d', 'x')" },
]);
});

it('should handle excluded values', () => {
const filters = {
a: {
included: new Set<string>(['b']),
excluded: new Set<string>(['c']),
},
};
expect(filtersToQuery(filters)).toEqual([
{ type: 'sql', condition: "a IN ('b')" },
{ type: 'sql', condition: "a NOT IN ('c')" },
]);
});

it('should wrap keys with toString() when specified', () => {
const filters = {
'json.key': {
included: new Set<string>(['value']),
excluded: new Set<string>(['other value']),
},
};
expect(filtersToQuery(filters, { stringifyKeys: true })).toEqual([
{ type: 'sql', condition: "toString(json.key) IN ('value')" },
{ type: 'sql', condition: "toString(json.key) NOT IN ('other value')" },
]);
});

it('should should handle boolean filter values', () => {
const filters = {
isRootSpan: {
included: new Set<string | boolean>([true]),
excluded: new Set<string | boolean>([]),
},
another_column: {
included: new Set<string | boolean>([]),
excluded: new Set<string | boolean>([true, false]),
},
};
expect(filtersToQuery(filters)).toEqual([
{ type: 'sql', condition: 'isRootSpan IN (true)' },
{ type: 'sql', condition: 'another_column NOT IN (true, false)' },
]);
});

it('should escape single quotes in filter values', () => {
const filters = {
message: {
included: new Set<string | boolean>(["my 'filter' key"]),
excluded: new Set<string | boolean>(),
},
};
expect(filtersToQuery(filters)).toEqual([
{
type: 'sql',
condition: "message IN ('my ''filter'' key')",
},
]);
});

it('should escape single quotes in excluded filter values', () => {
const filters = {
message: {
included: new Set<string | boolean>(),
excluded: new Set<string | boolean>(["it's a test"]),
},
};
expect(filtersToQuery(filters)).toEqual([
{
type: 'sql',
condition: "message NOT IN ('it''s a test')",
},
]);
});

it('should escape single quotes with stringifyKeys', () => {
const filters = {
'json.key': {
included: new Set<string | boolean>(["value with 'quotes'"]),
excluded: new Set<string | boolean>(),
},
};
expect(filtersToQuery(filters, { stringifyKeys: true })).toEqual([
{
type: 'sql',
condition: "toString(json.key) IN ('value with ''quotes''')",
},
]);
});
});

describe('parseQuery', () => {
it('empty query', () => {
const result = parseQuery([]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useFieldArray } from 'react-hook-form';
import {
ActionIcon,
Box,
Button,
Group,
InputLabel,
Stack,
Text,
} from '@mantine/core';
import { IconPlus, IconTrash } from '@tabler/icons-react';

import { TextInputControlled } from '@/components/InputControlled';

import { DrawerControl } from './utils';

export function FilterTemplateList({ control }: { control: DrawerControl }) {
const {
fields: filters,
append,
remove,
} = useFieldArray({
control,
name: 'onClick.filters' as const,
});

return (
<Box>
<InputLabel>Filters</InputLabel>
<Text size="xs" c="dimmed" mb="xs">
Enter an expression (e.g. a column name) and a template for its value.
</Text>
<Stack gap="xs">
{filters.map((filter, i) => (
<Group key={filter.id} gap="xs" align="flex-start" wrap="nowrap">
<TextInputControlled
control={control}
name={`onClick.filters.${i}.expression` as const}
placeholder="Expression"
style={{ flex: 1 }}
data-testid="onclick-filter-expression-input"
/>
<TextInputControlled
control={control}
name={`onClick.filters.${i}.template` as const}
placeholder="Template (e.g. {{ServiceName}})"
style={{ flex: 1 }}
data-testid="onclick-filter-template-input"
/>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Remove filter"
onClick={() => remove(i)}
mt={3}
data-testid="onclick-filter-remove-button"
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
<Button
variant="subtle"
size="compact-sm"
leftSection={<IconPlus size={14} />}
onClick={() =>
append({
kind: 'expressionTemplate',
expression: '',
template: '',
})
}
style={{ alignSelf: 'flex-start' }}
>
Add filter
</Button>
</Stack>
</Box>
);
}
Loading
Loading