From 7cbc55d615004151eb2cc06e76c58f7539e4c9ca Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 5 Sep 2025 15:46:07 -0400 Subject: [PATCH] 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 --- .../panel-table/table_footer.json | 1472 +++++++++++++++++ devenv/jsonnet/dev-dashboards.libsonnet | 1 + .../visualizations/table/index.md | 9 +- .../panels-suite/table-footer.spec.ts | 34 +- .../src/components/Table/TableNG/TableNG.tsx | 44 +- .../TableNG/components/SummaryCell.test.tsx | 75 +- .../Table/TableNG/components/SummaryCell.tsx | 263 ++- .../components/Table/TableNG/hooks.test.ts | 131 +- .../src/components/Table/TableNG/hooks.ts | 92 +- .../src/components/Table/TableNG/styles.ts | 14 + .../src/components/Table/TableNG/types.ts | 5 + .../src/components/Table/TableNG/utils.ts | 16 + 12 files changed, 1922 insertions(+), 234 deletions(-) create mode 100644 devenv/dev-dashboards/panel-table/table_footer.json diff --git a/devenv/dev-dashboards/panel-table/table_footer.json b/devenv/dev-dashboards/panel-table/table_footer.json new file mode 100644 index 00000000000..fddbeaa7747 --- /dev/null +++ b/devenv/dev-dashboards/panel-table/table_footer.json @@ -0,0 +1,1472 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1841, + "links": [], + "panels": [ + { + "id": 1, + "type": "table", + "title": "Multiple global reducers, uniform", + "gridPos": { + "x": 0, + "y": 0, + "h": 6, + "w": 12 + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "footer": { + "reducers": ["countAll", "lastNotNull", "sum"] + }, + "cellOptions": { + "type": "auto" + }, + "inspect": false, + "filterable": true, + "wrapText": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "color": { + "mode": "thresholds" + }, + "fieldMinMax": true, + "links": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "mode": "lcd", + "type": "gauge" + } + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + }, + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Boolean" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Max" + }, + "properties": [ + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + }, + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "Info", + "mode": "outerTabular" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "A": false + }, + "includeByName": {}, + "indexByName": { + "A": 3, + "Boolean": 6, + "Info": 4, + "Max A": 2, + "Min A": 1, + "State A": 5, + "Time A": 0 + }, + "orderBy": [], + "orderByMode": "manual", + "renameByName": { + "A": "Gauge", + "Info": "", + "Max A": "Max", + "Min A": "Min", + "State A": "State", + "Time": "Some really long title that requires wrapping", + "Time A": "Time", + "img_url": "Cat" + } + } + } + ], + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "random_walk_table" + }, + { + "csvContent": "Info,Boolean\ndown,FALSE\nup,TRUE\nup fast,TRUE\ndown fast,FALSE", + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "datasource": { + "type": "grafana-testdata-datasource" + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "enablePagination": false, + "frameIndex": 0, + "sortBy": [ + { + "desc": false, + "displayName": "Long Text" + } + ] + } + }, + { + "id": 2, + "type": "table", + "title": "Multiple reducers - numeric, non-numeric, field, global", + "gridPos": { + "x": 12, + "y": 0, + "h": 6, + "w": 12 + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "footer": { + "reducers": ["min", "lastNotNull", "median", "firstNotNull"] + }, + "cellOptions": { + "type": "auto" + }, + "inspect": false, + "filterable": true, + "wrapText": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "color": { + "mode": "thresholds" + }, + "fieldMinMax": true, + "links": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "mode": "lcd", + "type": "gauge" + } + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + }, + { + "id": "custom.inspect", + "value": true + }, + { + "id": "custom.footer.reducers", + "value": ["sum"] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Boolean" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Max" + }, + "properties": [ + { + "id": "custom.align", + "value": "center" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.footer.reducers", + "value": ["countAll"] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Min" + }, + "properties": [ + { + "id": "custom.footer.reducers", + "value": ["mean", "diffperc"] + }, + { + "id": "custom.width", + "value": 200 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Max" + }, + "properties": [] + } + ] + }, + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "Info", + "mode": "outerTabular" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "A": false, + "Max A": false, + "Min A": false + }, + "includeByName": {}, + "indexByName": { + "A": 3, + "Boolean": 6, + "Info": 4, + "Max A": 2, + "Min A": 1, + "State A": 5, + "Time A": 0 + }, + "orderBy": [], + "orderByMode": "manual", + "renameByName": { + "A": "Gauge", + "Info": "", + "Max A": "Max", + "Min A": "Min", + "State A": "State", + "Time": "Some really long title that requires wrapping", + "Time A": "Time", + "img_url": "Cat" + } + } + } + ], + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "random_walk_table" + }, + { + "csvContent": "Info,Boolean\ndown,FALSE\nup,TRUE\nup fast,TRUE\ndown fast,FALSE", + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "datasource": { + "type": "grafana-testdata-datasource" + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "enablePagination": false, + "frameIndex": 0, + "sortBy": [ + { + "desc": false, + "displayName": "Long Text" + } + ] + } + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "footer": { + "reducers": ["sum"] + }, + "inspect": false, + "wrapText": false + }, + "fieldMinMax": true, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "mode": "lcd", + "type": "gauge" + } + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + }, + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Boolean" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Max" + }, + "properties": [ + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 3, + "options": { + "cellHeight": "sm", + "enablePagination": false, + "frameIndex": 0, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Long Text" + } + ] + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "random_walk_table" + }, + { + "csvContent": "Info,Boolean\ndown,FALSE\nup,TRUE\nup fast,TRUE\ndown fast,FALSE", + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "Global footer, sum", + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "Info", + "mode": "outerTabular" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "A": false + }, + "includeByName": {}, + "indexByName": { + "A": 3, + "Boolean": 6, + "Info": 4, + "Max A": 2, + "Min A": 1, + "State A": 5, + "Time A": 0 + }, + "orderBy": [], + "orderByMode": "manual", + "renameByName": { + "A": "Gauge", + "Info": "", + "Max A": "Max", + "Min A": "Min", + "State A": "State", + "Time": "Some really long title that requires wrapping", + "Time A": "Time", + "img_url": "Cat" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "footer": { + "reducers": ["lastNotNull"] + }, + "inspect": false, + "wrapText": false + }, + "fieldMinMax": true, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "mode": "lcd", + "type": "gauge" + } + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + }, + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Boolean" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Max" + }, + "properties": [ + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 4, + "options": { + "cellHeight": "sm", + "enablePagination": false, + "frameIndex": 0, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Long Text" + } + ] + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "random_walk_table" + }, + { + "csvContent": "Info,Boolean\ndown,FALSE\nup,TRUE\nup fast,TRUE\ndown fast,FALSE", + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "Global footer, last not null", + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "Info", + "mode": "outerTabular" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "A": false, + "Max A": false, + "Min A": false + }, + "includeByName": {}, + "indexByName": { + "A": 3, + "Boolean": 6, + "Info": 4, + "Max A": 2, + "Min A": 1, + "State A": 5, + "Time A": 0 + }, + "orderBy": [], + "orderByMode": "manual", + "renameByName": { + "A": "Gauge", + "Info": "", + "Max A": "Max", + "Min A": "Min", + "State A": "State", + "Time": "Some really long title that requires wrapping", + "Time A": "Time", + "img_url": "Cat" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "footer": { + "reducers": [] + }, + "inspect": false, + "wrapText": false + }, + "fieldMinMax": true, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "mode": "lcd", + "type": "gauge" + } + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + }, + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Boolean" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Max" + }, + "properties": [ + { + "id": "custom.align", + "value": "center" + } + ] + }, + { + "matcher": { + "id": "byNames", + "options": { + "mode": "include", + "names": ["Max", "A"] + } + }, + "properties": [ + { + "id": "custom.footer.reducers", + "value": ["mean"] + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 12 + }, + "id": 5, + "options": { + "cellHeight": "sm", + "enablePagination": false, + "frameIndex": 0, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Long Text" + } + ] + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "random_walk_table" + }, + { + "csvContent": "Info,Boolean\ndown,FALSE\nup,TRUE\nup fast,TRUE\ndown fast,FALSE", + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "Scoped footer, average", + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "Info", + "mode": "outerTabular" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "A": false + }, + "includeByName": {}, + "indexByName": { + "A": 3, + "Boolean": 6, + "Info": 4, + "Max A": 2, + "Min A": 1, + "State A": 5, + "Time A": 0 + }, + "orderBy": [], + "orderByMode": "manual", + "renameByName": { + "A": "Gauge", + "Info": "", + "Max A": "Max", + "Min A": "Min", + "State A": "State", + "Time": "Some really long title that requires wrapping", + "Time A": "Time", + "img_url": "Cat" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "footer": { + "reducers": [] + }, + "inspect": false, + "wrapText": false + }, + "fieldMinMax": true, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "mode": "lcd", + "type": "gauge" + } + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + }, + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Boolean" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Max" + }, + "properties": [ + { + "id": "custom.align", + "value": "center" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Min" + }, + "properties": [ + { + "id": "custom.footer.reducers", + "value": ["sum"] + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 12 + }, + "id": 6, + "options": { + "cellHeight": "sm", + "enablePagination": false, + "frameIndex": 0, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Long Text" + } + ] + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "random_walk_table" + }, + { + "csvContent": "Info,Boolean\ndown,FALSE\nup,TRUE\nup fast,TRUE\ndown fast,FALSE", + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "Single sum reducer", + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "Info", + "mode": "outerTabular" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "A": false + }, + "includeByName": {}, + "indexByName": { + "A": 3, + "Boolean": 6, + "Info": 4, + "Max A": 2, + "Min A": 1, + "State A": 5, + "Time A": 0 + }, + "orderBy": [], + "orderByMode": "manual", + "renameByName": { + "A": "Gauge", + "Info": "", + "Max A": "Max", + "Min A": "Min", + "State A": "State", + "Time": "Some really long title that requires wrapping", + "Time A": "Time", + "img_url": "Cat" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "footer": { + "reducers": ["countAll"] + }, + "inspect": false, + "wrapText": false + }, + "fieldMinMax": true, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "mode": "lcd", + "type": "gauge" + } + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + }, + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Boolean" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Max" + }, + "properties": [ + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "cellHeight": "sm", + "enablePagination": false, + "frameIndex": 0, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Long Text" + } + ] + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "random_walk_table" + }, + { + "csvContent": "Info,Boolean\ndown,FALSE\nup,TRUE\nup fast,TRUE\ndown fast,FALSE", + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "Count rows", + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "Info", + "mode": "outerTabular" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "A": false + }, + "includeByName": {}, + "indexByName": { + "A": 3, + "Boolean": 6, + "Info": 4, + "Max A": 2, + "Min A": 1, + "State A": 5, + "Time A": 0 + }, + "orderBy": [], + "orderByMode": "manual", + "renameByName": { + "A": "Gauge", + "Info": "", + "Max A": "Max", + "Min A": "Min", + "State A": "State", + "Time": "Some really long title that requires wrapping", + "Time A": "Time", + "img_url": "Cat" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "footer": { + "reducers": ["countAll"] + }, + "inspect": false, + "wrapText": false + }, + "fieldMinMax": true, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "mode": "lcd", + "type": "gauge" + } + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + }, + { + "id": "custom.inspect", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Boolean" + }, + "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Max" + }, + "properties": [ + { + "id": "custom.align", + "value": "center" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.hideFrom.viz", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Min" + }, + "properties": [ + { + "id": "custom.hideFrom.viz", + "value": true + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 18 + }, + "id": 8, + "options": { + "cellHeight": "sm", + "enablePagination": false, + "frameIndex": 0, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Long Text" + } + ] + }, + "pluginVersion": "12.2.0", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "random_walk_table" + }, + { + "csvContent": "Info,Boolean\ndown,FALSE\nup,TRUE\nup fast,TRUE\ndown fast,FALSE", + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "B", + "scenarioId": "csv_content" + } + ], + "title": "Count rows, hide initial columns", + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "Info", + "mode": "outerTabular" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "A": false + }, + "includeByName": {}, + "indexByName": { + "A": 3, + "Boolean": 6, + "Info": 4, + "Max A": 2, + "Min A": 1, + "State A": 5, + "Time A": 0 + }, + "orderBy": [], + "orderByMode": "manual", + "renameByName": { + "A": "Gauge", + "Info": "", + "Max A": "Max", + "Min A": "Min", + "State A": "State", + "Time": "", + "Time A": "Time", + "img_url": "Cat" + } + } + } + ], + "type": "table" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [ + { + "baseFilters": [], + "datasource": { + "type": "grafana-testdata-datasource" + }, + "filters": [], + "name": "Filters", + "type": "adhoc" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Panel Tests - Table - Footer variants", + "uid": "8100236d-603c-421e-a21b-2a0b0ea4eaa3", + "version": 1 +} diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index 9403498e0dc..2338d8c7f8a 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -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'), diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md index 666d0bc5531..e1b36ae706a 100644 --- a/docs/sources/panels-visualizations/visualizations/table/index.md +++ b/docs/sources/panels-visualizations/visualizations/table/index.md @@ -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>}} diff --git a/e2e-playwright/panels-suite/table-footer.spec.ts b/e2e-playwright/panels-suite/table-footer.spec.ts index 1d552392325..4c7064fa1fc 100644 --- a/e2e-playwright/panels-suite/table-footer.spec.ts +++ b/e2e-playwright/panels-suite/table-footer.spec.ts @@ -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, diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index dc3ab2a08d2..5b5fcc5b2a4 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -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 = []; + 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: () => 0} />, + renderSummaryCell: () => ( + + ), }); }); @@ -660,10 +698,12 @@ export function TableNG(props: TableNGProps) { data, disableSanitizeHtml, filter, + footers, frozenColumns, getCellActions, getCellColorInlineStyles, getTextColorForBackground, + isUniformFooter, maxRowHeight, numFrozenColsFullyInView, onCellFilterAdded, diff --git a/packages/grafana-ui/src/components/Table/TableNG/components/SummaryCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/SummaryCell.test.tsx index 9c43989d90c..85627731043 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/components/SummaryCell.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/components/SummaryCell.test.tsx @@ -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(); + render(); expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3 }); - it('should hide the label for the sum reducer if its the only reducer', () => { - render(); + it('should hide the label for the sum reducer if hideLabel is true', () => { + render(); 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(); + render( + + ); 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(); + render(); 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(); + render(); 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(); + render( + + ); 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(); + render(); expect(screen.getByTestId('summary-cell-empty')).toBeInTheDocument(); }); it('should correctly calculate sum for numeric fields based on selected fields', () => { render( <> - - + + ); @@ -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) => ( - + ))} ); @@ -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( + ({ ...r, NoReducer: 1 }))} + field={numericFieldNoReducers} + textAlign="left" + colIdx={0} + rowLabel + /> + ); + expect(screen.getByText('Total')).toBeInTheDocument(); + }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/components/SummaryCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/SummaryCell.tsx index de0323b2631..8f080f9913d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/components/SummaryCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/components/SummaryCell.tsx @@ -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; + 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.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.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>(() => { - 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 = 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
; +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 }) => ( +
{children}
+ ); + const SummaryCellLabel = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+ ); + const SummaryCellValue = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+ ); // Render each reducer in the footer return ( -
- {reducerResultsEntries.map(([reducerId, reducerResultEntry]) => { - const isCountAll = reducerId === ReducerID.countAll; - +
+ {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 ( -
-   -
+ + {rowLabel ? {getReducerName(reducerId)} : <> } + ); } - const { reducerName, formattedValue } = reducerResultEntry; - - const canonicalReducerName = isCountAll ? t('grafana-ui.table.footer.reducer.count', 'Count') : reducerName; - return ( -
- {!isSingleSumReducer && ( -
- {canonicalReducerName} -
- )} -
- {formattedValue} -
-
+ + {!hideLabel && {getReducerName(reducerId)}} + {reducerResult} + ); })} + + {renderRowLabel && + firstFooterReducers!.map((reducerId) => ( + + {getReducerName(reducerId)} + + ))}
); }; -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%', - }), }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts index cbc9a861951..48992a9cdaa 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts @@ -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'], + ]); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts index 1169584a06d..cc648af019a 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -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.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.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 = 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]); +}; diff --git a/packages/grafana-ui/src/components/Table/TableNG/styles.ts b/packages/grafana-ui/src/components/Table/TableNG/styles.ts index 34e53f8742f..2cc95024d80 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/styles.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/styles.ts @@ -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%', diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index edd94a4e002..376fe895ba2 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -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; colsWithTooltip: Record; } + +export interface FooterFieldState extends FieldState { + lastProcessedRowCount: number; +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 4fdfbc60e22..7da729539a3 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -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; +}