Table: Update UX for uniform-reducer case in new footer and overflow (#110493)

* Table: Update UX for single-reducer use case in new footer

* all cases are working; unit tests pass

* style and code cleanup in SummaryCell

* remove e2e test for sum reducer label

* reorganize code, todo tests

* slight style cleanup

* one more little reorganization

* updates based on CI failures

* update tests and docs

* unused prop

* update table footer image

* alt text, lint issue

* update gdev to create footer dashboard and re-point e2es there, add a few new cases

* remove console.log
This commit is contained in:
Paul Marbach
2025-09-05 15:46:07 -04:00
committed by GitHub
parent f9e82aba9c
commit 7cbc55d615
12 changed files with 1922 additions and 234 deletions
@@ -25,7 +25,7 @@ import {
getDisplayProcessor,
} from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { FieldColorModeId, TableCellTooltipPlacement } from '@grafana/schema';
import { FieldColorModeId, TableCellTooltipPlacement, TableFooterOptions } from '@grafana/schema';
import { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
import { getTextColorForBackground as _getTextColorForBackground } from '../../../utils/colors';
@@ -95,6 +95,7 @@ import {
shouldTextOverflow,
shouldTextWrap,
withDataLinksActionsTooltip,
getSummaryCellTextAlign,
} from './utils';
const EXPANDED_COLUMN_KEY = 'expanded';
@@ -237,6 +238,33 @@ export function TableNG(props: TableNGProps) {
rowHeight,
});
const [footers, isUniformFooter] = useMemo(() => {
const footers: Array<TableFooterOptions | undefined> = [];
let isUniformFooter = true;
let firstReducers: string[] | undefined;
for (const field of visibleFields) {
const footer = field.config?.custom?.footer;
footers.push(footer);
if (firstReducers === undefined && (footer?.reducers?.length ?? 0) > 0) {
firstReducers = footer?.reducers; // store the reducers for the first visible array with a footer.
} else if (firstReducers !== undefined) {
// once we have a list of reducers, compare each subsequent footer's reducers to the first.
const reducers: string[] | undefined = footer?.reducers;
// ignore fields with no footer reducers.
if (reducers?.length ?? 0 > 0) {
// isUniformFooter is false if there are different numbers of reducers or if the reducers are not identical.
if (reducers!.length !== firstReducers!.length || reducers!.some((r, idx) => firstReducers?.[idx] !== r)) {
isUniformFooter = false;
break;
}
}
}
}
return [footers, isUniformFooter];
}, [visibleFields]);
// normalize the row height into a function which returns a number, so we avoid a bunch of conditionals during rendering.
const rowHeightFn = useMemo((): ((row: TableRow) => number) => {
if (typeof rowHeight === 'function') {
@@ -647,7 +675,17 @@ export function TableNG(props: TableNGProps) {
showTypeIcons={showTypeIcons}
/>
),
renderSummaryCell: () => <SummaryCell rows={rows} field={field} omitCountAll={i > 0} />,
renderSummaryCell: () => (
<SummaryCell
rows={rows}
footers={footers}
field={field}
colIdx={i}
textAlign={getSummaryCellTextAlign(textAlign, cellType)}
rowLabel={isUniformFooter && i === 0}
hideLabel={isUniformFooter && i !== 0}
/>
),
});
});
@@ -660,10 +698,12 @@ export function TableNG(props: TableNGProps) {
data,
disableSanitizeHtml,
filter,
footers,
frozenColumns,
getCellActions,
getCellColorInlineStyles,
getTextColorForBackground,
isUniformFooter,
maxRowHeight,
numFrozenColsFullyInView,
onCellFilterAdded,
@@ -1,6 +1,6 @@
import { screen, render } from '@testing-library/react';
import { Field, FieldType } from '@grafana/data';
import { Field, FieldType, ReducerID } from '@grafana/data';
import { SummaryCell } from './SummaryCell';
@@ -11,6 +11,18 @@ describe('SummaryCell', () => {
{ Field1: 3, Text: 'efghi', __depth: 0, __index: 2 },
];
const footers = [
{
reducers: [ReducerID.sum],
},
{
reducers: [ReducerID.sum],
},
{
reducers: [ReducerID.sum],
},
];
const numericField: Field = {
name: 'Field1',
type: FieldType.number,
@@ -18,7 +30,7 @@ describe('SummaryCell', () => {
config: {
custom: {
footer: {
reducers: ['sum'],
reducers: [ReducerID.sum],
},
},
},
@@ -40,7 +52,7 @@ describe('SummaryCell', () => {
config: {
custom: {
footer: {
reducers: ['sum'],
reducers: [ReducerID.sum],
},
},
},
@@ -61,7 +73,7 @@ describe('SummaryCell', () => {
values: ['a', 'b', 'c'],
config: {
custom: {
reducers: ['sum'],
reducers: [ReducerID.sum],
},
},
display: (value: unknown) => ({
@@ -76,12 +88,12 @@ describe('SummaryCell', () => {
};
it('should calculate sum for numeric fields', () => {
render(<SummaryCell rows={rows} field={numericField} />);
render(<SummaryCell footers={footers} rows={rows} field={numericField} textAlign="left" colIdx={1} />);
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
});
it('should hide the label for the sum reducer if its the only reducer', () => {
render(<SummaryCell rows={rows} field={numericField} />);
it('should hide the label for the sum reducer if hideLabel is true', () => {
render(<SummaryCell footers={footers} rows={rows} field={numericField} textAlign="left" colIdx={1} hideLabel />);
expect(screen.queryByText('Total')).not.toBeInTheDocument();
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
});
@@ -91,10 +103,12 @@ describe('SummaryCell', () => {
...numericField,
config: {
...numericField.config,
custom: { ...numericField.config.custom, footer: { reducers: ['sum', 'mean'] } },
custom: { ...numericField.config.custom, footer: { reducers: [ReducerID.sum, ReducerID.mean] } },
},
};
render(<SummaryCell rows={rows} field={numericFieldWithMultipleReducers} />);
render(
<SummaryCell footers={footers} rows={rows} field={numericFieldWithMultipleReducers} textAlign="left" colIdx={1} />
);
expect(screen.getByText('Total')).toBeInTheDocument();
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
expect(screen.getByText('Mean')).toBeInTheDocument();
@@ -106,25 +120,27 @@ describe('SummaryCell', () => {
...numericField,
config: {
...numericField.config,
custom: { ...numericField.config.custom, footer: { reducers: ['mean'] } },
custom: { ...numericField.config.custom, footer: { reducers: [ReducerID.mean] } },
},
};
render(<SummaryCell rows={rows} field={newNumericField} />);
render(<SummaryCell footers={footers} rows={rows} field={newNumericField} textAlign="left" colIdx={1} />);
expect(screen.getByText('Mean')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument(); // (1 + 2 + 3) / 3
});
it('should render an empty summary cell for non-numeric fields with numeric reducers', () => {
render(<SummaryCell rows={rows} field={textField} />);
render(<SummaryCell footers={footers} rows={rows} field={textField} textAlign="left" colIdx={1} />);
expect(screen.getByTestId('summary-cell-empty')).toBeInTheDocument();
});
it('should render the summary cell if a non-numeric reducer is set for a non-numeric field', () => {
const textFieldNonNumericReducer = {
...textField,
config: { ...textField.config, custom: { ...textField.config.custom, footer: { reducers: ['last'] } } },
config: { ...textField.config, custom: { ...textField.config.custom, footer: { reducers: [ReducerID.last] } } },
};
render(<SummaryCell rows={rows} field={textFieldNonNumericReducer} />);
render(
<SummaryCell footers={footers} rows={rows} field={textFieldNonNumericReducer} textAlign="left" colIdx={1} />
);
expect(screen.getByText('Last')).toBeInTheDocument();
expect(screen.getByText('efghi')).toBeInTheDocument();
});
@@ -134,15 +150,15 @@ describe('SummaryCell', () => {
...numericField,
config: { ...numericField.config, custom: { ...numericField.config.custom, footer: { reducers: [] } } },
};
render(<SummaryCell rows={rows} field={numericFieldNoReducers} />);
render(<SummaryCell footers={footers} rows={rows} field={numericFieldNoReducers} textAlign="left" colIdx={1} />);
expect(screen.getByTestId('summary-cell-empty')).toBeInTheDocument();
});
it('should correctly calculate sum for numeric fields based on selected fields', () => {
render(
<>
<SummaryCell rows={rows} field={numericField} />
<SummaryCell rows={rows} field={numericField2} />
<SummaryCell footers={footers} rows={rows} field={numericField} textAlign="left" colIdx={1} />
<SummaryCell footers={footers} rows={rows} field={numericField2} textAlign="left" colIdx={1} />
</>
);
@@ -157,14 +173,14 @@ describe('SummaryCell', () => {
...field.config,
custom: {
...field.config.custom,
footer: { reducers: ['sum', 'mean', 'last'] },
footer: { reducers: [ReducerID.sum, ReducerID.mean, ReducerID.last] },
},
},
}));
render(
<>
{fields.map((field, index) => (
<SummaryCell key={index} rows={rows} field={field} />
<SummaryCell footers={footers} key={index} rows={rows} field={field} textAlign="left" colIdx={1} />
))}
</>
);
@@ -173,5 +189,24 @@ describe('SummaryCell', () => {
expect(screen.getByText('efghi')).toBeInTheDocument(); // last
});
// TODO: add test for noFormattingReducers
it('renders row labels when the prop is set, there are reducers in the footers, and no reducers for this field', () => {
const numericFieldNoReducers = {
...numericField,
state: {
displayName: 'NoReducer',
},
config: { ...numericField.config, custom: { ...numericField.config.custom, footer: { reducers: [] } } },
};
render(
<SummaryCell
footers={[{ reducers: [] }, ...footers]}
rows={rows.map((r) => ({ ...r, NoReducer: 1 }))}
field={numericFieldNoReducers}
textAlign="left"
colIdx={0}
rowLabel
/>
);
expect(screen.getByText('Total')).toBeInTheDocument();
});
});
@@ -1,219 +1,146 @@
import { css, cx } from '@emotion/css';
import { useMemo } from 'react';
import { css } from '@emotion/css';
import clsx from 'clsx';
import { ReactNode, useMemo } from 'react';
import {
GrafanaTheme2,
Field,
FieldState,
FieldType,
reduceField,
fieldReducers,
formattedValueToString,
ReducerID,
} from '@grafana/data';
import { GrafanaTheme2, Field, fieldReducers, ReducerID } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { TableFooterOptions } from '@grafana/schema';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { useStyles2, useTheme2 } from '../../../../themes/ThemeContext';
import { useReducerEntries } from '../hooks';
import { getDefaultCellStyles } from '../styles';
import { TableRow } from '../types';
import { getDisplayName } from '../utils';
import { getDisplayName, getJustifyContent, TextAlign } from '../utils';
interface SummaryCellProps {
rows: TableRow[];
field: Field;
omitCountAll?: boolean;
footers: Array<TableFooterOptions | undefined>;
textAlign: TextAlign;
colIdx: number;
rowLabel?: boolean;
hideLabel?: boolean;
}
export interface ReducerResult {
value: number | null;
formattedValue: string;
reducerName: string;
}
interface FooterFieldState extends FieldState {
lastProcessedRowCount: number;
}
function isReducer(maybeReducer: string): maybeReducer is ReducerID {
return maybeReducer in ReducerID;
}
const nonMathReducers = new Set<ReducerID>([
ReducerID.allValues,
ReducerID.changeCount,
ReducerID.count,
ReducerID.countAll,
ReducerID.distinctCount,
ReducerID.first,
ReducerID.firstNotNull,
ReducerID.last,
ReducerID.lastNotNull,
ReducerID.uniqueValues,
]);
const isNonMathReducer = (reducer: string) => isReducer(reducer) && nonMathReducers.has(reducer);
const noFormattingReducers = new Set<ReducerID>([ReducerID.count, ReducerID.countAll]);
const shouldReducerSkipFormatting = (reducer: string) => isReducer(reducer) && noFormattingReducers.has(reducer);
export const SummaryCell = ({ rows, field, omitCountAll = false }: SummaryCellProps) => {
const styles = useStyles2(getStyles);
const displayName = getDisplayName(field);
const reducerResultsEntries = useMemo<Array<[string, ReducerResult | null]>>(() => {
const reducers: string[] = field.config.custom?.footer?.reducers ?? [];
if (
reducers.length === 0 ||
(field.type !== FieldType.number && reducers.every((reducerId) => !isNonMathReducer(reducerId)))
) {
return [];
}
// Create a new state object that matches the original behavior exactly
const newState: FooterFieldState = {
lastProcessedRowCount: 0,
...(field.state || {}), // Preserve any existing state properties
};
// Assign back to field
field.state = newState;
const currentRowCount = rows.length;
const lastRowCount = newState.lastProcessedRowCount;
// Check if we need to invalidate the cache
if (lastRowCount !== currentRowCount) {
// Cache should be invalidated as row count has changed
if (newState.calcs) {
delete newState.calcs;
}
// Update the row count tracker
newState.lastProcessedRowCount = currentRowCount;
}
// Calculate all specified reducers
const results: Record<string, number | null> = reduceField({
field: {
...field,
values: rows.map((row) => row[displayName]),
},
reducers,
});
return reducers.map((reducerId) => {
// For number fields, show all reducers
// For non-number fields, only show special count reducers
if (results[reducerId] === undefined || (field.type !== FieldType.number && !isNonMathReducer(reducerId))) {
return [reducerId, null];
}
const value = results[reducerId];
const reducerName = fieldReducers.get(reducerId)?.name || reducerId;
const formattedValue =
field.display && !shouldReducerSkipFormatting(reducerId)
? formattedValueToString(field.display(value))
: String(value);
return [
reducerId,
{
value,
formattedValue,
reducerName,
},
];
});
}, [field, rows, displayName]);
const isSingleSumReducer = useMemo(
() => reducerResultsEntries.every(([item]) => item === 'sum'),
[reducerResultsEntries]
);
if (reducerResultsEntries.length === 0) {
return <div data-testid="summary-cell-empty" className={styles.footerCell} />;
const getReducerName = (reducerId: string): string => {
if (reducerId === ReducerID.countAll) {
return t('grafana-ui.table.footer.reducer.count', 'Count');
}
return fieldReducers.get(reducerId)?.name || reducerId;
};
export const SummaryCell = ({
rows,
footers,
field,
colIdx,
hideLabel = false,
rowLabel = false,
textAlign,
}: SummaryCellProps) => {
const styles = useStyles2(getStyles, textAlign, hideLabel);
const theme = useTheme2();
const defaultFooterCellStyles = getDefaultCellStyles(theme, {
textAlign: 'left', // alignment is set in footerItem
shouldOverflow: true,
textWrap: false,
});
const displayName = getDisplayName(field);
const reducerResultsEntries = useReducerEntries(field, rows, displayName, colIdx);
const cellClass = clsx(styles.footerCell, defaultFooterCellStyles);
const firstFooterReducers = useMemo(() => {
for (const footer of footers) {
if (footer?.reducers?.length ?? 0 > 0) {
return footer!.reducers!;
}
}
return;
}, [footers]);
const renderRowLabel = rowLabel && reducerResultsEntries.length === 0 && Boolean(firstFooterReducers);
const SummaryCellItem = ({ children }: { children: ReactNode }) => (
<div className={styles.footerItem}>{children}</div>
);
const SummaryCellLabel = ({ children }: { children: ReactNode }) => (
<div
data-testid={selectors.components.Panels.Visualization.TableNG.Footer.ReducerLabel}
className={styles.footerItemLabel}
>
{children}
</div>
);
const SummaryCellValue = ({ children }: { children: ReactNode }) => (
<div
data-testid={selectors.components.Panels.Visualization.TableNG.Footer.Value}
className={styles.footerItemValue}
>
{children}
</div>
);
// Render each reducer in the footer
return (
<div className={styles.footerCell}>
{reducerResultsEntries.map(([reducerId, reducerResultEntry]) => {
const isCountAll = reducerId === ReducerID.countAll;
<div
className={cellClass}
data-testid={reducerResultsEntries.length === 0 && !renderRowLabel ? 'summary-cell-empty' : undefined}
>
{reducerResultsEntries.map(([reducerId, reducerResult]) => {
// empty reducer entry, but there may be more after - render a spacer.
if ((isCountAll && omitCountAll) || reducerResultEntry === null) {
if (reducerResult === null) {
return (
<div key={reducerId} className={styles.footerItem}>
&nbsp;
</div>
<SummaryCellItem key={reducerId}>
{rowLabel ? <SummaryCellLabel>{getReducerName(reducerId)}</SummaryCellLabel> : <>&nbsp;</>}
</SummaryCellItem>
);
}
const { reducerName, formattedValue } = reducerResultEntry;
const canonicalReducerName = isCountAll ? t('grafana-ui.table.footer.reducer.count', 'Count') : reducerName;
return (
<div key={reducerId} className={cx(styles.footerItem, isSingleSumReducer && styles.sumReducer)}>
{!isSingleSumReducer && (
<div
data-testid={selectors.components.Panels.Visualization.TableNG.Footer.ReducerLabel}
className={styles.footerItemLabel}
>
{canonicalReducerName}
</div>
)}
<div
data-testid={selectors.components.Panels.Visualization.TableNG.Footer.Value}
className={styles.footerItemValue}
>
{formattedValue}
</div>
</div>
<SummaryCellItem key={reducerId}>
{!hideLabel && <SummaryCellLabel>{getReducerName(reducerId)}</SummaryCellLabel>}
<SummaryCellValue>{reducerResult}</SummaryCellValue>
</SummaryCellItem>
);
})}
{renderRowLabel &&
firstFooterReducers!.map((reducerId) => (
<SummaryCellItem key={reducerId}>
<SummaryCellLabel>{getReducerName(reducerId)}</SummaryCellLabel>
</SummaryCellItem>
))}
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
export const getStyles = (theme: GrafanaTheme2, textAlign: TextAlign, hideLabel: boolean) => ({
footerCell: css({
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: '100%',
width: '100%',
}),
footerItem: css({
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
justifyContent: hideLabel ? getJustifyContent(textAlign) : 'space-between',
alignItems: 'flex-start',
width: '100%',
gap: theme.spacing(0.5),
}),
footerItemLabel: css({
// Handle overflow reducer name collision with footer item value
maxWidth: '75%',
flexShrink: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: theme.colors.text.secondary,
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightLight,
marginRight: theme.spacing(1),
textTransform: 'uppercase',
lineHeight: '22px',
}),
footerItemValue: css({
maxWidth: '75%',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontWeight: theme.typography.fontWeightMedium,
}),
sumReducer: css({
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
width: '100%',
}),
});
@@ -1,10 +1,17 @@
import { act, renderHook } from '@testing-library/react';
import { createDataFrame, Field, FieldType } from '@grafana/data';
import { createDataFrame, Field, FieldType, ReducerID } from '@grafana/data';
import { TableCellDisplayMode } from '@grafana/schema';
import { TABLE } from './constants';
import { useFilteredRows, usePaginatedRows, useSortedRows, useHeaderHeight, useRowHeight } from './hooks';
import {
useFilteredRows,
usePaginatedRows,
useSortedRows,
useHeaderHeight,
useRowHeight,
useReducerEntries,
} from './hooks';
import { TableRow } from './types';
import { createTypographyContext } from './utils';
@@ -581,4 +588,124 @@ describe('TableNG hooks', () => {
});
});
});
describe('useReducerEntries', () => {
it('should return the correct reducers for a field', () => {
const { fields, rows } = setupData();
fields[0].config.custom = {
footer: {
reducers: [ReducerID.first],
},
};
fields[1].config.custom = {
footer: {
reducers: [ReducerID.mean, 'max', 'min', ReducerID.first],
},
};
const { result } = renderHook(() => useReducerEntries(fields[0], rows, 'name', 0));
expect(result.current).toEqual([[ReducerID.first, 'Alice']]);
const { result: result2 } = renderHook(() => useReducerEntries(fields[1], rows, 'age', 0));
expect(result2.current).toEqual([
[ReducerID.mean, '30'],
['max', '35'],
['min', '25'],
[ReducerID.first, '30'],
]);
});
it('should return an empty array if no reducers are configured', () => {
const { fields, rows } = setupData();
const { result } = renderHook(() => useReducerEntries(fields[0], rows, 'name', 0));
expect(result.current).toEqual([]);
});
it('should return an empty array if all of the reducers are numeric and the field non-numeric', () => {
const { fields, rows } = setupData();
fields[0].config.custom = {
footer: {
reducers: [ReducerID.mean, 'max'],
},
};
const { result } = renderHook(() => useReducerEntries(fields[0], rows, 'name', 0));
expect(result.current).toEqual([]);
});
it('should return null for non-numeric fields for numeric reducers', () => {
const { fields, rows } = setupData();
fields[0].config.custom = {
footer: {
reducers: [ReducerID.mean, ReducerID.first],
},
};
const { result } = renderHook(() => useReducerEntries(fields[0], rows, 'name', 0));
expect(result.current).toEqual([
[ReducerID.mean, null],
[ReducerID.first, 'Alice'],
]);
});
it('should return null when the colIdx is not 0 for the countAll reducer', () => {
const { fields, rows } = setupData();
fields[0].config.custom = {
footer: {
reducers: [ReducerID.countAll, ReducerID.first],
},
};
const { result } = renderHook(() => useReducerEntries(fields[0], rows, 'name', 1));
expect(result.current).toEqual([
[ReducerID.countAll, null],
[ReducerID.first, 'Alice'],
]);
});
it('should return null (and should not throw) for an unknown reducer', () => {
const { fields, rows } = setupData();
fields[0].config.custom = {
footer: {
reducers: ['unknownReducer', ReducerID.first],
},
};
const { result } = renderHook(() => useReducerEntries(fields[0], rows, 'name', 0));
expect(result.current).toEqual([
['unknownReducer', null],
[ReducerID.first, 'Alice'],
]);
});
it('should format the value for most reducers', () => {
const { fields, rows } = setupData();
fields[1].config.custom = {
footer: {
reducers: [ReducerID.mean, ReducerID.first],
},
};
fields[1].display = (v) => ({ text: `$${v}`, numeric: v as number });
const { result } = renderHook(() => useReducerEntries(fields[1], rows, 'age', 0));
expect(result.current).toEqual([
[ReducerID.mean, '$30'],
[ReducerID.first, '$30'],
]);
});
it.each([ReducerID.count, ReducerID.countAll])('should not format the value for the %s reducer', (reducerId) => {
const { fields, rows } = setupData();
fields[1].config.custom = {
footer: {
reducers: [reducerId, ReducerID.first],
},
};
fields[1].display = (v) => ({ text: `${v} years`, numeric: v as number });
const { result } = renderHook(() => useReducerEntries(fields[1], rows, 'age', 0));
expect(result.current).toEqual([
[reducerId, '3'],
[ReducerID.first, '30 years'],
]);
});
});
});
@@ -1,12 +1,12 @@
import { useState, useMemo, useCallback, useRef, useLayoutEffect, RefObject, CSSProperties, useEffect } from 'react';
import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-grid';
import { compareArrayValues, Field, formattedValueToString } from '@grafana/data';
import { compareArrayValues, Field, FieldType, formattedValueToString, reduceField, ReducerID } from '@grafana/data';
import { TableColumnResizeActionCallback } from '../types';
import { TABLE } from './constants';
import { FilterType, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
import { FilterType, FooterFieldState, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
import {
getDisplayName,
processNestedTableRows,
@@ -529,3 +529,91 @@ export function useColWidths(
return [widths, numFrozenColsFullyInView];
}
const isReducer = (maybeReducer: string): maybeReducer is ReducerID => maybeReducer in ReducerID;
const nonMathReducers = new Set<ReducerID>([
ReducerID.allValues,
ReducerID.changeCount,
ReducerID.count,
ReducerID.countAll,
ReducerID.distinctCount,
ReducerID.first,
ReducerID.firstNotNull,
ReducerID.last,
ReducerID.lastNotNull,
ReducerID.uniqueValues,
]);
const isNonMathReducer = (reducer: string) => isReducer(reducer) && nonMathReducers.has(reducer);
const noFormattingReducers = new Set<ReducerID>([ReducerID.count, ReducerID.countAll]);
const shouldReducerSkipFormatting = (reducer: string) => isReducer(reducer) && noFormattingReducers.has(reducer);
export const useReducerEntries = (
field: Field,
rows: TableRow[],
displayName: string,
colIdx: number
): Array<[string, string | null]> => {
return useMemo(() => {
const reducers: string[] = field.config.custom?.footer?.reducers ?? [];
if (
reducers.length === 0 ||
(field.type !== FieldType.number && reducers.every((reducerId) => !isNonMathReducer(reducerId)))
) {
return [];
}
// Create a new state object that matches the original behavior exactly
const newState: FooterFieldState = {
lastProcessedRowCount: 0,
...(field.state || {}), // Preserve any existing state properties
};
// Assign back to field
field.state = newState;
const currentRowCount = rows.length;
const lastRowCount = newState.lastProcessedRowCount;
// Check if we need to invalidate the cache
if (lastRowCount !== currentRowCount) {
// Cache should be invalidated as row count has changed
if (newState.calcs) {
delete newState.calcs;
}
// Update the row count tracker
newState.lastProcessedRowCount = currentRowCount;
}
// Calculate all specified reducers
const results: Record<string, number | null> = reduceField({
field: {
...field,
values: rows.map((row) => row[displayName]),
},
reducers,
});
return reducers.map((reducerId) => {
if (
results[reducerId] === undefined ||
// For non-number fields, only show special count reducers
(field.type !== FieldType.number && !isNonMathReducer(reducerId)) ||
// for countAll, only show the reducer in the first column
(reducerId === ReducerID.countAll && colIdx !== 0)
) {
return [reducerId, null];
}
const value = results[reducerId];
let result = null;
if (!shouldReducerSkipFormatting(reducerId) && field.display) {
result = formattedValueToString(field.display(value));
} else if (value != null) {
result = String(value);
}
return [reducerId, result];
});
}, [field, rows, displayName, colIdx]);
};
@@ -70,6 +70,20 @@ export const getGridStyles = (theme: GrafanaTheme2, enablePagination?: boolean,
},
},
},
'.rdg-summary-row >': {
'.rdg-cell': {
// 0.75 padding causes "jumping" on hover.
paddingBlock: theme.spacing(0.625),
},
[getActiveCellSelector()]: {
whiteSpace: 'pre-line',
height: '100%',
minHeight: 'fit-content',
overflowY: 'visible',
boxShadow: theme.shadows.z2,
},
},
}),
gridNested: css({
height: '100%',
@@ -12,6 +12,7 @@ import {
FieldType,
DataFrameWithValue,
SelectableValue,
FieldState,
} from '@grafana/data';
import { TableCellHeight, TableFieldOptions } from '@grafana/schema';
@@ -309,3 +310,7 @@ export interface FromFieldsResult {
cellRootRenderers: Record<string, CellRootRenderer>;
colsWithTooltip: Record<string, boolean>;
}
export interface FooterFieldState extends FieldState {
lastProcessedRowCount: number;
}
@@ -1031,3 +1031,19 @@ export const displayJsonValue: DisplayProcessor = (value: unknown): DisplayValue
return { text: displayValue, numeric: Number.NaN };
};
export function getSummaryCellTextAlign(textAlign: TextAlign, cellType: TableCellDisplayMode): TextAlign {
// gauge is weird. left-aligned gauge has the viz on the left and its numbers on the right, and vice-versa.
// if you center-aligned your gauge... ok.
if (cellType === TableCellDisplayMode.Gauge) {
return (
{
left: 'right',
right: 'left',
center: 'center',
} as const
)[textAlign];
}
return textAlign;
}