TableNG: Footer enhancements (#102948)

* Table: Footer support for multiple reducers

* fix migrator test output due to required default value

* fix go migration test

* more go test fixes

* fix go tests for footer

* Merge #110062

* update migration dashboard

* Small docs fixes

* Small docs fix

* remove migration method in Go, update js unit test

* add migration dashboard for footer and clean up some issues with countAll

* Footer should always use og rows for calcs

* update footer to be unaffected by filtering and sorting

* more e2es

* add more complex footer to kitchen sink, migrate panel all the way up

* update codeowners for e2e

* relocate footer migration panels and e2es to main 12.2 migration dashboard

* go further with the migration; kill unused fields, rename reducer to reducers

* get the go code up to date, move enablePagination to its own option as well

* add a unit to one of the numeric columns with a footer in kitchen sink

* fix reducers override in kitchen sink migration table

---------

Co-authored-by: Paul Marbach <paul.marbach@grafana.com>
Co-authored-by: Isabel Matwawana <isabel.matwawana@grafana.com>
Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Alex Spencer
2025-09-03 18:03:33 -04:00
committed by GitHub
co-authored by Paul Marbach Isabel Matwawana Isabel Matwawana Leon Sorokin
parent ae97eb860c
commit 6c9faa7595
37 changed files with 2304 additions and 711 deletions
+1 -6
View File
@@ -939,13 +939,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"packages/grafana-ui/src/components/Table/TableNG/utils.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"packages/grafana-ui/src/components/Table/TableNG/utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-ui/src/components/Table/TableRT/Filter.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
+1
View File
@@ -462,6 +462,7 @@
/e2e-playwright/panels-suite/panelEdit_base.spec.ts @grafana/dashboards-squad
/e2e-playwright/panels-suite/panelEdit_queries.spec.ts @grafana/dashboards-squad
/e2e-playwright/panels-suite/panelEdit_transforms.spec.ts @grafana/datapro
/e2e-playwright/panels-suite/table-footer.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-markdown.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-sparkline.spec.ts @grafana/dataviz-squad
@@ -511,6 +511,9 @@ func migrateDefaults(prevDefaults map[string]interface{}) map[string]interface{}
"type": "auto",
},
"inspect": false,
"footer": map[string]interface{}{
"reducers": []interface{}{},
},
},
"mappings": []interface{}{},
}
@@ -47,6 +47,9 @@ func TestV24(t *testing.T) {
"cellOptions": map[string]interface{}{
"type": "auto",
},
"footer": map[string]interface{}{
"reducers": []interface{}{},
},
"inspect": false,
},
"mappings": []interface{}{},
@@ -155,6 +158,9 @@ func TestV24(t *testing.T) {
"cellOptions": map[string]interface{}{
"type": "color-background",
},
"footer": map[string]interface{}{
"reducers": []interface{}{},
},
"inspect": false,
},
"mappings": []interface{}{},
@@ -287,6 +293,9 @@ func TestV24(t *testing.T) {
"cellOptions": map[string]interface{}{
"type": "auto",
},
"footer": map[string]interface{}{
"reducers": []interface{}{},
},
"inspect": false,
},
"mappings": []interface{}{},
@@ -364,6 +373,9 @@ func TestV24(t *testing.T) {
"cellOptions": map[string]interface{}{
"type": "auto",
},
"footer": map[string]interface{}{
"reducers": []interface{}{},
},
"inspect": false,
},
"mappings": []interface{}{},
@@ -440,6 +452,9 @@ func TestV24(t *testing.T) {
"cellOptions": map[string]interface{}{
"type": "auto",
},
"footer": map[string]interface{}{
"reducers": []interface{}{},
},
"inspect": false,
},
"mappings": []interface{}{},
@@ -515,6 +530,9 @@ func TestV24(t *testing.T) {
"cellOptions": map[string]interface{}{
"type": "auto",
},
"footer": map[string]interface{}{
"reducers": []interface{}{},
},
"inspect": false,
},
"mappings": []interface{}{},
@@ -601,6 +619,9 @@ func TestV24(t *testing.T) {
"cellOptions": map[string]interface{}{
"type": "auto",
},
"footer": map[string]interface{}{
"reducers": []interface{}{},
},
"inspect": false,
},
"mappings": []interface{}{},
@@ -783,6 +804,9 @@ func TestV24(t *testing.T) {
"cellOptions": map[string]interface{}{
"type": "auto",
},
"footer": map[string]interface{}{
"reducers": []interface{}{},
},
"inspect": false,
},
"mappings": []interface{}{},
@@ -13,6 +13,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -87,4 +90,4 @@
"refresh": "",
"schemaVersion": 41,
"title": "V22 Table Panel Styles Test"
}
}
@@ -14,6 +14,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -91,6 +94,9 @@
"cellOptions": {
"type": "color-background"
},
"footer": {
"reducers": []
},
"inspect": false
},
"decimals": 2,
@@ -241,6 +247,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"decimals": 1,
@@ -318,6 +327,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -386,6 +398,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -454,6 +469,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -521,6 +539,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -600,6 +621,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -669,6 +693,9 @@
"cellOptions": {
"type": "color-background"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -772,6 +799,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -869,6 +899,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -979,6 +1012,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -1095,6 +1131,9 @@
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
@@ -18,7 +18,7 @@
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 21409,
"id": 1827,
"links": [],
"panels": [
{
@@ -33,11 +33,11 @@
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto",
"wrapText": false
"type": "auto"
},
"filterable": true,
"inspect": false
"inspect": false,
"wrapText": false
},
"fieldMinMax": true,
"links": [],
@@ -81,6 +81,10 @@
{
"id": "custom.inspect",
"value": true
},
{
"id": "custom.footer.reducers",
"value": ["sum"]
}
]
},
@@ -154,6 +158,10 @@
{
"id": "unit",
"value": "dateTimeFromNow"
},
{
"id": "custom.footer.reducers",
"value": ["countAll"]
}
]
},
@@ -242,6 +250,10 @@
"fixedColor": "blue",
"mode": "continuous-YlRd"
}
},
{
"id": "unit",
"value": "areaM2"
}
]
},
@@ -277,7 +289,7 @@
"value": "State"
},
{
"id": "custom.hidden",
"id": "custom.hideFrom.viz",
"value": true
}
]
@@ -291,10 +303,13 @@
{
"id": "custom.cellOptions",
"value": {
"type": "auto",
"wrapText": true
"type": "auto"
}
},
{
"id": "custom.wrapText",
"value": true
},
{
"id": "custom.width",
"value": 255
@@ -306,6 +321,10 @@
{
"id": "custom.tooltip.placement",
"value": "left"
},
{
"id": "custom.footer.reducers",
"value": ["last"]
}
]
},
@@ -318,10 +337,13 @@
{
"id": "custom.cellOptions",
"value": {
"type": "pill",
"wrapText": true
"type": "pill"
}
},
{
"id": "custom.wrapText",
"value": true
},
{
"id": "custom.width",
"value": 120
@@ -337,10 +359,13 @@
{
"id": "custom.cellOptions",
"value": {
"type": "data-links",
"wrapText": true
"type": "data-links"
}
},
{
"id": "custom.wrapText",
"value": true
},
{
"id": "links",
"value": [
@@ -421,6 +446,21 @@
]
}
]
},
{
"matcher": {
"id": "byNames",
"options": {
"mode": "include",
"names": ["Min", "Max"]
}
},
"properties": [
{
"id": "custom.footer.reducers",
"value": ["min", "max"]
}
]
}
]
},
@@ -433,13 +473,6 @@
"id": 1,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"enablePagination": false,
"fields": ["Min", "Max"],
"reducer": ["max"],
"show": true
},
"frameIndex": 0,
"frozenColumns": {
"left": 1
@@ -452,7 +485,7 @@
}
]
},
"pluginVersion": "12.1.0-pre",
"pluginVersion": "12.2.0",
"targets": [
{
"datasource": {
@@ -556,6 +589,9 @@
"type": "color-background"
},
"filterable": true,
"footer": {
"reducer": []
},
"inspect": true
},
"fieldMinMax": true,
@@ -587,13 +623,7 @@
"maxDataPoints": 100,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"enablePagination": false,
"fields": "",
"reducer": ["max"],
"show": false
},
"enablePagination": false,
"showHeader": true,
"sortBy": [
{
@@ -602,7 +632,7 @@
}
]
},
"pluginVersion": "12.1.0-pre",
"pluginVersion": "12.2.0",
"targets": [
{
"datasource": {
@@ -812,16 +842,10 @@
"id": 2,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.1.0-pre",
"pluginVersion": "12.2.0",
"targets": [
{
"csvContent": "auto,link,links,bg,bg-link,bg-links,color,color-link,color-links,bg-row,bg-row-link,bg-row-links,color-row,color-row-link,color-row-links,links-cellType\na,a,a,a,a,a,a,a,a,1,1,1,1,1,1,1\nb,b,b,b,b,b,b,b,b,2,2,2,2,2,2,2\nc,c,c,c,c,c,c,c,c,3,3,3,3,3,3,3\nd,d,d,d,d,d,d,d,d,4,4,4,4,4,4,4",
@@ -975,16 +999,10 @@
"id": 10,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.1.0-pre",
"pluginVersion": "12.2.0",
"targets": [
{
"csvContent": "use-case,normal,color-text,color-bg,highlight\n\"color bg and apply to row\",1,0,1,1\n\"color text and apply to row\",1,1,0,1\n\"color text + color bg + apply to row\",1,1,1,1\n\"only apply to row\",1,0,0,1\n\"only color bg\",1,0,1,0\n\"only color text\",1,1,0,0\n\"color text + color bg\",1,1,1,0\n\"no colorization\",1,0,0,0",
@@ -1061,7 +1079,7 @@
},
"properties": [
{
"id": "custom.hidden",
"id": "custom.hideFrom.viz",
"value": true
}
]
@@ -1124,15 +1142,9 @@
"maxDataPoints": 10,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true
},
"pluginVersion": "12.1.0-pre",
"pluginVersion": "12.2.0",
"targets": [
{
"rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Min\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Max\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Info\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"State\",\n \"type\": \"enum\",\n \"typeInfo\": {\n \"frame\": \"enum\"\n },\n \"config\": {\n \"type\": {\n \"enum\": {\n \"text\": [\n \"Unknown\",\n \"Up\",\n \"Down\"\n ]\n }\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1754578636810,\n 1754580436810,\n 1754582236810,\n 1754584036810,\n 1754585836810,\n 1754587636810,\n 1754589436810,\n 1754591236810,\n 1754593036810,\n 1754594836810\n ],\n [\n 68.67815082171406,\n 68.21038738705283,\n 67.85748579518524,\n 67.48088827530566,\n 67.36362085363314,\n 67.75399477824531,\n 67.9006935145112,\n 68.2893033558627,\n 68.20547872017494,\n 67.96251158324088\n ],\n [\n 67.52655915076163,\n 68.03367017865995,\n 65.44308825471086,\n 66.94142900198796,\n 66.33531057489242,\n 66.97007154625013,\n 67.88134129373145,\n 65.92102089546943,\n 65.7896781452792,\n 67.77680599320166\n ],\n [\n 68.78026730954913,\n 68.46495903270079,\n 68.29118217530531,\n 67.90561937677037,\n 68.05316512901861,\n 70.26033810424696,\n 68.05691975265877,\n 68.76874724839202,\n 70.02772232081647,\n 70.38628221361253\n ],\n [\n \"up\",\n \"down fast\",\n \"down\",\n \"down\",\n \"down\",\n \"up\",\n \"up\",\n \"up\",\n \"down\",\n \"down\"\n ],\n [\n 1,\n 2,\n 2,\n 2,\n 2,\n 1,\n 1,\n 1,\n 2,\n 2\n ]\n ]\n }\n }\n]",
@@ -1208,7 +1220,7 @@
},
"properties": [
{
"id": "custom.hidden",
"id": "custom.hideFrom.viz",
"value": true
}
]
@@ -1271,15 +1283,9 @@
"maxDataPoints": 10,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true
},
"pluginVersion": "12.1.0-pre",
"pluginVersion": "12.2.0",
"targets": [
{
"rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Min\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Max\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Info\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"State\",\n \"type\": \"enum\",\n \"typeInfo\": {\n \"frame\": \"enum\"\n },\n \"config\": {\n \"type\": {\n \"enum\": {\n \"text\": [\n \"Unknown\",\n \"Up\",\n \"Down\"\n ]\n }\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1754579008785,\n 1754580808785,\n 1754582608785,\n 1754584408785,\n 1754586208785,\n 1754588008785,\n 1754589808785,\n 1754591608785,\n 1754593408785,\n 1754595208785\n ],\n [\n 89.82213040422468,\n 89.53763263564208,\n 90.03103064454615,\n 90.51535567776907,\n 90.88960652895187,\n 91.30921636258488,\n 91.12427953645643,\n 91.1507153149385,\n 90.81021008780039,\n 91.11327042147582\n ],\n [\n 87.33191559754641,\n 88.46410173314496,\n 88.06920794235972,\n 88.98473401700574,\n 90.60349141857057,\n 90.5858441719162,\n 90.42154517148411,\n 90.32413455176213,\n 90.72055904641739,\n 88.74774424339796\n ],\n [\n 90.58845140604446,\n 90.08173133413374,\n 90.58230197880795,\n 92.55531809442493,\n 93.09165646181742,\n 93.38354884838586,\n 92.68544239576967,\n 92.15770413350477,\n 92.77335012158814,\n 92.52115072193222\n ],\n [\n \"down\",\n \"down\",\n \"up fast\",\n \"up fast\",\n \"up\",\n \"up fast\",\n \"down\",\n \"up\",\n \"down\",\n \"up\"\n ],\n [\n 2,\n 2,\n 1,\n 1,\n 1,\n 1,\n 2,\n 1,\n 2,\n 1\n ]\n ]\n }\n }\n]",
@@ -1355,7 +1361,7 @@
},
"properties": [
{
"id": "custom.hidden",
"id": "custom.hideFrom.viz",
"value": true
}
]
@@ -1418,15 +1424,9 @@
"maxDataPoints": 10,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true
},
"pluginVersion": "12.1.0-pre",
"pluginVersion": "12.2.0",
"targets": [
{
"rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Min\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Max\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Info\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"State\",\n \"type\": \"enum\",\n \"typeInfo\": {\n \"frame\": \"enum\"\n },\n \"config\": {\n \"type\": {\n \"enum\": {\n \"text\": [\n \"Unknown\",\n \"Up\",\n \"Down\"\n ]\n }\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1754579078813,\n 1754580878813,\n 1754582678813,\n 1754584478813,\n 1754586278813,\n 1754588078813,\n 1754589878813,\n 1754591678813,\n 1754593478813,\n 1754595278813\n ],\n [\n 77.30289281564126,\n 87.14357945690202,\n 76.67466266903213,\n 76.27969352662608,\n 95.80292639037513,\n 75.68396049965999,\n 75.76117912054544,\n 76.05073308839697,\n 75.93744737071192,\n 75.82930696129104\n ],\n [\n 76.58900057075734,\n 76.01493865596112,\n 75.25501274661029,\n 75.52871130918913,\n 73.53324017648568,\n 74.74670600818675,\n 73.4627470190636,\n 75.38455781635166,\n 74.04318166296315,\n 74.79620005730261\n ],\n [\n 77.58417866514938,\n 77.9390885538468,\n 78.37258492198521,\n 76.82242506518101,\n 75.81423643776408,\n 75.86349731305505,\n 76.79031910137064,\n 77.71934637974913,\n 76.11981207380533,\n 77.29746416618009\n ],\n [\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"up\",\n \"up\",\n \"down\",\n \"down\"\n ],\n [\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 1,\n 1,\n 2,\n 2\n ]\n ]\n }\n }\n]",
@@ -1489,7 +1489,7 @@
},
"properties": [
{
"id": "custom.hidden",
"id": "custom.hideFrom.viz",
"value": true
}
]
@@ -1559,12 +1559,6 @@
"maxDataPoints": 10,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true,
"sortBy": [
{
@@ -1573,7 +1567,7 @@
}
]
},
"pluginVersion": "12.1.0-pre",
"pluginVersion": "12.2.0",
"targets": [
{
"rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Min\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Max\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Info\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"State\",\n \"type\": \"enum\",\n \"typeInfo\": {\n \"frame\": \"enum\"\n },\n \"config\": {\n \"type\": {\n \"enum\": {\n \"text\": [\n \"Unknown\",\n \"Up\",\n \"Down\"\n ]\n }\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1754579078813,\n 1754580878813,\n 1754582678813,\n 1754584478813,\n 1754586278813,\n 1754588078813,\n 1754589878813,\n 1754591678813,\n 1754593478813,\n 1754595278813\n ],\n [\n 77.30289281564126,\n 87.14357945690202,\n 76.67466266903213,\n 76.27969352662608,\n 95.80292639037513,\n 75.68396049965999,\n 75.76117912054544,\n 76.05073308839697,\n 75.93744737071192,\n 75.82930696129104\n ],\n [\n 76.58900057075734,\n 76.01493865596112,\n 75.25501274661029,\n 75.52871130918913,\n 73.53324017648568,\n 74.74670600818675,\n 73.4627470190636,\n 75.38455781635166,\n 74.04318166296315,\n 74.79620005730261\n ],\n [\n 77.58417866514938,\n 77.9390885538468,\n 78.37258492198521,\n 76.82242506518101,\n 75.81423643776408,\n 75.86349731305505,\n 76.79031910137064,\n 77.71934637974913,\n 76.11981207380533,\n 77.29746416618009\n ],\n [\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"up\",\n \"up\",\n \"down\",\n \"down\"\n ],\n [\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 1,\n 1,\n 2,\n 2\n ]\n ]\n }\n }\n]",
@@ -1642,7 +1636,7 @@
},
"properties": [
{
"id": "custom.hidden",
"id": "custom.hideFrom.viz",
"value": true
}
]
@@ -1746,16 +1740,10 @@
"maxDataPoints": 10,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.1.0-pre",
"pluginVersion": "12.2.0",
"targets": [
{
"rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Min\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Max\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Info\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"State\",\n \"type\": \"enum\",\n \"typeInfo\": {\n \"frame\": \"enum\"\n },\n \"config\": {\n \"type\": {\n \"enum\": {\n \"text\": [\n \"Unknown\",\n \"Up\",\n \"Down\"\n ]\n }\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1754579078813,\n 1754580878813,\n 1754582678813,\n 1754584478813,\n 1754586278813,\n 1754588078813,\n 1754589878813,\n 1754591678813,\n 1754593478813,\n 1754595278813\n ],\n [\n 77.30289281564126,\n 87.14357945690202,\n 76.67466266903213,\n 76.27969352662608,\n 95.80292639037513,\n 75.68396049965999,\n 75.76117912054544,\n 76.05073308839697,\n 75.93744737071192,\n 75.82930696129104\n ],\n [\n 76.58900057075734,\n 76.01493865596112,\n 75.25501274661029,\n 75.52871130918913,\n 73.53324017648568,\n 74.74670600818675,\n 73.4627470190636,\n 75.38455781635166,\n 74.04318166296315,\n 74.79620005730261\n ],\n [\n 77.58417866514938,\n 77.9390885538468,\n 78.37258492198521,\n 76.82242506518101,\n 75.81423643776408,\n 75.86349731305505,\n 76.79031910137064,\n 77.71934637974913,\n 76.11981207380533,\n 77.29746416618009\n ],\n [\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"up\",\n \"up\",\n \"down\",\n \"down\"\n ],\n [\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 1,\n 1,\n 2,\n 2\n ]\n ]\n }\n }\n]",
@@ -1808,15 +1796,9 @@
"id": 3,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true
},
"pluginVersion": "12.1.0-pre",
"pluginVersion": "12.2.0",
"targets": [
{
"csvContent": "a,b,c",
@@ -1852,5 +1834,5 @@
"timezone": "",
"title": "Panel Tests - Table - Kitchen Sink",
"uid": "dcb9f5e9-8066-4397-889e-864b99555dbb",
"version": 33
"version": 34
}
File diff suppressed because it is too large Load Diff
@@ -16,13 +16,14 @@ weight: 1100
# Calculation types
The following table contains a list of calculations you can perform in Grafana. You can find these calculations in the **Transform** tab and in the bar gauge, gauge, and stat visualizations.
The following table contains a list of calculations you can perform in Grafana. You can find these calculations in the **Transform** tab and in the bar gauge, gauge, stat, and table visualizations.
| Calculation | Description |
| :----------------- | :-------------------------------------------------------- |
| 1st % - 99th % | 1st - 99th percentile value. |
| All nulls | True when all values are null |
| All values | Array with all values |
| All unique values | Array with all unique values |
| All values | Array with all values |
| All zeros | True when all values are 0 |
| Change count | Number of times the field's value changes |
| Count | Number of values in a field |
@@ -35,11 +36,12 @@ The following table contains a list of calculations you can perform in Grafana.
| Last | Last value in a field |
| Last\* (not null) | Last, not null value in a field (also excludes NaNs) |
| Max | Maximum value of a field |
| Median | Median value of all values in a field |
| Mean | Mean value of all values in a field |
| Variance | Variance of all values in a field |
| StdDev | Standard deviation of all values in a field |
| Min | Minimum value of a field |
| Min (above zero) | Minimum, positive value of a field |
| Range | Difference between maximum and minimum values of a field |
| StdDev | Standard deviation of all values in a field |
| Step | Minimal interval between values of a field |
| Total | Sum of all values in a field |
| Variance | Variance of all values in a field |
@@ -216,14 +216,35 @@ This option is only available when you're editing the panel.
### Table footer options
Toggle the **Show table footer** switch on and off to control the display of the footer.
When the toggle is switched on, you can use the table footer to show [calculations](ref:calculations) 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:
After you activate the table footer, make selections for the following options:
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-table-footer-selector-v12.2.png" max-width="300px" alt="" >}}
- **Calculation** - The calculation that you want to apply.
- **Count rows** - This option is displayed if you select the **Count** calculation. If you want to show the number of rows in the dataset instead of the number of values in the selected fields, toggle on the **Count rows** switch.
- **Fields** - The fields to which you want to apply the calculation. Grafana applies the calculation to all numeric fields if you don't select a field.
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).
In the table footer:
- You can apply multiple calculations at once.
- All calculations and reducer functions are labeled except **Total** when it's the only function applied.
- The calculations and reducer functions apply to all fields in the table, by default. To control which fields have a calculation or function applied, add the table footer in an override instead.
- If you enable a mathematical function for a non-numeric field, nothing for that function is displayed for that field.
In the following image, multiple calculations&mdash;**Mean**, **Max**, and **Last**&mdash;have been applied:
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-table-footer-1-v12.2.png" max-width="750px" alt="" >}}
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>}}
### Cell options
@@ -0,0 +1,176 @@
import { Page, Locator } from '@playwright/test';
import { test, expect } from '@grafana/plugin-e2e';
import { getColumnIdx } from './table-utils';
const DASHBOARD_UID = '1ea31838-e4e8-4aa0-9333-1d4c3fa95641';
const waitForTableLoad = async (loc: Page | Locator) => {
await expect(loc.locator('.rdg')).toBeVisible();
};
test.describe('Panels test: Table - Footer', { tag: ['@panels', '@table'] }, () => {
test('Footer unaffected by filtering', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '4' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Global footer, last not null'))
).toBeVisible();
await waitForTableLoad(page);
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)
.innerText();
const minColumnHeader = page.getByRole('columnheader').nth(minColumnIdx);
// get the first value in the "State" column, filter it out, then check that it went away.
await minColumnHeader.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.HeaderButton).click();
const filterContainer = dashboardPage.getByGrafanaSelector(
selectors.components.Panels.Visualization.TableNG.Filters.Container
);
await expect(filterContainer).toBeVisible();
// select all, then click the first value to unselect it, filtering it out.
await filterContainer.getByTestId(selectors.components.Panels.Visualization.TableNG.Filters.SelectAll).click();
await filterContainer.getByTitle('Filter values').fill(minReducerValue);
await filterContainer.getByTitle(minReducerValue, { exact: true }).locator('label').click();
await filterContainer.getByRole('button', { name: 'Ok' }).click();
await expect(
dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
.nth(minColumnIdx)
).toHaveText(minReducerValue);
});
test('Footer unaffected by sorting', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '4' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Global footer, last not null'))
).toBeVisible();
await waitForTableLoad(page);
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)
.innerText();
const minColumnHeader = page.getByRole('columnheader').nth(minColumnIdx);
// get the first value in the "State" column, filter it out, then check that it went away.
await minColumnHeader.getByText('Min').click();
await expect(minColumnHeader).toHaveAttribute('aria-sort', 'ascending');
await expect(
dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
.nth(minColumnIdx)
).toHaveText(minReducerValue);
await minColumnHeader.getByText('Min').click();
await expect(minColumnHeader).toHaveAttribute('aria-sort', 'descending');
await expect(
dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
.nth(minColumnIdx)
).toHaveText(minReducerValue);
await minColumnHeader.getByText('Min').click();
await expect(minColumnHeader).not.toHaveAttribute('aria-sort');
await expect(
dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
.nth(minColumnIdx)
).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,
queryParams: new URLSearchParams({ editPanel: '7' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Count rows'))
).toBeVisible();
await waitForTableLoad(page);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.ReducerLabel)
).toContainText('Count');
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
).toBeVisible();
});
test('Count rows with a few hidden columns', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '8' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Count rows, hide initial columns'))
).toBeVisible();
await waitForTableLoad(page);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.ReducerLabel)
).toContainText('Count');
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Footer.Value)
).toBeVisible();
});
});
@@ -2,7 +2,7 @@ import { Page, Locator } from '@playwright/test';
import { test, expect, E2ESelectorGroups } from '@grafana/plugin-e2e';
import { getCell, getCellHeight } from './table-utils';
import { getCell, getCellHeight, getColumnIdx } from './table-utils';
const DASHBOARD_UID = 'dcb9f5e9-8066-4397-889e-864b99555dbb';
@@ -13,24 +13,6 @@ const waitForTableLoad = async (loc: Page | Locator) => {
await expect(loc.locator('.rdg')).toBeVisible();
};
const getColumnIdx = async (loc: Page | Locator, columnName: string) => {
// find the index of the column "Long text." The kitchen sink table will change over time, but
// we can just find the column programatically and use it throughout the test.
let result = -1;
const colCount = await loc.getByRole('columnheader').count();
for (let colIdx = 0; colIdx < colCount; colIdx++) {
const cell = await getCell(loc, 0, colIdx);
if ((await cell.textContent()) === columnName) {
result = colIdx;
break;
}
}
if (result === -1) {
throw new Error(`Could not find the "${columnName}" column in the table`);
}
return result;
};
const disableAllTextWrap = async (loc: Page | Locator, selectors: E2ESelectorGroups) => {
// disable text wrapping for all of the columns, since long text with links in them can push the links off the screen.
const wrapTextToggle = loc.getByLabel('Wrap text');
@@ -11,3 +11,21 @@ export const getCellHeight = async (loc: Page | Locator, rowIdx: number, colIdx:
const cell = await getCell(loc, rowIdx, colIdx);
return (await cell.boundingBox())?.height ?? 0;
};
export const getColumnIdx = async (loc: Page | Locator, columnName: string) => {
// find the index of the column "Long text." The kitchen sink table will change over time, but
// we can just find the column programatically and use it throughout the test.
let result = -1;
const colCount = await loc.getByRole('columnheader').count();
for (let colIdx = 0; colIdx < colCount; colIdx++) {
const cell = await getCell(loc, 0, colIdx);
if ((await cell.textContent()) === columnName) {
result = colIdx;
break;
}
}
if (result === -1) {
throw new Error(`Could not find the "${columnName}" column in the table`);
}
return result;
};
@@ -17,6 +17,7 @@ export enum ReducerID {
median = 'median',
first = 'first',
count = 'count',
countAll = 'countAll',
range = 'range',
diff = 'diff',
diffperc = 'diffperc',
@@ -132,7 +133,10 @@ export enum ReducerID {
}
export function getFieldTypeForReducer(id: ReducerID, fallback: FieldType): FieldType {
return id === ReducerID.count || id === ReducerID.distinctCount || id === ReducerID.changeCount
return id === ReducerID.count ||
id === ReducerID.distinctCount ||
id === ReducerID.changeCount ||
id === ReducerID.countAll
? FieldType.number
: id === ReducerID.allIsNull || id === ReducerID.allIsZero
? FieldType.boolean
@@ -329,6 +333,15 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [
standard: true,
preservesUnits: false,
},
{
id: ReducerID.countAll,
name: 'Count all',
description: 'Number of values (including empty)',
emptyInputResult: 0,
standard: false,
reduce: (field: Field): FieldCalcs => ({ countAll: field.values.length }),
preservesUnits: false,
},
{
id: ReducerID.range,
name: 'Range',
@@ -501,6 +501,14 @@ export const versionedComponents = {
'12.2.0': 'data-testid tableng tooltip caret',
},
},
Footer: {
ReducerLabel: {
'12.2.0': 'data-testid tableng footer reducer-label',
},
Value: {
'12.2.0': 'data-testid tableng footer value',
},
},
},
},
},
@@ -759,22 +759,6 @@ export interface TableSortByFieldState {
displayName: string;
}
/**
* Footer options
*/
export interface TableFooterOptions {
countRows?: boolean;
enablePagination?: boolean;
fields?: Array<string>;
reducer: Array<string>; // actually 1 value
show: boolean;
}
export const defaultTableFooterOptions: Partial<TableFooterOptions> = {
fields: [],
reducer: [],
};
/**
* Auto mode table cell options
*/
@@ -992,6 +976,17 @@ export enum TableCellTooltipPlacement {
Top = 'top',
}
export interface TableFooterOptions {
/**
* footer reducers to apply to this field
*/
reducers?: Array<string>;
}
export const defaultTableFooterOptions: Partial<TableFooterOptions> = {
reducers: [],
};
/**
* Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
* Generally defines alignment, filtering capabilties, display options, etc.
@@ -1004,6 +999,10 @@ export interface TableFieldOptions extends HideableFieldConfig {
*/
displayMode?: TableCellDisplayMode;
filterable?: boolean;
/**
* options for the footer for this field
*/
footer?: TableFooterOptions;
/**
* Hides any header for a column, useful for columns that show some static content or buttons.
*/
+7 -11
View File
@@ -19,15 +19,6 @@ TableSortByFieldState: {
desc?: bool
} @cuetsy(kind="interface")
// Footer options
TableFooterOptions: {
show: bool
reducer: [...string] // actually 1 value
fields?: [...string]
enablePagination?: bool
countRows?: bool
} @cuetsy(kind="interface")
// Auto mode table cell options
TableAutoCellOptions: {
type: TableCellDisplayMode & "auto"
@@ -101,8 +92,6 @@ TableGeoCellOptions: {
// Height of a table cell
TableCellHeight: "sm" | "md" | "lg" | "auto" @cuetsy(kind="enum")
// Table cell options. Each cell has a display mode
// and other potential options for that display.
TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions | TableMarkdownCellOptions | TableGeoCellOptions @cuetsy(kind="type")
@@ -116,6 +105,11 @@ TableCellTooltipOptions: {
placement?: TableCellTooltipPlacement
}
TableFooterOptions: {
// footer reducers to apply to this field
reducers?: [...string]
} @cuetsy(kind="interface")
// Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
// Generally defines alignment, filtering capabilties, display options, etc.
TableFieldOptions: {
@@ -135,4 +129,6 @@ TableFieldOptions: {
wrapHeaderText?: bool
// Selecting or hovering this field will show a tooltip containing the content within the target field
tooltip?: TableCellTooltipOptions
// options for the footer for this field
footer?: TableFooterOptions
} & HideableFieldConfig @cuetsy(kind="interface")
@@ -18,9 +18,9 @@ export interface Options {
*/
cellHeight?: ui.TableCellHeight;
/**
* Controls footer options
* Enable pagination on the table
*/
footer?: ui.TableFooterOptions;
enablePagination?: boolean;
/**
* Represents the index of the selected frame
*/
@@ -51,20 +51,6 @@ export interface Options {
export const defaultOptions: Partial<Options> = {
cellHeight: ui.TableCellHeight.Sm,
footer: {
/**
* Controls whether the footer should be shown
*/
show: false,
/**
* Controls whether the footer should show the total number of rows on Count calculation
*/
countRows: false,
/**
* Represents the selected calculations
*/
reducer: [],
},
frameIndex: 0,
showHeader: true,
showTypeIcons: false,
@@ -102,6 +102,7 @@ export const FilterPopup = ({
[setFilter, onClose] // eslint-disable-line react-hooks/exhaustive-deps
);
const filterInputPlaceholder = t('grafana-ui.table.filter-popup-input-placeholder', 'Filter values');
const clearFilterVisible = useMemo(() => filterValue !== undefined, [filterValue]);
const styles = useStyles2(getStyles);
@@ -130,7 +131,8 @@ export const FilterPopup = ({
<Stack gap={1}>
<FilterInput
placeholder={t('grafana-ui.table.filter-popup-input-placeholder', 'Filter values')}
placeholder={filterInputPlaceholder}
title={filterInputPlaceholder}
onChange={setSearchFilter}
value={searchFilter}
/>
@@ -462,18 +462,22 @@ describe('TableNG', () => {
});
it('renders footer with aggregations when footerOptions are provided', () => {
const baseFrame = createBasicDataFrame();
// Create a filter function that only shows rows with A1
const frameWithReducers = {
...baseFrame,
fields: baseFrame.fields.map((field) => ({
...field,
config: {
...field.config,
custom: {
footer: { reducers: ['sum'] },
},
},
})),
};
const { container } = render(
<TableNG
enableVirtualization={false}
data={createBasicDataFrame()}
width={800}
height={600}
footerOptions={{
show: true,
reducer: ['sum'],
countRows: false,
}}
/>
<TableNG enableVirtualization={false} data={frameWithReducers} width={800} height={600} />
);
// Check for footer row
@@ -483,36 +487,6 @@ describe('TableNG', () => {
// Sum of Column B values (1+2+3=6)
expect(screen.getByText('6')).toBeInTheDocument();
});
it('renders row count in footer when countRows is true', () => {
const { container } = render(
<TableNG
enableVirtualization={false}
data={createBasicDataFrame()}
width={800}
height={600}
footerOptions={{
show: true,
reducer: ['count'],
countRows: true, // Enable row counting
}}
/>
);
// Check for footer row
const footerRow = container.querySelector('.rdg-summary-row');
expect(footerRow).toBeInTheDocument();
// Get the text content of the footer cells
const footerCells = footerRow?.querySelectorAll('[role="gridcell"]');
const footerTexts = Array.from(footerCells || []).map((cell) => cell.textContent);
// The first cell should contain the row count (3 rows)
expect(footerTexts[0]).toBe('Count3');
// There should be no other footer cells
expect(footerTexts[1]).toBe('');
});
});
describe('Pagination', () => {
@@ -1147,28 +1121,36 @@ describe('TableNG', () => {
it('updates footer calculations when rows are filtered', () => {
// Create a filtered frame with only the first row
const baseFrame = createBasicDataFrame();
const baseFrameWithReducers = {
...baseFrame,
fields: baseFrame.fields.map((field) => ({
...field,
state: {
calcs: {
sum: {
value: 6,
formattedValue: '6',
reducerName: 'sum',
},
},
},
config: { ...field.config, custom: { footer: { reducers: ['sum'] } } },
})),
};
const filteredFrame = {
...baseFrame,
length: 1,
fields: baseFrame.fields.map((field) => ({
...field,
values: field.name === 'Column A' ? ['A1'] : field.name === 'Column B' ? [1] : field.values.slice(0, 1),
config: { ...field.config, custom: { footer: { reducers: ['sum'] } } },
})),
};
// Render with unfiltered data and footer options
const { container, rerender } = render(
<TableNG
enableVirtualization={false}
data={baseFrame}
width={800}
height={600}
footerOptions={{
show: true,
reducer: ['sum'],
countRows: false,
}}
/>
<TableNG enableVirtualization={false} data={baseFrameWithReducers} width={800} height={600} />
);
// Check initial footer sum (1+2+3=6)
@@ -1183,20 +1165,7 @@ describe('TableNG', () => {
expect(initialFooterTexts[1]).toBe('6');
// Rerender with filtered data
rerender(
<TableNG
enableVirtualization={false}
data={filteredFrame}
width={800}
height={600}
footerOptions={{
show: true,
reducer: ['sum'],
countRows: false,
}}
/>
);
rerender(<TableNG enableVirtualization={false} data={filteredFrame} width={800} height={600} />);
// Check filtered footer sum (should be 1)
const filteredFooter = container.querySelector('.rdg-summary-row');
expect(filteredFooter).toBeInTheDocument();
@@ -23,7 +23,6 @@ import {
Field,
FieldType,
getDisplayProcessor,
ReducerID,
} from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { FieldColorModeId, TableCellTooltipPlacement } from '@grafana/schema';
@@ -40,6 +39,7 @@ import { DataLinksActionsTooltipState } from '../utils';
import { getCellRenderer, getCellSpecificStyles } from './Cells/renderers';
import { HeaderCell } from './components/HeaderCell';
import { RowExpander } from './components/RowExpander';
import { SummaryCell } from './components/SummaryCell';
import { TableCellActions } from './components/TableCellActions';
import { TableCellTooltip } from './components/TableCellTooltip';
import { COLUMN, TABLE } from './constants';
@@ -47,7 +47,6 @@ import {
useColumnResize,
useColWidths,
useFilteredRows,
useFooterCalcs,
useHeaderHeight,
usePaginatedRows,
useRowHeight,
@@ -57,7 +56,6 @@ import {
import {
getCellActionStyles,
getDefaultCellStyles,
getFooterStyles,
getGridStyles,
getHeaderCellStyles,
getLinkStyles,
@@ -77,6 +75,7 @@ import {
import {
applySort,
canFieldBeColorized,
calculateFooterHeight,
createTypographyContext,
displayJsonValue,
extractPixelValue,
@@ -108,7 +107,7 @@ export function TableNG(props: TableNGProps) {
enablePagination = false,
enableSharedCrosshair = false,
enableVirtualization,
footerOptions,
fieldConfig,
frozenColumns = 0,
getActions = () => [],
height,
@@ -125,6 +124,12 @@ export function TableNG(props: TableNGProps) {
width,
} = props;
const hasFooter = useMemo(
() => data.fields.some((field) => field.config?.custom?.footer?.reducers?.length ?? false),
[data.fields]
);
const footerHeight = hasFooter ? calculateFooterHeight(data, fieldConfig) : 0;
const theme = useTheme2();
const styles = useStyles2(getGridStyles, enablePagination, transparent);
const panelContext = usePanelContext();
@@ -135,13 +140,6 @@ export function TableNG(props: TableNGProps) {
);
const hasHeader = !noHeader;
const hasFooter = Boolean(footerOptions?.show && footerOptions.reducer?.length);
const isCountRowsSet = Boolean(
footerOptions?.countRows &&
footerOptions.reducer &&
footerOptions.reducer.length &&
footerOptions.reducer[0] === ReducerID.count
);
const resizeHandler = useColumnResize(onColumnResize);
@@ -228,18 +226,11 @@ export function TableNG(props: TableNGProps) {
enabled: enablePagination,
width: availableWidth,
height,
headerHeight,
footerHeight: hasFooter ? (typeof defaultRowHeight === 'number' ? defaultRowHeight : TABLE.MAX_CELL_HEIGHT) : 0,
footerHeight,
headerHeight: hasHeader ? TABLE.HEADER_ROW_HEIGHT : 0,
rowHeight,
});
// Create a map of column key to text wrap
const footerCalcs = useFooterCalcs(sortedRows, visibleFields, {
enabled: hasFooter,
footerOptions,
isCountRowsSet,
});
// 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') {
@@ -279,8 +270,22 @@ export function TableNG(props: TableNGProps) {
sortColumns,
rowHeight,
bottomSummaryRows: hasFooter ? [{}] : undefined,
summaryRowHeight: footerHeight,
headerRowClass: styles.headerRow,
headerRowHeight: noHeader ? 0 : TABLE.HEADER_ROW_HEIGHT,
}) satisfies Partial<DataGridProps<TableRow, TableSummaryRow>>,
[enableVirtualization, resizeHandler, sortColumns, rowHeight, hasFooter, setSortColumns, onSortByChange]
[
enableVirtualization,
hasFooter,
resizeHandler,
sortColumns,
rowHeight,
styles.headerRow,
noHeader,
setSortColumns,
onSortByChange,
footerHeight,
]
);
const buildNestedTableExpanderColumn = useCallback(
@@ -404,7 +409,6 @@ export function TableNG(props: TableNGProps) {
// contents with flexbox. We always just get both and provide both when styling the cell.
const textAlign = getAlignment(field);
const justifyContent = getJustifyContent(textAlign);
const footerStyles = getFooterStyles(justifyContent);
const displayName = getDisplayName(field);
const headerCellClass = getHeaderCellStyles(theme, justifyContent);
const CellType = getCellRenderer(field, cellOptions);
@@ -637,19 +641,7 @@ export function TableNG(props: TableNGProps) {
showTypeIcons={showTypeIcons}
/>
),
renderSummaryCell: () => {
if (isCountRowsSet && i === 0) {
return (
<div className={footerStyles.footerCellCountRows}>
<span>
<Trans i18nKey="grafana-ui.table.count">Count</Trans>
</span>
<span>{footerCalcs[i]}</span>
</div>
);
}
return <div className={footerStyles.footerCell}>{footerCalcs[i]}</div>;
},
renderSummaryCell: () => <SummaryCell rows={rows} field={field} omitCountAll={i > 0} />,
});
});
@@ -662,12 +654,10 @@ export function TableNG(props: TableNGProps) {
data,
disableSanitizeHtml,
filter,
footerCalcs,
frozenColumns,
getCellActions,
getCellColorInlineStyles,
getTextColorForBackground,
isCountRowsSet,
maxRowHeight,
numFrozenColsFullyInView,
onCellFilterAdded,
@@ -0,0 +1,177 @@
import { screen, render } from '@testing-library/react';
import { Field, FieldType } from '@grafana/data';
import { SummaryCell } from './SummaryCell';
describe('SummaryCell', () => {
const rows = [
{ Field1: 1, Field2: 3, Text: 'a', __depth: 0, __index: 0 },
{ Field1: 2, Field2: 10, Text: 'b', __depth: 0, __index: 1 },
{ Field1: 3, Text: 'efghi', __depth: 0, __index: 2 },
];
const numericField: Field = {
name: 'Field1',
type: FieldType.number,
values: [1, 2, 3],
config: {
custom: {
footer: {
reducers: ['sum'],
},
},
},
display: (value: unknown) => ({
text: String(value),
numeric: Number(value),
color: undefined,
prefix: undefined,
suffix: undefined,
}),
state: {},
getLinks: undefined,
};
const numericField2: Field = {
name: 'Field2',
type: FieldType.number,
values: [3, 10],
config: {
custom: {
footer: {
reducers: ['sum'],
},
},
},
display: (value: unknown) => ({
text: String(value),
numeric: Number(value),
color: undefined,
prefix: undefined,
suffix: undefined,
}),
state: {},
getLinks: undefined,
};
const textField: Field = {
name: 'Text',
type: FieldType.string,
values: ['a', 'b', 'c'],
config: {
custom: {
reducers: ['sum'],
},
},
display: (value: unknown) => ({
text: String(value),
numeric: 0,
color: undefined,
prefix: undefined,
suffix: undefined,
}),
state: {},
getLinks: undefined,
};
it('should calculate sum for numeric fields', () => {
render(<SummaryCell rows={rows} field={numericField} />);
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
});
it('should hide the label for the sum reducer if its the only reducer', () => {
render(<SummaryCell rows={rows} field={numericField} />);
expect(screen.queryByText('Total')).not.toBeInTheDocument();
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
});
it('should show the label for the sum reducer if its not the only reducer', () => {
const numericFieldWithMultipleReducers = {
...numericField,
config: {
...numericField.config,
custom: { ...numericField.config.custom, footer: { reducers: ['sum', 'mean'] } },
},
};
render(<SummaryCell rows={rows} field={numericFieldWithMultipleReducers} />);
expect(screen.getByText('Total')).toBeInTheDocument();
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
expect(screen.getByText('Mean')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument(); // (1 + 2 + 3) / 3
});
it('should calculate mean for numeric fields', () => {
const newNumericField = {
...numericField,
config: {
...numericField.config,
custom: { ...numericField.config.custom, footer: { reducers: ['mean'] } },
},
};
render(<SummaryCell rows={rows} field={newNumericField} />);
expect(screen.getByText('Mean')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument(); // (1 + 2 + 3) / 3
});
it('should render an empty summary cell for non-numeric fields with numeric reducers', () => {
render(<SummaryCell rows={rows} field={textField} />);
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'] } } },
};
render(<SummaryCell rows={rows} field={textFieldNonNumericReducer} />);
expect(screen.getByText('Last')).toBeInTheDocument();
expect(screen.getByText('efghi')).toBeInTheDocument();
});
it('should render an empty summary cell if no reducers are set', () => {
const numericFieldNoReducers = {
...numericField,
config: { ...numericField.config, custom: { ...numericField.config.custom, footer: { reducers: [] } } },
};
render(<SummaryCell rows={rows} field={numericFieldNoReducers} />);
expect(screen.getByTestId('summary-cell-empty')).toBeInTheDocument();
});
it('should correctly calculate sum for numeric fields based on selected fields', () => {
render(
<>
<SummaryCell rows={rows} field={numericField} />
<SummaryCell rows={rows} field={numericField2} />
</>
);
expect(screen.getByText('6')).toBeInTheDocument(); // 1 + 2 + 3
expect(screen.getByText('13')).toBeInTheDocument(); // 3 + 10
});
it('should render summary cells with a mix of numeric-and non-numeric reducers and fields', () => {
const fields = [numericField, numericField2, textField].map((field) => ({
...field,
config: {
...field.config,
custom: {
...field.config.custom,
footer: { reducers: ['sum', 'mean', 'last'] },
},
},
}));
render(
<>
{fields.map((field, index) => (
<SummaryCell key={index} rows={rows} field={field} />
))}
</>
);
expect(screen.getByText('13')).toBeInTheDocument(); // sum
expect(screen.getByText('6.5')).toBeInTheDocument(); // mean
expect(screen.getByText('efghi')).toBeInTheDocument(); // last
});
// TODO: add test for noFormattingReducers
});
@@ -0,0 +1,219 @@
import { css, cx } from '@emotion/css';
import { useMemo } from 'react';
import {
GrafanaTheme2,
Field,
FieldState,
FieldType,
reduceField,
fieldReducers,
formattedValueToString,
ReducerID,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { TableRow } from '../types';
import { getDisplayName } from '../utils';
interface SummaryCellProps {
rows: TableRow[];
field: Field;
omitCountAll?: boolean;
}
export interface ReducerResult {
value: number | null;
formattedValue: string;
reducerName: string;
}
interface FooterFieldState extends FieldState {
lastProcessedRowCount: number;
}
function isReducer(maybeReducer: string): maybeReducer is ReducerID {
return maybeReducer in ReducerID;
}
const nonMathReducers = new Set<ReducerID>([
ReducerID.allValues,
ReducerID.changeCount,
ReducerID.count,
ReducerID.countAll,
ReducerID.distinctCount,
ReducerID.first,
ReducerID.firstNotNull,
ReducerID.last,
ReducerID.lastNotNull,
ReducerID.uniqueValues,
]);
const isNonMathReducer = (reducer: string) => isReducer(reducer) && nonMathReducers.has(reducer);
const noFormattingReducers = new Set<ReducerID>([ReducerID.count, ReducerID.countAll]);
const shouldReducerSkipFormatting = (reducer: string) => isReducer(reducer) && noFormattingReducers.has(reducer);
export const SummaryCell = ({ rows, field, omitCountAll = false }: SummaryCellProps) => {
const styles = useStyles2(getStyles);
const displayName = getDisplayName(field);
const reducerResultsEntries = useMemo<Array<[string, ReducerResult | null]>>(() => {
const reducers: string[] = field.config.custom?.footer?.reducers ?? [];
if (
reducers.length === 0 ||
(field.type !== FieldType.number && reducers.every((reducerId) => !isNonMathReducer(reducerId)))
) {
return [];
}
// Create a new state object that matches the original behavior exactly
const newState: FooterFieldState = {
lastProcessedRowCount: 0,
...(field.state || {}), // Preserve any existing state properties
};
// Assign back to field
field.state = newState;
const currentRowCount = rows.length;
const lastRowCount = newState.lastProcessedRowCount;
// Check if we need to invalidate the cache
if (lastRowCount !== currentRowCount) {
// Cache should be invalidated as row count has changed
if (newState.calcs) {
delete newState.calcs;
}
// Update the row count tracker
newState.lastProcessedRowCount = currentRowCount;
}
// Calculate all specified reducers
const results: Record<string, number | null> = reduceField({
field: {
...field,
values: rows.map((row) => row[displayName]),
},
reducers,
});
return reducers.map((reducerId) => {
// For number fields, show all reducers
// For non-number fields, only show special count reducers
if (results[reducerId] === undefined || (field.type !== FieldType.number && !isNonMathReducer(reducerId))) {
return [reducerId, null];
}
const value = results[reducerId];
const reducerName = fieldReducers.get(reducerId)?.name || reducerId;
const formattedValue =
field.display && !shouldReducerSkipFormatting(reducerId)
? formattedValueToString(field.display(value))
: String(value);
return [
reducerId,
{
value,
formattedValue,
reducerName,
},
];
});
}, [field, rows, displayName]);
const isSingleSumReducer = useMemo(
() => reducerResultsEntries.every(([item]) => item === 'sum'),
[reducerResultsEntries]
);
if (reducerResultsEntries.length === 0) {
return <div data-testid="summary-cell-empty" className={styles.footerCell} />;
}
// Render each reducer in the footer
return (
<div className={styles.footerCell}>
{reducerResultsEntries.map(([reducerId, reducerResultEntry]) => {
const isCountAll = reducerId === ReducerID.countAll;
// empty reducer entry, but there may be more after - render a spacer.
if ((isCountAll && omitCountAll) || reducerResultEntry === null) {
return (
<div key={reducerId} className={styles.footerItem}>
&nbsp;
</div>
);
}
const { reducerName, formattedValue } = reducerResultEntry;
const canonicalReducerName = isCountAll ? t('grafana-ui.table.footer.reducer.count', 'Count') : reducerName;
return (
<div key={reducerId} className={cx(styles.footerItem, isSingleSumReducer && styles.sumReducer)}>
{!isSingleSumReducer && (
<div
data-testid={selectors.components.Panels.Visualization.TableNG.Footer.ReducerLabel}
className={styles.footerItemLabel}
>
{canonicalReducerName}
</div>
)}
<div
data-testid={selectors.components.Panels.Visualization.TableNG.Footer.Value}
className={styles.footerItemValue}
>
{formattedValue}
</div>
</div>
);
})}
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
footerCell: css({
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
}),
footerItem: css({
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
}),
footerItemLabel: css({
// Handle overflow reducer name collision with footer item value
maxWidth: '75%',
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',
}),
footerItemValue: css({
maxWidth: '75%',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontWeight: theme.typography.fontWeightMedium,
}),
sumReducer: css({
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
width: '100%',
}),
});
@@ -9,11 +9,12 @@ export const COLUMN = {
/** Table layout and display constants */
export const TABLE = {
CELL_PADDING: 6,
LINE_HEIGHT: 22,
HEADER_ROW_HEIGHT: 28,
MAX_CELL_HEIGHT: 48,
PAGINATION_LIMIT: 750,
SCROLL_BAR_WIDTH: 8,
SCROLL_BAR_MARGIN: 2,
LINE_HEIGHT: 22,
HEADER_HEIGHT: 28,
NESTED_NO_DATA_HEIGHT: 60,
BORDER_RIGHT: 1,
@@ -1,17 +1,10 @@
import { act, renderHook } from '@testing-library/react';
import { cacheFieldDisplayNames, createDataFrame, Field, FieldType } from '@grafana/data';
import { createDataFrame, Field, FieldType } from '@grafana/data';
import { TableCellDisplayMode } from '@grafana/schema';
import { TABLE } from './constants';
import {
useFilteredRows,
usePaginatedRows,
useSortedRows,
useFooterCalcs,
useHeaderHeight,
useRowHeight,
} from './hooks';
import { useFilteredRows, usePaginatedRows, useSortedRows, useHeaderHeight, useRowHeight } from './hooks';
import { TableRow } from './types';
import { createTypographyContext } from './utils';
@@ -220,210 +213,6 @@ describe('TableNG hooks', () => {
});
});
describe('useFooterCalcs', () => {
const rows = [
{ 'Field 1': 1, Text: 'a', __depth: 0, __index: 0 },
{ 'Field 1': 2, Text: 'b', __depth: 0, __index: 1 },
{ 'Field 1': 3, Text: 'c', __depth: 0, __index: 2 },
{ 'Field 2': 3, Text: 'd', __depth: 0, __index: 3 },
{ 'Field 2': 10, Text: 'e', __depth: 0, __index: 4 },
];
const numericField: Field = {
name: 'Field1',
type: FieldType.number,
values: [1, 2, 3],
config: {
custom: {},
displayName: 'Field 1',
},
display: (value: unknown) => ({
text: String(value),
numeric: Number(value),
color: undefined,
prefix: undefined,
suffix: undefined,
}),
getLinks: undefined,
};
const numericField2: Field = {
name: 'Field2',
type: FieldType.number,
values: [3, 10],
config: {
custom: {},
displayName: 'Field 2',
},
display: (value: unknown) => ({
text: String(value),
numeric: Number(value),
color: undefined,
prefix: undefined,
suffix: undefined,
}),
getLinks: undefined,
};
const textField: Field = {
name: 'Text',
type: FieldType.string,
values: ['a', 'b', 'c'],
config: { custom: {} },
display: (value: unknown) => ({
text: String(value),
numeric: 0,
color: undefined,
prefix: undefined,
suffix: undefined,
}),
getLinks: undefined,
};
it('should calculate sum for numeric fields', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [textField, numericField] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: true,
footerOptions: { show: true, reducer: ['sum'] },
});
});
expect(result.current).toEqual(['Total', '6']); // 1 + 2 + 3
});
it('should calculate mean for numeric fields', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [textField, numericField] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: true,
footerOptions: { show: true, reducer: ['mean'] },
});
});
expect(result.current).toEqual(['Mean', '2']); // (1 + 2 + 3) / 3
});
it('should return an empty string for non-numeric fields', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [textField, textField] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: true,
footerOptions: { show: true, reducer: ['sum'] },
});
});
expect(result.current).toEqual(['Total', '']);
});
it('should return empty array if no footerOptions are provided', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [textField, numericField, numericField2] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: true,
footerOptions: undefined,
});
});
expect(result.current).toEqual([]);
});
it('should return empty array when footer is disabled', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [textField, numericField, numericField2] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: false,
footerOptions: { show: true, reducer: ['sum'] },
});
});
expect(result.current).toEqual([]);
});
it('should return empty array when reducer is undefined', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [textField, textField] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: true,
footerOptions: { show: true, reducer: undefined },
});
});
expect(result.current).toEqual([]);
});
it('should return empty array when reducer is empty', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [textField, numericField, numericField2] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: true,
footerOptions: { show: true, reducer: [] },
});
});
expect(result.current).toEqual([]);
});
it('should return empty string if fields array doesnt include this field', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [textField, numericField, numericField2] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: true,
footerOptions: { show: true, reducer: ['sum'], fields: ['Field2', 'Field3'] },
});
});
expect(result.current).toEqual(['Total', '', '13']);
});
it('should return the calculation if fields array includes this field', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [textField, numericField, numericField2] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: true,
footerOptions: { show: true, reducer: ['sum'], fields: ['Field1', 'Field2', 'Field3'] },
});
});
expect(result.current).toEqual(['Total', '6', '13']);
});
it('should return the calculation if fields array includes this field by either name or display name', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [textField, numericField, numericField2] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: true,
footerOptions: { show: true, reducer: ['sum'], fields: ['Field1', 'Field 2'] },
});
});
expect(result.current).toEqual(['Total', '6', '13']);
});
it('should not return the reducer label in the first column if there is a calc to render', () => {
const { result } = renderHook(() => {
const data = createDataFrame({ fields: [numericField, numericField2] });
cacheFieldDisplayNames([data]);
return useFooterCalcs(rows, data.fields, {
enabled: true,
footerOptions: { show: true, reducer: ['sum'], fields: [] },
});
});
expect(result.current).toEqual(['6', '13']);
});
});
describe('useHeaderHeight', () => {
const typographyCtx = createTypographyContext(14, 'sans-serif');
@@ -1,19 +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,
fieldReducers,
FieldType,
formattedValueToString,
reduceField,
} from '@grafana/data';
import { compareArrayValues, Field, formattedValueToString } from '@grafana/data';
import { TableColumnResizeActionCallback } from '../types';
import { TABLE } from './constants';
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
import { FilterType, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
import {
getDisplayName,
processNestedTableRows,
@@ -259,73 +252,6 @@ export function usePaginatedRows(
};
}
export interface FooterCalcsOptions {
enabled?: boolean;
isCountRowsSet?: boolean;
footerOptions?: TableFooterCalc;
}
export function useFooterCalcs(
rows: TableRow[],
// it's very important that this is the _visible_ fields.
fields: Field[],
{ enabled, footerOptions, isCountRowsSet }: FooterCalcsOptions
): string[] {
return useMemo(() => {
const footerReducers = footerOptions?.reducer;
if (!enabled || !footerOptions || !Array.isArray(footerReducers) || !footerReducers.length) {
return [];
}
const fieldNameSet = footerOptions.fields?.length ? new Set(footerOptions.fields) : null;
return fields.map((field, index) => {
if (field.state?.calcs) {
delete field.state?.calcs;
}
if (isCountRowsSet) {
return index === 0 ? `${rows.length}` : '';
}
let emptyValue = '';
if (index === 0) {
const footerCalcReducer = footerReducers[0];
emptyValue = footerCalcReducer ? fieldReducers.get(footerCalcReducer).name : '';
}
if (field.type !== FieldType.number) {
return emptyValue;
}
// if field.display is undefined, don't throw
const displayFn = field.display;
if (!displayFn) {
return emptyValue;
}
// If fields array is specified, only show footer for fields included in that array.
// the array can include either the display name or the field name. we don't use a field matcher
// because that requires us to drill the data frame down here.
if (fieldNameSet && !fieldNameSet.has(getDisplayName(field)) && !fieldNameSet.has(field.name)) {
return emptyValue;
}
const calc = footerReducers[0];
const value = reduceField({
field: {
...field,
values: rows.map((row) => row[getDisplayName(field)]),
},
reducers: footerReducers,
})[calc];
return formattedValueToString(displayFn(value));
});
}, [fields, enabled, footerOptions, isCountRowsSet, rows]);
}
const ICON_WIDTH = 16;
const ICON_GAP = 4;
@@ -112,11 +112,6 @@ export const getGridStyles = (theme: GrafanaTheme2, enablePagination?: boolean,
};
};
export const getFooterStyles = (justifyContent: Property.JustifyContent) => ({
footerCellCountRows: css({ display: 'flex', justifyContent: 'space-between' }),
footerCell: css({ display: 'flex', justifyContent: justifyContent || 'space-between' }),
});
export const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) =>
css({
display: 'flex',
@@ -104,14 +104,6 @@ export interface TableSortByFieldState {
desc?: boolean;
}
export interface TableFooterCalc {
show: boolean;
reducer?: string[];
fields?: string[];
enablePagination?: boolean;
countRows?: boolean;
}
export interface BaseTableProps {
ariaLabel?: string;
data: DataFrame;
@@ -127,7 +119,6 @@ export interface BaseTableProps {
onColumnResize?: TableColumnResizeActionCallback;
onSortByChange?: TableSortByActionCallback;
onCellFilterAdded?: TableFilterActionCallback;
footerOptions?: TableFooterCalc;
footerValues?: FooterItem[];
frozenColumns?: number;
enablePagination?: boolean;
@@ -857,8 +857,6 @@ describe('TableNG utils', () => {
expect(extractPixelValue('not-a-number')).toBe(0);
expect(extractPixelValue('px')).toBe(0);
expect(extractPixelValue('')).toBe(0);
expect(extractPixelValue(null as any)).toBe(0);
expect(extractPixelValue(undefined as any)).toBe(0);
});
});
@@ -8,6 +8,7 @@ import { Count, varPreLine } from 'uwrap';
import {
FieldType,
Field,
FieldConfigSource,
formattedValueToString,
GrafanaTheme2,
DisplayValue,
@@ -692,7 +693,7 @@ export const frameToRecords = (frame: DataFrame): TableRow[] => {
// Creates a function that converts a DataFrame into an array of TableRows
// Uses new Function() for performance as it's faster than creating rows using loops
const convert = new Function('frame', fnBody) as unknown as FrameToRowsConverter;
const convert = new Function('frame', fnBody) as FrameToRowsConverter;
return convert(frame);
};
@@ -843,6 +844,58 @@ export const processNestedTableRows = (
/**
* @internal
* Get the maximum number of reducers across all fields
*/
const getMaxReducerCount = (dataFrame: DataFrame, fieldConfig?: FieldConfigSource): number => {
// Filter to only numeric fields that can have reducers
const numericFields = dataFrame.fields.filter(({ type }) => type === FieldType.number);
// If there are no numeric fields, return 0
if (numericFields.length === 0) {
return 0;
}
// Map each field to its reducer count (direct config or override)
const reducerCounts = numericFields.map((field) => {
// Get the direct reducer count from the field config
const directReducers = field.config?.custom?.footer?.reducers ?? [];
let reducerCount = directReducers.length;
// Check for overrides if field config is available
if (fieldConfig?.overrides) {
// Find override that matches this field
const override = fieldConfig.overrides.find(
({ matcher: { id, options } }) => id === 'byName' && options === getDisplayName(field)
);
// Check if there's a footer reducer property in the override
const footerProperty = override?.properties?.find(({ id }) => id === 'custom.footer.reducers');
if (footerProperty?.value && Array.isArray(footerProperty.value)) {
// If override exists, it takes precedence over direct config
reducerCount = footerProperty.value.length;
}
}
return reducerCount;
});
// Return the maximum count or 0 if no reducers found
return reducerCounts.length > 0 ? Math.max(...reducerCounts) : 0;
};
/**
* @internal
* Calculate the footer height based on the maximum reducer count
*/
export const calculateFooterHeight = (dataFrame: DataFrame, fieldConfig?: FieldConfigSource) => {
const maxReducerCount = getMaxReducerCount(dataFrame, fieldConfig);
// Base height (+ padding) + height per reducer
return maxReducerCount * TABLE.LINE_HEIGHT + TABLE.CELL_PADDING * 2;
};
/**
* @internal
* returns the display name of a field
* returns the display name of a field.
* We intentionally do not want to use @grafana/data's getFieldDisplayName here,
* instead we have a call to cacheFieldDisplayNames up in TablePanel to handle this
@@ -76,9 +76,8 @@ export function TablePanel(props: Props) {
onSortByChange={(sortBy) => onSortByChange(sortBy, props)}
onColumnResize={(displayName, resizedWidth) => onColumnResize(displayName, resizedWidth, props)}
onCellFilterAdded={panelContext.onAddAdHocFilter}
footerOptions={options.footer}
frozenColumns={options.frozenColumns?.left}
enablePagination={options.footer?.enablePagination}
enablePagination={options.enablePagination}
cellHeight={options.cellHeight}
maxRowHeight={options.maxRowHeight}
timeRange={timeRange}
@@ -1,7 +1,8 @@
import { createDataFrame, FieldType, PanelModel } from '@grafana/data';
import { ByNamesMatcherMode, createDataFrame, FieldMatcherID, FieldType, PanelModel } from '@grafana/data';
import { TableCellDisplayMode } from '@grafana/ui';
import {
migrateFooterV2,
migrateFromParentRowIndexToNestedFrames,
migrateHiddenFields,
migrateTextWrapToFieldLevel,
@@ -474,4 +475,194 @@ describe('Table Migrations', () => {
);
});
});
describe('migrateFooterV2', () => {
it('is a no-op for panels without footer v1 settings', () => {
const panel = {
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
} as unknown as PanelModel;
// create a clone pre-migration to compare
const origPanel = JSON.parse(JSON.stringify(panel));
migrateFooterV2(panel);
// panel should be unchanged
expect(origPanel).toEqual(panel);
});
it('migrates a global footer', () => {
const panel = {
options: {
footer: {
show: true,
reducer: ['sum'],
},
},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
} as unknown as PanelModel;
migrateFooterV2(panel);
expect(panel.options.footer).toBeUndefined();
expect(panel.fieldConfig.defaults.custom.footer).toEqual({
reducers: ['sum'],
});
});
it('migrates a field-specific footer', () => {
const panel = {
options: {
footer: {
show: true,
reducer: ['sum'],
fields: ['field1', 'field2'],
},
},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
} as unknown as PanelModel;
migrateFooterV2(panel);
expect(panel.options.footer).toBeUndefined();
expect(panel.fieldConfig.defaults.custom.footer).toBeUndefined();
expect(panel.fieldConfig.overrides).toEqual(
expect.arrayContaining([
{
matcher: {
id: FieldMatcherID.byNames,
options: {
mode: ByNamesMatcherMode.include,
names: ['field1', 'field2'],
},
},
properties: [{ id: 'custom.footer.reducers', value: ['sum'] }],
},
])
);
});
it('migrates a single-field footer', () => {
const panel = {
options: {
footer: {
show: true,
reducer: ['sum'],
fields: ['field1'],
},
},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
} as unknown as PanelModel;
migrateFooterV2(panel);
expect(panel.options.footer).toBeUndefined();
expect(panel.fieldConfig.defaults.custom.footer).toBeUndefined();
expect(panel.fieldConfig.overrides).toEqual(
expect.arrayContaining([
{
matcher: {
id: FieldMatcherID.byName,
options: 'field1',
},
properties: [{ id: 'custom.footer.reducers', value: ['sum'] }],
},
])
);
});
it('handles the countAll case', () => {
const panel = {
options: {
footer: {
show: true,
countRows: true,
reducer: ['count'],
},
},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
} as unknown as PanelModel;
migrateFooterV2(panel);
expect(panel.options.footer).toBeUndefined();
expect(panel.fieldConfig.defaults.custom.footer).toEqual({
reducers: ['countAll'],
});
});
it('destroys an existing footer if it was hidden', () => {
const panel = {
options: {
footer: {
show: false,
reducer: ['sum'],
fields: ['field1'],
},
},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
} as unknown as PanelModel;
migrateFooterV2(panel);
expect(panel.options.footer).toBeUndefined();
expect(panel.fieldConfig.defaults.custom.footer).toBeUndefined();
expect(panel.fieldConfig.overrides).toEqual([]);
});
it('retains the enablePagination setting if it exists', () => {
const panel = {
options: {
footer: {
show: false,
reducer: ['sum'],
fields: ['field1'],
enablePagination: true,
},
},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
} as unknown as PanelModel;
migrateFooterV2(panel);
expect(panel.options.enablePagination).toBe(true);
expect(panel.fieldConfig.defaults.custom.footer).toBeUndefined();
expect(panel.fieldConfig.overrides).toEqual([]);
});
});
});
@@ -9,6 +9,7 @@ import {
FieldConfig,
DataFrame,
FieldType,
ByNamesMatcherMode,
} from '@grafana/data';
import { ReduceTransformerOptions } from '@grafana/data/internal';
@@ -27,6 +28,7 @@ export const tableMigrationHandler = (panel: PanelModel<Options>): Partial<Optio
migrateTextWrapToFieldLevel(panel);
migrateHiddenFields(panel);
migrateFooterV2(panel);
// Nothing changed
return panel.options;
@@ -346,3 +348,66 @@ export const migrateHiddenFields = (panel: PanelModel<Partial<Options>>) => {
return panel;
};
interface LegacyTableFooterOptions {
show: boolean;
reducer: string[];
fields?: string[];
countRows?: boolean;
enablePagination?: boolean;
}
export const migrateFooterV2 = (panel: PanelModel<Options>) => {
if (panel.options && 'footer' in panel.options) {
// we need to cast the footer to the old type to work with it here.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const oldFooter = panel.options.footer as LegacyTableFooterOptions;
if (oldFooter.show) {
const reducers = oldFooter.reducer;
panel.fieldConfig.defaults.custom = {
...panel.fieldConfig.defaults.custom,
footer: {
reducers: reducers,
},
};
if (oldFooter.countRows && reducers[0] === 'count') {
panel.fieldConfig.defaults.custom.footer.reducers = ['countAll'];
} else if (oldFooter.fields && oldFooter.fields.length > 1) {
delete panel.fieldConfig.defaults.custom.footer;
// Fields is an array of field names, so push a byNames matcher
// on with the matched reducer.
panel.fieldConfig.overrides.push({
matcher: {
id: FieldMatcherID.byNames,
options: {
mode: ByNamesMatcherMode.include,
names: oldFooter.fields,
},
},
properties: [{ id: 'custom.footer.reducers', value: reducers }],
});
} else if (oldFooter.fields && oldFooter.fields.length === 1) {
delete panel.fieldConfig.defaults.custom.footer;
// Single field, so we can use a byName matcher
panel.fieldConfig.overrides.push({
matcher: {
id: FieldMatcherID.byName,
options: oldFooter.fields[0],
},
properties: [{ id: 'custom.footer.reducers', value: reducers }],
});
}
}
if (oldFooter.enablePagination != null) {
panel.options.enablePagination = oldFooter.enablePagination;
}
delete panel.options.footer;
}
};
+43 -88
View File
@@ -1,13 +1,4 @@
import {
FieldOverrideContext,
FieldType,
getFieldDisplayName,
PanelPlugin,
ReducerID,
standardEditorsRegistry,
identityOverrideProcessor,
FieldConfigProperty,
} from '@grafana/data';
import { PanelPlugin, standardEditorsRegistry, identityOverrideProcessor, FieldConfigProperty } from '@grafana/data';
import { t } from '@grafana/i18n';
import {
defaultTableFieldOptions,
@@ -75,6 +66,45 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
},
defaultValue: defaultTableFieldOptions.align,
})
.addBooleanSwitch({
path: 'filterable',
name: t('table.name-column-filter', 'Column filter'),
category,
description: t('table.description-column-filter', 'Enables/disables field filters in table'),
defaultValue: defaultTableFieldOptions.filterable,
})
.addBooleanSwitch({
path: 'wrapText',
name: t('table.name-wrap-text', 'Wrap text'),
category,
})
.addBooleanSwitch({
path: 'wrapHeaderText',
name: t('table.name-wrap-header-text', 'Wrap header text'),
category,
})
.addBooleanSwitch({
path: 'hideFrom.viz',
name: t('table.name-hide-in-table', 'Hide in table'),
category,
defaultValue: undefined,
hideFromDefaults: true,
})
.addCustomEditor({
id: 'footer.reducers',
category: [t('table.category-table-footer', 'Table footer')],
path: 'footer.reducers',
name: t('table.name-calculation', 'Calculation'),
description: t('table.description-calculation', 'Choose a reducer function / calculation'),
editor: standardEditorsRegistry.get('stats-picker').editor,
override: standardEditorsRegistry.get('stats-picker').editor,
defaultValue: [],
process: identityOverrideProcessor,
shouldApply: () => true,
settings: {
allowMultiple: true,
},
})
.addCustomEditor<void, TableCellOptions>({
id: 'cellOptions',
path: 'cellOptions',
@@ -101,30 +131,6 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
);
},
})
.addBooleanSwitch({
path: 'filterable',
name: t('table.name-column-filter', 'Column filter'),
category,
description: t('table.description-column-filter', 'Enables/disables field filters in table'),
defaultValue: defaultTableFieldOptions.filterable,
})
.addBooleanSwitch({
path: 'wrapText',
name: t('table.name-wrap-text', 'Wrap text'),
category,
})
.addBooleanSwitch({
path: 'wrapHeaderText',
name: t('table.name-wrap-header-text', 'Wrap header text'),
category,
})
.addBooleanSwitch({
path: 'hideFrom.viz',
name: t('table.name-hide-in-table', 'Hide in table'),
category,
defaultValue: undefined,
hideFromDefaults: true,
})
.addFieldNamePicker({
path: 'tooltip.field',
name: t('table.name-tooltip-from-field', 'Tooltip from field'),
@@ -167,7 +173,6 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
},
})
.setPanelOptions((builder) => {
const footerCategory = [t('table.category-table-footer', 'Table footer')];
const category = [t('table.category-table', 'Table')];
builder
.addBooleanSwitch({
@@ -207,63 +212,13 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
min: 0,
},
})
.addBooleanSwitch({
path: 'footer.show',
category: footerCategory,
name: t('table.name-show-table-footer', 'Show table footer'),
defaultValue: defaultOptions.footer?.show,
})
.addCustomEditor({
id: 'footer.reducer',
category: footerCategory,
path: 'footer.reducer',
name: t('table.name-calculation', 'Calculation'),
description: t('table.description-calculation', 'Choose a reducer function / calculation'),
editor: standardEditorsRegistry.get('stats-picker').editor,
defaultValue: [ReducerID.sum],
showIf: (cfg) => cfg.footer?.show,
})
.addBooleanSwitch({
path: 'footer.countRows',
category: footerCategory,
name: t('table.name-count-rows', 'Count rows'),
description: t('table.description-count-rows', 'Display a single count for all data rows'),
defaultValue: defaultOptions.footer?.countRows,
showIf: (cfg) => cfg.footer?.reducer?.length === 1 && cfg.footer?.reducer[0] === ReducerID.count,
})
.addMultiSelect({
path: 'footer.fields',
category: footerCategory,
name: t('table.name-fields', 'Fields'),
description: t('table.description-fields', 'Select the fields that should be calculated'),
settings: {
allowCustomValue: false,
options: [],
placeholder: t('table.placeholder-fields', 'All Numeric Fields'),
getOptions: async (context: FieldOverrideContext) => {
const options = [];
if (context && context.data && context.data.length > 0) {
const frame = context.data[0];
for (const field of frame.fields) {
if (field.type === FieldType.number) {
const name = getFieldDisplayName(field, frame, context.data);
const value = field.name;
options.push({ value, label: name });
}
}
}
return options;
},
},
defaultValue: '',
showIf: (cfg) => cfg.footer?.show && !(cfg.footer?.countRows && cfg.footer?.reducer.includes(ReducerID.count)),
})
.addCustomEditor({
id: 'footer.enablePagination',
path: 'footer.enablePagination',
id: 'enablePagination',
path: 'enablePagination',
name: t('table.name-enable-pagination', 'Enable pagination'),
category,
editor: PaginationEditor,
defaultValue: defaultOptions?.enablePagination,
});
})
.setSuggestionsSupplier(new TableSuggestionsSupplier());
+2 -9
View File
@@ -33,15 +33,8 @@ composableKinds: PanelCfg: {
showTypeIcons?: bool | *false
// Used to control row sorting
sortBy?: [...ui.TableSortByFieldState]
// Controls footer options
footer?: ui.TableFooterOptions | *{
// Controls whether the footer should be shown
show: false
// Controls whether the footer should show the total number of rows on Count calculation
countRows: false
// Represents the selected calculations
reducer: []
}
// Enable pagination on the table
enablePagination?: bool
// Controls the height of the rows
cellHeight?: ui.TableCellHeight & (*"sm" | _)
// limits the maximum height of a row, if text wrapping or dynamic height is enabled
+2 -16
View File
@@ -16,9 +16,9 @@ export interface Options {
*/
cellHeight?: ui.TableCellHeight;
/**
* Controls footer options
* Enable pagination on the table
*/
footer?: ui.TableFooterOptions;
enablePagination?: boolean;
/**
* Represents the index of the selected frame
*/
@@ -49,20 +49,6 @@ export interface Options {
export const defaultOptions: Partial<Options> = {
cellHeight: ui.TableCellHeight.Sm,
footer: {
/**
* Controls whether the footer should be shown
*/
show: false,
/**
* Controls whether the footer should show the total number of rows on Count calculation
*/
countRows: false,
/**
* Represents the selected calculations
*/
reducer: [],
},
frameIndex: 0,
showHeader: true,
showTypeIcons: false,
+5 -7
View File
@@ -8772,7 +8772,6 @@
"cell-inspect": "Inspect value",
"cell-inspect-tooltip": "Inspect value",
"copy": "Copy to Clipboard",
"count": "Count",
"csv-counts": "Rows:{{rows}}, Columns:{{columns}} <5></5>",
"csv-placeholder": "Enter CSV here...",
"filter-placeholder": "Filter values",
@@ -8783,6 +8782,11 @@
"filter-popup-heading": "Filter by values:",
"filter-popup-input-placeholder": "Filter values",
"filter-popup-match-case": "Match case",
"footer": {
"reducer": {
"count": "Count"
}
},
"inspect-drawer-title": "Inspect value",
"nested-table": {
"no-data": "No data"
@@ -12788,8 +12792,6 @@
"description-calculation": "Choose a reducer function / calculation",
"description-cell-value-inspect": "Enable cell value inspection in a modal window",
"description-column-filter": "Enables/disables field filters in table",
"description-count-rows": "Display a single count for all data rows",
"description-fields": "Select the fields that should be calculated",
"description-frozen-columns": "Columns are frozen from the left side of the table",
"description-min-column-width": "The minimum width for column auto resizing",
"description-tooltip-from-field": "Render a cell from a field (hidden or visible) in a tooltip",
@@ -12816,21 +12818,17 @@
"name-column-alignment": "Column alignment",
"name-column-filter": "Column filter",
"name-column-width": "Column width",
"name-count-rows": "Count rows",
"name-enable-pagination": "Enable pagination",
"name-fields": "Fields",
"name-frozen-columns": "Frozen columns",
"name-hide-in-table": "Hide in table",
"name-max-height": "Max row height",
"name-min-column-width": "Minimum column width",
"name-show-table-footer": "Show table footer",
"name-show-table-header": "Show table header",
"name-tooltip-from-field": "Tooltip from field",
"name-tooltip-placement": "Tooltip placement",
"name-wrap-header-text": "Wrap header text",
"name-wrap-text": "Wrap text",
"placeholder-column-width": "auto",
"placeholder-fields": "All Numeric Fields",
"placeholder-max-height": "none",
"tooltip-placement-options": {
"label-auto": "Auto",