Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/eighty-walls-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

Fix bug where loading saved search from another page might use default values instead
71 changes: 41 additions & 30 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -622,14 +622,14 @@ function useLiveUpdate({
]);
}

function useSearchedConfigToChartConfig({
select,
source,
whereLanguage,
where,
filters,
orderBy,
}: SearchConfig) {
/**
* Takes in a input search config (user edited search config) and a default search config (saved search or source default config)
* and returns a chart config.
*/
function useSearchedConfigToChartConfig(
{ select, source, whereLanguage, where, filters, orderBy }: SearchConfig,
defaultSearchConfig?: Partial<SearchConfig>,
) {
const { data: sourceObj, isLoading } = useSource({
id: source,
});
Expand All @@ -639,7 +639,11 @@ function useSearchedConfigToChartConfig({
if (sourceObj != null) {
return {
data: {
select: select || (sourceObj.defaultTableSelectExpression ?? ''),
select:
select ||
defaultSearchConfig?.select ||
sourceObj.defaultTableSelectExpression ||
'',
from: sourceObj.from,
source: sourceObj.id,
...(sourceObj.tableFilterExpression != null
Expand All @@ -660,7 +664,7 @@ function useSearchedConfigToChartConfig({
implicitColumnExpression: sourceObj.implicitColumnExpression,
connection: sourceObj.connection,
displayType: DisplayType.Search,
orderBy: orderBy || defaultOrderBy,
orderBy: orderBy || defaultSearchConfig?.orderBy || defaultOrderBy,
},
};
}
Expand All @@ -671,6 +675,7 @@ function useSearchedConfigToChartConfig({
isLoading,
select,
filters,
defaultSearchConfig,
where,
whereLanguage,
defaultOrderBy,
Expand Down Expand Up @@ -752,6 +757,9 @@ export function useDefaultOrderBy(sourceID: string | undefined | null) {
const { data: source } = useSource({ id: sourceID });
const { data: tableMetadata } = useTableMetadata(tcFromSource(source));

// If no source, return undefined so that the orderBy is not set incorrectly
if (!source) return undefined;

// When source changes, make sure select and orderby fields are set to default
return useMemo(
() =>
Expand Down Expand Up @@ -807,11 +815,6 @@ function DBSearchPage() {
]).withDefault('results'),
);

const [outlierSqlCondition, setOutlierSqlCondition] = useQueryState(
'outlierSqlCondition',
parseAsString,
);

const [isLive, setIsLive] = useQueryState(
'isLive',
parseAsBoolean.withDefault(true),
Expand Down Expand Up @@ -867,12 +870,25 @@ function DBSearchPage() {
});

const inputSource = useWatch({ name: 'source', control });

const defaultOrderBy = useDefaultOrderBy(inputSource);

// The default search config to use when the user hasn't changed the search config
const defaultSearchConfig = useMemo(() => {
return {
select:
savedSearch?.select ?? searchedSource?.defaultTableSelectExpression,
where: savedSearch?.where ?? '',
whereLanguage: savedSearch?.whereLanguage ?? 'lucene',
source: savedSearch?.source,
orderBy: savedSearch?.orderBy || defaultOrderBy,
};
}, [searchedSource, savedSearch, defaultOrderBy]);

// const { data: inputSourceObj } = useSource({ id: inputSource });
const { data: inputSourceObjs } = useSources();
const inputSourceObj = inputSourceObjs?.find(s => s.id === inputSource);

const defaultOrderBy = useDefaultOrderBy(inputSource);

const [displayedTimeInputValue, setDisplayedTimeInputValue] =
useState('Live Tail');

Expand Down Expand Up @@ -1066,7 +1082,7 @@ function DBSearchPage() {
>(undefined);

const { data: chartConfig, isLoading: isChartConfigLoading } =
useSearchedConfigToChartConfig(searchedConfig);
useSearchedConfigToChartConfig(searchedConfig, defaultSearchConfig);

// query error handling
const { hasQueryError, queryError } = useMemo(() => {
Expand Down Expand Up @@ -1239,11 +1255,9 @@ function DBSearchPage() {
const displayedColumns = useMemo(
() =>
splitAndTrimWithBracket(
dbSqlRowTableConfig?.select ??
searchedSource?.defaultTableSelectExpression ??
'',
dbSqlRowTableConfig?.select ?? defaultSearchConfig.select ?? '',
),
[dbSqlRowTableConfig?.select, searchedSource?.defaultTableSelectExpression],
[dbSqlRowTableConfig?.select, defaultSearchConfig.select],
);

const toggleColumn = useCallback(
Expand Down Expand Up @@ -1393,10 +1407,10 @@ function DBSearchPage() {
setSearchedConfig({
orderBy: sort
? `${sort.id} ${sort.desc ? 'DESC' : 'ASC'}`
: defaultOrderBy,
: defaultSearchConfig.orderBy,
});
},
[setIsLive, defaultOrderBy, setSearchedConfig],
[setIsLive, defaultSearchConfig.orderBy, setSearchedConfig],
);
// Parse the orderBy string into a SortingState. We need the string
// version in other places so we keep this parser separate.
Expand Down Expand Up @@ -1574,10 +1588,8 @@ function DBSearchPage() {
tableConnection={inputSourceTableConnection}
control={control}
name="select"
defaultValue={inputSourceObj?.defaultTableSelectExpression}
placeholder={
inputSourceObj?.defaultTableSelectExpression || 'SELECT Columns'
}
defaultValue={defaultSearchConfig.select}
placeholder={defaultSearchConfig.select || 'SELECT Columns'}
onSubmit={onSubmit}
label="SELECT"
size="xs"
Expand All @@ -1588,7 +1600,7 @@ function DBSearchPage() {
tableConnection={inputSourceTableConnection}
control={control}
name="orderBy"
defaultValue={defaultOrderBy}
defaultValue={defaultSearchConfig.orderBy}
onSubmit={onSubmit}
label="ORDER BY"
size="xs"
Expand Down Expand Up @@ -1754,7 +1766,6 @@ function DBSearchPage() {
<SaveSearchModal
opened={saveSearchModalState != null}
onClose={clearSaveSearchModalState}
// @ts-ignore FIXME: Do some sort of validation?
searchedConfig={searchedConfig}
isUpdate={saveSearchModalState === 'update'}
savedSearchId={savedSearchId}
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/__tests__/DBSearchPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ describe('useDefaultOrderBy', () => {

const { result } = renderHook(() => useDefaultOrderBy(null));

expect(result.current).toBe(' DESC');
expect(result.current).toBe(undefined);
});

it('should handle undefined sourceID ungracefully', () => {
Expand All @@ -221,7 +221,7 @@ describe('useDefaultOrderBy', () => {

const { result } = renderHook(() => useDefaultOrderBy(undefined));

expect(result.current).toBe(' DESC');
expect(result.current).toBe(undefined);
});

it('should handle complex Timestamp expressions', () => {
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/components/DBRowTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,20 @@ function DBSqlRowTableComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceId]);

// Sync local orderBy state with initialSortBy when it changes
// (e.g., when loading a saved search)
const prevInitialSortBy = usePrevious(initialSortBy);
useEffect(() => {
const currentSort = initialSortBy?.[0] ?? null;
const prevSort = prevInitialSortBy?.[0] ?? null;

// Only sync if initialSortBy actually changed (not orderBy)
// We don't include orderBy in deps to avoid infinite loop
if (JSON.stringify(currentSort) !== JSON.stringify(prevSort)) {
setOrderBy(currentSort);
}
}, [initialSortBy, prevInitialSortBy]);

const mergedConfigObj = useMemo(() => {
const base = {
...searchChartConfigDefaults(me?.team),
Expand Down
174 changes: 174 additions & 0 deletions packages/app/tests/e2e/features/search/saved-search.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { SearchPage } from '../../page-objects/SearchPage';
import { expect, test } from '../../utils/base-test';

Expand Down Expand Up @@ -185,4 +185,178 @@
});
},
);

test(
'should load saved search when navigating from another page',
{ tag: '@full-stack' },
async ({ page }) => {
/**
* This test verifies the fix for the issue where saved searches would not
* load properly when users navigate to them from another page (e.g., service map).
*
* Test flow:
* 1. Create a saved search with custom configuration (WHERE and ORDER BY)
* 2. Navigate to a different page (service map)
* 3. Navigate to the saved search URL
* 4. Verify saved search loaded correctly with all configuration restored
*/

let savedSearchUrl: string;
const customOrderBy = 'ServiceName ASC';

await test.step('Create a saved search with custom WHERE and ORDER BY', async () => {
// Set up a custom search with WHERE clause
// Use SeverityText which is a valid column in the demo data
await searchPage.performSearch('SeverityText:info');

// Set custom ORDER BY
await searchPage.setCustomOrderBy(customOrderBy);

// Submit the search to ensure configuration is applied
await searchPage.submitButton.click();

Check failure on line 216 in packages/app/tests/e2e/features/search/saved-search.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests - Shard 3

[chromium] › tests/e2e/features/search/saved-search.spec.ts:189:7 › Saved Search Functionality › should load saved search when navigating from another page @full-stack @full-stack

1) [chromium] › tests/e2e/features/search/saved-search.spec.ts:189:7 › Saved Search Functionality › should load saved search when navigating from another page @full-stack @full-stack › Create a saved search with custom WHERE and ORDER BY Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: locator.click: Test timeout of 60000ms exceeded. Call log: - waiting for getByTestId('search-submit-button') - locator resolved to <button type="submit" data-variant="outline" data-testid="search-submit-button" class="mantine-focus-auto mantine-active m_77c9d27d mantine-Button-root m_87cf2631 mantine-UnstyledButton-root">…</button> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - <li role="option" id="cm-ac-7779-1">…</li> from <div class="m_8bffd616 mantine-Flex-root __m__-_r_e_">…</div> subtree intercepts pointer events - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - <li role="option" id="cm-ac-7779-1">…</li> from <div class="m_8bffd616 mantine-Flex-root __m__-_r_e_">…</div> subtree intercepts pointer events - retrying click action - waiting 100ms 100 × waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - <li role="option" id="cm-ac-7779-1">…</li> from <div class="m_8bffd616 mantine-Flex-root __m__-_r_e_">…</div> subtree intercepts pointer events - retrying click action - waiting 500ms 214 | 215 | // Submit the search to ensure configuration is applied > 216 | await searchPage.submitButton.click(); | ^ 217 | await searchPage.table.waitForRowsToPopulate(); 218 | 219 | // Save the search at /__w/hyperdx/hyperdx/packages/app/tests/e2e/features/search/saved-search.spec.ts:216:39 at /__w/hyperdx/hyperdx/packages/app/tests/e2e/features/search/saved-search.spec.ts:207:7

Check failure on line 216 in packages/app/tests/e2e/features/search/saved-search.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests - Shard 3

[chromium] › tests/e2e/features/search/saved-search.spec.ts:189:7 › Saved Search Functionality › should load saved search when navigating from another page @full-stack @full-stack

1) [chromium] › tests/e2e/features/search/saved-search.spec.ts:189:7 › Saved Search Functionality › should load saved search when navigating from another page @full-stack @full-stack › Create a saved search with custom WHERE and ORDER BY Error: locator.click: Test timeout of 60000ms exceeded. Call log: - waiting for getByTestId('search-submit-button') - locator resolved to <button type="submit" data-variant="outline" data-testid="search-submit-button" class="mantine-focus-auto mantine-active m_77c9d27d mantine-Button-root m_87cf2631 mantine-UnstyledButton-root">…</button> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - <li role="option" id="cm-ac-uxjk-1">…</li> from <div class="m_8bffd616 mantine-Flex-root __m__-_r_e_">…</div> subtree intercepts pointer events - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - <li role="option" id="cm-ac-uxjk-1">…</li> from <div class="m_8bffd616 mantine-Flex-root __m__-_r_e_">…</div> subtree intercepts pointer events - retrying click action - waiting 100ms 86 × waiting for element to be visible, enabled and stable - element is visible, enabled and stable - scrolling into view if needed - done scrolling - <li role="option" id="cm-ac-uxjk-1">…</li> from <div class="m_8bffd616 mantine-Flex-root __m__-_r_e_">…</div> subtree intercepts pointer events - retrying click action - waiting 500ms - waiting for element to be visible, enabled and stable 214 | 215 | // Submit the search to ensure configuration is applied > 216 | await searchPage.submitButton.click(); | ^ 217 | await searchPage.table.waitForRowsToPopulate(); 218 | 219 | // Save the search at /__w/hyperdx/hyperdx/packages/app/tests/e2e/features/search/saved-search.spec.ts:216:39 at /__w/hyperdx/hyperdx/packages/app/tests/e2e/features/search/saved-search.spec.ts:207:7
await searchPage.table.waitForRowsToPopulate();

// Save the search
await searchPage.openSaveSearchModal();
await searchPage.savedSearchModal.saveSearch(
'Info Logs Navigation Test',
);

// Wait for save to complete and URL to change
await expect(searchPage.savedSearchModal.container).toBeHidden();
await page.waitForURL(/\/search\/[a-f0-9]+/, { timeout: 5000 });

// Capture the saved search URL (without query params)
savedSearchUrl = page.url().split('?')[0];
});

await test.step('Navigate to a different page (service map)', async () => {
// Navigate to service map page
await page.goto('/service-map');

// Wait for service map page to load
await expect(page.getByTestId('service-map-page')).toBeVisible();
});

await test.step('Navigate to saved search from service map', async () => {
// Navigate directly to the saved search URL (simulating clicking a link)
await page.goto(savedSearchUrl);

// Wait for the search page to load
await expect(page.getByTestId('search-page')).toBeVisible();
});

await test.step('Verify saved search loaded and executed automatically', async () => {
// Verify the WHERE clause is populated
const whereInput = searchPage.input;
await expect(whereInput).toHaveValue('SeverityText:info');

// Verify ORDER BY is restored
const orderByEditor = searchPage.getOrderByEditor();
const orderByContent = await orderByEditor.textContent();
expect(orderByContent).toContain('ServiceName ASC');

// Verify search results are visible (search executed automatically)
await searchPage.table.waitForRowsToPopulate();
const rowCount = await searchPage.table.getRows().count();
expect(rowCount).toBeGreaterThan(0);

// Verify the search actually ran (not just showing cached results)
const resultsTable = searchPage.getSearchResultsTable();
await expect(resultsTable).toBeVisible();
});
},
);

test(
'should preserve custom SELECT when loading saved search from another page',
{ tag: '@full-stack' },
async ({ page }) => {
/**
* This test specifically verifies that custom SELECT statements are preserved
* when navigating to a saved search from another page.
*/

let savedSearchUrl: string;
const customSelect =
'Timestamp, Body, upper(ServiceName) as service_name';

await test.step('Create saved search with custom SELECT', async () => {
await searchPage.setCustomSELECT(customSelect);
await searchPage.performSearch('ServiceName:frontend');
await searchPage.openSaveSearchModal();
await searchPage.savedSearchModal.saveSearch(
'Custom Select Navigation Test',
);

await expect(searchPage.savedSearchModal.container).toBeHidden();
await page.waitForURL(/\/search\/[a-f0-9]+/, { timeout: 5000 });

savedSearchUrl = page.url().split('?')[0];
});

await test.step('Navigate to dashboards page', async () => {
await page.goto('/dashboards');
await expect(page.getByTestId('dashboard-page')).toBeVisible();
});

await test.step('Navigate back to saved search', async () => {
await page.goto(savedSearchUrl);
await expect(page.getByTestId('search-page')).toBeVisible();
});

await test.step('Verify custom SELECT is preserved', async () => {
// Wait for results to load
await searchPage.table.waitForRowsToPopulate();

// Verify SELECT content
const selectEditor = searchPage.getSELECTEditor();
const selectContent = await selectEditor.textContent();

expect(selectContent).toContain('upper(ServiceName) as service_name');
expect(selectContent).toContain('Timestamp, Body');
});
},
);

test(
'should handle navigation via browser back button',
{ tag: '@full-stack' },
async ({ page }) => {
/**
* This test verifies that using browser back/forward navigation
* properly loads saved searches.
*/

await test.step('Create and save a search', async () => {
await searchPage.performSearch('SeverityText:error');
await searchPage.openSaveSearchModal();
await searchPage.savedSearchModal.saveSearch('Browser Navigation Test');

await expect(searchPage.savedSearchModal.container).toBeHidden();
await page.waitForURL(/\/search\/[a-f0-9]+/, { timeout: 5000 });
});

await test.step('Navigate to sessions page', async () => {
await page.goto('/sessions');
await expect(page.getByTestId('sessions-page')).toBeVisible();
});

await test.step('Use browser back button', async () => {
await page.goBack();
await expect(page.getByTestId('search-page')).toBeVisible();
});

await test.step('Verify saved search loads correctly after back navigation', async () => {
// Verify WHERE clause
const whereInput = searchPage.input;
await expect(whereInput).toHaveValue('SeverityText:error');

// Verify results load
await searchPage.table.waitForRowsToPopulate();
const rowCount = await searchPage.table.getRows().count();
expect(rowCount).toBeGreaterThan(0);
});
},
);
});
Loading
Loading