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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1922 additions and 234 deletions

File diff suppressed because it is too large Load Diff

View File

@ -94,6 +94,7 @@
"rows-to-fields": (import '../dev-dashboards/transforms/rows-to-fields.json'),
"shared_queries": (import '../dev-dashboards/panel-common/shared_queries.json'),
"slow_queries_and_annotations": (import '../dev-dashboards/scenarios/slow_queries_and_annotations.json'),
"table_footer": (import '../dev-dashboards/panel-table/table_footer.json'),
"table_kitchen_sink": (import '../dev-dashboards/panel-table/table_kitchen_sink.json'),
"table_markdown": (import '../dev-dashboards/panel-table/table_markdown.json'),
"table_pagination": (import '../dev-dashboards/panel-table/table_pagination.json'),

View File

@ -219,7 +219,7 @@ This option is only available when you're editing the panel.
The table footer displays the results of calculations (and reducer functions) on fields.
The footer is only displayed after you select an option in the **Calculation** drop-down list:
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-table-footer-selector-v12.2.png" max-width="300px" alt="" >}}
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-table-footer-selector-v12.2.png" max-width="300px" alt="The footer calculation selector, open" >}}
There are several calculations you can choose from including minimum, maximum, first, last, and total.
For the full list of options, refer to [Calculations](ref:calculations).
@ -227,21 +227,16 @@ For the full list of options, refer to [Calculations](ref:calculations).
In the table footer:
- You can apply multiple calculations at once.
- All calculations and reducer functions are labeled except **Total** when it's the only function applied.
- The calculations and reducer functions apply to all fields in the table, by default. To control which fields have a calculation or function applied, add the table footer in an override instead.
- If you enable a mathematical function for a non-numeric field, nothing for that function is displayed for that field.
In the following image, multiple calculations&mdash;**Mean**, **Max**, and **Last**&mdash;have been applied:
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-table-footer-1-v12.2.png" max-width="750px" alt="" >}}
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-tablefooter-v12.2.png" max-width="750px" alt="Table with footer displaying mean, max, and last" >}}
You can also see in the previous image that the mathematical functions, **Mean** and **Max**, haven't been applied to the text field in the table.
Only the **Last** function has been applied to that field.
In the following image, the **Total** calculation has been applied, and no label is displayed because it's the only function:
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-table-footer-2-v12.2.png" max-width="750px" alt="" >}}
{{< admonition type="note">}}
Calculations applied to cell types like **Markdown + HTML** might have unexpected results.
{{< /admonition>}}

View File

@ -4,7 +4,7 @@ import { test, expect } from '@grafana/plugin-e2e';
import { getColumnIdx } from './table-utils';
const DASHBOARD_UID = '1ea31838-e4e8-4aa0-9333-1d4c3fa95641';
const DASHBOARD_UID = '8100236d-603c-421e-a21b-2a0b0ea4eaa3';
const waitForTableLoad = async (loc: Page | Locator) => {
await expect(loc.locator('.rdg')).toBeVisible();
@ -26,12 +26,6 @@ test.describe('Panels test: Table - Footer', { tag: ['@panels', '@table'] }, ()
const minColumnIdx = await getColumnIdx(page, 'Min');
// this is the footer cell for the "Min" column.
await expect(
dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.ReducerLabel)
.nth(minColumnIdx)
).toHaveText('Last *');
const minReducerValue = await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
.nth(minColumnIdx)
@ -75,12 +69,6 @@ test.describe('Panels test: Table - Footer', { tag: ['@panels', '@table'] }, ()
const minColumnIdx = await getColumnIdx(page, 'Min');
// this is the footer cell for the "Min" column.
await expect(
dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.ReducerLabel)
.nth(minColumnIdx)
).toHaveText('Last *');
const minReducerValue = await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
.nth(minColumnIdx)
@ -114,26 +102,6 @@ test.describe('Panels test: Table - Footer', { tag: ['@panels', '@table'] }, ()
).toHaveText(minReducerValue);
});
test('Single-sum reducer label is hidden', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '6' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Single sum reducer'))
).toBeVisible();
await waitForTableLoad(page);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.ReducerLabel)
).not.toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
).toBeVisible();
});
test('Count rows for normal case', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,

View File

@ -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,

View File

@ -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();
});
});

View File

@ -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%',
}),
});

View File

@ -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'],
]);
});
});
});

View File

@ -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]);
};

View File

@ -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%',

View File

@ -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;
}

View File

@ -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;
}