diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.test.tsx index 4f2903ea2061c..6900d40fdc7a3 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.test.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.test.tsx @@ -15,22 +15,25 @@ import { setUnifiedDocViewerServices } from '../../../../../plugin'; import type { UnifiedDocViewerServices } from '../../../../../types'; jest.mock('./waterfall_flyout/span_flyout', () => ({ - SpanFlyout: ({ traceId, spanId, _, activeSection }: any) => ( -
- ), spanFlyoutId: 'spanFlyout', })); jest.mock('./waterfall_flyout/logs_flyout', () => ({ - LogsFlyout: ({ id, _ }: any) =>
, logsFlyoutId: 'logsFlyout', })); +jest.mock('./waterfall_flyout/document_detail_flyout', () => ({ + DocumentDetailFlyout: ({ type, docId, traceId, activeSection }: any) => ( +
+ ), +})); + describe('FullScreenWaterfall', () => { const defaultProps: FullScreenWaterfallProps = { traceId: 'test-trace-id', diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.tsx index e032d163eb542..ae9ef50c2286f 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/index.tsx @@ -20,10 +20,9 @@ import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; import React, { useCallback, useState } from 'react'; import { getUnifiedDocViewerServices } from '../../../../../plugin'; import type { TraceOverviewSections } from '../../doc_viewer_overview/overview'; -import type { logsFlyoutId as logsFlyoutIdType } from './waterfall_flyout/logs_flyout'; -import { LogsFlyout, logsFlyoutId } from './waterfall_flyout/logs_flyout'; -import type { spanFlyoutId as spanFlyoutIdType } from './waterfall_flyout/span_flyout'; -import { SpanFlyout, spanFlyoutId } from './waterfall_flyout/span_flyout'; +import { spanFlyoutId } from './waterfall_flyout/span_flyout'; +import { logsFlyoutId } from './waterfall_flyout/logs_flyout'; +import { DocumentDetailFlyout, type DocumentType } from './waterfall_flyout/document_detail_flyout'; export const EUI_FLYOUT_BODY_OVERFLOW_CLASS = 'euiFlyoutBody__overflow'; @@ -51,9 +50,7 @@ export const FullScreenWaterfall = ({ const { euiTheme } = useEuiTheme(); const [docId, setDocId] = useState(null); const [docIndex, setDocIndex] = useState(undefined); - const [activeFlyoutId, setActiveFlyoutId] = useState< - typeof spanFlyoutIdType | typeof logsFlyoutIdType | null - >(null); + const [activeFlyoutType, setActiveFlyoutType] = useState(null); const [activeSection, setActiveSection] = useState(); const [scrollElement, setScrollElement] = useState(null); @@ -88,7 +85,7 @@ export const FullScreenWaterfall = ({ }, []); function handleCloseFlyout() { - setActiveFlyoutId(null); + setActiveFlyoutType(null); setActiveSection(undefined); setDocId(null); setDocIndex(undefined); @@ -98,7 +95,7 @@ export const FullScreenWaterfall = ({ setActiveSection(undefined); setDocId(nodeSpanId); setDocIndex(undefined); - setActiveFlyoutId(spanFlyoutId); + setActiveFlyoutType(spanFlyoutId); } function handleErrorClick(params: { @@ -109,12 +106,12 @@ export const FullScreenWaterfall = ({ docIndex?: string; }) { if (params.errorCount > 1) { - setActiveFlyoutId(spanFlyoutId); + setActiveFlyoutType(spanFlyoutId); setActiveSection('errors-table'); setDocId(params.docId); setDocIndex(undefined); } else if (params.errorDocId) { - setActiveFlyoutId(logsFlyoutId); + setActiveFlyoutType(logsFlyoutId); setDocId(params.errorDocId); setDocIndex(params.docIndex); } @@ -163,23 +160,16 @@ export const FullScreenWaterfall = ({
- {docId && activeFlyoutId ? ( - activeFlyoutId === spanFlyoutId ? ( - - ) : ( - - ) + {docId && activeFlyoutType ? ( + ) : null} ); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.test.tsx new file mode 100644 index 0000000000000..231d14f96695a --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.test.tsx @@ -0,0 +1,337 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DocumentDetailFlyout, type DocumentDetailFlyoutProps } from './document_detail_flyout'; +import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { buildDataTableRecord } from '@kbn/discover-utils'; + +const mockSpanHit = buildDataTableRecord( + { + _id: 'span-doc-id', + _index: 'traces-apm-default', + _source: { + '@timestamp': '2023-01-01T00:00:00.000Z', + 'span.name': 'test-span', + }, + }, + dataViewMock +); + +const mockLogHit = buildDataTableRecord( + { + _id: 'log-doc-id', + _index: 'logs-default', + _source: { + '@timestamp': '2023-01-01T00:00:00.000Z', + message: 'test log message', + }, + }, + dataViewMock +); + +const mockUseDocumentFlyoutData = jest.fn(); + +jest.mock('./use_document_flyout_data', () => ({ + useDocumentFlyoutData: (params: any) => mockUseDocumentFlyoutData(params), +})); + +jest.mock('./span_flyout', () => ({ + spanFlyoutId: 'spanDetailFlyout', + SpanFlyoutContent: ({ hit, dataView, activeSection }: any) => ( +
+ Span Flyout Content +
+ ), +})); + +jest.mock('./logs_flyout', () => ({ + logsFlyoutId: 'logsFlyout', + LogFlyoutContent: ({ hit, logDataView }: any) => ( +
+ Log Flyout Content +
+ ), +})); + +jest.mock('.', () => ({ + WaterfallFlyout: ({ onCloseFlyout, dataView, hit, loading, title, children }: any) => ( +
+ {loading ? ( +
Loading...
+ ) : hit ? ( + children + ) : ( +
No hit
+ )} +
+ ), +})); + +describe('DocumentDetailFlyout', () => { + const defaultSpanProps: DocumentDetailFlyoutProps = { + type: 'spanDetailFlyout', + docId: 'test-span-id', + traceId: 'test-trace-id', + dataView: dataViewMock, + onCloseFlyout: jest.fn(), + }; + + const defaultLogProps: DocumentDetailFlyoutProps = { + type: 'logsFlyout', + docId: 'test-log-id', + traceId: 'test-trace-id', + dataView: dataViewMock, + onCloseFlyout: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('hook calls', () => { + it('should call useDocumentFlyoutData with correct params for span type', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'spanDetailFlyout', + hit: mockSpanHit, + loading: false, + title: 'Span document', + logDataView: null, + error: null, + }); + + render(); + + expect(mockUseDocumentFlyoutData).toHaveBeenCalledWith({ + type: 'spanDetailFlyout', + docId: 'test-span-id', + traceId: 'test-trace-id', + docIndex: undefined, + }); + }); + + it('should call useDocumentFlyoutData with correct params for log type', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'logsFlyout', + hit: mockLogHit, + loading: false, + title: 'Log document', + logDataView: dataViewMock, + error: null, + }); + + render(); + + expect(mockUseDocumentFlyoutData).toHaveBeenCalledWith({ + type: 'logsFlyout', + docId: 'test-log-id', + traceId: 'test-trace-id', + docIndex: 'logs-*', + }); + }); + }); + + describe('content rendering based on type', () => { + it('should render SpanFlyoutContent when type is span', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'spanDetailFlyout', + hit: mockSpanHit, + loading: false, + title: 'Span document', + logDataView: null, + error: null, + }); + + render(); + + expect(screen.getByTestId('spanFlyoutContent')).toBeInTheDocument(); + expect(screen.queryByTestId('logFlyoutContent')).not.toBeInTheDocument(); + }); + + it('should render LogFlyoutContent when type is log', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'logsFlyout', + hit: mockLogHit, + loading: false, + title: 'Log document', + logDataView: dataViewMock, + error: null, + }); + + render(); + + expect(screen.getByTestId('logFlyoutContent')).toBeInTheDocument(); + expect(screen.queryByTestId('spanFlyoutContent')).not.toBeInTheDocument(); + }); + + it('should pass activeSection to SpanFlyoutContent', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'spanDetailFlyout', + hit: mockSpanHit, + loading: false, + title: 'Span document', + logDataView: null, + error: null, + }); + + render(); + + const spanContent = screen.getByTestId('spanFlyoutContent'); + expect(spanContent).toHaveAttribute('data-active-section', 'errors-table'); + }); + }); + + describe('WaterfallFlyout props', () => { + it('should pass correct props to WaterfallFlyout for span type', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'spanDetailFlyout', + hit: mockSpanHit, + loading: false, + title: 'Span document', + logDataView: null, + error: null, + }); + + render(); + + const flyout = screen.getByTestId('waterfallFlyout'); + expect(flyout).toHaveAttribute('data-loading', 'false'); + expect(flyout).toHaveAttribute('data-title', 'Span document'); + expect(flyout).toHaveAttribute('data-has-hit', 'true'); + }); + + it('should pass correct props to WaterfallFlyout for log type', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'logsFlyout', + hit: mockLogHit, + loading: false, + title: 'Log document', + logDataView: dataViewMock, + error: null, + }); + + render(); + + const flyout = screen.getByTestId('waterfallFlyout'); + expect(flyout).toHaveAttribute('data-loading', 'false'); + expect(flyout).toHaveAttribute('data-title', 'Log document'); + expect(flyout).toHaveAttribute('data-has-hit', 'true'); + }); + }); + + describe('loading states', () => { + it('should show loading state when data is loading', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'spanDetailFlyout', + hit: null, + loading: true, + title: 'Span document', + logDataView: null, + error: null, + }); + + render(); + + const flyout = screen.getByTestId('waterfallFlyout'); + expect(flyout).toHaveAttribute('data-loading', 'true'); + expect(screen.getByTestId('loadingSkeleton')).toBeInTheDocument(); + expect(screen.queryByTestId('spanFlyoutContent')).not.toBeInTheDocument(); + }); + + it('should not render content when hit is null (even if not loading)', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'spanDetailFlyout', + hit: null, + loading: false, + title: 'Span document', + logDataView: null, + error: null, + }); + + render(); + + expect(screen.queryByTestId('spanFlyoutContent')).not.toBeInTheDocument(); + }); + }); + + describe('log flyout edge cases', () => { + it('should render EuiCallOut when data has error', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'logsFlyout', + hit: mockLogHit, + loading: false, + title: 'Log document', + logDataView: dataViewMock, + error: 'Failed to load data view', + }); + + render(); + + expect(screen.getByText('Failed to load data view')).toBeInTheDocument(); + expect(screen.getByTestId('logFlyoutContent')).toBeInTheDocument(); + }); + + it('should not render LogFlyoutContent when logDataView is null', () => { + mockUseDocumentFlyoutData.mockReturnValue({ + type: 'logsFlyout', + hit: mockLogHit, + loading: false, + title: 'Log document', + logDataView: null, + error: null, + }); + + render(); + + expect(screen.queryByTestId('logFlyoutContent')).not.toBeInTheDocument(); + }); + }); + + describe('switching between types', () => { + it('should correctly switch from span to log type', () => { + mockUseDocumentFlyoutData + .mockReturnValueOnce({ + type: 'spanDetailFlyout', + hit: mockSpanHit, + loading: false, + title: 'Span document', + logDataView: null, + error: null, + }) + .mockReturnValueOnce({ + type: 'logsFlyout', + hit: mockLogHit, + loading: false, + title: 'Log document', + logDataView: dataViewMock, + error: null, + }); + + const { rerender } = render(); + + expect(screen.getByTestId('spanFlyoutContent')).toBeInTheDocument(); + expect(screen.queryByTestId('logFlyoutContent')).not.toBeInTheDocument(); + + rerender(); + + expect(screen.queryByTestId('spanFlyoutContent')).not.toBeInTheDocument(); + expect(screen.getByTestId('logFlyoutContent')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.tsx new file mode 100644 index 0000000000000..6d4341ab291aa --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/document_detail_flyout.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EuiCallOut } from '@elastic/eui'; +import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; +import React from 'react'; +import { WaterfallFlyout } from '.'; +import type { TraceOverviewSections } from '../../../doc_viewer_overview/overview'; +import { spanFlyoutId, SpanFlyoutContent } from './span_flyout'; +import { logsFlyoutId, LogFlyoutContent } from './logs_flyout'; +import { + useDocumentFlyoutData, + type DocumentType, + type DocumentFlyoutData, +} from './use_document_flyout_data'; + +export type { DocumentType } from './use_document_flyout_data'; + +interface FlyoutContentProps { + data: DocumentFlyoutData; + dataView: DocViewRenderProps['dataView']; + activeSection?: TraceOverviewSections; +} + +function FlyoutContent({ data, dataView, activeSection }: FlyoutContentProps) { + if (!data.hit) { + return null; + } + + const isSpanType = data.type === spanFlyoutId; + if (isSpanType) { + return ; + } + + const isLogType = data.type === logsFlyoutId; + if (isLogType && data.logDataView) { + return ; + } + + return null; +} + +export interface DocumentDetailFlyoutProps { + type: DocumentType; + docId: string; + docIndex?: string; + traceId: string; + dataView: DocViewRenderProps['dataView']; + onCloseFlyout: () => void; + activeSection?: TraceOverviewSections; +} + +export function DocumentDetailFlyout({ + type, + docId, + docIndex, + traceId, + dataView, + onCloseFlyout, + activeSection, +}: DocumentDetailFlyoutProps) { + const data = useDocumentFlyoutData({ type, docId, traceId, docIndex }); + + return ( + + {data.error && } + {data.hit ? ( + + ) : null} + + ); +} diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.test.tsx index 0da237b447a35..9cd3382e34ccb 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.test.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.test.tsx @@ -46,7 +46,6 @@ describe('WaterfallFlyout', () => { const defaultProps: Props = { title: 'Test Flyout Title', - flyoutId: 'testFlyoutId', onCloseFlyout: jest.fn(), hit: mockHit, loading: false, diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.tsx index 6766748325bbf..b5c5f6547100a 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/index.tsx @@ -71,7 +71,6 @@ const FlyoutTabs = ({ onClick, selectedTabId }: FlyoutTabsProps) => { export interface Props { title: string; - flyoutId: string; onCloseFlyout: () => void; hit: DataTableRecord | null; loading: boolean; @@ -79,17 +78,10 @@ export interface Props { children: React.ReactNode; } -export function WaterfallFlyout({ - onCloseFlyout, - dataView, - hit, - loading, - children, - title, - flyoutId, -}: Props) { +export function WaterfallFlyout({ onCloseFlyout, dataView, hit, loading, children, title }: Props) { const [selectedTabId, setSelectedTabId] = useState(tabIds.OVERVIEW); const flyoutTitleId = useGeneratedHtmlId(); + const flyoutId = useGeneratedHtmlId({ prefix: 'documentDetailFlyout' }); return ( ({ + __esModule: true, + default: ({ hit, dataView, indexes, showTraceWaterfall }: any) => ( +
+ Logs Overview Mock +
+ ), +})); + +const mockIndexes = { + traces: 'traces-*', + logs: 'logs-*', +}; + +jest.mock('../../../../../../../hooks/use_data_sources', () => ({ + useDataSourcesContext: () => ({ + indexes: mockIndexes, + }), +})); + +describe('LogFlyoutContent', () => { + const mockHit = buildDataTableRecord( + { + _id: 'test-log-id', + _index: 'logs-default', + _source: { + '@timestamp': '2023-01-01T00:00:00.000Z', + message: 'test log message', + }, + }, + dataViewMock + ); + + const defaultProps: LogFlyoutContentProps = { + hit: mockHit, + logDataView: dataViewMock, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render LogsOverview component with correct props', () => { + render(); + + const logsOverview = screen.getByTestId('logsOverviewComponent'); + expect(logsOverview).toBeInTheDocument(); + expect(logsOverview).toHaveAttribute('data-hit-id', mockHit.id); + expect(logsOverview).toHaveAttribute('data-show-trace-waterfall', 'false'); + }); + + it('should render with different hit data', () => { + const differentHit = buildDataTableRecord( + { + _id: 'different-log-id', + _index: 'logs-other', + _source: { + '@timestamp': '2023-01-02T00:00:00.000Z', + message: 'different log message', + }, + }, + dataViewMock + ); + + render(); + + const logsOverview = screen.getByTestId('logsOverviewComponent'); + expect(logsOverview).toHaveAttribute('data-hit-id', differentHit.id); + }); + + it('should pass dataView correctly to LogsOverview', () => { + const differentDataView = { ...dataViewMock, id: 'different-data-view' } as typeof dataViewMock; + + render(); + + const logsOverview = screen.getByTestId('logsOverviewComponent'); + expect(logsOverview).toHaveAttribute('data-view-id', 'different-data-view'); + }); +}); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx index 5c84707823fc4..d125fc2bb5655 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/index.tsx @@ -7,71 +7,26 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { EuiCallOut } from '@elastic/eui'; import type { DataTableRecord } from '@kbn/discover-utils'; -import { i18n } from '@kbn/i18n'; -import { flattenObject } from '@kbn/object-utils'; import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; -import React, { useMemo } from 'react'; -import { WaterfallFlyout } from '..'; +import React from 'react'; import LogsOverview from '../../../../../../doc_viewer_logs_overview'; import { useDataSourcesContext } from '../../../../../../../hooks/use_data_sources'; -import { useAdhocDataView } from '../../hooks/use_adhoc_data_view'; -import { useFetchLog } from '../../hooks/use_fetch_log'; + +export { useLogFlyoutData } from './use_log_flyout_data'; +export type { UseLogFlyoutDataParams, LogFlyoutData } from './use_log_flyout_data'; export const logsFlyoutId = 'logsFlyout' as const; -export interface LogsFlyoutProps { - onCloseFlyout: () => void; - id: string; - index?: string; - dataView: DocViewRenderProps['dataView']; +export interface LogFlyoutContentProps { + hit: DataTableRecord; + logDataView: DocViewRenderProps['dataView']; } -export function LogsFlyout({ onCloseFlyout, id, index, dataView }: LogsFlyoutProps) { - const { loading, log, index: resolvedIndex } = useFetchLog({ id, index }); +export function LogFlyoutContent({ hit, logDataView }: LogFlyoutContentProps) { const { indexes } = useDataSourcesContext(); - const { - dataView: logDataView, - error, - loading: loadingDataView, - } = useAdhocDataView({ index: resolvedIndex ?? null }); - - const documentAsHit = useMemo(() => { - if (!log || !id || !resolvedIndex) return null; - - return { - id, - raw: { - _index: resolvedIndex, - _id: id, - _source: log, - }, - flattened: flattenObject(log), - }; - }, [id, log, resolvedIndex]); return ( - - {error ? : null} - {documentAsHit && logDataView ? ( - - ) : null} - + ); } diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.test.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.test.ts new file mode 100644 index 0000000000000..97b0d7fac71ad --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.test.ts @@ -0,0 +1,340 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { useLogFlyoutData } from './use_log_flyout_data'; +import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; + +const mockUseFetchLog = jest.fn(); +const mockUseAdhocDataView = jest.fn(); + +jest.mock('../../hooks/use_fetch_log', () => ({ + useFetchLog: (params: { id: string; index?: string }) => mockUseFetchLog(params), +})); + +jest.mock('../../hooks/use_adhoc_data_view', () => ({ + useAdhocDataView: (params: { index: string | null }) => mockUseAdhocDataView(params), +})); + +describe('useLogFlyoutData', () => { + const id = 'test-log-id'; + const index = 'logs-*'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return loading true when fetching log', () => { + mockUseFetchLog.mockReturnValue({ + loading: true, + log: undefined, + index: undefined, + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: null, + error: null, + loading: false, + }); + + const { result } = renderHook(() => useLogFlyoutData({ id })); + + expect(result.current.loading).toBe(true); + expect(result.current.hit).toBeNull(); + expect(mockUseFetchLog).toHaveBeenCalledWith({ id }); + }); + + it('should return loading true when fetching data view', () => { + mockUseFetchLog.mockReturnValue({ + loading: false, + log: { message: 'test' }, + index: 'logs-*', + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: null, + error: null, + loading: true, + }); + + const { result } = renderHook(() => useLogFlyoutData({ id })); + + expect(result.current.loading).toBe(true); + }); + + it('should return loading true when both are loading', () => { + mockUseFetchLog.mockReturnValue({ + loading: true, + log: undefined, + index: undefined, + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: null, + error: null, + loading: true, + }); + + const { result } = renderHook(() => useLogFlyoutData({ id })); + + expect(result.current.loading).toBe(true); + }); + + it('should return null hit when log is not available', async () => { + mockUseFetchLog.mockReturnValue({ + loading: false, + log: undefined, + index: undefined, + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: null, + error: null, + loading: false, + }); + + const { result } = renderHook(() => useLogFlyoutData({ id })); + + await waitFor(() => !result.current.loading); + + expect(result.current.loading).toBe(false); + expect(result.current.hit).toBeNull(); + }); + + it('should return null hit when id is empty', async () => { + mockUseFetchLog.mockReturnValue({ + loading: false, + log: { message: 'test' }, + index: 'logs-*', + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: dataViewMock, + error: null, + loading: false, + }); + + const { result } = renderHook(() => useLogFlyoutData({ id: '' })); + + await waitFor(() => !result.current.loading); + + expect(result.current.hit).toBeNull(); + }); + + it('should return null hit when resolvedIndex is not available', async () => { + mockUseFetchLog.mockReturnValue({ + loading: false, + log: { message: 'test' }, + index: undefined, + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: null, + error: null, + loading: false, + }); + + const { result } = renderHook(() => useLogFlyoutData({ id })); + + await waitFor(() => !result.current.loading); + + expect(result.current.hit).toBeNull(); + }); + + it('should return hit with correct structure when log is fetched', async () => { + const mockLog = { + message: 'test log message', + '@timestamp': '2023-01-01T00:00:00.000Z', + }; + const resolvedIndex = 'logs-2023.01.01'; + + mockUseFetchLog.mockReturnValue({ + loading: false, + log: mockLog, + index: resolvedIndex, + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: dataViewMock, + error: null, + loading: false, + }); + + const { result } = renderHook(() => useLogFlyoutData({ id })); + + await waitFor(() => !result.current.loading); + + expect(result.current.loading).toBe(false); + expect(result.current.hit).not.toBeNull(); + expect(result.current.hit?.id).toBe(id); + expect(result.current.hit?.raw._index).toBe(resolvedIndex); + expect(result.current.hit?.raw._id).toBe(id); + expect(result.current.hit?.raw._source).toBe(mockLog); + expect(result.current.hit?.flattened).toBeDefined(); + }); + + it('should return "Log document" title', async () => { + mockUseFetchLog.mockReturnValue({ + loading: false, + log: { message: 'test' }, + index: 'logs-*', + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: dataViewMock, + error: null, + loading: false, + }); + + const { result } = renderHook(() => useLogFlyoutData({ id })); + + await waitFor(() => !result.current.loading); + + expect(result.current.title).toBe('Log document'); + }); + + it('should return error from adhoc data view', async () => { + const errorMessage = 'Failed to create data view'; + + mockUseFetchLog.mockReturnValue({ + loading: false, + log: { message: 'test' }, + index: 'logs-*', + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: null, + error: errorMessage, + loading: false, + }); + + const { result } = renderHook(() => useLogFlyoutData({ id })); + + await waitFor(() => !result.current.loading); + + expect(result.current.error).toBe(errorMessage); + }); + + it('should return logDataView from adhoc data view', async () => { + mockUseFetchLog.mockReturnValue({ + loading: false, + log: { message: 'test' }, + index: 'logs-*', + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: dataViewMock, + error: null, + loading: false, + }); + + const { result } = renderHook(() => useLogFlyoutData({ id })); + + await waitFor(() => !result.current.loading); + + expect(result.current.logDataView).toBe(dataViewMock); + }); + + it('should pass index to useFetchLog when provided', () => { + mockUseFetchLog.mockReturnValue({ + loading: true, + log: undefined, + index: undefined, + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: null, + error: null, + loading: false, + }); + + renderHook(() => useLogFlyoutData({ id, index })); + + expect(mockUseFetchLog).toHaveBeenCalledWith({ id, index }); + }); + + it('should pass resolvedIndex to useAdhocDataView', () => { + const resolvedIndex = 'logs-2023.01.01'; + + mockUseFetchLog.mockReturnValue({ + loading: false, + log: { message: 'test' }, + index: resolvedIndex, + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: dataViewMock, + error: null, + loading: false, + }); + + renderHook(() => useLogFlyoutData({ id })); + + expect(mockUseAdhocDataView).toHaveBeenCalledWith({ index: resolvedIndex }); + }); + + it('should pass null to useAdhocDataView when resolvedIndex is undefined', () => { + mockUseFetchLog.mockReturnValue({ + loading: false, + log: undefined, + index: undefined, + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: null, + error: null, + loading: false, + }); + + renderHook(() => useLogFlyoutData({ id })); + + expect(mockUseAdhocDataView).toHaveBeenCalledWith({ index: null }); + }); + + it('should refetch when id changes', async () => { + const mockLog1 = { message: 'log 1' }; + const mockLog2 = { message: 'log 2' }; + + mockUseFetchLog + .mockReturnValueOnce({ loading: false, log: mockLog1, index: 'logs-*' }) + .mockReturnValueOnce({ loading: false, log: mockLog2, index: 'logs-*' }); + mockUseAdhocDataView.mockReturnValue({ + dataView: dataViewMock, + error: null, + loading: false, + }); + + const { result, rerender } = renderHook( + ({ logId }: { logId: string }) => useLogFlyoutData({ id: logId }), + { + initialProps: { logId: 'log-1' }, + } + ); + + await waitFor(() => !result.current.loading); + expect(result.current.hit?.raw._source).toBe(mockLog1); + expect(mockUseFetchLog).toHaveBeenCalledWith({ id: 'log-1' }); + + rerender({ logId: 'log-2' }); + + expect(mockUseFetchLog).toHaveBeenCalledWith({ id: 'log-2' }); + }); + + it('should memoize hit when log does not change', async () => { + const mockLog = { message: 'test' }; + const resolvedIndex = 'logs-*'; + + mockUseFetchLog.mockReturnValue({ + loading: false, + log: mockLog, + index: resolvedIndex, + }); + mockUseAdhocDataView.mockReturnValue({ + dataView: dataViewMock, + error: null, + loading: false, + }); + + const { result, rerender } = renderHook(() => useLogFlyoutData({ id })); + + await waitFor(() => !result.current.loading); + const firstHit = result.current.hit; + + rerender(); + + expect(result.current.hit).toBe(firstHit); + }); +}); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.ts new file mode 100644 index 0000000000000..0f766494a60bf --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/logs_flyout/use_log_flyout_data.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { DataTableRecord } from '@kbn/discover-utils'; +import { i18n } from '@kbn/i18n'; +import { flattenObject } from '@kbn/object-utils'; +import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; +import { useMemo } from 'react'; +import { useAdhocDataView } from '../../hooks/use_adhoc_data_view'; +import { useFetchLog } from '../../hooks/use_fetch_log'; +import type { BaseFlyoutData } from '../use_document_flyout_data'; + +export interface UseLogFlyoutDataParams { + id: string; + index?: string; +} + +export interface LogFlyoutData extends BaseFlyoutData { + logDataView: DocViewRenderProps['dataView'] | null; +} + +export function useLogFlyoutData({ id, index }: UseLogFlyoutDataParams): LogFlyoutData { + const { loading, log, index: resolvedIndex } = useFetchLog({ id, index }); + const { + dataView: logDataView, + error, + loading: loadingDataView, + } = useAdhocDataView({ index: resolvedIndex ?? null }); + + const hit = useMemo(() => { + if (!log || !id || !resolvedIndex) return null; + + return { + id, + raw: { + _index: resolvedIndex, + _id: id, + _source: log, + }, + flattened: flattenObject(log), + }; + }, [id, log, resolvedIndex]); + + const title = i18n.translate( + 'unifiedDocViewer.observability.traces.fullScreenWaterfall.logFlyout.title.log', + { defaultMessage: 'Log document' } + ); + + return { + hit, + loading: loading || loadingDataView, + title, + error, + logDataView, + }; +} diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.test.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.test.tsx new file mode 100644 index 0000000000000..c5b7171e58060 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { SpanFlyoutContent, type SpanFlyoutContentProps } from '.'; +import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { buildDataTableRecord } from '@kbn/discover-utils'; + +const mockOpenAndScrollToSection = jest.fn(); + +jest.mock('../../../../doc_viewer_overview/overview', () => { + const ReactMock = jest.requireActual('react'); + + const stableApi = { + openAndScrollToSection: (section: string) => mockOpenAndScrollToSection(section), + }; + + return { + Overview: ReactMock.forwardRef( + ({ hit, indexes, showWaterfall, showActions, dataView }: any, ref: any) => { + ReactMock.useImperativeHandle(ref, () => stableApi, []); + + return ( +
+ Overview Mock +
+ ); + } + ), + }; +}); + +const mockIndexes = { + traces: 'traces-*', + logs: 'logs-*', +}; + +jest.mock('../../../../../../../hooks/use_data_sources', () => ({ + useDataSourcesContext: () => ({ + indexes: mockIndexes, + }), +})); + +describe('SpanFlyoutContent', () => { + const mockHit = buildDataTableRecord( + { + _id: 'test-span-id', + _index: 'traces-apm-default', + _source: { + '@timestamp': '2023-01-01T00:00:00.000Z', + 'span.name': 'test-span', + 'span.id': 'test-span-id', + 'trace.id': 'test-trace-id', + }, + }, + dataViewMock + ); + + const defaultProps: SpanFlyoutContentProps = { + hit: mockHit, + dataView: dataViewMock, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockOpenAndScrollToSection.mockClear(); + }); + + it('should render Overview component with correct props', () => { + render(); + + const overview = screen.getByTestId('overviewComponent'); + expect(overview).toBeInTheDocument(); + expect(overview).toHaveAttribute('data-hit-id', mockHit.id); + expect(overview).toHaveAttribute('data-show-waterfall', 'false'); + expect(overview).toHaveAttribute('data-show-actions', 'false'); + }); + + it('should render with different hit data', () => { + const differentHit = buildDataTableRecord( + { + _id: 'different-span-id', + _index: 'traces-apm-default', + _source: { + '@timestamp': '2023-01-02T00:00:00.000Z', + 'span.name': 'different-span', + }, + }, + dataViewMock + ); + + render(); + + const overview = screen.getByTestId('overviewComponent'); + expect(overview).toHaveAttribute('data-hit-id', differentHit.id); + }); + + describe('activeSection behavior', () => { + it('should call openAndScrollToSection when activeSection is provided', async () => { + render(); + + await waitFor(() => { + expect(mockOpenAndScrollToSection).toHaveBeenCalledWith('errors-table'); + }); + }); + + it('should not call openAndScrollToSection when activeSection is undefined', () => { + render(); + + expect(mockOpenAndScrollToSection).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx index d7f35d966090b..2900bd2740058 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/index.tsx @@ -8,54 +8,27 @@ */ import type { DataTableRecord } from '@kbn/discover-utils'; -import { i18n } from '@kbn/i18n'; -import { flattenObject } from '@kbn/object-utils'; import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; -import React, { useEffect, useMemo, useState } from 'react'; -import { WaterfallFlyout } from '..'; +import React, { useEffect, useState } from 'react'; import type { OverviewApi } from '../../../../doc_viewer_overview/overview'; import { Overview, type TraceOverviewSections } from '../../../../doc_viewer_overview/overview'; import { useDataSourcesContext } from '../../../../../../../hooks/use_data_sources'; -import { isSpanHit } from '../../helpers/is_span'; -import { useFetchSpan } from '../../hooks/use_fetch_span'; + +export { useSpanFlyoutData } from './use_span_flyout_data'; +export type { UseSpanFlyoutDataParams, SpanFlyoutData } from './use_span_flyout_data'; export const spanFlyoutId = 'spanDetailFlyout' as const; -export interface SpanFlyoutProps { - spanId: string; - traceId: string; +export interface SpanFlyoutContentProps { + hit: DataTableRecord; dataView: DocViewRenderProps['dataView']; - onCloseFlyout: () => void; activeSection?: TraceOverviewSections; } -export const SpanFlyout = ({ - spanId, - traceId, - dataView, - onCloseFlyout, - activeSection, -}: SpanFlyoutProps) => { - const { span, loading } = useFetchSpan({ spanId, traceId }); +export function SpanFlyoutContent({ hit, dataView, activeSection }: SpanFlyoutContentProps) { const { indexes } = useDataSourcesContext(); const [flyoutRef, setFlyoutRef] = useState(null); - const documentAsHit = useMemo(() => { - if (!span) return null; - - return { - id: span._id, - raw: { - _index: span._index, - _id: span._id, - _source: span as unknown as Record, - }, - flattened: flattenObject(span), - }; - }, [span]); - - const isSpan = isSpanHit(documentAsHit); - useEffect(() => { if (activeSection && flyoutRef) { flyoutRef.openAndScrollToSection(activeSection); @@ -63,40 +36,13 @@ export const SpanFlyout = ({ }, [activeSection, flyoutRef]); return ( - - {documentAsHit ? ( - - ) : null} - + /> ); -}; +} diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.test.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.test.ts new file mode 100644 index 0000000000000..940129a5768ab --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.test.ts @@ -0,0 +1,243 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import type { UnifiedSpanDocument } from '@kbn/apm-types'; +import { useSpanFlyoutData } from './use_span_flyout_data'; + +const mockUseFetchSpan = jest.fn(); + +jest.mock('../../hooks/use_fetch_span', () => ({ + useFetchSpan: (params: { spanId: string; traceId: string }) => mockUseFetchSpan(params), +})); + +jest.mock('../../helpers/is_span', () => ({ + isSpanHit: jest.fn((hit) => { + if (!hit) return false; + return hit.flattened?.['span.id'] !== undefined; + }), +})); + +describe('useSpanFlyoutData', () => { + const spanId = 'test-span-id'; + const traceId = 'test-trace-id'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return loading true when fetching span', () => { + mockUseFetchSpan.mockReturnValue({ + span: undefined, + loading: true, + error: undefined, + }); + + const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId })); + + expect(result.current.loading).toBe(true); + expect(result.current.hit).toBeNull(); + expect(result.current.error).toBeNull(); + expect(mockUseFetchSpan).toHaveBeenCalledWith({ spanId, traceId }); + }); + + it('should return null hit when span is not available', async () => { + mockUseFetchSpan.mockReturnValue({ + span: undefined, + loading: false, + error: undefined, + }); + + const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId })); + + await waitFor(() => !result.current.loading); + + expect(result.current.loading).toBe(false); + expect(result.current.hit).toBeNull(); + }); + + it('should return hit with correct structure when span is fetched', async () => { + const mockSpan: UnifiedSpanDocument = { + _id: 'test-id', + _index: 'traces-apm-*', + span: { + id: spanId, + name: 'test-span', + duration: { us: 100000 }, + }, + trace: { id: traceId }, + service: { name: 'test-service' }, + } as UnifiedSpanDocument; + + mockUseFetchSpan.mockReturnValue({ + span: mockSpan, + loading: false, + error: undefined, + }); + + const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId })); + + await waitFor(() => !result.current.loading); + + expect(result.current.loading).toBe(false); + expect(result.current.hit).not.toBeNull(); + expect(result.current.hit?.id).toBe('test-id'); + expect(result.current.hit?.raw._index).toBe('traces-apm-*'); + expect(result.current.hit?.raw._id).toBe('test-id'); + expect(result.current.hit?.raw._source).toBe(mockSpan); + expect(result.current.hit?.flattened).toBeDefined(); + expect(result.current.error).toBeNull(); + }); + + it('should return "Span document" title when hit is a span', async () => { + const mockSpan: UnifiedSpanDocument = { + _id: 'test-id', + _index: 'traces-apm-*', + span: { + id: spanId, + name: 'test-span', + }, + } as UnifiedSpanDocument; + + mockUseFetchSpan.mockReturnValue({ + span: mockSpan, + loading: false, + error: undefined, + }); + + const isSpanHitMock = jest.requireMock('../../helpers/is_span').isSpanHit; + isSpanHitMock.mockReturnValue(true); + + const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId })); + + await waitFor(() => !result.current.loading); + + expect(result.current.title).toBe('Span document'); + }); + + it('should return "Transaction document" title when hit is a transaction', async () => { + const mockTransaction: UnifiedSpanDocument = { + _id: 'test-id', + _index: 'traces-apm-*', + transaction: { + id: 'transaction-id', + name: 'test-transaction', + }, + } as UnifiedSpanDocument; + + mockUseFetchSpan.mockReturnValue({ + span: mockTransaction, + loading: false, + error: undefined, + }); + + const isSpanHitMock = jest.requireMock('../../helpers/is_span').isSpanHit; + isSpanHitMock.mockReturnValue(false); + + const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId })); + + await waitFor(() => !result.current.loading); + + expect(result.current.title).toBe('Transaction document'); + }); + + it('should refetch when spanId changes', async () => { + const mockSpan1: UnifiedSpanDocument = { + _id: 'span-1', + _index: 'traces-apm-*', + span: { id: 'span-1', name: 'span-1' }, + } as UnifiedSpanDocument; + + const mockSpan2: UnifiedSpanDocument = { + _id: 'span-2', + _index: 'traces-apm-*', + span: { id: 'span-2', name: 'span-2' }, + } as UnifiedSpanDocument; + + mockUseFetchSpan + .mockReturnValueOnce({ span: mockSpan1, loading: false, error: undefined }) + .mockReturnValueOnce({ span: mockSpan2, loading: false, error: undefined }); + + const { result, rerender } = renderHook( + ({ sId, tId }: { sId: string; tId: string }) => + useSpanFlyoutData({ spanId: sId, traceId: tId }), + { + initialProps: { sId: 'span-1', tId: traceId }, + } + ); + + await waitFor(() => !result.current.loading); + expect(result.current.hit?.id).toBe('span-1'); + + rerender({ sId: 'span-2', tId: traceId }); + + await waitFor(() => result.current.hit?.id === 'span-2'); + expect(result.current.hit?.id).toBe('span-2'); + expect(mockUseFetchSpan).toHaveBeenCalledWith({ spanId: 'span-2', traceId }); + }); + + it('should refetch when traceId changes', async () => { + const mockSpan: UnifiedSpanDocument = { + _id: 'test-id', + _index: 'traces-apm-*', + span: { id: spanId, name: 'test-span' }, + } as UnifiedSpanDocument; + + mockUseFetchSpan.mockReturnValue({ span: mockSpan, loading: false, error: undefined }); + + const { rerender } = renderHook( + ({ sId, tId }: { sId: string; tId: string }) => + useSpanFlyoutData({ spanId: sId, traceId: tId }), + { + initialProps: { sId: spanId, tId: 'trace-1' }, + } + ); + + expect(mockUseFetchSpan).toHaveBeenCalledWith({ spanId, traceId: 'trace-1' }); + + rerender({ sId: spanId, tId: 'trace-2' }); + + expect(mockUseFetchSpan).toHaveBeenCalledWith({ spanId, traceId: 'trace-2' }); + }); + + it('should memoize hit when span does not change', async () => { + const mockSpan: UnifiedSpanDocument = { + _id: 'test-id', + _index: 'traces-apm-*', + span: { id: spanId, name: 'test-span' }, + } as UnifiedSpanDocument; + + mockUseFetchSpan.mockReturnValue({ span: mockSpan, loading: false, error: undefined }); + + const { result, rerender } = renderHook(() => useSpanFlyoutData({ spanId, traceId })); + + await waitFor(() => !result.current.loading); + const firstHit = result.current.hit; + + rerender(); + + expect(result.current.hit).toBe(firstHit); + }); + + it('should return error message when fetch fails', async () => { + const errorMessage = 'Failed to fetch span'; + mockUseFetchSpan.mockReturnValue({ + span: undefined, + loading: false, + error: new Error(errorMessage), + }); + + const { result } = renderHook(() => useSpanFlyoutData({ spanId, traceId })); + + await waitFor(() => !result.current.loading); + + expect(result.current.error).toBe(errorMessage); + expect(result.current.hit).toBeNull(); + }); +}); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.ts new file mode 100644 index 0000000000000..081956ba26f42 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/span_flyout/use_span_flyout_data.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { DataTableRecord } from '@kbn/discover-utils'; +import { i18n } from '@kbn/i18n'; +import { flattenObject } from '@kbn/object-utils'; +import { useMemo } from 'react'; +import { isSpanHit } from '../../helpers/is_span'; +import { useFetchSpan } from '../../hooks/use_fetch_span'; +import type { BaseFlyoutData } from '../use_document_flyout_data'; + +export interface UseSpanFlyoutDataParams { + spanId: string; + traceId: string; +} + +export type SpanFlyoutData = BaseFlyoutData; + +export function useSpanFlyoutData({ spanId, traceId }: UseSpanFlyoutDataParams): SpanFlyoutData { + const { span, loading, error } = useFetchSpan({ spanId, traceId }); + + const hit = useMemo(() => { + if (!span) return null; + + return { + id: span._id, + raw: { + _index: span._index, + _id: span._id, + _source: span as unknown as Record, + }, + flattened: flattenObject(span), + }; + }, [span]); + + const isSpan = isSpanHit(hit); + + const title = i18n.translate( + 'unifiedDocViewer.observability.traces.fullScreenWaterfall.spanFlyout.title', + { + defaultMessage: '{docType} document', + values: { + docType: isSpan + ? i18n.translate( + 'unifiedDocViewer.observability.traces.fullScreenWaterfall.spanFlyout.title.span', + { defaultMessage: 'Span' } + ) + : i18n.translate( + 'unifiedDocViewer.observability.traces.fullScreenWaterfall.spanFlyout.title.transction', + { defaultMessage: 'Transaction' } + ), + }, + } + ); + + return { hit, loading, title, error: error?.message ?? null }; +} diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.test.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.test.ts new file mode 100644 index 0000000000000..869850f331307 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.test.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { renderHook } from '@testing-library/react'; +import { useDocumentFlyoutData, type DocumentType } from './use_document_flyout_data'; +import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { buildDataTableRecord } from '@kbn/discover-utils'; + +const mockSpanHit = buildDataTableRecord( + { + _id: 'span-id', + _index: 'traces-apm-*', + _source: { 'span.id': 'span-id', 'span.name': 'test-span' }, + }, + dataViewMock +); + +const mockLogHit = buildDataTableRecord( + { + _id: 'log-id', + _index: 'logs-*', + _source: { message: 'test log' }, + }, + dataViewMock +); + +const mockUseSpanFlyoutData = jest.fn(); +const mockUseLogFlyoutData = jest.fn(); + +jest.mock('./span_flyout', () => ({ + spanFlyoutId: 'spanDetailFlyout', + useSpanFlyoutData: (params: any) => mockUseSpanFlyoutData(params), +})); + +jest.mock('./logs_flyout', () => ({ + logsFlyoutId: 'logsFlyout', + useLogFlyoutData: (params: any) => mockUseLogFlyoutData(params), +})); + +describe('useDocumentFlyoutData', () => { + const traceId = 'test-trace-id'; + const docId = 'test-doc-id'; + const docIndex = 'logs-*'; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSpanFlyoutData.mockReturnValue({ + hit: null, + loading: false, + title: 'Span document', + error: null, + }); + + mockUseLogFlyoutData.mockReturnValue({ + hit: null, + loading: false, + title: 'Log document', + logDataView: null, + error: null, + }); + }); + + describe('span type', () => { + it('should call useSpanFlyoutData with docId and useLogFlyoutData with empty id', () => { + renderHook(() => useDocumentFlyoutData({ type: 'spanDetailFlyout', docId, traceId })); + + expect(mockUseSpanFlyoutData).toHaveBeenCalledWith({ spanId: docId, traceId }); + expect(mockUseLogFlyoutData).toHaveBeenCalledWith({ id: '', index: undefined }); + }); + + it('should return span data when type is span', () => { + mockUseSpanFlyoutData.mockReturnValue({ + hit: mockSpanHit, + loading: false, + title: 'Span document', + error: null, + }); + + const { result } = renderHook(() => + useDocumentFlyoutData({ type: 'spanDetailFlyout', docId, traceId }) + ); + + expect(result.current.type).toBe('spanDetailFlyout'); + expect(result.current.hit).toBe(mockSpanHit); + expect(result.current.title).toBe('Span document'); + }); + + it('should return loading true when span is loading', () => { + mockUseSpanFlyoutData.mockReturnValue({ + hit: null, + loading: true, + title: 'Span document', + error: null, + }); + + const { result } = renderHook(() => + useDocumentFlyoutData({ type: 'spanDetailFlyout', docId, traceId }) + ); + + expect(result.current.loading).toBe(true); + }); + + it('should not include log-specific fields for span type', () => { + mockUseSpanFlyoutData.mockReturnValue({ + hit: mockSpanHit, + loading: false, + title: 'Span document', + error: null, + }); + + const { result } = renderHook(() => + useDocumentFlyoutData({ type: 'spanDetailFlyout', docId, traceId }) + ); + + expect(result.current).not.toHaveProperty('logDataView'); + }); + + it('should return error from span data', () => { + const errorMessage = 'Failed to fetch span'; + mockUseSpanFlyoutData.mockReturnValue({ + hit: null, + loading: false, + title: 'Span document', + error: errorMessage, + }); + + const { result } = renderHook(() => + useDocumentFlyoutData({ type: 'spanDetailFlyout', docId, traceId }) + ); + + expect(result.current.error).toBe(errorMessage); + }); + }); + + describe('log type', () => { + it('should call useLogFlyoutData with docId and useSpanFlyoutData with empty spanId', () => { + renderHook(() => useDocumentFlyoutData({ type: 'logsFlyout', docId, traceId, docIndex })); + + expect(mockUseSpanFlyoutData).toHaveBeenCalledWith({ spanId: '', traceId }); + expect(mockUseLogFlyoutData).toHaveBeenCalledWith({ id: docId, index: docIndex }); + }); + + it('should return log data when type is log', () => { + mockUseLogFlyoutData.mockReturnValue({ + hit: mockLogHit, + loading: false, + title: 'Log document', + logDataView: dataViewMock, + error: null, + }); + + const { result } = renderHook(() => + useDocumentFlyoutData({ type: 'logsFlyout', docId, traceId }) + ); + + expect(result.current.type).toBe('logsFlyout'); + expect(result.current.hit).toBe(mockLogHit); + expect(result.current.title).toBe('Log document'); + expect(result.current.logDataView).toBe(dataViewMock); + }); + + it('should return loading true when log is loading', () => { + mockUseLogFlyoutData.mockReturnValue({ + hit: null, + loading: true, + title: 'Log document', + logDataView: null, + error: null, + }); + + const { result } = renderHook(() => + useDocumentFlyoutData({ type: 'logsFlyout', docId, traceId }) + ); + + expect(result.current.loading).toBe(true); + }); + + it('should return error from log data', () => { + const errorMessage = 'Failed to create data view'; + mockUseLogFlyoutData.mockReturnValue({ + hit: mockLogHit, + loading: false, + title: 'Log document', + logDataView: null, + error: errorMessage, + }); + + const { result } = renderHook(() => + useDocumentFlyoutData({ type: 'logsFlyout', docId, traceId }) + ); + + expect(result.current.error).toBe(errorMessage); + }); + }); + + describe('type switching', () => { + it('should update data when type changes', () => { + mockUseSpanFlyoutData.mockReturnValue({ + hit: mockSpanHit, + loading: false, + title: 'Span document', + error: null, + }); + + mockUseLogFlyoutData.mockReturnValue({ + hit: mockLogHit, + loading: false, + title: 'Log document', + logDataView: dataViewMock, + error: null, + }); + + const { result, rerender } = renderHook( + ({ type }: { type: DocumentType }) => useDocumentFlyoutData({ type, docId, traceId }), + { initialProps: { type: 'spanDetailFlyout' as DocumentType } } + ); + + expect(result.current.type).toBe('spanDetailFlyout'); + expect(result.current.hit).toBe(mockSpanHit); + + rerender({ type: 'logsFlyout' }); + + expect(result.current.type).toBe('logsFlyout'); + expect(result.current.hit).toBe(mockLogHit); + }); + }); +}); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.ts new file mode 100644 index 0000000000000..08b3e799dfcf5 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces/components/full_screen_waterfall/waterfall_flyout/use_document_flyout_data.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { DataTableRecord } from '@kbn/discover-utils'; +import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; +import { spanFlyoutId, useSpanFlyoutData } from './span_flyout'; +import { useLogFlyoutData } from './logs_flyout'; + +export type DocumentType = 'spanDetailFlyout' | 'logsFlyout'; + +export interface UseDocumentFlyoutDataParams { + type: DocumentType; + docId: string; + traceId: string; + docIndex?: string; +} + +/** + * Base interface that all flyout data hooks must implement. + * Any new flyout type should extend this interface. + */ +export interface BaseFlyoutData { + hit: DataTableRecord | null; + loading: boolean; + title: string; + error: string | null; +} + +export interface DocumentFlyoutData extends BaseFlyoutData { + type: DocumentType; + // Log-specific fields (only present when type is 'logsFlyout') + logDataView?: DocViewRenderProps['dataView'] | null; +} + +/** + * Unified hook for fetching document flyout data. + * Orchestrates the individual hooks based on document type. + * Both hooks are called but short-circuit with empty params when not needed. + */ +export function useDocumentFlyoutData({ + type, + docId, + traceId, + docIndex, +}: UseDocumentFlyoutDataParams): DocumentFlyoutData { + const isSpanType = type === spanFlyoutId; + + const spanData = useSpanFlyoutData({ + spanId: isSpanType ? docId : '', + traceId, + }); + + const logData = useLogFlyoutData({ + id: isSpanType ? '' : docId, + index: docIndex, + }); + + if (isSpanType) { + return { + type, + hit: spanData.hit, + loading: spanData.loading, + title: spanData.title, + error: spanData.error, + }; + } + + return { + type, + hit: logData.hit, + loading: logData.loading, + title: logData.title, + logDataView: logData.logDataView, + error: logData.error, + }; +}