mirror of https://github.com/grafana/grafana.git
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:
parent
f9e82aba9c
commit
7cbc55d615
File diff suppressed because it is too large
Load Diff
|
@ -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'),
|
||||
|
|
|
@ -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—**Mean**, **Max**, and **Last**—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>}}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}>
|
||||
|
||||
</div>
|
||||
<SummaryCellItem key={reducerId}>
|
||||
{rowLabel ? <SummaryCellLabel>{getReducerName(reducerId)}</SummaryCellLabel> : <> </>}
|
||||
</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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue