mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
[HDX-3908] Dedupe source validation issue toasts (#2103)
## Summary Source validation toasts were emitted from `useSources()` on every query refresh for each invalid source, and each notification had no stable `id`. Because the notifications are persistent (`autoClose: false`), repeated refreshes stacked many identical toasts and made the Sources form hard to use. This change adds deterministic toast IDs per source validation error so repeated emissions update the same toast instead of creating duplicates. - Added `getSourceValidationNotificationId(sourceId)` in `source.ts` - Updated source validation notifications in `useSources()` to pass `id` - Added a unit regression test that executes the query function twice and verifies the same notification `id` is reused - Added a patch changeset for `@hyperdx/app` ### Screenshots or video [hdx_3908_validation_toast_dedupe_demo.mp4](https://cursor.com/agents/bc-28804425-1ca7-4133-91d8-94acfd659f05/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fhdx_3908_validation_toast_dedupe_demo.mp4) [Single source validation toast after repeated refreshes](https://cursor.com/agents/bc-28804425-1ca7-4133-91d8-94acfd659f05/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fhdx_3908_single_validation_toast_after_refreshes.webp) ### How to test locally or on Vercel 1. Run `yarn ci:unit src/__tests__/source.test.ts` inside `packages/app`. 2. Confirm `useSources validation notifications` test passes. 3. Open Team Settings → Sources with an invalid source and refresh repeatedly; verify only one validation toast remains visible per source. ### References - Linear Issue: HDX-3908 - Related PRs: none <sub>To show artifacts inline, <a href="https://cursor.com/dashboard/cloud-agents#team-pull-requests">enable</a> in settings.</sub> <div><a href="https://cursor.com/agents/bc-28804425-1ca7-4133-91d8-94acfd659f05"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-28804425-1ca7-4133-91d8-94acfd659f05"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div> Co-authored-by: Cursor Agent <199161495+cursoragent@users.noreply.github.com>
This commit is contained in:
parent
085f30743e
commit
a5869f0eb7
3 changed files with 87 additions and 1 deletions
5
.changeset/hdx-3908-validation-toast-dedupe.md
Normal file
5
.changeset/hdx-3908-validation-toast-dedupe.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
Dedupe source validation issue toasts so repeated source refetches update a single notification instead of stacking duplicates.
|
||||
|
|
@ -3,8 +3,29 @@ import {
|
|||
TLogSource,
|
||||
TTraceSource,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
import { getEventBody, getTraceDurationNumberFormat } from '../source';
|
||||
import {
|
||||
getEventBody,
|
||||
getSourceValidationNotificationId,
|
||||
getTraceDurationNumberFormat,
|
||||
useSources,
|
||||
} from '../source';
|
||||
|
||||
jest.mock('../api', () => ({ hdxServer: jest.fn() }));
|
||||
jest.mock('../config', () => ({ IS_LOCAL_MODE: false }));
|
||||
jest.mock('@mantine/notifications', () => ({
|
||||
notifications: { show: jest.fn() },
|
||||
}));
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(),
|
||||
useMutation: jest.fn(),
|
||||
useQueryClient: jest.fn(),
|
||||
}));
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { hdxServer } from '../api';
|
||||
|
||||
const TRACE_SOURCE: TTraceSource = {
|
||||
kind: SourceKind.Trace,
|
||||
|
|
@ -182,3 +203,58 @@ describe('getTraceDurationNumberFormat', () => {
|
|||
expect(getTraceDurationNumberFormat(TRACE_SOURCE, [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSources validation notifications', () => {
|
||||
const mockUseQuery = useQuery as jest.Mock;
|
||||
const mockHdxServer = hdxServer as jest.Mock;
|
||||
const mockShow = notifications.show as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reuses the same notification id for repeated validation errors', async () => {
|
||||
let capturedQueryFn: (() => Promise<unknown>) | undefined;
|
||||
mockUseQuery.mockImplementation(({ queryFn }) => {
|
||||
capturedQueryFn = queryFn;
|
||||
return { data: [] };
|
||||
});
|
||||
|
||||
const invalidSource = {
|
||||
id: 'source-1',
|
||||
kind: SourceKind.Log,
|
||||
name: 'Broken Source',
|
||||
connection: 'conn-1',
|
||||
from: { databaseName: 'default', tableName: 'logs' },
|
||||
timestampValueExpression: 'Timestamp',
|
||||
// Intentionally invalid for SourceSchema to trigger validation error.
|
||||
serviceNameExpression: 42,
|
||||
};
|
||||
|
||||
mockHdxServer.mockReturnValue({
|
||||
json: jest.fn().mockResolvedValue([invalidSource]),
|
||||
});
|
||||
|
||||
useSources();
|
||||
expect(capturedQueryFn).toBeDefined();
|
||||
|
||||
await capturedQueryFn?.();
|
||||
await capturedQueryFn?.();
|
||||
|
||||
expect(mockShow).toHaveBeenCalledTimes(2);
|
||||
expect(mockShow).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
id: getSourceValidationNotificationId('source-1'),
|
||||
autoClose: false,
|
||||
}),
|
||||
);
|
||||
expect(mockShow).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
id: getSourceValidationNotificationId('source-1'),
|
||||
autoClose: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ export const JSON_SESSION_TABLE_EXPRESSIONS = {
|
|||
timestampValueExpression: 'Timestamp',
|
||||
} as const;
|
||||
|
||||
export function getSourceValidationNotificationId(sourceId: string) {
|
||||
return `source-validation-${sourceId}`;
|
||||
}
|
||||
|
||||
// If a user specifies a timestampValueExpression with multiple columns,
|
||||
// this will return the first one. We'll want to refine this over time
|
||||
export function getFirstTimestampValueExpression(valueExpression: string) {
|
||||
|
|
@ -112,6 +116,7 @@ export function useSources() {
|
|||
.map(issue => issue.path.join('.'))
|
||||
.join(', ');
|
||||
notifications.show({
|
||||
id: getSourceValidationNotificationId(source.id),
|
||||
color: 'yellow',
|
||||
title: `Source "${source.name}" has validation issues`,
|
||||
message: React.createElement(
|
||||
|
|
|
|||
Loading…
Reference in a new issue