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'),
|
"rows-to-fields": (import '../dev-dashboards/transforms/rows-to-fields.json'),
|
||||||
"shared_queries": (import '../dev-dashboards/panel-common/shared_queries.json'),
|
"shared_queries": (import '../dev-dashboards/panel-common/shared_queries.json'),
|
||||||
"slow_queries_and_annotations": (import '../dev-dashboards/scenarios/slow_queries_and_annotations.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_kitchen_sink": (import '../dev-dashboards/panel-table/table_kitchen_sink.json'),
|
||||||
"table_markdown": (import '../dev-dashboards/panel-table/table_markdown.json'),
|
"table_markdown": (import '../dev-dashboards/panel-table/table_markdown.json'),
|
||||||
"table_pagination": (import '../dev-dashboards/panel-table/table_pagination.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 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:
|
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.
|
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).
|
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:
|
In the table footer:
|
||||||
|
|
||||||
- You can apply multiple calculations at once.
|
- 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.
|
- 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.
|
- 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:
|
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.
|
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.
|
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">}}
|
{{< admonition type="note">}}
|
||||||
Calculations applied to cell types like **Markdown + HTML** might have unexpected results.
|
Calculations applied to cell types like **Markdown + HTML** might have unexpected results.
|
||||||
{{< /admonition>}}
|
{{< /admonition>}}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||||
|
|
||||||
import { getColumnIdx } from './table-utils';
|
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) => {
|
const waitForTableLoad = async (loc: Page | Locator) => {
|
||||||
await expect(loc.locator('.rdg')).toBeVisible();
|
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');
|
const minColumnIdx = await getColumnIdx(page, 'Min');
|
||||||
|
|
||||||
// this is the footer cell for the "Min" column.
|
// 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
|
const minReducerValue = await dashboardPage
|
||||||
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
|
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
|
||||||
.nth(minColumnIdx)
|
.nth(minColumnIdx)
|
||||||
|
@ -75,12 +69,6 @@ test.describe('Panels test: Table - Footer', { tag: ['@panels', '@table'] }, ()
|
||||||
const minColumnIdx = await getColumnIdx(page, 'Min');
|
const minColumnIdx = await getColumnIdx(page, 'Min');
|
||||||
|
|
||||||
// this is the footer cell for the "Min" column.
|
// 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
|
const minReducerValue = await dashboardPage
|
||||||
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
|
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
|
||||||
.nth(minColumnIdx)
|
.nth(minColumnIdx)
|
||||||
|
@ -114,26 +102,6 @@ test.describe('Panels test: Table - Footer', { tag: ['@panels', '@table'] }, ()
|
||||||
).toHaveText(minReducerValue);
|
).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 }) => {
|
test('Count rows for normal case', async ({ gotoDashboardPage, selectors, page }) => {
|
||||||
const dashboardPage = await gotoDashboardPage({
|
const dashboardPage = await gotoDashboardPage({
|
||||||
uid: DASHBOARD_UID,
|
uid: DASHBOARD_UID,
|
||||||
|
|
|
@ -25,7 +25,7 @@ import {
|
||||||
getDisplayProcessor,
|
getDisplayProcessor,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { Trans } from '@grafana/i18n';
|
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 { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
|
||||||
import { getTextColorForBackground as _getTextColorForBackground } from '../../../utils/colors';
|
import { getTextColorForBackground as _getTextColorForBackground } from '../../../utils/colors';
|
||||||
|
@ -95,6 +95,7 @@ import {
|
||||||
shouldTextOverflow,
|
shouldTextOverflow,
|
||||||
shouldTextWrap,
|
shouldTextWrap,
|
||||||
withDataLinksActionsTooltip,
|
withDataLinksActionsTooltip,
|
||||||
|
getSummaryCellTextAlign,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
const EXPANDED_COLUMN_KEY = 'expanded';
|
const EXPANDED_COLUMN_KEY = 'expanded';
|
||||||
|
@ -237,6 +238,33 @@ export function TableNG(props: TableNGProps) {
|
||||||
rowHeight,
|
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.
|
// 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) => {
|
const rowHeightFn = useMemo((): ((row: TableRow) => number) => {
|
||||||
if (typeof rowHeight === 'function') {
|
if (typeof rowHeight === 'function') {
|
||||||
|
@ -647,7 +675,17 @@ export function TableNG(props: TableNGProps) {
|
||||||
showTypeIcons={showTypeIcons}
|
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,
|
data,
|
||||||
disableSanitizeHtml,
|
disableSanitizeHtml,
|
||||||
filter,
|
filter,
|
||||||
|
footers,
|
||||||
frozenColumns,
|
frozenColumns,
|
||||||
getCellActions,
|
getCellActions,
|
||||||
getCellColorInlineStyles,
|
getCellColorInlineStyles,
|
||||||
getTextColorForBackground,
|
getTextColorForBackground,
|
||||||
|
isUniformFooter,
|
||||||
maxRowHeight,
|
maxRowHeight,
|
||||||
numFrozenColsFullyInView,
|
numFrozenColsFullyInView,
|
||||||
onCellFilterAdded,
|
onCellFilterAdded,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { screen, render } from '@testing-library/react';
|
import { screen, render } from '@testing-library/react';
|
||||||
|
|
||||||
import { Field, FieldType } from '@grafana/data';
|
import { Field, FieldType, ReducerID } from '@grafana/data';
|
||||||
|
|
||||||
import { SummaryCell } from './SummaryCell';
|
import { SummaryCell } from './SummaryCell';
|
||||||
|
|
||||||
|
@ -11,6 +11,18 @@ describe('SummaryCell', () => {
|
||||||
{ Field1: 3, Text: 'efghi', __depth: 0, __index: 2 },
|
{ Field1: 3, Text: 'efghi', __depth: 0, __index: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const footers = [
|
||||||
|
{
|
||||||
|
reducers: [ReducerID.sum],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reducers: [ReducerID.sum],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reducers: [ReducerID.sum],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const numericField: Field = {
|
const numericField: Field = {
|
||||||
name: 'Field1',
|
name: 'Field1',
|
||||||
type: FieldType.number,
|
type: FieldType.number,
|
||||||
|
@ -18,7 +30,7 @@ describe('SummaryCell', () => {
|
||||||
config: {
|
config: {
|
||||||
custom: {
|
custom: {
|
||||||
footer: {
|
footer: {
|
||||||
reducers: ['sum'],
|
reducers: [ReducerID.sum],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -40,7 +52,7 @@ describe('SummaryCell', () => {
|
||||||
config: {
|
config: {
|
||||||
custom: {
|
custom: {
|
||||||
footer: {
|
footer: {
|
||||||
reducers: ['sum'],
|
reducers: [ReducerID.sum],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -61,7 +73,7 @@ describe('SummaryCell', () => {
|
||||||
values: ['a', 'b', 'c'],
|
values: ['a', 'b', 'c'],
|
||||||
config: {
|
config: {
|
||||||
custom: {
|
custom: {
|
||||||
reducers: ['sum'],
|
reducers: [ReducerID.sum],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
display: (value: unknown) => ({
|
display: (value: unknown) => ({
|
||||||
|
@ -76,12 +88,12 @@ describe('SummaryCell', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should calculate sum for numeric fields', () => {
|
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
|
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should hide the label for the sum reducer if its the only reducer', () => {
|
it('should hide the label for the sum reducer if hideLabel is true', () => {
|
||||||
render(<SummaryCell rows={rows} field={numericField} />);
|
render(<SummaryCell footers={footers} rows={rows} field={numericField} textAlign="left" colIdx={1} hideLabel />);
|
||||||
expect(screen.queryByText('Total')).not.toBeInTheDocument();
|
expect(screen.queryByText('Total')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
|
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
|
||||||
});
|
});
|
||||||
|
@ -91,10 +103,12 @@ describe('SummaryCell', () => {
|
||||||
...numericField,
|
...numericField,
|
||||||
config: {
|
config: {
|
||||||
...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('Total')).toBeInTheDocument();
|
||||||
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
|
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
|
||||||
expect(screen.getByText('Mean')).toBeInTheDocument();
|
expect(screen.getByText('Mean')).toBeInTheDocument();
|
||||||
|
@ -106,25 +120,27 @@ describe('SummaryCell', () => {
|
||||||
...numericField,
|
...numericField,
|
||||||
config: {
|
config: {
|
||||||
...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('Mean')).toBeInTheDocument();
|
||||||
expect(screen.getByText('2')).toBeInTheDocument(); // (1 + 2 + 3) / 3
|
expect(screen.getByText('2')).toBeInTheDocument(); // (1 + 2 + 3) / 3
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render an empty summary cell for non-numeric fields with numeric reducers', () => {
|
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();
|
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', () => {
|
it('should render the summary cell if a non-numeric reducer is set for a non-numeric field', () => {
|
||||||
const textFieldNonNumericReducer = {
|
const textFieldNonNumericReducer = {
|
||||||
...textField,
|
...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('Last')).toBeInTheDocument();
|
||||||
expect(screen.getByText('efghi')).toBeInTheDocument();
|
expect(screen.getByText('efghi')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -134,15 +150,15 @@ describe('SummaryCell', () => {
|
||||||
...numericField,
|
...numericField,
|
||||||
config: { ...numericField.config, custom: { ...numericField.config.custom, footer: { reducers: [] } } },
|
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();
|
expect(screen.getByTestId('summary-cell-empty')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly calculate sum for numeric fields based on selected fields', () => {
|
it('should correctly calculate sum for numeric fields based on selected fields', () => {
|
||||||
render(
|
render(
|
||||||
<>
|
<>
|
||||||
<SummaryCell rows={rows} field={numericField} />
|
<SummaryCell footers={footers} rows={rows} field={numericField} textAlign="left" colIdx={1} />
|
||||||
<SummaryCell rows={rows} field={numericField2} />
|
<SummaryCell footers={footers} rows={rows} field={numericField2} textAlign="left" colIdx={1} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -157,14 +173,14 @@ describe('SummaryCell', () => {
|
||||||
...field.config,
|
...field.config,
|
||||||
custom: {
|
custom: {
|
||||||
...field.config.custom,
|
...field.config.custom,
|
||||||
footer: { reducers: ['sum', 'mean', 'last'] },
|
footer: { reducers: [ReducerID.sum, ReducerID.mean, ReducerID.last] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
render(
|
render(
|
||||||
<>
|
<>
|
||||||
{fields.map((field, index) => (
|
{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
|
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 { css } from '@emotion/css';
|
||||||
import { useMemo } from 'react';
|
import clsx from 'clsx';
|
||||||
|
import { ReactNode, useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import { GrafanaTheme2, Field, fieldReducers, ReducerID } from '@grafana/data';
|
||||||
GrafanaTheme2,
|
|
||||||
Field,
|
|
||||||
FieldState,
|
|
||||||
FieldType,
|
|
||||||
reduceField,
|
|
||||||
fieldReducers,
|
|
||||||
formattedValueToString,
|
|
||||||
ReducerID,
|
|
||||||
} from '@grafana/data';
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { t } from '@grafana/i18n';
|
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 { TableRow } from '../types';
|
||||||
import { getDisplayName } from '../utils';
|
import { getDisplayName, getJustifyContent, TextAlign } from '../utils';
|
||||||
|
|
||||||
interface SummaryCellProps {
|
interface SummaryCellProps {
|
||||||
rows: TableRow[];
|
rows: TableRow[];
|
||||||
field: Field;
|
field: Field;
|
||||||
omitCountAll?: boolean;
|
footers: Array<TableFooterOptions | undefined>;
|
||||||
|
textAlign: TextAlign;
|
||||||
|
colIdx: number;
|
||||||
|
rowLabel?: boolean;
|
||||||
|
hideLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReducerResult {
|
const getReducerName = (reducerId: string): string => {
|
||||||
value: number | null;
|
if (reducerId === ReducerID.countAll) {
|
||||||
formattedValue: string;
|
return t('grafana-ui.table.footer.reducer.count', 'Count');
|
||||||
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} />;
|
|
||||||
}
|
}
|
||||||
|
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
|
// Render each reducer in the footer
|
||||||
return (
|
return (
|
||||||
<div className={styles.footerCell}>
|
<div
|
||||||
{reducerResultsEntries.map(([reducerId, reducerResultEntry]) => {
|
className={cellClass}
|
||||||
const isCountAll = reducerId === ReducerID.countAll;
|
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.
|
// empty reducer entry, but there may be more after - render a spacer.
|
||||||
if ((isCountAll && omitCountAll) || reducerResultEntry === null) {
|
if (reducerResult === null) {
|
||||||
return (
|
return (
|
||||||
<div key={reducerId} className={styles.footerItem}>
|
<SummaryCellItem key={reducerId}>
|
||||||
|
{rowLabel ? <SummaryCellLabel>{getReducerName(reducerId)}</SummaryCellLabel> : <> </>}
|
||||||
</div>
|
</SummaryCellItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { reducerName, formattedValue } = reducerResultEntry;
|
|
||||||
|
|
||||||
const canonicalReducerName = isCountAll ? t('grafana-ui.table.footer.reducer.count', 'Count') : reducerName;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={reducerId} className={cx(styles.footerItem, isSingleSumReducer && styles.sumReducer)}>
|
<SummaryCellItem key={reducerId}>
|
||||||
{!isSingleSumReducer && (
|
{!hideLabel && <SummaryCellLabel>{getReducerName(reducerId)}</SummaryCellLabel>}
|
||||||
<div
|
<SummaryCellValue>{reducerResult}</SummaryCellValue>
|
||||||
data-testid={selectors.components.Panels.Visualization.TableNG.Footer.ReducerLabel}
|
</SummaryCellItem>
|
||||||
className={styles.footerItemLabel}
|
|
||||||
>
|
|
||||||
{canonicalReducerName}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
data-testid={selectors.components.Panels.Visualization.TableNG.Footer.Value}
|
|
||||||
className={styles.footerItemValue}
|
|
||||||
>
|
|
||||||
{formattedValue}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{renderRowLabel &&
|
||||||
|
firstFooterReducers!.map((reducerId) => (
|
||||||
|
<SummaryCellItem key={reducerId}>
|
||||||
|
<SummaryCellLabel>{getReducerName(reducerId)}</SummaryCellLabel>
|
||||||
|
</SummaryCellItem>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
export const getStyles = (theme: GrafanaTheme2, textAlign: TextAlign, hideLabel: boolean) => ({
|
||||||
footerCell: css({
|
footerCell: css({
|
||||||
boxSizing: 'border-box',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
height: '100%',
|
minHeight: '100%',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}),
|
}),
|
||||||
footerItem: css({
|
footerItem: css({
|
||||||
alignItems: 'center',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: hideLabel ? getJustifyContent(textAlign) : 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
gap: theme.spacing(0.5),
|
||||||
}),
|
}),
|
||||||
footerItemLabel: css({
|
footerItemLabel: css({
|
||||||
// Handle overflow reducer name collision with footer item value
|
flexShrink: 0,
|
||||||
maxWidth: '75%',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
color: theme.colors.text.secondary,
|
color: theme.colors.text.secondary,
|
||||||
fontSize: theme.typography.bodySmall.fontSize,
|
fontSize: theme.typography.bodySmall.fontSize,
|
||||||
fontWeight: theme.typography.fontWeightLight,
|
fontWeight: theme.typography.fontWeightLight,
|
||||||
marginRight: theme.spacing(1),
|
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
|
lineHeight: '22px',
|
||||||
}),
|
}),
|
||||||
footerItemValue: css({
|
footerItemValue: css({
|
||||||
maxWidth: '75%',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
fontWeight: theme.typography.fontWeightMedium,
|
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 { 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 { TableCellDisplayMode } from '@grafana/schema';
|
||||||
|
|
||||||
import { TABLE } from './constants';
|
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 { TableRow } from './types';
|
||||||
import { createTypographyContext } from './utils';
|
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 { useState, useMemo, useCallback, useRef, useLayoutEffect, RefObject, CSSProperties, useEffect } from 'react';
|
||||||
import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-grid';
|
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 { TableColumnResizeActionCallback } from '../types';
|
||||||
|
|
||||||
import { TABLE } from './constants';
|
import { TABLE } from './constants';
|
||||||
import { FilterType, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
|
import { FilterType, FooterFieldState, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
|
||||||
import {
|
import {
|
||||||
getDisplayName,
|
getDisplayName,
|
||||||
processNestedTableRows,
|
processNestedTableRows,
|
||||||
|
@ -529,3 +529,91 @@ export function useColWidths(
|
||||||
|
|
||||||
return [widths, numFrozenColsFullyInView];
|
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({
|
gridNested: css({
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
FieldType,
|
FieldType,
|
||||||
DataFrameWithValue,
|
DataFrameWithValue,
|
||||||
SelectableValue,
|
SelectableValue,
|
||||||
|
FieldState,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { TableCellHeight, TableFieldOptions } from '@grafana/schema';
|
import { TableCellHeight, TableFieldOptions } from '@grafana/schema';
|
||||||
|
|
||||||
|
@ -309,3 +310,7 @@ export interface FromFieldsResult {
|
||||||
cellRootRenderers: Record<string, CellRootRenderer>;
|
cellRootRenderers: Record<string, CellRootRenderer>;
|
||||||
colsWithTooltip: Record<string, boolean>;
|
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 };
|
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