diff --git a/packages/api/src/models/dashboard.ts b/packages/api/src/models/dashboard.ts index 3792da80..d40f9ba3 100644 --- a/packages/api/src/models/dashboard.ts +++ b/packages/api/src/models/dashboard.ts @@ -26,6 +26,9 @@ export default mongoose.model( 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, diff --git a/packages/api/src/routers/api/dashboards.ts b/packages/api/src/routers/api/dashboards.ts index 155bfc71..8ac2ea2f 100644 --- a/packages/api/src/routers/api/dashboards.ts +++ b/packages/api/src/routers/api/dashboards.ts @@ -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, diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 3f4bb8ae..717e3cf5 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -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()); + + // Track if we've initialized query for this dashboard + const initializedDashboard = useRef(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(); @@ -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 ( @@ -1097,7 +1229,11 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { {!isLocalDashboard /* local dashboards cant be "deleted" */ && ( - + @@ -1141,6 +1277,27 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { > {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} + + } + onClick={handleSaveQuery} + > + {hasSavedQueryAndFilterDefaults + ? 'Update Default Query & Filters' + : 'Save Query & Filters as Default'} + + {hasSavedQueryAndFilterDefaults && ( + } + color="red" + onClick={handleRemoveSavedQuery} + > + Remove Default Query & Filters + + )} + } color="red" diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 33070157..539d1eef 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -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() { diff --git a/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx b/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx index d2f3cd16..4c9ccbce 100644 --- a/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx +++ b/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx @@ -108,6 +108,7 @@ describe('usePresetDashboardFilters', () => { filterValues: mockFilterValues, setFilterValue: mockSetFilterValue, filterQueries: mockFilterQueries, + setFilterQueries: jest.fn(), }); }); diff --git a/packages/app/src/hooks/useDashboardFilters.tsx b/packages/app/src/hooks/useDashboardFilters.tsx index b14212d3..68d7f35d 100644 --- a/packages/app/src/hooks/useDashboardFilters.tsx +++ b/packages/app/src/hooks/useDashboardFilters.tsx @@ -58,6 +58,7 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { filterValues: valuesForExistingFilters, filterQueries: queriesForExistingFilters, setFilterValue, + setFilterQueries, }; }; diff --git a/packages/app/tests/e2e/features/dashboard.spec.ts b/packages/app/tests/e2e/features/dashboard.spec.ts index 6e7c0be5..7265f838 100644 --- a/packages/app/tests/e2e/features/dashboard.spec.ts +++ b/packages/app/tests/e2e/features/dashboard.spec.ts @@ -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(''); + }); + }, + ); }); diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 94a6936a..58f90594 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -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 */ diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index db848397..ebd2a77d 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -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;