From 675d0d28d24e6f19af7090ff2c767c09876e7fe4 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 9 Dec 2021 11:08:31 -0800 Subject: [PATCH] Job Output expand/collapse take 2 (#11312) --- awx/ui/src/api/index.js | 3 + awx/ui/src/api/models/JobEvents.js | 14 + awx/ui/src/screens/Job/JobOutput/JobEvent.js | 78 +- .../screens/Job/JobOutput/JobEvent.test.js | 57 +- .../screens/Job/JobOutput/JobEventSkeleton.js | 8 +- .../Job/JobOutput/JobEventSkeleton.test.js | 6 +- awx/ui/src/screens/Job/JobOutput/JobOutput.js | 474 +++-- .../screens/Job/JobOutput/JobOutput.test.js | 186 +- .../Job/JobOutput/JobOutputSearch.test.js | 48 + .../Job/JobOutput/getEventRequestParams.js | 14 +- .../JobOutput/getEventRequestParams.test.js | 47 + .../screens/Job/JobOutput/getLineTextHtml.js | 4 +- .../screens/Job/JobOutput/loadJobEvents.js | 65 +- .../Job/JobOutput/shared/JobEventEllipsis.js | 20 + .../Job/JobOutput/shared/JobEventLine.js | 4 - .../JobOutput/shared/JobEventLineToggle.js | 39 +- .../src/screens/Job/JobOutput/shared/index.js | 1 + .../src/screens/Job/JobOutput/useJobEvents.js | 498 +++++ .../Job/JobOutput/useJobEvents.test.js | 1643 +++++++++++++++++ 19 files changed, 2799 insertions(+), 410 deletions(-) create mode 100644 awx/ui/src/api/models/JobEvents.js create mode 100644 awx/ui/src/screens/Job/JobOutput/JobOutputSearch.test.js create mode 100644 awx/ui/src/screens/Job/JobOutput/getEventRequestParams.test.js create mode 100644 awx/ui/src/screens/Job/JobOutput/shared/JobEventEllipsis.js create mode 100644 awx/ui/src/screens/Job/JobOutput/useJobEvents.js create mode 100644 awx/ui/src/screens/Job/JobOutput/useJobEvents.test.js diff --git a/awx/ui/src/api/index.js b/awx/ui/src/api/index.js index ff2d0e6fba..a098f28781 100644 --- a/awx/ui/src/api/index.js +++ b/awx/ui/src/api/index.js @@ -18,6 +18,7 @@ import InventorySources from './models/InventorySources'; import InventoryUpdates from './models/InventoryUpdates'; import JobTemplates from './models/JobTemplates'; import Jobs from './models/Jobs'; +import JobEvents from './models/JobEvents'; import Labels from './models/Labels'; import Me from './models/Me'; import Metrics from './models/Metrics'; @@ -63,6 +64,7 @@ const InventorySourcesAPI = new InventorySources(); const InventoryUpdatesAPI = new InventoryUpdates(); const JobTemplatesAPI = new JobTemplates(); const JobsAPI = new Jobs(); +const JobEventsAPI = new JobEvents(); const LabelsAPI = new Labels(); const MeAPI = new Me(); const MetricsAPI = new Metrics(); @@ -109,6 +111,7 @@ export { InventoryUpdatesAPI, JobTemplatesAPI, JobsAPI, + JobEventsAPI, LabelsAPI, MeAPI, MetricsAPI, diff --git a/awx/ui/src/api/models/JobEvents.js b/awx/ui/src/api/models/JobEvents.js new file mode 100644 index 0000000000..dad879af89 --- /dev/null +++ b/awx/ui/src/api/models/JobEvents.js @@ -0,0 +1,14 @@ +import Base from '../Base'; + +class JobEvents extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/job_events/'; + } + + readChildren(id, params) { + return this.http.get(`${this.baseUrl}${id}/children/`, { params }); + } +} + +export default JobEvents; diff --git a/awx/ui/src/screens/Job/JobOutput/JobEvent.js b/awx/ui/src/screens/Job/JobOutput/JobEvent.js index 29f02e8245..3516f24749 100644 --- a/awx/ui/src/screens/Job/JobOutput/JobEvent.js +++ b/awx/ui/src/screens/Job/JobOutput/JobEvent.js @@ -1,42 +1,68 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { JobEventLine, JobEventLineToggle, JobEventLineNumber, JobEventLineText, + JobEventEllipsis, } from './shared'; function JobEvent({ - counter, - stdout, style, - type, lineTextHtml, isClickable, onJobEventClick, + event, + measure, + isCollapsed, + onToggleCollapsed, + hasChildren, }) { - return !stdout ? null : ( -
- {lineTextHtml.map( - ({ lineNumber, html }) => - lineNumber >= 0 && ( - - - {lineNumber} - - - ) - )} + const numOutputLines = lineTextHtml?.length || 0; + useEffect(() => { + measure(); + }, [numOutputLines, isCollapsed, measure]); + + let toggleLineIndex = -1; + if (hasChildren) { + lineTextHtml.forEach(({ html }, index) => { + if (html) { + toggleLineIndex = index; + } + }); + } + return !event.stdout ? null : ( +
+ {lineTextHtml.map(({ lineNumber, html }, index) => { + if (lineNumber < 0) { + return null; + } + const canToggle = index === toggleLineIndex; + return ( + + + + {lineNumber} + + + + + ); + })}
); } diff --git a/awx/ui/src/screens/Job/JobOutput/JobEvent.test.js b/awx/ui/src/screens/Job/JobOutput/JobEvent.test.js index 310c1910a2..f2a8cf8c8f 100644 --- a/awx/ui/src/screens/Job/JobOutput/JobEvent.test.js +++ b/awx/ui/src/screens/Job/JobOutput/JobEvent.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { shallow } from 'enzyme'; import JobEvent from './JobEvent'; const mockOnPlayStartEvent = { @@ -19,9 +19,6 @@ const mockRunnerOnOkEvent = { end_line: 5, stdout: '\u001b[0;32mok: [localhost]\u001b[0m', }; -const selectors = { - lineText: 'JobEventLineText', -}; const singleDigitTimestampEvent = { ...mockOnPlayStartEvent, @@ -52,55 +49,51 @@ const mockOnPlayStartLineTextHtml = [ ]; describe('', () => { - test('initially renders successfully', () => { - mountWithContexts( - - ); - }); - test('playbook event timestamps are rendered', () => { - let wrapper = mountWithContexts( + const wrapper1 = shallow( ); - let lineText = wrapper.find(selectors.lineText); - expect( - lineText.filterWhere((e) => e.text().includes('18:11:22')) - ).toHaveLength(1); + const lineText1 = wrapper1.find('JobEventLineText'); + const html1 = lineText1.at(1).prop('dangerouslySetInnerHTML').__html; + expect(html1.includes('18:11:22')).toBe(true); - wrapper = mountWithContexts( + const wrapper2 = shallow( ); - lineText = wrapper.find(selectors.lineText); - expect( - lineText.filterWhere((e) => e.text().includes('08:01:02')) - ).toHaveLength(1); + const lineText2 = wrapper2.find('JobEventLineText'); + const html2 = lineText2.at(1).prop('dangerouslySetInnerHTML').__html; + expect(html2.includes('08:01:02')).toBe(true); }); test('ansi stdout colors are rendered as html', () => { - const wrapper = mountWithContexts( - + const wrapper = shallow( + ); - const lineText = wrapper.find(selectors.lineText); + const lineText = wrapper.find('JobEventLineText'); expect( lineText - .html() - .includes('ok: [localhost]') + .prop('dangerouslySetInnerHTML') + .__html.includes( + 'ok: [localhost]' + ) ).toBe(true); }); test("events without stdout aren't rendered", () => { const missingStdoutEvent = { ...mockOnPlayStartEvent }; delete missingStdoutEvent.stdout; - const wrapper = mountWithContexts(); - expect(wrapper.find(selectors.lineText)).toHaveLength(0); + const wrapper = shallow( + + ); + expect(wrapper.find('JobEventLineText')).toHaveLength(0); }); }); diff --git a/awx/ui/src/screens/Job/JobOutput/JobEventSkeleton.js b/awx/ui/src/screens/Job/JobOutput/JobEventSkeleton.js index 1cae0c74df..2ecc66d856 100644 --- a/awx/ui/src/screens/Job/JobOutput/JobEventSkeleton.js +++ b/awx/ui/src/screens/Job/JobOutput/JobEventSkeleton.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { JobEventLine, JobEventLineToggle, @@ -14,7 +14,11 @@ function JobEventSkeletonContent({ contentLength }) { ); } -function JobEventSkeleton({ counter, contentLength, style }) { +function JobEventSkeleton({ counter, contentLength, style, measure }) { + useEffect(() => { + measure(); + }, [measure]); + return ( counter > 1 && (
diff --git a/awx/ui/src/screens/Job/JobOutput/JobEventSkeleton.test.js b/awx/ui/src/screens/Job/JobOutput/JobEventSkeleton.test.js index b4a069e037..dac7d18d4c 100644 --- a/awx/ui/src/screens/Job/JobOutput/JobEventSkeleton.test.js +++ b/awx/ui/src/screens/Job/JobOutput/JobEventSkeleton.test.js @@ -5,17 +5,17 @@ import JobEventSkeleton from './JobEventSkeleton'; const contentSelector = 'JobEventSkeletonContent'; -describe('', () => { +describe('', () => { test('initially renders successfully', () => { const wrapper = mountWithContexts( - + ); expect(wrapper.find(contentSelector).length).toEqual(1); }); test('always skips first counter', () => { const wrapper = mountWithContexts( - + ); expect(wrapper.find(contentSelector).length).toEqual(0); }); diff --git a/awx/ui/src/screens/Job/JobOutput/JobOutput.js b/awx/ui/src/screens/Job/JobOutput/JobOutput.js index 15bf7e83af..4e48fc169b 100644 --- a/awx/ui/src/screens/Job/JobOutput/JobOutput.js +++ b/awx/ui/src/screens/Job/JobOutput/JobOutput.js @@ -17,6 +17,7 @@ import ContentError from 'components/ContentError'; import ContentLoading from 'components/ContentLoading'; import ErrorDetail from 'components/ErrorDetail'; import StatusIcon from 'components/StatusIcon'; +import { JobEventsAPI } from 'api'; import { getJobModel, isJobRunning } from 'util/jobs'; import useRequest, { useDismissableError } from 'hooks/useRequest'; @@ -33,7 +34,8 @@ import getLineTextHtml from './getLineTextHtml'; import connectJobSocket, { closeWebSocket } from './connectJobSocket'; import getEventRequestParams from './getEventRequestParams'; import isHostEvent from './isHostEvent'; -import { fetchCount, normalizeEvents } from './loadJobEvents'; +import { prependTraceback } from './loadJobEvents'; +import useJobEvents from './useJobEvents'; const QS_CONFIG = getQSConfig('job_output', { order_by: 'counter', @@ -94,20 +96,113 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { const scrollTop = useRef(0); const scrollHeight = useRef(0); const history = useHistory(); - const [contentError, setContentError] = useState(null); + const eventByUuidRequests = useRef([]); + const siblingRequests = useRef([]); + const numEventsRequests = useRef([]); + + const fetchEventByUuid = async (uuid) => { + let promise = eventByUuidRequests.current[uuid]; + if (!promise) { + promise = getJobModel(job.type).readEvents(job.id, { uuid }); + eventByUuidRequests.current[uuid] = promise; + } + const { data } = await promise; + eventByUuidRequests.current[uuid] = null; + return data.results[0] || null; + }; + + const fetchNextSibling = async (parentEventId, counter) => { + const key = `${parentEventId}-${counter}`; + let promise = siblingRequests.current[key]; + if (!promise) { + promise = JobEventsAPI.readChildren(parentEventId, { + page_size: 1, + order_by: 'counter', + counter__gt: counter, + }); + siblingRequests.current[key] = promise; + } + + const { data } = await promise; + siblingRequests.current[key] = null; + return data.results[0] || null; + }; + + const fetchNextRootNode = async (counter) => { + const { data } = await getJobModel(job.type).readEvents(job.id, { + page_size: 1, + order_by: 'counter', + counter__gt: counter, + parent_uuid: '', + }); + return data.results[0] || null; + }; + + const fetchNumEvents = async (startCounter, endCounter) => { + if (endCounter <= startCounter + 1) { + return 0; + } + const key = `${startCounter}-${endCounter}`; + let promise = numEventsRequests.current[key]; + if (!promise) { + const params = { + page_size: 1, + order_by: 'counter', + counter__gt: startCounter, + }; + if (endCounter) { + params.counter__lt = endCounter; + } + promise = getJobModel(job.type).readEvents(job.id, params); + numEventsRequests.current[key] = promise; + } + + const { data } = await promise; + numEventsRequests.current[key] = null; + return data.count || 0; + }; + + const [jobStatus, setJobStatus] = useState(job.status ?? 'waiting'); + const isFlatMode = isJobRunning(jobStatus) || location.search.length > 1; + + const { + addEvents, + toggleNodeIsCollapsed, + getEventForRow, + getNumCollapsedEvents, + getCounterForRow, + getEvent, + clearLoadedEvents, + rebuildEventsTree, + } = useJobEvents( + { + fetchEventByUuid, + fetchNextSibling, + fetchNextRootNode, + fetchNumEvents, + }, + isFlatMode + ); + const [wsEvents, setWsEvents] = useState([]); const [cssMap, setCssMap] = useState({}); + const [remoteRowCount, setRemoteRowCount] = useState(0); + const [contentError, setContentError] = useState(null); const [currentlyLoading, setCurrentlyLoading] = useState([]); const [hasContentLoading, setHasContentLoading] = useState(true); const [hostEvent, setHostEvent] = useState({}); const [isHostModalOpen, setIsHostModalOpen] = useState(false); - const [jobStatus, setJobStatus] = useState(job.status ?? 'waiting'); const [showCancelModal, setShowCancelModal] = useState(false); - const [remoteRowCount, setRemoteRowCount] = useState(0); - const [results, setResults] = useState({}); + const [highestLoadedCounter, setHighestLoadedCounter] = useState(0); const [isFollowModeEnabled, setIsFollowModeEnabled] = useState( isJobRunning(job.status) ); const [isMonitoringWebsocket, setIsMonitoringWebsocket] = useState(false); + const [lastScrollPosition, setLastScrollPosition] = useState(0); + + const totalNonCollapsedRows = Math.max( + remoteRowCount - getNumCollapsedEvents(), + 0 + ); useInterval( () => { @@ -117,53 +212,116 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { ); useEffect(() => { - loadJobEvents(); + const pendingRequests = [ + ...Object.values(eventByUuidRequests.current || {}), + ...Object.values(siblingRequests.current || {}), + ...Object.values(numEventsRequests.current || {}), + ]; + Promise.all(pendingRequests).then(() => { + setRemoteRowCount(0); + clearLoadedEvents(); + loadJobEvents(); + }); + }, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps - if (isJobRunning(job.status)) { - connectJobSocket(job, (data) => { - if (data.group_name === 'job_events') { - if (data.counter && data.counter > jobSocketCounter.current) { - jobSocketCounter.current = data.counter; - } - } - if (data.group_name === 'jobs' && data.unified_job_id === job.id) { - if (data.final_counter) { - jobSocketCounter.current = data.final_counter; - } - if (data.status) { - setJobStatus(data.status); - } - } - }); - setIsMonitoringWebsocket(true); + useEffect(() => { + if (!isJobRunning(jobStatus)) { + setIsFollowModeEnabled(false); } + rebuildEventsTree(); + }, [isFlatMode]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (!isJobRunning(jobStatus)) { + setTimeout(() => { + loadJobEvents().then(() => { + setWsEvents([]); + scrollToRow(lastScrollPosition); + }); + }, 250); + return; + } + let batchTimeout; + let batchedEvents = []; + connectJobSocket(job, (data) => { + const addBatchedEvents = () => { + let min; + let max; + let newCssMap; + batchedEvents.forEach((event) => { + if (!min || event.counter < min) { + min = event.counter; + } + if (!max || event.counter > max) { + max = event.counter; + } + const { lineCssMap } = getLineTextHtml(event); + newCssMap = { + ...newCssMap, + ...lineCssMap, + }; + }); + setWsEvents((oldWsEvents) => { + const updated = oldWsEvents.concat(batchedEvents); + jobSocketCounter.current = updated.length; + return updated.sort((a, b) => a.counter - b.counter); + }); + setCssMap((prevCssMap) => ({ + ...prevCssMap, + ...newCssMap, + })); + if (max > jobSocketCounter.current) { + jobSocketCounter.current = max; + } + batchedEvents = []; + }; + + if (data.group_name === 'job_events') { + batchedEvents.push(data); + clearTimeout(batchTimeout); + if (batchedEvents.length >= 25) { + addBatchedEvents(); + } else { + batchTimeout = setTimeout(addBatchedEvents, 500); + } + } + if (data.group_name === 'jobs' && data.unified_job_id === job.id) { + if (data.final_counter) { + jobSocketCounter.current = data.final_counter; + } + if (data.status) { + setJobStatus(data.status); + } + } + }); + setIsMonitoringWebsocket(true); + + // eslint-disable-next-line consistent-return return function cleanup() { + clearTimeout(batchTimeout); closeWebSocket(); setIsMonitoringWebsocket(false); isMounted.current = false; }; - }, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isJobRunning(jobStatus)]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (listRef.current?.recomputeRowHeights) { listRef.current.recomputeRowHeights(); } - }, [currentlyLoading, cssMap, remoteRowCount]); + }, [currentlyLoading, cssMap, remoteRowCount, wsEvents.length]); useEffect(() => { - if (jobStatus && !isJobRunning(jobStatus)) { - if (jobSocketCounter.current > remoteRowCount && isMounted.current) { - setRemoteRowCount(jobSocketCounter.current); - } + if (!jobStatus || isJobRunning(jobStatus)) { + return; + } - if (isMonitoringWebsocket) { - setIsMonitoringWebsocket(false); - } + if (isMonitoringWebsocket) { + setIsMonitoringWebsocket(false); + } - if (isFollowModeEnabled) { - setTimeout(() => setIsFollowModeEnabled(false), 1000); - } + if (isFollowModeEnabled) { + setTimeout(() => setIsFollowModeEnabled(false), 1000); } }, [jobStatus]); // eslint-disable-line react-hooks/exhaustive-deps @@ -197,9 +355,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { useDismissableError(deleteError); const monitorJobSocketCounter = () => { - if (jobSocketCounter.current > remoteRowCount && isMounted.current) { - setRemoteRowCount(jobSocketCounter.current); - } if ( jobSocketCounter.current === remoteRowCount && !isJobRunning(job.status) @@ -218,34 +373,43 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { ); } + if (isFlatMode) { + params.not__stdout = ''; + } + const qsParams = parseQueryString(QS_CONFIG, location.search); const eventPromise = getJobModel(job.type).readEvents(job.id, { ...params, - ...parseQueryString(QS_CONFIG, location.search), + ...qsParams, }); try { - const [ - { - data: { results: fetchedEvents = [] }, - }, - count, - ] = await Promise.all([eventPromise, fetchCount(job, eventPromise)]); + const { + data: { count, results: fetchedEvents = [] }, + } = await eventPromise; if (!isMounted.current) { return; } - const { events, countOffset } = normalizeEvents(job, fetchedEvents); - - const newResults = {}; - let newResultsCssMap = {}; - events.forEach((jobEvent, index) => { - newResults[index] = jobEvent; - const { lineCssMap } = getLineTextHtml(jobEvent); - newResultsCssMap = { ...newResultsCssMap, ...lineCssMap }; + let newCssMap; + let rowNumber = 0; + const { events, countOffset } = prependTraceback(job, fetchedEvents); + events.forEach((event) => { + event.rowNumber = rowNumber; + rowNumber++; + const { lineCssMap } = getLineTextHtml(event); + newCssMap = { + ...newCssMap, + ...lineCssMap, + }; }); - setResults(newResults); + setCssMap((prevCssMap) => ({ + ...prevCssMap, + ...newCssMap, + })); + const lastCounter = events[events.length - 1]?.counter || 50; + addEvents(events); + setHighestLoadedCounter(lastCounter); setRemoteRowCount(count + countOffset); - setCssMap(newResultsCssMap); } catch (err) { setContentError(err); } finally { @@ -262,10 +426,20 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { }; const isRowLoaded = ({ index }) => { - if (results[index]) { + let counter; + try { + counter = getCounterForRow(index); + } catch (e) { + console.error(e); // eslint-disable-line no-console + return false; + } + if (getEvent(counter)) { return true; } - return currentlyLoading.includes(index); + if (index > remoteRowCount && index < remoteRowCount + wsEvents.length) { + return true; + } + return currentlyLoading.includes(counter); }; const handleHostEventClick = (hostEventToOpen) => { @@ -281,9 +455,30 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { if (listRef.current && isFollowModeEnabled) { setTimeout(() => scrollToRow(remoteRowCount - 1), 0); } + let event; + let node; + try { + const eventForRow = getEventForRow(index) || {}; + event = eventForRow.event; + node = eventForRow.node; + } catch (e) { + event = null; + } + if ( + !event && + index > remoteRowCount && + index < remoteRowCount + wsEvents.length + ) { + event = wsEvents[index - remoteRowCount]; + node = { + eventIndex: event?.counter, + isCollapsed: false, + children: [], + }; + } let actualLineTextHtml = []; - if (results[index]) { - const { lineTextHtml } = getLineTextHtml(results[index]); + if (event) { + const { lineTextHtml } = getLineTextHtml(event); actualLineTextHtml = lineTextHtml; } @@ -295,86 +490,120 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { rowIndex={index} columnIndex={0} > - {results[index] ? ( - handleHostEventClick(results[index])} - className="row" - style={style} - lineTextHtml={actualLineTextHtml} - index={index} - {...results[index]} - /> - ) : ( - - )} + {({ measure }) => + event ? ( + handleHostEventClick(event)} + className="row" + style={style} + lineTextHtml={actualLineTextHtml} + index={index} + event={event} + measure={measure} + isCollapsed={node.isCollapsed} + hasChildren={node.children.length} + onToggleCollapsed={() => { + toggleNodeIsCollapsed(event.uuid); + }} + /> + ) : ( + + ) + } ); }; - const loadMoreRows = ({ startIndex, stopIndex }) => { + const loadMoreRows = async ({ startIndex, stopIndex }) => { + if (!isMounted.current) { + return; + } if (startIndex === 0 && stopIndex === 0) { - return Promise.resolve(null); + return; } - if (stopIndex > startIndex + 50) { - stopIndex = startIndex + 50; - } - - const [requestParams, loadRange, firstIndex] = getEventRequestParams( - job, - remoteRowCount, - [startIndex, stopIndex] - ); - if (isMounted.current) { setCurrentlyLoading((prevCurrentlyLoading) => prevCurrentlyLoading.concat(loadRange) ); } + let range = [startIndex, stopIndex]; + if (!isFlatMode) { + const diff = stopIndex - startIndex; + const startCounter = getCounterForRow(startIndex); + range = [startCounter, startCounter + diff]; + } + + const [requestParams, loadRange] = getEventRequestParams( + job, + remoteRowCount, + range + ); + const qs = parseQueryString(QS_CONFIG, location.search); const params = { ...requestParams, - ...parseQueryString(QS_CONFIG, location.search), + ...qs, }; + if (isFlatMode) { + params.not__stdout = ''; + } - return getJobModel(job.type) - .readEvents(job.id, params) - .then((response) => { - if (!isMounted.current) { - return; - } + const model = getJobModel(job.type); - const newResults = {}; - let newResultsCssMap = {}; - response.data.results.forEach((jobEvent, index) => { - newResults[firstIndex + index] = jobEvent; - const { lineCssMap } = getLineTextHtml(jobEvent); - newResultsCssMap = { ...newResultsCssMap, ...lineCssMap }; - }); - setResults((prevResults) => ({ - ...prevResults, - ...newResults, - })); - setCssMap((prevCssMap) => ({ - ...prevCssMap, - ...newResultsCssMap, - })); - setCurrentlyLoading((prevCurrentlyLoading) => - prevCurrentlyLoading.filter((n) => !loadRange.includes(n)) - ); - loadRange.forEach((n) => { - cache.clear(n); - }); - }); + let response; + try { + response = await model.readEvents(job.id, params); + } catch (error) { + if (error.response.status === 404) { + return; + } + throw error; + } + if (!isMounted.current) { + return; + } + const events = response.data.results; + const firstIndex = (params.page - 1) * params.page_size; + + let newCssMap; + let rowNumber = firstIndex; + events.forEach((event) => { + event.rowNumber = rowNumber; + rowNumber++; + const { lineCssMap } = getLineTextHtml(event); + newCssMap = { + ...newCssMap, + ...lineCssMap, + }; + }); + setCssMap((prevCssMap) => ({ + ...prevCssMap, + ...newCssMap, + })); + + const lastCounter = events[events.length - 1]?.counter || 50; + addEvents(events); + if (lastCounter > highestLoadedCounter) { + setHighestLoadedCounter(lastCounter); + } + setCurrentlyLoading((prevCurrentlyLoading) => + prevCurrentlyLoading.filter((n) => !loadRange.includes(n)) + ); + loadRange.forEach((n) => { + cache.clear(n); + }); }; const scrollToRow = (rowIndex) => { + setLastScrollPosition(rowIndex); if (listRef.current) { listRef.current.scrollToRow(rowIndex); } @@ -385,6 +614,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { const stopIndex = listRef.current.Grid._renderedRowStopIndex; const scrollRange = stopIndex - startIndex + 1; scrollToRow(Math.max(0, startIndex - scrollRange)); + setIsFollowModeEnabled(false); }; const handleScrollNext = () => { @@ -394,10 +624,11 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { const handleScrollFirst = () => { scrollToRow(0); + setIsFollowModeEnabled(false); }; const handleScrollLast = () => { - scrollToRow(remoteRowCount - 1); + scrollToRow(totalNonCollapsedRows + wsEvents.length); }; const handleResize = ({ width }) => { @@ -470,7 +701,8 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { {({ onRowsRendered, registerChild }) => ( @@ -489,7 +721,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) { deferredMeasurementCache={cache} height={height || 1} onRowsRendered={onRowsRendered} - rowCount={remoteRowCount} + rowCount={totalNonCollapsedRows + wsEvents.length} rowHeight={cache.rowHeight} rowRenderer={rowRenderer} scrollToAlignment="start" diff --git a/awx/ui/src/screens/Job/JobOutput/JobOutput.test.js b/awx/ui/src/screens/Job/JobOutput/JobOutput.test.js index 58c8897061..03797f1ef8 100644 --- a/awx/ui/src/screens/Job/JobOutput/JobOutput.test.js +++ b/awx/ui/src/screens/Job/JobOutput/JobOutput.test.js @@ -1,7 +1,7 @@ /* eslint-disable max-len */ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { JobsAPI } from 'api'; +import { JobsAPI, JobEventsAPI } from 'api'; import { mountWithContexts, waitForElement, @@ -9,7 +9,6 @@ import { import JobOutput from './JobOutput'; import mockJobData from '../shared/data.job.json'; import mockJobEventsData from './data.job_events.json'; -import mockFilteredJobEventsData from './data.filtered_job_events.json'; jest.mock('../../../api'); @@ -27,73 +26,17 @@ const applyJobEventMock = (mockJobEvents) => { }; }; JobsAPI.readEvents = jest.fn().mockImplementation(mockReadEvents); -}; - -const generateChattyRows = () => { - const rows = [ - '', - 'PLAY [all] *********************************************************************16:17:13', - '', - 'TASK [debug] *******************************************************************16:17:13', - ]; - - for (let i = 1; i < 95; i++) { - rows.push( - `ok: [localhost] => (item=${i}) => {`, - ` "msg": "This is a debug message: ${i}"`, - '}' - ); - } - - rows.push( - '', - 'PLAY RECAP *********************************************************************16:17:15', - 'localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ', - '' - ); - - return rows; -}; - -async function checkOutput(wrapper, expectedLines) { - await waitForElement(wrapper, 'div[type="job_event"]', (el) => el.length > 1); - const jobEventLines = wrapper.find('JobEventLineText div'); - const actualLines = []; - jobEventLines.forEach((line) => { - actualLines.push(line.text()); + JobEventsAPI.readChildren = jest.fn().mockResolvedValue({ + data: { + results: [ + { + counter: 20, + uuid: 'abc-020', + }, + ], + }, }); - - expect( - wrapper.find('JobEvent[event="playbook_on_stats"]').prop('end_line') - ).toEqual(expectedLines.length); -} - -async function findScrollButtons(wrapper) { - const pageControls = await waitForElement(wrapper, 'PageControls'); - const scrollFirstButton = pageControls.find( - 'button[aria-label="Scroll first"]' - ); - const scrollLastButton = pageControls.find( - 'button[aria-label="Scroll last"]' - ); - const scrollPreviousButton = pageControls.find( - 'button[aria-label="Scroll previous"]' - ); - return { - scrollFirstButton, - scrollLastButton, - scrollPreviousButton, - }; -} - -const originalOffsetHeight = Object.getOwnPropertyDescriptor( - HTMLElement.prototype, - 'offsetHeight' -); -const originalOffsetWidth = Object.getOwnPropertyDescriptor( - HTMLElement.prototype, - 'offsetWidth' -); +}; describe('', () => { let wrapper; @@ -101,88 +44,18 @@ describe('', () => { beforeEach(() => { applyJobEventMock(mockJobEventsData); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('initially renders successfully', async () => { - await act(async () => { - wrapper = mountWithContexts(); - }); - await waitForElement(wrapper, 'JobEvent', (el) => el.length > 0); - await checkOutput(wrapper, generateChattyRows()); - - expect(wrapper.find('JobOutput').length).toBe(1); - }); - - test('navigation buttons should display output properly', async () => { Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, - value: 10, + value: 200, }); Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 100, }); - await act(async () => { - wrapper = mountWithContexts(); - }); - await waitForElement(wrapper, 'JobEvent', (el) => el.length > 0); - const { scrollFirstButton, scrollLastButton, scrollPreviousButton } = - await findScrollButtons(wrapper); - let jobEvents = wrapper.find('JobEvent'); - expect(jobEvents.at(0).prop('stdout')).toBe(''); - expect(jobEvents.at(1).prop('stdout')).toBe( - '\r\nPLAY [all] *********************************************************************' - ); - await act(async () => { - scrollLastButton.simulate('click'); - }); - wrapper.update(); - jobEvents = wrapper.find('JobEvent'); - expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe( - '\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n' - ); - await act(async () => { - scrollPreviousButton.simulate('click'); - }); - wrapper.update(); - jobEvents = wrapper.find('JobEvent'); - expect(jobEvents.at(0).prop('stdout')).toBe( - '\u001b[0;32mok: [localhost] => (item=76) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 76"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' - ); - expect(jobEvents.at(1).prop('stdout')).toBe( - '\u001b[0;32mok: [localhost] => (item=77) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 77"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' - ); - await act(async () => { - scrollFirstButton.simulate('click'); - }); - wrapper.update(); - jobEvents = wrapper.find('JobEvent'); - expect(jobEvents.at(0).prop('stdout')).toBe(''); - expect(jobEvents.at(1).prop('stdout')).toBe( - '\r\nPLAY [all] *********************************************************************' - ); - await act(async () => { - scrollLastButton.simulate('click'); - }); - wrapper.update(); - jobEvents = wrapper.find('JobEvent'); - expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe( - '\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n' - ); - Object.defineProperty( - HTMLElement.prototype, - 'offsetHeight', - originalOffsetHeight - ); - Object.defineProperty( - HTMLElement.prototype, - 'offsetWidth', - originalOffsetWidth - ); + }); + + afterEach(() => { + jest.clearAllMocks(); }); test('should make expected api call for delete', async () => { @@ -260,33 +133,6 @@ describe('', () => { expect(wrapper.find('Search').props().isDisabled).toBe(true); }); - test('filter should trigger api call and display correct rows', async () => { - const searchBtn = 'button[aria-label="Search submit button"]'; - const searchTextInput = 'input[aria-label="Search text input"]'; - await act(async () => { - wrapper = mountWithContexts(); - }); - await waitForElement(wrapper, 'JobEvent', (el) => el.length > 0); - applyJobEventMock(mockFilteredJobEventsData); - await act(async () => { - wrapper.find(searchTextInput).instance().value = '99'; - wrapper.find(searchTextInput).simulate('change'); - }); - wrapper.update(); - await act(async () => { - wrapper.find(searchBtn).simulate('click'); - }); - wrapper.update(); - expect(JobsAPI.readEvents).toHaveBeenCalled(); - const jobEvents = wrapper.find('JobEvent'); - expect(jobEvents.at(0).prop('stdout')).toBe( - '\u001b[0;32mok: [localhost] => (item=99) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 99"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' - ); - expect(jobEvents.at(1).prop('stdout')).toBe( - '\u001b[0;32mok: [localhost] => (item=199) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 199"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' - ); - }); - test('should throw error', async () => { JobsAPI.readEvents = () => Promise.reject(new Error()); await act(async () => { diff --git a/awx/ui/src/screens/Job/JobOutput/JobOutputSearch.test.js b/awx/ui/src/screens/Job/JobOutput/JobOutputSearch.test.js new file mode 100644 index 0000000000..e06b575749 --- /dev/null +++ b/awx/ui/src/screens/Job/JobOutput/JobOutputSearch.test.js @@ -0,0 +1,48 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import JobOutputSearch from './JobOutputSearch'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + history: () => ({ + location: '/jobs/playbook/1/output', + }), +})); + +describe('JobOutputSearch', () => { + test('should update url query params', async () => { + const searchBtn = 'button[aria-label="Search submit button"]'; + const searchTextInput = 'input[aria-label="Search text input"]'; + const history = createMemoryHistory({ + initialEntries: ['/jobs/playbook/1/output'], + }); + + const wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + + await act(async () => { + wrapper.find(searchTextInput).instance().value = '99'; + wrapper.find(searchTextInput).simulate('change'); + }); + wrapper.update(); + await act(async () => { + wrapper.find(searchBtn).simulate('click'); + }); + + expect(history.location.search).toEqual('?stdout__icontains=99'); + }); +}); diff --git a/awx/ui/src/screens/Job/JobOutput/getEventRequestParams.js b/awx/ui/src/screens/Job/JobOutput/getEventRequestParams.js index 07ac49a768..2555f598ec 100644 --- a/awx/ui/src/screens/Job/JobOutput/getEventRequestParams.js +++ b/awx/ui/src/screens/Job/JobOutput/getEventRequestParams.js @@ -1,4 +1,3 @@ -import { isJobRunning } from 'util/jobs'; import getRowRangePageSize from './shared/jobOutputUtils'; export default function getEventRequestParams( @@ -7,26 +6,19 @@ export default function getEventRequestParams( requestRange ) { const [startIndex, stopIndex] = requestRange; - if (isJobRunning(job?.status)) { - return [ - { counter__gte: startIndex, limit: stopIndex - startIndex + 1 }, - range(startIndex, Math.min(stopIndex, remoteRowCount)), - startIndex, - ]; - } const { page, pageSize, firstIndex } = getRowRangePageSize( startIndex, stopIndex ); const loadRange = range( - firstIndex, + firstIndex + 1, Math.min(firstIndex + pageSize, remoteRowCount) ); - return [{ page, page_size: pageSize }, loadRange, firstIndex]; + return [{ page, page_size: pageSize }, loadRange]; } -function range(low, high) { +export function range(low, high) { const numbers = []; for (let n = low; n <= high; n++) { numbers.push(n); diff --git a/awx/ui/src/screens/Job/JobOutput/getEventRequestParams.test.js b/awx/ui/src/screens/Job/JobOutput/getEventRequestParams.test.js new file mode 100644 index 0000000000..9181cdedea --- /dev/null +++ b/awx/ui/src/screens/Job/JobOutput/getEventRequestParams.test.js @@ -0,0 +1,47 @@ +import getEventRequestParams, { range } from './getEventRequestParams'; + +describe('getEventRequestParams', () => { + const job = { + status: 'successful', + }; + + it('should return first page', () => { + const [params, loadRange] = getEventRequestParams(job, 50, [1, 50]); + + expect(params).toEqual({ + page: 1, + page_size: 50, + }); + expect(loadRange).toEqual(range(1, 50)); + }); + + it('should return second page', () => { + const [params, loadRange] = getEventRequestParams(job, 1000, [51, 100]); + + expect(params).toEqual({ + page: 2, + page_size: 50, + }); + expect(loadRange).toEqual(range(51, 100)); + }); + + it('should return page for first portion of requested range', () => { + const [params, loadRange] = getEventRequestParams(job, 1000, [75, 125]); + + expect(params).toEqual({ + page: 2, + page_size: 50, + }); + expect(loadRange).toEqual(range(51, 100)); + }); + + it('should return smaller page for shorter range', () => { + const [params, loadRange] = getEventRequestParams(job, 1000, [120, 125]); + + expect(params).toEqual({ + page: 21, + page_size: 6, + }); + expect(loadRange).toEqual(range(121, 126)); + }); +}); diff --git a/awx/ui/src/screens/Job/JobOutput/getLineTextHtml.js b/awx/ui/src/screens/Job/JobOutput/getLineTextHtml.js index 1ac82bd0c4..4a96924f59 100644 --- a/awx/ui/src/screens/Job/JobOutput/getLineTextHtml.js +++ b/awx/ui/src/screens/Job/JobOutput/getLineTextHtml.js @@ -72,7 +72,7 @@ function replaceStyleAttrs(html) { export default function getLineTextHtml({ created, event, - start_line, + start_line: startLine, stdout, }) { const sanitized = encode(stdout); @@ -95,7 +95,7 @@ export default function getLineTextHtml({ } lineTextHtml.push({ - lineNumber: start_line + index, + lineNumber: startLine + index, html, }); }); diff --git a/awx/ui/src/screens/Job/JobOutput/loadJobEvents.js b/awx/ui/src/screens/Job/JobOutput/loadJobEvents.js index 9c9033e927..f6300d4525 100644 --- a/awx/ui/src/screens/Job/JobOutput/loadJobEvents.js +++ b/awx/ui/src/screens/Job/JobOutput/loadJobEvents.js @@ -1,42 +1,41 @@ -import { getJobModel, isJobRunning } from 'util/jobs'; - -export async function fetchCount(job, eventPromise) { - if (isJobRunning(job?.status)) { - const { - data: { results: lastEvents = [] }, - } = await getJobModel(job.type).readEvents(job.id, { - order_by: '-counter', - limit: 1, - }); - return lastEvents.length >= 1 ? lastEvents[0].counter : 0; - } +import { getJobModel } from 'util/jobs'; +export async function fetchCount(job, params) { const { - data: { count: eventCount }, - } = await eventPromise; - return eventCount; + data: { results: lastEvents = [] }, + } = await getJobModel(job.type).readEvents(job.id, { + ...params, + order_by: '-counter', + limit: 1, + }); + return lastEvents.length >= 1 ? lastEvents[0].counter : 0; } -export function normalizeEvents(job, events) { +export function prependTraceback(job, events) { let countOffset = 0; - if (job?.result_traceback) { - const tracebackEvent = { - counter: 1, - created: null, - event: null, - type: null, - stdout: job?.result_traceback, - start_line: 0, + if (!job?.result_traceback) { + return { + events, + countOffset, }; - const firstIndex = events.findIndex((jobEvent) => jobEvent.counter === 1); - if (firstIndex && events[firstIndex]?.stdout) { - const stdoutLines = events[firstIndex].stdout.split('\r\n'); - stdoutLines[0] = tracebackEvent.stdout; - events[firstIndex].stdout = stdoutLines.join('\r\n'); - } else { - countOffset += 1; - events.unshift(tracebackEvent); - } + } + + const tracebackEvent = { + counter: 1, + created: null, + event: null, + type: null, + stdout: job?.result_traceback, + start_line: 0, + }; + const firstIndex = events.findIndex((jobEvent) => jobEvent.counter === 1); + if (firstIndex && events[firstIndex]?.stdout) { + const stdoutLines = events[firstIndex].stdout.split('\r\n'); + stdoutLines[0] = tracebackEvent.stdout; + events[firstIndex].stdout = stdoutLines.join('\r\n'); + } else { + countOffset += 1; + events.unshift(tracebackEvent); } return { diff --git a/awx/ui/src/screens/Job/JobOutput/shared/JobEventEllipsis.js b/awx/ui/src/screens/Job/JobOutput/shared/JobEventEllipsis.js new file mode 100644 index 0000000000..bfff0c8f27 --- /dev/null +++ b/awx/ui/src/screens/Job/JobOutput/shared/JobEventEllipsis.js @@ -0,0 +1,20 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Wrapper = styled.div` + border-radius: 1em; + background-color: var(--pf-global--BackgroundColor--light-200); + font-size: 0.6rem; + width: max-content; + padding: 0em 1em; + margin-left: auto; + margin-right: -0.3em; +`; + +export default function JobEventEllipsis({ isCollapsed }) { + if (!isCollapsed) { + return null; + } + + return ...; +} diff --git a/awx/ui/src/screens/Job/JobOutput/shared/JobEventLine.js b/awx/ui/src/screens/Job/JobOutput/shared/JobEventLine.js index a6a626d5ac..db399e908e 100644 --- a/awx/ui/src/screens/Job/JobOutput/shared/JobEventLine.js +++ b/awx/ui/src/screens/Job/JobOutput/shared/JobEventLine.js @@ -8,10 +8,6 @@ export default styled.div` cursor: ${(props) => (props.isClickable ? 'pointer' : 'default')}; } - &:hover div { - background-color: white; - } - &--hidden { display: none; } diff --git a/awx/ui/src/screens/Job/JobOutput/shared/JobEventLineToggle.js b/awx/ui/src/screens/Job/JobOutput/shared/JobEventLineToggle.js index 45ee8ffc94..41633b118e 100644 --- a/awx/ui/src/screens/Job/JobOutput/shared/JobEventLineToggle.js +++ b/awx/ui/src/screens/Job/JobOutput/shared/JobEventLineToggle.js @@ -1,6 +1,9 @@ +import React from 'react'; import styled from 'styled-components'; +import { t } from '@lingui/macro'; +import { AngleDownIcon, AngleRightIcon } from '@patternfly/react-icons'; -export default styled.div` +const Wrapper = styled.div` background-color: #ebebeb; color: #646972; display: flex; @@ -8,10 +11,34 @@ export default styled.div` font-size: 18px; justify-content: center; line-height: 12px; - - & > i { - cursor: pointer; - } - user-select: none; `; + +const Button = styled.button` + align-self: flex-start; + border: 0; + padding: 2px; + background: transparent; + line-height: 1; +`; + +export default function JobEventLineToggle({ + canToggle, + isCollapsed, + onToggle, +}) { + if (!canToggle) { + return ; + } + return ( + + + + ); +} diff --git a/awx/ui/src/screens/Job/JobOutput/shared/index.js b/awx/ui/src/screens/Job/JobOutput/shared/index.js index a93574639c..b73346355d 100644 --- a/awx/ui/src/screens/Job/JobOutput/shared/index.js +++ b/awx/ui/src/screens/Job/JobOutput/shared/index.js @@ -4,3 +4,4 @@ export { default as JobEventLineToggle } from './JobEventLineToggle'; export { default as JobEventLineNumber } from './JobEventLineNumber'; export { default as JobEventLineText } from './JobEventLineText'; export { default as OutputToolbar } from './OutputToolbar'; +export { default as JobEventEllipsis } from './JobEventEllipsis'; diff --git a/awx/ui/src/screens/Job/JobOutput/useJobEvents.js b/awx/ui/src/screens/Job/JobOutput/useJobEvents.js new file mode 100644 index 0000000000..02cf0fa0bd --- /dev/null +++ b/awx/ui/src/screens/Job/JobOutput/useJobEvents.js @@ -0,0 +1,498 @@ +import { useState, useEffect, useReducer } from 'react'; + +const initialState = { + // array of root level nodes (no parent_uuid) + tree: [], + // all events indexed by counter value + events: {}, + // counter value indexed by uuid + uuidMap: {}, + // events with parent events that aren't yet loaded. + // arrays indexed by parent uuid + eventsWithoutParents: {}, +}; +export const ADD_EVENTS = 'ADD_EVENTS'; +export const TOGGLE_NODE_COLLAPSED = 'TOGGLE_NODE_COLLAPSED'; +export const SET_EVENT_NUM_CHILDREN = 'SET_EVENT_NUM_CHILDREN'; +export const CLEAR_EVENTS = 'CLEAR_EVENTS'; +export const REBUILD_TREE = 'REBUILD_TREE'; + +export default function useJobEvents(callbacks, isFlatMode) { + const [actionQueue, setActionQueue] = useState([]); + const enqueueAction = (action) => { + setActionQueue((queue) => queue.concat(action)); + }; + const reducer = jobEventsReducer(callbacks, isFlatMode, enqueueAction); + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + setActionQueue((queue) => { + const action = queue[0]; + if (!action) { + return queue; + } + try { + dispatch(action); + } catch (e) { + console.error(e); // eslint-disable-line no-console + } + return queue.slice(1); + }); + }, [actionQueue]); + + return { + addEvents: (events) => dispatch({ type: ADD_EVENTS, events }), + getNodeByUuid: (uuid) => getNodeByUuid(state, uuid), + toggleNodeIsCollapsed: (uuid) => + dispatch({ type: TOGGLE_NODE_COLLAPSED, uuid }), + getEventForRow: (rowIndex) => getEventForRow(state, rowIndex), + getNodeForRow: (rowIndex) => getNodeForRow(state, rowIndex), + getTotalNumChildren: (uuid) => { + const node = getNodeByUuid(state, uuid); + return getTotalNumChildren(node); + }, + getNumCollapsedEvents: () => + state.tree.reduce((sum, node) => sum + getNumCollapsedChildren(node), 0), + getCounterForRow: (rowIndex) => getCounterForRow(state, rowIndex), + getEvent: (eventIndex) => getEvent(state, eventIndex), + clearLoadedEvents: () => dispatch({ type: CLEAR_EVENTS }), + rebuildEventsTree: () => dispatch({ type: REBUILD_TREE }), + }; +} + +export function jobEventsReducer(callbacks, isFlatMode, enqueueAction) { + return (state, action) => { + switch (action.type) { + case ADD_EVENTS: + return addEvents(state, action.events); + case TOGGLE_NODE_COLLAPSED: + return toggleNodeIsCollapsed(state, action.uuid); + case SET_EVENT_NUM_CHILDREN: + return setEventNumChildren(state, action.uuid, action.numChildren); + case CLEAR_EVENTS: + return initialState; + case REBUILD_TREE: + return rebuildTree(state); + default: + throw new Error(`Unrecognized action: ${action.type}`); + } + }; + + function addEvents(origState, newEvents) { + let state = { + ...origState, + events: { ...origState.events }, + tree: [...origState.tree], + }; + const parentsToFetch = {}; + newEvents.forEach((event) => { + if ( + typeof event.rowNumber !== 'number' || + Number.isNaN(event.rowNumber) + ) { + throw new Error('Cannot add event; missing rowNumber'); + } + const eventIndex = event.counter; + if (state.events[eventIndex]) { + state.events[eventIndex] = event; + state = _gatherEventsForNewParent(state, event.uuid); + return; + } + if (!event.parent_uuid || isFlatMode) { + state = _addRootLevelEvent(state, event); + return; + } + + let isParentFound; + [state, isParentFound] = _addNestedLevelEvent(state, event); + if (!isParentFound) { + parentsToFetch[event.parent_uuid] = { + childCounter: event.counter, + childRowNumber: event.rowNumber, + }; + state = _addEventWithoutParent(state, event); + } + }); + + Object.keys(parentsToFetch).forEach(async (uuid) => { + const { childCounter, childRowNumber } = parentsToFetch[uuid]; + const parent = await callbacks.fetchEventByUuid(uuid); + const numPrevSiblings = await callbacks.fetchNumEvents( + parent.counter, + childCounter + ); + parent.rowNumber = childRowNumber - numPrevSiblings - 1; + enqueueAction({ + type: ADD_EVENTS, + events: [parent], + }); + }); + + return state; + } + + function _addRootLevelEvent(state, event) { + const eventIndex = event.counter; + const newNode = { + eventIndex, + isCollapsed: false, + children: [], + }; + const index = state.tree.findIndex((node) => node.eventIndex > eventIndex); + const updatedTree = [...state.tree]; + if (index === -1) { + updatedTree.push(newNode); + } else { + updatedTree.splice(index, 0, newNode); + } + return _gatherEventsForNewParent( + { + ...state, + events: { ...state.events, [eventIndex]: event }, + tree: updatedTree, + uuidMap: { + ...state.uuidMap, + [event.uuid]: eventIndex, + }, + }, + event.uuid + ); + } + + function _addNestedLevelEvent(state, event) { + const eventIndex = event.counter; + const parent = getNodeByUuid(state, event.parent_uuid); + if (!parent) { + return [state, false]; + } + const newNode = { + eventIndex, + isCollapsed: false, + children: [], + }; + const index = parent.children.findIndex( + (node) => node.eventIndex >= eventIndex + ); + const length = parent.children.length + 1; + if (index === -1) { + state = updateNodeByUuid(state, event.parent_uuid, (node) => { + node.children.push(newNode); + return node; + }); + } else { + state = updateNodeByUuid(state, event.parent_uuid, (node) => { + node.children.splice(index, 0, newNode); + return node; + }); + } + state = _gatherEventsForNewParent( + { + ...state, + events: { + ...state.events, + [eventIndex]: event, + }, + uuidMap: { + ...state.uuidMap, + [event.uuid]: eventIndex, + }, + }, + event.uuid + ); + if (length === 1) { + _fetchNumChildren(state, parent); + } + + return [state, true]; + } + + function _addEventWithoutParent(state, event) { + const parentUuid = event.parent_uuid; + let eventsList; + if (!state.eventsWithoutParents[parentUuid]) { + eventsList = [event]; + } else { + eventsList = state.eventsWithoutParents[parentUuid].concat(event); + } + + return { + ...state, + eventsWithoutParents: { + ...state.eventsWithoutParents, + [parentUuid]: eventsList, + }, + }; + } + + async function _fetchNumChildren(state, node) { + const event = state.events[node.eventIndex]; + if (!event) { + throw new Error( + `Cannot fetch numChildren; event ${node.eventIndex} not found` + ); + } + const sibling = await _getNextSibling(state, event); + const numChildren = await callbacks.fetchNumEvents( + event.counter, + sibling?.counter + ); + enqueueAction({ + type: SET_EVENT_NUM_CHILDREN, + uuid: event.uuid, + numChildren, + }); + if (sibling) { + sibling.rowNumber = event.rowNumber + numChildren + 1; + enqueueAction({ + type: ADD_EVENTS, + events: [sibling], + }); + } + } + + async function _getNextSibling(state, event) { + if (!event.parent_uuid) { + return callbacks.fetchNextRootNode(event.counter); + } + const parentNode = getNodeByUuid(state, event.parent_uuid); + const parent = state.events[parentNode.eventIndex]; + const sibling = await callbacks.fetchNextSibling(parent.id, event.counter); + if (!sibling) { + return _getNextSibling(state, parent); + } + return sibling; + } + + function _gatherEventsForNewParent(state, parentUuid) { + if (!state.eventsWithoutParents[parentUuid]) { + return state; + } + + const { [parentUuid]: newEvents, ...remaining } = + state.eventsWithoutParents; + return addEvents( + { + ...state, + eventsWithoutParents: remaining, + }, + newEvents + ); + } + + function rebuildTree(state) { + const events = Object.values(state.events); + return addEvents(initialState, events); + } +} + +function getEventForRow(state, rowIndex) { + const { node } = _getNodeForRow(state, rowIndex, state.tree); + if (node) { + return { + node, + event: state.events[node.eventIndex], + }; + } + return null; +} + +function getNodeForRow(state, rowToFind) { + const { node } = _getNodeForRow(state, rowToFind, state.tree); + return node; +} + +function getCounterForRow(state, rowToFind) { + const { node, expectedCounter } = _getNodeForRow( + state, + rowToFind, + state.tree + ); + + if (node) { + const event = state.events[node.eventIndex]; + return event.counter; + } + return expectedCounter; +} + +function _getNodeForRow(state, rowToFind, nodes) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const event = state.events[node.eventIndex]; + if (event.rowNumber === rowToFind) { + return { node }; + } + const totalNodeDescendants = getTotalNumChildren(node); + const numCollapsedChildren = getNumCollapsedChildren(node); + const nodeChildren = totalNodeDescendants - numCollapsedChildren; + if (event.rowNumber + nodeChildren >= rowToFind) { + // requested row is in children/descendants + return _getNodeInChildren(state, node, rowToFind); + } + rowToFind += numCollapsedChildren; + + const nextNode = nodes[i + 1]; + if (!nextNode) { + continue; + } + const nextEvent = state.events[nextNode.eventIndex]; + const lastChild = _getLastDescendantNode([node]); + if (nextEvent.rowNumber > rowToFind) { + // requested row is not loaded; return best guess at counter number + const lastChildEvent = state.events[lastChild.eventIndex]; + const rowDiff = rowToFind - lastChildEvent.rowNumber; + return { + node: null, + expectedCounter: lastChild.eventIndex + rowDiff, + }; + } + } + + const lastDescendant = _getLastDescendantNode(nodes); + if (!lastDescendant) { + return { node: null, expectedCounter: rowToFind }; + } + + const lastDescendantEvent = state.events[lastDescendant.eventIndex]; + const rowDiff = rowToFind - lastDescendantEvent.rowNumber; + return { + node: null, + expectedCounter: lastDescendant.eventIndex + rowDiff, + }; +} + +function _getNodeInChildren(state, node, rowToFind) { + const event = state.events[node.eventIndex]; + const firstChild = state.events[node.children[0].eventIndex]; + if (rowToFind < firstChild.rowNumber) { + const rowDiff = rowToFind - event.rowNumber; + return { + node: null, + expectedCounter: event.counter + rowDiff, + }; + } + return _getNodeForRow(state, rowToFind, node.children); +} + +function _getLastDescendantNode(nodes) { + let lastDescendant = nodes[nodes.length - 1]; + let children = lastDescendant?.children || []; + while (children.length) { + lastDescendant = children[children.length - 1]; + children = lastDescendant.children; + } + return lastDescendant; +} + +function getTotalNumChildren(node) { + if (typeof node.numChildren !== 'undefined') { + return node.numChildren; + } + + let estimatedNumChildren = node.children.length; + node.children.forEach((child) => { + estimatedNumChildren += getTotalNumChildren(child); + }); + return estimatedNumChildren; +} + +function getNumCollapsedChildren(node) { + if (node.isCollapsed) { + return getTotalNumChildren(node); + } + + let sum = 0; + node.children.forEach((child) => { + sum += getNumCollapsedChildren(child); + }); + return sum; +} + +function toggleNodeIsCollapsed(state, eventUuid) { + return updateNodeByUuid(state, eventUuid, (node) => ({ + ...node, + isCollapsed: !node.isCollapsed, + })); +} + +function updateNodeByUuid(state, uuid, update) { + if (!state.uuidMap[uuid]) { + throw new Error(`Cannot update node; Event UUID not found ${uuid}`); + } + const index = state.uuidMap[uuid]; + return { + ...state, + tree: _updateNodeByIndex(index, state.tree, update), + }; +} + +function _updateNodeByIndex(target, nodeArray, update) { + const nextIndex = nodeArray.findIndex((node) => node.eventIndex > target); + const targetIndex = nextIndex === -1 ? nodeArray.length - 1 : nextIndex - 1; + let updatedNode; + if (nodeArray[targetIndex].eventIndex === target) { + updatedNode = update({ + ...nodeArray[targetIndex], + children: [...nodeArray[targetIndex].children], + }); + } else { + updatedNode = { + ...nodeArray[targetIndex], + children: _updateNodeByIndex( + target, + nodeArray[targetIndex].children, + update + ), + }; + } + return [ + ...nodeArray.slice(0, targetIndex), + updatedNode, + ...nodeArray.slice(targetIndex + 1), + ]; +} + +function getNodeByUuid(state, uuid) { + if (!state.uuidMap[uuid]) { + return null; + } + + const index = state.uuidMap[uuid]; + return _getNodeByIndex(state.tree, index); +} + +function _getNodeByIndex(arr, index) { + if (!arr.length) { + return null; + } + const i = arr.findIndex((node) => node.eventIndex >= index); + if (i === -1) { + return _getNodeByIndex(arr[arr.length - 1].children, index); + } + if (arr[i].eventIndex === index) { + return arr[i]; + } + if (!arr[i - 1]) { + return null; + } + return _getNodeByIndex(arr[i - 1].children, index); +} + +function setEventNumChildren(state, uuid, numChildren) { + if (!state.uuidMap[uuid]) { + return state; + } + return updateNodeByUuid(state, uuid, (node) => ({ + ...node, + numChildren, + })); +} + +function getEvent(state, eventIndex) { + const event = state.events[eventIndex]; + if (event) { + return event; + } + + return null; +} diff --git a/awx/ui/src/screens/Job/JobOutput/useJobEvents.test.js b/awx/ui/src/screens/Job/JobOutput/useJobEvents.test.js new file mode 100644 index 0000000000..1f19db3750 --- /dev/null +++ b/awx/ui/src/screens/Job/JobOutput/useJobEvents.test.js @@ -0,0 +1,1643 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { shallow, mount } from 'enzyme'; +import useJobEvents, { + jobEventsReducer, + ADD_EVENTS, + TOGGLE_NODE_COLLAPSED, + SET_EVENT_NUM_CHILDREN, +} from './useJobEvents'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function Child() { + return
; +} +function HookTest({ + fetchEventByUuid = () => {}, + fetchNextSibling = () => {}, + fetchNextRootNode = () => {}, + fetchNumEvents = () => {}, + isFlatMode = false, +}) { + const hookFuncs = useJobEvents( + { + fetchEventByUuid, + fetchNextSibling, + fetchNextRootNode, + fetchNumEvents, + }, + isFlatMode + ); + return ; +} + +const eventsList = [ + { + id: 101, + counter: 1, + rowNumber: 0, + uuid: 'abc-001', + event_level: 0, + parent_uuid: '', + }, + { + id: 102, + counter: 2, + rowNumber: 1, + uuid: 'abc-002', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 103, + counter: 3, + rowNumber: 2, + uuid: 'abc-003', + event_level: 2, + parent_uuid: 'abc-002', + }, + { + id: 104, + counter: 4, + rowNumber: 3, + uuid: 'abc-004', + event_level: 2, + parent_uuid: 'abc-002', + }, + { + id: 105, + counter: 5, + rowNumber: 4, + uuid: 'abc-005', + event_level: 2, + parent_uuid: 'abc-002', + }, + { + id: 106, + counter: 6, + rowNumber: 5, + uuid: 'abc-006', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 107, + counter: 7, + rowNumber: 6, + uuid: 'abc-007', + event_level: 2, + parent_uuid: 'abc-006', + }, + { + id: 108, + counter: 8, + rowNumber: 7, + uuid: 'abc-008', + event_level: 2, + parent_uuid: 'abc-006', + }, + { + id: 109, + counter: 9, + rowNumber: 8, + uuid: 'abc-009', + event_level: 2, + parent_uuid: 'abc-006', + }, +]; +const basicEvents = { + 1: eventsList[0], + 2: eventsList[1], + 3: eventsList[2], + 4: eventsList[3], + 5: eventsList[4], + 6: eventsList[5], + 7: eventsList[6], + 8: eventsList[7], + 9: eventsList[8], +}; +const basicTree = [ + { + eventIndex: 1, + isCollapsed: false, + children: [ + { + eventIndex: 2, + isCollapsed: false, + children: [ + { eventIndex: 3, isCollapsed: false, children: [] }, + { eventIndex: 4, isCollapsed: false, children: [] }, + { eventIndex: 5, isCollapsed: false, children: [] }, + ], + }, + { + eventIndex: 6, + isCollapsed: false, + children: [ + { eventIndex: 7, isCollapsed: false, children: [] }, + { eventIndex: 8, isCollapsed: false, children: [] }, + { eventIndex: 9, isCollapsed: false, children: [] }, + ], + }, + ], + }, +]; + +describe('useJobEvents', () => { + let callbacks; + let reducer; + let emptyState; + let enqueueAction; + + beforeEach(() => { + callbacks = { + fetchEventByUuid: jest.fn(), + fetchNextSibling: jest.fn(), + fetchNextRootNode: jest.fn(), + fetchNumEvents: jest.fn(), + }; + enqueueAction = jest.fn(); + callbacks.fetchNextSibling.mockResolvedValue(eventsList[9]); + callbacks.fetchNextRootNode.mockResolvedValue(eventsList[9]); + reducer = jobEventsReducer(callbacks, false, enqueueAction); + emptyState = { + tree: [], + events: {}, + uuidMap: {}, + eventsWithoutParents: {}, + eventGaps: [], + }; + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + describe('addEvents', () => { + test('should build initial tree', () => { + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + + expect(state.events).toEqual(basicEvents); + expect(state.tree).toEqual(basicTree); + expect(state.uuidMap).toEqual({ + 'abc-001': 1, + 'abc-002': 2, + 'abc-003': 3, + 'abc-004': 4, + 'abc-005': 5, + 'abc-006': 6, + 'abc-007': 7, + 'abc-008': 8, + 'abc-009': 9, + }); + }); + + test('should append new events', () => { + const newEvents = [ + { + id: 110, + counter: 10, + rowNumber: 9, + uuid: 'abc-010', + event_level: 2, + parent_uuid: 'abc-006', + }, + { + id: 111, + counter: 11, + rowNumber: 10, + uuid: 'abc-011', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 112, + counter: 12, + rowNumber: 11, + uuid: 'abc-012', + event_level: 0, + parent_uuid: '', + }, + ]; + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + const { events, tree } = reducer(state, { + type: ADD_EVENTS, + events: newEvents, + }); + + expect(events).toEqual({ + 1: eventsList[0], + 2: eventsList[1], + 3: eventsList[2], + 4: eventsList[3], + 5: eventsList[4], + 6: eventsList[5], + 7: eventsList[6], + 8: eventsList[7], + 9: eventsList[8], + 10: newEvents[0], + 11: newEvents[1], + 12: newEvents[2], + }); + expect(tree).toEqual([ + { + eventIndex: 1, + isCollapsed: false, + children: [ + { + eventIndex: 2, + isCollapsed: false, + children: [ + { eventIndex: 3, isCollapsed: false, children: [] }, + { eventIndex: 4, isCollapsed: false, children: [] }, + { eventIndex: 5, isCollapsed: false, children: [] }, + ], + }, + { + eventIndex: 6, + isCollapsed: false, + children: [ + { eventIndex: 7, isCollapsed: false, children: [] }, + { eventIndex: 8, isCollapsed: false, children: [] }, + { eventIndex: 9, isCollapsed: false, children: [] }, + { eventIndex: 10, isCollapsed: false, children: [] }, + ], + }, + { + eventIndex: 11, + isCollapsed: false, + children: [], + }, + ], + }, + { eventIndex: 12, isCollapsed: false, children: [] }, + ]); + }); + + test('should not mutate original state', () => { + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: [eventsList[0], eventsList[1]], + }); + window.debug = true; + reducer(state, { + type: ADD_EVENTS, + events: [eventsList[2], eventsList[5]], + }); + + expect(state.events).toEqual({ 1: eventsList[0], 2: eventsList[1] }); + expect(state.tree).toEqual([ + { + eventIndex: 1, + isCollapsed: false, + children: [ + { + eventIndex: 2, + isCollapsed: false, + children: [], + }, + ], + }, + ]); + expect(state.uuidMap).toEqual({ + 'abc-001': 1, + 'abc-002': 2, + }); + }); + + test('should not duplicate events in events tree', () => { + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + const newNode = { + id: 110, + counter: 10, + rowNumber: 9, + uuid: 'abc-010', + event_level: 2, + parent_uuid: 'abc-006', + }; + reducer(state, { + type: ADD_EVENTS, + events: [newNode], + }); + const { events, tree } = reducer(state, { + type: ADD_EVENTS, + events: [newNode], + }); + + expect(events).toEqual({ + 1: eventsList[0], + 2: eventsList[1], + 3: eventsList[2], + 4: eventsList[3], + 5: eventsList[4], + 6: eventsList[5], + 7: eventsList[6], + 8: eventsList[7], + 9: eventsList[8], + 10: newNode, + }); + expect(tree).toEqual([ + { + eventIndex: 1, + isCollapsed: false, + children: [ + { + eventIndex: 2, + isCollapsed: false, + children: [ + { eventIndex: 3, isCollapsed: false, children: [] }, + { eventIndex: 4, isCollapsed: false, children: [] }, + { eventIndex: 5, isCollapsed: false, children: [] }, + ], + }, + { + eventIndex: 6, + isCollapsed: false, + children: [ + { eventIndex: 7, isCollapsed: false, children: [] }, + { eventIndex: 8, isCollapsed: false, children: [] }, + { eventIndex: 9, isCollapsed: false, children: [] }, + { eventIndex: 10, isCollapsed: false, children: [] }, + ], + }, + ], + }, + ]); + }); + + test('should fetch parent for events with missing parent', async () => { + callbacks.fetchEventByUuid.mockResolvedValue({ + counter: 10, + }); + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + + const newEvents = [ + { + id: 112, + counter: 12, + rowNumber: 11, + uuid: 'abc-012', + event_level: 2, + parent_uuid: 'abc-010', + }, + ]; + reducer(state, { type: ADD_EVENTS, events: newEvents }); + + expect(callbacks.fetchEventByUuid).toHaveBeenCalledWith('abc-010'); + }); + + test('should batch parent fetches by uuid', () => { + callbacks.fetchEventByUuid.mockResolvedValue({ + counter: 10, + }); + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + + const newEvents = [ + { + id: 112, + counter: 12, + rowNumber: 11, + uuid: 'abc-012', + event_level: 2, + parent_uuid: 'abc-010', + }, + { + id: 113, + counter: 13, + rowNumber: 12, + uuid: 'abc-013', + event_level: 2, + parent_uuid: 'abc-010', + }, + ]; + reducer(state, { type: ADD_EVENTS, events: newEvents }); + + expect(callbacks.fetchEventByUuid).toHaveBeenCalledTimes(1); + expect(callbacks.fetchEventByUuid).toHaveBeenCalledWith('abc-010'); + }); + + test('should fetch multiple parent fetches by uuid', () => { + callbacks.fetchEventByUuid.mockResolvedValue({ + counter: 10, + }); + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + + const newEvents = [ + { + id: 114, + counter: 14, + rowNumber: 13, + uuid: 'abc-014', + event_level: 2, + parent_uuid: 'abc-012', + }, + { + id: 115, + counter: 15, + rowNumber: 14, + uuid: 'abc-015', + event_level: 1, + parent_uuid: 'abc-011', + }, + ]; + reducer(state, { type: ADD_EVENTS, events: newEvents }); + + expect(callbacks.fetchEventByUuid).toHaveBeenCalledTimes(2); + expect(callbacks.fetchEventByUuid).toHaveBeenCalledWith('abc-012'); + expect(callbacks.fetchEventByUuid).toHaveBeenCalledWith('abc-011'); + }); + + test('should set eventsWithoutParents while fetching parent events', () => { + callbacks.fetchEventByUuid.mockResolvedValue({ + counter: 10, + }); + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + + const newEvents = [ + { + id: 112, + counter: 12, + rowNumber: 11, + uuid: 'abc-012', + event_level: 2, + parent_uuid: 'abc-010', + }, + ]; + const { eventsWithoutParents, tree } = reducer(state, { + type: ADD_EVENTS, + events: newEvents, + }); + + expect(eventsWithoutParents).toEqual({ + 'abc-010': [newEvents[0]], + }); + expect(tree).toEqual(basicTree); + }); + + test('should check for eventsWithoutParents belonging to new nodes', () => { + const childEvent = { + id: 112, + counter: 12, + rowNumber: 11, + uuid: 'abc-012', + event_level: 1, + parent_uuid: 'abc-010', + }; + const initialState = { + ...emptyState, + eventsWithoutParents: { + 'abc-010': [childEvent], + }, + }; + const parentEvent = { + id: 110, + counter: 10, + rowNumber: 9, + uuid: 'abc-010', + event_level: 0, + parent_uuid: '', + }; + + const { tree, events, eventsWithoutParents } = reducer(initialState, { + type: ADD_EVENTS, + events: [parentEvent], + }); + + expect(tree).toEqual([ + { + eventIndex: 10, + isCollapsed: false, + children: [ + { + eventIndex: 12, + isCollapsed: false, + children: [], + }, + ], + }, + ]); + expect(events).toEqual({ + 10: parentEvent, + 12: childEvent, + }); + expect(eventsWithoutParents).toEqual({}); + }); + + test('should fetch parent of parent and compile them together', () => { + callbacks.fetchEventByUuid.mockResolvedValueOnce({ + counter: 2, + }); + callbacks.fetchEventByUuid.mockResolvedValueOnce({ + counter: 1, + }); + const event3 = { + id: 103, + counter: 3, + rowNumber: 2, + uuid: 'abc-003', + event_level: 2, + parent_uuid: 'abc-002', + }; + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: [event3], + }); + expect(callbacks.fetchEventByUuid).toHaveBeenCalledWith('abc-002'); + + const event2 = { + id: 102, + counter: 2, + rowNumber: 1, + uuid: 'abc-002', + event_level: 1, + parent_uuid: 'abc-001', + }; + const state2 = reducer(state, { + type: ADD_EVENTS, + events: [event2], + }); + expect(callbacks.fetchEventByUuid).toHaveBeenCalledWith('abc-001'); + expect(state2.events).toEqual({}); + expect(state2.tree).toEqual([]); + expect(state2.eventsWithoutParents).toEqual({ + 'abc-001': [event2], + 'abc-002': [event3], + }); + + const event1 = { + id: 101, + counter: 1, + rowNumber: 0, + uuid: 'abc-001', + event_level: 0, + parent_uuid: '', + }; + const state3 = reducer(state2, { + type: ADD_EVENTS, + events: [event1], + }); + expect(state3.events).toEqual({ + 1: event1, + 2: event2, + 3: event3, + }); + expect(state3.tree).toEqual([ + { + eventIndex: 1, + isCollapsed: false, + children: [ + { + eventIndex: 2, + isCollapsed: false, + children: [ + { + eventIndex: 3, + isCollapsed: false, + children: [], + }, + ], + }, + ], + }, + ]); + expect(state3.eventsWithoutParents).toEqual({}); + }); + + test('should add root level node in middle of array', () => { + const events = [ + { + id: 101, + counter: 1, + rowNumber: 0, + uuid: 'abc-001', + event_level: 0, + parent_uuid: '', + }, + { + id: 102, + counter: 2, + rowNumber: 1, + uuid: 'abc-002', + event_level: 0, + parent_uuid: '', + }, + { + id: 103, + counter: 3, + rowNumber: 2, + uuid: 'abc-003', + event_level: 0, + parent_uuid: '', + }, + ]; + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: [events[0]], + }); + const state2 = reducer(state, { + type: ADD_EVENTS, + events: [events[2]], + }); + const state3 = reducer(state2, { + type: ADD_EVENTS, + events: [events[1]], + }); + + expect(state3.tree[0].eventIndex).toEqual(1); + expect(state3.tree[1].eventIndex).toEqual(2); + expect(state3.tree[2].eventIndex).toEqual(3); + }); + + test('should add child nodes in middle of array', () => { + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: [...eventsList.slice(0, 3), ...eventsList.slice(4)], + }); + const state2 = reducer(state, { + type: ADD_EVENTS, + events: [eventsList[3]], + }); + + expect(state2.tree).toEqual([ + { + eventIndex: 1, + isCollapsed: false, + children: [ + { + eventIndex: 2, + isCollapsed: false, + children: [ + { eventIndex: 3, isCollapsed: false, children: [] }, + { eventIndex: 4, isCollapsed: false, children: [] }, + { eventIndex: 5, isCollapsed: false, children: [] }, + ], + }, + { + eventIndex: 6, + isCollapsed: false, + children: [ + { eventIndex: 7, isCollapsed: false, children: [] }, + { eventIndex: 8, isCollapsed: false, children: [] }, + { eventIndex: 9, isCollapsed: false, children: [] }, + ], + }, + ], + }, + ]); + }); + + test('should build in flat mode', () => { + const flatReducer = jobEventsReducer(callbacks, true, enqueueAction); + const state = flatReducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + + expect(state.events).toEqual(basicEvents); + expect(state.tree).toEqual([ + { eventIndex: 1, isCollapsed: false, children: [] }, + { eventIndex: 2, isCollapsed: false, children: [] }, + { eventIndex: 3, isCollapsed: false, children: [] }, + { eventIndex: 4, isCollapsed: false, children: [] }, + { eventIndex: 5, isCollapsed: false, children: [] }, + { eventIndex: 6, isCollapsed: false, children: [] }, + { eventIndex: 7, isCollapsed: false, children: [] }, + { eventIndex: 8, isCollapsed: false, children: [] }, + { eventIndex: 9, isCollapsed: false, children: [] }, + ]); + expect(state.uuidMap).toEqual({ + 'abc-001': 1, + 'abc-002': 2, + 'abc-003': 3, + 'abc-004': 4, + 'abc-005': 5, + 'abc-006': 6, + 'abc-007': 7, + 'abc-008': 8, + 'abc-009': 9, + }); + }); + + describe('fetchNumChildren', () => { + test('should find child count for root node', async () => { + callbacks.fetchNextRootNode.mockResolvedValue({ + id: 121, + counter: 21, + rowNumber: 20, + uuid: 'abc-021', + event_level: 0, + parent_uuid: '', + }); + callbacks.fetchNumEvents.mockResolvedValue(19); + reducer(emptyState, { + type: ADD_EVENTS, + events: [eventsList[0], eventsList[1]], + }); + + expect(callbacks.fetchNextSibling).toHaveBeenCalledTimes(0); + expect(callbacks.fetchNextRootNode).toHaveBeenCalledTimes(1); + expect(callbacks.fetchNextRootNode).toHaveBeenCalledWith(1); + await sleep(0); + expect(callbacks.fetchNumEvents).toHaveBeenCalledTimes(1); + expect(callbacks.fetchNumEvents).toHaveBeenCalledWith(1, 21); + expect(enqueueAction).toHaveBeenCalledWith({ + type: SET_EVENT_NUM_CHILDREN, + uuid: 'abc-001', + numChildren: 19, + }); + }); + + test('should find child count for last root node', async () => { + callbacks.fetchNextRootNode.mockResolvedValue(null); + callbacks.fetchNumEvents.mockResolvedValue(19); + reducer(emptyState, { + type: ADD_EVENTS, + events: [eventsList[0], eventsList[1]], + }); + + expect(callbacks.fetchNextSibling).toHaveBeenCalledTimes(0); + expect(callbacks.fetchNextRootNode).toHaveBeenCalledTimes(1); + expect(callbacks.fetchNextRootNode).toHaveBeenCalledWith(1); + await sleep(0); + expect(callbacks.fetchNumEvents).toHaveBeenCalledTimes(1); + expect(callbacks.fetchNumEvents).toHaveBeenCalledWith(1, undefined); + expect(enqueueAction).toHaveBeenCalledWith({ + type: SET_EVENT_NUM_CHILDREN, + uuid: 'abc-001', + numChildren: 19, + }); + }); + + test('should find child count for nested node', async () => { + const state = { + events: { + 1: eventsList[0], + 2: eventsList[1], + }, + tree: [ + { + children: [{ children: [], eventIndex: 2, isCollapsed: false }], + eventIndex: 1, + isCollapsed: false, + }, + ], + uuidMap: { + 'abc-001': 1, + 'abc-002': 2, + }, + eventsWithoutParents: {}, + }; + + callbacks.fetchNextSibling.mockResolvedValue({ + id: 20, + counter: 20, + rowNumber: 19, + uuid: 'abc-020', + event_level: 1, + parent_uuid: 'abc-001', + }); + callbacks.fetchNumEvents.mockResolvedValue(18); + reducer(state, { + type: ADD_EVENTS, + events: [eventsList[2]], + }); + + expect(callbacks.fetchNextSibling).toHaveBeenCalledTimes(1); + expect(callbacks.fetchNextSibling).toHaveBeenCalledWith(101, 2); + await sleep(0); + expect(callbacks.fetchNextRootNode).toHaveBeenCalledTimes(0); + expect(callbacks.fetchNumEvents).toHaveBeenCalledTimes(1); + expect(callbacks.fetchNumEvents).toHaveBeenCalledWith(2, 20); + expect(enqueueAction).toHaveBeenCalledWith({ + type: SET_EVENT_NUM_CHILDREN, + uuid: 'abc-002', + numChildren: 18, + }); + }); + + test('should find child count for nested node, last sibling', async () => { + const state = { + events: { + 1: eventsList[0], + 2: eventsList[1], + }, + tree: [ + { + children: [{ children: [], eventIndex: 2, isCollapsed: false }], + eventIndex: 1, + isCollapsed: false, + }, + ], + uuidMap: { + 'abc-001': 1, + 'abc-002': 2, + }, + eventsWithoutParents: {}, + }; + + callbacks.fetchNextSibling.mockResolvedValue(null); + callbacks.fetchNextRootNode.mockResolvedValue({ + id: 121, + counter: 21, + rowNumber: 20, + uuid: 'abc-021', + event_level: 0, + parent_uuid: '', + }); + callbacks.fetchNumEvents.mockResolvedValue(19); + reducer(state, { + type: ADD_EVENTS, + events: [eventsList[2]], + }); + + expect(callbacks.fetchNextSibling).toHaveBeenCalledTimes(1); + expect(callbacks.fetchNextSibling).toHaveBeenCalledWith(101, 2); + await sleep(0); + expect(callbacks.fetchNextRootNode).toHaveBeenCalledTimes(1); + expect(callbacks.fetchNextRootNode).toHaveBeenCalledWith(1); + await sleep(0); + expect(callbacks.fetchNumEvents).toHaveBeenCalledTimes(1); + expect(callbacks.fetchNumEvents).toHaveBeenCalledWith(2, 21); + expect(enqueueAction).toHaveBeenCalledWith({ + type: SET_EVENT_NUM_CHILDREN, + uuid: 'abc-002', + numChildren: 19, + }); + }); + }); + }); + + describe('getNodeByUuid', () => { + let wrapper; + beforeEach(() => { + wrapper = shallow(); + wrapper.find('#test').prop('addEvents')(eventsList); + }); + + test('should get a root node', () => { + const node = wrapper.find('#test').prop('getNodeByUuid')('abc-001'); + expect(node.eventIndex).toEqual(1); + expect(node.isCollapsed).toEqual(false); + expect(node.children).toHaveLength(2); + }); + + test('should get 2nd level node', () => { + const node = wrapper.find('#test').prop('getNodeByUuid')('abc-002'); + expect(node.eventIndex).toEqual(2); + expect(node.isCollapsed).toEqual(false); + expect(node.children).toHaveLength(3); + }); + + test('should get 3rd level node', () => { + const node = wrapper.find('#test').prop('getNodeByUuid')('abc-008'); + expect(node.eventIndex).toEqual(8); + expect(node.isCollapsed).toEqual(false); + expect(node.children).toHaveLength(0); + }); + + test('should return null if node not found', () => { + const node = wrapper.find('#test').prop('getNodeByUuid')('abc-028'); + expect(node).toEqual(null); + }); + }); + + describe('toggleNodeIsCollapsed', () => { + test('should collapse node', () => { + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + const { tree } = reducer(state, { + type: TOGGLE_NODE_COLLAPSED, + uuid: 'abc-001', + }); + + expect(tree).toEqual([ + { + ...basicTree[0], + isCollapsed: true, + }, + ]); + }); + + test('should expand node', () => { + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + const { tree } = reducer( + { + ...state, + tree: [ + { + ...state.tree[0], + isCollapsed: true, + }, + ], + }, + { + type: TOGGLE_NODE_COLLAPSED, + uuid: 'abc-001', + } + ); + + expect(tree).toEqual(basicTree); + }); + }); + + describe('setEventNumChildren', () => { + test('should set number of children on root node', () => { + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + expect(state.tree[0].numChildren).toEqual(undefined); + + const { tree } = reducer(state, { + type: SET_EVENT_NUM_CHILDREN, + uuid: 'abc-001', + numChildren: 8, + }); + + expect(tree[0].numChildren).toEqual(8); + }); + + test('should set number of children on nested node', () => { + const state = reducer(emptyState, { + type: ADD_EVENTS, + events: eventsList, + }); + expect(state.tree[0].numChildren).toEqual(undefined); + + const { tree } = reducer(state, { + type: SET_EVENT_NUM_CHILDREN, + uuid: 'abc-006', + numChildren: 3, + }); + + expect(tree[0].children[1].numChildren).toEqual(3); + }); + }); + + describe('getNodeForRow', () => { + let wrapper; + beforeEach(() => { + wrapper = shallow(); + wrapper.find('#test').prop('addEvents')(eventsList); + }); + + test('should get root node', () => { + const node = wrapper.find('#test').prop('getNodeForRow')(0); + + expect(node.eventIndex).toEqual(1); + expect(node.isCollapsed).toEqual(false); + expect(node.children).toHaveLength(2); + }); + + test('should get 2nd level node', () => { + const node = wrapper.find('#test').prop('getNodeForRow')(1); + + expect(node.eventIndex).toEqual(2); + expect(node.isCollapsed).toEqual(false); + expect(node.children).toHaveLength(3); + }); + + test('should get 3rd level node', () => { + const node = wrapper.find('#test').prop('getNodeForRow')(7); + + expect(node.eventIndex).toEqual(8); + expect(node.isCollapsed).toEqual(false); + expect(node.children).toHaveLength(0); + }); + + test('should get last child node', () => { + const node = wrapper.find('#test').prop('getNodeForRow')(4); + + expect(node.eventIndex).toEqual(5); + expect(node.isCollapsed).toEqual(false); + expect(node.children).toHaveLength(0); + }); + + test('should get a second root-level node', () => { + const lastNode = { + id: 110, + counter: 10, + rowNumber: 9, + uuid: 'abc-010', + event_level: 0, + parent_uuid: '', + }; + wrapper.find('#test').prop('addEvents')([lastNode]); + + const node = wrapper.find('#test').prop('getNodeForRow')(9); + + expect(node).toEqual({ + eventIndex: 10, + isCollapsed: false, + children: [], + }); + }); + + test('should return null if no node matches index', () => { + const node = wrapper.find('#test').prop('getNodeForRow')(10); + + expect(node).toEqual(null); + }); + + test('should return null if no nodes loaded', () => { + wrapper = shallow(); + const node = wrapper.find('#test').prop('getNodeForRow')(5); + + expect(node).toEqual(null); + }); + + test('should return collapsed node', () => { + wrapper.find('#test').prop('toggleNodeIsCollapsed')('abc-002'); + + const node = wrapper.find('#test').prop('getNodeForRow')(1); + + expect(node.eventIndex).toEqual(2); + expect(node.isCollapsed).toBe(true); + }); + + test('should skip nodes with collapsed parent', () => { + wrapper.find('#test').prop('toggleNodeIsCollapsed')('abc-002'); + + const node = wrapper.find('#test').prop('getNodeForRow')(2); + expect(node.eventIndex).toEqual(6); + expect(node.isCollapsed).toBe(false); + + const node2 = wrapper.find('#test').prop('getNodeForRow')(4); + expect(node2.eventIndex).toEqual(8); + expect(node2.isCollapsed).toBe(false); + }); + + test('should skip deeply-nested collapsed nodes', () => { + wrapper = shallow(); + wrapper.find('#test').prop('addEvents')([ + { id: 101, counter: 1, rowNumber: 0, uuid: 'abc-001', event_level: 0 }, + { + id: 102, + counter: 2, + rowNumber: 1, + uuid: 'abc-002', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 103, + counter: 3, + rowNumber: 2, + uuid: 'abc-003', + event_level: 2, + parent_uuid: 'abc-002', + }, + { + id: 104, + counter: 4, + rowNumber: 3, + uuid: 'abc-004', + event_level: 2, + parent_uuid: 'abc-002', + }, + { + id: 105, + counter: 5, + rowNumber: 4, + uuid: 'abc-005', + event_level: 3, + parent_uuid: 'abc-004', + }, + { + id: 106, + counter: 6, + rowNumber: 5, + uuid: 'abc-006', + event_level: 3, + parent_uuid: 'abc-004', + }, + { + id: 107, + counter: 7, + rowNumber: 6, + uuid: 'abc-007', + event_level: 2, + parent_uuid: 'abc-002', + }, + { + id: 108, + counter: 8, + rowNumber: 7, + uuid: 'abc-008', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 109, + counter: 9, + rowNumber: 8, + uuid: 'abc-009', + event_level: 2, + parent_uuid: 'abc-008', + }, + { + id: 110, + counter: 10, + rowNumber: 9, + uuid: 'abc-010', + event_level: 2, + parent_uuid: 'abc-008', + }, + ]); + wrapper.update(); + wrapper.find('#test').prop('toggleNodeIsCollapsed')('abc-004'); + wrapper.update(); + + const node = wrapper.find('#test').prop('getNodeForRow')(5); + expect(node.eventIndex).toEqual(8); + expect(node.isCollapsed).toBe(false); + }); + + test('should skip full sub-tree of collapsed node', () => { + wrapper = shallow(); + wrapper.find('#test').prop('addEvents')([ + { id: 101, counter: 1, rowNumber: 0, uuid: 'abc-001', event_level: 0 }, + { + id: 102, + counter: 2, + rowNumber: 1, + uuid: 'abc-002', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 103, + counter: 3, + rowNumber: 2, + uuid: 'abc-003', + event_level: 2, + parent_uuid: 'abc-002', + }, + { + id: 104, + counter: 4, + rowNumber: 3, + uuid: 'abc-004', + event_level: 2, + parent_uuid: 'abc-002', + }, + { + id: 105, + counter: 5, + rowNumber: 4, + uuid: 'abc-005', + event_level: 3, + parent_uuid: 'abc-004', + }, + { + id: 106, + counter: 6, + rowNumber: 5, + uuid: 'abc-006', + event_level: 3, + parent_uuid: 'abc-004', + }, + { + id: 107, + counter: 7, + rowNumber: 6, + uuid: 'abc-007', + event_level: 2, + parent_uuid: 'abc-002', + }, + { + id: 108, + counter: 8, + rowNumber: 7, + uuid: 'abc-008', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 109, + counter: 9, + rowNumber: 8, + uuid: 'abc-009', + event_level: 2, + parent_uuid: 'abc-008', + }, + { + id: 110, + counter: 10, + rowNumber: 9, + uuid: 'abc-010', + event_level: 2, + parent_uuid: 'abc-008', + }, + ]); + wrapper.find('#test').prop('toggleNodeIsCollapsed')('abc-002'); + + const node = wrapper.find('#test').prop('getNodeForRow')(3); + expect(node.eventIndex).toEqual(9); + expect(node.isCollapsed).toBe(false); + }); + + test('should get node after gap in loaded children', async () => { + const fetchNumEvents = jest.fn(); + fetchNumEvents.mockImplementation((index) => { + const counts = { + 1: 52, + 2: 3, + 6: 47, + }; + return Promise.resolve(counts[index]); + }); + wrapper = mount(); + const laterEvents = [ + { + id: 151, + counter: 51, + rowNumber: 50, + uuid: 'abc-051', + event_level: 2, + parent_uuid: 'abc-006', + }, + { + id: 152, + counter: 52, + rowNumber: 51, + uuid: 'abc-052', + event_level: 2, + parent_uuid: 'abc-006', + }, + { + id: 153, + counter: 53, + rowNumber: 52, + uuid: 'abc-052', + event_level: 2, + parent_uuid: 'abc-006', + }, + ]; + await act(async () => { + wrapper.find('#test').prop('addEvents')(eventsList); + wrapper.find('#test').prop('addEvents')(laterEvents); + }); + wrapper.update(); + wrapper.update(); + + const node = wrapper.find('#test').prop('getNodeForRow')(51); + expect(node).toEqual({ + eventIndex: 52, + isCollapsed: false, + children: [], + }); + }); + + test('should skip gaps in counter', () => { + const nextNode = { + id: 112, + counter: 12, + rowNumber: 9, + uuid: 'abc-012', + event_level: 0, + parent_uuid: '', + }; + wrapper.find('#test').prop('addEvents')([nextNode]); + + const node = wrapper.find('#test').prop('getNodeForRow')(9); + + expect(node).toEqual({ + eventIndex: 12, + isCollapsed: false, + children: [], + }); + }); + }); + + describe('getNumCollapsedEvents', () => { + let wrapper; + beforeEach(() => { + wrapper = shallow(); + wrapper.find('#test').prop('addEvents')(eventsList); + }); + + test('should return number of collapsed events', () => { + expect(wrapper.find('#test').prop('getNumCollapsedEvents')()).toEqual(0); + + wrapper.find('#test').prop('toggleNodeIsCollapsed')('abc-002'); + expect(wrapper.find('#test').prop('getNumCollapsedEvents')()).toEqual(3); + }); + }); + + describe('getEventforRow', () => { + let wrapper; + beforeEach(() => { + wrapper = shallow(); + wrapper.find('#test').prop('addEvents')(eventsList); + }); + + test('should get event & node', () => { + const { event, node } = wrapper.find('#test').prop('getEventForRow')(5); + expect(event).toEqual(eventsList[5]); + expect(node).toEqual({ + eventIndex: 6, + isCollapsed: false, + children: [ + { eventIndex: 7, isCollapsed: false, children: [] }, + { eventIndex: 8, isCollapsed: false, children: [] }, + { eventIndex: 9, isCollapsed: false, children: [] }, + ], + }); + }); + }); + + describe('getEvent', () => { + let wrapper; + beforeEach(() => { + wrapper = shallow(); + wrapper.find('#test').prop('addEvents')(eventsList); + }); + + test('should get event object', () => { + const event = wrapper.find('#test').prop('getEvent')(7); + expect(event).toEqual(eventsList[6]); + }); + }); + + describe('getTotalNumChildren', () => { + let wrapper; + beforeEach(() => { + wrapper = shallow(); + wrapper.find('#test').prop('addEvents')(eventsList); + }); + + test('should get basic number of children', () => { + expect( + wrapper.find('#test').prop('getTotalNumChildren')('abc-002') + ).toEqual(3); + }); + + test('should get total number of nested children', () => { + expect( + wrapper.find('#test').prop('getTotalNumChildren')('abc-001') + ).toEqual(8); + }); + }); + + describe('getCounterForRow', () => { + test('should return exact counter when no nodes are collapsed', () => { + const wrapper = shallow(); + wrapper.find('#test').prop('addEvents')(eventsList); + const getCounterForRow = wrapper.find('#test').prop('getCounterForRow'); + expect(getCounterForRow(8)).toEqual(9); + }); + + test('should return estimated counter when node not loaded', () => { + const wrapper = shallow(); + wrapper.find('#test').prop('addEvents')(eventsList); + const getCounterForRow = wrapper.find('#test').prop('getCounterForRow'); + expect(getCounterForRow(12)).toEqual(13); + }); + + test('should return estimated counter when node is non-loaded child', async () => { + callbacks.fetchNumEvents.mockImplementation((counter) => { + const children = { + 1: 28, + 2: 3, + 6: 23, + }; + return children[counter]; + }); + const wrapper = mount(); + wrapper.update(); + await act(async () => { + wrapper.find('#test').prop('addEvents')(eventsList); + wrapper.find('#test').prop('addEvents')([ + { + id: 130, + counter: 30, + rowNumber: 29, + uuid: 'abc-030', + event_level: 1, + parent_uuid: 'abc-001', + }, + ]); + }); + wrapper.update(); + + const getCounterForRow = wrapper.find('#test').prop('getCounterForRow'); + + expect(getCounterForRow(15)).toEqual(16); + }); + + test('should skip over collapsed subtree', () => { + const wrapper = shallow(); + wrapper.find('#test').prop('addEvents')(eventsList); + wrapper.find('#test').prop('toggleNodeIsCollapsed')('abc-002'); + const getCounterForRow = wrapper.find('#test').prop('getCounterForRow'); + expect(getCounterForRow(4)).toEqual(8); + }); + + test('should estimate counter after skipping collapsed subtree', async () => { + callbacks.fetchNumEvents.mockImplementation((counter) => { + const children = { + 1: 85, + 2: 66, + 69: 17, + }; + return children[counter]; + }); + const wrapper = mount(); + await act(async () => { + wrapper.find('#test').prop('addEvents')([ + eventsList[0], + eventsList[1], + eventsList[2], + eventsList[3], + eventsList[4], + { + id: 169, + counter: 69, + rowNumber: 68, + event_level: 2, + uuid: 'abc-069', + parent_uuid: 'abc-001', + }, + ]); + wrapper.find('#test').prop('toggleNodeIsCollapsed')('abc-002'); + }); + wrapper.update(); + + const getCounterForRow = wrapper.find('#test').prop('getCounterForRow'); + expect(getCounterForRow(3)).toEqual(70); + }); + + test('should estimate counter in gap between loaded events', async () => { + callbacks.fetchNumEvents.mockImplementation( + (counter) => + ({ + 1: 30, + }[counter]) + ); + const wrapper = mount(); + await act(async () => { + wrapper.find('#test').prop('addEvents')([ + eventsList[0], + { + id: 102, + counter: 2, + rowNumber: 1, + uuid: 'abc-002', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 103, + counter: 3, + rowNumber: 2, + uuid: 'abc-003', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 120, + counter: 20, + rowNumber: 19, + uuid: 'abc-020', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 121, + counter: 21, + rowNumber: 20, + uuid: 'abc-021', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 122, + counter: 22, + rowNumber: 21, + uuid: 'abc-022', + event_level: 1, + parent_uuid: 'abc-001', + }, + ]); + }); + wrapper.update(); + + const getCounterForRow = wrapper.find('#test').prop('getCounterForRow'); + expect(getCounterForRow(10)).toEqual(11); + }); + + test('should estimate counter in gap before loaded sibling events', async () => { + callbacks.fetchNumEvents.mockImplementation( + (counter) => + ({ + 1: 30, + }[counter]) + ); + const wrapper = mount(); + await act(async () => { + wrapper.find('#test').prop('addEvents')([ + eventsList[0], + { + id: 120, + counter: 20, + rowNumber: 19, + uuid: 'abc-020', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 121, + counter: 21, + rowNumber: 20, + uuid: 'abc-021', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 122, + counter: 22, + rowNumber: 21, + uuid: 'abc-022', + event_level: 1, + parent_uuid: 'abc-001', + }, + ]); + }); + wrapper.update(); + + const getCounterForRow = wrapper.find('#test').prop('getCounterForRow'); + expect(getCounterForRow(10)).toEqual(11); + }); + + test('should get counter for node between unloaded siblings', async () => { + callbacks.fetchNumEvents.mockImplementation( + (counter) => + ({ + 1: 30, + }[counter]) + ); + const wrapper = mount(); + await act(async () => { + wrapper.find('#test').prop('addEvents')([ + eventsList[0], + { + id: 109, + counter: 9, + rowNumber: 8, + uuid: 'abc-009', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 110, + counter: 10, + rowNumber: 9, + uuid: 'abc-010', + event_level: 1, + parent_uuid: 'abc-001', + }, + { + id: 111, + counter: 11, + rowNumber: 10, + uuid: 'abc-011', + event_level: 1, + parent_uuid: 'abc-001', + }, + ]); + }); + wrapper.update(); + + const getCounterForRow = wrapper.find('#test').prop('getCounterForRow'); + expect(getCounterForRow(10)).toEqual(11); + }); + }); +});