Infinite scroll: implementation clean up (#81725)

* Infinite scroll: clean up for clarity

* Infinite scroll: clean up test

* Formatting

* Infinite scroll: disable by feature flag

* Infinite scroll: improve visibility of the lower loader
This commit is contained in:
Matias Chomicki 2024-02-02 13:45:20 +01:00 committed by GitHub
parent 574078b977
commit e749c2b062
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 117 additions and 115 deletions

View File

@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { LogRowModel, dateTimeForTimeZone } from '@grafana/data'; import { LogRowModel, dateTimeForTimeZone } from '@grafana/data';
import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil'; import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil';
import { config } from '@grafana/runtime';
import { LogsSortOrder } from '@grafana/schema'; import { LogsSortOrder } from '@grafana/schema';
import { InfiniteScroll, Props, SCROLLING_THRESHOLD } from './InfiniteScroll'; import { InfiniteScroll, Props, SCROLLING_THRESHOLD } from './InfiniteScroll';
@ -50,6 +51,45 @@ function ScrollWithWrapper({ children, ...props }: Props) {
); );
} }
function setup(loadMoreMock: () => void, startPosition: number, rows: LogRowModel[], order: LogsSortOrder) {
const { element, events } = getMockElement(startPosition);
function scrollTo(position: number) {
element.scrollTop = position;
act(() => {
events['scroll'](new Event('scroll'));
});
}
function wheel(deltaY: number) {
act(() => {
const event = new WheelEvent('wheel', { deltaY });
events['wheel'](event);
});
}
render(
<InfiniteScroll
{...defaultProps}
sortOrder={order}
rows={rows}
scrollElement={element as unknown as HTMLDivElement}
loadMoreLogs={loadMoreMock}
>
<div data-testid="contents" style={{ height: 100 }} />
</InfiniteScroll>
);
return { element, events, scrollTo, wheel };
}
beforeAll(() => {
config.featureToggles.logsInfiniteScrolling = true;
});
afterAll(() => {
config.featureToggles.logsInfiniteScrolling = false;
});
describe('InfiniteScroll', () => { describe('InfiniteScroll', () => {
test('Wraps components without adding DOM elements', async () => { test('Wraps components without adding DOM elements', async () => {
const { container } = render( const { container } = render(
@ -81,39 +121,29 @@ describe('InfiniteScroll', () => {
rows = createLogRows(absoluteRange.from + 2 * SCROLLING_THRESHOLD, absoluteRange.to - 2 * SCROLLING_THRESHOLD); rows = createLogRows(absoluteRange.from + 2 * SCROLLING_THRESHOLD, absoluteRange.to - 2 * SCROLLING_THRESHOLD);
}); });
function setup(loadMoreMock: () => void, startPosition: number) {
const { element, events } = getMockElement(startPosition);
render(
<InfiniteScroll
{...defaultProps}
sortOrder={order}
rows={rows}
scrollElement={element as unknown as HTMLDivElement}
loadMoreLogs={loadMoreMock}
>
<div data-testid="contents" style={{ height: 100 }} />
</InfiniteScroll>
);
return { element, events };
}
test.each([ test.each([
['top', 10, 0], ['top', 10, 0],
['bottom', 90, 100], ['bottom', 50, 60],
])('Requests more logs when scrolling %s', async (_: string, startPosition: number, endPosition: number) => { ])(
const loadMoreMock = jest.fn(); 'Requests more logs when scrolling %s',
const { element, events } = setup(loadMoreMock, startPosition); async (direction: string, startPosition: number, endPosition: number) => {
const loadMoreMock = jest.fn();
const { scrollTo, element } = setup(loadMoreMock, startPosition, rows, order);
expect(await screen.findByTestId('contents')).toBeInTheDocument(); expect(await screen.findByTestId('contents')).toBeInTheDocument();
element.scrollTop = endPosition;
act(() => { scrollTo(endPosition);
events['scroll'](new Event('scroll'));
});
expect(loadMoreMock).toHaveBeenCalled(); expect(loadMoreMock).toHaveBeenCalled();
expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); expect(await screen.findByTestId('Spinner')).toBeInTheDocument();
}); if (direction === 'bottom') {
// Bottom loader visibility trick
expect(element.scrollTo).toHaveBeenCalled();
} else {
expect(element.scrollTo).not.toHaveBeenCalled();
}
}
);
test.each([ test.each([
['up', -5, 0], ['up', -5, 0],
@ -122,14 +152,11 @@ describe('InfiniteScroll', () => {
'Requests more logs when moving the mousewheel %s', 'Requests more logs when moving the mousewheel %s',
async (_: string, deltaY: number, startPosition: number) => { async (_: string, deltaY: number, startPosition: number) => {
const loadMoreMock = jest.fn(); const loadMoreMock = jest.fn();
const { events } = setup(loadMoreMock, startPosition); const { wheel } = setup(loadMoreMock, startPosition, rows, order);
expect(await screen.findByTestId('contents')).toBeInTheDocument(); expect(await screen.findByTestId('contents')).toBeInTheDocument();
act(() => { wheel(deltaY);
const event = new WheelEvent('wheel', { deltaY });
events['wheel'](event);
});
expect(loadMoreMock).toHaveBeenCalled(); expect(loadMoreMock).toHaveBeenCalled();
expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); expect(await screen.findByTestId('Spinner')).toBeInTheDocument();
@ -138,33 +165,28 @@ describe('InfiniteScroll', () => {
test('Does not request more logs when there is no scroll', async () => { test('Does not request more logs when there is no scroll', async () => {
const loadMoreMock = jest.fn(); const loadMoreMock = jest.fn();
const { element, events } = setup(loadMoreMock, 0); const { scrollTo, element } = setup(loadMoreMock, 0, rows, order);
expect(await screen.findByTestId('contents')).toBeInTheDocument(); expect(await screen.findByTestId('contents')).toBeInTheDocument();
element.clientHeight = 40; element.clientHeight = 40;
element.scrollHeight = element.clientHeight; element.scrollHeight = element.clientHeight;
act(() => { scrollTo(40);
events['scroll'](new Event('scroll'));
});
expect(loadMoreMock).not.toHaveBeenCalled(); expect(loadMoreMock).not.toHaveBeenCalled();
expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument();
}); });
test('Requests newer logs from the most recent timestamp', async () => { test('Requests newer logs from the most recent timestamp', async () => {
const startPosition = order === LogsSortOrder.Descending ? 10 : 90; // Scroll top const startPosition = order === LogsSortOrder.Descending ? 10 : 50; // Scroll top
const endPosition = order === LogsSortOrder.Descending ? 0 : 100; // Scroll bottom const endPosition = order === LogsSortOrder.Descending ? 0 : 60; // Scroll bottom
const loadMoreMock = jest.fn(); const loadMoreMock = jest.fn();
const { element, events } = setup(loadMoreMock, startPosition); const { scrollTo } = setup(loadMoreMock, startPosition, rows, order);
expect(await screen.findByTestId('contents')).toBeInTheDocument(); expect(await screen.findByTestId('contents')).toBeInTheDocument();
element.scrollTop = endPosition;
act(() => { scrollTo(endPosition);
events['scroll'](new Event('scroll'));
});
expect(loadMoreMock).toHaveBeenCalledWith({ expect(loadMoreMock).toHaveBeenCalledWith({
from: rows[rows.length - 1].timeEpochMs, from: rows[rows.length - 1].timeEpochMs,
@ -173,18 +195,15 @@ describe('InfiniteScroll', () => {
}); });
test('Requests older logs from the oldest timestamp', async () => { test('Requests older logs from the oldest timestamp', async () => {
const startPosition = order === LogsSortOrder.Ascending ? 10 : 90; // Scroll top const startPosition = order === LogsSortOrder.Ascending ? 10 : 50; // Scroll top
const endPosition = order === LogsSortOrder.Ascending ? 0 : 100; // Scroll bottom const endPosition = order === LogsSortOrder.Ascending ? 0 : 60; // Scroll bottom
const loadMoreMock = jest.fn(); const loadMoreMock = jest.fn();
const { element, events } = setup(loadMoreMock, startPosition); const { scrollTo } = setup(loadMoreMock, startPosition, rows, order);
expect(await screen.findByTestId('contents')).toBeInTheDocument(); expect(await screen.findByTestId('contents')).toBeInTheDocument();
element.scrollTop = endPosition;
act(() => { scrollTo(endPosition);
events['scroll'](new Event('scroll'));
});
expect(loadMoreMock).toHaveBeenCalledWith({ expect(loadMoreMock).toHaveBeenCalledWith({
from: absoluteRange.from, from: absoluteRange.from,
@ -193,39 +212,20 @@ describe('InfiniteScroll', () => {
}); });
describe('With absolute range matching visible range', () => { describe('With absolute range matching visible range', () => {
function setup(loadMoreMock: () => void, startPosition: number, rows: LogRowModel[]) {
const { element, events } = getMockElement(startPosition);
render(
<InfiniteScroll
{...defaultProps}
sortOrder={order}
rows={rows}
scrollElement={element as unknown as HTMLDivElement}
loadMoreLogs={loadMoreMock}
>
<div data-testid="contents" style={{ height: 100 }} />
</InfiniteScroll>
);
return { element, events };
}
test.each([ test.each([
['top', 10, 0], ['top', 10, 0],
['bottom', 90, 100], ['bottom', 50, 60],
])( ])(
'It does not request more when scrolling %s', 'It does not request more when scrolling %s',
async (_: string, startPosition: number, endPosition: number) => { async (_: string, startPosition: number, endPosition: number) => {
// Visible range matches the current range // Visible range matches the current range
const rows = createLogRows(absoluteRange.from, absoluteRange.to); const rows = createLogRows(absoluteRange.from, absoluteRange.to);
const loadMoreMock = jest.fn(); const loadMoreMock = jest.fn();
const { element, events } = setup(loadMoreMock, startPosition, rows); const { scrollTo } = setup(loadMoreMock, startPosition, rows, order);
expect(await screen.findByTestId('contents')).toBeInTheDocument(); expect(await screen.findByTestId('contents')).toBeInTheDocument();
element.scrollTop = endPosition;
act(() => { scrollTo(endPosition);
events['scroll'](new Event('scroll'));
});
expect(loadMoreMock).not.toHaveBeenCalled(); expect(loadMoreMock).not.toHaveBeenCalled();
expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument();
@ -235,39 +235,20 @@ describe('InfiniteScroll', () => {
}); });
describe('With relative range matching visible range', () => { describe('With relative range matching visible range', () => {
function setup(loadMoreMock: () => void, startPosition: number, rows: LogRowModel[]) {
const { element, events } = getMockElement(startPosition);
render(
<InfiniteScroll
{...defaultProps}
sortOrder={order}
rows={rows}
scrollElement={element as unknown as HTMLDivElement}
loadMoreLogs={loadMoreMock}
>
<div data-testid="contents" style={{ height: 100 }} />
</InfiniteScroll>
);
return { element, events };
}
test.each([ test.each([
['top', 10, 0], ['top', 10, 0],
['bottom', 90, 100], ['bottom', 50, 60],
])( ])(
'It does not request more when scrolling %s', 'It does not request more when scrolling %s',
async (_: string, startPosition: number, endPosition: number) => { async (_: string, startPosition: number, endPosition: number) => {
// Visible range matches the current range // Visible range matches the current range
const rows = createLogRows(absoluteRange.from, absoluteRange.to); const rows = createLogRows(absoluteRange.from, absoluteRange.to);
const loadMoreMock = jest.fn(); const loadMoreMock = jest.fn();
const { element, events } = setup(loadMoreMock, startPosition, rows); const { scrollTo } = setup(loadMoreMock, startPosition, rows, order);
expect(await screen.findByTestId('contents')).toBeInTheDocument(); expect(await screen.findByTestId('contents')).toBeInTheDocument();
element.scrollTop = endPosition;
act(() => { scrollTo(endPosition);
events['scroll'](new Event('scroll'));
});
expect(loadMoreMock).not.toHaveBeenCalled(); expect(loadMoreMock).not.toHaveBeenCalled();
expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument();
@ -301,6 +282,7 @@ function getMockElement(scrollTop: number) {
scrollHeight: 100, scrollHeight: 100,
clientHeight: 40, clientHeight: 40,
scrollTop, scrollTop,
scrollTo: jest.fn(),
}; };
return { element, events }; return { element, events };

View File

@ -3,7 +3,7 @@ import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { AbsoluteTimeRange, LogRowModel, TimeRange } from '@grafana/data'; import { AbsoluteTimeRange, LogRowModel, TimeRange } from '@grafana/data';
import { convertRawToRange, isRelativeTime, isRelativeTimeRange } from '@grafana/data/src/datetime/rangeutil'; import { convertRawToRange, isRelativeTime, isRelativeTimeRange } from '@grafana/data/src/datetime/rangeutil';
import { reportInteraction } from '@grafana/runtime'; import { config, reportInteraction } from '@grafana/runtime';
import { LogsSortOrder, TimeZone } from '@grafana/schema'; import { LogsSortOrder, TimeZone } from '@grafana/schema';
import { LoadingIndicator } from './LoadingIndicator'; import { LoadingIndicator } from './LoadingIndicator';
@ -33,13 +33,16 @@ export const InfiniteScroll = ({
const [lowerOutOfRange, setLowerOutOfRange] = useState(false); const [lowerOutOfRange, setLowerOutOfRange] = useState(false);
const [upperLoading, setUpperLoading] = useState(false); const [upperLoading, setUpperLoading] = useState(false);
const [lowerLoading, setLowerLoading] = useState(false); const [lowerLoading, setLowerLoading] = useState(false);
const rowsRef = useRef<LogRowModel[]>(rows);
const lastScroll = useRef<number>(scrollElement?.scrollTop || 0); const lastScroll = useRef<number>(scrollElement?.scrollTop || 0);
// Reset messages when range/order/rows change
useEffect(() => { useEffect(() => {
setUpperOutOfRange(false); setUpperOutOfRange(false);
setLowerOutOfRange(false); setLowerOutOfRange(false);
}, [range, rows, sortOrder]); }, [range, rows, sortOrder]);
// Reset loading messages when loading stops
useEffect(() => { useEffect(() => {
if (!loading) { if (!loading) {
setUpperLoading(false); setUpperLoading(false);
@ -47,13 +50,32 @@ export const InfiniteScroll = ({
} }
}, [loading]); }, [loading]);
// Ensure bottom loader visibility
useEffect(() => {
if (lowerLoading && scrollElement) {
scrollElement.scrollTo(0, scrollElement.scrollHeight - scrollElement.clientHeight);
}
}, [lowerLoading, scrollElement]);
// Request came back with no new past rows
useEffect(() => {
if (rows !== rowsRef.current && rows.length === rowsRef.current.length && (upperLoading || lowerLoading)) {
if (sortOrder === LogsSortOrder.Descending && lowerLoading) {
setLowerOutOfRange(true);
} else if (sortOrder === LogsSortOrder.Ascending && upperLoading) {
setUpperOutOfRange(true);
}
}
rowsRef.current = rows;
}, [lowerLoading, rows, sortOrder, upperLoading]);
useEffect(() => { useEffect(() => {
if (!scrollElement || !loadMoreLogs) { if (!scrollElement || !loadMoreLogs) {
return; return;
} }
function handleScroll(event: Event | WheelEvent) { function handleScroll(event: Event | WheelEvent) {
if (!scrollElement || !loadMoreLogs || !rows.length || loading) { if (!scrollElement || !loadMoreLogs || !rows.length || loading || !config.featureToggles.logsInfiniteScrolling) {
return; return;
} }
event.stopImmediatePropagation(); event.stopImmediatePropagation();
@ -69,15 +91,12 @@ export const InfiniteScroll = ({
} }
function scrollTop() { function scrollTop() {
if (!canScrollTop(getVisibleRange(rows), range, timeZone, sortOrder)) { const newRange = canScrollTop(getVisibleRange(rows), range, timeZone, sortOrder);
if (!newRange) {
setUpperOutOfRange(true); setUpperOutOfRange(true);
return; return;
} }
setUpperOutOfRange(false); setUpperOutOfRange(false);
const newRange =
sortOrder === LogsSortOrder.Descending
? getNextRange(getVisibleRange(rows), range, timeZone)
: getPrevRange(getVisibleRange(rows), range);
loadMoreLogs?.(newRange); loadMoreLogs?.(newRange);
setUpperLoading(true); setUpperLoading(true);
reportInteraction('grafana_logs_infinite_scrolling', { reportInteraction('grafana_logs_infinite_scrolling', {
@ -87,15 +106,12 @@ export const InfiniteScroll = ({
} }
function scrollBottom() { function scrollBottom() {
if (!canScrollBottom(getVisibleRange(rows), range, timeZone, sortOrder)) { const newRange = canScrollBottom(getVisibleRange(rows), range, timeZone, sortOrder);
if (!newRange) {
setLowerOutOfRange(true); setLowerOutOfRange(true);
return; return;
} }
setLowerOutOfRange(false); setLowerOutOfRange(false);
const newRange =
sortOrder === LogsSortOrder.Descending
? getPrevRange(getVisibleRange(rows), range)
: getNextRange(getVisibleRange(rows), range, timeZone);
loadMoreLogs?.(newRange); loadMoreLogs?.(newRange);
setLowerLoading(true); setLowerLoading(true);
reportInteraction('grafana_logs_infinite_scrolling', { reportInteraction('grafana_logs_infinite_scrolling', {
@ -160,9 +176,8 @@ function shouldLoadMore(event: Event | WheelEvent, element: HTMLDivElement, last
scrollDirection === ScrollDirection.Top scrollDirection === ScrollDirection.Top
? element.scrollTop ? element.scrollTop
: element.scrollHeight - element.scrollTop - element.clientHeight; : element.scrollHeight - element.scrollTop - element.clientHeight;
const coef = 1;
return diff <= coef ? scrollDirection : ScrollDirection.NoScroll; return diff <= 1 ? scrollDirection : ScrollDirection.NoScroll;
} }
function getVisibleRange(rows: LogRowModel[]) { function getVisibleRange(rows: LogRowModel[]) {
@ -195,13 +210,16 @@ function canScrollTop(
currentRange: TimeRange, currentRange: TimeRange,
timeZone: TimeZone, timeZone: TimeZone,
sortOrder: LogsSortOrder sortOrder: LogsSortOrder
) { ): AbsoluteTimeRange | undefined {
if (sortOrder === LogsSortOrder.Descending) { if (sortOrder === LogsSortOrder.Descending) {
// When requesting new logs, update the current range if using relative time ranges. // When requesting new logs, update the current range if using relative time ranges.
currentRange = updateCurrentRange(currentRange, timeZone); currentRange = updateCurrentRange(currentRange, timeZone);
return currentRange.to.valueOf() - visibleRange.to > SCROLLING_THRESHOLD; const canScroll = currentRange.to.valueOf() - visibleRange.to > SCROLLING_THRESHOLD;
return canScroll ? getNextRange(visibleRange, currentRange, timeZone) : undefined;
} }
return Math.abs(currentRange.from.valueOf() - visibleRange.from) > SCROLLING_THRESHOLD;
const canScroll = Math.abs(currentRange.from.valueOf() - visibleRange.from) > SCROLLING_THRESHOLD;
return canScroll ? getPrevRange(visibleRange, currentRange) : undefined;
} }
function canScrollBottom( function canScrollBottom(
@ -209,13 +227,15 @@ function canScrollBottom(
currentRange: TimeRange, currentRange: TimeRange,
timeZone: TimeZone, timeZone: TimeZone,
sortOrder: LogsSortOrder sortOrder: LogsSortOrder
) { ): AbsoluteTimeRange | undefined {
if (sortOrder === LogsSortOrder.Descending) { if (sortOrder === LogsSortOrder.Descending) {
return Math.abs(currentRange.from.valueOf() - visibleRange.from) > SCROLLING_THRESHOLD; const canScroll = Math.abs(currentRange.from.valueOf() - visibleRange.from) > SCROLLING_THRESHOLD;
return canScroll ? getPrevRange(visibleRange, currentRange) : undefined;
} }
// When requesting new logs, update the current range if using relative time ranges. // When requesting new logs, update the current range if using relative time ranges.
currentRange = updateCurrentRange(currentRange, timeZone); currentRange = updateCurrentRange(currentRange, timeZone);
return currentRange.to.valueOf() - visibleRange.to > SCROLLING_THRESHOLD; const canScroll = currentRange.to.valueOf() - visibleRange.to > SCROLLING_THRESHOLD;
return canScroll ? getNextRange(visibleRange, currentRange, timeZone) : undefined;
} }
// Given a TimeRange, returns a new instance if using relative time, or else the same. // Given a TimeRange, returns a new instance if using relative time, or else the same.