mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -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 { capitalize, uniqueId } from 'lodash';
|
||||||
import React, { FC, useCallback, useState } from 'react';
|
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 { 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 { ClassicConditions } from 'app/features/expressions/components/ClassicConditions';
|
||||||
import { Math } from 'app/features/expressions/components/Math';
|
import { Math } from 'app/features/expressions/components/Math';
|
||||||
import { Reduce } from 'app/features/expressions/components/Reduce';
|
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 { ExpressionQuery, ExpressionQueryType, gelTypes } from 'app/features/expressions/types';
|
||||||
import { AlertQuery, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
import { AlertQuery, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||||
|
|
||||||
|
import { usePagination } from '../../hooks/usePagination';
|
||||||
import { HoverCard } from '../HoverCard';
|
import { HoverCard } from '../HoverCard';
|
||||||
import { Spacer } from '../Spacer';
|
import { Spacer } from '../Spacer';
|
||||||
import { AlertStateTag } from '../rules/AlertStateTag';
|
import { AlertStateTag } from '../rules/AlertStateTag';
|
||||||
@ -105,6 +106,7 @@ export const Expression: FC<ExpressionProps> = ({
|
|||||||
/>
|
/>
|
||||||
<div className={styles.expression.body}>{renderExpressionType(query)}</div>
|
<div className={styles.expression.body}>{renderExpressionType(query)}</div>
|
||||||
{hasResults && <ExpressionResult series={series} isAlertCondition={isAlertCondition} />}
|
{hasResults && <ExpressionResult series={series} isAlertCondition={isAlertCondition} />}
|
||||||
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<Stack direction="row" alignItems="center">
|
<Stack direction="row" alignItems="center">
|
||||||
<AlertConditionIndicator
|
<AlertConditionIndicator
|
||||||
@ -131,30 +133,73 @@ interface ExpressionResultProps {
|
|||||||
series: DataFrame[];
|
series: DataFrame[];
|
||||||
isAlertCondition?: boolean;
|
isAlertCondition?: boolean;
|
||||||
}
|
}
|
||||||
|
export const PAGE_SIZE = 20;
|
||||||
export const ExpressionResult: FC<ExpressionResultProps> = ({ series, isAlertCondition }) => {
|
export const ExpressionResult: FC<ExpressionResultProps> = ({ series, isAlertCondition }) => {
|
||||||
|
const { page, pageItems, onPageChange, numberOfPages, pageStart, pageEnd } = usePagination(series, 1, PAGE_SIZE);
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
// sometimes we receive results where every value is just "null" when noData occurs
|
// sometimes we receive results where every value is just "null" when noData occurs
|
||||||
const emptyResults = isEmptySeries(series);
|
const emptyResults = isEmptySeries(series);
|
||||||
const isTimeSeriesResults = !emptyResults && isTimeSeriesFrames(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 (
|
return (
|
||||||
<div className={styles.expression.results}>
|
<div className={styles.expression.results}>
|
||||||
{!emptyResults && isTimeSeriesResults && (
|
{!emptyResults && isTimeSeriesResults && (
|
||||||
<div>
|
<div>
|
||||||
{series.map((frame, index) => (
|
{pageItems.map((frame, index) => (
|
||||||
<TimeseriesRow key={uniqueId()} frame={frame} index={index} isAlertCondition={isAlertCondition} />
|
<TimeseriesRow
|
||||||
|
key={uniqueId()}
|
||||||
|
frame={frame}
|
||||||
|
index={pageStart + index}
|
||||||
|
isAlertCondition={isAlertCondition}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!emptyResults &&
|
{!emptyResults &&
|
||||||
!isTimeSeriesResults &&
|
!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)
|
// 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>}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -429,11 +474,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
`,
|
`,
|
||||||
timeseriesTableWrapper: css`
|
timeseriesTableWrapper: css`
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
max-width: 300px;
|
|
||||||
|
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
|
||||||
padding: 0 !important; // not sure why but style override doesn't work otherwise :( (Gilles)
|
|
||||||
`,
|
`,
|
||||||
timeseriesTable: css`
|
timeseriesTable: css`
|
||||||
table-layout: auto;
|
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 [page, setPage] = useState(initialPage);
|
||||||
|
|
||||||
const numberOfPages = Math.ceil(items.length / itemsPerPage);
|
const pages = useMemo(() => chunk(items, itemsPerPage), [items, itemsPerPage]);
|
||||||
const firstItemOnPageIndex = itemsPerPage * (page - 1);
|
|
||||||
|
|
||||||
const pageItems = useMemo(
|
const numberOfPages = pages.length;
|
||||||
() => items.slice(firstItemOnPageIndex, firstItemOnPageIndex + itemsPerPage),
|
const pageItems = pages[page - 1] ?? [];
|
||||||
[items, firstItemOnPageIndex, itemsPerPage]
|
|
||||||
);
|
const pageStart = (page - 1) * itemsPerPage + 1;
|
||||||
|
const pageEnd = clamp(page * itemsPerPage, items.length);
|
||||||
|
|
||||||
const onPageChange = useCallback(
|
const onPageChange = useCallback(
|
||||||
(newPage: number) => {
|
(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
|
// Reset the current page when number of pages has been changed
|
||||||
useEffect(() => setPage(1), [numberOfPages]);
|
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