Compare commits

..

1 Commits

Author SHA1 Message Date
joshhunt 3824e96523 Generate unique colours per state timeline value 2025-12-01 18:07:19 +00:00
29 changed files with 778 additions and 573 deletions
+3
View File
@@ -843,6 +843,9 @@
}
},
"packages/grafana-ui/src/components/Table/TableRT/Table.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
},
"@typescript-eslint/no-explicit-any": {
"count": 2
}
+4 -2
View File
@@ -151,6 +151,8 @@
"@types/react-table": "7.7.20",
"@types/react-transition-group": "4.4.12",
"@types/react-virtualized-auto-sizer": "1.0.8",
"@types/react-window": "1.8.8",
"@types/react-window-infinite-loader": "^1",
"@types/redux-mock-store": "1.5.0",
"@types/semver": "7.7.1",
"@types/slate": "0.47.11",
@@ -412,8 +414,8 @@
"react-use": "17.6.0",
"react-virtual": "2.10.4",
"react-virtualized-auto-sizer": "1.0.26",
"react-window": "2.2.3",
"react-window-infinite-loader": "2.0.0",
"react-window": "1.8.11",
"react-window-infinite-loader": "1.0.10",
"reduce-reducers": "^1.0.4",
"redux": "5.0.1",
"redux-thunk": "3.1.0",
+2 -1
View File
@@ -60,6 +60,7 @@
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react-highlight-words": "0.20.0",
"@types/react-window": "1.8.8",
"@types/semver": "7.7.1",
"@types/uuid": "10.0.0",
"debounce-promise": "3.1.2",
@@ -72,7 +73,7 @@
"prismjs": "1.30.0",
"react-highlight-words": "0.21.0",
"react-use": "17.6.0",
"react-window": "2.2.3",
"react-window": "1.8.11",
"rxjs": "7.8.2",
"semver": "7.7.3",
"uuid": "11.1.0"
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { List } from 'react-window';
import { FixedSizeList } from 'react-window';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
@@ -65,12 +65,15 @@ export function MetricSelector() {
className={styles.valueListWrapper}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.metricList}
>
<List
rowProps={{}}
rowCount={filteredMetrics.length}
rowHeight={LIST_ITEM_SIZE}
<FixedSizeList
height={Math.min(450, filteredMetrics.length * LIST_ITEM_SIZE)}
itemCount={filteredMetrics.length}
itemSize={LIST_ITEM_SIZE}
itemKey={(i) => filteredMetrics[i].name}
width={300}
className={styles.valueList}
rowComponent={({ index, style }) => {
>
{({ index, style }) => {
const metric = filteredMetrics[index];
return (
<div style={style}>
@@ -89,11 +92,7 @@ export function MetricSelector() {
</div>
);
}}
style={{
height: Math.min(450, filteredMetrics.length * LIST_ITEM_SIZE),
width: 300,
}}
/>
</FixedSizeList>
</div>
</div>
</div>
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { List } from 'react-window';
import { FixedSizeList } from 'react-window';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
@@ -78,16 +78,15 @@ export function ValueSelector() {
<div className={styles.valueTitle}>
<PromLabel name={lk} active={true} hidden={false} facets={lv.length} onClick={onLabelKeyClick} />
</div>
<List
rowProps={{}}
rowCount={lv.length || 0}
rowHeight={28}
style={{
height: Math.min(200, LIST_ITEM_SIZE * (lv.length || 0)),
width: 200,
}}
<FixedSizeList
height={Math.min(200, LIST_ITEM_SIZE * (lv.length || 0))}
itemCount={lv.length || 0}
itemSize={28}
itemKey={(i) => lv[i]}
width={200}
className={styles.valueList}
rowComponent={({ index, style }) => {
>
{({ index, style }) => {
const value = lv[index];
const isSelected = selectedLabelValues[lk]?.includes(value);
return (
@@ -102,7 +101,7 @@ export function ValueSelector() {
</div>
);
}}
/>
</FixedSizeList>
</div>
);
})}
+2 -1
View File
@@ -122,7 +122,7 @@
"react-table": "7.8.0",
"react-transition-group": "4.4.5",
"react-use": "17.6.0",
"react-window": "2.2.3",
"react-window": "1.8.11",
"rxjs": "7.8.2",
"slate": "0.47.9",
"slate-plain-serializer": "0.7.13",
@@ -172,6 +172,7 @@
"@types/react-dom": "18.3.5",
"@types/react-highlight-words": "0.20.0",
"@types/react-transition-group": "4.4.12",
"@types/react-window": "1.8.8",
"@types/slate": "0.47.11",
"@types/slate-plain-serializer": "0.7.5",
"@types/slate-react": "0.22.9",
@@ -1,8 +1,8 @@
import { css, cx } from '@emotion/css';
import { max } from 'lodash';
import { RefCallback, useLayoutEffect, useMemo, type JSX } from 'react';
import { RefCallback, useLayoutEffect, useMemo, useRef, type JSX } from 'react';
import * as React from 'react';
import { List, useListRef } from 'react-window';
import { FixedSizeList as List } from 'react-window';
import { SelectableValue, toIconName } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -101,13 +101,14 @@ interface VirtualSelectMenuProps<T> {
export const VirtualizedSelectMenu = ({
children,
maxHeight,
innerRef: scrollRef,
options,
selectProps,
focusedOption,
}: VirtualSelectMenuProps<SelectableValue>) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const listRef = useListRef(null);
const listRef = useRef<List>(null);
const { toggleAllOptions, components } = selectProps;
const optionComponent = components?.Option ?? SelectMenuOptions;
@@ -125,10 +126,8 @@ export const VirtualizedSelectMenu = ({
(option: SelectableValue<unknown>) => option.value === focusedOption?.value
);
useLayoutEffect(() => {
listRef.current?.scrollToRow({
index: focusedIndex,
});
}, [focusedIndex, listRef]);
listRef.current?.scrollToItem(focusedIndex);
}, [focusedIndex]);
if (!Array.isArray(children)) {
return null;
@@ -181,20 +180,17 @@ export const VirtualizedSelectMenu = ({
return (
<List
rowComponent={({ index, style }) => (
<div style={{ ...style, overflow: 'hidden' }}>{flattenedChildren[index]}</div>
)}
rowCount={flattenedChildren.length}
rowHeight={VIRTUAL_LIST_ITEM_HEIGHT}
rowProps={{}}
listRef={listRef}
outerRef={scrollRef}
ref={listRef}
className={styles.menu}
style={{
height: heightEstimate,
width: widthEstimate,
}}
height={heightEstimate}
width={widthEstimate}
aria-label={t('grafana-ui.select.menu-label', 'Select options menu')}
/>
itemCount={flattenedChildren.length}
itemSize={VIRTUAL_LIST_ITEM_HEIGHT}
>
{({ index, style }) => <div style={{ ...style, overflow: 'hidden' }}>{flattenedChildren[index]}</div>}
</List>
);
};
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { useCallback, useMemo } from 'react';
import * as React from 'react';
import { List, type RowComponentProps } from 'react-window';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { GrafanaTheme2, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -162,16 +162,15 @@ export const FilterList = ({ options, values, caseSensitive, onChange, searchFil
{items.length > 0 ? (
<>
<List
rowComponent={ItemRenderer}
rowCount={items.length}
rowHeight={ITEM_HEIGHT}
rowProps={{ items, values: selectedItems, onCheckedChanged, className: styles.filterListRow }}
style={{
height,
width: '100%',
}}
height={height}
itemCount={items.length}
itemSize={ITEM_HEIGHT}
itemData={{ items, values: selectedItems, onCheckedChanged, className: styles.filterListRow }}
width="100%"
className={styles.filterList}
/>
>
{ItemRenderer}
</List>
<div
className={styles.filterListRow}
data-testid={selectors.components.Panels.Visualization.TableNG.Filters.SelectAll}
@@ -194,21 +193,16 @@ export const FilterList = ({ options, values, caseSensitive, onChange, searchFil
);
};
interface ItemRendererProps {
onCheckedChanged: (option: SelectableValue) => (event: React.FormEvent<HTMLInputElement>) => void;
items: SelectableValue[];
values: SelectableValue[];
className: string;
interface ItemRendererProps extends ListChildComponentProps {
data: {
onCheckedChanged: (option: SelectableValue) => (event: React.FormEvent<HTMLInputElement>) => void;
items: SelectableValue[];
values: SelectableValue[];
className: string;
};
}
function ItemRenderer({
index,
style,
onCheckedChanged,
items,
values,
className,
}: RowComponentProps<ItemRendererProps>) {
function ItemRenderer({ index, style, data: { onCheckedChanged, items, values, className } }: ItemRendererProps) {
const option = items[index];
const { value, label } = option;
const isChecked = values.find((s) => s.value === value) !== undefined;
@@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css';
import { useCallback, useMemo } from 'react';
import * as React from 'react';
import { List, type RowComponentProps } from 'react-window';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { GrafanaTheme2, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
@@ -202,16 +202,15 @@ export const FilterList = ({
{items.length > 0 ? (
<>
<List
rowComponent={ItemRenderer}
rowCount={items.length}
rowHeight={ITEM_HEIGHT}
rowProps={{ items, values: selectedItems, onCheckedChanged, className: styles.filterListRow }}
style={{
height,
width: '100%',
}}
height={height}
itemCount={items.length}
itemSize={ITEM_HEIGHT}
itemData={{ items, values: selectedItems, onCheckedChanged, className: styles.filterListRow }}
width="100%"
className={styles.filterList}
/>
>
{ItemRenderer}
</List>
<Stack direction="column" gap={0.25}>
<div className={cx(styles.selectDivider)} />
<div className={cx(styles.filterListRow)}>
@@ -234,21 +233,16 @@ export const FilterList = ({
);
};
interface ItemRendererProps {
onCheckedChanged: (option: SelectableValue) => (event: React.FormEvent<HTMLInputElement>) => void;
items: SelectableValue[];
values: SelectableValue[];
className: string;
interface ItemRendererProps extends ListChildComponentProps {
data: {
onCheckedChanged: (option: SelectableValue) => (event: React.FormEvent<HTMLInputElement>) => void;
items: SelectableValue[];
values: SelectableValue[];
className: string;
};
}
function ItemRenderer({
index,
style,
onCheckedChanged,
items,
values,
className,
}: RowComponentProps<ItemRendererProps>) {
function ItemRenderer({ index, style, data: { onCheckedChanged, items, values, className } }: ItemRendererProps) {
const option = items[index];
const { value, label } = option;
const isChecked = values.find((s) => s.value === value) !== undefined;
@@ -1,8 +1,8 @@
import { css, cx } from '@emotion/css';
import { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react';
import { CSSProperties, UIEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
import * as React from 'react';
import { Cell, Row, TableState, HeaderGroup } from 'react-table';
import { List, useListRef } from 'react-window';
import { VariableSizeList } from 'react-window';
import { Subscription, debounceTime } from 'rxjs';
import {
@@ -18,6 +18,7 @@ import {
import { TableCellDisplayMode, TableCellHeight } from '@grafana/schema';
import { useTheme2 } from '../../../themes/ThemeContext';
import CustomScrollbar from '../../CustomScrollbar/CustomScrollbar';
import { usePanelContext } from '../../PanelChrome';
import { TableCell } from '../Cells/TableCell';
import {
@@ -41,10 +42,14 @@ interface RowsListProps {
data: DataFrame;
rows: Row[];
enableSharedCrosshair: boolean;
headerHeight: number;
rowHeight: number;
itemCount: number;
pageIndex: number;
listHeight: number;
width: number;
cellHeight?: TableCellHeight;
listRef: React.RefObject<VariableSizeList>;
tableState: TableState;
tableStyles: TableStyles;
nestedDataField?: Field;
@@ -65,8 +70,11 @@ export const RowsList = (props: RowsListProps) => {
const {
data,
rows,
headerHeight,
footerPaginationEnabled,
rowHeight,
itemCount,
pageIndex,
tableState,
prepareRow,
onCellFilterAdded,
@@ -76,6 +84,7 @@ export const RowsList = (props: RowsListProps) => {
tableStyles,
nestedDataField,
listHeight,
listRef,
enableSharedCrosshair = false,
initialRowIndex = undefined,
headerGroups,
@@ -86,25 +95,11 @@ export const RowsList = (props: RowsListProps) => {
setInspectCell,
} = props;
const listRef = useListRef(null);
const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(initialRowIndex);
if (initialRowIndex === undefined && rowHighlightIndex !== undefined) {
setRowHighlightIndex(undefined);
}
useEffect(() => {
if (rowHighlightIndex !== undefined) {
// TODO can we do this without a setTimeout?
setTimeout(() => {
listRef.current?.scrollToRow({
index: rowHighlightIndex,
align: 'center',
behavior: 'instant',
});
});
}
}, [rowHighlightIndex, listRef]);
const theme = useTheme2();
const panelContext = usePanelContext();
@@ -239,6 +234,15 @@ export const RowsList = (props: RowsListProps) => {
};
}, [data, enableSharedCrosshair, footerPaginationEnabled, onDataHoverEvent, panelContext]);
let scrollTop: number | undefined = undefined;
if (rowHighlightIndex !== undefined) {
const firstMatchedRowIndex = rows.findIndex((row) => row.index === rowHighlightIndex);
if (firstMatchedRowIndex !== -1) {
scrollTop = headerHeight + (firstMatchedRowIndex - 1) * rowHeight;
}
}
const rowIndexForPagination = useCallback(
(index: number) => {
return tableState.pageIndex * tableState.pageSize + index;
@@ -312,17 +316,6 @@ export const RowsList = (props: RowsListProps) => {
);
style.height = bbox.height;
}
// some disgusting code to mutate the style object to convert transform to top
// this is all so that hover behaviour is maintained
// using transform creates new stacking contexts which means hover states don't overlay correctly
const yPos = style.transform?.match(/translateY\((.*)\)/)?.[1];
style = {
...style,
top: yPos,
transform: undefined,
};
const { key, ...rowProps } = row.getRowProps({ style, ...additionalProps });
return (
@@ -421,17 +414,36 @@ export const RowsList = (props: RowsListProps) => {
return tableStyles.rowHeight;
};
const handleScroll: UIEventHandler = (event) => {
const { scrollTop } = event.currentTarget;
if (listRef.current !== null) {
listRef.current.scrollTo(scrollTop);
}
};
// It's a hack for text wrapping.
// VariableSizeList component didn't know that we manually set row height.
// So we need to reset the list when the rows high changes.
useEffect(() => {
if (listRef.current) {
listRef.current.resetAfterIndex(0);
}
}, [rows, listRef]);
return (
<List
rowProps={{}}
rowHeight={getItemSize}
rowCount={itemCount}
listRef={listRef}
style={{
height: listHeight,
width,
}}
rowComponent={({ index, style }) => RenderRow({ index, style, rowHighlightIndex })}
/>
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true} scrollTop={scrollTop}>
<VariableSizeList
key={`${rowHeight}${pageIndex}`}
height={listHeight}
itemCount={itemCount}
itemSize={getItemSize}
width={'100%'}
ref={listRef}
style={{ overflow: undefined }}
>
{({ index, style }) => RenderRow({ index, style, rowHighlightIndex })}
</VariableSizeList>
</CustomScrollbar>
);
};
@@ -8,6 +8,7 @@ import {
useSortBy,
useTable,
} from 'react-table';
import { VariableSizeList } from 'react-window';
import { FieldType, ReducerID, getRowUniqueId, getFieldMatcher } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -15,10 +16,12 @@ import { Trans } from '@grafana/i18n';
import { TableCellHeight } from '@grafana/schema';
import { useTheme2 } from '../../../themes/ThemeContext';
import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar';
import { Pagination } from '../../Pagination/Pagination';
import { TableCellInspector } from '../TableCellInspector';
import { useFixScrollbarContainer, useResetVariableListSizeCache } from '../hooks';
import { getInitialState, useTableStateReducer } from '../reducer';
import { FooterItem, InspectCell, TableRTProps as Props } from '../types';
import { FooterItem, GrafanaTableState, InspectCell, TableRTProps as Props } from '../types';
import {
getColumns,
sortCaseInsensitive,
@@ -67,6 +70,7 @@ export const Table = memo((props: Props) => {
replaceVariables,
} = props;
const listRef = useRef<VariableSizeList>(null);
const tableDivRef = useRef<HTMLDivElement>(null);
const variableSizeListScrollbarRef = useRef<HTMLDivElement>(null);
const theme = useTheme2();
@@ -196,6 +200,7 @@ export const Table = memo((props: Props) => {
toggleAllRowsExpanded,
} = useTable(options, useFilters, useSortBy, useAbsoluteLayout, useResizeColumns, useExpanded, usePagination);
const extendedState = state as GrafanaTableState;
toggleAllRowsExpandedRef.current = toggleAllRowsExpanded;
/*
@@ -265,6 +270,9 @@ export const Table = memo((props: Props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useResetVariableListSizeCache(extendedState, listRef, data, hasUniqueId);
useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef);
const onNavigate = useCallback(
(toPage: number) => {
gotoPage(toPage - 1);
@@ -332,51 +340,60 @@ export const Table = memo((props: Props) => {
ref={tableDivRef}
style={{ width, height }}
>
<div className={tableStyles.tableContentWrapper(totalColumnsWidth)}>
{!noHeader && (
<HeaderRow headerGroups={headerGroups} showTypeIcons={showTypeIcons} tableStyles={tableStyles} />
)}
{itemCount > 0 ? (
<div data-testid={selectors.components.Panels.Visualization.Table.body} ref={variableSizeListScrollbarRef}>
<RowsList
headerGroups={headerGroups}
data={data}
rows={rows}
width={width}
cellHeight={cellHeight}
itemCount={itemCount}
listHeight={listHeight}
tableState={state}
prepareRow={prepareRow}
timeRange={timeRange}
onCellFilterAdded={onCellFilterAdded}
nestedDataField={nestedDataField}
<CustomScrollbar hideVerticalTrack={true}>
<div className={tableStyles.tableContentWrapper(totalColumnsWidth)}>
{!noHeader && (
<HeaderRow headerGroups={headerGroups} showTypeIcons={showTypeIcons} tableStyles={tableStyles} />
)}
{itemCount > 0 ? (
<div
data-testid={selectors.components.Panels.Visualization.Table.body}
ref={variableSizeListScrollbarRef}
>
<RowsList
headerGroups={headerGroups}
data={data}
rows={rows}
width={width}
cellHeight={cellHeight}
headerHeight={headerHeight}
rowHeight={tableStyles.rowHeight}
itemCount={itemCount}
pageIndex={state.pageIndex}
listHeight={listHeight}
listRef={listRef}
tableState={state}
prepareRow={prepareRow}
timeRange={timeRange}
onCellFilterAdded={onCellFilterAdded}
nestedDataField={nestedDataField}
tableStyles={tableStyles}
footerPaginationEnabled={Boolean(enablePagination)}
enableSharedCrosshair={enableSharedCrosshair}
initialRowIndex={initialRowIndex}
longestField={longestField}
textWrapField={textWrapField}
getActions={getActions}
replaceVariables={replaceVariables}
setInspectCell={setInspectCell}
/>
</div>
) : (
<div style={{ height: height - headerHeight, width }} className={tableStyles.noData}>
{noValuesDisplayText}
</div>
)}
{footerItems && (
<FooterRow
isPaginationVisible={Boolean(enablePagination)}
footerValues={footerItems}
footerGroups={footerGroups}
totalColumnsWidth={totalColumnsWidth}
tableStyles={tableStyles}
footerPaginationEnabled={Boolean(enablePagination)}
enableSharedCrosshair={enableSharedCrosshair}
initialRowIndex={initialRowIndex}
longestField={longestField}
textWrapField={textWrapField}
getActions={getActions}
replaceVariables={replaceVariables}
setInspectCell={setInspectCell}
/>
</div>
) : (
<div style={{ height: height - headerHeight, width }} className={tableStyles.noData}>
{noValuesDisplayText}
</div>
)}
{footerItems && (
<FooterRow
isPaginationVisible={Boolean(enablePagination)}
footerValues={footerItems}
footerGroups={footerGroups}
totalColumnsWidth={totalColumnsWidth}
tableStyles={tableStyles}
/>
)}
</div>
)}
</div>
</CustomScrollbar>
{paginationEl}
</div>
@@ -111,7 +111,6 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
width: '100%',
overflow: 'auto',
display: 'flex',
position: 'relative',
flexDirection: 'column',
}),
thead: css({
@@ -0,0 +1,83 @@
import { useEffect } from 'react';
import * as React from 'react';
import { VariableSizeList } from 'react-window';
import { DataFrame } from '@grafana/data';
import { GrafanaTableState } from './types';
/**
To have the custom vertical scrollbar always visible (https://github.com/grafana/grafana/issues/52136),
we need to bring the element from the VariableSizeList scope to the outer Table container scope,
because the VariableSizeList scope has overflow. By moving scrollbar to container scope we will have
it always visible since the entire width is in view.
Select the scrollbar element from the VariableSizeList scope
*/
export function useFixScrollbarContainer(
variableSizeListScrollbarRef: React.RefObject<HTMLDivElement>,
tableDivRef: React.RefObject<HTMLDivElement>
) {
useEffect(() => {
if (variableSizeListScrollbarRef.current && tableDivRef.current) {
const listVerticalScrollbarHTML = variableSizeListScrollbarRef.current.querySelector('.track-vertical');
// Select Table custom scrollbars
const tableScrollbarView = tableDivRef.current.firstChild;
//If they exist, move the scrollbar element to the Table container scope
if (tableScrollbarView && listVerticalScrollbarHTML) {
listVerticalScrollbarHTML.remove();
if (tableScrollbarView instanceof HTMLElement) {
tableScrollbarView.querySelector(':scope > .track-vertical')?.remove();
tableScrollbarView.append(listVerticalScrollbarHTML);
}
}
}
});
}
/**
react-table caches the height of cells, so we need to reset them when expanding/collapsing rows.
We use `lastExpandedOrCollapsedIndex` since collapsed rows disappear from `expandedIndexes` but still keep their expanded
height.
*/
export function useResetVariableListSizeCache(
extendedState: GrafanaTableState,
listRef: React.RefObject<VariableSizeList>,
data: DataFrame,
hasUniqueId: boolean
) {
// Make sure we trigger the reset when keys change in any way
const expandedRowsRepr = JSON.stringify(Object.keys(extendedState.expanded));
useEffect(() => {
// By default, reset all rows
let resetIndex = 0;
// If we have unique field, extendedState.expanded keys are not row indexes but IDs so instead of trying to search
// for correct index we just reset the whole table.
if (!hasUniqueId) {
// If we don't have we reset from the last changed index.
if (Number.isFinite(extendedState.lastExpandedOrCollapsedIndex)) {
resetIndex = extendedState.lastExpandedOrCollapsedIndex!;
}
// Account for paging.
resetIndex =
extendedState.pageIndex === 0
? resetIndex - 1
: resetIndex - extendedState.pageIndex - extendedState.pageIndex * extendedState.pageSize;
}
listRef.current?.resetAfterIndex(Math.max(resetIndex, 0));
return;
}, [
extendedState.lastExpandedOrCollapsedIndex,
extendedState.pageSize,
extendedState.pageIndex,
listRef,
data,
expandedRowsRepr,
hasUniqueId,
]);
}
@@ -3,7 +3,7 @@ import { isEqual } from 'lodash';
import { createRef, PureComponent } from 'react';
import * as React from 'react';
import ReactDOM from 'react-dom';
import { List, type ListImperativeAPI } from 'react-window';
import { FixedSizeList } from 'react-window';
import { GrafanaTheme2, ThemeContext } from '@grafana/data';
@@ -36,7 +36,7 @@ export interface State {
export class Typeahead extends PureComponent<Props, State> {
static contextType = ThemeContext;
context!: React.ContextType<typeof ThemeContext>;
listRef = createRef<ListImperativeAPI>();
listRef = createRef<FixedSizeList>();
state: State = {
hoveredItem: null,
@@ -81,14 +81,10 @@ export class Typeahead extends PureComponent<Props, State> {
this.listRef.current
) {
if (this.state.typeaheadIndex === 1) {
this.listRef.current.scrollToRow({
index: 0,
}); // special case for handling the first group label
this.listRef.current.scrollToItem(0); // special case for handling the first group label
return;
}
this.listRef.current.scrollToRow({
index: this.state.typeaheadIndex,
});
this.listRef.current.scrollToItem(this.state.typeaheadIndex);
}
if (isEqual(prevProps.groupedItems, this.props.groupedItems) === false) {
@@ -171,19 +167,22 @@ export class Typeahead extends PureComponent<Props, State> {
return (
<Portal origin={origin} isOpen={isOpen} style={this.menuPosition}>
<ul role="menu" className={styles.typeahead} data-testid="typeahead">
<List
listRef={this.listRef}
rowCount={allItems.length}
rowHeight={itemHeight}
rowProps={{}}
style={{
width: listWidth,
height: listHeight,
<FixedSizeList
ref={this.listRef}
itemCount={allItems.length}
itemSize={itemHeight}
itemKey={(index) => {
const item = allItems && allItems[index];
const key = item ? `${index}-${item.label}` : `${index}`;
return key;
}}
rowComponent={({ index, style }) => {
width={listWidth}
height={listHeight}
>
{({ index, style }) => {
const item = allItems && allItems[index];
if (!item) {
return <></>;
return null;
}
return (
@@ -198,7 +197,7 @@ export class Typeahead extends PureComponent<Props, State> {
/>
);
}}
/>
</FixedSizeList>
</ul>
{showDocumentation && <TypeaheadInfo height={listHeight} item={documentationItem} />}
@@ -1,9 +1,9 @@
import { css, cx } from '@emotion/css';
import { useCallback, useId, useRef } from 'react';
import { useCallback, useId, useMemo, useRef } from 'react';
import * as React from 'react';
import Skeleton from 'react-loading-skeleton';
import { List, type RowComponentProps } from 'react-window';
import { useInfiniteLoader } from 'react-window-infinite-loader';
import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
@@ -47,8 +47,32 @@ export function NestedFolderList({
requestLoadMore,
emptyFolders,
}: NestedFolderListProps) {
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const styles = useStyles2(getStyles);
const virtualData = useMemo(
(): VirtualData => ({
items,
focusedItemIndex,
foldersAreOpenable,
selectedFolder,
onFolderExpand,
onFolderSelect,
idPrefix,
emptyFolders,
}),
[
items,
focusedItemIndex,
foldersAreOpenable,
selectedFolder,
onFolderExpand,
onFolderSelect,
idPrefix,
emptyFolders,
]
);
const handleIsItemLoaded = useCallback(
(itemIndex: number) => {
return isItemLoaded(itemIndex);
@@ -57,42 +81,36 @@ export function NestedFolderList({
);
const handleLoadMore = useCallback(
async (startIndex: number, endIndex: number) => {
(startIndex: number, endIndex: number) => {
const { parentUID } = items[startIndex];
requestLoadMore(parentUID);
},
[requestLoadMore, items]
);
const onRowsRendered = useInfiniteLoader({
rowCount: items.length,
isRowLoaded: handleIsItemLoaded,
loadMoreRows: handleLoadMore,
});
return (
<div className={styles.table} role="tree">
{items.length > 0 ? (
<List
onRowsRendered={onRowsRendered}
rowProps={{
items,
focusedItemIndex,
foldersAreOpenable,
selectedFolder,
onFolderExpand,
onFolderSelect,
idPrefix,
emptyFolders,
}}
rowComponent={Row}
rowCount={items.length}
rowHeight={ROW_HEIGHT}
style={{
height: ROW_HEIGHT * Math.min(6.5, items.length),
width: '100%',
}}
/>
<InfiniteLoader
ref={infiniteLoaderRef}
itemCount={items.length}
isItemLoaded={handleIsItemLoaded}
loadMoreItems={handleLoadMore}
>
{({ onItemsRendered, ref }) => (
<List
ref={ref}
height={ROW_HEIGHT * Math.min(6.5, items.length)}
width="100%"
itemData={virtualData}
itemSize={ROW_HEIGHT}
itemCount={items.length}
onItemsRendered={onItemsRendered}
>
{Row}
</List>
)}
</InfiniteLoader>
) : (
<div className={styles.emptyMessage}>
<Trans i18nKey="browse-dashboards.folder-picker.empty-message">No folders found</Trans>
@@ -104,20 +122,25 @@ export function NestedFolderList({
interface VirtualData extends Omit<NestedFolderListProps, 'isItemLoaded' | 'requestLoadMore'> {}
interface RowProps {
index: number;
style: React.CSSProperties;
data: VirtualData;
}
const SKELETON_WIDTHS = [100, 200, 130, 160, 150];
function Row({
index,
style: virtualStyles,
items,
focusedItemIndex,
foldersAreOpenable,
selectedFolder,
onFolderExpand,
onFolderSelect,
idPrefix,
emptyFolders,
}: RowComponentProps<VirtualData>) {
function Row({ index, style: virtualStyles, data }: RowProps) {
const {
items,
focusedItemIndex,
foldersAreOpenable,
selectedFolder,
onFolderExpand,
onFolderSelect,
idPrefix,
emptyFolders,
} = data;
const { item, isOpen, level, parentUID } = items[index];
const rowRef = useRef<HTMLDivElement>(null);
const labelId = useId();
@@ -167,9 +190,7 @@ function Row({
Non-folder {{ itemKind }} {{ itemUID }}
</Trans>
</span>
) : (
<></>
);
) : null;
}
// We don't have a direct value of whether things are coming from user searching but this seems to be a good
@@ -35,6 +35,82 @@ const propsToDiff = [
'annotationLanes',
];
const FEATURE_TOGGLE_ENABLED = true;
/**
* Hashes a string to generate a consistent, visually distinct color.
* Optimized for at least 32 distinct colors with varying hue and brightness.
*
* @param str - The string to hash into a color
* @returns A hex color string (e.g., "#3498db")
*/
function hashStringToColor(str: string): string {
// Simple hash function to convert string to a number
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash; // Convert to 32-bit integer
}
// Use the hash to generate distinct hue values
// Distribute across the full 360° color wheel for maximum distinction
const hue = Math.abs(hash % 360);
// Create two alternating brightness levels for better distinction
// This effectively doubles our color space
const lightnessVariant = Math.abs(Math.floor(hash / 360) % 2);
const lightness = lightnessVariant === 0 ? 45 : 65; // Darker or lighter variant
// Use moderate saturation for good visibility
// Vary saturation slightly based on hash for additional distinction
const saturationVariant = Math.abs(Math.floor(hash / 720) % 3);
const saturation = 65 + saturationVariant * 10; // 65%, 75%, or 85%
// Convert HSL to RGB
const h = hue / 360;
const s = saturation / 100;
const l = lightness / 100;
let r, g, b;
if (s === 0) {
r = g = b = l; // Achromatic
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) {
t += 1;
}
if (t > 1) {
t -= 1;
}
if (t < 1 / 6) {
return p + (q - p) * 6 * t;
}
if (t < 1 / 2) {
return q;
}
if (t < 2 / 3) {
return p + (q - p) * (2 / 3 - t) * 6;
}
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
// Convert to hex
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
export const TimelineChart = (props: TimelineProps) => {
const { frames, timeZone, rowHeight, tooltip, legend, legendItems } = props;
@@ -42,6 +118,12 @@ export const TimelineChart = (props: TimelineProps) => {
(frameIdx: number, fieldIdx: number, value: unknown) => {
const field = frames[frameIdx]?.fields[fieldIdx];
if (FEATURE_TOGGLE_ENABLED && field?.config?.color?.mode === 'palette-classic') {
const valueAsString = typeof value === 'string' ? value : String(value);
const hashedColor = hashStringToColor(valueAsString);
return hashedColor;
}
if (field?.display) {
const disp = field.display(value); // will apply color modes
if (disp.color) {
@@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css';
import { useCallback, useMemo, useState } from 'react';
import { CSSProperties, useCallback, useMemo, useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { List, type RowComponentProps } from 'react-window';
import { FixedSizeList } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
@@ -95,7 +95,10 @@ export function AlertInstanceModalSelector({
const filteredRulesKeys = Object.keys(filteredRules || []);
const RuleRow = ({ index, style }: RowComponentProps) => {
const RuleRow = ({ index, style }: { index: number; style?: CSSProperties }) => {
if (!filteredRules) {
return null;
}
const ruleName = filteredRulesKeys[index];
const isSelected = ruleName === selectedRule;
@@ -130,7 +133,7 @@ export function AlertInstanceModalSelector({
return tags;
};
const InstanceRow = ({ index, style }: RowComponentProps) => {
const InstanceRow = ({ index, style }: { index: number; style: CSSProperties }) => {
const alerts = useMemo(() => (selectedRule ? rulesWithInstances[selectedRule] : []), []);
const alert = alerts[index];
const isSelected = selectedInstances?.includes(alert);
@@ -233,16 +236,9 @@ export function AlertInstanceModalSelector({
{!loading && (
<AutoSizer>
{({ height, width }) => (
<List
rowComponent={RuleRow}
rowCount={filteredRulesKeys.length}
rowHeight={50}
rowProps={{}}
style={{
height,
width,
}}
/>
<FixedSizeList itemSize={50} height={height} width={width} itemCount={filteredRulesKeys.length}>
{RuleRow}
</FixedSizeList>
)}
</AutoSizer>
)}
@@ -268,16 +264,14 @@ export function AlertInstanceModalSelector({
{selectedRule && rulesWithInstances[selectedRule].length && !loading && (
<AutoSizer>
{({ width, height }) => (
<List
rowComponent={InstanceRow}
rowCount={rulesWithInstances[selectedRule].length || 0}
rowHeight={32}
rowProps={{}}
style={{
height,
width,
}}
/>
<FixedSizeList
itemSize={32}
height={height}
width={width}
itemCount={rulesWithInstances[selectedRule].length || 0}
>
{InstanceRow}
</FixedSizeList>
)}
</AutoSizer>
)}
@@ -1,9 +1,9 @@
import { css, cx } from '@emotion/css';
import { noop } from 'lodash';
import { useCallback, useMemo, useState } from 'react';
import { CSSProperties, useCallback, useMemo, useState } from 'react';
import { useAsync, useDebounce } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { List, type ListImperativeAPI, type RowComponentProps } from 'react-window';
import { FixedSizeList } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
@@ -104,13 +104,11 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
const selectedDashboardIsInPageResult = selectedDashboardIndex >= 0;
const scrollToItem = useCallback(
(list: ListImperativeAPI) => {
(node: FixedSizeList) => {
const canScroll = selectedDashboardIndex >= 0;
if (isDefaultSelection && canScroll) {
list?.scrollToRow({
index: selectedDashboardIndex,
});
node?.scrollToItem(selectedDashboardIndex, 'smart');
}
},
[isDefaultSelection, selectedDashboardIndex]
@@ -124,7 +122,7 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
[dashboardFilter]
);
const DashboardRow = ({ index, style }: RowComponentProps) => {
const DashboardRow = ({ index, style }: { index: number; style?: CSSProperties }) => {
const dashboard = filteredDashboards[index];
const isSelected = selectedDashboardUid === dashboard.uid;
const folderTitle = locationInfo?.[dashboard.location]?.name ?? 'Dashboards';
@@ -145,7 +143,7 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
);
};
const PanelRow = ({ index, style }: RowComponentProps) => {
const PanelRow = ({ index, style }: { index: number; style: CSSProperties }) => {
const panel = filteredPanels[index];
const panelTitle = panel.title || '<No title>';
const isSelected = Boolean(panel.id) && selectedPanelId === panel.id;
@@ -260,18 +258,15 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
{!isDashSearchFetching && (
<AutoSizer>
{({ height, width }) => (
<List
listRef={scrollToItem}
rowHeight={50}
rowCount={filteredDashboards.length}
rowComponent={DashboardRow}
rowProps={{}}
style={{
height,
maxHeight: height,
width,
}}
/>
<FixedSizeList
ref={scrollToItem}
itemSize={50}
height={height}
width={width}
itemCount={filteredDashboards.length}
>
{DashboardRow}
</FixedSizeList>
)}
</AutoSizer>
)}
@@ -297,17 +292,9 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
{selectedDashboardUid && !isDashboardFetching && (
<AutoSizer>
{({ width, height }) => (
<List
rowHeight={32}
rowCount={filteredPanels.length}
rowComponent={PanelRow}
rowProps={{}}
style={{
height,
maxHeight: height,
width,
}}
/>
<FixedSizeList itemSize={32} height={height} width={width} itemCount={filteredPanels.length}>
{PanelRow}
</FixedSizeList>
)}
</AutoSizer>
)}
@@ -1,8 +1,9 @@
import { css, cx } from '@emotion/css';
import { useCallback, useId, useMemo } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef } from 'react';
import * as React from 'react';
import { TableInstance, useTable } from 'react-table';
import { List, type RowComponentProps } from 'react-window';
import { useInfiniteLoader } from 'react-window-infinite-loader';
import { VariableSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { GrafanaTheme2, isTruthy } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -44,15 +45,6 @@ const HEADER_HEIGHT = 36;
const ROW_HEIGHT = 36;
const DIVIDER_HEIGHT = 0; // Yes - make it appear as a border on the row rather than a row itself
function getRowHeight(rowIndex: number, { items }: VirtualListRowProps) {
const row = items[rowIndex];
if (row.item.kind === 'ui' && row.item.uiKind === 'divider') {
return DIVIDER_HEIGHT;
}
return ROW_HEIGHT;
}
export function DashboardsTree({
items,
width,
@@ -67,21 +59,23 @@ export function DashboardsTree({
permissions,
}: DashboardsTreeProps) {
const treeID = useId();
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const listRef = useRef<List | null>(null);
const styles = useStyles2(getStyles);
// TODO verify if we need this with v2
// useEffect(() => {
// // If the tree changed identity, then some indexes that were previously loaded may now be unloaded,
// // especially after a refetch after a move/delete.
// // Clear that cache, and check if we need to trigger another load
// if (infiniteLoaderRef.current) {
// infiniteLoaderRef.current.resetloadMoreItemsCache(true);
// }
useEffect(() => {
// If the tree changed identity, then some indexes that were previously loaded may now be unloaded,
// especially after a refetch after a move/delete.
// Clear that cache, and check if we need to trigger another load
if (infiniteLoaderRef.current) {
infiniteLoaderRef.current.resetloadMoreItemsCache(true);
}
// if (listRef.current) {
// listRef.current.resetAfterIndex(0);
// }
// }, [items]);
if (listRef.current) {
listRef.current.resetAfterIndex(0);
}
}, [items]);
const tableColumns = useMemo(() => {
const checkboxColumn: DashboardsTreeColumn = {
@@ -117,6 +111,20 @@ export function DashboardsTree({
const table = useTable({ columns: tableColumns, data: items }, useCustomFlexLayout);
const { getTableProps, getTableBodyProps, headerGroups } = table;
const virtualData = useMemo(
() => ({
table,
isSelected,
onAllSelectionChange,
onItemSelectionChange,
treeID,
permissions,
}),
// we need this to rerender if items changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[table, isSelected, onAllSelectionChange, onItemSelectionChange, items, treeID, permissions]
);
const handleIsItemLoaded = useCallback(
(itemIndex: number) => {
return isItemLoaded(itemIndex);
@@ -125,18 +133,24 @@ export function DashboardsTree({
);
const handleLoadMore = useCallback(
async (startIndex: number, endIndex: number) => {
(startIndex: number, endIndex: number) => {
const { parentUID } = items[startIndex];
requestLoadMore(parentUID);
},
[requestLoadMore, items]
);
const onRowsRendered = useInfiniteLoader({
rowCount: items.length,
isRowLoaded: handleIsItemLoaded,
loadMoreRows: handleLoadMore,
});
const getRowHeight = useCallback(
(rowIndex: number) => {
const row = items[rowIndex];
if (row.item.kind === 'ui' && row.item.uiKind === 'divider') {
return DIVIDER_HEIGHT;
}
return ROW_HEIGHT;
},
[items]
);
return (
<div {...getTableProps()} role="table">
@@ -161,50 +175,51 @@ export function DashboardsTree({
})}
<div {...getTableBodyProps()} data-testid={selectors.pages.BrowseDashboards.table.body}>
<List
rowComponent={VirtualListRow}
rowCount={items.length}
rowHeight={getRowHeight}
rowProps={{
table,
isSelected,
onAllSelectionChange,
onItemSelectionChange,
treeID,
permissions,
items,
}}
onRowsRendered={onRowsRendered}
style={{
height: height - HEADER_HEIGHT,
width,
}}
/>
<InfiniteLoader
ref={infiniteLoaderRef}
itemCount={items.length}
isItemLoaded={handleIsItemLoaded}
loadMoreItems={handleLoadMore}
>
{({ onItemsRendered, ref }) => (
<List
ref={(elem) => {
ref(elem);
listRef.current = elem;
}}
height={height - HEADER_HEIGHT}
width={width}
itemCount={items.length}
itemData={virtualData}
estimatedItemSize={ROW_HEIGHT}
itemSize={getRowHeight}
onItemsRendered={onItemsRendered}
>
{VirtualListRow}
</List>
)}
</InfiniteLoader>
</div>
</div>
);
}
interface VirtualListRowProps {
items: DashboardsTreeItem[];
table: TableInstance<DashboardsTreeItem>;
isSelected: DashboardsTreeCellProps['isSelected'];
onAllSelectionChange: DashboardsTreeCellProps['onAllSelectionChange'];
onItemSelectionChange: DashboardsTreeCellProps['onItemSelectionChange'];
treeID: string;
permissions: BrowseDashboardsPermissions;
index: number;
style: React.CSSProperties;
data: {
table: TableInstance<DashboardsTreeItem>;
isSelected: DashboardsTreeCellProps['isSelected'];
onAllSelectionChange: DashboardsTreeCellProps['onAllSelectionChange'];
onItemSelectionChange: DashboardsTreeCellProps['onItemSelectionChange'];
treeID: string;
permissions: BrowseDashboardsPermissions;
};
}
function VirtualListRow({
index,
style,
table,
isSelected,
onItemSelectionChange,
treeID,
permissions,
}: RowComponentProps<VirtualListRowProps>) {
function VirtualListRow({ index, style, data }: VirtualListRowProps) {
const styles = useStyles2(getStyles);
const { table, isSelected, onItemSelectionChange, treeID, permissions } = data;
const { rows, prepareRow } = table;
const row = rows[index];
@@ -1,7 +1,8 @@
import { css, cx } from '@emotion/css';
import { type KeyboardEvent } from 'react';
import { memo, CSSProperties } from 'react';
import * as React from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { type CellComponentProps, Grid } from 'react-window';
import { areEqual, FixedSizeGrid as Grid } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
@@ -10,14 +11,20 @@ import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG';
import { ResourceItem } from './FolderPickerTab';
interface CellProps {
cards: ResourceItem[];
columnCount: number;
onChange: (value: string) => void;
selected?: string;
columnIndex: number;
rowIndex: number;
style: CSSProperties;
data: {
cards: ResourceItem[];
columnCount: number;
onChange: (value: string) => void;
selected?: string;
};
}
function Cell(props: CellComponentProps<CellProps>) {
const { columnIndex, rowIndex, style, cards, columnCount, onChange, selected } = props;
const MemoizedCell = memo(function Cell(props: CellProps) {
const { columnIndex, rowIndex, style, data } = props;
const { cards, columnCount, onChange, selected } = data;
const singleColumnIndex = columnIndex + rowIndex * columnCount;
const card = cards[singleColumnIndex];
const styles = useStyles2(getStyles);
@@ -29,7 +36,7 @@ function Cell(props: CellComponentProps<CellProps>) {
key={card.value}
className={selected === card.value ? cx(styles.card, styles.selected) : styles.card}
onClick={() => onChange(card.value)}
onKeyDown={(e: KeyboardEvent) => {
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onChange(card.value);
}
@@ -47,7 +54,7 @@ function Cell(props: CellComponentProps<CellProps>) {
)}
</div>
);
}
}, areEqual);
interface CardProps {
onChange: (value: string) => void;
@@ -68,20 +75,17 @@ export const ResourceCards = (props: CardProps) => {
const rowCount = Math.ceil(cards.length / columnCount);
return (
<Grid
style={{
height,
maxHeight: height,
maxWidth: width,
width,
}}
width={width}
height={height}
columnCount={columnCount}
columnWidth={cardWidth}
rowCount={rowCount}
rowHeight={cardHeight}
cellProps={{ cards, columnCount, onChange, selected: value }}
itemData={{ cards, columnCount, onChange, selected: value }}
className={styles.grid}
cellComponent={Cell}
/>
>
{MemoizedCell}
</Grid>
);
}}
</AutoSizer>
@@ -1,8 +1,8 @@
import { css } from '@emotion/css';
import { cloneDeep } from 'lodash';
import { useId, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { List } from 'react-window';
import { VariableSizeList as List } from 'react-window';
import { DataFrame, Field as DataFrameField } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
@@ -59,24 +59,6 @@ const styles = {
const mobileWidthThreshold = 480;
const numberOfColumnsBeforeExpandedViewIsDefault = 2;
interface RowProps {
isExpandedView: boolean;
valueLabels: DataFrameField[];
items: instantQueryRawVirtualizedListData[];
}
function getListItemHeight(itemIndex: number, { isExpandedView, items, valueLabels }: RowProps) {
const singleLineHeight = 32;
const additionalLineHeight = 22;
if (!isExpandedView) {
return singleLineHeight;
}
const item = items[itemIndex];
// Height of 1.5 lines, plus the number of non-value attributes times the height of additional lines
return 1.5 * singleLineHeight + (Object.keys(item).length - valueLabels.length) * additionalLineHeight;
}
/**
* The container that provides the virtualized list to the child components
* @param props
@@ -85,6 +67,7 @@ function getListItemHeight(itemIndex: number, { isExpandedView, items, valueLabe
const RawListContainer = (props: RawListContainerProps) => {
const { tableResult } = props;
const dataFrame = cloneDeep(tableResult);
const listRef = useRef<List | null>(null);
const valueLabels = dataFrame.fields.filter((field) => field.name.includes('Value'));
const items = getRawPrometheusListItemsFromDataFrame(dataFrame);
@@ -101,6 +84,11 @@ const RawListContainer = (props: RawListContainerProps) => {
reportInteraction('grafana_explore_prometheus_instant_query_ui_raw_toggle_expand', props);
};
useEffect(() => {
// After the expanded view has updated, tell the list to re-render
listRef.current?.resetAfterIndex(0, true);
}, [isExpandedView]);
const calculateInitialHeight = (length: number): number => {
const maxListHeight = 600;
const shortListLength = 10;
@@ -108,11 +96,7 @@ const RawListContainer = (props: RawListContainerProps) => {
if (length < shortListLength) {
let sum = 0;
for (let i = 0; i < length; i++) {
sum += getListItemHeight(i, {
isExpandedView: true,
items,
valueLabels,
});
sum += getListItemHeight(i, true);
}
return Math.min(maxListHeight, sum);
@@ -121,6 +105,18 @@ const RawListContainer = (props: RawListContainerProps) => {
return maxListHeight;
};
const getListItemHeight = (itemIndex: number, isExpandedView: boolean) => {
const singleLineHeight = 32;
const additionalLineHeight = 22;
if (!isExpandedView) {
return singleLineHeight;
}
const item = items[itemIndex];
// Height of 1.5 lines, plus the number of non-value attributes times the height of additional lines
return 1.5 * singleLineHeight + (Object.keys(item).length - valueLabels.length) * additionalLineHeight;
};
const switchId = `isExpandedView ${useId()}`;
return (
@@ -157,19 +153,14 @@ const RawListContainer = (props: RawListContainerProps) => {
<ItemLabels valueLabels={valueLabels} expanded={isExpandedView} />
)}
<List
ref={listRef}
itemCount={items.length}
className={styles.wrapper}
style={{
height: calculateInitialHeight(items.length),
width: '100%',
}}
rowCount={items.length}
rowHeight={getListItemHeight}
rowProps={{
isExpandedView,
valueLabels,
items,
}}
rowComponent={({ index, style }) => {
itemSize={(index) => getListItemHeight(index, isExpandedView)}
height={calculateInitialHeight(items.length)}
width="100%"
>
{({ index, style }) => {
let filteredValueLabels: DataFrameField[] | undefined;
if (isExpandedView) {
filteredValueLabels = valueLabels.filter((valueLabel) => {
@@ -190,7 +181,7 @@ const RawListContainer = (props: RawListContainerProps) => {
</div>
);
}}
/>
</List>
</>
}
</div>
@@ -1,5 +1,5 @@
import { act, render, screen } from '@testing-library/react';
import { List } from 'react-window';
import { VariableSizeList } from 'react-window';
import { createTheme, dateTimeForTimeZone, rangeUtil } from '@grafana/data';
import { LogsSortOrder } from '@grafana/schema';
@@ -89,19 +89,19 @@ function setup(
loadMore={loadMoreMock}
infiniteScrollMode={infiniteScrollMode}
>
{({ itemCount, onItemsRendered, Renderer }) => (
<List
rowComponent={Renderer}
rowCount={itemCount}
rowProps={{}}
rowHeight={() => virtualization.getLineHeight()}
onRowsRendered={onItemsRendered}
style={{
overflow: 'scroll',
height: 100,
width: '100%',
}}
/>
{({ getItemKey, itemCount, onItemsRendered, Renderer }) => (
<VariableSizeList
height={100}
itemCount={itemCount}
itemSize={() => virtualization.getLineHeight()}
itemKey={getItemKey}
layout="vertical"
onItemsRendered={onItemsRendered}
style={{ overflow: 'scroll' }}
width="100%"
>
{Renderer}
</VariableSizeList>
)}
</InfiniteScroll>
);
@@ -1,6 +1,6 @@
import { ReactNode, useCallback, useEffect, useRef, useState, MouseEvent, type JSX } from 'react';
import { ReactNode, useCallback, useEffect, useRef, useState, MouseEvent } from 'react';
import { usePrevious } from 'react-use';
import { type RowComponentProps, type ListProps } from 'react-window';
import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window';
import { AbsoluteTimeRange, LogsSortOrder, TimeRange } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -17,8 +17,8 @@ import { LogLineVirtualization } from './virtualization';
interface ChildrenProps {
itemCount: number;
getItemKey: (index: number) => string;
onItemsRendered: ListProps<{}>['onRowsRendered'];
Renderer: (props: RowComponentProps) => JSX.Element;
onItemsRendered: (props: ListOnItemsRenderedProps) => void;
Renderer: (props: ListChildComponentProps) => ReactNode;
}
export interface Props {
@@ -200,7 +200,7 @@ export const InfiniteScroll = ({
}, [onLoadMore]);
const Renderer = useCallback(
({ index, style }: RowComponentProps) => {
({ index, style }: ListChildComponentProps) => {
if (!logs[index] && infiniteLoaderState !== 'idle') {
return (
<LogLineMessage
@@ -248,12 +248,12 @@ export const InfiniteScroll = ({
]
);
const onItemsRendered = useCallback<NonNullable<ListProps<{}>['onRowsRendered']>>(
(props) => {
const onItemsRendered = useCallback(
(props: ListOnItemsRenderedProps) => {
if (!scrollElement) {
return;
}
if (props.startIndex === 0) {
if (props.visibleStartIndex === 0) {
noScrollRef.current = scrollElement.scrollHeight <= scrollElement.clientHeight;
}
if (noScrollRef.current || infiniteLoaderState === 'loading' || infiniteLoaderState === 'out-of-bounds') {
@@ -261,9 +261,9 @@ export const InfiniteScroll = ({
}
const lastLogIndex = logs.length - 1;
const preScrollIndex = logs.length - 2;
if (props.stopIndex >= lastLogIndex) {
if (props.visibleStopIndex >= lastLogIndex) {
setInfiniteLoaderState('pre-scroll-bottom');
} else if (props.startIndex < preScrollIndex) {
} else if (props.visibleStartIndex < preScrollIndex) {
setInfiniteLoaderState('idle');
}
},
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { Grammar } from 'prismjs';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, MouseEvent } from 'react';
import { Align, List, ListImperativeAPI, useListRef } from 'react-window';
import { Align, VariableSizeList } from 'react-window';
import {
CoreApp,
@@ -282,9 +282,10 @@ const LogListComponent = ({
const [processedLogs, setProcessedLogs] = useState<LogListModel[]>([]);
const [listHeight, setListHeight] = useState(getListHeight(containerElement, app));
const theme = useTheme2();
const listRef = useListRef(null);
const listRef = useRef<VariableSizeList | null>(null);
const widthRef = useRef(containerElement.clientWidth);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const virtualization = useMemo(() => new LogLineVirtualization(theme, fontSize), [theme, fontSize]);
const dimensions = useMemo(
() =>
@@ -328,29 +329,25 @@ const LogListComponent = ({
// When log lines report size discrepancies, we debounce the calculation reset to give time to
// use the smallest log index to reset the heights.
// TODO do we need this?
// const debouncedResetAfterIndex = useMemo(() => {
// return debounce((index: number) => {
// listRef.current?.resetAfterIndex(index);
// overflowIndexRef.current = Infinity;
// }, 0);
// }, []);
const debouncedResetAfterIndex = useMemo(() => {
return debounce((index: number) => {
listRef.current?.resetAfterIndex(index);
overflowIndexRef.current = Infinity;
}, 0);
}, []);
const debouncedScrollToItem = useMemo(() => {
return debounce((index: number, align?: Align) => {
listRef.current?.scrollToRow({
index,
align,
});
listRef.current?.scrollToItem(index, align);
}, 250);
}, [listRef]);
}, []);
useEffect(() => {
const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) =>
handleScrollToEvent(e, filteredLogs, listRef.current)
);
return () => subscription.unsubscribe();
}, [eventBus, filteredLogs, listRef]);
}, [eventBus, filteredLogs]);
useEffect(() => {
setProcessedLogs(
@@ -369,13 +366,11 @@ const LogListComponent = ({
)
);
virtualization.resetLogLineSizes();
// TODO do we need this?
// listRef.current?.resetAfterIndex(0);
listRef.current?.resetAfterIndex(0);
}, [forceEscape, getFieldLinks, grammar, logs, prettifyJSON, sortOrder, timeZone, virtualization, wrapLogMessage]);
useEffect(() => {
// TODO do we need this?
// listRef.current?.resetAfterIndex(0);
listRef.current?.resetAfterIndex(0);
}, [wrapLogMessage, showDetails, displayedFields, dedupStrategy]);
useLayoutEffect(() => {
@@ -404,10 +399,9 @@ const LogListComponent = ({
return;
}
overflowIndexRef.current = index < overflowIndexRef.current ? index : overflowIndexRef.current;
// TODO do we need this?
// debouncedResetAfterIndex(overflowIndexRef.current);
debouncedResetAfterIndex(overflowIndexRef.current);
},
[virtualization, widthContainer]
[debouncedResetAfterIndex, virtualization, widthContainer]
);
const handleScrollPosition = useCallback(
@@ -416,13 +410,13 @@ const LogListComponent = ({
if (scrollToUID) {
const index = processedLogs.findIndex((log) => log.uid === scrollToUID);
if (index >= 0) {
listRef.current?.scrollToRow({ index, align: 'start' });
listRef.current?.scrollToItem(index, 'start');
return;
}
}
listRef.current?.scrollToRow({ index: initialScrollPosition === 'top' ? 0 : processedLogs.length - 1 });
listRef.current?.scrollToItem(initialScrollPosition === 'top' ? 0 : processedLogs.length - 1);
},
[initialScrollPosition, permalinkedLogId, processedLogs, listRef]
[initialScrollPosition, permalinkedLogId, processedLogs]
);
const handleLogLineClick = useCallback(
@@ -509,7 +503,7 @@ const LogListComponent = ({
logs={filteredLogs}
loadMore={loadMore}
onClick={handleLogLineClick}
scrollElement={listRef.current?.element ?? null}
scrollElement={scrollRef.current}
showTime={showTime}
sortOrder={sortOrder}
timeRange={timeRange}
@@ -518,25 +512,12 @@ const LogListComponent = ({
virtualization={virtualization}
wrapLogMessage={wrapLogMessage}
>
{({ itemCount, onItemsRendered, Renderer }) => (
<List
{({ getItemKey, itemCount, onItemsRendered, Renderer }) => (
<VariableSizeList
className={styles.logList}
style={
wrapLogMessage
? {
height: listHeight,
width: '100%',
overflowY: 'scroll',
}
: {
height: listHeight,
width: '100%',
overflow: 'scroll',
}
}
rowProps={{}}
rowCount={itemCount}
rowHeight={getLogLineSize.bind(null, virtualization, filteredLogs, widthContainer, displayedFields, {
height={listHeight}
itemCount={itemCount}
itemSize={getLogLineSize.bind(null, virtualization, filteredLogs, widthContainer, displayedFields, {
detailsMode,
hasLogsWithErrors,
hasSampledLogs,
@@ -545,11 +526,17 @@ const LogListComponent = ({
showTime,
wrap: wrapLogMessage,
})}
rowComponent={Renderer}
onRowsRendered={onItemsRendered}
itemKey={getItemKey}
layout="vertical"
onItemsRendered={onItemsRendered}
outerRef={scrollRef}
overscanCount={5}
listRef={listRef}
/>
ref={listRef}
style={wrapLogMessage ? { overflowY: 'scroll' } : { overflow: 'scroll' }}
width="100%"
>
{Renderer}
</VariableSizeList>
)}
</InfiniteScroll>
</div>
@@ -599,23 +586,16 @@ function getStyles(
};
}
function handleScrollToEvent(event: ScrollToLogsEvent, logs: LogListModel[], list: ListImperativeAPI | null) {
function handleScrollToEvent(event: ScrollToLogsEvent, logs: LogListModel[], list: VariableSizeList | null) {
if (event.payload.scrollTo === 'top') {
list?.scrollToRow({
index: 0,
});
list?.scrollTo(0);
} else if (event.payload.scrollTo === 'bottom') {
list?.scrollToRow({
index: logs.length - 1,
});
list?.scrollToItem(logs.length - 1);
} else {
// uid
const index = logs.findIndex((log) => log.uid === event.payload.scrollTo);
if (index >= 0) {
list?.scrollToRow({
index,
align: 'center',
});
list?.scrollToItem(index, 'center');
}
}
}
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { ChangeEvent, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type ListImperativeAPI } from 'react-window';
import { VariableSizeList } from 'react-window';
import { escapeRegex, GrafanaTheme2, shallowCompare } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -12,7 +12,7 @@ import { useLogListSearchContext } from './LogListSearchContext';
import { LogListModel } from './processing';
interface Props {
listRef: ListImperativeAPI | null;
listRef: VariableSizeList | null;
logs: LogListModel[];
}
@@ -61,10 +61,7 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
}
const prev = currentResult > 0 ? currentResult - 1 : matches.length - 1;
setCurrentResult(prev);
listRef?.scrollToRow({
index: logs.indexOf(matches[prev]),
align: 'center',
});
listRef?.scrollToItem(logs.indexOf(matches[prev]), 'center');
}, [currentResult, listRef, logs, matches]);
const nextResult = useCallback(() => {
@@ -73,10 +70,7 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
}
const next = currentResult < matches.length - 1 ? currentResult + 1 : 0;
setCurrentResult(next);
listRef?.scrollToRow({
index: logs.indexOf(matches[next]),
align: 'center',
});
listRef?.scrollToItem(logs.indexOf(matches[next]), 'center');
}, [currentResult, listRef, logs, matches]);
useEffect(() => {
@@ -86,10 +80,7 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
}
if (!currentResult) {
setCurrentResult(0);
listRef?.scrollToRow({
index: logs.indexOf(matches[0]),
align: 'center',
});
listRef?.scrollToItem(logs.indexOf(matches[0]), 'center');
}
}, [currentResult, listRef, logs, matches]);
@@ -1,9 +1,9 @@
import { css } from '@emotion/css';
import { useMemo, useCallback, useEffect } from 'react';
import { useEffect, useMemo, useRef, useCallback, useState, CSSProperties } from 'react';
import * as React from 'react';
import { useTable, Column, TableOptions, Cell } from 'react-table';
import { List, useListRef, type RowComponentProps } from 'react-window';
import { useInfiniteLoader } from 'react-window-infinite-loader';
import { FixedSizeList } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { Observable } from 'rxjs';
import { Field, GrafanaTheme2 } from '@grafana/data';
@@ -57,7 +57,8 @@ export const SearchResultsTable = React.memo(
const styles = useStyles2(getStyles);
const columnStyles = useStyles2(getColumnStyles);
const tableStyles = useTableStyles(useTheme2(), TableCellHeight.Sm);
const listRef = useListRef(null);
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const [listEl, setListEl] = useState<FixedSizeList | null>(null);
const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, 0, response);
const memoizedData = useMemo(() => {
@@ -71,13 +72,15 @@ export const SearchResultsTable = React.memo(
return Array(response.totalRows).fill(0);
}, [response]);
// Scroll to the top when the query results change
// Scroll to the top and clear loader cache when the query results change
useEffect(() => {
const list = listRef.current;
list?.scrollToRow({
index: 0,
});
}, [listRef, memoizedData]);
if (infiniteLoaderRef.current) {
infiniteLoaderRef.current.resetloadMoreItemsCache();
}
if (listEl) {
listEl.scrollTo(0);
}
}, [memoizedData, listEl]);
// React-table column definitions
const memoizedColumns = useMemo(() => {
@@ -126,14 +129,8 @@ export const SearchResultsTable = React.memo(
[response, selection, selectionToggle]
);
const onRowsRendered = useInfiniteLoader({
rowCount: rows.length,
isRowLoaded: response.isItemLoaded,
loadMoreRows: handleLoadMore,
});
const RenderRow = useCallback(
({ index: rowIndex, style }: RowComponentProps) => {
({ index: rowIndex, style }: { index: number; style: CSSProperties }) => {
const row = rows[rowIndex];
prepareRow(row);
@@ -228,19 +225,29 @@ export const SearchResultsTable = React.memo(
})}
<div {...getTableBodyProps()}>
<List
rowProps={{}}
listRef={listRef}
rowComponent={RenderRow}
rowCount={rows.length}
onRowsRendered={onRowsRendered}
rowHeight={tableStyles.rowHeight}
style={{
height: height - ROW_HEIGHT,
width: width,
overflow: 'hidden auto',
}}
/>
<InfiniteLoader
ref={infiniteLoaderRef}
isItemLoaded={response.isItemLoaded}
itemCount={rows.length}
loadMoreItems={handleLoadMore}
>
{({ onItemsRendered, ref }) => (
<FixedSizeList
ref={(innerRef) => {
ref(innerRef);
setListEl(innerRef);
}}
onItemsRendered={onItemsRendered}
height={height - ROW_HEIGHT}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={width}
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
)}
</InfiniteLoader>
</div>
</div>
);
@@ -16,7 +16,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-select": "5.10.2",
"react-window": "2.2.3",
"react-window": "1.8.11",
"rxjs": "7.8.2",
"stream-browserify": "3.0.0",
"tslib": "2.8.1",
@@ -34,6 +34,7 @@
"@types/node": "24.10.1",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/react-window": "1.8.8",
"@types/uuid": "10.0.0",
"jest": "29.7.0",
"ts-node": "10.9.2",
@@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css';
import { sortBy } from 'lodash';
import { ChangeEvent } from 'react';
import * as React from 'react';
import { List } from 'react-window';
import { FixedSizeList } from 'react-window';
import { CoreApp, GrafanaTheme2, TimeRange } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
@@ -505,19 +505,18 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
onClick={this.onClickLabel}
/>
</div>
<List
rowCount={label.values?.length || 0}
rowHeight={28}
rowProps={{}}
style={{
height: 200,
width: 200,
}}
<FixedSizeList
height={200}
itemCount={label.values?.length || 0}
itemSize={28}
itemKey={(i) => label.values?.[i].name ?? i}
width={200}
className={styles.valueList}
rowComponent={({ index, style }) => {
>
{({ index, style }) => {
const value = label.values?.[index];
if (!value) {
return <></>;
return null;
}
return (
<div style={style}>
@@ -532,7 +531,7 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
</div>
);
}}
/>
</FixedSizeList>
</div>
))}
</div>
+52 -18
View File
@@ -1473,7 +1473,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.26.7, @babel/runtime@npm:^7.27.0, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7":
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.26.7, @babel/runtime@npm:^7.27.0, @babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7":
version: 7.28.4
resolution: "@babel/runtime@npm:7.28.4"
checksum: 10/6c9a70452322ea80b3c9b2a412bcf60771819213a67576c8cec41e88a95bb7bf01fc983754cda35dc19603eef52df22203ccbf7777b9d6316932f9fb77c25163
@@ -2654,6 +2654,7 @@ __metadata:
"@types/node": "npm:24.10.1"
"@types/react": "npm:18.3.18"
"@types/react-dom": "npm:18.3.5"
"@types/react-window": "npm:1.8.8"
"@types/uuid": "npm:10.0.0"
jest: "npm:29.7.0"
lodash: "npm:4.17.21"
@@ -2661,7 +2662,7 @@ __metadata:
react: "npm:18.3.1"
react-dom: "npm:18.3.1"
react-select: "npm:5.10.2"
react-window: "npm:2.2.3"
react-window: "npm:1.8.11"
rxjs: "npm:7.8.2"
stream-browserify: "npm:3.0.0"
ts-node: "npm:10.9.2"
@@ -3521,6 +3522,7 @@ __metadata:
"@types/react": "npm:18.3.18"
"@types/react-dom": "npm:18.3.5"
"@types/react-highlight-words": "npm:0.20.0"
"@types/react-window": "npm:1.8.8"
"@types/semver": "npm:7.7.1"
"@types/uuid": "npm:10.0.0"
debounce-promise: "npm:3.1.2"
@@ -3540,7 +3542,7 @@ __metadata:
react-highlight-words: "npm:0.21.0"
react-select-event: "npm:5.5.1"
react-use: "npm:17.6.0"
react-window: "npm:2.2.3"
react-window: "npm:1.8.11"
rimraf: "npm:6.0.1"
rollup: "npm:^4.22.4"
rollup-plugin-esbuild: "npm:6.2.1"
@@ -3823,6 +3825,7 @@ __metadata:
"@types/react-highlight-words": "npm:0.20.0"
"@types/react-table": "npm:7.7.20"
"@types/react-transition-group": "npm:4.4.12"
"@types/react-window": "npm:1.8.8"
"@types/slate": "npm:0.47.11"
"@types/slate-plain-serializer": "npm:0.7.5"
"@types/slate-react": "npm:0.22.9"
@@ -3882,7 +3885,7 @@ __metadata:
react-table: "npm:7.8.0"
react-transition-group: "npm:4.4.5"
react-use: "npm:17.6.0"
react-window: "npm:2.2.3"
react-window: "npm:1.8.11"
rimraf: "npm:6.0.1"
rollup: "npm:^4.22.4"
rollup-plugin-copy: "npm:3.5.0"
@@ -10724,6 +10727,25 @@ __metadata:
languageName: node
linkType: hard
"@types/react-window-infinite-loader@npm:^1":
version: 1.0.9
resolution: "@types/react-window-infinite-loader@npm:1.0.9"
dependencies:
"@types/react": "npm:*"
"@types/react-window": "npm:*"
checksum: 10/9f2c27f24bfa726ceaef6612a4adbda745f3455c877193f68dfa48591274c670a6df4fa6870785cff5f948e289ceb9a247fb7cbf67e3cd555ab16d11866fd63f
languageName: node
linkType: hard
"@types/react-window@npm:*, @types/react-window@npm:1.8.8":
version: 1.8.8
resolution: "@types/react-window@npm:1.8.8"
dependencies:
"@types/react": "npm:*"
checksum: 10/79b70b7c33161efb14bf69115792843de8e038594136a8373cfbbcc4066c49fd611dd2d3592a9a81d19d21c075bf14e5e73a64f4d9ad32e45d4d5493f5f53918
languageName: node
linkType: hard
"@types/react@npm:*, @types/react@npm:18.3.18":
version: 18.3.18
resolution: "@types/react@npm:18.3.18"
@@ -19297,6 +19319,8 @@ __metadata:
"@types/react-table": "npm:7.7.20"
"@types/react-transition-group": "npm:4.4.12"
"@types/react-virtualized-auto-sizer": "npm:1.0.8"
"@types/react-window": "npm:1.8.8"
"@types/react-window-infinite-loader": "npm:^1"
"@types/redux-mock-store": "npm:1.5.0"
"@types/semver": "npm:7.7.1"
"@types/slate": "npm:0.47.11"
@@ -19476,8 +19500,8 @@ __metadata:
react-use: "npm:17.6.0"
react-virtual: "npm:2.10.4"
react-virtualized-auto-sizer: "npm:1.0.26"
react-window: "npm:2.2.3"
react-window-infinite-loader: "npm:2.0.0"
react-window: "npm:1.8.11"
react-window-infinite-loader: "npm:1.0.10"
reduce-reducers: "npm:^1.0.4"
redux: "npm:5.0.1"
redux-mock-store: "npm:1.5.5"
@@ -23870,6 +23894,13 @@ __metadata:
languageName: node
linkType: hard
"memoize-one@npm:>=3.1.1 <6":
version: 5.2.1
resolution: "memoize-one@npm:5.2.1"
checksum: 10/b7141dc148b5c6fdd51e77ecf0421fd2581681eb8756e0b3dfbd4fe765b5e2b5a6bc90214bb6f19a96b6aed44de17eda3407142a7be9e24ccd0774bbd9874d1b
languageName: node
linkType: hard
"memoize-one@npm:^4.0.0":
version: 4.0.3
resolution: "memoize-one@npm:4.0.3"
@@ -28933,23 +28964,26 @@ __metadata:
languageName: node
linkType: hard
"react-window-infinite-loader@npm:2.0.0":
version: 2.0.0
resolution: "react-window-infinite-loader@npm:2.0.0"
"react-window-infinite-loader@npm:1.0.10":
version: 1.0.10
resolution: "react-window-infinite-loader@npm:1.0.10"
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
checksum: 10/a67ba08dbdb557a46390bf1056ad01d09202fc76492d27818429ac8f9e893792c3476a584fadfc7ae3f2766e1986468658356105000d7b31034287012e7a89dc
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10/4f4c097a2948f8da71d13199289d85c89720e41888ea50039d2b8e6d7cf160300e97f66421c53420d8cb95b3ece74940c07ebd23bddd7a705a679d7e2dc2957e
languageName: node
linkType: hard
"react-window@npm:2.2.3":
version: 2.2.3
resolution: "react-window@npm:2.2.3"
"react-window@npm:1.8.11":
version: 1.8.11
resolution: "react-window@npm:1.8.11"
dependencies:
"@babel/runtime": "npm:^7.0.0"
memoize-one: "npm:>=3.1.1 <6"
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
checksum: 10/f676883c6cd16a0f56690b6d9c929a3da8b7d0ccf78dd5247f474b65ead38aae32a328fcd24c95956d1541383b774fca2b6decae65b6991697a90168222de86d
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10/bdbac2b664c5a799443b97a32b2f60a00cc13cc14ca8a8b1e81e2dc7dd00d8d54f05743113972fe1a641b57ada5d874b59c3cbe7e8a07a88c6713a0fb65d60f6
languageName: node
linkType: hard