feat: add filters to saved searches (#1712)

Fixes: HDX-3351

Saves search filters with Saved Searches
This commit is contained in:
Tom Alexander 2026-02-11 09:19:08 -05:00 committed by GitHub
parent c3bc43add1
commit a8aa94b0d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 232 additions and 54 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/api": patch
"@hyperdx/app": patch
"@hyperdx/common-utils": patch
---
feat: add filters to saved searches

View file

@ -33,6 +33,7 @@ export const SavedSearch = mongoose.model<ISavedSearch>(
ref: 'Source',
},
tags: [String],
filters: [{ type: mongoose.Schema.Types.Mixed }],
},
{
toJSON: { virtuals: true },

View file

@ -417,6 +417,7 @@ function SaveSearchModalComponent({
whereLanguage: searchedConfig.whereLanguage ?? 'lucene',
source: searchedConfig.source ?? '',
orderBy: searchedConfig.orderBy ?? '',
filters: searchedConfig.filters ?? [],
tags: tags,
},
{
@ -434,6 +435,7 @@ function SaveSearchModalComponent({
whereLanguage: searchedConfig.whereLanguage ?? 'lucene',
source: searchedConfig.source ?? '',
orderBy: searchedConfig.orderBy ?? '',
filters: searchedConfig.filters ?? [],
tags: tags,
},
{
@ -486,6 +488,22 @@ function SaveSearchModalComponent({
ORDER BY
</Text>
<Text size="xs">{chartConfig.orderBy}</Text>
{searchedConfig.filters && searchedConfig.filters.length > 0 && (
<>
<Text size="xs" mb="xs" mt="sm">
FILTERS
</Text>
<Stack gap="xs">
{searchedConfig.filters.map((filter, idx) => (
<Text key={idx} size="xs" c="dimmed">
{filter.type === 'sql_ast'
? `${filter.left} ${filter.operator} ${filter.right}`
: filter.condition}
</Text>
))}
</Stack>
</>
)}
</Card>
) : (
<Text>Loading Chart Config...</Text>
@ -895,6 +913,7 @@ function DBSearchPage() {
where: _savedSearch?.where ?? '',
whereLanguage: _savedSearch?.whereLanguage ?? 'lucene',
source: _savedSearch?.source,
filters: _savedSearch?.filters ?? [],
orderBy: _savedSearch?.orderBy || defaultOrderBy,
};
}, [searchedSource, inputSource, savedSearch, defaultOrderBy, savedSearchId]);
@ -939,33 +958,34 @@ function DBSearchPage() {
const isSearchConfigEmpty =
!source && !where && !select && !whereLanguage && !filters?.length;
if (isSearchConfigEmpty) {
// Landed on saved search (if we just landed on a searchId route)
if (
savedSearch != null && // Make sure saved search data is loaded
savedSearch.id === savedSearchId // Make sure we've loaded the correct saved search
) {
setSearchedConfig({
source: savedSearch.source,
where: savedSearch.where,
select: savedSearch.select,
whereLanguage: savedSearch.whereLanguage as 'sql' | 'lucene',
orderBy: savedSearch.orderBy ?? '',
});
return;
}
// Landed on saved search (if we just landed on a searchId route)
if (
savedSearch != null && // Make sure saved search data is loaded
savedSearch.id === savedSearchId && // Make sure we've loaded the correct saved search
isSearchConfigEmpty // Only populate if URL doesn't have explicit config
) {
setSearchedConfig({
source: savedSearch.source,
where: savedSearch.where,
select: savedSearch.select,
whereLanguage: savedSearch.whereLanguage as 'sql' | 'lucene',
filters: savedSearch.filters ?? [],
orderBy: savedSearch.orderBy ?? '',
});
return;
}
// Landed on a new search - ensure we have a source selected
if (savedSearchId == null && defaultSourceId) {
setSearchedConfig({
source: defaultSourceId,
where: '',
select: '',
whereLanguage: 'lucene',
orderBy: '',
});
return;
}
// Landed on a new search - ensure we have a source selected
if (savedSearchId == null && defaultSourceId && isSearchConfigEmpty) {
setSearchedConfig({
source: defaultSourceId,
where: '',
select: '',
whereLanguage: 'lucene',
filters: [],
orderBy: '',
});
return;
}
}, [
savedSearch,
@ -1057,13 +1077,14 @@ function DBSearchPage() {
if (savedSearchId == null || savedSearch?.source !== watchedSource) {
setValue('select', '');
setValue('orderBy', '');
// Clear all search filters only when switching to a different source
searchFilters.clearAllFilters();
// If the user is in a saved search, prefer the saved search's select/orderBy if available
} else {
setValue('select', savedSearch?.select ?? '');
setValue('orderBy', savedSearch?.orderBy ?? '');
// Don't clear filters - we're loading from saved search
}
// Clear all search filters
searchFilters.clearAllFilters();
}
}
}, [
@ -1154,6 +1175,7 @@ function DBSearchPage() {
whereLanguage: searchedConfig.whereLanguage ?? 'lucene',
source: searchedConfig.source ?? '',
orderBy: searchedConfig.orderBy ?? '',
filters: searchedConfig.filters ?? [],
tags: newTags,
},
{

View file

@ -312,6 +312,7 @@ export const DBSearchPageAlertModal = ({
whereLanguage: searchedConfig.whereLanguage ?? 'lucene',
source: searchedConfig.source ?? '',
orderBy: searchedConfig.orderBy ?? '',
filters: searchedConfig.filters ?? [],
tags: [],
});
await createAlert.mutate({

View file

@ -375,8 +375,6 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
*/
let originalSourceName: string | null = null;
let secondSourceName: string | null = null;
const thirdSourceName: string | null = null;
const customOrderBy = 'Body DESC';
await test.step('Create saved search with custom ORDER BY', async () => {
@ -398,7 +396,6 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
await test.step('Switch to second source', async () => {
await searchPage.sourceDropdown.click();
secondSourceName = await searchPage.otherSources.first().textContent();
await searchPage.otherSources.first().click();
await page.waitForLoadState('networkidle');
await searchPage.table.waitForRowsToPopulate();
@ -406,7 +403,6 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
await test.step('Verify ORDER BY changed to second source default', async () => {
const orderByEditor = searchPage.getOrderByEditor();
const orderByContent = await orderByEditor.textContent();
// Should not contain the custom ORDER BY from the saved search
@ -436,4 +432,143 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
});
},
);
test(
'should save and restore filters with saved searches',
{ tag: '@full-stack' },
async ({ page }) => {
/**
* This test verifies that filters applied in the sidebar are saved
* along with saved searches and restored when loading the saved search.
*
* Test flow:
* 1. Apply filters in the sidebar
* 2. Create a saved search
* 3. Navigate away and clear filters
* 4. Navigate back to the saved search
* 5. Verify filters are restored
*/
let savedSearchUrl: string;
let appliedFilterValue: string;
await test.step('Apply filters in the sidebar', async () => {
appliedFilterValue = 'accounting';
// Apply the filter
await searchPage.filters.applyFilter(appliedFilterValue);
// Verify filter is checked
const filterInput =
searchPage.filters.getFilterCheckboxInput(appliedFilterValue);
await expect(filterInput).toBeChecked();
// Submit search to apply filters
await searchPage.submitButton.click();
await searchPage.table.waitForRowsToPopulate();
});
await test.step('Create and save the search with filters', async () => {
await searchPage.openSaveSearchModal();
await searchPage.savedSearchModal.saveSearch(
'Search with Filters Test',
);
await expect(searchPage.savedSearchModal.container).toBeHidden();
await page.waitForURL(/\/search\/[a-f0-9]+/, { timeout: 5000 });
// Capture the saved search URL
savedSearchUrl = page.url().split('?')[0];
});
await test.step('Navigate to a fresh search page', async () => {
await searchPage.goto();
await searchPage.table.waitForRowsToPopulate();
});
await test.step('Verify filters are cleared on new search page', async () => {
// Open the same filter group
await searchPage.filters.openFilterGroup('SeverityText');
// Verify filter is not checked
const filterInput =
searchPage.filters.getFilterCheckboxInput(appliedFilterValue);
await expect(filterInput).not.toBeChecked();
});
await test.step('Navigate back to the saved search', async () => {
await page.goto(savedSearchUrl);
await expect(page.getByTestId('search-page')).toBeVisible();
await searchPage.table.waitForRowsToPopulate();
});
await test.step('Verify filters are restored from saved search', async () => {
// Open the filter group
await searchPage.filters.openFilterGroup('SeverityText');
// Verify filter is checked again
const filterInput =
searchPage.filters.getFilterCheckboxInput(appliedFilterValue);
await expect(filterInput).toBeChecked();
});
},
);
test(
'should update filters when updating a saved search',
{ tag: '@full-stack' },
async ({ page }) => {
/**
* Verifies that updating a saved search with additional filters
* persists and restores both the original and new filters.
* Uses the same fixed filter values as "should save and restore filters"
* for consistency and reliability.
*/
const firstFilter = 'accounting';
const secondFilter = 'info';
let savedSearchUrl: string;
await test.step('Create saved search with one filter', async () => {
await searchPage.filters.openFilterGroup('SeverityText');
await searchPage.filters.applyFilter(firstFilter);
await searchPage.submitButton.click();
await searchPage.table.waitForRowsToPopulate();
await searchPage.openSaveSearchModal();
await searchPage.savedSearchModal.saveSearch('Updatable Filter Search');
await expect(searchPage.savedSearchModal.container).toBeHidden();
await page.waitForURL(/\/search\/[a-f0-9]+/, { timeout: 5000 });
savedSearchUrl = page.url().split('?')[0];
});
await test.step('Update saved search with second filter', async () => {
await searchPage.filters.openFilterGroup('SeverityText');
await searchPage.filters.applyFilter(secondFilter);
await searchPage.submitButton.click();
await searchPage.table.waitForRowsToPopulate();
await searchPage.openSaveSearchModal({ update: true });
await searchPage.savedSearchModal.submit();
await page.waitForLoadState('networkidle');
});
await test.step('Navigate away and back', async () => {
await searchPage.goto();
await searchPage.table.waitForRowsToPopulate();
await page.goto(savedSearchUrl);
await expect(page.getByTestId('search-page')).toBeVisible();
await searchPage.table.waitForRowsToPopulate();
});
await test.step('Verify both filters are restored', async () => {
await searchPage.filters.openFilterGroup('SeverityText');
await expect(
searchPage.filters.getFilterCheckboxInput(firstFilter),
).toBeChecked();
await expect(
searchPage.filters.getFilterCheckboxInput(secondFilter),
).toBeChecked();
});
},
);
});

View file

@ -11,6 +11,9 @@ import { SidePanelComponent } from '../components/SidePanelComponent';
import { TableComponent } from '../components/TableComponent';
import { TimePickerComponent } from '../components/TimePickerComponent';
type SaveSearchModalProps = {
update: boolean;
};
export class SearchPage {
readonly page: Page;
readonly table: TableComponent;
@ -27,6 +30,7 @@ export class SearchPage {
private readonly searchInput: Locator;
private readonly searchButton: Locator;
private readonly saveSearchButton: Locator;
private readonly updateSearchButton: Locator;
private readonly luceneTab: Locator;
private readonly sqlTab: Locator;
private readonly sourceSelector: Locator;
@ -52,6 +56,7 @@ export class SearchPage {
this.searchInput = page.getByTestId('search-input');
this.searchButton = page.getByTestId('search-submit-button');
this.saveSearchButton = page.getByTestId('save-search-button');
this.updateSearchButton = page.getByTestId('update-search-button');
this.luceneTab = page.getByRole('button', { name: 'Lucene', exact: true });
this.sqlTab = page.getByRole('button', { name: 'SQL', exact: true });
this.sourceSelector = page.getByTestId('source-selector');
@ -169,9 +174,12 @@ export class SearchPage {
/**
* Open save search modal
*/
async openSaveSearchModal() {
await this.saveSearchButton.scrollIntoViewIfNeeded();
await this.saveSearchButton.click();
async openSaveSearchModal(options: SaveSearchModalProps = { update: false }) {
const button = options.update
? this.updateSearchButton
: this.saveSearchButton;
await button.scrollIntoViewIfNeeded();
await button.click();
}
/**

View file

@ -352,6 +352,28 @@ export type AlertHistory = {
state: AlertState;
};
// --------------------------
// FILTERS
// --------------------------
export const SqlAstFilterSchema = z.object({
type: z.literal('sql_ast'),
operator: z.enum(['=', '<', '>', '!=', '<=', '>=']),
left: z.string(),
right: z.string(),
});
export type SqlAstFilter = z.infer<typeof SqlAstFilterSchema>;
export const FilterSchema = z.union([
z.object({
type: z.enum(['lucene', 'sql']),
condition: z.string(),
}),
SqlAstFilterSchema,
]);
export type Filter = z.infer<typeof FilterSchema>;
// --------------------------
// SAVED SEARCH
// --------------------------
@ -364,6 +386,7 @@ export const SavedSearchSchema = z.object({
source: z.string(),
tags: z.array(z.string()),
orderBy: z.string().optional(),
filters: z.array(FilterSchema).optional(),
alerts: z.array(AlertSchema).optional(),
});
@ -385,25 +408,6 @@ export const NumberFormatSchema = z.object({
export type NumberFormat = z.infer<typeof NumberFormatSchema>;
export const SqlAstFilterSchema = z.object({
type: z.literal('sql_ast'),
operator: z.enum(['=', '<', '>', '!=', '<=', '>=']),
left: z.string(),
right: z.string(),
});
export type SqlAstFilter = z.infer<typeof SqlAstFilterSchema>;
export const FilterSchema = z.union([
z.object({
type: z.enum(['lucene', 'sql']),
condition: z.string(),
}),
SqlAstFilterSchema,
]);
export type Filter = z.infer<typeof FilterSchema>;
export const _ChartConfigSchema = z.object({
displayType: z.nativeEnum(DisplayType).optional(),
numberFormat: NumberFormatSchema.optional(),