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(