mirror of
https://github.com/grafana/grafana.git
synced 2025-02-13 00:55:47 -06:00
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:
parent
382b24742a
commit
409bd33a8f
@ -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],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
@ -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()};
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
@ -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]);
|
||||
});
|
||||
});
|
@ -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 };
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user