mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: add filters to saved searches (#1712)
Fixes: HDX-3351 Saves search filters with Saved Searches
This commit is contained in:
parent
c3bc43add1
commit
a8aa94b0d8
7 changed files with 232 additions and 54 deletions
7
.changeset/perfect-jeans-work.md
Normal file
7
.changeset/perfect-jeans-work.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
"@hyperdx/common-utils": patch
|
||||
---
|
||||
|
||||
feat: add filters to saved searches
|
||||
|
|
@ -33,6 +33,7 @@ export const SavedSearch = mongoose.model<ISavedSearch>(
|
|||
ref: 'Source',
|
||||
},
|
||||
tags: [String],
|
||||
filters: [{ type: mongoose.Schema.Types.Mixed }],
|
||||
},
|
||||
{
|
||||
toJSON: { virtuals: true },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ export const DBSearchPageAlertModal = ({
|
|||
whereLanguage: searchedConfig.whereLanguage ?? 'lucene',
|
||||
source: searchedConfig.source ?? '',
|
||||
orderBy: searchedConfig.orderBy ?? '',
|
||||
filters: searchedConfig.filters ?? [],
|
||||
tags: [],
|
||||
});
|
||||
await createAlert.mutate({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue