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);
+ });
+ });
+});