Table: Move library to react-data-grid (#102482)
* Changes galore * Freedom 🗽 * Add feature flag * Latest changes * Basic auto cell type * Partially working bar-gauge * Brokenish but whatevs * Include the toggle doc * TableNG: Context menu (#94094) * feat(table-ng): context menu init commit * betterer * feat(table-ng): re-use contextmenu component * fix(table-ng): close context menu issue * TableNG: Sorting columns (#94200) feat(table-ng): sorting column * fix feature toggle conflict * TableNG: Sorting with custom table header (#95351) * TableNG: Header Toggle (#95310) * TableNG: Multi-column sorting (#95395) feat(table-ng): multi-sorting * TableNG: Column width options (#95426) * feat(table-ng): column width * mouse handle drag event * move resizing task * TableNG: Fix icon sorting direction (#95653) fix(table-ng): sorting icon direction * TableNG: Show table footer (#95313) * TableNG: Show table footer * Revert betterer * Update betterer * Incorporate reducer calculations into footer * Update imports in FooterRow * Use getFooterValue for summary cell render * TableNG: Min column width (#95657) * feat(table-ng): min column width * feat(table-ng): set a min width constant * TableNG: Column alignment (#95679) * feat(table-ng): column alignment * cleaning * feat(table-ng): header cell alignment * optimizations * feat(table-ng): footer cell alignment * calc counter * TableNG: use compiled fn for columns -> records conversion (#95914) * use compiled fn for columns -> records conversion * TableNG: Move key rev and fix width overrides (#95921) * meh * add index to records --------- Co-authored-by: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> * TableNG: Sparkline Cell Parity (#95690) * sparkline value * todo * Remove unsued shallowField * Pass justifyContent to sparkline --------- Co-authored-by: drew08t <drew08@gmail.com> * TableNG: BarGauge cell updates (#95521) * fix bargauge cell * merge and fix props * cleanup imports * TableNG: Text wrapping (#96041) * feat(table-ng): fix long text cell width * feat(table-ng): fix long text cell width 2 * comment out column rowHeight * fix long text column width * fix types * fix types * naming * Check current header cell ref is defined for key * cleaning * make table re-render when data changed * eslint --------- Co-authored-by: drew08t <drew08@gmail.com> * TableNG: Text overflow (#96641) * feat(table-ng): text overflow * cleaning * TableNG: Fix footer for count (#96802) * TableNG: Table column filter (#96767) * feat(table-ng): add filter form --------- Co-authored-by: drew08t <drew08@gmail.com> Co-authored-by: Leon Sorokin <leeoniya@gmail.com> * TableNG: On column resize trigger (#97004) chore(table-ng): trigger on resize on text wrap only * TableNG: Improve sort performance (#97767) * TableNG: Improve sort performance * clean a bit * a bit more * Remove const that was breaking sort --------- Co-authored-by: Leon Sorokin <leeoniya@gmail.com> * TableNG: Fix sorting (#98141) fix(table-ng): sorting * TableNG: fix multi sorting (#98668) fix(table-ng): multi sorting * TableNG: Column re-size handler (#98901) * feat(table-ng): column re-size handler * TableNG: Fix footer calcs with no reducer (#99347) * TableNG: Update renderHeaderCell with filter dep (#99483) * TableNG: Updated styles for demo (#99530) * style proposal: table ng * chore: revert gauge cell custom stuff * TableNG: Cross-filter (#99459) * feat(table-ng): cross-filter * fix filter update issue * fix filter reset issue * Fix spacebar for filter input --------- Co-authored-by: drew08t <drew08@gmail.com> * TableNG: Filter perfomance optimization (#99620) fix(table-ng): filter performance optimization * TableNG: Refine styling closer to original table (#99625) * TableNG: Support groupToNestedTableTransform (#97134) * TableNG: Support groupToNestedTableTransform * Fix merge issues * Force refresh for now * Remove log * Fix some conflicts * Fix more conflicts * Help avoid clash with compiled frameToRecords keys * Make subtable height unconstrained * Support show field names in nested tables toggle * TableNG: Fix footer + some other misc updates (#99846) fix: footer fixes huzzah * TableNG: Styling - Update styling for cells (#99851) * fix(table-ng): bargauge inner width issue * TableNG: Move header cell component (#99844) * fix(table-ng): move header cell into separate file * Fix sub table --------- Co-authored-by: drew08t <drew08@gmail.com> * TableNG: Auto cell feature parity (#100095) * feat(table-ng): auto cell feature parity * TableNG: JSON cell implementation + hover fixes (#100152) * feat: tableNG json cell + auto fixes * chore: add comment * add justify content to json cell --------- Co-authored-by: Ihor Yeromin <yeryomin.igor@gmail.com> * TableNG: Fix cell hover issue (#100207) * fix(table-ng): cell hover issue * better commenting * TableNG: Text color cell (#100120) feat(table-ng): text color cell feature parity * TableNG: Image cell implementation (#100132) * feat: tableNG image cell * fix: incorporate justify-content correctly * chore: pass down cell options from fieldConfig --------- Co-authored-by: Ihor Yeromin <yeryomin.igor@gmail.com> * TableNG: Cell height performance improvement (#100544) * chore: perf improvement * chore: minor fix * Update packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx Co-authored-by: Leon Sorokin <leeoniya@gmail.com> * chore: fix betterer --------- Co-authored-by: Leon Sorokin <leeoniya@gmail.com> * TableNG: Add pagination (#100165) * TableNG: Add pagination * TableNG: Get collapsed icon state correct + update `rowHeight` (#100556) * fix: get collapsed icon state correct + update condition for calculating row height * chore: some cleanup! * chore: naming to avoid confusion with local state name * TableNG: Add support for `DataLinksCell` (#100459) * TableNG: Improve sub table styling (#100772) * Move files temporarily to fix conflicts * Fix feature flag conflicts * Move files back to cell dir * TableNG: Update inner height of bar gauge cell (#100996) * fix: change inner height of bar gauge cell * chore: move function to utils, cleanup * Remove testing line * TableNG: Add bottom border to column headers + fix footer styling (#101016) * feat: add bottom border to column headers for table parity * feat: summary row style fix * chore: remove redundant style --------- Co-authored-by: drew08t <drew08@gmail.com> * TableNG: Add support for `ActionsCell` (#101024) * TableNG: Cell hover styles + header resize handler indicator (#100770) * fix: tableNG styles * chore: clean up comments * chore: remove column header stuffz for now * fix: refactor to transform/translate + resize handler hover styling * chore: re-think approach - change a lot of things * chore: most recent iteration * chore: wait i like this better * chore: hoist into colors function + clean it up! * moar better * chore: define constants for clarity * chore: calculate rbga to rgb values given background color --------- Co-authored-by: drew08t <drew08@gmail.com> * TableNG: Fix scoll hover jumpy behavior (#101085) * fix(table-ng): hover scroll jumping * Account for panel padding during pagination --------- Co-authored-by: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Co-authored-by: drew08t <drew08@gmail.com> * TableNG: Fix imports (#101059) * fix(table-ng): clean imports Co-authored-by: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> * TableNG: Sorted rows dependent upon filtered rows (#100985) TableNG: Improve multi-sort performance * TableNG: Fix sparkline width (#101164) fix(table-ng): sparkline width * TableNG: Type TableNG (#101257) * feat: type tableNG * chore: push betterer * chore: fix linter + why can't I have inline if statements... GRR! * fix: linter - props name got changed at some point... * feedback: data links prop consistency + json cell robustness * chore: remove unused rowIndex prop --------- Co-authored-by: drew08t <drew08@gmail.com> * TableNG: Add support for datalinks (#100769) Co-authored-by: drew08t <drew08@gmail.com> * Chore: Remove unused import (#102064) remove unused import * Update betterer * BarGauge: Remove z-index (#102220) fix(bargauge): remove z-index * TableNG: Refactor + testing (#102045) * feat: type tableNG * chore: push betterer * chore: fix linter + why can't I have inline if statements... GRR! * fix: linter - props name got changed at some point... * feedback: data links prop consistency + json cell robustness * feat: refactor + tests * chore: fix import lint errors * betterer * chore: fix image cell * chore: revert width function * add test * betterer * chore: fix sorting + add tests * chore: pr feedback --------- Co-authored-by: Ihor Yeromin <yeryomin.igor@gmail.com> Co-authored-by: drew08t <drew08@gmail.com> * TableNG: Fix table suggestion (#102497) fix: defensively guard against missing cellOptions * TableNG: Footer fields calc fix (#102487) * fix: respect footer fields calc selection * chore: add test * TableNG: Image cell hover fix (#102489) fix: image cell hover * TableNG: Persist scrollbars during re render (#102559) * TableNG: Persist scrollbars during re render * Update improved betterer * TableNG: Fix column width override (#102474) * fix(table): column width override * TableNG: Add support for crosshair share (#102410) * TableNG: Add support for crosshair share * Add tests * TableNG: Fix table ng tests (#102645) fix: cellType causing tests to fail * Remove empty file * TableNG: Update util tests (#102646) * TableNG: Add column type icon (#102686) * chore(table-ng): add column type icon * chore(table-ng): clean styling * Use core internationalization outside grafana ui * Import popover directly * Add count to grafana-ui locales * TableNG: Change feature flag to tableNextGen (#102814) Change feature flag to tableNextGen * TableNG: Add row colors (#102706) * chore(table-ng): add row colors * clean up * fix params * fix(table-ng): cell color background indexing --------- Co-authored-by: Kyle Cunningham <kyle@codeincarnate.com> Co-authored-by: Ihor Yeromin <yeryomin.igor@gmail.com> Co-authored-by: Adela Almasan <adela.almasan@grafana.com> Co-authored-by: Leon Sorokin <leeoniya@gmail.com> Co-authored-by: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com>
This commit is contained in:
@@ -649,25 +649,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/Filter.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/FilterPopup.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/FooterRow.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/HeaderRow.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/Table.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableCell.tsx:5381": [
|
||||
"packages/grafana-ui/src/components/Table/Cells/TableCell.tsx: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.", "2"],
|
||||
@@ -676,6 +658,80 @@ exports[`better eslint`] = {
|
||||
"packages/grafana-ui/src/components/Table/TableCellInspector.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableNG/Filter/Filter.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableNG/Filter/FilterList.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableNG/Filter/utils.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"]
|
||||
],
|
||||
"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"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"]
|
||||
],
|
||||
"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"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableRT/Filter.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableRT/FilterPopup.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableRT/FooterRow.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableRT/HeaderRow.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/TableRT/Table.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Table/reducer.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
@@ -5070,8 +5126,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/search/page/components/SearchResultsTable.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
"public/app/features/search/page/components/columns.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
|
||||
@@ -18,14 +18,11 @@
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": 89,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
@@ -33,15 +30,7 @@
|
||||
"y": 0
|
||||
},
|
||||
"id": 7,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"panels": [],
|
||||
"title": "Cell styles",
|
||||
"type": "row"
|
||||
},
|
||||
@@ -164,7 +153,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginVersion": "9.5.0-pre",
|
||||
"pluginVersion": "11.6.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -286,6 +275,7 @@
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"cellHeight": "sm",
|
||||
"footer": {
|
||||
"countRows": false,
|
||||
"fields": "",
|
||||
@@ -303,7 +293,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginVersion": "9.5.0-pre",
|
||||
"pluginVersion": "11.6.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -376,7 +366,7 @@
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "rate"
|
||||
"options": "Trend #A"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
@@ -448,7 +438,7 @@
|
||||
"showRowNums": false,
|
||||
"sortBy": []
|
||||
},
|
||||
"pluginVersion": "9.5.0-pre",
|
||||
"pluginVersion": "11.6.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -625,7 +615,7 @@
|
||||
"showHeader": true,
|
||||
"showRowNums": false
|
||||
},
|
||||
"pluginVersion": "9.5.0-pre",
|
||||
"pluginVersion": "11.6.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -683,10 +673,7 @@
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "gdev-testdata"
|
||||
},
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
@@ -694,15 +681,7 @@
|
||||
"y": 24
|
||||
},
|
||||
"id": 9,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "gdev-testdata"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"panels": [],
|
||||
"title": "Data links",
|
||||
"type": "row"
|
||||
},
|
||||
@@ -752,18 +731,19 @@
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "Time"
|
||||
"options": "time"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.align"
|
||||
"id": "custom.align",
|
||||
"value": "center"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": {
|
||||
"id": "byName",
|
||||
"options": "{name=\"S1\", server=\"A\"}"
|
||||
"options": "S1 A"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
@@ -799,7 +779,7 @@
|
||||
"showHeader": true,
|
||||
"showRowNums": false
|
||||
},
|
||||
"pluginVersion": "9.5.0-pre",
|
||||
"pluginVersion": "11.6.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"alias": "S1",
|
||||
@@ -916,7 +896,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginVersion": "9.5.0-pre",
|
||||
"pluginVersion": "11.6.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -1005,7 +985,7 @@
|
||||
"showHeader": true,
|
||||
"showRowNums": false
|
||||
},
|
||||
"pluginVersion": "9.5.0-pre",
|
||||
"pluginVersion": "11.6.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -1019,9 +999,9 @@
|
||||
"type": "table"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
"refresh": "",
|
||||
"revision": 1,
|
||||
"schemaVersion": 38,
|
||||
"schemaVersion": 41,
|
||||
"tags": [
|
||||
"gdev",
|
||||
"panel-tests"
|
||||
@@ -1049,6 +1029,5 @@
|
||||
"timezone": "",
|
||||
"title": "Panel Tests - React Table",
|
||||
"uid": "U_bZIMRMk",
|
||||
"version": 7,
|
||||
"weekStart": ""
|
||||
"version": 17
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `alertingCentralAlertHistory` | Enables the new central alert history. |
|
||||
| `failWrongDSUID` | Throws an error if a datasource has an invalid UIDs |
|
||||
| `dataplaneAggregator` | Enable grafana dataplane aggregator |
|
||||
| `tableNextGen` | Allows access to the new react-data-grid based table component. |
|
||||
| `lokiSendDashboardPanelNames` | Send dashboard and panel names to Loki when querying |
|
||||
| `alertingPrometheusRulesPrimary` | Uses Prometheus rules as the primary source of truth for ruler-enabled data sources |
|
||||
| `exploreLogsShardSplitting` | Used in Logs Drilldown to split queries into multiple queries based on the number of shards |
|
||||
|
||||
@@ -747,6 +747,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
newFiltersUI?: boolean;
|
||||
/**
|
||||
* Allows access to the new react-data-grid based table component.
|
||||
*/
|
||||
tableNextGen?: boolean;
|
||||
/**
|
||||
* Send dashboard and panel names to Loki when querying
|
||||
*/
|
||||
lokiSendDashboardPanelNames?: boolean;
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"react-calendar": "^5.1.0",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-custom-scrollbars-2": "4.5.0",
|
||||
"react-data-grid": "7.0.0-beta.46",
|
||||
"react-dropzone": "14.3.5",
|
||||
"react-highlight-words": "0.21.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
|
||||
@@ -527,7 +527,6 @@ export function getBasicAndGradientStyles(props: Props): BasicAndGradientStyles
|
||||
const barStyles: CSSProperties = {
|
||||
borderRadius: theme.shape.radius.default,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
const emptyBar: CSSProperties = {
|
||||
|
||||
@@ -3,11 +3,10 @@ import { isFunction } from 'lodash';
|
||||
import { ThresholdsConfig, ThresholdsMode, VizOrientation, getFieldConfigWithMinMax } from '@grafana/data';
|
||||
import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@grafana/schema';
|
||||
|
||||
import { BarGauge } from '../BarGauge/BarGauge';
|
||||
import { DataLinksContextMenu, DataLinksContextMenuApi } from '../DataLinks/DataLinksContextMenu';
|
||||
|
||||
import { TableCellProps } from './types';
|
||||
import { getAlignmentFactor, getCellOptions } from './utils';
|
||||
import { BarGauge } from '../../BarGauge/BarGauge';
|
||||
import { DataLinksContextMenu, DataLinksContextMenuApi } from '../../DataLinks/DataLinksContextMenu';
|
||||
import { TableCellProps } from '../types';
|
||||
import { getAlignmentFactor, getCellOptions } from '../utils';
|
||||
|
||||
const defaultScale: ThresholdsConfig = {
|
||||
mode: ThresholdsMode.Absolute,
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getCellLinks } from '../../utils';
|
||||
|
||||
import { TableCellProps } from './types';
|
||||
import { getCellLinks } from '../../../utils';
|
||||
import { TableCellProps } from '../types';
|
||||
|
||||
export const DataLinksCell = (props: TableCellProps) => {
|
||||
const { field, row, cellProps, tableStyles } = props;
|
||||
@@ -5,16 +5,15 @@ import * as React from 'react';
|
||||
import { DisplayValue, formattedValueToString } from '@grafana/data';
|
||||
import { TableCellDisplayMode } from '@grafana/schema';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { getCellLinks } from '../../utils';
|
||||
import { clearLinkButtonStyles } from '../Button';
|
||||
import { DataLinksContextMenu } from '../DataLinks/DataLinksContextMenu';
|
||||
|
||||
import { CellActions } from './CellActions';
|
||||
import { TableCellInspectorMode } from './TableCellInspector';
|
||||
import { TableStyles } from './styles';
|
||||
import { TableCellProps, CustomCellRendererProps, TableCellOptions } from './types';
|
||||
import { getCellColors, getCellOptions } from './utils';
|
||||
import { useStyles2 } from '../../../themes';
|
||||
import { getCellLinks } from '../../../utils';
|
||||
import { clearLinkButtonStyles } from '../../Button';
|
||||
import { DataLinksContextMenu } from '../../DataLinks/DataLinksContextMenu';
|
||||
import { CellActions } from '../CellActions';
|
||||
import { TableCellInspectorMode } from '../TableCellInspector';
|
||||
import { TableStyles } from '../TableRT/styles';
|
||||
import { TableCellProps, CustomCellRendererProps, TableCellOptions } from '../types';
|
||||
import { getCellColors, getCellOptions } from '../utils';
|
||||
|
||||
export const DefaultCell = (props: TableCellProps) => {
|
||||
const { field, cell, tableStyles, row, cellProps, frame, rowStyled, rowExpanded, textWrapped, height } = props;
|
||||
@@ -124,7 +123,7 @@ function getCellStyle(
|
||||
let bgHoverColor: string | undefined = undefined;
|
||||
|
||||
// Get colors
|
||||
const colors = getCellColors(tableStyles, cellOptions, displayValue);
|
||||
const colors = getCellColors(tableStyles.theme, cellOptions, displayValue);
|
||||
textColor = colors.textColor;
|
||||
bgColor = colors.bgColor;
|
||||
bgHoverColor = colors.bgHoverColor;
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { KeyValue } from '@grafana/data';
|
||||
|
||||
import { FooterItem } from './types';
|
||||
import { FooterItem } from '../types';
|
||||
|
||||
export interface FooterProps {
|
||||
value: FooterItem;
|
||||
@@ -1,7 +1,7 @@
|
||||
import WKT from 'ol/format/WKT';
|
||||
import { Geometry } from 'ol/geom';
|
||||
|
||||
import { TableCellProps } from './types';
|
||||
import { TableCellProps } from '../types';
|
||||
|
||||
export function GeoCell(props: TableCellProps): JSX.Element {
|
||||
const { cell, tableStyles, cellProps } = props;
|
||||
@@ -1,10 +1,9 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { getCellLinks } from '../../utils';
|
||||
import { DataLinksContextMenu } from '../DataLinks/DataLinksContextMenu';
|
||||
|
||||
import { TableCellDisplayMode, TableCellProps } from './types';
|
||||
import { getCellOptions } from './utils';
|
||||
import { getCellLinks } from '../../../utils';
|
||||
import { DataLinksContextMenu } from '../../DataLinks/DataLinksContextMenu';
|
||||
import { TableCellDisplayMode, TableCellProps } from '../types';
|
||||
import { getCellOptions } from '../utils';
|
||||
|
||||
const DATALINKS_HEIGHT_OFFSET = 10;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { getCellLinks } from '../../utils';
|
||||
import { Button, clearLinkButtonStyles } from '../Button';
|
||||
import { DataLinksContextMenu } from '../DataLinks/DataLinksContextMenu';
|
||||
|
||||
import { CellActions } from './CellActions';
|
||||
import { TableCellInspectorMode } from './TableCellInspector';
|
||||
import { TableCellProps } from './types';
|
||||
import { useStyles2 } from '../../../themes';
|
||||
import { getCellLinks } from '../../../utils';
|
||||
import { Button, clearLinkButtonStyles } from '../../Button';
|
||||
import { DataLinksContextMenu } from '../../DataLinks/DataLinksContextMenu';
|
||||
import { CellActions } from '../CellActions';
|
||||
import { TableCellInspectorMode } from '../TableCellInspector';
|
||||
import { TableCellProps } from '../types';
|
||||
|
||||
export function JSONViewCell(props: TableCellProps): JSX.Element {
|
||||
const { cell, tableStyles, cellProps, field, row } = props;
|
||||
@@ -21,13 +21,12 @@ import {
|
||||
VisibilityMode,
|
||||
} from '@grafana/schema';
|
||||
|
||||
import { useTheme2 } from '../../themes';
|
||||
import { measureText } from '../../utils';
|
||||
import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay';
|
||||
import { Sparkline } from '../Sparkline/Sparkline';
|
||||
|
||||
import { TableCellProps } from './types';
|
||||
import { getAlignmentFactor, getCellOptions } from './utils';
|
||||
import { useTheme2 } from '../../../themes';
|
||||
import { measureText } from '../../../utils';
|
||||
import { FormattedValueDisplay } from '../../FormattedValueDisplay/FormattedValueDisplay';
|
||||
import { Sparkline } from '../../Sparkline/Sparkline';
|
||||
import { TableCellProps } from '../types';
|
||||
import { getAlignmentFactor, getCellOptions } from '../utils';
|
||||
|
||||
export const defaultSparklineCellConfig: TableSparklineCellOptions = {
|
||||
type: TableCellDisplayMode.Sparkline,
|
||||
@@ -2,8 +2,8 @@ import { Cell } from 'react-table';
|
||||
|
||||
import { TimeRange, DataFrame, InterpolateFunction } from '@grafana/data';
|
||||
|
||||
import { TableStyles } from './styles';
|
||||
import { GetActionsFunction, GrafanaTableColumn, TableFilterActionCallback, TableInspectCellCallback } from './types';
|
||||
import { TableStyles } from '../TableRT/styles';
|
||||
import { GetActionsFunction, GrafanaTableColumn, TableFilterActionCallback, TableInspectCellCallback } from '../types';
|
||||
|
||||
export interface Props {
|
||||
cell: Cell;
|
||||
@@ -5,8 +5,8 @@ import { applyFieldOverrides, createTheme, DataFrame, FieldType, toDataFrame } f
|
||||
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
import { Table } from './Table';
|
||||
import { CustomHeaderRendererProps, Props } from './types';
|
||||
import { Table } from './TableRT/Table';
|
||||
import { CustomHeaderRendererProps, BaseTableProps } from './types';
|
||||
|
||||
// mock transition styles to ensure consistent behaviour in unit tests
|
||||
jest.mock('@floating-ui/react', () => ({
|
||||
@@ -101,11 +101,11 @@ function applyOverrides(dataFrame: DataFrame) {
|
||||
return dataFrames[0];
|
||||
}
|
||||
|
||||
function getTestContext(propOverrides: Partial<Props> = {}) {
|
||||
function getTestContext(propOverrides: Partial<BaseTableProps> = {}) {
|
||||
const onSortByChange = jest.fn();
|
||||
const onCellFilterAdded = jest.fn();
|
||||
const onColumnResize = jest.fn();
|
||||
const props: Props = {
|
||||
const props: BaseTableProps = {
|
||||
ariaLabel: 'aria-label',
|
||||
data: getDataFrame(fullDataFrame),
|
||||
height: 600,
|
||||
@@ -415,7 +415,7 @@ describe('Table', () => {
|
||||
const onSortByChange = jest.fn();
|
||||
const onCellFilterAdded = jest.fn();
|
||||
const onColumnResize = jest.fn();
|
||||
const props: Props = {
|
||||
const props: BaseTableProps = {
|
||||
ariaLabel: 'aria-label',
|
||||
data: getDataFrame(fullDataFrame),
|
||||
height: 600,
|
||||
@@ -557,7 +557,7 @@ describe('Table', () => {
|
||||
const onSortByChange = jest.fn();
|
||||
const onCellFilterAdded = jest.fn();
|
||||
const onColumnResize = jest.fn();
|
||||
const props: Props = {
|
||||
const props: BaseTableProps = {
|
||||
ariaLabel: 'aria-label',
|
||||
data: getDataFrame(fullDataFrame),
|
||||
height: 600,
|
||||
|
||||
@@ -1,411 +1,8 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useAbsoluteLayout,
|
||||
useExpanded,
|
||||
useFilters,
|
||||
usePagination,
|
||||
useResizeColumns,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import { VariableSizeList } from 'react-window';
|
||||
import { TableNG } from './TableNG/TableNG';
|
||||
import { Table as TableRT } from './TableRT/Table';
|
||||
import { GeneralTableProps } from './types';
|
||||
|
||||
import { FieldType, ReducerID, getRowUniqueId, getFieldMatcher, Field } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { TableCellHeight } from '@grafana/schema';
|
||||
|
||||
import { useTheme2 } from '../../themes';
|
||||
import { Trans } from '../../utils/i18n';
|
||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
|
||||
import { FooterRow } from './FooterRow';
|
||||
import { HeaderRow } from './HeaderRow';
|
||||
import { RowsList } from './RowsList';
|
||||
import { TableCellInspector } from './TableCellInspector';
|
||||
import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks';
|
||||
import { getInitialState, useTableStateReducer } from './reducer';
|
||||
import { useTableStyles } from './styles';
|
||||
import { FooterItem, GrafanaTableState, InspectCell, Props } from './types';
|
||||
import {
|
||||
getColumns,
|
||||
sortCaseInsensitive,
|
||||
sortNumber,
|
||||
getFooterItems,
|
||||
createFooterCalculationValues,
|
||||
guessLongestField,
|
||||
} from './utils';
|
||||
|
||||
const COLUMN_MIN_WIDTH = 150;
|
||||
const FOOTER_ROW_HEIGHT = 36;
|
||||
const NO_DATA_TEXT = 'No data';
|
||||
|
||||
export const Table = memo((props: Props) => {
|
||||
const {
|
||||
ariaLabel,
|
||||
data,
|
||||
height,
|
||||
onCellFilterAdded,
|
||||
onColumnResize,
|
||||
width,
|
||||
columnMinWidth = COLUMN_MIN_WIDTH,
|
||||
noHeader,
|
||||
resizable = true,
|
||||
initialSortBy,
|
||||
footerOptions,
|
||||
showTypeIcons,
|
||||
footerValues,
|
||||
enablePagination,
|
||||
cellHeight = TableCellHeight.Sm,
|
||||
timeRange,
|
||||
enableSharedCrosshair = false,
|
||||
initialRowIndex = undefined,
|
||||
fieldConfig,
|
||||
getActions,
|
||||
replaceVariables,
|
||||
} = props;
|
||||
|
||||
const listRef = useRef<VariableSizeList>(null);
|
||||
const tableDivRef = useRef<HTMLDivElement>(null);
|
||||
const variableSizeListScrollbarRef = useRef<HTMLDivElement>(null);
|
||||
const theme = useTheme2();
|
||||
const tableStyles = useTableStyles(theme, cellHeight);
|
||||
const headerHeight = noHeader ? 0 : tableStyles.rowHeight;
|
||||
const [footerItems, setFooterItems] = useState<FooterItem[] | undefined>(footerValues);
|
||||
const noValuesDisplayText = fieldConfig?.defaults?.noValue ?? NO_DATA_TEXT;
|
||||
const [inspectCell, setInspectCell] = useState<InspectCell | null>(null);
|
||||
|
||||
const footerHeight = useMemo(() => {
|
||||
const EXTENDED_ROW_HEIGHT = FOOTER_ROW_HEIGHT;
|
||||
let length = 0;
|
||||
|
||||
if (!footerItems) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const fv of footerItems) {
|
||||
if (Array.isArray(fv) && fv.length > length) {
|
||||
length = fv.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (length > 1) {
|
||||
return EXTENDED_ROW_HEIGHT * length;
|
||||
}
|
||||
|
||||
return EXTENDED_ROW_HEIGHT;
|
||||
}, [footerItems]);
|
||||
|
||||
// React table data array. This data acts just like a dummy array to let react-table know how many rows exist.
|
||||
// The cells use the field to look up values, therefore this is simply a length/size placeholder.
|
||||
const memoizedData = useMemo(() => {
|
||||
if (!data.fields.length) {
|
||||
return [];
|
||||
}
|
||||
// As we only use this to fake the length of our data set for react-table we need to make sure we always return an array
|
||||
// filled with values at each index otherwise we'll end up trying to call accessRow for null|undefined value in
|
||||
// https://github.com/tannerlinsley/react-table/blob/7be2fc9d8b5e223fc998af88865ae86a88792fdb/src/hooks/useTable.js#L585
|
||||
return Array(data.length).fill(0);
|
||||
}, [data]);
|
||||
|
||||
// This checks whether `Show table footer` is toggled on, the `Calculation` is set to `Count`, and finally, whether `Count rows` is toggled on.
|
||||
const isCountRowsSet = Boolean(
|
||||
footerOptions?.countRows &&
|
||||
footerOptions.reducer &&
|
||||
footerOptions.reducer.length &&
|
||||
footerOptions.reducer[0] === ReducerID.count
|
||||
);
|
||||
|
||||
const nestedDataField = data.fields.find((f) => f.type === FieldType.nestedFrames);
|
||||
const hasNestedData = nestedDataField !== undefined;
|
||||
|
||||
// React-table column definitions
|
||||
const memoizedColumns = useMemo(
|
||||
() => getColumns(data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet),
|
||||
[data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet]
|
||||
);
|
||||
|
||||
// we need a ref to later store the `toggleAllRowsExpanded` function, returned by `useTable`.
|
||||
// We cannot simply use a variable because we need to use such function in the initialization of
|
||||
// `useTableStateReducer`, which is needed to construct options for `useTable` (the hook that returns
|
||||
// `toggleAllRowsExpanded`), and if we used a variable, that variable would be undefined at the time
|
||||
// we initialize `useTableStateReducer`.
|
||||
const toggleAllRowsExpandedRef = useRef<(value?: boolean) => void>();
|
||||
|
||||
// Internal react table state reducer
|
||||
const stateReducer = useTableStateReducer({
|
||||
onColumnResize,
|
||||
onSortByChange: (state) => {
|
||||
// Collapse all rows. This prevents a known bug that causes the size of the rows to be incorrect due to
|
||||
// using `VariableSizeList` and `useExpanded` together.
|
||||
toggleAllRowsExpandedRef.current!(false);
|
||||
|
||||
if (props.onSortByChange) {
|
||||
props.onSortByChange(state);
|
||||
}
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
const hasUniqueId = !!data.meta?.uniqueRowIdFields?.length;
|
||||
|
||||
const options: any = useMemo(() => {
|
||||
// This is a bit hard to type with the react-table types here, the reducer does not actually match with the
|
||||
// TableOptions.
|
||||
const options: any = {
|
||||
columns: memoizedColumns,
|
||||
data: memoizedData,
|
||||
disableResizing: !resizable,
|
||||
stateReducer: stateReducer,
|
||||
autoResetPage: false,
|
||||
initialState: getInitialState(initialSortBy, memoizedColumns),
|
||||
autoResetFilters: false,
|
||||
sortTypes: {
|
||||
// the builtin number type on react-table does not handle NaN values
|
||||
number: sortNumber,
|
||||
// should be replaced with the builtin string when react-table is upgraded,
|
||||
// see https://github.com/tannerlinsley/react-table/pull/3235
|
||||
'alphanumeric-insensitive': sortCaseInsensitive,
|
||||
},
|
||||
};
|
||||
if (hasUniqueId) {
|
||||
// row here is just always 0 because here we don't use real data but just a dummy array filled with 0.
|
||||
// See memoizedData variable above.
|
||||
options.getRowId = (row: Record<string, unknown>, relativeIndex: number) => getRowUniqueId(data, relativeIndex);
|
||||
|
||||
// If we have unique field we assume we can count on it as being globally unique, and we don't need to reset when
|
||||
// data changes.
|
||||
options.autoResetExpanded = false;
|
||||
}
|
||||
return options;
|
||||
}, [initialSortBy, memoizedColumns, memoizedData, resizable, stateReducer, hasUniqueId, data]);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
headerGroups,
|
||||
footerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
totalColumnsWidth,
|
||||
page,
|
||||
state,
|
||||
gotoPage,
|
||||
setPageSize,
|
||||
pageOptions,
|
||||
toggleAllRowsExpanded,
|
||||
} = useTable(options, useFilters, useSortBy, useAbsoluteLayout, useResizeColumns, useExpanded, usePagination);
|
||||
|
||||
const extendedState = state as GrafanaTableState;
|
||||
toggleAllRowsExpandedRef.current = toggleAllRowsExpanded;
|
||||
|
||||
/*
|
||||
Footer value calculation is being moved in the Table component and the footerValues prop will be deprecated.
|
||||
The footerValues prop is still used in the Table component for backwards compatibility. Adding the
|
||||
footerOptions prop will switch the Table component to use the new footer calculation. Using both props will
|
||||
result in the footerValues prop being ignored.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!footerOptions) {
|
||||
setFooterItems(footerValues);
|
||||
}
|
||||
}, [footerValues, footerOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!footerOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!footerOptions.show) {
|
||||
setFooterItems(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCountRowsSet) {
|
||||
const footerItemsCountRows: FooterItem[] = [];
|
||||
footerItemsCountRows[0] = rows.length.toString() ?? data.length.toString();
|
||||
setFooterItems(footerItemsCountRows);
|
||||
return;
|
||||
}
|
||||
|
||||
const footerItems = getFooterItems(
|
||||
headerGroups[0].headers,
|
||||
createFooterCalculationValues(rows),
|
||||
footerOptions,
|
||||
theme
|
||||
);
|
||||
|
||||
setFooterItems(footerItems);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [footerOptions, theme, state.filters, data]);
|
||||
|
||||
let listHeight = height - (headerHeight + footerHeight);
|
||||
|
||||
if (enablePagination) {
|
||||
listHeight -= tableStyles.cellHeight;
|
||||
}
|
||||
|
||||
const pageSize = Math.round(listHeight / tableStyles.rowHeight) - 1;
|
||||
|
||||
useEffect(() => {
|
||||
// Don't update the page size if it is less than 1
|
||||
if (pageSize <= 0) {
|
||||
return;
|
||||
}
|
||||
setPageSize(pageSize);
|
||||
}, [pageSize, setPageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset page index when data changes
|
||||
// This is needed because react-table does not do this automatically
|
||||
// autoResetPage is set to false because setting it to true causes the issue described in
|
||||
// https://github.com/grafana/grafana/pull/67477
|
||||
if (data.length / pageSize < state.pageIndex) {
|
||||
gotoPage(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
useResetVariableListSizeCache(extendedState, listRef, data, hasUniqueId);
|
||||
useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(toPage: number) => {
|
||||
gotoPage(toPage - 1);
|
||||
},
|
||||
[gotoPage]
|
||||
);
|
||||
|
||||
const itemCount = enablePagination ? page.length : rows.length;
|
||||
let paginationEl = null;
|
||||
if (enablePagination) {
|
||||
const itemsRangeStart = state.pageIndex * state.pageSize + 1;
|
||||
let itemsRangeEnd = itemsRangeStart + state.pageSize - 1;
|
||||
const isSmall = width < 550;
|
||||
if (itemsRangeEnd > data.length) {
|
||||
itemsRangeEnd = data.length;
|
||||
}
|
||||
const numRows = rows.length;
|
||||
const displayedEnd = itemsRangeEnd < rows.length ? itemsRangeEnd : rows.length;
|
||||
paginationEl = (
|
||||
<div className={tableStyles.paginationWrapper}>
|
||||
<Pagination
|
||||
currentPage={state.pageIndex + 1}
|
||||
numberOfPages={pageOptions.length}
|
||||
showSmallVersion={isSmall}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
{isSmall ? null : (
|
||||
<div className={tableStyles.paginationSummary}>
|
||||
<Trans i18nKey="grafana-ui.table.pagination-summary">
|
||||
{{ itemsRangeStart }} - {{ displayedEnd }} of {{ numRows }} rows
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Try to determine the longet field
|
||||
// TODO: do we wrap only one field?
|
||||
// What if there are multiple fields with long text?
|
||||
let longestField: Field | undefined = undefined;
|
||||
let textWrapField = undefined;
|
||||
|
||||
if (fieldConfig) {
|
||||
longestField = guessLongestField(fieldConfig, data);
|
||||
|
||||
data.fields.forEach((field) => {
|
||||
fieldConfig.overrides.forEach((override) => {
|
||||
const matcher = getFieldMatcher(override.matcher);
|
||||
if (matcher(field, data, [data])) {
|
||||
for (const property of override.properties) {
|
||||
if (property.id === 'custom.cellOptions' && property.value.wrapText) {
|
||||
textWrapField = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
{...getTableProps()}
|
||||
className={tableStyles.table}
|
||||
aria-label={ariaLabel}
|
||||
role="table"
|
||||
ref={tableDivRef}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<CustomScrollbar hideVerticalTrack={true}>
|
||||
<div className={tableStyles.tableContentWrapper(totalColumnsWidth)}>
|
||||
{!noHeader && (
|
||||
<HeaderRow headerGroups={headerGroups} showTypeIcons={showTypeIcons} tableStyles={tableStyles} />
|
||||
)}
|
||||
{itemCount > 0 ? (
|
||||
<div
|
||||
data-testid={selectors.components.Panels.Visualization.Table.body}
|
||||
ref={variableSizeListScrollbarRef}
|
||||
>
|
||||
<RowsList
|
||||
headerGroups={headerGroups}
|
||||
data={data}
|
||||
rows={rows}
|
||||
width={width}
|
||||
cellHeight={cellHeight}
|
||||
headerHeight={headerHeight}
|
||||
rowHeight={tableStyles.rowHeight}
|
||||
itemCount={itemCount}
|
||||
pageIndex={state.pageIndex}
|
||||
listHeight={listHeight}
|
||||
listRef={listRef}
|
||||
tableState={state}
|
||||
prepareRow={prepareRow}
|
||||
timeRange={timeRange}
|
||||
onCellFilterAdded={onCellFilterAdded}
|
||||
nestedDataField={nestedDataField}
|
||||
tableStyles={tableStyles}
|
||||
footerPaginationEnabled={Boolean(enablePagination)}
|
||||
enableSharedCrosshair={enableSharedCrosshair}
|
||||
initialRowIndex={initialRowIndex}
|
||||
longestField={longestField}
|
||||
textWrapField={textWrapField}
|
||||
getActions={getActions}
|
||||
replaceVariables={replaceVariables}
|
||||
setInspectCell={setInspectCell}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: height - headerHeight, width }} className={tableStyles.noData}>
|
||||
{noValuesDisplayText}
|
||||
</div>
|
||||
)}
|
||||
{footerItems && (
|
||||
<FooterRow
|
||||
isPaginationVisible={Boolean(enablePagination)}
|
||||
footerValues={footerItems}
|
||||
footerGroups={footerGroups}
|
||||
totalColumnsWidth={totalColumnsWidth}
|
||||
tableStyles={tableStyles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
{paginationEl}
|
||||
</div>
|
||||
|
||||
{inspectCell !== null && (
|
||||
<TableCellInspector
|
||||
mode={inspectCell.mode}
|
||||
value={inspectCell.value}
|
||||
onDismiss={() => {
|
||||
setInspectCell(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Table.displayName = 'Table';
|
||||
export function Table(props: GeneralTableProps) {
|
||||
let table = props.useTableNg ? <TableNG {...props} /> : <TableRT {...props} />;
|
||||
return table;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../../../themes';
|
||||
import { ActionButton } from '../../../Actions/ActionButton';
|
||||
import { ActionCellProps } from '../types';
|
||||
|
||||
export const ActionsCell = ({ actions }: ActionCellProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.buttonsGap}>
|
||||
{actions && actions.map((action, i) => <ActionButton key={i} action={action} variant="secondary" />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
buttonsGap: css({
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Property } from 'csstype';
|
||||
|
||||
import { GrafanaTheme2, formattedValueToString } from '@grafana/data';
|
||||
import { TableCellDisplayMode, TableCellOptions } from '@grafana/schema';
|
||||
|
||||
import { useStyles2 } from '../../../../themes';
|
||||
import { clearLinkButtonStyles } from '../../../Button';
|
||||
import { DataLinksContextMenu } from '../../../DataLinks/DataLinksContextMenu';
|
||||
import { AutoCellProps } from '../types';
|
||||
import { getCellLinks } from '../utils';
|
||||
|
||||
export default function AutoCell({ value, field, justifyContent, rowIdx, cellOptions }: AutoCellProps) {
|
||||
const styles = useStyles2(getStyles, justifyContent);
|
||||
|
||||
const displayValue = field.display!(value);
|
||||
const formattedValue = formattedValueToString(displayValue);
|
||||
const hasLinks = Boolean(getCellLinks(field, rowIdx)?.length);
|
||||
const clearButtonStyle = useStyles2(clearLinkButtonStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.cell}>
|
||||
{hasLinks ? (
|
||||
<DataLinksContextMenu links={() => getCellLinks(field, rowIdx) || []}>
|
||||
{(api) => {
|
||||
if (api.openMenu) {
|
||||
return (
|
||||
<button
|
||||
className={cx(clearButtonStyle, getLinkStyle(styles, cellOptions, api.targetClassName))}
|
||||
onClick={api.openMenu}
|
||||
>
|
||||
{formattedValue}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
return <div className={getLinkStyle(styles, cellOptions, api.targetClassName)}>{formattedValue}</div>;
|
||||
}
|
||||
}}
|
||||
</DataLinksContextMenu>
|
||||
) : (
|
||||
formattedValue
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getLinkStyle = (
|
||||
styles: ReturnType<typeof getStyles>,
|
||||
cellOptions: TableCellOptions,
|
||||
targetClassName: string | undefined
|
||||
) => {
|
||||
if (cellOptions.type === TableCellDisplayMode.Auto) {
|
||||
return cx(styles.linkCell, targetClassName);
|
||||
}
|
||||
|
||||
return cx(styles.cellLinkForColoredCell, targetClassName);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent | undefined) => ({
|
||||
cell: css({
|
||||
display: 'flex',
|
||||
justifyContent: justifyContent,
|
||||
|
||||
a: {
|
||||
color: 'inherit',
|
||||
},
|
||||
}),
|
||||
cellLinkForColoredCell: css({
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
userSelect: 'text',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
textDecoration: 'underline',
|
||||
}),
|
||||
linkCell: css({
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
userSelect: 'text',
|
||||
whiteSpace: 'nowrap',
|
||||
color: theme.colors.text.link,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
paddingRight: theme.spacing(1.5),
|
||||
a: {
|
||||
color: theme.colors.text.link,
|
||||
},
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
color: theme.colors.text.link,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ThresholdsConfig, ThresholdsMode, VizOrientation, getFieldConfigWithMinMax } from '@grafana/data';
|
||||
import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@grafana/schema';
|
||||
|
||||
import { BarGauge } from '../../../BarGauge/BarGauge';
|
||||
import { DataLinksContextMenu, DataLinksContextMenuApi } from '../../../DataLinks/DataLinksContextMenu';
|
||||
import { BarGaugeCellProps } from '../types';
|
||||
import { extractPixelValue, getCellOptions, getAlignmentFactor, getCellLinks } from '../utils';
|
||||
|
||||
const defaultScale: ThresholdsConfig = {
|
||||
mode: ThresholdsMode.Absolute,
|
||||
steps: [
|
||||
{
|
||||
color: 'blue',
|
||||
value: -Infinity,
|
||||
},
|
||||
{
|
||||
color: 'green',
|
||||
value: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: BarGaugeCellProps) => {
|
||||
const displayValue = field.display!(value);
|
||||
const cellOptions = getCellOptions(field);
|
||||
const heightOffset = extractPixelValue(theme.spacing(1));
|
||||
|
||||
let config = getFieldConfigWithMinMax(field, false);
|
||||
if (!config.thresholds) {
|
||||
config = {
|
||||
...config,
|
||||
thresholds: defaultScale,
|
||||
};
|
||||
}
|
||||
|
||||
// Set default display mode and update if defined
|
||||
// and update the valueMode if defined
|
||||
let barGaugeMode: BarGaugeDisplayMode = BarGaugeDisplayMode.Gradient;
|
||||
let valueDisplayMode: BarGaugeValueMode | undefined = undefined;
|
||||
|
||||
if (cellOptions.type === TableCellDisplayMode.Gauge) {
|
||||
barGaugeMode = cellOptions.mode ?? BarGaugeDisplayMode.Gradient;
|
||||
valueDisplayMode =
|
||||
cellOptions.valueDisplayMode !== undefined ? cellOptions.valueDisplayMode : BarGaugeValueMode.Text;
|
||||
}
|
||||
|
||||
const hasLinks = Boolean(getCellLinks(field, rowIdx)?.length);
|
||||
|
||||
const alignmentFactors = getAlignmentFactor(field, displayValue, rowIdx!);
|
||||
|
||||
const renderComponent = (menuProps: DataLinksContextMenuApi) => {
|
||||
const { openMenu } = menuProps;
|
||||
|
||||
return (
|
||||
<BarGauge
|
||||
width={width}
|
||||
height={height - heightOffset}
|
||||
field={config}
|
||||
display={field.display}
|
||||
text={{ valueSize: 14 }}
|
||||
value={displayValue}
|
||||
orientation={VizOrientation.Horizontal}
|
||||
theme={theme}
|
||||
alignmentFactors={alignmentFactors}
|
||||
onClick={openMenu}
|
||||
itemSpacing={1}
|
||||
lcdCellWidth={8}
|
||||
displayMode={barGaugeMode}
|
||||
valueDisplayMode={valueDisplayMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// @TODO: Actions
|
||||
return (
|
||||
<>
|
||||
{hasLinks ? (
|
||||
<DataLinksContextMenu
|
||||
links={() => getCellLinks(field, rowIdx) || []}
|
||||
style={{ display: 'flex', width: '100%' }}
|
||||
>
|
||||
{(api) => renderComponent(api)}
|
||||
</DataLinksContextMenu>
|
||||
) : (
|
||||
renderComponent({})
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../../../themes';
|
||||
import { DataLinksCellProps } from '../types';
|
||||
import { getCellLinks } from '../utils';
|
||||
|
||||
export const DataLinksCell = ({ field, rowIdx }: DataLinksCellProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const links = getCellLinks(field, rowIdx!);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{links &&
|
||||
links.map((link, idx) => {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<span key={idx} className={styles.linkCell} onClick={link.onClick}>
|
||||
<a href={link.href} target={link.target}>
|
||||
{link.title}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
linkCell: css({
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
userSelect: 'text',
|
||||
whiteSpace: 'nowrap',
|
||||
color: theme.colors.text.link,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
paddingRight: theme.spacing(1.5),
|
||||
a: {
|
||||
color: theme.colors.text.link,
|
||||
},
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
color: theme.colors.text.link,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Property } from 'csstype';
|
||||
|
||||
import { fieldReducers, KeyValue, ReducerID } from '@grafana/data';
|
||||
|
||||
export type FooterItem = Array<KeyValue<string>> | string | undefined;
|
||||
|
||||
export interface FooterProps {
|
||||
value: FooterItem;
|
||||
justifyContent?: Property.JustifyContent;
|
||||
}
|
||||
|
||||
export const FooterCell = (props: FooterProps) => {
|
||||
const cell = css({
|
||||
width: '100%',
|
||||
listStyle: 'none',
|
||||
});
|
||||
|
||||
const item = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: props.justifyContent || 'space-between',
|
||||
});
|
||||
|
||||
const list = css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
if (props.value && !Array.isArray(props.value)) {
|
||||
return <span className={item}>{props.value}</span>;
|
||||
}
|
||||
|
||||
if (props.value && Array.isArray(props.value) && props.value.length > 0) {
|
||||
return (
|
||||
<ul className={cell}>
|
||||
{props.value.map((v: KeyValue<string>, i) => {
|
||||
const key = Object.keys(v)[0];
|
||||
return (
|
||||
<li className={list} key={i}>
|
||||
<span>{key}</span>
|
||||
<span>{v[key]}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return EmptyCell;
|
||||
};
|
||||
|
||||
export const EmptyCell = () => {
|
||||
return <span> </span>;
|
||||
};
|
||||
|
||||
export function getFooterValue(
|
||||
index: number,
|
||||
footerValues?: FooterItem[],
|
||||
isCountRowsSet?: boolean,
|
||||
justifyContent?: Property.JustifyContent
|
||||
) {
|
||||
if (footerValues === undefined) {
|
||||
return EmptyCell;
|
||||
}
|
||||
|
||||
if (isCountRowsSet) {
|
||||
if (footerValues[index] === undefined) {
|
||||
return EmptyCell;
|
||||
}
|
||||
|
||||
const key = fieldReducers.get(ReducerID.count).name;
|
||||
|
||||
return FooterCell({ value: [{ [key]: String(footerValues[index]) }] });
|
||||
}
|
||||
|
||||
return FooterCell({ value: footerValues[index], justifyContent });
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Property } from 'csstype';
|
||||
import React, { useLayoutEffect, useRef, useEffect } from 'react';
|
||||
import { Column, SortDirection } from 'react-data-grid';
|
||||
|
||||
import { Field, GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../../../themes';
|
||||
import { getFieldTypeIcon } from '../../../../types';
|
||||
import { Icon } from '../../../Icon/Icon';
|
||||
import { Filter } from '../Filter/Filter';
|
||||
import { TableColumnResizeActionCallback, FilterType, TableRow, TableSummaryRow } from '../types';
|
||||
|
||||
interface HeaderCellProps {
|
||||
column: Column<TableRow, TableSummaryRow>;
|
||||
rows: TableRow[];
|
||||
field: Field;
|
||||
onSort: (columnKey: string, direction: SortDirection, isMultiSort: boolean) => void;
|
||||
direction?: SortDirection;
|
||||
justifyContent: Property.JustifyContent;
|
||||
filter: FilterType;
|
||||
setFilter: React.Dispatch<React.SetStateAction<FilterType>>;
|
||||
filterable: boolean;
|
||||
onColumnResize?: TableColumnResizeActionCallback;
|
||||
headerCellRefs: React.MutableRefObject<Record<string, HTMLDivElement>>;
|
||||
crossFilterOrder: React.MutableRefObject<string[]>;
|
||||
crossFilterRows: React.MutableRefObject<{ [key: string]: TableRow[] }>;
|
||||
showTypeIcons?: boolean;
|
||||
}
|
||||
|
||||
const HeaderCell: React.FC<HeaderCellProps> = ({
|
||||
column,
|
||||
rows,
|
||||
field,
|
||||
onSort,
|
||||
direction,
|
||||
justifyContent,
|
||||
filter,
|
||||
setFilter,
|
||||
filterable,
|
||||
onColumnResize,
|
||||
headerCellRefs,
|
||||
crossFilterOrder,
|
||||
crossFilterRows,
|
||||
showTypeIcons,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
let isColumnFilterable = filterable;
|
||||
if (field.config.custom?.filterable !== filterable) {
|
||||
isColumnFilterable = field.config.custom?.filterable || false;
|
||||
}
|
||||
// we have to remove/reset the filter if the column is not filterable
|
||||
if (!isColumnFilterable && filter[field.name]) {
|
||||
setFilter((filter: FilterType) => {
|
||||
const newFilter = { ...filter };
|
||||
delete newFilter[field.name];
|
||||
return newFilter;
|
||||
});
|
||||
}
|
||||
|
||||
const handleSort = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const isMultiSort = event.shiftKey;
|
||||
onSort(column.key as string, direction === 'ASC' ? 'DESC' : 'ASC', isMultiSort);
|
||||
};
|
||||
|
||||
// collecting header cell refs to handle manual column resize
|
||||
useLayoutEffect(() => {
|
||||
if (headerRef.current) {
|
||||
headerCellRefs.current[column.key] = headerRef.current;
|
||||
}
|
||||
}, [headerRef, column.key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// TODO: this is a workaround to handle manual column resize;
|
||||
useEffect(() => {
|
||||
const headerCellParent = headerRef.current?.parentElement;
|
||||
if (headerCellParent) {
|
||||
// `lastElement` is an HTML element added by react-data-grid for resizing columns.
|
||||
// We add event listeners to `lastElement` to handle the resize operation.
|
||||
const lastElement = headerCellParent.lastElementChild;
|
||||
if (lastElement) {
|
||||
const handleMouseUp = () => {
|
||||
let newWidth = headerCellParent.clientWidth;
|
||||
onColumnResize?.(column.key as string, newWidth);
|
||||
};
|
||||
|
||||
lastElement.addEventListener('click', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
lastElement.removeEventListener('click', handleMouseUp);
|
||||
};
|
||||
}
|
||||
}
|
||||
// to handle "Not all code paths return a value." error
|
||||
return;
|
||||
}, [column]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={headerRef}
|
||||
style={{ display: 'flex', justifyContent }}
|
||||
// TODO find a better solution to this issue, see: https://github.com/adazzle/react-data-grid/issues/3535
|
||||
// Unblock spacebar event
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className={styles.headerCellLabel} onClick={handleSort}>
|
||||
{showTypeIcons && <Icon name={getFieldTypeIcon(field)} title={field?.type} size="sm" />}
|
||||
<div>{column.name}</div>
|
||||
{direction &&
|
||||
(direction === 'ASC' ? (
|
||||
<Icon name="arrow-up" size="lg" className={styles.sortIcon} />
|
||||
) : (
|
||||
<Icon name="arrow-down" size="lg" className={styles.sortIcon} />
|
||||
))}
|
||||
</button>
|
||||
|
||||
{isColumnFilterable && (
|
||||
<Filter
|
||||
name={column.key}
|
||||
rows={rows}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
field={field}
|
||||
crossFilterOrder={crossFilterOrder.current}
|
||||
crossFilterRows={crossFilterRows.current}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
headerCellLabel: css({
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
background: 'inherit',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginRight: theme.spacing(0.5),
|
||||
color: theme.colors.text.secondary,
|
||||
gap: theme.spacing(1),
|
||||
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
color: theme.colors.text.link,
|
||||
},
|
||||
}),
|
||||
sortIcon: css({
|
||||
marginLeft: theme.spacing(0.5),
|
||||
}),
|
||||
});
|
||||
|
||||
export { HeaderCell };
|
||||
@@ -0,0 +1,69 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Property } from 'csstype';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { TableCellDisplayMode } from '@grafana/schema';
|
||||
|
||||
import { useStyles2 } from '../../../../themes';
|
||||
import { DataLinksContextMenu } from '../../../DataLinks/DataLinksContextMenu';
|
||||
import { ImageCellProps } from '../types';
|
||||
import { getCellLinks } from '../utils';
|
||||
|
||||
const DATALINKS_HEIGHT_OFFSET = 10;
|
||||
|
||||
export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => {
|
||||
const calculatedHeight = height - DATALINKS_HEIGHT_OFFSET;
|
||||
const styles = useStyles2(getStyles, calculatedHeight, justifyContent);
|
||||
const hasLinks = Boolean(getCellLinks(field, rowIdx)?.length);
|
||||
|
||||
const { text } = field.display!(value);
|
||||
const { alt, title } =
|
||||
cellOptions.type === TableCellDisplayMode.Image ? cellOptions : { alt: undefined, title: undefined };
|
||||
|
||||
const img = <img alt={alt} src={text} className={styles.image} title={title} />;
|
||||
|
||||
// TODO: Implement actions
|
||||
return (
|
||||
<div className={styles.imageContainer}>
|
||||
{hasLinks ? (
|
||||
<DataLinksContextMenu links={() => getCellLinks(field, rowIdx) || []}>
|
||||
{(api) => {
|
||||
if (api.openMenu) {
|
||||
return (
|
||||
<div
|
||||
onClick={api.openMenu}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && api.openMenu) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
api.openMenu(e as any);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{img}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return img;
|
||||
}
|
||||
}}
|
||||
</DataLinksContextMenu>
|
||||
) : (
|
||||
img
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({
|
||||
image: css({
|
||||
height,
|
||||
width: 'auto',
|
||||
}),
|
||||
imageContainer: css({
|
||||
display: 'flex',
|
||||
justifyContent,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Property } from 'csstype';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../../../themes';
|
||||
import { Button, clearLinkButtonStyles } from '../../../Button';
|
||||
import { DataLinksContextMenu } from '../../../DataLinks/DataLinksContextMenu';
|
||||
import { JSONCellProps } from '../types';
|
||||
import { getCellLinks } from '../utils';
|
||||
|
||||
export const JSONCell = ({ value, justifyContent, field, rowIdx }: JSONCellProps) => {
|
||||
const styles = useStyles2(getStyles, justifyContent);
|
||||
const clearButtonStyle = useStyles2(clearLinkButtonStyles);
|
||||
|
||||
let displayValue = value;
|
||||
|
||||
// Handle string values that might be JSON
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
displayValue = JSON.stringify(parsed, null, ' ');
|
||||
} catch {
|
||||
displayValue = value; // Keep original if not valid JSON
|
||||
}
|
||||
} else {
|
||||
// For non-string values, stringify them
|
||||
try {
|
||||
displayValue = JSON.stringify(value, null, ' ');
|
||||
} catch (error) {
|
||||
// Handle circular references or other stringify errors
|
||||
displayValue = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
const hasLinks = Boolean(getCellLinks(field, rowIdx)?.length);
|
||||
|
||||
// TODO: Implement actions
|
||||
return (
|
||||
<div className={styles.jsonText}>
|
||||
{hasLinks ? (
|
||||
<DataLinksContextMenu links={() => getCellLinks(field, rowIdx) || []}>
|
||||
{(api) => {
|
||||
if (api.openMenu) {
|
||||
return (
|
||||
<Button className={cx(clearButtonStyle)} onClick={api.openMenu}>
|
||||
{displayValue}
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return <>{displayValue}</>;
|
||||
}
|
||||
}}
|
||||
</DataLinksContextMenu>
|
||||
) : (
|
||||
displayValue
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({
|
||||
jsonText: css({
|
||||
display: 'flex',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'monospace',
|
||||
justifyContent: justifyContent,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../../../themes';
|
||||
import { Icon } from '../../../Icon/Icon';
|
||||
import { RowExpanderNGProps } from '../types';
|
||||
|
||||
export function RowExpander({ height, onCellExpand, isExpanded }: RowExpanderNGProps) {
|
||||
const styles = useStyles2(getStyles, height);
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLSpanElement>) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onCellExpand();
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className={styles.expanderCell} onClick={onCellExpand} onKeyDown={handleKeyDown}>
|
||||
<Icon
|
||||
aria-label={isExpanded ? 'Collapse row' : 'Expand row'}
|
||||
name={isExpanded ? 'angle-down' : 'angle-right'}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, rowHeight: number) => ({
|
||||
expanderCell: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
height: `${rowHeight}px`,
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Property } from 'csstype';
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
FieldType,
|
||||
FieldConfig,
|
||||
getMinMaxAndDelta,
|
||||
FieldSparkline,
|
||||
isDataFrame,
|
||||
Field,
|
||||
isDataFrameWithValue,
|
||||
GrafanaTheme2,
|
||||
} from '@grafana/data';
|
||||
import {
|
||||
BarAlignment,
|
||||
GraphDrawStyle,
|
||||
GraphFieldConfig,
|
||||
GraphGradientMode,
|
||||
LineInterpolation,
|
||||
TableSparklineCellOptions,
|
||||
TableCellDisplayMode,
|
||||
VisibilityMode,
|
||||
} from '@grafana/schema';
|
||||
|
||||
import { useStyles2 } from '../../../../themes';
|
||||
import { measureText } from '../../../../utils';
|
||||
import { FormattedValueDisplay } from '../../../FormattedValueDisplay/FormattedValueDisplay';
|
||||
import { Sparkline } from '../../../Sparkline/Sparkline';
|
||||
import { SparklineCellProps } from '../types';
|
||||
import { getAlignmentFactor, getCellOptions } from '../utils';
|
||||
|
||||
export const defaultSparklineCellConfig: TableSparklineCellOptions = {
|
||||
type: TableCellDisplayMode.Sparkline,
|
||||
drawStyle: GraphDrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.Smooth,
|
||||
lineWidth: 1,
|
||||
fillOpacity: 17,
|
||||
gradientMode: GraphGradientMode.Hue,
|
||||
pointSize: 2,
|
||||
barAlignment: BarAlignment.Center,
|
||||
showPoints: VisibilityMode.Never,
|
||||
hideValue: false,
|
||||
};
|
||||
|
||||
export const SparklineCell = (props: SparklineCellProps) => {
|
||||
const { field, value, theme, timeRange, rowIdx, justifyContent, width } = props;
|
||||
const styles = useStyles2(getStyles, justifyContent);
|
||||
const sparkline = getSparkline(value);
|
||||
|
||||
if (!sparkline) {
|
||||
return <>{field.config.noValue || 'no data'}</>;
|
||||
}
|
||||
|
||||
// Get the step from the first two values to null-fill the x-axis based on timerange
|
||||
if (sparkline.x && !sparkline.x.config.interval && sparkline.x.values.length > 1) {
|
||||
sparkline.x.config.interval = sparkline.x.values[1] - sparkline.x.values[0];
|
||||
}
|
||||
|
||||
// Remove non-finite values, e.g: NaN, +/-Infinity
|
||||
sparkline.y.values = sparkline.y.values.map((v) => {
|
||||
if (!Number.isFinite(v)) {
|
||||
return null;
|
||||
} else {
|
||||
return v;
|
||||
}
|
||||
});
|
||||
|
||||
const range = getMinMaxAndDelta(sparkline.y);
|
||||
sparkline.y.config.min = range.min;
|
||||
sparkline.y.config.max = range.max;
|
||||
sparkline.y.state = { range };
|
||||
sparkline.timeRange = timeRange;
|
||||
|
||||
const cellOptions = getTableSparklineCellOptions(field);
|
||||
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
color: field.config.color,
|
||||
custom: {
|
||||
...defaultSparklineCellConfig,
|
||||
...cellOptions,
|
||||
},
|
||||
};
|
||||
|
||||
const hideValue = field.config.custom?.cellOptions?.hideValue;
|
||||
let valueWidth = 0;
|
||||
let valueElement: React.ReactNode = null;
|
||||
if (!hideValue) {
|
||||
const newValue = isDataFrameWithValue(value) ? value.value : null;
|
||||
const displayValue = field.display!(newValue);
|
||||
const alignmentFactor = getAlignmentFactor(field, displayValue, rowIdx!);
|
||||
|
||||
valueWidth =
|
||||
measureText(`${alignmentFactor.prefix ?? ''}${alignmentFactor.text}${alignmentFactor.suffix ?? ''}`, 16).width +
|
||||
theme.spacing.gridSize;
|
||||
|
||||
valueElement = (
|
||||
<FormattedValueDisplay
|
||||
style={{
|
||||
width: `${valueWidth - theme.spacing.gridSize}px`,
|
||||
textAlign: 'right',
|
||||
marginRight: theme.spacing(1),
|
||||
}}
|
||||
value={displayValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// @TODO update width, height
|
||||
return (
|
||||
<div className={styles.cellContainer}>
|
||||
{valueElement}
|
||||
<Sparkline width={width - valueWidth} height={25} sparkline={sparkline} config={config} theme={theme} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getSparkline(value: unknown): FieldSparkline | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
y: {
|
||||
name: 'test',
|
||||
type: FieldType.number,
|
||||
values: value,
|
||||
config: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isDataFrame(value)) {
|
||||
const timeField = value.fields.find((x) => x.type === FieldType.time);
|
||||
const numberField = value.fields.find((x) => x.type === FieldType.number);
|
||||
|
||||
if (timeField && numberField) {
|
||||
return { x: timeField, y: numberField };
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function getTableSparklineCellOptions(field: Field): TableSparklineCellOptions {
|
||||
let options = getCellOptions(field);
|
||||
if (options.type === TableCellDisplayMode.Auto) {
|
||||
options = { ...options, type: TableCellDisplayMode.Sparkline };
|
||||
}
|
||||
if (options.type === TableCellDisplayMode.Sparkline) {
|
||||
return options;
|
||||
}
|
||||
throw new Error(`Expected options type ${TableCellDisplayMode.Sparkline} but got ${options.type}`);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent | undefined) => ({
|
||||
cellContainer: css({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { ReactNode, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { TableAutoCellOptions, TableCellDisplayMode } from '@grafana/schema';
|
||||
|
||||
import { useStyles2 } from '../../../../themes';
|
||||
import { IconButton } from '../../../IconButton/IconButton';
|
||||
import { TableCellInspectorMode } from '../../TableCellInspector';
|
||||
import { CellColors, TableCellNGProps } from '../types';
|
||||
import { getCellColors, getTextAlign } from '../utils';
|
||||
|
||||
import { ActionsCell } from './ActionsCell';
|
||||
import AutoCell from './AutoCell';
|
||||
import { BarGaugeCell } from './BarGaugeCell';
|
||||
import { DataLinksCell } from './DataLinksCell';
|
||||
import { ImageCell } from './ImageCell';
|
||||
import { JSONCell } from './JSONCell';
|
||||
import { SparklineCell } from './SparklineCell';
|
||||
|
||||
export function TableCellNG(props: TableCellNGProps) {
|
||||
const {
|
||||
field,
|
||||
frame,
|
||||
value,
|
||||
theme,
|
||||
timeRange,
|
||||
height,
|
||||
rowIdx,
|
||||
justifyContent,
|
||||
shouldTextOverflow,
|
||||
setIsInspecting,
|
||||
setContextMenuProps,
|
||||
cellInspect,
|
||||
getActions,
|
||||
rowBg,
|
||||
} = props;
|
||||
|
||||
const { config: fieldConfig } = field;
|
||||
const defaultCellOptions: TableAutoCellOptions = { type: TableCellDisplayMode.Auto };
|
||||
const cellOptions = fieldConfig.custom?.cellOptions ?? defaultCellOptions;
|
||||
const { type: cellType } = cellOptions;
|
||||
|
||||
const isRightAligned = getTextAlign(field) === 'flex-end';
|
||||
const displayValue = field.display!(value);
|
||||
let colors: CellColors = { bgColor: '', textColor: '', bgHoverColor: '' };
|
||||
if (rowBg) {
|
||||
colors = rowBg(rowIdx);
|
||||
} else {
|
||||
colors = useMemo(() => getCellColors(theme, cellOptions, displayValue), [theme, cellOptions, displayValue]);
|
||||
}
|
||||
const styles = useStyles2(getStyles, isRightAligned, colors);
|
||||
|
||||
// TODO
|
||||
// TableNG provides either an overridden cell width or 'auto' as the cell width value.
|
||||
// While the overridden value gives the exact cell width, 'auto' does not.
|
||||
// Therefore, we need to determine the actual cell width from the DOM.
|
||||
const divWidthRef = useRef<HTMLDivElement>(null);
|
||||
const [divWidth, setDivWidth] = useState(0);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const actions = getActions ? getActions(frame, field, rowIdx) : [];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (divWidthRef.current && divWidthRef.current.clientWidth !== 0) {
|
||||
setDivWidth(divWidthRef.current.clientWidth);
|
||||
}
|
||||
}, [divWidthRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Get the correct cell type
|
||||
let cell: ReactNode = null;
|
||||
switch (cellType) {
|
||||
case TableCellDisplayMode.Sparkline:
|
||||
cell = (
|
||||
<SparklineCell
|
||||
value={value}
|
||||
field={field}
|
||||
theme={theme}
|
||||
timeRange={timeRange}
|
||||
width={divWidth}
|
||||
rowIdx={rowIdx}
|
||||
justifyContent={justifyContent}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case TableCellDisplayMode.Gauge:
|
||||
case TableCellDisplayMode.BasicGauge:
|
||||
case TableCellDisplayMode.GradientGauge:
|
||||
case TableCellDisplayMode.LcdGauge:
|
||||
cell = (
|
||||
<BarGaugeCell
|
||||
value={value}
|
||||
field={field}
|
||||
theme={theme}
|
||||
timeRange={timeRange}
|
||||
height={height}
|
||||
width={divWidth}
|
||||
rowIdx={rowIdx}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case TableCellDisplayMode.Image:
|
||||
cell = (
|
||||
<ImageCell
|
||||
cellOptions={cellOptions}
|
||||
field={field}
|
||||
height={height}
|
||||
justifyContent={justifyContent}
|
||||
value={value}
|
||||
rowIdx={rowIdx}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case TableCellDisplayMode.JSONView:
|
||||
cell = <JSONCell value={value} justifyContent={justifyContent} field={field} rowIdx={rowIdx} />;
|
||||
break;
|
||||
case TableCellDisplayMode.DataLinks:
|
||||
cell = <DataLinksCell field={field} rowIdx={rowIdx} />;
|
||||
break;
|
||||
case TableCellDisplayMode.Actions:
|
||||
cell = <ActionsCell actions={actions} />;
|
||||
break;
|
||||
case TableCellDisplayMode.Auto:
|
||||
default:
|
||||
cell = (
|
||||
<AutoCell
|
||||
value={value}
|
||||
field={field}
|
||||
justifyContent={justifyContent}
|
||||
rowIdx={rowIdx}
|
||||
cellOptions={cellOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
if (shouldTextOverflow()) {
|
||||
// TODO: The table cell styles in TableNG do not update dynamically even if we change the state
|
||||
const div = divWidthRef.current;
|
||||
const tableCellDiv = div?.parentElement;
|
||||
tableCellDiv?.style.setProperty('position', 'absolute');
|
||||
tableCellDiv?.style.setProperty('top', '0');
|
||||
tableCellDiv?.style.setProperty('z-index', String(theme.zIndex.tooltip));
|
||||
tableCellDiv?.style.setProperty('white-space', 'normal');
|
||||
tableCellDiv?.style.setProperty('min-height', `${height}px`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovered(false);
|
||||
if (shouldTextOverflow()) {
|
||||
// TODO: The table cell styles in TableNG do not update dynamically even if we change the state
|
||||
const div = divWidthRef.current;
|
||||
const tableCellDiv = div?.parentElement;
|
||||
tableCellDiv?.style.setProperty('position', 'relative');
|
||||
tableCellDiv?.style.removeProperty('top');
|
||||
tableCellDiv?.style.removeProperty('z-index');
|
||||
tableCellDiv?.style.setProperty('white-space', 'nowrap');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={divWidthRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className={styles.cell}>
|
||||
{cell}
|
||||
{cellInspect && isHovered && (
|
||||
<div className={styles.cellActions}>
|
||||
<IconButton
|
||||
name="eye"
|
||||
tooltip="Inspect value"
|
||||
onClick={() => {
|
||||
setContextMenuProps({
|
||||
value: String(value ?? ''),
|
||||
mode:
|
||||
cellType === TableCellDisplayMode.JSONView
|
||||
? TableCellInspectorMode.code
|
||||
: TableCellInspectorMode.text,
|
||||
});
|
||||
setIsInspecting(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, isRightAligned: boolean, color: CellColors) => ({
|
||||
cell: css({
|
||||
height: '100%',
|
||||
alignContent: 'center',
|
||||
paddingInline: '8px',
|
||||
// TODO: follow-up on this: change styles on hover on table row level
|
||||
background: color.bgColor || 'none',
|
||||
color: color.textColor,
|
||||
'&:hover': { background: color.bgHoverColor },
|
||||
}),
|
||||
cellActions: css({
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
top: '1px',
|
||||
left: isRightAligned ? 0 : undefined,
|
||||
right: isRightAligned ? undefined : 0,
|
||||
margin: 'auto',
|
||||
height: '100%',
|
||||
background: theme.colors.background.secondary,
|
||||
color: theme.colors.text.primary,
|
||||
padding: '4px 0px 4px 4px',
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { useStyles2 } from '../../../../themes';
|
||||
import { Icon } from '../../../Icon/Icon';
|
||||
import { Popover } from '../../../Tooltip/Popover';
|
||||
import { TableRow } from '../types';
|
||||
|
||||
import { REGEX_OPERATOR } from './FilterList';
|
||||
import { FilterPopup } from './FilterPopup';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
rows: any[];
|
||||
filter: any;
|
||||
setFilter: (value: any) => void;
|
||||
field?: Field;
|
||||
crossFilterOrder: string[];
|
||||
crossFilterRows: { [key: string]: TableRow[] };
|
||||
}
|
||||
|
||||
export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder, crossFilterRows }: Props) => {
|
||||
const filterValue = filter[name]?.filtered;
|
||||
|
||||
// get rows for cross filtering
|
||||
const filterIndex = crossFilterOrder.indexOf(name);
|
||||
let filteredRows: TableRow[];
|
||||
if (filterIndex > 0) {
|
||||
// current filter list should be based on the previous filter list
|
||||
const previousFilterName = crossFilterOrder[filterIndex - 1];
|
||||
filteredRows = crossFilterRows[previousFilterName];
|
||||
} else if (filterIndex === -1 && crossFilterOrder.length > 0) {
|
||||
// current filter list should be based on the last filter list
|
||||
const previousFilterName = crossFilterOrder[crossFilterOrder.length - 1];
|
||||
filteredRows = crossFilterRows[previousFilterName];
|
||||
} else {
|
||||
filteredRows = rows;
|
||||
}
|
||||
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [isPopoverVisible, setPopoverVisible] = useState<boolean>(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
const filterEnabled = useMemo(() => Boolean(filterValue), [filterValue]);
|
||||
const onShowPopover = useCallback(() => setPopoverVisible(true), [setPopoverVisible]);
|
||||
const onClosePopover = useCallback(() => setPopoverVisible(false), [setPopoverVisible]);
|
||||
const [searchFilter, setSearchFilter] = useState(filter[name]?.searchFilter || '');
|
||||
const [operator, setOperator] = useState<SelectableValue<string>>(filter[name]?.operator || REGEX_OPERATOR);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cx(styles.headerFilter, filterEnabled ? styles.filterIconEnabled : styles.filterIconDisabled)}
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={onShowPopover}
|
||||
>
|
||||
<Icon name="filter" />
|
||||
{isPopoverVisible && ref.current && (
|
||||
<Popover
|
||||
content={
|
||||
<FilterPopup
|
||||
name={name}
|
||||
rows={filteredRows}
|
||||
filterValue={filterValue}
|
||||
setFilter={setFilter}
|
||||
field={field}
|
||||
onClose={onClosePopover}
|
||||
searchFilter={searchFilter}
|
||||
setSearchFilter={setSearchFilter}
|
||||
operator={operator}
|
||||
setOperator={setOperator}
|
||||
/>
|
||||
}
|
||||
placement="bottom-start"
|
||||
referenceElement={ref.current}
|
||||
show
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
headerFilter: css({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
label: 'headerFilter',
|
||||
padding: 0,
|
||||
}),
|
||||
filterIconEnabled: css({
|
||||
label: 'filterIconEnabled',
|
||||
color: theme.colors.primary.text,
|
||||
}),
|
||||
filterIconDisabled: css({
|
||||
label: 'filterIconDisabled',
|
||||
color: theme.colors.text.disabled,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import * as React from 'react';
|
||||
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
|
||||
|
||||
import { GrafanaTheme2, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { ButtonSelect, Checkbox, FilterInput, Label, Stack } from '../../..';
|
||||
import { useStyles2, useTheme2 } from '../../../../themes';
|
||||
import { Trans } from '../../../../utils/i18n';
|
||||
|
||||
interface Props {
|
||||
values: SelectableValue[];
|
||||
options: SelectableValue[];
|
||||
onChange: (options: SelectableValue[]) => void;
|
||||
caseSensitive?: boolean;
|
||||
showOperators?: boolean;
|
||||
searchFilter: string;
|
||||
setSearchFilter: (value: string) => void;
|
||||
operator: SelectableValue<string>;
|
||||
setOperator: (item: SelectableValue<string>) => void;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 28;
|
||||
const MIN_HEIGHT = ITEM_HEIGHT * 5;
|
||||
|
||||
const operatorSelectableValues: { [key: string]: SelectableValue<string> } = {
|
||||
Contains: { label: 'Contains', value: 'Contains', description: 'Contains' },
|
||||
'=': { label: '=', value: '=', description: 'Equals' },
|
||||
'!=': { label: '!=', value: '!=', description: 'Not equals' },
|
||||
'>': { label: '>', value: '>', description: 'Greater' },
|
||||
'>=': { label: '>=', value: '>=', description: 'Greater or Equal' },
|
||||
'<': { label: '<', value: '<', description: 'Less' },
|
||||
'<=': { label: '<=', value: '<=', description: 'Less or Equal' },
|
||||
Expression: {
|
||||
label: 'Expression',
|
||||
value: 'Expression',
|
||||
description: 'Bool Expression (Char $ represents the column value in the expression, e.g. "$ >= 10 && $ <= 12")',
|
||||
},
|
||||
};
|
||||
const OPERATORS = Object.values(operatorSelectableValues);
|
||||
export const REGEX_OPERATOR = operatorSelectableValues['Contains'];
|
||||
const XPR_OPERATOR = operatorSelectableValues['Expression'];
|
||||
|
||||
const comparableValue = (value: string): string | number | Date | boolean => {
|
||||
value = value.trim().replace(/\\/g, '');
|
||||
|
||||
// Does it look like a Date (Starting with pattern YYYY-MM-DD* or YYYY/MM/DD*)?
|
||||
if (/^(\d{4}-\d{2}-\d{2}|\d{4}\/\d{2}\/\d{2})/.test(value)) {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const fmt = getValueFormat('dateTimeAsIso');
|
||||
return formattedValueToString(fmt(date.getTime()));
|
||||
}
|
||||
}
|
||||
// Does it look like a Number?
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) {
|
||||
return num;
|
||||
}
|
||||
// Does it look like a Bool?
|
||||
const lvalue = value.toLowerCase();
|
||||
if (lvalue === 'true' || lvalue === 'false') {
|
||||
return lvalue === 'true';
|
||||
}
|
||||
// Anything else
|
||||
return value;
|
||||
};
|
||||
|
||||
export const FilterList = ({
|
||||
options,
|
||||
values,
|
||||
caseSensitive,
|
||||
showOperators,
|
||||
onChange,
|
||||
searchFilter,
|
||||
setSearchFilter,
|
||||
operator,
|
||||
setOperator,
|
||||
}: Props) => {
|
||||
const regex = useMemo(() => new RegExp(searchFilter, caseSensitive ? undefined : 'i'), [searchFilter, caseSensitive]);
|
||||
const items = useMemo(
|
||||
() =>
|
||||
options.filter((option) => {
|
||||
if (!showOperators || !searchFilter || operator.value === REGEX_OPERATOR.value) {
|
||||
if (option.label === undefined) {
|
||||
return false;
|
||||
}
|
||||
return regex.test(option.label);
|
||||
} else if (operator.value === XPR_OPERATOR.value) {
|
||||
if (option.value === undefined) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const xpr = searchFilter.replace(/\\/g, '');
|
||||
const fnc = new Function('$', `'use strict'; return ${xpr};`);
|
||||
const val = comparableValue(option.value);
|
||||
return fnc(val);
|
||||
} catch (_) {}
|
||||
return false;
|
||||
} else {
|
||||
if (option.value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value1 = comparableValue(option.value);
|
||||
const value2 = comparableValue(searchFilter);
|
||||
|
||||
switch (operator.value) {
|
||||
case '=':
|
||||
return value1 === value2;
|
||||
case '!=':
|
||||
return value1 !== value2;
|
||||
case '>':
|
||||
return value1 > value2;
|
||||
case '>=':
|
||||
return value1 >= value2;
|
||||
case '<':
|
||||
return value1 < value2;
|
||||
case '<=':
|
||||
return value1 <= value2;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
[options, regex, showOperators, operator, searchFilter]
|
||||
);
|
||||
const selectedItems = useMemo(() => items.filter((item) => values.includes(item)), [items, values]);
|
||||
|
||||
const selectCheckValue = useMemo(() => items.length === selectedItems.length, [items, selectedItems]);
|
||||
const selectCheckIndeterminate = useMemo(
|
||||
() => selectedItems.length > 0 && items.length > selectedItems.length,
|
||||
[items, selectedItems]
|
||||
);
|
||||
const selectCheckLabel = useMemo(
|
||||
() => (selectedItems.length ? `${selectedItems.length} selected` : `Select all`),
|
||||
[selectedItems]
|
||||
);
|
||||
const selectCheckDescription = useMemo(
|
||||
() =>
|
||||
items.length !== selectedItems.length
|
||||
? 'Add all displayed values to the filter'
|
||||
: 'Remove all displayed values from the filter',
|
||||
[items, selectedItems]
|
||||
);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
const gutter = theme.spacing.gridSize;
|
||||
const height = useMemo(() => Math.min(items.length * ITEM_HEIGHT, MIN_HEIGHT) + gutter, [gutter, items.length]);
|
||||
|
||||
const onCheckedChanged = useCallback(
|
||||
(option: SelectableValue) => (event: React.FormEvent<HTMLInputElement>) => {
|
||||
const newValues = event.currentTarget.checked
|
||||
? values.concat(option)
|
||||
: values.filter((c) => c.value !== option.value);
|
||||
|
||||
onChange(newValues);
|
||||
},
|
||||
[onChange, values]
|
||||
);
|
||||
|
||||
const onSelectChanged = useCallback(() => {
|
||||
if (items.length === selectedItems.length) {
|
||||
const newValues = values.filter((item) => !items.includes(item));
|
||||
onChange(newValues);
|
||||
} else {
|
||||
const newValues = [...new Set([...values, ...items])];
|
||||
onChange(newValues);
|
||||
}
|
||||
}, [onChange, values, items, selectedItems]);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={0.25}>
|
||||
{!showOperators && <FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} />}
|
||||
{showOperators && (
|
||||
<Stack direction="row" gap={0}>
|
||||
<ButtonSelect
|
||||
variant="canvas"
|
||||
options={OPERATORS}
|
||||
onChange={setOperator}
|
||||
value={operator}
|
||||
tooltip={operator.description}
|
||||
/>
|
||||
<FilterInput placeholder="Filter values" onChange={setSearchFilter} value={searchFilter} />
|
||||
</Stack>
|
||||
)}
|
||||
{items.length > 0 ? (
|
||||
<>
|
||||
<List
|
||||
height={height}
|
||||
itemCount={items.length}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
itemData={{ items, values: selectedItems, onCheckedChanged, className: styles.filterListRow }}
|
||||
width="100%"
|
||||
className={styles.filterList}
|
||||
>
|
||||
{ItemRenderer}
|
||||
</List>
|
||||
<Stack direction="column" gap={0.25}>
|
||||
<div className={cx(styles.selectDivider)} />
|
||||
<div className={cx(styles.filterListRow)}>
|
||||
<Checkbox
|
||||
value={selectCheckValue}
|
||||
indeterminate={selectCheckIndeterminate}
|
||||
label={selectCheckLabel}
|
||||
description={selectCheckDescription}
|
||||
onChange={onSelectChanged}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
<Label className={styles.noValuesLabel}>
|
||||
<Trans i18nKey="grafana-ui.table.no-values-label">No values</Trans>
|
||||
</Label>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemRendererProps extends ListChildComponentProps {
|
||||
data: {
|
||||
onCheckedChanged: (option: SelectableValue) => (event: React.FormEvent<HTMLInputElement>) => void;
|
||||
items: SelectableValue[];
|
||||
values: SelectableValue[];
|
||||
className: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ItemRenderer({ index, style, data: { onCheckedChanged, items, values, className } }: ItemRendererProps) {
|
||||
const option = items[index];
|
||||
const { value, label } = option;
|
||||
const isChecked = values.find((s) => s.value === value) !== undefined;
|
||||
|
||||
return (
|
||||
<div className={className} style={style} title={label}>
|
||||
<Checkbox value={isChecked} label={label} onChange={onCheckedChanged(option)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
filterList: css({
|
||||
label: 'filterList',
|
||||
}),
|
||||
filterListRow: css({
|
||||
label: 'filterListRow',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: theme.spacing(0.5),
|
||||
|
||||
':hover': {
|
||||
backgroundColor: theme.colors.action.hover,
|
||||
},
|
||||
}),
|
||||
selectDivider: css({
|
||||
label: 'selectDivider',
|
||||
width: '100%',
|
||||
borderTop: `1px solid ${theme.colors.border.medium}`,
|
||||
padding: theme.spacing(0.5, 2),
|
||||
}),
|
||||
noValuesLabel: css({
|
||||
paddingTop: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { Button, ClickOutsideWrapper, IconButton, Label, Stack } from '../../..';
|
||||
import { useStyles2, useTheme2 } from '../../../../themes';
|
||||
import { Trans } from '../../../../utils/i18n';
|
||||
import { FilterType } from '../types';
|
||||
|
||||
import { FilterList } from './FilterList';
|
||||
import { calculateUniqueFieldValues, getFilteredOptions, valuesToOptions } from './utils';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
rows: any[];
|
||||
filterValue: any;
|
||||
setFilter: (value: any) => void;
|
||||
onClose: () => void;
|
||||
field?: Field;
|
||||
searchFilter: string;
|
||||
setSearchFilter: (value: string) => void;
|
||||
operator: SelectableValue<string>;
|
||||
setOperator: (item: SelectableValue<string>) => void;
|
||||
}
|
||||
|
||||
export const FilterPopup = ({
|
||||
name,
|
||||
rows,
|
||||
filterValue,
|
||||
setFilter,
|
||||
onClose,
|
||||
field,
|
||||
searchFilter,
|
||||
setSearchFilter,
|
||||
operator,
|
||||
setOperator,
|
||||
}: Props) => {
|
||||
const theme = useTheme2();
|
||||
const uniqueValues = useMemo(() => calculateUniqueFieldValues(rows, field), [rows, field]);
|
||||
const options = useMemo(() => valuesToOptions(uniqueValues), [uniqueValues]);
|
||||
const filteredOptions = useMemo(() => getFilteredOptions(options, filterValue), [options, filterValue]);
|
||||
const [values, setValues] = useState<SelectableValue[]>(filteredOptions);
|
||||
const [matchCase, setMatchCase] = useState(false);
|
||||
|
||||
const onCancel = useCallback((event?: React.MouseEvent) => onClose(), [onClose]);
|
||||
|
||||
const onFilter = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (values.length !== 0) {
|
||||
// create a Set for faster filtering
|
||||
const filteredSet = new Set(values.map((item) => item.value));
|
||||
|
||||
setFilter((filter: FilterType) => ({
|
||||
...filter,
|
||||
[name]: { filtered: values, filteredSet, searchFilter, operator },
|
||||
}));
|
||||
} else {
|
||||
setFilter((filter: FilterType) => {
|
||||
const newFilter = { ...filter };
|
||||
delete newFilter[name];
|
||||
return newFilter;
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[setFilter, values, onClose] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const onClearFilter = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
setFilter((filter: FilterType) => {
|
||||
const newFilter = { ...filter };
|
||||
delete newFilter[name];
|
||||
return newFilter;
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
[setFilter, onClose] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const clearFilterVisible = useMemo(() => filterValue !== undefined, [filterValue]);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<ClickOutsideWrapper onClick={onCancel} useCapture={true}>
|
||||
{/* This is just blocking click events from bubbeling and should not have a keyboard interaction. */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
|
||||
<div className={cx(styles.filterContainer)} onClick={stopPropagation}>
|
||||
<Stack direction="column" gap={3}>
|
||||
<Stack direction="column" gap={0.5}>
|
||||
<Stack justifyContent="space-between" alignItems="center">
|
||||
<Label className={styles.label}>
|
||||
<Trans i18nKey="grafana-ui.table.filter-popup-heading">Filter by values:</Trans>
|
||||
</Label>
|
||||
<IconButton
|
||||
name="text-fields"
|
||||
tooltip="Match case"
|
||||
style={{ color: matchCase ? theme.colors.text.link : theme.colors.text.disabled }}
|
||||
onClick={() => {
|
||||
setMatchCase((s) => !s);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<div className={cx(styles.listDivider)} />
|
||||
<FilterList
|
||||
onChange={setValues}
|
||||
values={values}
|
||||
options={options}
|
||||
caseSensitive={matchCase}
|
||||
showOperators={true}
|
||||
searchFilter={searchFilter}
|
||||
setSearchFilter={setSearchFilter}
|
||||
operator={operator}
|
||||
setOperator={setOperator}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={3}>
|
||||
<Stack>
|
||||
<Button size="sm" onClick={onFilter}>
|
||||
<Trans i18nKey="grafana-ui.table.filter-popup-apply">Ok</Trans>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onCancel}>
|
||||
<Trans i18nKey="grafana-ui.table.filter-popup-cancel">Cancel</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
{clearFilterVisible && (
|
||||
<Stack>
|
||||
<Button fill="text" size="sm" onClick={onClearFilter}>
|
||||
<Trans i18nKey="grafana-ui.table.filter-popup-clear">Clear filter</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</ClickOutsideWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
filterContainer: css({
|
||||
label: 'filterContainer',
|
||||
width: '100%',
|
||||
minWidth: '250px',
|
||||
height: '100%',
|
||||
maxHeight: '400px',
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
padding: theme.spacing(2),
|
||||
boxShadow: theme.shadows.z3,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
listDivider: css({
|
||||
label: 'listDivider',
|
||||
width: '100%',
|
||||
borderTop: `1px solid ${theme.colors.border.medium}`,
|
||||
padding: theme.spacing(0.5, 2),
|
||||
}),
|
||||
label: css({
|
||||
marginBottom: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const stopPropagation = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Field, formattedValueToString, SelectableValue } from '@grafana/data';
|
||||
|
||||
export function calculateUniqueFieldValues(rows: any[], field?: Field) {
|
||||
if (!field || rows.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const set: Record<string, string> = {};
|
||||
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index];
|
||||
const fieldValue = row[field.name];
|
||||
const displayValue = field.display ? field.display(fieldValue) : fieldValue;
|
||||
const value = field.display ? formattedValueToString(displayValue) : displayValue;
|
||||
|
||||
set[value || '(Blanks)'] = value;
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
export function getFilteredOptions(options: SelectableValue[], filterValues?: SelectableValue[]): SelectableValue[] {
|
||||
if (!filterValues) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return options.filter((option) => filterValues.some((filtered) => filtered.value === option.value));
|
||||
}
|
||||
|
||||
export function valuesToOptions(unique: Record<string, unknown>): SelectableValue[] {
|
||||
return Object.keys(unique)
|
||||
.map((key) => ({ value: unique[key], label: key }))
|
||||
.sort(sortOptions);
|
||||
}
|
||||
|
||||
function sortOptions(a: SelectableValue, b: SelectableValue): number {
|
||||
if (a.label === undefined && b.label === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (a.label === undefined && b.label !== undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.label !== undefined && b.label === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.label! < b.label!) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.label! > b.label!) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
1524
packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx
Normal file
1524
packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
948
packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
Normal file
948
packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx
Normal file
@@ -0,0 +1,948 @@
|
||||
import 'react-data-grid/lib/styles.css';
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo, useState, useLayoutEffect, useCallback, useRef, useEffect } from 'react';
|
||||
import DataGrid, { RenderCellProps, RenderRowProps, Row, SortColumn, DataGridHandle } from 'react-data-grid';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
DataHoverClearEvent,
|
||||
DataHoverEvent,
|
||||
Field,
|
||||
fieldReducers,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getDefaultTimeRange,
|
||||
GrafanaTheme2,
|
||||
ReducerID,
|
||||
} from '@grafana/data';
|
||||
import { TableCellDisplayMode } from '@grafana/schema';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../../themes';
|
||||
import { Trans } from '../../../utils/i18n';
|
||||
import { ContextMenu } from '../../ContextMenu/ContextMenu';
|
||||
import { MenuItem } from '../../Menu/MenuItem';
|
||||
import { Pagination } from '../../Pagination/Pagination';
|
||||
import { PanelContext, usePanelContext } from '../../PanelChrome';
|
||||
import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspector';
|
||||
|
||||
import { HeaderCell } from './Cells/HeaderCell';
|
||||
import { RowExpander } from './Cells/RowExpander';
|
||||
import { TableCellNG } from './Cells/TableCellNG';
|
||||
import { COLUMN, TABLE } from './constants';
|
||||
import {
|
||||
TableNGProps,
|
||||
FilterType,
|
||||
TableRow,
|
||||
TableSummaryRow,
|
||||
ColumnTypes,
|
||||
TableColumnResizeActionCallback,
|
||||
TableColumn,
|
||||
TableFieldOptionsType,
|
||||
ScrollPosition,
|
||||
CellColors,
|
||||
} from './types';
|
||||
import {
|
||||
frameToRecords,
|
||||
getCellColors,
|
||||
getComparator,
|
||||
getDefaultRowHeight,
|
||||
getFooterItemNG,
|
||||
getFooterStyles,
|
||||
getIsNestedTable,
|
||||
getRowHeight,
|
||||
getTextAlign,
|
||||
handleSort,
|
||||
MapFrameToGridOptions,
|
||||
shouldTextOverflow,
|
||||
} from './utils';
|
||||
|
||||
export function TableNG(props: TableNGProps) {
|
||||
const {
|
||||
cellHeight,
|
||||
enablePagination,
|
||||
enableVirtualization = true,
|
||||
fieldConfig,
|
||||
footerOptions,
|
||||
height,
|
||||
noHeader,
|
||||
onColumnResize,
|
||||
width,
|
||||
data,
|
||||
enableSharedCrosshair,
|
||||
showTypeIcons,
|
||||
} = props;
|
||||
|
||||
/* ------------------------------- Local state ------------------------------ */
|
||||
const [revId, setRevId] = useState(0);
|
||||
const [contextMenuProps, setContextMenuProps] = useState<{
|
||||
rowIdx?: number;
|
||||
value: string;
|
||||
mode?: TableCellInspectorMode.code | TableCellInspectorMode.text;
|
||||
top?: number;
|
||||
left?: number;
|
||||
} | null>(null);
|
||||
const [isInspecting, setIsInspecting] = useState(false);
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<FilterType>({});
|
||||
const [page, setPage] = useState(0);
|
||||
// This state will trigger re-render for recalculating row heights
|
||||
const [, setResizeTrigger] = useState(0);
|
||||
const [, setReadyForRowHeightCalc] = useState(false);
|
||||
const [sortColumns, setSortColumns] = useState<readonly SortColumn[]>([]);
|
||||
const [expandedRows, setExpandedRows] = useState<number[]>([]);
|
||||
const [isNestedTable, setIsNestedTable] = useState(false);
|
||||
const scrollPositionRef = useRef<ScrollPosition>({ x: 0, y: 0 });
|
||||
const [hasScroll, setHasScroll] = useState(false);
|
||||
|
||||
/* ------------------------------- Local refs ------------------------------- */
|
||||
const crossFilterOrder = useRef<string[]>([]);
|
||||
const crossFilterRows = useRef<Record<string, TableRow[]>>({});
|
||||
const headerCellRefs = useRef<Record<string, HTMLDivElement>>({});
|
||||
// TODO: This ref persists sortColumns between renders. setSortColumns is still used to trigger re-render
|
||||
const sortColumnsRef = useRef(sortColumns);
|
||||
const prevProps = useRef(props);
|
||||
const calcsRef = useRef<string[]>([]);
|
||||
const [paginationWrapperRef, { height: paginationHeight }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const textWrap = fieldConfig?.defaults?.custom?.cellOptions?.wrapText ?? false;
|
||||
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles, textWrap);
|
||||
const panelContext = usePanelContext();
|
||||
|
||||
const isFooterVisible = Boolean(footerOptions?.show && footerOptions.reducer?.length);
|
||||
const isCountRowsSet = Boolean(
|
||||
footerOptions?.countRows &&
|
||||
footerOptions.reducer &&
|
||||
footerOptions.reducer.length &&
|
||||
footerOptions.reducer[0] === ReducerID.count
|
||||
);
|
||||
const tableRef = useRef<DataGridHandle | null>(null);
|
||||
|
||||
/* --------------------------------- Effects -------------------------------- */
|
||||
useEffect(() => {
|
||||
// TODO: there is a use case when adding a new column to the table doesn't update the table
|
||||
if (
|
||||
prevProps.current.data.fields.length !== props.data.fields.length ||
|
||||
prevProps.current.fieldConfig?.overrides !== fieldConfig?.overrides ||
|
||||
prevProps.current.fieldConfig?.defaults !== fieldConfig?.defaults
|
||||
) {
|
||||
setRevId(revId + 1);
|
||||
}
|
||||
prevProps.current = props;
|
||||
}, [props, revId, fieldConfig?.overrides, fieldConfig?.defaults]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isContextMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
setIsContextMenuOpen(false);
|
||||
}
|
||||
|
||||
addEventListener('click', onClick);
|
||||
|
||||
return () => {
|
||||
removeEventListener('click', onClick);
|
||||
};
|
||||
}, [isContextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasNestedFrames = getIsNestedTable(props.data);
|
||||
setIsNestedTable(hasNestedFrames);
|
||||
}, [props.data]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = tableRef.current;
|
||||
if (el) {
|
||||
const gridElement = el?.element;
|
||||
if (gridElement) {
|
||||
setHasScroll(
|
||||
gridElement.scrollHeight > gridElement.clientHeight || gridElement.scrollWidth > gridElement.clientWidth
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// TODO: this is a hack to force the column width to update when the fieldConfig changes
|
||||
const columnWidth = useMemo(() => {
|
||||
setRevId(revId + 1);
|
||||
return fieldConfig?.defaults?.custom?.width || 'auto';
|
||||
}, [fieldConfig]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Create off-screen canvas for measuring rows for virtualized rendering
|
||||
// This line is like this because Jest doesn't have OffscreenCanvas mocked
|
||||
// nor is it a part of the jest-canvas-mock package
|
||||
let osContext = null;
|
||||
if (window.OffscreenCanvas !== undefined) {
|
||||
// The canvas size is defined arbitrarily
|
||||
// As we never actually visualize rendered content
|
||||
// from the offscreen canvas, only perform text measurements
|
||||
osContext = new OffscreenCanvas(256, 1024).getContext('2d');
|
||||
}
|
||||
|
||||
// Set font property using theme info
|
||||
// This will make text measurement accurate
|
||||
if (osContext !== undefined && osContext !== null) {
|
||||
osContext.font = `${theme.typography.fontSize}px ${theme.typography.body.fontFamily}`;
|
||||
}
|
||||
|
||||
const defaultRowHeight = getDefaultRowHeight(theme, cellHeight);
|
||||
const defaultLineHeight = theme.typography.body.lineHeight * theme.typography.fontSize;
|
||||
const panelPaddingHeight = theme.components.panel.padding * theme.spacing.gridSize * 2;
|
||||
|
||||
/* ------------------------------ Rows & Columns ----------------------------- */
|
||||
const rows = useMemo(() => frameToRecords(props.data), [frameToRecords, props.data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Create a map of column key to column type
|
||||
const columnTypes = useMemo(() => {
|
||||
return props.data.fields.reduce((acc, field) => {
|
||||
acc[field.name] = field.type;
|
||||
return acc;
|
||||
}, {} as ColumnTypes);
|
||||
}, [props.data.fields]);
|
||||
|
||||
const getDisplayedValue = (row: TableRow, key: string) => {
|
||||
const field = props.data.fields.find((field) => field.name === key)!;
|
||||
const displayedValue = formattedValueToString(field.display!(row[key]));
|
||||
return displayedValue;
|
||||
};
|
||||
|
||||
// Filter rows
|
||||
const filteredRows = useMemo(() => {
|
||||
const filterValues = Object.entries(filter);
|
||||
if (filterValues.length === 0) {
|
||||
// reset cross filter order
|
||||
crossFilterOrder.current = [];
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Update crossFilterOrder
|
||||
const filterKeys = new Set(filterValues.map(([key]) => key));
|
||||
filterKeys.forEach((key) => {
|
||||
if (!crossFilterOrder.current.includes(key)) {
|
||||
// Each time a filter is added or removed, it is always a single filter.
|
||||
// When adding a new filter, it is always appended to the end, maintaining the order.
|
||||
crossFilterOrder.current.push(key);
|
||||
}
|
||||
});
|
||||
// Remove keys from crossFilterOrder that are no longer present in the current filter values
|
||||
crossFilterOrder.current = crossFilterOrder.current.filter((key) => filterKeys.has(key));
|
||||
|
||||
// reset crossFilterRows
|
||||
crossFilterRows.current = {};
|
||||
|
||||
return rows.filter((row) => {
|
||||
for (const [key, value] of filterValues) {
|
||||
const displayedValue = getDisplayedValue(row, key);
|
||||
if (!value.filteredSet.has(displayedValue)) {
|
||||
return false;
|
||||
}
|
||||
// collect rows for crossFilter
|
||||
if (!crossFilterRows.current[key]) {
|
||||
crossFilterRows.current[key] = [row];
|
||||
} else {
|
||||
crossFilterRows.current[key].push(row);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [rows, filter, props.data.fields]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Sort rows
|
||||
const sortedRows = useMemo(() => {
|
||||
const comparators = sortColumns.map(({ columnKey }) => getComparator(columnTypes[columnKey]));
|
||||
const sortDirs = sortColumns.map(({ direction }) => (direction === 'ASC' ? 1 : -1));
|
||||
|
||||
if (sortColumns.length === 0) {
|
||||
return filteredRows;
|
||||
}
|
||||
|
||||
return filteredRows.slice().sort((a, b) => {
|
||||
let result = 0;
|
||||
let sortIndex = 0;
|
||||
|
||||
for (const { columnKey } of sortColumns) {
|
||||
const compare = comparators[sortIndex];
|
||||
result = sortDirs[sortIndex] * compare(a[columnKey], b[columnKey]);
|
||||
|
||||
if (result !== 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
sortIndex += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}, [filteredRows, sortColumns, columnTypes]);
|
||||
|
||||
// Paginated rows
|
||||
// TODO consolidate calculations into pagination wrapper component and only use when needed
|
||||
const numRows = sortedRows.length;
|
||||
// calculate number of rowsPerPage based on height stack
|
||||
let headerCellHeight = TABLE.MAX_CELL_HEIGHT;
|
||||
if (noHeader) {
|
||||
headerCellHeight = 0;
|
||||
} else if (!noHeader && Object.keys(headerCellRefs.current).length > 0) {
|
||||
headerCellHeight = headerCellRefs.current[Object.keys(headerCellRefs.current)[0]].getBoundingClientRect().height;
|
||||
}
|
||||
let rowsPerPage = Math.floor(
|
||||
(height - headerCellHeight - TABLE.SCROLL_BAR_WIDTH - paginationHeight - panelPaddingHeight) / defaultRowHeight
|
||||
);
|
||||
// if footer calcs are on, remove one row per page
|
||||
if (isFooterVisible) {
|
||||
rowsPerPage -= 1;
|
||||
}
|
||||
if (rowsPerPage < 1) {
|
||||
// avoid 0 or negative rowsPerPage
|
||||
rowsPerPage = 1;
|
||||
}
|
||||
const numberOfPages = Math.ceil(numRows / rowsPerPage);
|
||||
if (page > numberOfPages) {
|
||||
// resets pagination to end
|
||||
setPage(numberOfPages - 1);
|
||||
}
|
||||
// calculate row range for pagination summary display
|
||||
const itemsRangeStart = page * rowsPerPage + 1;
|
||||
let displayedEnd = itemsRangeStart + rowsPerPage - 1;
|
||||
if (displayedEnd > numRows) {
|
||||
displayedEnd = numRows;
|
||||
}
|
||||
const smallPagination = width < TABLE.PAGINATION_LIMIT;
|
||||
|
||||
const paginatedRows = useMemo(() => {
|
||||
const pageOffset = page * rowsPerPage;
|
||||
return sortedRows.slice(pageOffset, pageOffset + rowsPerPage);
|
||||
}, [rows, sortedRows, page, rowsPerPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useMemo(() => {
|
||||
calcsRef.current = props.data.fields.map((field, index) => {
|
||||
if (field.state?.calcs) {
|
||||
delete field.state?.calcs;
|
||||
}
|
||||
if (isCountRowsSet) {
|
||||
return index === 0 ? `${sortedRows.length}` : '';
|
||||
}
|
||||
if (index === 0) {
|
||||
const footerCalcReducer = footerOptions?.reducer?.[0];
|
||||
return footerCalcReducer ? fieldReducers.get(footerCalcReducer).name : '';
|
||||
}
|
||||
return getFooterItemNG(sortedRows, field, footerOptions);
|
||||
});
|
||||
}, [sortedRows, props.data.fields, footerOptions, isCountRowsSet]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onCellExpand = (rowIdx: number) => {
|
||||
if (!expandedRows.includes(rowIdx)) {
|
||||
setExpandedRows([...expandedRows, rowIdx]);
|
||||
} else {
|
||||
const currentExpandedRows = expandedRows;
|
||||
const indexToRemove = currentExpandedRows.indexOf(rowIdx);
|
||||
if (indexToRemove > -1) {
|
||||
currentExpandedRows.splice(indexToRemove, 1);
|
||||
setExpandedRows(currentExpandedRows);
|
||||
}
|
||||
}
|
||||
setResizeTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
mapFrameToDataGrid({
|
||||
frame: props.data,
|
||||
calcsRef,
|
||||
options: {
|
||||
columnTypes,
|
||||
columnWidth,
|
||||
crossFilterOrder,
|
||||
crossFilterRows,
|
||||
defaultLineHeight,
|
||||
defaultRowHeight,
|
||||
expandedRows,
|
||||
filter,
|
||||
headerCellRefs,
|
||||
isCountRowsSet,
|
||||
osContext,
|
||||
// INFO: sortedRows is for correct row indexing for cell background coloring
|
||||
rows: sortedRows,
|
||||
setContextMenuProps,
|
||||
setFilter,
|
||||
setIsInspecting,
|
||||
setSortColumns,
|
||||
sortColumnsRef,
|
||||
styles,
|
||||
textWrap,
|
||||
theme,
|
||||
showTypeIcons,
|
||||
...props,
|
||||
},
|
||||
handlers: {
|
||||
onCellExpand,
|
||||
onColumnResize: onColumnResize!,
|
||||
},
|
||||
// Adjust table width to account for the scroll bar width
|
||||
availableWidth: width - (hasScroll ? TABLE.SCROLL_BAR_WIDTH + TABLE.SCROLL_BAR_MARGIN : 0),
|
||||
}),
|
||||
[props.data, calcsRef, filter, expandedRows, expandedRows.length, footerOptions, width, hasScroll, sortedRows] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
// This effect needed to set header cells refs before row height calculation
|
||||
useLayoutEffect(() => {
|
||||
setReadyForRowHeightCalc(Object.keys(headerCellRefs.current).length > 0);
|
||||
}, [columns]);
|
||||
|
||||
const renderMenuItems = () => {
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
label="Inspect value"
|
||||
onClick={() => {
|
||||
setIsInspecting(true);
|
||||
}}
|
||||
className={styles.menuItem}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const calculateRowHeight = useCallback(
|
||||
(row: TableRow) => {
|
||||
// Logic for sub-tables
|
||||
if (Number(row.__depth) === 1 && !expandedRows.includes(Number(row.__index))) {
|
||||
return 0;
|
||||
} else if (Number(row.__depth) === 1 && expandedRows.includes(Number(row.__index))) {
|
||||
const headerCount = row?.data?.meta?.custom?.noHeader ? 0 : 1;
|
||||
return defaultRowHeight * (row.data?.length ?? 0 + headerCount); // TODO this probably isn't very robust
|
||||
}
|
||||
return getRowHeight(
|
||||
row,
|
||||
columnTypes,
|
||||
headerCellRefs,
|
||||
osContext,
|
||||
defaultLineHeight,
|
||||
defaultRowHeight,
|
||||
TABLE.CELL_PADDING
|
||||
);
|
||||
},
|
||||
[expandedRows, defaultRowHeight, columnTypes, headerCellRefs, osContext, defaultLineHeight]
|
||||
);
|
||||
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLDivElement;
|
||||
scrollPositionRef.current = {
|
||||
x: target.scrollLeft,
|
||||
y: target.scrollTop,
|
||||
};
|
||||
};
|
||||
|
||||
// Restore scroll position after re-renders
|
||||
useEffect(() => {
|
||||
if (tableRef.current?.element) {
|
||||
tableRef.current.element.scrollLeft = scrollPositionRef.current.x;
|
||||
tableRef.current.element.scrollTop = scrollPositionRef.current.y;
|
||||
}
|
||||
}, [revId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGrid<TableRow, TableSummaryRow>
|
||||
ref={tableRef}
|
||||
className={styles.dataGrid}
|
||||
// Default to true, overridden to false for testing
|
||||
enableVirtualization={enableVirtualization}
|
||||
key={`DataGrid${revId}`}
|
||||
rows={enablePagination ? paginatedRows : sortedRows}
|
||||
columns={columns}
|
||||
headerRowHeight={noHeader ? 0 : undefined}
|
||||
defaultColumnOptions={{
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
}}
|
||||
rowHeight={textWrap || isNestedTable ? calculateRowHeight : defaultRowHeight}
|
||||
// TODO: This doesn't follow current table behavior
|
||||
style={{ width, height: height - (enablePagination ? paginationHeight : 0) }}
|
||||
renderers={{
|
||||
renderRow: (key, props) =>
|
||||
myRowRenderer(key, props, expandedRows, panelContext, data, enableSharedCrosshair ?? false),
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
onCellContextMenu={({ row, column }, event) => {
|
||||
event.preventGridDefault();
|
||||
// Do not show the default context menu
|
||||
event.preventDefault();
|
||||
|
||||
const cellValue = row[column.key];
|
||||
setContextMenuProps({
|
||||
// rowIdx: rows.indexOf(row),
|
||||
value: String(cellValue ?? ''),
|
||||
top: event.clientY,
|
||||
left: event.clientX,
|
||||
});
|
||||
setIsContextMenuOpen(true);
|
||||
}}
|
||||
// sorting
|
||||
sortColumns={sortColumns}
|
||||
// footer
|
||||
// TODO figure out exactly how this works - some array needs to be here for it to render regardless of renderSummaryCell()
|
||||
bottomSummaryRows={isFooterVisible ? [{}] : undefined}
|
||||
onColumnResize={() => {
|
||||
// NOTE: This method is called continuously during the column resize drag operation,
|
||||
// providing the current column width. There is no separate event for the end of the drag operation.
|
||||
if (textWrap) {
|
||||
// This is needed only when textWrap is enabled
|
||||
// TODO: this is a hack to force rowHeight re-calculation
|
||||
setResizeTrigger((prev) => prev + 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{enablePagination && (
|
||||
<div className={styles.paginationContainer} ref={paginationWrapperRef}>
|
||||
<Pagination
|
||||
className="table-ng-pagination"
|
||||
currentPage={page + 1}
|
||||
numberOfPages={numberOfPages}
|
||||
showSmallVersion={smallPagination}
|
||||
onNavigate={(toPage) => {
|
||||
setPage(toPage - 1);
|
||||
}}
|
||||
/>
|
||||
{!smallPagination && (
|
||||
<div className={styles.paginationSummary}>
|
||||
<Trans i18nKey="grafana-ui.table.pagination-summary">
|
||||
{{ itemsRangeStart }} - {{ displayedEnd }} of {{ numRows }} rows
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isContextMenuOpen && (
|
||||
<ContextMenu
|
||||
x={contextMenuProps?.left || 0}
|
||||
y={contextMenuProps?.top || 0}
|
||||
renderMenuItems={renderMenuItems}
|
||||
focusOnOpen={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isInspecting && (
|
||||
<TableCellInspector
|
||||
mode={contextMenuProps?.mode ?? TableCellInspectorMode.text}
|
||||
value={contextMenuProps?.value}
|
||||
onDismiss={() => {
|
||||
setIsInspecting(false);
|
||||
setContextMenuProps(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function mapFrameToDataGrid({
|
||||
frame,
|
||||
calcsRef,
|
||||
options,
|
||||
handlers,
|
||||
availableWidth,
|
||||
}: {
|
||||
frame: DataFrame;
|
||||
calcsRef: React.MutableRefObject<string[]>;
|
||||
options: MapFrameToGridOptions;
|
||||
handlers: { onCellExpand: (rowIdx: number) => void; onColumnResize: TableColumnResizeActionCallback };
|
||||
availableWidth: number;
|
||||
}): TableColumn[] {
|
||||
const {
|
||||
columnTypes,
|
||||
crossFilterOrder,
|
||||
crossFilterRows,
|
||||
defaultLineHeight,
|
||||
defaultRowHeight,
|
||||
expandedRows,
|
||||
filter,
|
||||
headerCellRefs,
|
||||
isCountRowsSet,
|
||||
osContext,
|
||||
rows,
|
||||
setContextMenuProps,
|
||||
setFilter,
|
||||
setIsInspecting,
|
||||
setSortColumns,
|
||||
sortColumnsRef,
|
||||
styles,
|
||||
textWrap,
|
||||
fieldConfig,
|
||||
theme,
|
||||
timeRange,
|
||||
getActions,
|
||||
showTypeIcons,
|
||||
} = options;
|
||||
const { onCellExpand, onColumnResize } = handlers;
|
||||
|
||||
const columns: TableColumn[] = [];
|
||||
const hasNestedFrames = getIsNestedTable(frame);
|
||||
|
||||
const cellInspect = fieldConfig?.defaults?.custom?.inspect ?? false;
|
||||
const filterable = fieldConfig?.defaults?.custom?.filterable ?? false;
|
||||
|
||||
// If nested frames, add expansion control column
|
||||
if (hasNestedFrames) {
|
||||
const expanderField: Field = {
|
||||
name: '',
|
||||
type: FieldType.other,
|
||||
config: {},
|
||||
values: [],
|
||||
};
|
||||
columns.push({
|
||||
key: 'expanded',
|
||||
name: '',
|
||||
field: expanderField,
|
||||
cellClass: styles.cell,
|
||||
colSpan(args) {
|
||||
return args.type === 'ROW' && Number(args.row.__depth) === 1 ? frame.fields.length : 1;
|
||||
},
|
||||
renderCell: ({ row }) => {
|
||||
// TODO add TableRow type extension to include row depth and optional data
|
||||
if (Number(row.__depth) === 0) {
|
||||
const rowIdx = Number(row.__index);
|
||||
return (
|
||||
<RowExpander
|
||||
height={defaultRowHeight}
|
||||
onCellExpand={() => onCellExpand(rowIdx)}
|
||||
isExpanded={expandedRows.includes(rowIdx)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// If it's a child, render entire DataGrid at first column position
|
||||
let expandedColumns: TableColumn[] = [];
|
||||
let expandedRecords: TableRow[] = [];
|
||||
|
||||
// Type guard to check if data exists as it's optional
|
||||
if (row.data) {
|
||||
expandedColumns = mapFrameToDataGrid({
|
||||
frame: row.data,
|
||||
calcsRef,
|
||||
options: { ...options },
|
||||
handlers: { onCellExpand, onColumnResize },
|
||||
availableWidth: availableWidth - COLUMN.EXPANDER_WIDTH,
|
||||
});
|
||||
expandedRecords = frameToRecords(row.data);
|
||||
}
|
||||
|
||||
// TODO add renderHeaderCell HeaderCell's here and handle all features
|
||||
return (
|
||||
<DataGrid<TableRow, TableSummaryRow>
|
||||
rows={expandedRecords}
|
||||
columns={expandedColumns}
|
||||
rowHeight={defaultRowHeight}
|
||||
style={{ height: '100%', overflow: 'visible', marginLeft: COLUMN.EXPANDER_WIDTH }}
|
||||
headerRowHeight={row.data?.meta?.custom?.noHeader ? 0 : undefined}
|
||||
/>
|
||||
);
|
||||
},
|
||||
width: COLUMN.EXPANDER_WIDTH,
|
||||
minWidth: COLUMN.EXPANDER_WIDTH,
|
||||
});
|
||||
|
||||
availableWidth -= COLUMN.EXPANDER_WIDTH;
|
||||
}
|
||||
|
||||
// Row background color function
|
||||
let rowBg: Function | undefined = undefined;
|
||||
for (const field of frame.fields) {
|
||||
const fieldOptions = field.config.custom;
|
||||
const cellOptionsExist = fieldOptions !== undefined && fieldOptions.cellOptions !== undefined;
|
||||
|
||||
if (
|
||||
cellOptionsExist &&
|
||||
fieldOptions.cellOptions.type === TableCellDisplayMode.ColorBackground &&
|
||||
fieldOptions.cellOptions.applyToRow
|
||||
) {
|
||||
rowBg = (rowIndex: number): CellColors => {
|
||||
const display = field.display!(field.values.get(rows[rowIndex].__index));
|
||||
const colors = getCellColors(theme, fieldOptions.cellOptions, display);
|
||||
return colors;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let fieldCountWithoutWidth = 0;
|
||||
frame.fields.map((field, fieldIndex) => {
|
||||
if (field.type === FieldType.nestedFrames) {
|
||||
// Don't render nestedFrames type field
|
||||
return;
|
||||
}
|
||||
const fieldTableOptions: TableFieldOptionsType = field.config.custom || {};
|
||||
const key = field.name;
|
||||
const justifyColumnContent = getTextAlign(field);
|
||||
const footerStyles = getFooterStyles(justifyColumnContent);
|
||||
|
||||
// current/old table width logic calculations
|
||||
if (fieldTableOptions.width) {
|
||||
availableWidth -= fieldTableOptions.width;
|
||||
} else {
|
||||
fieldCountWithoutWidth++;
|
||||
}
|
||||
|
||||
// Add a column for each field
|
||||
columns.push({
|
||||
key,
|
||||
name: field.name,
|
||||
field,
|
||||
cellClass: styles.cell,
|
||||
renderCell: (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
|
||||
const { row, rowIdx } = props;
|
||||
const cellType = field.config?.custom?.cellOptions?.type ?? TableCellDisplayMode.Auto;
|
||||
const value = row[key];
|
||||
// Cell level rendering here
|
||||
return (
|
||||
<TableCellNG
|
||||
frame={frame}
|
||||
key={key}
|
||||
value={value}
|
||||
field={field}
|
||||
theme={theme}
|
||||
timeRange={timeRange ?? getDefaultTimeRange()}
|
||||
height={defaultRowHeight}
|
||||
justifyContent={justifyColumnContent}
|
||||
rowIdx={rowIdx}
|
||||
shouldTextOverflow={() =>
|
||||
shouldTextOverflow(
|
||||
key,
|
||||
row,
|
||||
columnTypes,
|
||||
headerCellRefs,
|
||||
osContext,
|
||||
defaultLineHeight,
|
||||
defaultRowHeight,
|
||||
TABLE.CELL_PADDING,
|
||||
textWrap,
|
||||
cellInspect,
|
||||
cellType
|
||||
)
|
||||
}
|
||||
setIsInspecting={setIsInspecting}
|
||||
setContextMenuProps={setContextMenuProps}
|
||||
cellInspect={cellInspect}
|
||||
getActions={getActions}
|
||||
rowBg={rowBg}
|
||||
/>
|
||||
);
|
||||
},
|
||||
renderSummaryCell: () => {
|
||||
if (isCountRowsSet && fieldIndex === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>
|
||||
<Trans i18nKey="grafana-ui.table.count">Count</Trans>
|
||||
</span>
|
||||
<span>{calcsRef.current[fieldIndex]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className={footerStyles.footerCell}>{calcsRef.current[fieldIndex]}</div>;
|
||||
},
|
||||
renderHeaderCell: ({ column, sortDirection }): JSX.Element => (
|
||||
<HeaderCell
|
||||
column={column}
|
||||
rows={rows}
|
||||
field={field}
|
||||
onSort={(columnKey, direction, isMultiSort) =>
|
||||
handleSort(columnKey, direction, isMultiSort, setSortColumns, sortColumnsRef)
|
||||
}
|
||||
direction={sortDirection}
|
||||
justifyContent={justifyColumnContent}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterable={filterable}
|
||||
onColumnResize={onColumnResize}
|
||||
headerCellRefs={headerCellRefs}
|
||||
crossFilterOrder={crossFilterOrder}
|
||||
crossFilterRows={crossFilterRows}
|
||||
showTypeIcons={showTypeIcons}
|
||||
/>
|
||||
),
|
||||
width: fieldTableOptions.width,
|
||||
minWidth: fieldTableOptions.minWidth || COLUMN.DEFAULT_WIDTH,
|
||||
});
|
||||
});
|
||||
|
||||
// INFO: This loop calculates the width for each column in less than a millisecond.
|
||||
let sharedWidth = availableWidth / fieldCountWithoutWidth;
|
||||
|
||||
// First pass: Assign minimum widths to columns that need it
|
||||
columns.forEach((column) => {
|
||||
if (!column.width && column.minWidth! > sharedWidth) {
|
||||
column.width = column.minWidth;
|
||||
availableWidth -= column.width!;
|
||||
fieldCountWithoutWidth -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Recalculate shared width after assigning minimum widths
|
||||
sharedWidth = availableWidth / fieldCountWithoutWidth;
|
||||
|
||||
// Second pass: Assign shared width to remaining columns
|
||||
columns.forEach((column) => {
|
||||
if (!column.width) {
|
||||
column.width = sharedWidth;
|
||||
}
|
||||
column.minWidth = COLUMN.MIN_WIDTH; // Ensure min-width is always set
|
||||
});
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
export function myRowRenderer(
|
||||
key: React.Key,
|
||||
props: RenderRowProps<TableRow, TableSummaryRow>,
|
||||
expandedRows: number[],
|
||||
panelContext: PanelContext,
|
||||
data: DataFrame,
|
||||
enableSharedCrosshair: boolean
|
||||
): React.ReactNode {
|
||||
// Let's render row level things here!
|
||||
// i.e. we can look at row styles and such here
|
||||
const { row } = props;
|
||||
const rowIdx = Number(row.__index);
|
||||
const isExpanded = expandedRows.includes(rowIdx);
|
||||
|
||||
// Don't render non expanded child rows
|
||||
if (Number(row.__depth) === 1 && !isExpanded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add aria-expanded to parent rows that have nested data
|
||||
if (row.data) {
|
||||
return <Row key={key} {...props} aria-expanded={isExpanded} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row
|
||||
key={key}
|
||||
{...props}
|
||||
onMouseEnter={() => onRowHover(rowIdx, panelContext, data, enableSharedCrosshair)}
|
||||
onMouseLeave={() => onRowLeave(panelContext, enableSharedCrosshair)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function onRowHover(idx: number, panelContext: PanelContext, frame: DataFrame, enableSharedCrosshair: boolean) {
|
||||
if (!enableSharedCrosshair) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeField: Field = frame!.fields.find((f) => f.type === FieldType.time)!;
|
||||
|
||||
if (!timeField) {
|
||||
return;
|
||||
}
|
||||
|
||||
panelContext.eventBus.publish(
|
||||
new DataHoverEvent({
|
||||
point: {
|
||||
time: timeField.values[idx],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function onRowLeave(panelContext: PanelContext, enableSharedCrosshair: boolean) {
|
||||
if (!enableSharedCrosshair) {
|
||||
return;
|
||||
}
|
||||
|
||||
panelContext.eventBus.publish(new DataHoverClearEvent());
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, textWrap: boolean) => ({
|
||||
dataGrid: css({
|
||||
'--rdg-background-color': theme.colors.background.primary,
|
||||
'--rdg-header-background-color': theme.colors.background.primary,
|
||||
'--rdg-border-color': 'transparent',
|
||||
'--rdg-color': theme.colors.text.primary,
|
||||
'&:hover': {
|
||||
'--rdg-row-hover-background-color': theme.colors.emphasize(theme.colors.action.hover, 0.6),
|
||||
},
|
||||
|
||||
// If we rely solely on borderInlineEnd which is added from data grid, we
|
||||
// get a small gap where the gridCell borders meet the column header borders.
|
||||
// To avoid this, we can unset borderInlineEnd and set borderRight instead.
|
||||
'.rdg-cell': {
|
||||
borderInlineEnd: 'unset',
|
||||
borderRight: `1px solid ${theme.colors.border.medium}`,
|
||||
|
||||
'&:last-child': {
|
||||
borderRight: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
'.rdg-summary-row': {
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
'--rdg-summary-border-color': theme.colors.border.medium,
|
||||
|
||||
'.rdg-cell': {
|
||||
borderRight: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
// Due to stylistic choices, we do not want borders on the column headers
|
||||
// other than the bottom border.
|
||||
'div[role=columnheader]': {
|
||||
borderBottom: `1px solid ${theme.colors.border.medium}`,
|
||||
borderInlineEnd: 'unset',
|
||||
|
||||
'.r1y6ywlx7-0-0-beta-46': {
|
||||
'&:hover': {
|
||||
borderRight: `3px solid ${theme.colors.text.link}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'::-webkit-scrollbar': {
|
||||
width: TABLE.SCROLL_BAR_WIDTH,
|
||||
height: TABLE.SCROLL_BAR_WIDTH,
|
||||
},
|
||||
'::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: 'rgba(204, 204, 220, 0.16)',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'::-webkit-scrollbar-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'::-webkit-scrollbar-corner': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}),
|
||||
menuItem: css({
|
||||
maxWidth: '200px',
|
||||
}),
|
||||
cell: css({
|
||||
'--rdg-border-color': theme.colors.border.medium,
|
||||
borderLeft: 'none',
|
||||
whiteSpace: `${textWrap ? 'break-spaces' : 'nowrap'}`,
|
||||
wordWrap: 'break-word',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
|
||||
// Reset default cell styles for custom cell component styling
|
||||
paddingInline: '0',
|
||||
}),
|
||||
paginationContainer: css({
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '8px',
|
||||
width: '100%',
|
||||
}),
|
||||
paginationSummary: css({
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: theme.spacing(0, 1, 0, 2),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Column width and sizing configuration */
|
||||
export const COLUMN = {
|
||||
DEFAULT_WIDTH: 150,
|
||||
EXPANDER_WIDTH: 50,
|
||||
// This will need to eventually change to 36
|
||||
MIN_WIDTH: 50,
|
||||
};
|
||||
|
||||
/** Table layout and display constants */
|
||||
export const TABLE = {
|
||||
CELL_PADDING: 6,
|
||||
MAX_CELL_HEIGHT: 48,
|
||||
PAGINATION_LIMIT: 750,
|
||||
SCROLL_BAR_WIDTH: 8,
|
||||
SCROLL_BAR_MARGIN: 2,
|
||||
};
|
||||
231
packages/grafana-ui/src/components/Table/TableNG/types.ts
Normal file
231
packages/grafana-ui/src/components/Table/TableNG/types.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Property } from 'csstype';
|
||||
import { Column } from 'react-data-grid';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
Field,
|
||||
GrafanaTheme2,
|
||||
KeyValue,
|
||||
TimeRange,
|
||||
FieldConfigSource,
|
||||
ActionModel,
|
||||
InterpolateFunction,
|
||||
FieldType,
|
||||
} from '@grafana/data';
|
||||
import { TableCellOptions, TableCellHeight, TableFieldOptions } from '@grafana/schema';
|
||||
|
||||
import { TableCellInspectorMode } from '../TableCellInspector';
|
||||
|
||||
export const FILTER_FOR_OPERATOR = '=';
|
||||
export const FILTER_OUT_OPERATOR = '!=';
|
||||
|
||||
export type AdHocFilterOperator = typeof FILTER_FOR_OPERATOR | typeof FILTER_OUT_OPERATOR;
|
||||
export type AdHocFilterItem = { key: string; value: string; operator: AdHocFilterOperator };
|
||||
export type TableFilterActionCallback = (item: AdHocFilterItem) => void;
|
||||
export type TableColumnResizeActionCallback = (fieldDisplayName: string, width: number) => void;
|
||||
export type TableSortByActionCallback = (state: TableSortByFieldState[]) => void;
|
||||
export type FooterItem = Array<KeyValue<string>> | string | undefined;
|
||||
|
||||
export type GetActionsFunction = (
|
||||
frame: DataFrame,
|
||||
field: Field,
|
||||
rowIndex: number,
|
||||
replaceVariables?: InterpolateFunction
|
||||
) => ActionModel[];
|
||||
|
||||
export type TableFieldOptionsType = Omit<TableFieldOptions, 'cellOptions'> & {
|
||||
cellOptions: TableCellOptions;
|
||||
headerComponent?: React.ComponentType<CustomHeaderRendererProps>;
|
||||
};
|
||||
|
||||
export type FilterType = {
|
||||
[key: string]: {
|
||||
filteredSet: Set<string>;
|
||||
};
|
||||
};
|
||||
|
||||
/* ----------------------------- Table specific types ----------------------------- */
|
||||
export interface TableSummaryRow {
|
||||
[columnName: string]: string | number | undefined;
|
||||
}
|
||||
|
||||
export interface TableColumn extends Column<TableRow, TableSummaryRow> {
|
||||
key: string; // Unique identifier used by DataGrid
|
||||
name: string; // Display name in header
|
||||
field: Field; // Grafana field data/config
|
||||
width?: number | string; // Column width
|
||||
minWidth?: number; // Min width constraint
|
||||
cellClass?: string; // CSS styling
|
||||
}
|
||||
|
||||
// Possible values for table cells based on field types
|
||||
export type TableCellValue =
|
||||
| string // FieldType.string, FieldType.enum
|
||||
| number // FieldType.number
|
||||
| boolean // FieldType.boolean
|
||||
| Date // FieldType.time
|
||||
| DataFrame // For nested data
|
||||
| DataFrame[] // For nested frames
|
||||
| undefined; // For undefined values
|
||||
|
||||
export interface TableRow {
|
||||
// Required metadata properties
|
||||
__depth: number;
|
||||
__index: number;
|
||||
|
||||
// Nested table properties
|
||||
data?: DataFrame;
|
||||
'Nested frames'?: DataFrame[];
|
||||
|
||||
// Generic typing for column values
|
||||
[columnName: string]: TableCellValue;
|
||||
}
|
||||
|
||||
export interface CustomHeaderRendererProps {
|
||||
field: Field;
|
||||
defaultContent: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TableSortByFieldState {
|
||||
displayName: string;
|
||||
desc?: boolean;
|
||||
}
|
||||
|
||||
export interface TableFooterCalc {
|
||||
show: boolean;
|
||||
reducer?: string[]; // Make this optional
|
||||
fields?: string[];
|
||||
enablePagination?: boolean;
|
||||
countRows?: boolean;
|
||||
}
|
||||
|
||||
export interface BaseTableProps {
|
||||
ariaLabel?: string;
|
||||
data: DataFrame;
|
||||
width: number;
|
||||
height: number;
|
||||
maxHeight?: number;
|
||||
/** Minimal column width specified in pixels */
|
||||
columnMinWidth?: number;
|
||||
noHeader?: boolean;
|
||||
showTypeIcons?: boolean;
|
||||
resizable?: boolean;
|
||||
initialSortBy?: TableSortByFieldState[];
|
||||
onColumnResize?: TableColumnResizeActionCallback;
|
||||
onSortByChange?: TableSortByActionCallback;
|
||||
onCellFilterAdded?: TableFilterActionCallback;
|
||||
footerOptions?: TableFooterCalc;
|
||||
footerValues?: FooterItem[];
|
||||
enablePagination?: boolean;
|
||||
cellHeight?: TableCellHeight;
|
||||
/** @alpha Used by SparklineCell when provided */
|
||||
timeRange?: TimeRange;
|
||||
enableSharedCrosshair?: boolean;
|
||||
// The index of the field value that the table will initialize scrolled to
|
||||
initialRowIndex?: number;
|
||||
fieldConfig?: FieldConfigSource;
|
||||
getActions?: GetActionsFunction;
|
||||
replaceVariables?: InterpolateFunction;
|
||||
// Used solely for testing as RTL can't correctly render the table otherwise
|
||||
enableVirtualization?: boolean;
|
||||
}
|
||||
|
||||
/* ---------------------------- Table cell props ---------------------------- */
|
||||
export interface TableNGProps extends BaseTableProps {}
|
||||
|
||||
export interface TableCellNGProps {
|
||||
cellInspect: boolean;
|
||||
field: Field;
|
||||
frame: DataFrame;
|
||||
getActions?: GetActionsFunction;
|
||||
height: number;
|
||||
justifyContent: Property.JustifyContent;
|
||||
rowIdx: number;
|
||||
setContextMenuProps: (props: { value: string; top?: number; left?: number; mode?: TableCellInspectorMode }) => void;
|
||||
setIsInspecting: (isInspecting: boolean) => void;
|
||||
shouldTextOverflow: () => boolean;
|
||||
theme: GrafanaTheme2;
|
||||
timeRange: TimeRange;
|
||||
value: TableCellValue;
|
||||
rowBg: Function | undefined;
|
||||
}
|
||||
|
||||
/* ------------------------- Specialized Cell Props ------------------------- */
|
||||
export interface RowExpanderNGProps {
|
||||
height: number;
|
||||
onCellExpand: () => void;
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export interface SparklineCellProps {
|
||||
field: Field;
|
||||
justifyContent: Property.JustifyContent;
|
||||
rowIdx: number;
|
||||
theme: GrafanaTheme2;
|
||||
timeRange: TimeRange;
|
||||
value: TableCellValue;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface BarGaugeCellProps {
|
||||
field: Field;
|
||||
height: number;
|
||||
rowIdx: number;
|
||||
theme: GrafanaTheme2;
|
||||
value: TableCellValue;
|
||||
width: number;
|
||||
timeRange: TimeRange;
|
||||
}
|
||||
|
||||
export interface ImageCellProps {
|
||||
cellOptions: TableCellOptions;
|
||||
field: Field;
|
||||
height: number;
|
||||
justifyContent: Property.JustifyContent;
|
||||
value: TableCellValue;
|
||||
rowIdx: number;
|
||||
}
|
||||
|
||||
export interface JSONCellProps {
|
||||
justifyContent: Property.JustifyContent;
|
||||
value: TableCellValue;
|
||||
field: Field;
|
||||
rowIdx: number;
|
||||
}
|
||||
|
||||
export interface DataLinksCellProps {
|
||||
field: Field;
|
||||
rowIdx: number;
|
||||
}
|
||||
|
||||
export interface ActionCellProps {
|
||||
actions?: ActionModel[];
|
||||
}
|
||||
|
||||
export interface CellColors {
|
||||
textColor?: string;
|
||||
bgColor?: string;
|
||||
bgHoverColor?: string;
|
||||
}
|
||||
|
||||
export interface AutoCellProps {
|
||||
value: TableCellValue;
|
||||
field: Field;
|
||||
justifyContent: Property.JustifyContent;
|
||||
rowIdx: number;
|
||||
cellOptions: TableCellOptions;
|
||||
}
|
||||
|
||||
// Comparator for sorting table values
|
||||
export type Comparator = (a: TableCellValue, b: TableCellValue) => number;
|
||||
|
||||
// Type for converting a DataFrame into an array of TableRows
|
||||
export type FrameToRowsConverter = (frame: DataFrame) => TableRow[];
|
||||
|
||||
// Type for mapping column names to their field types
|
||||
export type ColumnTypes = Record<string, FieldType>;
|
||||
|
||||
export interface ScrollPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
1795
packages/grafana-ui/src/components/Table/TableNG/utils.test.ts
Normal file
1795
packages/grafana-ui/src/components/Table/TableNG/utils.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
570
packages/grafana-ui/src/components/Table/TableNG/utils.ts
Normal file
570
packages/grafana-ui/src/components/Table/TableNG/utils.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Property } from 'csstype';
|
||||
import React from 'react';
|
||||
import { SortColumn, SortDirection } from 'react-data-grid';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
import {
|
||||
FieldType,
|
||||
Field,
|
||||
formattedValueToString,
|
||||
reduceField,
|
||||
GrafanaTheme2,
|
||||
DisplayValue,
|
||||
LinkModel,
|
||||
DisplayValueAlignmentFactors,
|
||||
DataFrame,
|
||||
} from '@grafana/data';
|
||||
import {
|
||||
BarGaugeDisplayMode,
|
||||
TableAutoCellOptions,
|
||||
TableCellBackgroundDisplayMode,
|
||||
TableCellDisplayMode,
|
||||
TableCellHeight,
|
||||
TableCellOptions,
|
||||
} from '@grafana/schema';
|
||||
|
||||
import { TableCellInspectorMode } from '../..';
|
||||
import { getTextColorForAlphaBackground } from '../../../utils';
|
||||
|
||||
import { TABLE } from './constants';
|
||||
import {
|
||||
CellColors,
|
||||
TableRow,
|
||||
TableFieldOptionsType,
|
||||
ColumnTypes,
|
||||
FilterType,
|
||||
FrameToRowsConverter,
|
||||
TableNGProps,
|
||||
Comparator,
|
||||
TableFooterCalc,
|
||||
} from './types';
|
||||
|
||||
/* ---------------------------- Cell calculations --------------------------- */
|
||||
export function getCellHeight(
|
||||
text: string,
|
||||
cellWidth: number, // width of the cell without padding
|
||||
osContext: OffscreenCanvasRenderingContext2D | null,
|
||||
lineHeight: number,
|
||||
defaultRowHeight: number,
|
||||
padding = 0
|
||||
) {
|
||||
const PADDING = padding * 2;
|
||||
|
||||
if (osContext !== null && typeof text === 'string') {
|
||||
const words = text.split(/\s/);
|
||||
const lines = [];
|
||||
let currentLine = '';
|
||||
|
||||
// Let's just wrap the lines and see how well the measurement works
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const currentWord = words[i];
|
||||
// TODO: this method is not accurate
|
||||
let lineWidth = osContext.measureText(currentLine + ' ' + currentWord).width;
|
||||
|
||||
// if line width is less than the cell width, add the word to the current line and continue
|
||||
// else add the current line to the lines array and start a new line with the current word
|
||||
if (lineWidth < cellWidth) {
|
||||
currentLine += ' ' + currentWord;
|
||||
} else {
|
||||
lines.push({
|
||||
width: lineWidth,
|
||||
line: currentLine,
|
||||
});
|
||||
|
||||
currentLine = currentWord;
|
||||
}
|
||||
|
||||
// if we are at the last word, add the current line to the lines array
|
||||
if (i === words.length - 1) {
|
||||
lines.push({
|
||||
width: lineWidth,
|
||||
line: currentLine,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 1) {
|
||||
return defaultRowHeight;
|
||||
}
|
||||
|
||||
// TODO: double padding to adjust osContext.measureText() results
|
||||
const height = lines.length * lineHeight + PADDING * 2;
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
return defaultRowHeight;
|
||||
}
|
||||
|
||||
export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight: TableCellHeight | undefined): number {
|
||||
const bodyFontSize = theme.typography.fontSize;
|
||||
const lineHeight = theme.typography.body.lineHeight;
|
||||
|
||||
switch (cellHeight) {
|
||||
case TableCellHeight.Sm:
|
||||
return 36;
|
||||
case TableCellHeight.Md:
|
||||
return 42;
|
||||
case TableCellHeight.Lg:
|
||||
return TABLE.MAX_CELL_HEIGHT;
|
||||
}
|
||||
|
||||
return TABLE.CELL_PADDING * 2 + bodyFontSize * lineHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* getRowHeight determines cell height based on cell width + text length. Used
|
||||
* for when textWrap is enabled.
|
||||
*/
|
||||
export function getRowHeight(
|
||||
row: TableRow,
|
||||
columnTypes: ColumnTypes,
|
||||
headerCellRefs: React.MutableRefObject<Record<string, HTMLDivElement>>,
|
||||
osContext: OffscreenCanvasRenderingContext2D | null,
|
||||
lineHeight: number,
|
||||
defaultRowHeight: number,
|
||||
padding: number
|
||||
): number {
|
||||
/**
|
||||
* 0. loop through all cells in row
|
||||
* 1. find text cell in row
|
||||
* 2. find width of text cell
|
||||
* 3. calculate height based on width and text length
|
||||
* 4. return biggest height
|
||||
*/
|
||||
|
||||
let biggestHeight = defaultRowHeight;
|
||||
|
||||
for (const key in row) {
|
||||
if (isTextCell(key, columnTypes)) {
|
||||
if (Object.keys(headerCellRefs.current).length === 0 || !headerCellRefs.current[key]) {
|
||||
return biggestHeight;
|
||||
}
|
||||
const cellWidth = headerCellRefs.current[key].offsetWidth;
|
||||
const cellText = String(row[key] ?? '');
|
||||
const newCellHeight = getCellHeight(cellText, cellWidth, osContext, lineHeight, defaultRowHeight, padding);
|
||||
|
||||
if (newCellHeight > biggestHeight) {
|
||||
biggestHeight = newCellHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return biggestHeight;
|
||||
}
|
||||
|
||||
export function isTextCell(key: string, columnTypes: Record<string, string>): boolean {
|
||||
return columnTypes[key] === FieldType.string;
|
||||
}
|
||||
|
||||
export function shouldTextOverflow(
|
||||
key: string,
|
||||
row: TableRow,
|
||||
columnTypes: ColumnTypes,
|
||||
headerCellRefs: React.MutableRefObject<Record<string, HTMLDivElement>>,
|
||||
osContext: OffscreenCanvasRenderingContext2D | null,
|
||||
lineHeight: number,
|
||||
defaultRowHeight: number,
|
||||
padding: number,
|
||||
textWrap: boolean,
|
||||
cellInspect: boolean,
|
||||
cellType: TableCellDisplayMode
|
||||
): boolean {
|
||||
// Tech debt: Technically image cells are of type string, which is misleading (kinda?)
|
||||
// so we need to ensure we don't apply overflow hover states fo type image
|
||||
if (textWrap || cellInspect || cellType === TableCellDisplayMode.Image || !isTextCell(key, columnTypes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cellWidth = headerCellRefs.current[key].offsetWidth;
|
||||
const cellText = String(row[key] ?? '');
|
||||
const newCellHeight = getCellHeight(cellText, cellWidth, osContext, lineHeight, defaultRowHeight, padding);
|
||||
|
||||
return newCellHeight > defaultRowHeight;
|
||||
}
|
||||
|
||||
export function getTextAlign(field?: Field): Property.JustifyContent {
|
||||
if (!field) {
|
||||
return 'flex-start';
|
||||
}
|
||||
|
||||
if (field.config.custom) {
|
||||
const custom: TableFieldOptionsType = field.config.custom;
|
||||
|
||||
switch (custom.align) {
|
||||
case 'right':
|
||||
return 'flex-end';
|
||||
case 'left':
|
||||
return 'flex-start';
|
||||
case 'center':
|
||||
return 'center';
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.number) {
|
||||
return 'flex-end';
|
||||
}
|
||||
|
||||
return 'flex-start';
|
||||
}
|
||||
|
||||
const defaultCellOptions: TableAutoCellOptions = { type: TableCellDisplayMode.Auto };
|
||||
|
||||
export function getCellOptions(field: Field): TableCellOptions {
|
||||
if (field.config.custom?.displayMode) {
|
||||
return migrateTableDisplayModeToCellOptions(field.config.custom?.displayMode);
|
||||
}
|
||||
|
||||
if (!field.config.custom?.cellOptions) {
|
||||
return defaultCellOptions;
|
||||
}
|
||||
|
||||
return field.config.custom.cellOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getting gauge or sparkline values to align is very tricky without looking at all values and passing them through display processor.
|
||||
* For very large tables that could pretty expensive. So this is kind of a compromise. We look at the first 1000 rows and cache the longest value.
|
||||
* If we have a cached value we just check if the current value is longer and update the alignmentFactor. This can obviously still lead to
|
||||
* unaligned gauges but it should a lot less common.
|
||||
**/
|
||||
export function getAlignmentFactor(
|
||||
field: Field,
|
||||
displayValue: DisplayValue,
|
||||
rowIndex: number
|
||||
): DisplayValueAlignmentFactors {
|
||||
let alignmentFactor = field.state?.alignmentFactors;
|
||||
|
||||
if (alignmentFactor) {
|
||||
// check if current alignmentFactor is still the longest
|
||||
if (formattedValueToString(alignmentFactor).length < formattedValueToString(displayValue).length) {
|
||||
alignmentFactor = { ...displayValue };
|
||||
field.state!.alignmentFactors = alignmentFactor;
|
||||
}
|
||||
return alignmentFactor;
|
||||
} else {
|
||||
// look at the next 1000 rows
|
||||
alignmentFactor = { ...displayValue };
|
||||
const maxIndex = Math.min(field.values.length, rowIndex + 1000);
|
||||
|
||||
for (let i = rowIndex + 1; i < maxIndex; i++) {
|
||||
const nextDisplayValue = field.display!(field.values[i]);
|
||||
if (formattedValueToString(alignmentFactor).length > formattedValueToString(nextDisplayValue).length) {
|
||||
alignmentFactor.text = displayValue.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (field.state) {
|
||||
field.state.alignmentFactors = alignmentFactor;
|
||||
} else {
|
||||
field.state = { alignmentFactors: alignmentFactor };
|
||||
}
|
||||
|
||||
return alignmentFactor;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ Footer calculations ------------------------------ */
|
||||
export function getFooterItemNG(rows: TableRow[], field: Field, options: TableFooterCalc | undefined): string {
|
||||
if (options === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (field.type !== FieldType.number) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Check if reducer array exists and has at least one element
|
||||
if (!options.reducer || !options.reducer.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If fields array is specified, only show footer for fields included in that array
|
||||
if (options.fields && options.fields.length > 0) {
|
||||
if (!options.fields.includes(field.name)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const calc = options.reducer[0];
|
||||
const value = reduceField({
|
||||
field: {
|
||||
...field,
|
||||
values: rows.map((row) => row[field.name]),
|
||||
},
|
||||
reducers: options.reducer,
|
||||
})[calc];
|
||||
|
||||
const formattedValue = formattedValueToString(field.display!(value));
|
||||
|
||||
return formattedValue;
|
||||
}
|
||||
|
||||
export const getFooterStyles = (justifyContent: Property.JustifyContent) => ({
|
||||
footerCell: css({
|
||||
display: 'flex',
|
||||
justifyContent: justifyContent || 'space-between',
|
||||
}),
|
||||
});
|
||||
|
||||
/* ------------------------- Cell color calculation ------------------------- */
|
||||
const CELL_COLOR_DARKENING_MULTIPLIER = 10;
|
||||
const CELL_GRADIENT_DARKENING_MULTIPLIER = 15;
|
||||
const CELL_GRADIENT_HUE_ROTATION_DEGREES = 5;
|
||||
|
||||
export function getCellColors(
|
||||
theme: GrafanaTheme2,
|
||||
cellOptions: TableCellOptions,
|
||||
displayValue: DisplayValue
|
||||
): CellColors {
|
||||
// Convert RGBA hover color to hex to prevent transparency issues on cell hover
|
||||
const autoCellBackgroundHoverColor = convertRGBAToHex(theme.colors.background.primary, theme.colors.action.hover);
|
||||
|
||||
// How much to darken elements depends upon if we're in dark mode
|
||||
const darkeningFactor = theme.isDark ? 1 : -0.7;
|
||||
|
||||
// Setup color variables
|
||||
let textColor: string | undefined = undefined;
|
||||
let bgColor: string | undefined = undefined;
|
||||
let bgHoverColor: string = autoCellBackgroundHoverColor;
|
||||
|
||||
if (cellOptions.type === TableCellDisplayMode.ColorText) {
|
||||
textColor = displayValue.color;
|
||||
} else if (cellOptions.type === TableCellDisplayMode.ColorBackground) {
|
||||
const mode = cellOptions.mode ?? TableCellBackgroundDisplayMode.Gradient;
|
||||
|
||||
if (mode === TableCellBackgroundDisplayMode.Basic) {
|
||||
textColor = getTextColorForAlphaBackground(displayValue.color!, theme.isDark);
|
||||
bgColor = tinycolor(displayValue.color).toRgbString();
|
||||
bgHoverColor = tinycolor(displayValue.color)
|
||||
.darken(CELL_COLOR_DARKENING_MULTIPLIER * darkeningFactor)
|
||||
.toRgbString();
|
||||
} else if (mode === TableCellBackgroundDisplayMode.Gradient) {
|
||||
const hoverColor = tinycolor(displayValue.color)
|
||||
.darken(CELL_GRADIENT_DARKENING_MULTIPLIER * darkeningFactor)
|
||||
.toRgbString();
|
||||
const bgColor2 = tinycolor(displayValue.color)
|
||||
.darken(CELL_COLOR_DARKENING_MULTIPLIER * darkeningFactor)
|
||||
.spin(CELL_GRADIENT_HUE_ROTATION_DEGREES);
|
||||
textColor = getTextColorForAlphaBackground(displayValue.color!, theme.isDark);
|
||||
bgColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${displayValue.color})`;
|
||||
bgHoverColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${hoverColor})`;
|
||||
}
|
||||
}
|
||||
|
||||
return { textColor, bgColor, bgHoverColor };
|
||||
}
|
||||
|
||||
/** Extracts numeric pixel value from theme spacing */
|
||||
export const extractPixelValue = (spacing: string | number): number => {
|
||||
return typeof spacing === 'number' ? spacing : parseFloat(spacing) || 0;
|
||||
};
|
||||
|
||||
/** Converts an RGBA color to hex by blending it with a background color */
|
||||
export const convertRGBAToHex = (backgroundColor: string, rgbaColor: string): string => {
|
||||
const bg = tinycolor(backgroundColor);
|
||||
const rgba = tinycolor(rgbaColor);
|
||||
return tinycolor.mix(bg, rgba, rgba.getAlpha() * 100).toHexString();
|
||||
};
|
||||
|
||||
/* ------------------------------- Data links ------------------------------- */
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export const getCellLinks = (field: Field, rowIdx: number) => {
|
||||
let links: Array<LinkModel<unknown>> | undefined;
|
||||
if (field.getLinks) {
|
||||
links = field.getLinks({
|
||||
valueRowIndex: rowIdx,
|
||||
});
|
||||
}
|
||||
|
||||
if (!links) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < links?.length; i++) {
|
||||
if (links[i].onClick) {
|
||||
const origOnClick = links[i].onClick;
|
||||
|
||||
links[i].onClick = (event) => {
|
||||
// Allow opening in new tab
|
||||
if (!(event.ctrlKey || event.metaKey || event.shiftKey)) {
|
||||
event.preventDefault();
|
||||
origOnClick!(event, {
|
||||
field,
|
||||
rowIndex: rowIdx,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
/* ----------------------------- Data grid sorting ---------------------------- */
|
||||
export const handleSort = (
|
||||
columnKey: string,
|
||||
direction: SortDirection,
|
||||
isMultiSort: boolean,
|
||||
setSortColumns: React.Dispatch<React.SetStateAction<readonly SortColumn[]>>,
|
||||
sortColumnsRef: React.MutableRefObject<readonly SortColumn[]>
|
||||
) => {
|
||||
let currentSortColumn: SortColumn | undefined;
|
||||
|
||||
const updatedSortColumns = sortColumnsRef.current.filter((column) => {
|
||||
const isCurrentColumn = column.columnKey === columnKey;
|
||||
if (isCurrentColumn) {
|
||||
currentSortColumn = column;
|
||||
}
|
||||
return !isCurrentColumn;
|
||||
});
|
||||
|
||||
// sorted column exists and is descending -> remove it to reset sorting
|
||||
if (currentSortColumn && currentSortColumn.direction === 'DESC') {
|
||||
setSortColumns(updatedSortColumns);
|
||||
sortColumnsRef.current = updatedSortColumns;
|
||||
} else {
|
||||
// new sort column or changed direction
|
||||
if (isMultiSort) {
|
||||
setSortColumns([...updatedSortColumns, { columnKey, direction }]);
|
||||
sortColumnsRef.current = [...updatedSortColumns, { columnKey, direction }];
|
||||
} else {
|
||||
setSortColumns([{ columnKey, direction }]);
|
||||
sortColumnsRef.current = [{ columnKey, direction }];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ----------------------------- Data grid mapping ---------------------------- */
|
||||
export const frameToRecords = (frame: DataFrame): TableRow[] => {
|
||||
const fnBody = `
|
||||
const rows = Array(frame.length);
|
||||
const values = frame.fields.map(f => f.values);
|
||||
let rowCount = 0;
|
||||
for (let i = 0; i < frame.length; i++) {
|
||||
rows[rowCount] = {
|
||||
__depth: 0,
|
||||
__index: i,
|
||||
${frame.fields.map((field, fieldIdx) => `${JSON.stringify(field.name)}: values[${fieldIdx}][i]`).join(',')}
|
||||
};
|
||||
rowCount += 1;
|
||||
if (rows[rowCount-1]['Nested frames']){
|
||||
const childFrame = rows[rowCount-1]['Nested frames'];
|
||||
rows[rowCount] = {__depth: 1, __index: i, data: childFrame[0]}
|
||||
rowCount += 1;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
`;
|
||||
|
||||
// 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;
|
||||
return convert(frame);
|
||||
};
|
||||
|
||||
export interface MapFrameToGridOptions extends TableNGProps {
|
||||
columnTypes: ColumnTypes;
|
||||
columnWidth: number | string;
|
||||
crossFilterOrder: React.MutableRefObject<string[]>;
|
||||
crossFilterRows: React.MutableRefObject<{ [key: string]: TableRow[] }>;
|
||||
defaultLineHeight: number;
|
||||
defaultRowHeight: number;
|
||||
expandedRows: number[];
|
||||
filter: FilterType;
|
||||
headerCellRefs: React.MutableRefObject<Record<string, HTMLDivElement>>;
|
||||
isCountRowsSet: boolean;
|
||||
osContext: OffscreenCanvasRenderingContext2D | null;
|
||||
rows: TableRow[];
|
||||
setContextMenuProps: (props: { value: string; top?: number; left?: number; mode?: TableCellInspectorMode }) => void;
|
||||
setFilter: React.Dispatch<React.SetStateAction<FilterType>>;
|
||||
setIsInspecting: (isInspecting: boolean) => void;
|
||||
setSortColumns: React.Dispatch<React.SetStateAction<readonly SortColumn[]>>;
|
||||
sortColumnsRef: React.MutableRefObject<readonly SortColumn[]>;
|
||||
styles: { cell: string };
|
||||
textWrap: boolean;
|
||||
theme: GrafanaTheme2;
|
||||
showTypeIcons?: boolean;
|
||||
}
|
||||
|
||||
/* ----------------------------- Data grid comparator ---------------------------- */
|
||||
const compare = new Intl.Collator('en', { sensitivity: 'base' }).compare;
|
||||
export function getComparator(sortColumnType: FieldType): Comparator {
|
||||
switch (sortColumnType) {
|
||||
case FieldType.time:
|
||||
case FieldType.number:
|
||||
case FieldType.boolean:
|
||||
return (a, b) => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
if (a == null) {
|
||||
return -1;
|
||||
}
|
||||
if (b == null) {
|
||||
return 1;
|
||||
}
|
||||
return Number(a) - Number(b);
|
||||
};
|
||||
case FieldType.string:
|
||||
case FieldType.enum:
|
||||
default:
|
||||
return (a, b) => compare(String(a ?? ''), String(b ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------- Miscellaneous ---------------------------- */
|
||||
/**
|
||||
* Migrates table cell display mode to new object format.
|
||||
*
|
||||
* @param displayMode The display mode of the cell
|
||||
* @returns TableCellOptions object in the correct format
|
||||
* relative to the old display mode.
|
||||
*/
|
||||
export function migrateTableDisplayModeToCellOptions(displayMode: TableCellDisplayMode): TableCellOptions {
|
||||
switch (displayMode) {
|
||||
// In the case of the gauge we move to a different option
|
||||
case 'basic':
|
||||
case 'gradient-gauge':
|
||||
case 'lcd-gauge':
|
||||
let gaugeMode = BarGaugeDisplayMode.Basic;
|
||||
|
||||
if (displayMode === 'gradient-gauge') {
|
||||
gaugeMode = BarGaugeDisplayMode.Gradient;
|
||||
} else if (displayMode === 'lcd-gauge') {
|
||||
gaugeMode = BarGaugeDisplayMode.Lcd;
|
||||
}
|
||||
|
||||
return {
|
||||
type: TableCellDisplayMode.Gauge,
|
||||
mode: gaugeMode,
|
||||
};
|
||||
// Also true in the case of the color background
|
||||
case 'color-background':
|
||||
case 'color-background-solid':
|
||||
let mode = TableCellBackgroundDisplayMode.Basic;
|
||||
|
||||
// Set the new mode field, somewhat confusingly the
|
||||
// color-background mode is for gradient display
|
||||
if (displayMode === 'color-background') {
|
||||
mode = TableCellBackgroundDisplayMode.Gradient;
|
||||
}
|
||||
|
||||
return {
|
||||
type: TableCellDisplayMode.ColorBackground,
|
||||
mode: mode,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
// @ts-ignore
|
||||
type: displayMode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the DataFrame contains nested frames */
|
||||
export const getIsNestedTable = (dataFrame: DataFrame): boolean =>
|
||||
dataFrame.fields.some(({ type }) => type === FieldType.nestedFrames);
|
||||
@@ -5,11 +5,11 @@ import * as React from 'react';
|
||||
import { DataFrame, Field, GrafanaTheme2 } from '@grafana/data';
|
||||
import { TableCellHeight } from '@grafana/schema';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../themes';
|
||||
import { useStyles2, useTheme2 } from '../../../themes';
|
||||
import { EXPANDER_WIDTH } from '../utils';
|
||||
|
||||
import { Table } from './Table';
|
||||
import { TableStyles } from './styles';
|
||||
import { EXPANDER_WIDTH } from './utils';
|
||||
|
||||
export interface Props {
|
||||
nestedData: Field;
|
||||
@@ -3,9 +3,9 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { Popover } from '..';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { Popover } from '../..';
|
||||
import { useStyles2 } from '../../../themes';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
|
||||
import { REGEX_OPERATOR } from './FilterList';
|
||||
import { FilterPopup } from './FilterPopup';
|
||||
@@ -5,9 +5,9 @@ import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
|
||||
|
||||
import { GrafanaTheme2, formattedValueToString, getValueFormat, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { ButtonSelect, Checkbox, FilterInput, Label, Stack } from '..';
|
||||
import { useStyles2, useTheme2 } from '../../themes';
|
||||
import { t, Trans } from '../../utils/i18n';
|
||||
import { ButtonSelect, Checkbox, FilterInput, Label, Stack } from '../..';
|
||||
import { useStyles2, useTheme2 } from '../../../themes';
|
||||
import { t, Trans } from '../../../utils/i18n';
|
||||
|
||||
interface Props {
|
||||
values: SelectableValue[];
|
||||
@@ -4,13 +4,13 @@ import * as React from 'react';
|
||||
|
||||
import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { Button, ClickOutsideWrapper, IconButton, Label, Stack } from '..';
|
||||
import { useStyles2, useTheme2 } from '../../themes';
|
||||
import { t, Trans } from '../../utils/i18n';
|
||||
import { Button, ClickOutsideWrapper, IconButton, Label, Stack } from '../..';
|
||||
import { useStyles2, useTheme2 } from '../../../themes';
|
||||
import { t, Trans } from '../../../utils/i18n';
|
||||
import { calculateUniqueFieldValues, getFilteredOptions, valuesToOptions } from '../utils';
|
||||
|
||||
import { FilterList } from './FilterList';
|
||||
import { TableStyles } from './styles';
|
||||
import { calculateUniqueFieldValues, getFilteredOptions, valuesToOptions } from './utils';
|
||||
|
||||
interface Props {
|
||||
column: any;
|
||||
@@ -3,9 +3,10 @@ import { ColumnInstance, HeaderGroup } from 'react-table';
|
||||
import { fieldReducers, ReducerID } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { EmptyCell, FooterCell } from './FooterCell';
|
||||
import { EmptyCell, FooterCell } from '../Cells/FooterCell';
|
||||
import { FooterItem } from '../types';
|
||||
|
||||
import { TableStyles } from './styles';
|
||||
import { FooterItem } from './types';
|
||||
|
||||
export interface FooterRowProps {
|
||||
totalColumnsWidth: number;
|
||||
@@ -3,12 +3,12 @@ import { HeaderGroup, Column } from 'react-table';
|
||||
import { Field } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { getFieldTypeIcon } from '../../types';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { getFieldTypeIcon } from '../../../types';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
import { TableFieldOptions } from '../types';
|
||||
|
||||
import { Filter } from './Filter';
|
||||
import { TableStyles } from './styles';
|
||||
import { TableFieldOptions } from './types';
|
||||
|
||||
export interface HeaderRowProps {
|
||||
headerGroups: HeaderGroup[];
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
import { GrafanaTableRow } from '../types';
|
||||
|
||||
import { TableStyles } from './styles';
|
||||
import { GrafanaTableRow } from './types';
|
||||
|
||||
export interface Props {
|
||||
row: GrafanaTableRow;
|
||||
@@ -17,26 +17,26 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { TableCellDisplayMode, TableCellHeight } from '@grafana/schema';
|
||||
|
||||
import { useTheme2 } from '../../themes';
|
||||
import CustomScrollbar from '../CustomScrollbar/CustomScrollbar';
|
||||
import { usePanelContext } from '../PanelChrome';
|
||||
|
||||
import { ExpandedRow, getExpandedRowHeight } from './ExpandedRow';
|
||||
import { TableCell } from './TableCell';
|
||||
import { TableStyles } from './styles';
|
||||
import { useTheme2 } from '../../../themes';
|
||||
import CustomScrollbar from '../../CustomScrollbar/CustomScrollbar';
|
||||
import { usePanelContext } from '../../PanelChrome';
|
||||
import { TableCell } from '../Cells/TableCell';
|
||||
import {
|
||||
CellColors,
|
||||
GetActionsFunction,
|
||||
TableFieldOptions,
|
||||
TableFilterActionCallback,
|
||||
TableInspectCellCallback,
|
||||
} from './types';
|
||||
} from '../types';
|
||||
import {
|
||||
calculateAroundPointThreshold,
|
||||
getCellColors,
|
||||
isPointTimeValAroundTableTimeVal,
|
||||
guessTextBoundingBox,
|
||||
} from './utils';
|
||||
} from '../utils';
|
||||
|
||||
import { ExpandedRow, getExpandedRowHeight } from './ExpandedRow';
|
||||
import { TableStyles } from './styles';
|
||||
|
||||
interface RowsListProps {
|
||||
data: DataFrame;
|
||||
@@ -264,7 +264,7 @@ export const RowsList = (props: RowsListProps) => {
|
||||
) {
|
||||
rowBg = (rowIndex: number): CellColors => {
|
||||
const display = field.display!(field.values.get(rowIndex));
|
||||
const colors = getCellColors(tableStyles, fieldOptions.cellOptions, display);
|
||||
const colors = getCellColors(tableStyles.theme, fieldOptions.cellOptions, display);
|
||||
return colors;
|
||||
};
|
||||
}
|
||||
390
packages/grafana-ui/src/components/Table/TableRT/Table.tsx
Normal file
390
packages/grafana-ui/src/components/Table/TableRT/Table.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useAbsoluteLayout,
|
||||
useExpanded,
|
||||
useFilters,
|
||||
usePagination,
|
||||
useResizeColumns,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import { VariableSizeList } from 'react-window';
|
||||
|
||||
import { FieldType, ReducerID, getRowUniqueId, getFieldMatcher } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { TableCellHeight } from '@grafana/schema';
|
||||
|
||||
import { useTheme2 } from '../../../themes';
|
||||
import { Trans } from '../../../utils/i18n';
|
||||
import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar';
|
||||
import { Pagination } from '../../Pagination/Pagination';
|
||||
import { useFixScrollbarContainer, useResetVariableListSizeCache } from '../hooks';
|
||||
import { getInitialState, useTableStateReducer } from '../reducer';
|
||||
import { FooterItem, GrafanaTableState, BaseTableProps as Props } from '../types';
|
||||
import {
|
||||
getColumns,
|
||||
sortCaseInsensitive,
|
||||
sortNumber,
|
||||
getFooterItems,
|
||||
createFooterCalculationValues,
|
||||
guessLongestField,
|
||||
} from '../utils';
|
||||
|
||||
import { FooterRow } from './FooterRow';
|
||||
import { HeaderRow } from './HeaderRow';
|
||||
import { RowsList } from './RowsList';
|
||||
import { useTableStyles } from './styles';
|
||||
|
||||
const COLUMN_MIN_WIDTH = 150;
|
||||
const FOOTER_ROW_HEIGHT = 36;
|
||||
const NO_DATA_TEXT = 'No data';
|
||||
|
||||
export const Table = memo((props: Props) => {
|
||||
const {
|
||||
ariaLabel,
|
||||
data,
|
||||
height,
|
||||
onCellFilterAdded,
|
||||
onColumnResize,
|
||||
width,
|
||||
columnMinWidth = COLUMN_MIN_WIDTH,
|
||||
noHeader,
|
||||
resizable = true,
|
||||
initialSortBy,
|
||||
footerOptions,
|
||||
showTypeIcons,
|
||||
footerValues,
|
||||
enablePagination,
|
||||
cellHeight = TableCellHeight.Sm,
|
||||
timeRange,
|
||||
enableSharedCrosshair = false,
|
||||
initialRowIndex = undefined,
|
||||
fieldConfig,
|
||||
getActions,
|
||||
replaceVariables,
|
||||
} = props;
|
||||
|
||||
const listRef = useRef<VariableSizeList>(null);
|
||||
const tableDivRef = useRef<HTMLDivElement>(null);
|
||||
const variableSizeListScrollbarRef = useRef<HTMLDivElement>(null);
|
||||
const theme = useTheme2();
|
||||
const tableStyles = useTableStyles(theme, cellHeight);
|
||||
const headerHeight = noHeader ? 0 : tableStyles.rowHeight;
|
||||
const [footerItems, setFooterItems] = useState<FooterItem[] | undefined>(footerValues);
|
||||
const noValuesDisplayText = fieldConfig?.defaults?.noValue ?? NO_DATA_TEXT;
|
||||
|
||||
const footerHeight = useMemo(() => {
|
||||
const EXTENDED_ROW_HEIGHT = FOOTER_ROW_HEIGHT;
|
||||
let length = 0;
|
||||
|
||||
if (!footerItems) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const fv of footerItems) {
|
||||
if (Array.isArray(fv) && fv.length > length) {
|
||||
length = fv.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (length > 1) {
|
||||
return EXTENDED_ROW_HEIGHT * length;
|
||||
}
|
||||
|
||||
return EXTENDED_ROW_HEIGHT;
|
||||
}, [footerItems]);
|
||||
|
||||
// React table data array. This data acts just like a dummy array to let react-table know how many rows exist.
|
||||
// The cells use the field to look up values, therefore this is simply a length/size placeholder.
|
||||
const memoizedData = useMemo(() => {
|
||||
if (!data.fields.length) {
|
||||
return [];
|
||||
}
|
||||
// As we only use this to fake the length of our data set for react-table we need to make sure we always return an array
|
||||
// filled with values at each index otherwise we'll end up trying to call accessRow for null|undefined value in
|
||||
// https://github.com/tannerlinsley/react-table/blob/7be2fc9d8b5e223fc998af88865ae86a88792fdb/src/hooks/useTable.js#L585
|
||||
return Array(data.length).fill(0);
|
||||
}, [data]);
|
||||
|
||||
// This checks whether `Show table footer` is toggled on, the `Calculation` is set to `Count`, and finally, whether `Count rows` is toggled on.
|
||||
const isCountRowsSet = Boolean(
|
||||
footerOptions?.countRows &&
|
||||
footerOptions.reducer &&
|
||||
footerOptions.reducer.length &&
|
||||
footerOptions.reducer[0] === ReducerID.count
|
||||
);
|
||||
|
||||
const nestedDataField = data.fields.find((f) => f.type === FieldType.nestedFrames);
|
||||
const hasNestedData = nestedDataField !== undefined;
|
||||
|
||||
// React-table column definitions
|
||||
const memoizedColumns = useMemo(
|
||||
() => getColumns(data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet),
|
||||
[data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet]
|
||||
);
|
||||
|
||||
// we need a ref to later store the `toggleAllRowsExpanded` function, returned by `useTable`.
|
||||
// We cannot simply use a variable because we need to use such function in the initialization of
|
||||
// `useTableStateReducer`, which is needed to construct options for `useTable` (the hook that returns
|
||||
// `toggleAllRowsExpanded`), and if we used a variable, that variable would be undefined at the time
|
||||
// we initialize `useTableStateReducer`.
|
||||
const toggleAllRowsExpandedRef = useRef<(value?: boolean) => void>();
|
||||
|
||||
// Internal react table state reducer
|
||||
const stateReducer = useTableStateReducer({
|
||||
onColumnResize,
|
||||
onSortByChange: (state) => {
|
||||
// Collapse all rows. This prevents a known bug that causes the size of the rows to be incorrect due to
|
||||
// using `VariableSizeList` and `useExpanded` together.
|
||||
toggleAllRowsExpandedRef.current!(false);
|
||||
|
||||
if (props.onSortByChange) {
|
||||
props.onSortByChange(state);
|
||||
}
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
const hasUniqueId = !!data.meta?.uniqueRowIdFields?.length;
|
||||
|
||||
const options: any = useMemo(() => {
|
||||
// This is a bit hard to type with the react-table types here, the reducer does not actually match with the
|
||||
// TableOptions.
|
||||
const options: any = {
|
||||
columns: memoizedColumns,
|
||||
data: memoizedData,
|
||||
disableResizing: !resizable,
|
||||
stateReducer: stateReducer,
|
||||
autoResetPage: false,
|
||||
initialState: getInitialState(initialSortBy, memoizedColumns),
|
||||
autoResetFilters: false,
|
||||
sortTypes: {
|
||||
// the builtin number type on react-table does not handle NaN values
|
||||
number: sortNumber,
|
||||
// should be replaced with the builtin string when react-table is upgraded,
|
||||
// see https://github.com/tannerlinsley/react-table/pull/3235
|
||||
'alphanumeric-insensitive': sortCaseInsensitive,
|
||||
},
|
||||
};
|
||||
if (hasUniqueId) {
|
||||
// row here is just always 0 because here we don't use real data but just a dummy array filled with 0.
|
||||
// See memoizedData variable above.
|
||||
options.getRowId = (row: Record<string, unknown>, relativeIndex: number) => getRowUniqueId(data, relativeIndex);
|
||||
|
||||
// If we have unique field we assume we can count on it as being globally unique, and we don't need to reset when
|
||||
// data changes.
|
||||
options.autoResetExpanded = false;
|
||||
}
|
||||
return options;
|
||||
}, [initialSortBy, memoizedColumns, memoizedData, resizable, stateReducer, hasUniqueId, data]);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
headerGroups,
|
||||
footerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
totalColumnsWidth,
|
||||
page,
|
||||
state,
|
||||
gotoPage,
|
||||
setPageSize,
|
||||
pageOptions,
|
||||
toggleAllRowsExpanded,
|
||||
} = useTable(options, useFilters, useSortBy, useAbsoluteLayout, useResizeColumns, useExpanded, usePagination);
|
||||
|
||||
const extendedState = state as GrafanaTableState;
|
||||
toggleAllRowsExpandedRef.current = toggleAllRowsExpanded;
|
||||
|
||||
/*
|
||||
Footer value calculation is being moved in the Table component and the footerValues prop will be deprecated.
|
||||
The footerValues prop is still used in the Table component for backwards compatibility. Adding the
|
||||
footerOptions prop will switch the Table component to use the new footer calculation. Using both props will
|
||||
result in the footerValues prop being ignored.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!footerOptions) {
|
||||
setFooterItems(footerValues);
|
||||
}
|
||||
}, [footerValues, footerOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!footerOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!footerOptions.show) {
|
||||
setFooterItems(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCountRowsSet) {
|
||||
const footerItemsCountRows: FooterItem[] = [];
|
||||
footerItemsCountRows[0] = rows.length.toString() ?? data.length.toString();
|
||||
setFooterItems(footerItemsCountRows);
|
||||
return;
|
||||
}
|
||||
|
||||
const footerItems = getFooterItems(
|
||||
headerGroups[0].headers,
|
||||
createFooterCalculationValues(rows),
|
||||
footerOptions,
|
||||
theme
|
||||
);
|
||||
|
||||
setFooterItems(footerItems);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [footerOptions, theme, state.filters, data]);
|
||||
|
||||
let listHeight = height - (headerHeight + footerHeight);
|
||||
|
||||
if (enablePagination) {
|
||||
listHeight -= tableStyles.cellHeight;
|
||||
}
|
||||
|
||||
const pageSize = Math.round(listHeight / tableStyles.rowHeight) - 1;
|
||||
|
||||
useEffect(() => {
|
||||
// Don't update the page size if it is less than 1
|
||||
if (pageSize <= 0) {
|
||||
return;
|
||||
}
|
||||
setPageSize(pageSize);
|
||||
}, [pageSize, setPageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset page index when data changes
|
||||
// This is needed because react-table does not do this automatically
|
||||
// autoResetPage is set to false because setting it to true causes the issue described in
|
||||
// https://github.com/grafana/grafana/pull/67477
|
||||
if (data.length / pageSize < state.pageIndex) {
|
||||
gotoPage(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
useResetVariableListSizeCache(extendedState, listRef, data, hasUniqueId);
|
||||
useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(toPage: number) => {
|
||||
gotoPage(toPage - 1);
|
||||
},
|
||||
[gotoPage]
|
||||
);
|
||||
|
||||
const itemCount = enablePagination ? page.length : rows.length;
|
||||
let paginationEl = null;
|
||||
if (enablePagination) {
|
||||
const itemsRangeStart = state.pageIndex * state.pageSize + 1;
|
||||
let itemsRangeEnd = itemsRangeStart + state.pageSize - 1;
|
||||
const isSmall = width < 550;
|
||||
if (itemsRangeEnd > data.length) {
|
||||
itemsRangeEnd = data.length;
|
||||
}
|
||||
const numRows = rows.length;
|
||||
const displayedEnd = itemsRangeEnd < rows.length ? itemsRangeEnd : rows.length;
|
||||
paginationEl = (
|
||||
<div className={tableStyles.paginationWrapper}>
|
||||
<Pagination
|
||||
currentPage={state.pageIndex + 1}
|
||||
numberOfPages={pageOptions.length}
|
||||
showSmallVersion={isSmall}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
{isSmall ? null : (
|
||||
<div className={tableStyles.paginationSummary}>
|
||||
<Trans i18nKey="grafana-ui.table.pagination-summary">
|
||||
{{ itemsRangeStart }} - {{ displayedEnd }} of {{ numRows }} rows
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Try to determine the longest field
|
||||
// TODO: do we wrap only one field?
|
||||
// What if there are multiple fields with long text?
|
||||
const longestField = fieldConfig ? guessLongestField(fieldConfig, data) : undefined;
|
||||
let textWrapField = undefined;
|
||||
if (fieldConfig !== undefined) {
|
||||
data.fields.forEach((field) => {
|
||||
fieldConfig.overrides.forEach((override) => {
|
||||
const matcher = getFieldMatcher(override.matcher);
|
||||
if (matcher(field, data, [data])) {
|
||||
for (const property of override.properties) {
|
||||
if (property.id === 'custom.cellOptions' && property.value.wrapText) {
|
||||
textWrapField = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getTableProps()}
|
||||
className={tableStyles.table}
|
||||
aria-label={ariaLabel}
|
||||
role="table"
|
||||
ref={tableDivRef}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<CustomScrollbar hideVerticalTrack={true}>
|
||||
<div className={tableStyles.tableContentWrapper(totalColumnsWidth)}>
|
||||
{!noHeader && (
|
||||
<HeaderRow headerGroups={headerGroups} showTypeIcons={showTypeIcons} tableStyles={tableStyles} />
|
||||
)}
|
||||
{itemCount > 0 ? (
|
||||
<div data-testid={selectors.components.Panels.Visualization.Table.body} ref={variableSizeListScrollbarRef}>
|
||||
<RowsList
|
||||
headerGroups={headerGroups}
|
||||
data={data}
|
||||
rows={rows}
|
||||
width={width}
|
||||
cellHeight={cellHeight}
|
||||
headerHeight={headerHeight}
|
||||
rowHeight={tableStyles.rowHeight}
|
||||
itemCount={itemCount}
|
||||
pageIndex={state.pageIndex}
|
||||
listHeight={listHeight}
|
||||
listRef={listRef}
|
||||
tableState={state}
|
||||
prepareRow={prepareRow}
|
||||
timeRange={timeRange}
|
||||
onCellFilterAdded={onCellFilterAdded}
|
||||
nestedDataField={nestedDataField}
|
||||
tableStyles={tableStyles}
|
||||
footerPaginationEnabled={Boolean(enablePagination)}
|
||||
enableSharedCrosshair={enableSharedCrosshair}
|
||||
initialRowIndex={initialRowIndex}
|
||||
longestField={longestField}
|
||||
textWrapField={textWrapField}
|
||||
getActions={getActions}
|
||||
replaceVariables={replaceVariables}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: height - headerHeight, width }} className={tableStyles.noData}>
|
||||
{noValuesDisplayText}
|
||||
</div>
|
||||
)}
|
||||
{footerItems && (
|
||||
<FooterRow
|
||||
isPaginationVisible={Boolean(enablePagination)}
|
||||
footerValues={footerItems}
|
||||
footerGroups={footerGroups}
|
||||
totalColumnsWidth={totalColumnsWidth}
|
||||
tableStyles={tableStyles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
{paginationEl}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Table.displayName = 'Table';
|
||||
@@ -2,7 +2,13 @@ import { useCallback } from 'react';
|
||||
|
||||
import { getFieldDisplayName } from '@grafana/data';
|
||||
|
||||
import { TableSortByFieldState, GrafanaTableColumn, GrafanaTableState, TableStateReducerProps, Props } from './types';
|
||||
import {
|
||||
TableSortByFieldState,
|
||||
GrafanaTableColumn,
|
||||
GrafanaTableState,
|
||||
TableStateReducerProps,
|
||||
GeneralTableProps,
|
||||
} from './types';
|
||||
|
||||
export interface ActionType {
|
||||
type: string;
|
||||
@@ -63,7 +69,7 @@ export function useTableStateReducer({ onColumnResize, onSortByChange, data }: T
|
||||
}
|
||||
|
||||
export function getInitialState(
|
||||
initialSortBy: Props['initialSortBy'],
|
||||
initialSortBy: GeneralTableProps['initialSortBy'],
|
||||
columns: GrafanaTableColumn[]
|
||||
): Partial<GrafanaTableState> {
|
||||
const state: Partial<GrafanaTableState> = {};
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import * as schema from '@grafana/schema';
|
||||
|
||||
import { TableCellInspectorMode } from './TableCellInspector';
|
||||
import { TableStyles } from './styles';
|
||||
import { TableStyles } from './TableRT/styles';
|
||||
|
||||
export {
|
||||
type FieldTextAlignment,
|
||||
@@ -96,7 +96,8 @@ export interface TableStateReducerProps {
|
||||
data: DataFrame;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
// export interface Props {
|
||||
export interface BaseTableProps {
|
||||
ariaLabel?: string;
|
||||
data: DataFrame;
|
||||
width: number;
|
||||
@@ -125,6 +126,22 @@ export interface Props {
|
||||
replaceVariables?: InterpolateFunction;
|
||||
}
|
||||
|
||||
export interface GeneralTableProps extends BaseTableProps {
|
||||
// Should the next generation table based off of react-data-grid be used
|
||||
// 🗻 BIG 🗻 if true
|
||||
useTableNg?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the react-data-grid based table.
|
||||
*/
|
||||
export interface TableNGProps extends BaseTableProps {}
|
||||
|
||||
/**
|
||||
* Props for the react-table based table.
|
||||
*/
|
||||
export interface TableRTProps extends BaseTableProps {}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* Props that will be passed to the TableCustomCellOptions.cellComponent when rendered.
|
||||
|
||||
@@ -32,16 +32,15 @@ import {
|
||||
import { getTextColorForAlphaBackground } from '../../utils';
|
||||
|
||||
import { ActionsCell } from './ActionsCell';
|
||||
import { BarGaugeCell } from './BarGaugeCell';
|
||||
import { DataLinksCell } from './DataLinksCell';
|
||||
import { DefaultCell } from './DefaultCell';
|
||||
import { getFooterValue } from './FooterRow';
|
||||
import { GeoCell } from './GeoCell';
|
||||
import { ImageCell } from './ImageCell';
|
||||
import { JSONViewCell } from './JSONViewCell';
|
||||
import { RowExpander } from './RowExpander';
|
||||
import { SparklineCell } from './SparklineCell';
|
||||
import { TableStyles } from './styles';
|
||||
import { BarGaugeCell } from './Cells/BarGaugeCell';
|
||||
import { DataLinksCell } from './Cells/DataLinksCell';
|
||||
import { DefaultCell } from './Cells/DefaultCell';
|
||||
import { GeoCell } from './Cells/GeoCell';
|
||||
import { ImageCell } from './Cells/ImageCell';
|
||||
import { JSONViewCell } from './Cells/JSONViewCell';
|
||||
import { SparklineCell } from './Cells/SparklineCell';
|
||||
import { getFooterValue } from './TableRT/FooterRow';
|
||||
import { RowExpander } from './TableRT/RowExpander';
|
||||
import {
|
||||
CellColors,
|
||||
CellComponent,
|
||||
@@ -604,12 +603,12 @@ export function calculateAroundPointThreshold(timeField: Field): number {
|
||||
* @returns CellColors
|
||||
*/
|
||||
export function getCellColors(
|
||||
tableStyles: TableStyles,
|
||||
theme: GrafanaTheme2,
|
||||
cellOptions: TableCellOptions,
|
||||
displayValue: DisplayValue
|
||||
): CellColors {
|
||||
// How much to darken elements depends upon if we're in dark mode
|
||||
const darkeningFactor = tableStyles.theme.isDark ? 1 : -0.7;
|
||||
const darkeningFactor = theme.isDark ? 1 : -0.7;
|
||||
|
||||
// Setup color variables
|
||||
let textColor: string | undefined = undefined;
|
||||
@@ -622,7 +621,7 @@ export function getCellColors(
|
||||
const mode = cellOptions.mode ?? TableCellBackgroundDisplayMode.Gradient;
|
||||
|
||||
if (mode === TableCellBackgroundDisplayMode.Basic) {
|
||||
textColor = getTextColorForAlphaBackground(displayValue.color!, tableStyles.theme.isDark);
|
||||
textColor = getTextColorForAlphaBackground(displayValue.color!, theme.isDark);
|
||||
bgColor = tinycolor(displayValue.color).toRgbString();
|
||||
bgHoverColor = tinycolor(displayValue.color).setAlpha(1).toRgbString();
|
||||
} else if (mode === TableCellBackgroundDisplayMode.Gradient) {
|
||||
@@ -630,7 +629,7 @@ export function getCellColors(
|
||||
const bgColor2 = tinycolor(displayValue.color)
|
||||
.darken(10 * darkeningFactor)
|
||||
.spin(5);
|
||||
textColor = getTextColorForAlphaBackground(displayValue.color!, tableStyles.theme.isDark);
|
||||
textColor = getTextColorForAlphaBackground(displayValue.color!, theme.isDark);
|
||||
bgColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${displayValue.color})`;
|
||||
bgHoverColor = `linear-gradient(120deg, ${bgColor2.setAlpha(1).toRgbString()}, ${hoverColor})`;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,9 @@ export {
|
||||
FILTER_FOR_OPERATOR,
|
||||
FILTER_OUT_OPERATOR,
|
||||
} from '../components/Table/types';
|
||||
export { defaultSparklineCellConfig } from '../components/Table/SparklineCell';
|
||||
export { TableCell } from '../components/Table/TableCell';
|
||||
export { useTableStyles } from '../components/Table/styles';
|
||||
export { defaultSparklineCellConfig } from '../components/Table/Cells/SparklineCell';
|
||||
export { TableCell } from '../components/Table/Cells/TableCell';
|
||||
export { useTableStyles } from '../components/Table/TableRT/styles';
|
||||
export { migrateTableDisplayModeToCellOptions } from '../components/Table/utils';
|
||||
export { type DataLinksContextMenuApi } from '../components/DataLinks/DataLinksContextMenu';
|
||||
export { MenuDivider } from '../components/Menu/MenuDivider';
|
||||
|
||||
@@ -28,6 +28,7 @@ const (
|
||||
enterpriseDatasourcesSquad codeowner = "@grafana/enterprise-datasources"
|
||||
grafanaSharingSquad codeowner = "@grafana/sharing-squad"
|
||||
grafanaDatabasesFrontend codeowner = "@grafana/databases-frontend"
|
||||
grafanaMesaVerde codeowner = "@grafana/mesa-verde"
|
||||
grafanaOSSBigTent codeowner = "@grafana/oss-big-tent"
|
||||
growthAndOnboarding codeowner = "@grafana/growth-and-onboarding"
|
||||
grafanaDatasourcesCoreServicesSquad codeowner = "@grafana/grafana-datasources-core-services"
|
||||
|
||||
@@ -1283,6 +1283,12 @@ var (
|
||||
Owner: grafanaDashboardsSquad,
|
||||
Expression: "true", // enabled by default
|
||||
},
|
||||
{
|
||||
Name: "tableNextGen",
|
||||
Description: "Allows access to the new react-data-grid based table component.",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaDatavizSquad,
|
||||
},
|
||||
{
|
||||
Name: "lokiSendDashboardPanelNames",
|
||||
Description: "Send dashboard and panel names to Loki when querying",
|
||||
|
||||
@@ -167,6 +167,7 @@ prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,fa
|
||||
alertingFilterV2,experimental,@grafana/alerting-squad,false,false,false
|
||||
dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
newFiltersUI,GA,@grafana/dashboards-squad,false,false,false
|
||||
tableNextGen,experimental,@grafana/dataviz-squad,false,false,false
|
||||
lokiSendDashboardPanelNames,experimental,@grafana/observability-logs,false,false,false
|
||||
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
|
||||
exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true
|
||||
|
||||
|
@@ -679,6 +679,10 @@ const (
|
||||
// Enables new combobox style UI for the Ad hoc filters variable in scenes architecture
|
||||
FlagNewFiltersUI = "newFiltersUI"
|
||||
|
||||
// FlagTableNextGen
|
||||
// Allows access to the new react-data-grid based table component.
|
||||
FlagTableNextGen = "tableNextGen"
|
||||
|
||||
// FlagLokiSendDashboardPanelNames
|
||||
// Send dashboard and panel names to Loki when querying
|
||||
FlagLokiSendDashboardPanelNames = "lokiSendDashboardPanelNames"
|
||||
|
||||
@@ -3978,6 +3978,31 @@
|
||||
"codeowner": "@grafana/search-and-storage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "tableNG",
|
||||
"resourceVersion": "1742417098717",
|
||||
"creationTimestamp": "2025-03-19T20:44:58Z",
|
||||
"deletionTimestamp": "2025-03-25T17:19:06Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Allows access to the new react-data-grid based table component.",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dataviz-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "tableNextGen",
|
||||
"resourceVersion": "1742923146714",
|
||||
"creationTimestamp": "2025-03-25T17:19:06Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Allows access to the new react-data-grid based table component.",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dataviz-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "tableSharedCrosshair",
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('SearchResultsTable', () => {
|
||||
width={1000}
|
||||
/>
|
||||
);
|
||||
const noData = await screen.findByText('No data');
|
||||
const noData = await screen.findByText('No values');
|
||||
expect(noData).toBeInTheDocument();
|
||||
expect(screen.queryByRole('table', { name: 'Search results table' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Field, GrafanaTheme2 } from '@grafana/data';
|
||||
import { TableCellHeight } from '@grafana/schema';
|
||||
import { useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { useTableStyles, TableCell } from '@grafana/ui/internal';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { useCustomFlexLayout } from 'app/features/browse-dashboards/components/customFlexTableLayout';
|
||||
|
||||
import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection';
|
||||
@@ -168,7 +169,11 @@ export const SearchResultsTable = React.memo(
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
return <div className={styles.noData}>No data</div>;
|
||||
return (
|
||||
<div className={styles.noData}>
|
||||
<Trans i18nKey="grafana-ui.table.no-values-label">No values</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,6 +34,7 @@ export function TablePanel(props: Props) {
|
||||
const hasFields = frames.some((frame) => frame.fields.length > 0);
|
||||
const currentIndex = getCurrentFrameIndex(frames, options);
|
||||
const main = frames[currentIndex];
|
||||
const useTableNg = config.featureToggles.tableNextGen;
|
||||
|
||||
let tableHeight = height;
|
||||
|
||||
@@ -68,6 +69,7 @@ export function TablePanel(props: Props) {
|
||||
timeRange={timeRange}
|
||||
enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair}
|
||||
fieldConfig={fieldConfig}
|
||||
useTableNg={useTableNg}
|
||||
getActions={getCellActions}
|
||||
replaceVariables={replaceVariables}
|
||||
/>
|
||||
|
||||
@@ -2647,6 +2647,7 @@
|
||||
"cell-filter-out": "Filter out value",
|
||||
"cell-inspect": "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",
|
||||
|
||||
Reference in New Issue
Block a user