feat: Add saved query support to dashboards (#1584)

Fixes: HDX-1717

On the dashboard page, this PR adds:
* An option in the ... dropdown to Save/update/delete default query/filters that will save the WHERE input filter, along with any filters applied to the dashboard via the custom filter functionality + ties it to the dashboard object itself. Future reloads of this dashboard will restore saved values.



https://github.com/user-attachments/assets/5f7c18f7-a695-4d19-b338-6de852a4af6b



https://github.com/user-attachments/assets/ea7653c8-f862-450f-916c-46edfcbfbf35




Co-authored-by: Himanshu Kapoor <2203925+fleon@users.noreply.github.com>
This commit is contained in:
Tom Alexander 2026-02-25 18:31:55 -05:00 committed by GitHub
parent d760d2db5b
commit 733d612649
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 398 additions and 8 deletions

View file

@ -26,6 +26,9 @@ export default mongoose.model<IDashboard>(
default: [],
},
filters: { type: mongoose.Schema.Types.Array, default: [] },
savedQuery: { type: String, required: false },
savedQueryLanguage: { type: String, required: false },
savedFilterValues: { type: mongoose.Schema.Types.Array, required: false },
},
{
timestamps: true,

View file

@ -80,7 +80,8 @@ router.patch(
return res.sendStatus(404);
}
const updates = _.omitBy(req.body, _.isNil);
// Only omit undefined values, keep null (which signals field removal)
const updates = _.omitBy(req.body, _.isUndefined);
const updatedDashboard = await updateDashboard(
dashboardId,

View file

@ -4,6 +4,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import dynamic from 'next/dynamic';
@ -11,7 +12,7 @@ import Head from 'next/head';
import { useRouter } from 'next/router';
import { formatRelative } from 'date-fns';
import produce from 'immer';
import { parseAsString, useQueryState } from 'nuqs';
import { parseAsJson, parseAsString, useQueryState } from 'nuqs';
import { ErrorBoundary } from 'react-error-boundary';
import RGL, { WidthProvider } from 'react-grid-layout';
import { Controller, useForm, useWatch } from 'react-hook-form';
@ -52,6 +53,7 @@ import {
IconArrowsMaximize,
IconBell,
IconCopy,
IconDeviceFloppy,
IconDotsVertical,
IconDownload,
IconFilterEdit,
@ -61,6 +63,7 @@ import {
IconTags,
IconTrash,
IconUpload,
IconX,
} from '@tabler/icons-react';
import { ContactSupportText } from '@/components/ContactSupportText';
@ -733,11 +736,16 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
'whereLanguage',
parseAsString.withDefault('lucene'),
);
// Get raw filter queries from URL (not processed by hook)
const [rawFilterQueries] = useQueryState('filters', parseAsJson<Filter[]>());
// Track if we've initialized query for this dashboard
const initializedDashboard = useRef<string>(undefined);
const [showFiltersModal, setShowFiltersModal] = useState(false);
const filters = dashboard?.filters ?? [];
const { filterValues, setFilterValue, filterQueries } =
const { filterValues, setFilterValue, filterQueries, setFilterQueries } =
useDashboardFilters(filters);
const handleSaveFilter = (filter: DashboardFilter) => {
@ -767,7 +775,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const [isLive, setIsLive] = useState(false);
const { control, setValue, handleSubmit } = useForm<{
const { control, setValue, getValues, handleSubmit } = useForm<{
granularity: SQLInterval | 'auto';
where: SearchCondition;
whereLanguage: SearchConditionLanguage;
@ -807,13 +815,134 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
isLive,
});
const onSubmit = () => {
const onSubmit = useCallback(() => {
onSearch(displayedTimeInputValue);
handleSubmit(data => {
setWhere(data.where as SearchCondition);
setWhereLanguage((data.whereLanguage as SearchConditionLanguage) ?? null);
})();
};
}, [
displayedTimeInputValue,
handleSubmit,
onSearch,
setWhere,
setWhereLanguage,
]);
// Initialize query/filter state once when dashboard changes.
useEffect(() => {
if (!dashboard?.id || !router.isReady) return;
if (!isLocalDashboard && isFetchingDashboard) return;
if (initializedDashboard.current === dashboard.id) return;
const isSwitchingDashboards =
initializedDashboard.current != null &&
initializedDashboard.current !== dashboard.id;
const hasWhereInUrl = 'where' in router.query;
const hasFiltersInUrl = 'filters' in router.query;
// Query defaults: URL query overrides saved defaults. If switching to a
// dashboard without defaults, clear query. On first load/reload, keep current state.
if (!hasWhereInUrl) {
if (dashboard.savedQuery) {
setValue('where', dashboard.savedQuery);
setWhere(dashboard.savedQuery);
const savedLanguage = dashboard.savedQueryLanguage ?? 'lucene';
setValue('whereLanguage', savedLanguage);
setWhereLanguage(savedLanguage);
} else if (isSwitchingDashboards) {
setValue('where', '');
setWhere('');
setValue('whereLanguage', 'lucene');
setWhereLanguage('lucene');
}
}
// Filter defaults: URL filters override saved defaults. If switching to a
// dashboard without defaults, clear selected filters.
if (!hasFiltersInUrl) {
if (dashboard.savedFilterValues) {
setFilterQueries(dashboard.savedFilterValues);
} else if (isSwitchingDashboards) {
setFilterQueries(null);
}
}
initializedDashboard.current = dashboard.id;
}, [
dashboard?.id,
dashboard?.savedQuery,
dashboard?.savedQueryLanguage,
dashboard?.savedFilterValues,
isLocalDashboard,
isFetchingDashboard,
router.isReady,
router.query,
setValue,
setWhere,
setWhereLanguage,
setFilterQueries,
]);
const handleSaveQuery = useCallback(() => {
if (!dashboard || isLocalDashboard) return;
// Execute the query first (updates URL)
onSubmit();
// Then save to database (reads from form values which were just submitted to URL)
const formValues = getValues();
const currentWhere = formValues.where || null;
const currentWhereLanguage = currentWhere
? formValues.whereLanguage || 'lucene'
: null;
const currentFilterValues = rawFilterQueries?.length
? rawFilterQueries
: null;
setDashboard(
produce(dashboard, draft => {
draft.savedQuery = currentWhere;
draft.savedQueryLanguage = currentWhereLanguage;
draft.savedFilterValues = currentFilterValues;
}),
() => {
notifications.show({
color: 'green',
title: 'Query saved and executed',
message:
'Filter query and dropdown values have been saved with the dashboard',
autoClose: 3000,
});
},
);
}, [
dashboard,
isLocalDashboard,
setDashboard,
getValues,
rawFilterQueries,
onSubmit,
]);
const handleRemoveSavedQuery = useCallback(() => {
if (!dashboard || isLocalDashboard) return;
setDashboard(
produce(dashboard, draft => {
draft.savedQuery = null;
draft.savedQueryLanguage = null;
draft.savedFilterValues = null;
}),
() => {
notifications.show({
color: 'green',
title: 'Default query and filters removed',
message: 'Dashboard will no longer auto-apply saved defaults',
autoClose: 3000,
});
},
);
}, [dashboard, isLocalDashboard, setDashboard]);
const [editedTile, setEditedTile] = useState<undefined | Tile>();
@ -1008,6 +1137,9 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const [isSaving, setIsSaving] = useState(false);
const hasTiles = dashboard && dashboard.tiles.length > 0;
const hasSavedQueryAndFilterDefaults = Boolean(
dashboard?.savedQuery || dashboard?.savedFilterValues?.length,
);
return (
<Box p="sm" data-testid="dashboard-page">
@ -1097,7 +1229,11 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
{!isLocalDashboard /* local dashboards cant be "deleted" */ && (
<Menu width={250}>
<Menu.Target>
<ActionIcon variant="secondary" size="input-xs">
<ActionIcon
variant="secondary"
size="input-xs"
data-testid="dashboard-menu-button"
>
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
@ -1141,6 +1277,27 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
>
{hasTiles ? 'Import New Dashboard' : 'Import Dashboard'}
</Menu.Item>
<Menu.Divider />
<Menu.Item
data-testid="save-default-query-filters-menu-item"
leftSection={<IconDeviceFloppy size={16} />}
onClick={handleSaveQuery}
>
{hasSavedQueryAndFilterDefaults
? 'Update Default Query & Filters'
: 'Save Query & Filters as Default'}
</Menu.Item>
{hasSavedQueryAndFilterDefaults && (
<Menu.Item
data-testid="remove-default-query-filters-menu-item"
leftSection={<IconX size={16} />}
color="red"
onClick={handleRemoveSavedQuery}
>
Remove Default Query & Filters
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"

View file

@ -2,7 +2,9 @@ import { useCallback, useMemo, useState } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import {
DashboardFilter,
Filter,
SavedChartConfig,
SearchConditionLanguage,
} from '@hyperdx/common-utils/dist/types';
import { notifications } from '@mantine/notifications';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@ -28,6 +30,9 @@ export type Dashboard = {
tiles: Tile[];
tags: string[];
filters?: DashboardFilter[];
savedQuery?: string | null;
savedQueryLanguage?: SearchConditionLanguage | null;
savedFilterValues?: Filter[] | null;
};
export function useUpdateDashboard() {

View file

@ -108,6 +108,7 @@ describe('usePresetDashboardFilters', () => {
filterValues: mockFilterValues,
setFilterValue: mockSetFilterValue,
filterQueries: mockFilterQueries,
setFilterQueries: jest.fn(),
});
});

View file

@ -58,6 +58,7 @@ const useDashboardFilters = (filters: DashboardFilter[]) => {
filterValues: valuesForExistingFilters,
filterQueries: queriesForExistingFilters,
setFilterValue,
setFilterQueries,
};
};

View file

@ -390,4 +390,203 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
});
},
);
test(
'should save and restore query and filter values',
{ tag: '@full-stack' },
async () => {
const testQuery = 'level:error';
let dashboardUrl: string;
await test.step('Create dashboard with chart', async () => {
await dashboardPage.createNewDashboard();
// Add a tile so dashboard is saveable
await dashboardPage.addTile();
await dashboardPage.chartEditor.createBasicChart('Test Chart');
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers).toHaveCount(1, { timeout: 10000 });
// Save dashboard URL for later
dashboardUrl = dashboardPage.page.url();
});
await test.step('Enter query in search bar', async () => {
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toBeVisible();
await searchInput.fill(testQuery);
});
await test.step('Click save query button', async () => {
await dashboardPage.saveQueryAndFiltersAsDefault();
// Wait for success notification
const notification = dashboardPage.page.locator(
'text=/Filter query and dropdown values/i',
);
await expect(notification).toBeVisible({ timeout: 5000 });
});
await test.step('Navigate away from dashboard', async () => {
await dashboardPage.page.goto('/search');
await expect(dashboardPage.page).toHaveURL(/.*\/search/);
});
await test.step('Return to dashboard and verify query restored', async () => {
await dashboardPage.page.goto(dashboardUrl);
// Wait for dashboard controls to load
await expect(
dashboardPage.page.getByTestId('dashboard-page'),
).toBeVisible({ timeout: 10000 });
await expect(dashboardPage.searchInput).toBeVisible({ timeout: 10000 });
// Verify saved query is restored in search input
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toHaveValue(testQuery);
});
await test.step('Clear URL params and verify query persists', async () => {
// Extract dashboard ID and navigate without query params
const dashboardId = dashboardUrl.split('/').pop()?.split('?')[0];
await dashboardPage.page.goto(`/dashboards/${dashboardId}`);
// Verify saved query still loads
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toHaveValue(testQuery);
});
},
);
test(
'should handle URL query params overriding saved query',
{ tag: '@full-stack' },
async () => {
const savedQuery = 'level:error';
const urlQuery = 'status:active';
let dashboardId: string;
await test.step('Create dashboard and save query', async () => {
await dashboardPage.createNewDashboard();
// Add a tile
await dashboardPage.addTile();
await dashboardPage.chartEditor.createBasicChart('Test Chart');
// Enter and save query
const searchInput = dashboardPage.searchInput;
await searchInput.fill(savedQuery);
await dashboardPage.saveQueryAndFiltersAsDefault();
// Wait for save confirmation
await dashboardPage.page.waitForTimeout(1000);
// Extract dashboard ID
const url = dashboardPage.page.url();
dashboardId = url.split('/').pop()?.split('?')[0] || '';
});
await test.step('Navigate with URL query param', async () => {
// Navigate to dashboard with URL query param
await dashboardPage.page.goto(
`/dashboards/${dashboardId}?where=${encodeURIComponent(urlQuery)}`,
);
// Wait for dashboard controls to load
await expect(
dashboardPage.page.getByTestId('dashboard-page'),
).toBeVisible({ timeout: 10000 });
await expect(dashboardPage.searchInput).toBeVisible({ timeout: 10000 });
// Verify URL query takes precedence over saved query
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toHaveValue(urlQuery);
await expect(searchInput).not.toHaveValue(savedQuery);
});
await test.step('Navigate without URL params to verify saved query', async () => {
await dashboardPage.page.goto(`/dashboards/${dashboardId}`);
// Verify saved query is restored when no URL params
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toHaveValue(savedQuery);
});
},
);
test(
'should clear saved query when WHERE input is cleared and saved',
{ tag: '@full-stack' },
async () => {
const testQuery = 'level:warn';
let dashboardUrl: string;
await test.step('Create dashboard with chart', async () => {
await dashboardPage.createNewDashboard();
// Add a tile so dashboard is saveable
await dashboardPage.addTile();
await dashboardPage.chartEditor.createBasicChart('Test Chart');
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers).toHaveCount(1, { timeout: 10000 });
// Save dashboard URL for later
dashboardUrl = dashboardPage.page.url();
});
await test.step('Enter and save query', async () => {
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toBeVisible();
await searchInput.fill(testQuery);
await dashboardPage.saveQueryAndFiltersAsDefault();
// Wait for success notification
const notification = dashboardPage.page.locator(
'text=/Filter query and dropdown values/i',
);
await expect(notification).toBeVisible({ timeout: 5000 });
});
await test.step('Navigate away and verify query persists', async () => {
await dashboardPage.page.goto('/search');
await dashboardPage.page.goto(dashboardUrl);
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toHaveValue(testQuery);
});
await test.step('Clear the query and save', async () => {
const searchInput = dashboardPage.searchInput;
await searchInput.clear();
await dashboardPage.saveQueryAndFiltersAsDefault();
// Wait for success notification
const notification = dashboardPage.page.locator(
'text=/Filter query and dropdown values/i',
);
await expect(notification).toBeVisible({ timeout: 5000 });
});
await test.step('Navigate away and verify query is cleared', async () => {
await dashboardPage.page.goto('/search');
// Extract dashboard ID and navigate back
const dashboardId = dashboardUrl.split('/').pop()?.split('?')[0];
await dashboardPage.page.goto(`/dashboards/${dashboardId}`);
// Wait for dashboard to load
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers).toHaveCount(1, { timeout: 10000 });
// Verify search input is empty (saved query was removed)
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toHaveValue('');
});
},
);
});

View file

@ -59,11 +59,11 @@ export class DashboardPage {
readonly timePicker: TimePickerComponent;
readonly chartEditor: ChartEditorComponent;
readonly granularityPicker: Locator;
readonly searchInput: Locator;
private readonly createDashboardButton: Locator;
private readonly addTileButton: Locator;
private readonly dashboardNameHeading: Locator;
private readonly searchInput: Locator;
private readonly searchSubmitButton: Locator;
private readonly liveButton: Locator;
private readonly tempDashboardBanner: Locator;
@ -80,6 +80,9 @@ export class DashboardPage {
private readonly confirmModal: Locator;
private readonly confirmCancelButton: Locator;
private readonly confirmConfirmButton: Locator;
private readonly dashboardMenuButton: Locator;
private readonly saveDefaultQueryAndFiltersMenuItem: Locator;
private readonly removeDefaultQueryAndFiltersMenuItem: Locator;
constructor(page: Page) {
this.page = page;
@ -117,6 +120,13 @@ export class DashboardPage {
this.confirmModal = page.getByTestId('confirm-modal');
this.confirmCancelButton = page.getByTestId('confirm-cancel-button');
this.confirmConfirmButton = page.getByTestId('confirm-confirm-button');
this.dashboardMenuButton = page.getByTestId('dashboard-menu-button');
this.saveDefaultQueryAndFiltersMenuItem = page.getByTestId(
'save-default-query-filters-menu-item',
);
this.removeDefaultQueryAndFiltersMenuItem = page.getByTestId(
'remove-default-query-filters-menu-item',
);
}
/**
@ -285,6 +295,16 @@ export class DashboardPage {
await this.searchSubmitButton.click();
}
async saveQueryAndFiltersAsDefault() {
await this.dashboardMenuButton.click();
await this.saveDefaultQueryAndFiltersMenuItem.click();
}
async removeSavedQueryAndFiltersDefaults() {
await this.dashboardMenuButton.click();
await this.removeDefaultQueryAndFiltersMenuItem.click();
}
/**
* Toggle live mode
*/

View file

@ -569,6 +569,9 @@ export const DashboardSchema = z.object({
tiles: z.array(TileSchema),
tags: z.array(z.string()),
filters: z.array(DashboardFilterSchema).optional(),
savedQuery: z.string().nullable().optional(),
savedQueryLanguage: SearchConditionLanguageSchema.nullable().optional(),
savedFilterValues: z.array(FilterSchema).nullable().optional(),
});
export const DashboardWithoutIdSchema = DashboardSchema.omit({ id: true });
export type DashboardWithoutId = z.infer<typeof DashboardWithoutIdSchema>;