diff --git a/datahub-web-react/src/App.tsx b/datahub-web-react/src/App.tsx index f610569de6e45e..382910911d756e 100644 --- a/datahub-web-react/src/App.tsx +++ b/datahub-web-react/src/App.tsx @@ -12,6 +12,7 @@ import { GlobalStyles } from '@components/components/GlobalStyles'; import { Routes } from '@app/Routes'; import { isLoggedInVar } from '@app/auth/checkAuthStatus'; +import { FilesUploadingDownloadingLatencyTracker } from '@app/shared/FilesUploadingDownloadingLatencyTracker'; import { ErrorCodes } from '@app/shared/constants'; import { PageRoutes } from '@conf/Global'; import CustomThemeProvider from '@src/CustomThemeProvider'; @@ -88,6 +89,7 @@ export const InnerApp: React.VFC = () => { + {useCustomTheme().theme?.content?.title} diff --git a/datahub-web-react/src/alchemy-components/components/Editor/Editor.tsx b/datahub-web-react/src/alchemy-components/components/Editor/Editor.tsx index deb49511a019ea..f22f8f3c09262f 100644 --- a/datahub-web-react/src/alchemy-components/components/Editor/Editor.tsx +++ b/datahub-web-react/src/alchemy-components/components/Editor/Editor.tsx @@ -10,6 +10,7 @@ import { CodeExtension, DropCursorExtension, FontSizeExtension, + GapCursorExtension, HardBreakExtension, HeadingExtension, HistoryExtension, @@ -37,6 +38,7 @@ import { FloatingToolbar } from '@components/components/Editor/toolbar/FloatingT import { TableCellMenu } from '@components/components/Editor/toolbar/TableCellMenu'; import { Toolbar } from '@components/components/Editor/toolbar/Toolbar'; import { EditorProps } from '@components/components/Editor/types'; +import { colors } from '@components/theme'; import { notEmpty } from '@app/entityV2/shared/utils'; @@ -66,7 +68,10 @@ export const Editor = forwardRef((props: EditorProps, ref) => { new CodeBlockExtension({ syntaxTheme: 'base16_ateliersulphurpool_light' }), new CodeExtension(), new DataHubMentionsExtension({}), - new DropCursorExtension({}), + new DropCursorExtension({ + color: colors.primary[100], + width: 2, + }), new HardBreakExtension(), new HeadingExtension({}), new HistoryExtension({}), @@ -78,6 +83,7 @@ export const Editor = forwardRef((props: EditorProps, ref) => { onFileUploadSucceeded, onFileDownloadView, }), + new GapCursorExtension(), // required to allow cursor placement next to non-editable inline elements new ImageExtension({ enableResizing: !readOnly }), new ItalicExtension(), new LinkExtension({ autoLink: true, defaultTarget: '_blank' }), diff --git a/datahub-web-react/src/alchemy-components/components/Editor/EditorTheme.tsx b/datahub-web-react/src/alchemy-components/components/Editor/EditorTheme.tsx index cfebd547f1b2e8..8485b63c57d6fc 100644 --- a/datahub-web-react/src/alchemy-components/components/Editor/EditorTheme.tsx +++ b/datahub-web-react/src/alchemy-components/components/Editor/EditorTheme.tsx @@ -71,6 +71,7 @@ export const EditorContainer = styled.div<{ $readOnly?: boolean; $hideBorder?: b flex: 1 1 100%; display: flex; flex-direction: column; + width: 100%; } .remirror-editor.ProseMirror { diff --git a/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileDragDropExtension.tsx b/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileDragDropExtension.tsx index c0692b81cacc58..8b9c1f136279a2 100644 --- a/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileDragDropExtension.tsx +++ b/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileDragDropExtension.tsx @@ -57,7 +57,7 @@ class FileDragDropExtension extends NodeExtension { } createTags() { - return [ExtensionTag.Block, ExtensionTag.Behavior, ExtensionTag.FormattingNode]; + return [ExtensionTag.InlineNode, ExtensionTag.Behavior]; } get defaultPriority() { @@ -74,7 +74,13 @@ class FileDragDropExtension extends NodeExtension { props: { handleDOMEvents: { drop: (view: EditorView, event: DragEvent) => { - return this.handleDrop(view, event); + const data = event.dataTransfer; + if (data && data.files && data.files.length > 0) { + // External file drop + return this.handleDrop(view, event); + } + // Moving nodes internally + return false; }, dragover: (view: EditorView, event: DragEvent) => { if (event.dataTransfer?.types.includes('Files')) { @@ -97,6 +103,23 @@ class FileDragDropExtension extends NodeExtension { dragleave: (_view: EditorView, _event: DragEvent) => { return false; }, + dragstart: (view: EditorView, event: DragEvent) => { + const pos = view.posAtCoords({ left: event.clientX, top: event.clientY }); + if (!pos) return false; + + const node = view.state.doc.nodeAt(pos.pos); + if (!node || node.type !== this.type) return false; + + const data = event.dataTransfer; + if (data) { + data.setData( + 'application/x-prosemirror-node', + JSON.stringify({ id: node.attrs.id, type: node.type.name }), + ); + data.effectAllowed = 'move'; + } + return false; // Allow default handling in ProseMirror + }, }, }, }), @@ -178,6 +201,17 @@ class FileDragDropExtension extends NodeExtension { description: 'Something went wrong', }); } + } else { + this.options.onFileUploadFailed?.( + file.type, + file.size, + 'drag-and-drop', + FileUploadFailureType.UPLOADING_NOT_SUPPORTED, + ); + this.removeNode(view, placeholderAttrs.id); + notification.error({ + message: 'Uploading files in this context is not currently supported', + }); } } catch (error) { console.error(error); @@ -198,7 +232,7 @@ class FileDragDropExtension extends NodeExtension { public updateNodeWithUrl(view: EditorView, nodeId: string, url: string): void { const { nodePos, nodeToUpdate } = this.findNodeById(view.state, nodeId); - if (!nodePos || !nodeToUpdate) return; + if (nodePos === null || !nodeToUpdate) return; const { name, type } = nodeToUpdate.attrs; @@ -211,7 +245,7 @@ class FileDragDropExtension extends NodeExtension { public removeNode(view: EditorView, nodeId: string) { const { nodePos, nodeToUpdate } = this.findNodeById(view.state, nodeId); - if (!nodePos || !nodeToUpdate) return; + if (nodePos === null || !nodeToUpdate) return; const updatedTransaction = view.state.tr.delete(nodePos, nodePos + nodeToUpdate.nodeSize); view.dispatch(updatedTransaction); @@ -298,12 +332,11 @@ class FileDragDropExtension extends NodeExtension { createNodeSpec(extra: ApplySchemaAttributes, override: Partial): NodeExtensionSpec { return { - inline: false, - group: 'block', - marks: '', - selectable: true, - draggable: true, + inline: true, + group: 'inline', atom: true, + selectable: true, + draggable: (state) => state.editable, ...override, attrs: { ...extra.defaults(), @@ -315,7 +348,7 @@ class FileDragDropExtension extends NodeExtension { }, parseDOM: [ { - tag: `div[${FILE_ATTRS.name}]`, + tag: `span[${FILE_ATTRS.name}]`, getAttrs: (node: string | Node) => this.parseFileNode(node, extra), }, { @@ -335,9 +368,10 @@ class FileDragDropExtension extends NodeExtension { [FILE_ATTRS.type]: type, [FILE_ATTRS.size]: size.toString(), [FILE_ATTRS.id]: id, + contenteditable: 'false', }; - return ['div', attrs, name]; + return ['span', attrs, name]; }, }; } diff --git a/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileNodeView.tsx b/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileNodeView.tsx index da9da78c757425..183b94bc74ceb9 100644 --- a/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileNodeView.tsx +++ b/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileNodeView.tsx @@ -1,33 +1,49 @@ import { NodeViewComponentProps } from '@remirror/react'; +import { Typography } from 'antd'; import React from 'react'; import styled from 'styled-components'; import { FILE_ATTRS, FileNodeAttributes, + getExtensionFromFileName, + getFileIconFromExtension, handleFileDownload, } from '@components/components/Editor/extensions/fileDragDrop/fileUtils'; import { Icon } from '@components/components/Icon'; +import { colors } from '@components/theme'; import Loading from '@app/shared/Loading'; -const FileContainer = styled.div` - max-width: 100%; +const FileContainer = styled.span` + width: fit-content; + display: inline-block; + padding: 4px; + cursor: pointer; + color: ${({ theme }) => theme.styles['primary-color']}; + + :hover { + border-radius: 8px; + background-color: ${colors.gray[1500]}; + } + + .ProseMirror-selectednode > & { + border-radius: 8px; + background-color: ${colors.gray[1500]}; + } `; -const FileDetails = styled.div` - min-width: 0; // Allows text truncation - cursor: pointer; +const FileDetails = styled.span` + width: fit-content; display: flex; gap: 4px; align-items: center; - color: ${({ theme }) => theme.styles['primary-color']}; font-weight: 600; + max-width: 350px; `; -const FileName = styled.span` +const FileName = styled(Typography.Text)` color: ${({ theme }) => theme.styles['primary-color']}; - display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -66,6 +82,9 @@ export const FileNodeView: React.FC = ({ node, onFileDownload ); } + const extension = getExtensionFromFileName(name); + const icon = getFileIconFromExtension(extension || ''); + return ( = ({ node, onFileDownload handleFileDownload(url, name); }} > - - {name} + + {name} ); diff --git a/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/__tests__/fileUtils.test.ts b/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/__tests__/fileUtils.test.ts index ae005114c88a63..53d232a9b57c10 100644 --- a/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/__tests__/fileUtils.test.ts +++ b/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/__tests__/fileUtils.test.ts @@ -4,6 +4,7 @@ import { createFileNodeAttributes, generateFileId, getExtensionFromFileName, + getFileIconFromExtension, getFileTypeFromFilename, getFileTypeFromUrl, handleFileDownload, @@ -294,4 +295,68 @@ describe('fileUtils', () => { expect(getExtensionFromFileName('file.TXT')).toBe('txt'); }); }); + + describe('getFileIconFromExtension', () => { + it('should return FilePdf for pdf extension', () => { + expect(getFileIconFromExtension('pdf')).toBe('FilePdf'); + expect(getFileIconFromExtension('PDF')).toBe('FilePdf'); // case-insensitive + }); + + it('should return FileWord for doc and docx extensions', () => { + expect(getFileIconFromExtension('doc')).toBe('FileWord'); + expect(getFileIconFromExtension('DOCX')).toBe('FileWord'); + }); + + it('should return FileText for txt, md, rtf extensions', () => { + expect(getFileIconFromExtension('txt')).toBe('FileText'); + expect(getFileIconFromExtension('md')).toBe('FileText'); + expect(getFileIconFromExtension('RTF')).toBe('FileText'); + }); + + it('should return FileXls for xls and xlsx extensions', () => { + expect(getFileIconFromExtension('xls')).toBe('FileXls'); + expect(getFileIconFromExtension('XLSX')).toBe('FileXls'); + }); + + it('should return FilePpt for ppt and pptx extensions', () => { + expect(getFileIconFromExtension('ppt')).toBe('FilePpt'); + expect(getFileIconFromExtension('PPTX')).toBe('FilePpt'); + }); + + it('should return FileImage for image extensions', () => { + ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff'].forEach((ext) => { + expect(getFileIconFromExtension(ext)).toBe('FileImage'); + expect(getFileIconFromExtension(ext.toUpperCase())).toBe('FileImage'); + }); + }); + + it('should return FileVideo for video extensions', () => { + ['mp4', 'wmv', 'mov'].forEach((ext) => { + expect(getFileIconFromExtension(ext)).toBe('FileVideo'); + expect(getFileIconFromExtension(ext.toUpperCase())).toBe('FileVideo'); + }); + }); + + it('should return FileAudio for mp3 extension', () => { + expect(getFileIconFromExtension('mp3')).toBe('FileAudio'); + expect(getFileIconFromExtension('MP3')).toBe('FileAudio'); + }); + + it('should return FileZip for archive extensions', () => { + ['zip', 'rar', 'gz'].forEach((ext) => { + expect(getFileIconFromExtension(ext)).toBe('FileZip'); + expect(getFileIconFromExtension(ext.toUpperCase())).toBe('FileZip'); + }); + }); + + it('should return FileCsv for csv extension', () => { + expect(getFileIconFromExtension('csv')).toBe('FileCsv'); + expect(getFileIconFromExtension('CSV')).toBe('FileCsv'); + }); + + it('should return FileArrowDown for unknown extensions', () => { + expect(getFileIconFromExtension('unknown')).toBe('FileArrowDown'); + expect(getFileIconFromExtension('')).toBe('FileArrowDown'); + }); + }); }); diff --git a/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/fileUtils.ts b/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/fileUtils.ts index 84251d7e197b4f..fa0fd314db9e0b 100644 --- a/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/fileUtils.ts +++ b/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/fileUtils.ts @@ -241,3 +241,50 @@ export const getFileTypeFromUrl = (url: string): string => { export const getFileTypeFromFilename = (filename: string): string => { return getFileTypeFromUrl(filename); }; + +/** + * Get icon to show based on file extension + * @param extension - the extension of the file + * @returns string depicting the phosphor icon name + */ +export const getFileIconFromExtension = (extension: string) => { + switch (extension.toLowerCase()) { + case 'pdf': + return 'FilePdf'; + case 'doc': + case 'docx': + return 'FileWord'; + case 'txt': + case 'md': + case 'rtf': + return 'FileText'; + case 'xls': + case 'xlsx': + return 'FileXls'; + case 'ppt': + case 'pptx': + return 'FilePpt'; + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'webp': + case 'bmp': + case 'tiff': + return 'FileImage'; + case 'mp4': + case 'wmv': + case 'mov': + return 'FileVideo'; + case 'mp3': + return 'FileAudio'; + case 'zip': + case 'rar': + case 'gz': + return 'FileZip'; + case 'csv': + return 'FileCsv'; + default: + return 'FileArrowDown'; + } +}; diff --git a/datahub-web-react/src/alchemy-components/components/Editor/extensions/markdownToHtml.tsx b/datahub-web-react/src/alchemy-components/components/Editor/extensions/markdownToHtml.tsx index 0b7b14f8740c35..6602206400c589 100644 --- a/datahub-web-react/src/alchemy-components/components/Editor/extensions/markdownToHtml.tsx +++ b/datahub-web-react/src/alchemy-components/components/Editor/extensions/markdownToHtml.tsx @@ -14,7 +14,7 @@ marked.use({ /* Check if this is a file link (URL points to our file storage) */ if (href && isFileUrl(href)) { - return `
`; + return ``; } /* Returning false allows marked to use the default link parser */ diff --git a/datahub-web-react/src/alchemy-components/components/Editor/types.ts b/datahub-web-react/src/alchemy-components/components/Editor/types.ts index 5eda10492a7fdd..2558c0bb7b58ec 100644 --- a/datahub-web-react/src/alchemy-components/components/Editor/types.ts +++ b/datahub-web-react/src/alchemy-components/components/Editor/types.ts @@ -3,6 +3,7 @@ export type FileUploadSource = 'drag-and-drop' | 'button'; export enum FileUploadFailureType { FILE_SIZE = 'file_size', FILE_TYPE = 'file_type', + UPLOADING_NOT_SUPPORTED = 'uploading_not_supported', UNKNOWN = 'unknown', } diff --git a/datahub-web-react/src/app/analytics/event.ts b/datahub-web-react/src/app/analytics/event.ts index b69a4806c6b8f5..1658f9ac2f6028 100644 --- a/datahub-web-react/src/app/analytics/event.ts +++ b/datahub-web-react/src/app/analytics/event.ts @@ -167,6 +167,8 @@ export enum EventType { FileUploadFailedEvent, FileUploadSucceededEvent, FileDownloadViewEvent, + FileUploadLatencyEvent, + FileDownloadLatencyEvent, } /** @@ -1220,6 +1222,18 @@ export interface FileDownloadViewEvent extends BaseEvent { schemaFieldUrn?: string; } +export interface FileUploadLatencyEvent extends BaseEvent { + type: EventType.FileUploadLatencyEvent; + url: string; + duration: number; +} + +export interface FileDownloadLatencyEvent extends BaseEvent { + type: EventType.FileDownloadLatencyEvent; + url: string; + duration: number; +} + /** * Event consisting of a union of specific event types. */ @@ -1364,4 +1378,6 @@ export type Event = | FileUploadAttemptEvent | FileUploadFailedEvent | FileUploadSucceededEvent - | FileDownloadViewEvent; + | FileDownloadViewEvent + | FileUploadLatencyEvent + | FileDownloadLatencyEvent; diff --git a/datahub-web-react/src/app/entityV2/summary/documentation/__tests__/useVisibilityObserver.test.ts b/datahub-web-react/src/app/entityV2/summary/documentation/__tests__/useVisibilityObserver.test.ts index 852c9458be3773..4c793b4eb7bbbd 100644 --- a/datahub-web-react/src/app/entityV2/summary/documentation/__tests__/useVisibilityObserver.test.ts +++ b/datahub-web-react/src/app/entityV2/summary/documentation/__tests__/useVisibilityObserver.test.ts @@ -1,9 +1,38 @@ +/* eslint-disable max-classes-per-file */ import { act, waitFor } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useVisibilityObserver } from '@app/entityV2/summary/documentation/useVisibilityObserver'; +class MockResizeObserver { + cb: ResizeObserverCallback; + + elements: Element[] = []; + + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + } + + observe = (el: Element) => { + this.elements.push(el); + }; + + disconnect = vi.fn(); + + unobserve = vi.fn(); + + triggerResize = () => { + const entries = this.elements.map((el) => ({ + target: el, + contentRect: {} as DOMRect, + borderBoxSize: [], + contentBoxSize: [], + })); + this.cb(entries as any, this as unknown as ResizeObserver); + }; +} + // Mock IntersectionObserver class MockIntersectionObserver { cb: IntersectionObserverCallback; @@ -36,9 +65,6 @@ class MockIntersectionObserver { }; } -// @ts-expect-error override global (will be replaced per test in beforeEach) -global.IntersectionObserver = MockIntersectionObserver; - // helper to mock scrollHeight const setScrollHeight = (el: HTMLElement, value: number) => { Object.defineProperty(el, 'scrollHeight', { value, configurable: true }); @@ -46,18 +72,25 @@ const setScrollHeight = (el: HTMLElement, value: number) => { describe('useVisibilityObserver', () => { let element: HTMLDivElement; - let mockObserver: MockIntersectionObserver; + let mockIntersectionObserver: MockIntersectionObserver; + let mockResizeObserver: MockResizeObserver; beforeEach(() => { element = document.createElement('div'); document.body.appendChild(element); - mockObserver = new MockIntersectionObserver(() => {}); - // @ts-expect-error override with vi.fn factory + mockIntersectionObserver = new MockIntersectionObserver(() => {}); + mockResizeObserver = new MockResizeObserver(() => {}); + global.IntersectionObserver = vi.fn((cb) => { - mockObserver = new MockIntersectionObserver(cb); - return mockObserver; - }); + mockIntersectionObserver = new MockIntersectionObserver(cb); + return mockIntersectionObserver; + }) as any; + + global.ResizeObserver = vi.fn((cb) => { + mockResizeObserver = new MockResizeObserver(cb); + return mockResizeObserver; + }) as any; }); afterEach(() => { @@ -123,7 +156,7 @@ describe('useVisibilityObserver', () => { act(() => { setScrollHeight(element, 100); - mockObserver.triggerIntersect(true); + mockIntersectionObserver.triggerIntersect(true); }); await waitFor(() => { @@ -155,7 +188,7 @@ describe('useVisibilityObserver', () => { expect((global.IntersectionObserver as any).mock.calls.length).toBeGreaterThan(1); }); - it('should clean up observer on unmount', async () => { + it('should clean up both observers on unmount', async () => { const { result, unmount, rerender } = renderHook(({ maxHeight }) => useVisibilityObserver(maxHeight), { initialProps: { maxHeight: 0 }, }); @@ -168,10 +201,40 @@ describe('useVisibilityObserver', () => { rerender({ maxHeight: 100 }); await waitFor(() => { - expect(mockObserver.elements.length).toBeGreaterThan(0); + expect(mockIntersectionObserver.elements.length).toBeGreaterThan(0); + expect(mockResizeObserver.elements.length).toBeGreaterThan(0); }); unmount(); - expect(mockObserver.disconnect).toHaveBeenCalled(); + expect(mockIntersectionObserver.disconnect).toHaveBeenCalled(); + expect(mockResizeObserver.disconnect).toHaveBeenCalled(); + }); + + it('should update hasMore when element size changes through ResizeObserver', async () => { + const { result, rerender } = renderHook(({ maxHeight }) => useVisibilityObserver(maxHeight), { + initialProps: { maxHeight: 0 }, + }); + + act(() => { + result.current.elementRef.current = element; + setScrollHeight(element, 100); + }); + + rerender({ maxHeight: 300 }); + + await waitFor(() => { + expect(result.current.hasMore).toBe(false); + }); + + // Simulate a resize by changing scrollHeight and triggering the resize observer + act(() => { + setScrollHeight(element, 400); + // Trigger the resize observer to detect the change + mockResizeObserver.triggerResize(); + }); + + await waitFor(() => { + expect(result.current.hasMore).toBe(true); + }); }); }); diff --git a/datahub-web-react/src/app/entityV2/summary/documentation/useVisibilityObserver.ts b/datahub-web-react/src/app/entityV2/summary/documentation/useVisibilityObserver.ts index e7028aee1316e8..288b184a2c8a62 100644 --- a/datahub-web-react/src/app/entityV2/summary/documentation/useVisibilityObserver.ts +++ b/datahub-web-react/src/app/entityV2/summary/documentation/useVisibilityObserver.ts @@ -11,21 +11,33 @@ export function useVisibilityObserver( const element = elementRef.current; if (!element) return undefined; - setHasMore(element.scrollHeight > maxViewHeight); + const updateHasMore = () => { + setHasMore(element.scrollHeight > maxViewHeight); + }; - const observer = new IntersectionObserver( + updateHasMore(); + + // ResizeObserver to detect size changes (e.g., when images load) + const resizeObserver = new ResizeObserver(updateHasMore); + resizeObserver.observe(element); + + const intersectionObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { - setHasMore(element.scrollHeight > maxViewHeight); + updateHasMore(); } }); }, { threshold: 0.01 }, ); + intersectionObserver.observe(element); - observer.observe(element); - return () => observer.disconnect(); + // Clean up observers + return () => { + intersectionObserver.disconnect(); + resizeObserver.disconnect(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [maxViewHeight, ...dependencies]); diff --git a/datahub-web-react/src/app/shared/FilesUploadingDownloadingLatencyTracker.test.tsx b/datahub-web-react/src/app/shared/FilesUploadingDownloadingLatencyTracker.test.tsx new file mode 100644 index 00000000000000..ca2690829d7554 --- /dev/null +++ b/datahub-web-react/src/app/shared/FilesUploadingDownloadingLatencyTracker.test.tsx @@ -0,0 +1,256 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import analytics, { EventType } from '@app/analytics'; +import { FilesUploadingDownloadingLatencyTracker } from '@app/shared/FilesUploadingDownloadingLatencyTracker'; + +// Mock the analytics module before the import +vi.mock('@app/analytics', () => { + const mockAnalyticsEvent = vi.fn(); + return { + __esModule: true, + default: { + event: mockAnalyticsEvent, + }, + EventType: { + FileUploadLatencyEvent: 'FileUploadLatencyEvent', + FileDownloadLatencyEvent: 'FileDownloadLatencyEvent', + } as any, + }; +}); + +// Now import after the mock + +// Mock the PerformanceObserver API +const mockObserve = vi.fn(); +const mockDisconnect = vi.fn(); + +class MockPerformanceObserver { + constructor(private callback: PerformanceObserverCallback) {} + + observe = mockObserve; + + disconnect = mockDisconnect; + + // Method to simulate the callback being called with entries (for testing) + triggerCallback = (list: any) => { + this.callback(list, this as any); + }; +} + +// Replace the global PerformanceObserver with our mock +Object.defineProperty(window, 'PerformanceObserver', { + writable: true, + value: vi.fn((callback) => new MockPerformanceObserver(callback)), +}); + +// Mock performance.getEntriesByType to return empty arrays by default +Object.defineProperty(window, 'performance', { + value: { + getEntriesByType: vi.fn(() => []), + getEntries: vi.fn(() => []), + }, + writable: true, +}); + +describe('FilesUploadingDownloadingLatencyTracker', () => { + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders without crashing and sets up PerformanceObserver', () => { + render(); + + // Component should render as null + expect(screen.queryByRole('main')).toBeNull(); + + // Should have created a PerformanceObserver instance + expect(window.PerformanceObserver).toHaveBeenCalled(); + + // Should have called observe with the right parameters + expect(mockObserve).toHaveBeenCalledWith({ type: 'resource', buffered: true }); + }); + + it('observes resource timing entries when performance entries are available', () => { + const mockUploadEntry: Partial = { + entryType: 'resource', + name: 'https://example.amazonaws.com/upload', + initiatorType: 'fetch', + duration: 100, + }; + + const mockDownloadEntry: Partial = { + entryType: 'resource', + name: 'https://api.example.com/openapi/v1/files/download', + initiatorType: 'xmlhttprequest', + duration: 200, + }; + + render(); + + // Get the callback that was passed to PerformanceObserver constructor + const callback = (window.PerformanceObserver as any).mock.calls[0][0]; + + // Simulate the callback with our entries + callback( + { + getEntries: () => [mockUploadEntry, mockDownloadEntry], + }, + new MockPerformanceObserver(() => {}), + ); + + // Check that analytics events were triggered + expect(analytics.event).toHaveBeenCalledTimes(2); + expect(analytics.event).toHaveBeenCalledWith({ + type: EventType.FileUploadLatencyEvent, + url: mockUploadEntry.name, + duration: mockUploadEntry.duration, + }); + expect(analytics.event).toHaveBeenCalledWith({ + type: EventType.FileDownloadLatencyEvent, + url: mockDownloadEntry.name, + duration: mockDownloadEntry.duration, + }); + }); + + it('filters out non-resource entries', () => { + const mockNavigationEntry: Partial = { + entryType: 'navigation', + name: 'https://example.com', + duration: 150, + }; + + render(); + + // Get the callback that was passed to PerformanceObserver constructor + const callback = (window.PerformanceObserver as any).mock.calls[0][0]; + + // Simulate the callback with a non-resource entry + callback( + { + getEntries: () => [mockNavigationEntry], + }, + new MockPerformanceObserver(() => {}), + ); + + // Analytics should not be called for non-resource entries + expect(analytics.event).not.toHaveBeenCalled(); + }); + + it('identifies upload entries correctly', () => { + const mockUploadEntry: Partial = { + entryType: 'resource', + name: 'https://bucket.s3.amazonaws.com/files', + initiatorType: 'fetch', + duration: 50, + }; + + const mockNonUploadEntry: Partial = { + entryType: 'resource', + name: 'https://googleapis.com/some-api', + initiatorType: 'fetch', + duration: 50, + }; + + render(); + + // Get the callback that was passed to PerformanceObserver constructor + const callback = (window.PerformanceObserver as any).mock.calls[0][0]; + + // Simulate the callback with both entries + callback( + { + getEntries: () => [mockUploadEntry, mockNonUploadEntry], + }, + new MockPerformanceObserver(() => {}), + ); + + // Only the upload entry should trigger an event + expect(analytics.event).toHaveBeenCalledTimes(1); + expect(analytics.event).toHaveBeenCalledWith({ + type: EventType.FileUploadLatencyEvent, + url: mockUploadEntry.name, + duration: mockUploadEntry.duration, + }); + }); + + it('identifies download entries correctly', () => { + const mockDownloadEntry: Partial = { + entryType: 'resource', + name: 'https://api.example.com/openapi/v1/files/download', + initiatorType: 'xmlhttprequest', + duration: 75, + }; + + const mockNonDownloadEntry: Partial = { + entryType: 'resource', + name: 'https://cdn.example.com/other-files', + initiatorType: 'xmlhttprequest', + duration: 75, + }; + + render(); + + // Get the callback that was passed to PerformanceObserver constructor + const callback = (window.PerformanceObserver as any).mock.calls[0][0]; + + // Simulate the callback with both entries + callback( + { + getEntries: () => [mockDownloadEntry, mockNonDownloadEntry], + }, + new MockPerformanceObserver(() => {}), + ); + + // Only the download entry should trigger an event + expect(analytics.event).toHaveBeenCalledTimes(1); + expect(analytics.event).toHaveBeenCalledWith({ + type: EventType.FileDownloadLatencyEvent, + url: mockDownloadEntry.name, + duration: mockDownloadEntry.duration, + }); + }); + + it('disconnects PerformanceObserver on unmount', () => { + const { unmount } = render(); + + // Initially, disconnect should not have been called + expect(mockDisconnect).not.toHaveBeenCalled(); + + unmount(); + + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it('does not trigger analytics for entries that are neither uploads nor downloads', () => { + const mockOtherEntry: Partial = { + entryType: 'resource', + name: 'https://some-other-domain.com/other-resource', + initiatorType: 'img', + duration: 100, + }; + + render(); + + // Get the callback that was passed to PerformanceObserver constructor + const callback = (window.PerformanceObserver as any).mock.calls[0][0]; + + // Simulate the callback with the other entry + callback( + { + getEntries: () => [mockOtherEntry], + }, + new MockPerformanceObserver(() => {}), + ); + + // No analytics events should be triggered for non-upload/download entries + expect(analytics.event).not.toHaveBeenCalled(); + }); +}); diff --git a/datahub-web-react/src/app/shared/FilesUploadingDownloadingLatencyTracker.tsx b/datahub-web-react/src/app/shared/FilesUploadingDownloadingLatencyTracker.tsx new file mode 100644 index 00000000000000..b9fbfaf6032614 --- /dev/null +++ b/datahub-web-react/src/app/shared/FilesUploadingDownloadingLatencyTracker.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; + +import analytics, { EventType } from '@app/analytics'; + +function isResource(entry: PerformanceEntry): entry is PerformanceResourceTiming { + return entry.entryType === 'resource'; +} + +function isUploading(entry: PerformanceResourceTiming) { + return entry.name.includes('amazonaws') && entry.initiatorType === 'fetch'; +} + +function isDownloading(entry: PerformanceResourceTiming) { + return entry.name.includes('openapi/v1/files'); +} + +export function FilesUploadingDownloadingLatencyTracker() { + useEffect(() => { + const observer = new PerformanceObserver((list) => { + list.getEntries() + .filter(isResource) + .forEach((entry) => { + if (isUploading(entry)) { + analytics.event({ + type: EventType.FileUploadLatencyEvent, + url: entry.name, + duration: entry.duration, + }); + } else if (isDownloading(entry)) { + analytics.event({ + type: EventType.FileDownloadLatencyEvent, + url: entry.name, + duration: entry.duration, + }); + } + }); + }); + + // Start observing resource timing entries + observer.observe({ type: 'resource', buffered: true }); + + return () => observer.disconnect(); + }, []); + + return null; +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java b/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java index 3ad9f662cfd64e..6185bf290cdd29 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/datahubusage/DataHubUsageEventType.java @@ -143,6 +143,8 @@ public enum DataHubUsageEventType { FILE_UPLOAD_FAILED_EVENT("FileUploadFailedEvent"), FILE_UPLOAD_SUCCEEDED_EVENT("FileUploadSucceededEvent"), FILE_DOWNLOAD_VIEW_EVENT("FileDownloadViewEvent"), + FILE_UPLOAD_LATENCY_EVENT("FileUploadLatencyEvent"), + FILE_DOWNLOAD_LATENCY_EVENT("FileDownloadLatencyEvent"), // Not replicated in frontend, represents generic event from backend CREATE_USER_EVENT("CreateUserEvent"), UPDATE_USER_EVENT("UpdateUserEvent"),