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:
Drew Slobodnjak
2025-03-25 20:57:57 -07:00
committed by GitHub
parent 0ce28c8dd8
commit 03d6d8f854
62 changed files with 8097 additions and 898 deletions

View File

@@ -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"],

View File

@@ -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
}

View File

@@ -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 |

View File

@@ -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;

View File

@@ -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",

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,
}),
});

View File

@@ -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,
},
}),
});

View File

@@ -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({})
)}
</>
);
};

View File

@@ -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,
},
}),
});

View File

@@ -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>&nbsp;</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 });
}

View File

@@ -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 };

View File

@@ -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,
}),
});

View File

@@ -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,
}),
});

View File

@@ -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',
}),
});

View File

@@ -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,
}),
});

View File

@@ -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',
}),
});

View File

@@ -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,
}),
});

View File

@@ -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),
}),
});

View File

@@ -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();
};

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View 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),
}),
});

View File

@@ -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,
};

View 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;
}

File diff suppressed because it is too large Load Diff

View 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);

View File

@@ -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;

View File

@@ -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';

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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;
};
}

View 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';

View File

@@ -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> = {};

View File

@@ -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.

View File

@@ -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})`;
}

View File

@@ -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';

View File

@@ -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"

View File

@@ -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",

View File

@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
167 alertingFilterV2 experimental @grafana/alerting-squad false false false
168 dataplaneAggregator experimental @grafana/grafana-app-platform-squad false true false
169 newFiltersUI GA @grafana/dashboards-squad false false false
170 tableNextGen experimental @grafana/dataviz-squad false false false
171 lokiSendDashboardPanelNames experimental @grafana/observability-logs false false false
172 alertingPrometheusRulesPrimary experimental @grafana/alerting-squad false false true
173 exploreLogsShardSplitting experimental @grafana/observability-logs false false true

View File

@@ -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"

View File

@@ -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",

View File

@@ -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();
});

View File

@@ -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 (

View File

@@ -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}
/>

View File

@@ -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",

1038
yarn.lock

File diff suppressed because it is too large Load Diff