diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx index fc440197e834917..fff5b465956de77 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx @@ -21,6 +21,7 @@ import { SourcererScopeName } from '../../store/sourcerer/model'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; import type { EntityType } from '../../../../../timelines/common'; +import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; export interface OwnProps { end: string; @@ -79,6 +80,7 @@ const AlertsTableComponent: React.FC = ({ const dispatch = useDispatch(); const alertsFilter = useMemo(() => [...defaultAlertsFilters, ...pageFilters], [pageFilters]); const { filterManager } = useKibana().services.data.query; + const ACTION_BUTTON_COUNT = 3; const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); @@ -104,6 +106,8 @@ const AlertsTableComponent: React.FC = ({ ); }, [dispatch, filterManager, tGridEnabled, timelineId]); + const leadingControlColumns = useMemo(() => getDefaultControlColumn(ACTION_BUTTON_COUNT), []); + return ( = ({ end={endDate} entityType={entityType} id={timelineId} + leadingControlColumns={leadingControlColumns} renderCellValue={DefaultCellRenderer} rowRenderers={defaultRowRenderers} scopeId={SourcererScopeName.default} diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx deleted file mode 100644 index cc94f24d0402483..000000000000000 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ /dev/null @@ -1,485 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { waitFor, act } from '@testing-library/react'; -import useResizeObserver from 'use-resize-observer/polyfilled'; - -import '../../mock/match_media'; -import { mockIndexNames, mockIndexPattern, TestProviders } from '../../mock'; - -import { mockEventViewerResponse, mockEventViewerResponseWithEvents } from './mock'; -import { StatefulEventsViewer } from '.'; -import { EventsViewer } from './events_viewer'; -import { defaultHeaders } from './default_headers'; -import { useSourcererDataView } from '../../containers/sourcerer'; -import { - mockBrowserFields, - mockDocValueFields, - mockRuntimeMappings, -} from '../../containers/source/mock'; -import { eventsDefaultModel } from './default_model'; -import { useMountAppended } from '../../utils/use_mount_appended'; -import { inputsModel } from '../../store/inputs'; -import { TimelineId, SortDirection } from '../../../../common/types/timeline'; -import { KqlMode } from '../../../timelines/store/timeline/model'; -import { EntityType } from '../../../../../timelines/common'; -import { AlertsTableFilterGroup } from '../../../detections/components/alerts_table/alerts_filter_group'; -import { SourcererScopeName } from '../../store/sourcerer/model'; -import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; -import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; -import { useTimelineEvents } from '../../../timelines/containers'; -import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; -import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions'; -import { mockTimelines } from '../../mock/mock_timelines_plugin'; - -jest.mock('../../lib/kibana', () => ({ - useKibana: () => ({ - services: { - application: { - navigateToApp: jest.fn(), - getUrlForApp: jest.fn(), - capabilities: { - siem: { crud_alerts: true, read_alerts: true }, - }, - }, - uiSettings: { - get: jest.fn(), - }, - savedObjects: { - client: {}, - }, - timelines: { ...mockTimelines }, - }, - }), - useToasts: jest.fn().mockReturnValue({ - addError: jest.fn(), - addSuccess: jest.fn(), - addWarning: jest.fn(), - }), - useGetUserCasesPermissions: jest.fn(), - useDateFormat: jest.fn(), - useTimeZone: jest.fn(), -})); - -jest.mock('../../hooks/use_experimental_features'); -const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; - -jest.mock('../../../timelines/components/graph_overlay', () => ({ - GraphOverlay: jest.fn(() =>
), -})); - -const mockDispatch = jest.fn(); -jest.mock('react-redux', () => { - const original = jest.requireActual('react-redux'); - return { - ...original, - useDispatch: () => mockDispatch, - }; -}); - -jest.mock('@elastic/eui', () => { - const original = jest.requireActual('@elastic/eui'); - return { - ...original, - useDataGridColumnSorting: jest.fn(), - }; -}); -jest.mock('../../../timelines/containers', () => ({ - useTimelineEvents: jest.fn(), -})); - -jest.mock('../../components/url_state/normalize_time_range.ts'); - -const mockUseSourcererDataView: jest.Mock = useSourcererDataView as jest.Mock; -jest.mock('../../containers/sourcerer'); - -const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock; -jest.mock('use-resize-observer/polyfilled'); -mockUseResizeObserver.mockImplementation(() => ({})); - -const mockUseTimelineEvents: jest.Mock = useTimelineEvents as jest.Mock; -jest.mock('../../../timelines/containers'); - -const from = '2019-08-26T22:10:56.791Z'; -const to = '2019-08-27T22:10:56.794Z'; - -const defaultMocks = { - browserFields: mockBrowserFields, - docValueFields: mockDocValueFields, - runtimeMappings: mockRuntimeMappings, - indexPattern: mockIndexPattern, - loading: false, - selectedPatterns: mockIndexNames, -}; - -const utilityBar = (refetch: inputsModel.Refetch, totalCount: number) => ( -
-); - -const eventsViewerDefaultProps = { - browserFields: {}, - columns: [], - dataProviders: [], - deletedEventIds: [], - docValueFields: [], - end: to, - entityType: EntityType.ALERTS, - filters: [], - id: TimelineId.detectionsPage, - indexNames: mockIndexNames, - indexPattern: mockIndexPattern, - isLive: false, - isLoadingIndexPattern: false, - itemsPerPage: 10, - itemsPerPageOptions: [], - kqlMode: 'filter' as KqlMode, - query: { - query: '', - language: 'kql', - }, - renderCellValue: DefaultCellRenderer, - rowRenderers: defaultRowRenderers, - runtimeMappings: {}, - start: from, - sort: [ - { - columnId: 'foo', - columnType: 'number', - sortDirection: 'asc' as SortDirection, - }, - ], - scopeId: SourcererScopeName.timeline, - utilityBar, -}; - -describe('EventsViewer', () => { - const mount = useMountAppended(); - - let testProps = { - defaultCellActions, - defaultModel: eventsDefaultModel, - end: to, - entityType: EntityType.ALERTS, - id: TimelineId.test, - renderCellValue: DefaultCellRenderer, - rowRenderers: defaultRowRenderers, - start: from, - scopeId: SourcererScopeName.timeline, - }; - beforeEach(() => { - mockUseTimelineEvents.mockReset(); - }); - beforeAll(() => { - mockUseSourcererDataView.mockImplementation(() => defaultMocks); - }); - - describe('event details', () => { - useIsExperimentalFeatureEnabledMock.mockReturnValue(false); - beforeEach(() => { - mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponseWithEvents]); - }); - - test('call the right reduce action to show event details', async () => { - const wrapper = mount( - - - - ); - - act(() => { - wrapper.find(`[data-test-subj="expand-event"]`).first().simulate('click'); - }); - - await waitFor(() => { - expect(mockDispatch).toBeCalledTimes(3); - expect(mockDispatch.mock.calls[1][0]).toEqual({ - payload: { - id: 'test', - isLoading: false, - }, - type: 'x-pack/timelines/t-grid/UPDATE_LOADING', - }); - }); - }); - }); - - describe('rendering', () => { - beforeEach(() => { - mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]); - }); - - test('it renders the "Showing..." subtitle with the expected event count by default', () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().text()).toEqual( - 'Showing: 12 events' - ); - }); - - test('should not render the "Showing..." subtitle with the expected event count if showTotalCount is set to false ', () => { - const disableSubTitle = { - ...eventsViewerDefaultProps, - showTotalCount: false, - }; - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().text()).toEqual(''); - }); - - test('it renders the Fields Browser as a settings gear', () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="field-browser"]`).first().exists()).toBe(true); - }); - - test('it renders the footer containing the pagination', () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="timeline-pagination"]`).first().exists()).toBe(true); - }); - - defaultHeaders.forEach((header) => { - test(`it renders the ${header.id} default EventsViewer column header`, () => { - testProps = { - ...testProps, - // Update with a new id, to force columns back to default. - id: TimelineId.alternateTest, - }; - const wrapper = mount( - - - - ); - - defaultHeaders.forEach((h) => { - expect(wrapper.find(`[data-test-subj="header-text-${header.id}"]`).first().exists()).toBe( - true - ); - }); - }); - }); - }); - - describe('loading', () => { - beforeAll(() => { - mockUseSourcererDataView.mockImplementation(() => ({ ...defaultMocks, loading: true })); - }); - beforeEach(() => { - mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]); - }); - - test('it does NOT render fetch index pattern is loading', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe( - false - ); - }); - - test('it does NOT render when start is empty', () => { - testProps = { - ...testProps, - start: '', - }; - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe( - false - ); - }); - - test('it does NOT render when end is empty', () => { - testProps = { - ...testProps, - end: '', - }; - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj="header-section-subtitle"]`).first().exists()).toBe( - false - ); - }); - }); - - describe('headerFilterGroup', () => { - beforeEach(() => { - mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]); - }); - - test('it renders the provided headerFilterGroup', () => { - const wrapper = mount( - - - } - /> - - ); - expect(wrapper.find(`[data-test-subj="alerts-table-filter-group"]`).exists()).toBe(true); - }); - - test('it has a visible HeaderFilterGroupWrapper when Resolver is NOT showing, because graphEventId is undefined', () => { - const wrapper = mount( - - - } - /> - - ); - expect( - wrapper.find(`[data-test-subj="header-filter-group-wrapper"]`).first() - ).not.toHaveStyleRule('visibility', 'hidden'); - }); - - test('it has a visible HeaderFilterGroupWrapper when Resolver is NOT showing, because graphEventId is an empty string', () => { - const wrapper = mount( - - - } - /> - - ); - expect( - wrapper.find(`[data-test-subj="header-filter-group-wrapper"]`).first() - ).not.toHaveStyleRule('visibility', 'hidden'); - }); - - test('it does NOT have a visible HeaderFilterGroupWrapper when Resolver is showing, because graphEventId is a valid id', () => { - const wrapper = mount( - - - } - /> - - ); - expect( - wrapper.find(`[data-test-subj="header-filter-group-wrapper"]`).first() - ).toHaveStyleRule('visibility', 'hidden'); - }); - - test('it (still) renders an invisible headerFilterGroup (to maintain state while hidden) when Resolver is showing, because graphEventId is a valid id', () => { - const wrapper = mount( - - - } - /> - - ); - expect(wrapper.find(`[data-test-subj="alerts-table-filter-group"]`).exists()).toBe(true); - }); - }); - - describe('utilityBar', () => { - beforeEach(() => { - mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]); - }); - - test('it renders the provided utilityBar when Resolver is NOT showing, because graphEventId is undefined', () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="mock-utility-bar"]`).exists()).toBe(true); - }); - - test('it renders the provided utilityBar when Resolver is NOT showing, because graphEventId is an empty string', () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="mock-utility-bar"]`).exists()).toBe(true); - }); - - test('it does NOT render the provided utilityBar when Resolver is showing, because graphEventId is a valid id', () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="mock-utility-bar"]`).exists()).toBe(false); - }); - }); - - describe('header inspect button', () => { - beforeEach(() => { - mockUseTimelineEvents.mockReturnValue([false, mockEventViewerResponse]); - }); - - test('it renders the inspect button when Resolver is NOT showing, because graphEventId is undefined', () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="inspect-icon-button"]`).exists()).toBe(true); - }); - - test('it renders the inspect button when Resolver is NOT showing, because graphEventId is an empty string', () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="inspect-icon-button"]`).exists()).toBe(true); - }); - - test('it does NOT render the inspect button when Resolver is showing, because graphEventId is a valid id', () => { - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="inspect-icon-button"]`).exists()).toBe(false); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx deleted file mode 100644 index 5a3aa2e6dc38a61..000000000000000 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ /dev/null @@ -1,395 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; -import React, { useEffect, useMemo, useState } from 'react'; -import styled from 'styled-components'; -import deepEqual from 'fast-deep-equal'; - -import { useDispatch } from 'react-redux'; -import { DataViewBase, Filter, Query } from '@kbn/es-query'; -import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { Direction } from '../../../../common/search_strategy'; -import { BrowserFields, DocValueFields } from '../../containers/source'; -import { useTimelineEvents } from '../../../timelines/containers'; -import { useKibana } from '../../lib/kibana'; -import { KqlMode } from '../../../timelines/store/timeline/model'; -import { HeaderSection } from '../header_section'; -import { defaultHeaders } from '../../../timelines/components/timeline/body/column_headers/default_headers'; -import { Sort } from '../../../timelines/components/timeline/body/sort'; -import { StatefulBody } from '../../../timelines/components/timeline/body'; -import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; -import { Footer, footerHeight } from '../../../timelines/components/timeline/footer'; -import { - calculateTotalPages, - combineQueries, - resolverIsShowing, -} from '../../../timelines/components/timeline/helpers'; -import { TimelineRefetch } from '../../../timelines/components/timeline/refetch_timeline'; -import { EventDetailsWidthProvider } from './event_details_width_context'; -import * as i18n from './translations'; -import { esQuery } from '../../../../../../../src/plugins/data/public'; -import { inputsModel } from '../../store'; -import { ExitFullScreen } from '../exit_full_screen'; -import { useGlobalFullScreen } from '../../containers/use_full_screen'; -import { - ColumnHeaderOptions, - ControlColumnProps, - RowRenderer, - TimelineId, - TimelineTabs, -} from '../../../../common/types/timeline'; -import { GraphOverlay } from '../../../timelines/components/graph_overlay'; -import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; -import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../../../timelines/components/timeline/styles'; -import { timelineSelectors, timelineActions } from '../../../timelines/store/timeline'; -import { useDeepEqualSelector } from '../../hooks/use_selector'; -import { defaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; -import { TimelineContext } from '../../../../../timelines/public'; - -export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px -const UTILITY_BAR_HEIGHT = 19; // px -const COMPACT_HEADER_HEIGHT = EVENTS_VIEWER_HEADER_HEIGHT - UTILITY_BAR_HEIGHT; // px - -const UtilityBar = styled.div` - height: ${UTILITY_BAR_HEIGHT}px; -`; - -const TitleText = styled.span` - margin-right: 12px; -`; - -const StyledEuiPanel = styled(EuiPanel)<{ $isFullScreen: boolean }>` - display: flex; - flex-direction: column; - - ${({ $isFullScreen }) => - $isFullScreen && - ` - border: 0; - box-shadow: none; - padding-top: 0; - padding-bottom: 0; - `} -`; - -const TitleFlexGroup = styled(EuiFlexGroup)` - margin-top: 8px; -`; - -const EventsContainerLoading = styled.div.attrs(({ className = '' }) => ({ - className: `${SELECTOR_TIMELINE_GLOBAL_CONTAINER} ${className}`, -}))` - width: 100%; - overflow: hidden; - flex: 1; - display: flex; - flex-direction: column; -`; - -const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible: boolean }>` - overflow: hidden; - margin: 0; - display: ${({ $visible }) => ($visible ? 'flex' : 'none')}; -`; - -const ScrollableFlexItem = styled(EuiFlexItem)` - overflow: auto; -`; - -/** - * Hides stateful headerFilterGroup implementations, but prevents the component - * from being unmounted, to preserve the state of the component - */ -const HeaderFilterGroupWrapper = styled.header<{ show: boolean }>` - ${({ show }) => (show ? '' : 'visibility: hidden;')} -`; - -interface Props { - browserFields: BrowserFields; - columns: ColumnHeaderOptions[]; - dataProviders: DataProvider[]; - deletedEventIds: Readonly; - docValueFields: DocValueFields[]; - end: string; - filters: Filter[]; - headerFilterGroup?: React.ReactNode; - id: TimelineId; - indexNames: string[]; - indexPattern: DataViewBase; - isLive: boolean; - isLoadingIndexPattern: boolean; - itemsPerPage: number; - itemsPerPageOptions: number[]; - kqlMode: KqlMode; - query: Query; - onRuleChange?: () => void; - renderCellValue: (props: CellValueElementProps) => React.ReactNode; - rowRenderers: RowRenderer[]; - runtimeMappings: MappingRuntimeFields; - start: string; - sort: Sort[]; - showTotalCount?: boolean; - utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; - // If truthy, the graph viewer (Resolver) is showing - graphEventId: string | undefined; -} - -const EventsViewerComponent: React.FC = ({ - browserFields, - columns, - dataProviders, - deletedEventIds, - docValueFields, - end, - filters, - headerFilterGroup, - id, - indexNames, - indexPattern, - isLive, - isLoadingIndexPattern, - itemsPerPage, - itemsPerPageOptions, - kqlMode, - onRuleChange, - query, - renderCellValue, - rowRenderers, - runtimeMappings, - start, - sort, - showTotalCount = true, - utilityBar, - graphEventId, -}) => { - const dispatch = useDispatch(); - const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); - const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; - const kibana = useKibana(); - const [isQueryLoading, setIsQueryLoading] = useState(false); - - useEffect(() => { - dispatch(timelineActions.updateIsLoading({ id, isLoading: isQueryLoading })); - }, [dispatch, id, isQueryLoading]); - - const getManageTimeline = useMemo(() => timelineSelectors.getManageTimelineById(), []); - const unit = useMemo(() => (n: number) => i18n.UNIT(n), []); - const { queryFields, title } = useDeepEqualSelector((state) => getManageTimeline(state, id)); - - const justTitle = useMemo(() => {title}, [title]); - - const titleWithExitFullScreen = useMemo( - () => ( - - {justTitle} - - - - - ), - [globalFullScreen, justTitle, setGlobalFullScreen] - ); - - const combinedQueries = combineQueries({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - dataProviders, - indexPattern, - browserFields, - filters, - kqlQuery: query, - kqlMode, - isEventViewer: true, - }); - - const canQueryTimeline = useMemo( - () => - combinedQueries != null && - isLoadingIndexPattern != null && - !isLoadingIndexPattern && - !isEmpty(start) && - !isEmpty(end), - [isLoadingIndexPattern, combinedQueries, start, end] - ); - - const fields = useMemo( - () => [...columnsHeader.map((c) => c.id), ...(queryFields ?? [])], - [columnsHeader, queryFields] - ); - - const sortField = useMemo( - () => - sort.map(({ columnId, columnType, sortDirection }) => ({ - field: columnId, - type: columnType, - direction: sortDirection as Direction, - })), - [sort] - ); - - const [loading, { events, updatedAt, inspect, loadPage, pageInfo, refetch, totalCount = 0 }] = - useTimelineEvents({ - docValueFields, - fields, - filterQuery: combinedQueries?.filterQuery, - id, - indexNames, - limit: itemsPerPage, - runtimeMappings, - sort: sortField, - startDate: start, - endDate: end, - skip: !canQueryTimeline || combinedQueries?.filterQuery === undefined, // When the filterQuery comes back as undefined, it means an error has been thrown and the request should be skipped - }); - - const totalCountMinusDeleted = useMemo( - () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), - [deletedEventIds.length, totalCount] - ); - - const subtitle = useMemo( - () => - showTotalCount - ? `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit( - totalCountMinusDeleted - )}` - : null, - [showTotalCount, totalCountMinusDeleted, unit] - ); - - const nonDeletedEvents = useMemo( - () => events.filter((e) => !deletedEventIds.includes(e._id)), - [deletedEventIds, events] - ); - - const HeaderSectionContent = useMemo( - () => - headerFilterGroup && ( - - {headerFilterGroup} - - ), - [graphEventId, headerFilterGroup] - ); - - useEffect(() => { - setIsQueryLoading(loading); - }, [loading]); - - const leadingControlColumns: ControlColumnProps[] = [defaultControlColumn]; - const trailingControlColumns: ControlColumnProps[] = []; - const timelineContext = useMemo(() => ({ timelineId: id }), [id]); - return ( - - {canQueryTimeline ? ( - - <> - - {HeaderSectionContent} - - {utilityBar && !resolverIsShowing(graphEventId) && ( - {utilityBar?.(refetch, totalCountMinusDeleted)} - )} - - - - - {graphEventId && } - - - -