Alerting: Paginate result previews (#65257)

Co-authored-by: konrad147 <konradlalik@gmail.com>
Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>
This commit is contained in:
Gilles De Mey 2023-03-23 15:17:33 +01:00 committed by GitHub
parent 382b24742a
commit 409bd33a8f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 206 additions and 21 deletions

View File

@ -0,0 +1,66 @@
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { times } from 'lodash';
import React from 'react';
import { DataFrame, toDataFrame } from '@grafana/data';
import { ExpressionResult } from './Expression';
describe('TestResult', () => {
it('should be able to render', () => {
expect(() => {
render(<ExpressionResult series={[]} />);
}).not.toThrow();
});
it('should not paginate with less than PAGE_SIZE', () => {
const series: DataFrame[] = [
toDataFrame({
fields: [
{
name: 'temp',
values: [23, 11, 10],
},
],
}),
];
render(<ExpressionResult series={series} />);
expect(screen.queryByTestId('paginate-expression')).not.toBeInTheDocument();
});
it('should paginate with greater than PAGE_SIZE', async () => {
const series: DataFrame[] = makeSeries(50);
render(<ExpressionResult series={series} />);
expect(screen.getByTestId('paginate-expression')).toBeInTheDocument();
expect(screen.getByText(`1 - 20 of ${50}`)).toBeInTheDocument();
// click previous page
await userEvent.click(screen.getByLabelText('previous-page'));
expect(screen.getByText(`1 - 20 of ${50}`)).toBeInTheDocument();
// keep clicking next page, should clamp
await userEvent.click(screen.getByLabelText('next-page'));
expect(screen.getByText(`21 - 40 of ${50}`)).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('next-page'));
expect(screen.getByText(`41 - 50 of ${50}`)).toBeInTheDocument();
// click one more time, should still be on the last page
await userEvent.click(screen.getByLabelText('next-page'));
expect(screen.getByText(`41 - 50 of ${50}`)).toBeInTheDocument();
});
});
function makeSeries(n: number) {
return times(n, () =>
toDataFrame({
fields: [
{
name: 'temp',
values: [1],
},
],
})
);
}

View File

@ -2,9 +2,9 @@ import { css, cx } from '@emotion/css';
import { capitalize, uniqueId } from 'lodash';
import React, { FC, useCallback, useState } from 'react';
import { DataFrame, dateTimeFormat, GrafanaTheme2, LoadingState, PanelData, isTimeSeriesFrames } from '@grafana/data';
import { DataFrame, dateTimeFormat, GrafanaTheme2, isTimeSeriesFrames, LoadingState, PanelData } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { AutoSizeInput, clearButtonStyles, Icon, IconButton, Select, useStyles2 } from '@grafana/ui';
import { AutoSizeInput, Button, clearButtonStyles, Icon, IconButton, Select, useStyles2 } from '@grafana/ui';
import { ClassicConditions } from 'app/features/expressions/components/ClassicConditions';
import { Math } from 'app/features/expressions/components/Math';
import { Reduce } from 'app/features/expressions/components/Reduce';
@ -13,6 +13,7 @@ import { Threshold } from 'app/features/expressions/components/Threshold';
import { ExpressionQuery, ExpressionQueryType, gelTypes } from 'app/features/expressions/types';
import { AlertQuery, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { usePagination } from '../../hooks/usePagination';
import { HoverCard } from '../HoverCard';
import { Spacer } from '../Spacer';
import { AlertStateTag } from '../rules/AlertStateTag';
@ -105,6 +106,7 @@ export const Expression: FC<ExpressionProps> = ({
/>
<div className={styles.expression.body}>{renderExpressionType(query)}</div>
{hasResults && <ExpressionResult series={series} isAlertCondition={isAlertCondition} />}
<div className={styles.footer}>
<Stack direction="row" alignItems="center">
<AlertConditionIndicator
@ -131,30 +133,73 @@ interface ExpressionResultProps {
series: DataFrame[];
isAlertCondition?: boolean;
}
export const PAGE_SIZE = 20;
export const ExpressionResult: FC<ExpressionResultProps> = ({ series, isAlertCondition }) => {
const { page, pageItems, onPageChange, numberOfPages, pageStart, pageEnd } = usePagination(series, 1, PAGE_SIZE);
const styles = useStyles2(getStyles);
// sometimes we receive results where every value is just "null" when noData occurs
const emptyResults = isEmptySeries(series);
const isTimeSeriesResults = !emptyResults && isTimeSeriesFrames(series);
const previousPage = useCallback(() => {
onPageChange(page - 1);
}, [page, onPageChange]);
const nextPage = useCallback(() => {
onPageChange(page + 1);
}, [page, onPageChange]);
const shouldShowPagination = numberOfPages > 1;
return (
<div className={styles.expression.results}>
{!emptyResults && isTimeSeriesResults && (
<div>
{series.map((frame, index) => (
<TimeseriesRow key={uniqueId()} frame={frame} index={index} isAlertCondition={isAlertCondition} />
{pageItems.map((frame, index) => (
<TimeseriesRow
key={uniqueId()}
frame={frame}
index={pageStart + index}
isAlertCondition={isAlertCondition}
/>
))}
</div>
)}
{!emptyResults &&
!isTimeSeriesResults &&
series.map((frame, index) => (
pageItems.map((frame, index) => (
// There's no way to uniquely identify a frame that doesn't cause render bugs :/ (Gilles)
<FrameRow key={uniqueId()} frame={frame} index={index} isAlertCondition={isAlertCondition} />
<FrameRow key={uniqueId()} frame={frame} index={pageStart + index} isAlertCondition={isAlertCondition} />
))}
{emptyResults && <div className={cx(styles.expression.noData, styles.mutedText)}>No data</div>}
{shouldShowPagination && (
<div className={styles.pagination.wrapper} data-testid="paginate-expression">
<Stack>
<Button
variant="secondary"
fill="outline"
onClick={previousPage}
icon="angle-left"
size="sm"
aria-label="previous-page"
/>
<Spacer />
<span className={styles.mutedText}>
{pageStart} - {pageEnd} of {series.length}
</span>
<Spacer />
<Button
variant="secondary"
fill="outline"
onClick={nextPage}
icon="angle-right"
size="sm"
aria-label="next-page"
/>
</Stack>
</div>
)}
</div>
);
};
@ -429,11 +474,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
`,
timeseriesTableWrapper: css`
max-height: 500px;
max-width: 300px;
overflow-y: scroll;
padding: 0 !important; // not sure why but style override doesn't work otherwise :( (Gilles)
`,
timeseriesTable: css`
table-layout: auto;
@ -462,4 +504,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
}
}
`,
pagination: {
wrapper: css`
border-top: 1px solid ${theme.colors.border.medium};
padding: ${theme.spacing()};
`,
},
});

View File

@ -0,0 +1,67 @@
import { act, renderHook } from '@testing-library/react-hooks';
import { usePagination } from './usePagination';
describe('usePagination()', () => {
it('should work with no items', () => {
const { result } = renderHook(() => {
return usePagination([], 1, 20);
});
const { pageItems, numberOfPages, page, pageStart, pageEnd } = result.current;
expect(pageItems).toStrictEqual([]);
expect(numberOfPages).toStrictEqual(0);
expect(page).toStrictEqual(1);
expect(pageStart).toStrictEqual(1);
expect(pageEnd).toStrictEqual(0);
});
it('should work with items < page size', () => {
const { result } = renderHook(() => {
return usePagination([1, 2, 3], 1, 10);
});
const { pageItems, numberOfPages, page, pageStart, pageEnd } = result.current;
expect(pageItems).toStrictEqual([1, 2, 3]);
expect(numberOfPages).toStrictEqual(1);
expect(page).toStrictEqual(1);
expect(pageStart).toStrictEqual(1);
expect(pageEnd).toStrictEqual(3);
});
it('should work with items > page size', () => {
const { result } = renderHook(() => {
return usePagination([1, 2, 3], 1, 1);
});
const { pageItems, numberOfPages, page, pageStart, pageEnd } = result.current;
expect(pageItems).toStrictEqual([1]);
expect(numberOfPages).toStrictEqual(3);
expect(page).toStrictEqual(1);
expect(pageStart).toStrictEqual(1);
expect(pageEnd).toStrictEqual(1);
});
it('should clamp pages', () => {
const { result } = renderHook(() => {
return usePagination([1, 2, 3], 1, 1);
});
expect(result.current.pageItems).toStrictEqual([1]);
act(() => result.current.previousPage());
expect(result.current.pageItems).toStrictEqual([1]);
act(() => result.current.nextPage());
expect(result.current.pageItems).toStrictEqual([2]);
act(() => result.current.nextPage());
expect(result.current.pageItems).toStrictEqual([3]);
act(() => result.current.nextPage());
expect(result.current.pageItems).toStrictEqual([3]);
});
});

View File

@ -1,25 +1,29 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { chunk, clamp } from 'lodash';
import { useCallback, useEffect, useState, useMemo } from 'react';
export function usePagination<T>(items: T[], initialPage: number, itemsPerPage: number) {
export function usePagination<T>(items: T[], initialPage = 1, itemsPerPage: number) {
const [page, setPage] = useState(initialPage);
const numberOfPages = Math.ceil(items.length / itemsPerPage);
const firstItemOnPageIndex = itemsPerPage * (page - 1);
const pages = useMemo(() => chunk(items, itemsPerPage), [items, itemsPerPage]);
const pageItems = useMemo(
() => items.slice(firstItemOnPageIndex, firstItemOnPageIndex + itemsPerPage),
[items, firstItemOnPageIndex, itemsPerPage]
);
const numberOfPages = pages.length;
const pageItems = pages[page - 1] ?? [];
const pageStart = (page - 1) * itemsPerPage + 1;
const pageEnd = clamp(page * itemsPerPage, items.length);
const onPageChange = useCallback(
(newPage: number) => {
setPage(newPage);
setPage(clamp(newPage, 1, pages.length));
},
[setPage]
[setPage, pages]
);
const nextPage = useCallback(() => onPageChange(page + 1), [page, onPageChange]);
const previousPage = useCallback(() => onPageChange(page - 1), [page, onPageChange]);
// Reset the current page when number of pages has been changed
useEffect(() => setPage(1), [numberOfPages]);
return { page, onPageChange, numberOfPages, pageItems };
return { page, onPageChange, numberOfPages, pageItems, pageStart, pageEnd, nextPage, previousPage };
}