Job Output expand/collapse take 2 (#11312)

This commit is contained in:
Keith Grant 2021-12-09 11:08:31 -08:00 committed by GitHub
parent a259e48377
commit 675d0d28d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 2799 additions and 410 deletions

View File

@ -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,

View File

@ -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;

View File

@ -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 : (
<div style={style} type={type}>
{lineTextHtml.map(
({ lineNumber, html }) =>
lineNumber >= 0 && (
<JobEventLine
onClick={isClickable ? onJobEventClick : undefined}
key={`${counter}-${lineNumber}`}
isFirst={lineNumber === 0}
isClickable={isClickable}
>
<JobEventLineToggle />
<JobEventLineNumber>{lineNumber}</JobEventLineNumber>
<JobEventLineText
type="job_event_line_text"
dangerouslySetInnerHTML={{
__html: html,
}}
/>
</JobEventLine>
)
)}
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 : (
<div style={style} type={event.type}>
{lineTextHtml.map(({ lineNumber, html }, index) => {
if (lineNumber < 0) {
return null;
}
const canToggle = index === toggleLineIndex;
return (
<JobEventLine
onClick={isClickable ? onJobEventClick : undefined}
key={`${event.counter}-${lineNumber}`}
isFirst={lineNumber === 0}
isClickable={isClickable}
>
<JobEventLineToggle
canToggle={canToggle}
isCollapsed={isCollapsed}
onToggle={onToggleCollapsed}
/>
<JobEventLineNumber>
{lineNumber}
<JobEventEllipsis isCollapsed={isCollapsed && canToggle} />
</JobEventLineNumber>
<JobEventLineText
type="job_event_line_text"
dangerouslySetInnerHTML={{
__html: html,
}}
/>
</JobEventLine>
);
})}
</div>
);
}

View File

@ -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('<JobEvent />', () => {
test('initially renders successfully', () => {
mountWithContexts(
<JobEvent
lineTextHtml={mockOnPlayStartLineTextHtml}
{...mockOnPlayStartEvent}
/>
);
});
test('playbook event timestamps are rendered', () => {
let wrapper = mountWithContexts(
const wrapper1 = shallow(
<JobEvent
lineTextHtml={mockOnPlayStartLineTextHtml}
{...mockOnPlayStartEvent}
event={mockOnPlayStartEvent}
/>
);
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(
<JobEvent
lineTextHtml={mockSingleDigitTimestampEventLineTextHtml}
{...singleDigitTimestampEvent}
event={singleDigitTimestampEvent}
/>
);
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(
<JobEvent lineTextHtml={mockAnsiLineTextHtml} {...mockRunnerOnOkEvent} />
const wrapper = shallow(
<JobEvent
lineTextHtml={mockAnsiLineTextHtml}
event={mockRunnerOnOkEvent}
/>
);
const lineText = wrapper.find(selectors.lineText);
const lineText = wrapper.find('JobEventLineText');
expect(
lineText
.html()
.includes('<span class="output--1977390340">ok: [localhost]</span>')
.prop('dangerouslySetInnerHTML')
.__html.includes(
'<span class="output--1977390340">ok: [localhost]</span>'
)
).toBe(true);
});
test("events without stdout aren't rendered", () => {
const missingStdoutEvent = { ...mockOnPlayStartEvent };
delete missingStdoutEvent.stdout;
const wrapper = mountWithContexts(<JobEvent {...missingStdoutEvent} />);
expect(wrapper.find(selectors.lineText)).toHaveLength(0);
const wrapper = shallow(
<JobEvent lineTextHtml={[]} event={missingStdoutEvent} />
);
expect(wrapper.find('JobEventLineText')).toHaveLength(0);
});
});

View File

@ -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 && (
<div style={style}>

View File

@ -5,17 +5,17 @@ import JobEventSkeleton from './JobEventSkeleton';
const contentSelector = 'JobEventSkeletonContent';
describe('<JobEvenSkeletont />', () => {
describe('<JobEvenSkeleton />', () => {
test('initially renders successfully', () => {
const wrapper = mountWithContexts(
<JobEventSkeleton contentLength={80} counter={100} />
<JobEventSkeleton measure={jest.fn()} contentLength={80} counter={100} />
);
expect(wrapper.find(contentSelector).length).toEqual(1);
});
test('always skips first counter', () => {
const wrapper = mountWithContexts(
<JobEventSkeleton contentLength={80} counter={1} />
<JobEventSkeleton measure={jest.fn()} contentLength={80} counter={1} />
);
expect(wrapper.find(contentSelector).length).toEqual(0);
});

View File

@ -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] ? (
<JobEvent
isClickable={isHostEvent(results[index])}
onJobEventClick={() => handleHostEventClick(results[index])}
className="row"
style={style}
lineTextHtml={actualLineTextHtml}
index={index}
{...results[index]}
/>
) : (
<JobEventSkeleton
className="row"
style={style}
counter={index}
contentLength={80}
/>
)}
{({ measure }) =>
event ? (
<JobEvent
isClickable={isHostEvent(event)}
onJobEventClick={() => 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);
}}
/>
) : (
<JobEventSkeleton
className="row"
style={style}
counter={index}
contentLength={80}
measure={measure}
/>
)
}
</CellMeasurer>
);
};
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 }) {
<InfiniteLoader
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={remoteRowCount}
rowCount={totalNonCollapsedRows + wsEvents.length}
minimumBatchSize={50}
>
{({ onRowsRendered, registerChild }) => (
<AutoSizer nonce={window.NONCE_ID} onResize={handleResize}>
@ -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"

View File

@ -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('<JobOutput />', () => {
let wrapper;
@ -101,88 +44,18 @@ describe('<JobOutput />', () => {
beforeEach(() => {
applyJobEventMock(mockJobEventsData);
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
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(<JobOutput job={mockJob} />);
});
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('<JobOutput />', () => {
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(<JobOutput job={mockJob} />);
});
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 () => {

View File

@ -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(
<JobOutputSearch
job={{
status: 'successful',
}}
qsConfig={{
defaultParams: { page: 1 },
integerFields: ['page', 'page_size'],
}}
/>,
{
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');
});
});

View File

@ -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);

View File

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

View File

@ -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,
});
});

View File

@ -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 {

View File

@ -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 <Wrapper>...</Wrapper>;
}

View File

@ -8,10 +8,6 @@ export default styled.div`
cursor: ${(props) => (props.isClickable ? 'pointer' : 'default')};
}
&:hover div {
background-color: white;
}
&--hidden {
display: none;
}

View File

@ -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 <Wrapper />;
}
return (
<Wrapper>
<Button onClick={onToggle} type="button">
{isCollapsed ? (
<AngleRightIcon size="sm" title={t`Expand section`} />
) : (
<AngleDownIcon size="sm" title={t`Collapse section`} />
)}
</Button>
</Wrapper>
);
}

View File

@ -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';

View File

@ -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;
}

File diff suppressed because it is too large Load Diff