From a5869f0eb7d0227489a7ae2c9feaae3c102fa369 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Mon, 13 Apr 2026 14:47:59 -0600 Subject: [PATCH] [HDX-3908] Dedupe source validation issue toasts (#2103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 To show artifacts inline, enable in settings.
Open in Web Open in Cursor 
Co-authored-by: Cursor Agent <199161495+cursoragent@users.noreply.github.com> --- .../hdx-3908-validation-toast-dedupe.md | 5 ++ packages/app/src/__tests__/source.test.ts | 78 ++++++++++++++++++- packages/app/src/source.ts | 5 ++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .changeset/hdx-3908-validation-toast-dedupe.md diff --git a/.changeset/hdx-3908-validation-toast-dedupe.md b/.changeset/hdx-3908-validation-toast-dedupe.md new file mode 100644 index 00000000..94716165 --- /dev/null +++ b/.changeset/hdx-3908-validation-toast-dedupe.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +Dedupe source validation issue toasts so repeated source refetches update a single notification instead of stacking duplicates. diff --git a/packages/app/src/__tests__/source.test.ts b/packages/app/src/__tests__/source.test.ts index 73ccd8b0..606122e2 100644 --- a/packages/app/src/__tests__/source.test.ts +++ b/packages/app/src/__tests__/source.test.ts @@ -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) | 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, + }), + ); + }); +}); diff --git a/packages/app/src/source.ts b/packages/app/src/source.ts index 1f0faf17..504d03ce 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -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(