Table: Enable tableNextGen by default (#109832)

* Table: Enable tableNextGen by default

* kill off tableNextGen feature flag

* i18n

* i18n

* i18n

* remove state beta from table panel

* fix migration for 0 decimals migration

* add explicit auto option bacjk

* match impl from previous migrations code

* try changing some selectors

* remove timezone test from Cypress

* fix PlaylistForm unit test

* fix some selectors

* clean up i18n, are these gridcells somehow?

* return a couple of selectors caught in the dragnet to being cell instead of gridcell

* fix i18n for en-US

* clean up gdevs now that wrapHeaderText has no default

* update role in e2e for when it is re-enabled
This commit is contained in:
Paul Marbach 2025-08-26 17:25:16 -04:00 committed by GitHub
parent 1c13f4a0f2
commit ec38e0bd58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 253 additions and 2650 deletions

View File

@ -3953,9 +3953,6 @@ exports[`better eslint`] = {
"public/app/plugins/panel/table/TableCellOptionEditor.tsx:5381": [ "public/app/plugins/panel/table/TableCellOptionEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
], ],
"public/app/plugins/panel/table/cells/AutoCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/plugins/panel/table/cells/BarGaugeCellOptionsEditor.tsx:5381": [ "public/app/plugins/panel/table/cells/BarGaugeCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"] [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"]
@ -3963,52 +3960,27 @@ exports[`better eslint`] = {
"public/app/plugins/panel/table/cells/ColorBackgroundCellOptionsEditor.tsx:5381": [ "public/app/plugins/panel/table/cells/ColorBackgroundCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"] [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "2"]
], ],
"public/app/plugins/panel/table/cells/ImageCellOptionsEditor.tsx:5381": [ "public/app/plugins/panel/table/cells/ImageCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"] [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"]
], ],
"public/app/plugins/panel/table/cells/MarkdownCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx:5381": [ "public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
], ],
"public/app/plugins/panel/table/cells/TextWrapOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/plugins/panel/table/migrations.ts:5381": [ "public/app/plugins/panel/table/migrations.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [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.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"], [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.", "3"]
], ],
"public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/plugins/panel/table/table-new/cells/BarGaugeCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"]
],
"public/app/plugins/panel/table/table-new/cells/ColorBackgroundCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "2"]
],
"public/app/plugins/panel/table/table-new/cells/ImageCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"]
],
"public/app/plugins/panel/table/table-new/cells/MarkdownCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/plugins/panel/table/table-new/cells/TextWrapOptionsEditor.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/plugins/panel/table/table-new/migrations.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"]
],
"public/app/plugins/panel/text/textPanelMigrationHandler.ts:5381": [ "public/app/plugins/panel/text/textPanelMigrationHandler.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],

View File

@ -1331,4 +1331,4 @@
], ],
"refresh": "", "refresh": "",
"schemaVersion": 41 "schemaVersion": 41
} }

View File

@ -37,8 +37,7 @@
"wrapText": false "wrapText": false
}, },
"filterable": true, "filterable": true,
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"fieldMinMax": true, "fieldMinMax": true,
"links": [], "links": [],
@ -557,8 +556,7 @@
"type": "color-background" "type": "color-background"
}, },
"filterable": true, "filterable": true,
"inspect": true, "inspect": true
"wrapHeaderText": false
}, },
"fieldMinMax": true, "fieldMinMax": true,
"links": [], "links": [],
@ -664,8 +662,7 @@
"type": "auto" "type": "auto"
}, },
"inspect": false, "inspect": false,
"minWidth": 50, "minWidth": 50
"wrapHeaderText": false
}, },
"mappings": [], "mappings": [],
"thresholds": { "thresholds": {
@ -1012,8 +1009,7 @@
"cellOptions": { "cellOptions": {
"type": "auto" "type": "auto"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"fieldMinMax": true, "fieldMinMax": true,
"mappings": [], "mappings": [],
@ -1161,8 +1157,7 @@
"cellOptions": { "cellOptions": {
"type": "auto" "type": "auto"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"fieldMinMax": true, "fieldMinMax": true,
"mappings": [], "mappings": [],
@ -1309,8 +1304,7 @@
"cellOptions": { "cellOptions": {
"type": "auto" "type": "auto"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"fieldMinMax": true, "fieldMinMax": true,
"mappings": [], "mappings": [],
@ -1457,8 +1451,7 @@
"cellOptions": { "cellOptions": {
"type": "auto" "type": "auto"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"fieldMinMax": true, "fieldMinMax": true,
"mappings": [], "mappings": [],
@ -1605,8 +1598,7 @@
"cellOptions": { "cellOptions": {
"type": "auto" "type": "auto"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"fieldMinMax": true, "fieldMinMax": true,
"mappings": [], "mappings": [],
@ -1788,8 +1780,7 @@
"cellOptions": { "cellOptions": {
"type": "auto" "type": "auto"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"mappings": [], "mappings": [],
"thresholds": { "thresholds": {

View File

@ -36,8 +36,7 @@
"cellOptions": { "cellOptions": {
"type": "auto" "type": "auto"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"mappings": [], "mappings": [],
"thresholds": { "thresholds": {

View File

@ -36,8 +36,7 @@
"cellOptions": { "cellOptions": {
"type": "sparkline" "type": "sparkline"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"mappings": [], "mappings": [],
"thresholds": { "thresholds": {
@ -113,8 +112,7 @@
"drawStyle": "bars", "drawStyle": "bars",
"type": "sparkline" "type": "sparkline"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"mappings": [], "mappings": [],
"thresholds": { "thresholds": {
@ -191,8 +189,7 @@
"drawStyle": "points", "drawStyle": "points",
"type": "sparkline" "type": "sparkline"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"mappings": [], "mappings": [],
"thresholds": { "thresholds": {
@ -269,8 +266,7 @@
"drawStyle": "line", "drawStyle": "line",
"type": "sparkline" "type": "sparkline"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"mappings": [], "mappings": [],
"min": 0, "min": 0,
@ -349,8 +345,7 @@
"drawStyle": "line", "drawStyle": "line",
"type": "sparkline" "type": "sparkline"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"mappings": [], "mappings": [],
"max": 100, "max": 100,
@ -430,8 +425,7 @@
"lineInterpolation": "linear", "lineInterpolation": "linear",
"type": "sparkline" "type": "sparkline"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"mappings": [], "mappings": [],
"max": 100, "max": 100,
@ -509,8 +503,7 @@
"cellOptions": { "cellOptions": {
"type": "auto" "type": "auto"
}, },
"inspect": false, "inspect": false
"wrapHeaderText": false
}, },
"mappings": [], "mappings": [],
"thresholds": { "thresholds": {

View File

@ -98,7 +98,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | | `canvasPanelPanZoom` | Allow pan and zoom in canvas panel |
| `regressionTransformation` | Enables regression analysis transformation | | `regressionTransformation` | Enables regression analysis transformation |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage | | `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage |
| `tableNextGen` | Allows access to the new react-data-grid based table component. |
| `enableSCIM` | Enables SCIM support for user and group management | | `enableSCIM` | Enables SCIM support for user and group management |
| `elasticsearchCrossClusterSearch` | Enables cross cluster search in the Elasticsearch datasource | | `elasticsearchCrossClusterSearch` | Enables cross cluster search in the Elasticsearch datasource |
| `alertRuleRestore` | Enables the alert rule restore feature | | `alertRuleRestore` | Enables the alert rule restore feature |

View File

@ -4,7 +4,6 @@ import testDashboard from '../dashboards/DashboardLiveTest.json';
test.use({ test.use({
featureToggles: { featureToggles: {
tableNextGen: true,
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true', kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
}, },
}); });

View File

@ -51,7 +51,7 @@ test.describe(
.getByGrafanaSelector(selectors.components.Panels.Panel.title(title)) .getByGrafanaSelector(selectors.components.Panels.Panel.title(title))
.getByRole('row') .getByRole('row')
.nth(1) .nth(1)
.getByRole('cell') .getByRole('gridcell')
.first(); .first();
const time = await timeCell.textContent(); const time = await timeCell.textContent();
if (time) { if (time) {
@ -79,7 +79,7 @@ test.describe(
.getByGrafanaSelector(selectors.components.Panels.Panel.title(title)) .getByGrafanaSelector(selectors.components.Panels.Panel.title(title))
.getByRole('row') .getByRole('row')
.nth(1) .nth(1)
.getByRole('cell') .getByRole('gridcell')
.first(); .first();
await expect(async () => { await expect(async () => {
const inUtc = timesInUtc[title]; const inUtc = timesInUtc[title];

View File

@ -2,12 +2,6 @@ import { test, expect } from '@grafana/plugin-e2e';
const DASHBOARD_ID = 'P2jR04WVk'; const DASHBOARD_ID = 'P2jR04WVk';
test.use({
featureToggles: {
tableNextGen: true,
},
});
test.describe( test.describe(
'Panels test: Geomap spatial operations', 'Panels test: Geomap spatial operations',
{ {

View File

@ -2,12 +2,6 @@ import { test, expect } from '@grafana/plugin-e2e';
const PANEL_UNDER_TEST = 'Lines 500 data points'; const PANEL_UNDER_TEST = 'Lines 500 data points';
test.use({
featureToggles: {
tableNextGen: true,
},
});
test.describe( test.describe(
'Panels test: Panel edit base', 'Panels test: Panel edit base',
{ {

View File

@ -4,7 +4,7 @@ import { test, expect, E2ESelectorGroups } from '@grafana/plugin-e2e';
const DASHBOARD_UID = 'dcb9f5e9-8066-4397-889e-864b99555dbb'; const DASHBOARD_UID = 'dcb9f5e9-8066-4397-889e-864b99555dbb';
test.use({ viewport: { width: 2000, height: 1080 }, featureToggles: { tableNextGen: true } }); test.use({ viewport: { width: 2000, height: 1080 } });
// helper utils // helper utils
const waitForTableLoad = async (loc: Page | Locator) => { const waitForTableLoad = async (loc: Page | Locator) => {

View File

@ -2,9 +2,6 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({ test.use({
viewport: { width: 1280, height: 1080 }, viewport: { width: 1280, height: 1080 },
featureToggles: {
tableNextGen: true,
},
}); });
test.describe( test.describe(

View File

@ -1,6 +1,6 @@
import { test, expect } from '@grafana/plugin-e2e'; import { test, expect } from '@grafana/plugin-e2e';
test.use({ viewport: { width: 1280, height: 1080 }, featureToggles: { tableNextGen: true } }); test.use({ viewport: { width: 1280, height: 1080 } });
test.describe('Panels test: Table - Sparkline', { tag: ['@panels', '@table'] }, () => { test.describe('Panels test: Table - Sparkline', { tag: ['@panels', '@table'] }, () => {
test('Tests sparkline tables are successfully rendered', async ({ gotoDashboardPage, selectors, page }) => { test('Tests sparkline tables are successfully rendered', async ({ gotoDashboardPage, selectors, page }) => {

View File

@ -38,7 +38,7 @@ test.describe(
formatExpectError('Could not locate header elements in table panel') formatExpectError('Could not locate header elements in table panel')
).toContainText(['col1', 'col2']); ).toContainText(['col1', 'col2']);
await expect( await expect(
panelEditPage.panel.data, panelEditPage.panel.locator.getByRole('gridcell'),
formatExpectError('Could not locate headers in table panel') formatExpectError('Could not locate headers in table panel')
).toContainText(['val1', 'val2', 'val3', 'val4']); ).toContainText(['val1', 'val2', 'val3', 'val4']);
}); });
@ -58,7 +58,7 @@ test.describe(
formatExpectError('Could not locate header elements in table panel') formatExpectError('Could not locate header elements in table panel')
).toContainText(['col1', 'col2']); ).toContainText(['col1', 'col2']);
await expect( await expect(
panelEditPage.panel.data, panelEditPage.panel.locator.getByRole('gridcell'),
formatExpectError('Could not locate data elements in table panel') formatExpectError('Could not locate data elements in table panel')
).toContainText(['val1', 'val2', 'val3', 'val4']); ).toContainText(['val1', 'val2', 'val3', 'val4']);
}); });

View File

@ -11,12 +11,6 @@ const STANDARD_OTIONS_CATEGORY = 'Standard options';
const DISPLAY_NAME_LABEL = 'Display name'; const DISPLAY_NAME_LABEL = 'Display name';
const REACT_TABLE_DASHBOARD = { uid: 'U_bZIMRMk' }; const REACT_TABLE_DASHBOARD = { uid: 'U_bZIMRMk' };
test.use({
featureToggles: {
tableNextGen: true,
},
});
test.describe( test.describe(
'plugin-e2e-api-tests admin', 'plugin-e2e-api-tests admin',
{ {

View File

@ -190,7 +190,7 @@ test.describe(
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
// Check the table cells in the panel // Check the table cells in the panel
const panelCells = panel.locator('[role="table"] [role="cell"]'); const panelCells = panel.locator('[role="gridcell"]');
// Should have 3 columns // Should have 3 columns
await expect(panelCells).toHaveCount(3); await expect(panelCells).toHaveCount(3);
@ -198,7 +198,7 @@ test.describe(
await expect(page.getByText('"wave":-0.5877852522916832')).toBeVisible(); await expect(page.getByText('"wave":-0.5877852522916832')).toBeVisible();
// Column has correct value of "targetLabelValue" // Column has correct value of "targetLabelValue"
await expect(panel.locator('[role="table"] [role="cell"]').filter({ hasText: 'targetLabelValue' })).toBeVisible(); await expect(panel.locator('[role="gridcell"]').filter({ hasText: 'targetLabelValue' })).toBeVisible();
}); });
} }
); );

View File

@ -1,11 +1,5 @@
import { test, expect } from '@grafana/plugin-e2e'; import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
tableNextGen: true,
},
});
test.describe( test.describe(
'Visualization suggestions', 'Visualization suggestions',
{ {

View File

@ -1,16 +0,0 @@
import testDashboard from '../dashboards/DashboardLiveTest.json';
import { e2e } from '../utils';
describe('Dashboard Live streaming support', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
e2e.flows.importDashboard(testDashboard, 1000);
});
it('Should receive streaming data', () => {
e2e.flows.openDashboard({ uid: 'live-e2e-test', queryParams: { '__feature.tableNextGen': false } });
cy.wait(1000);
e2e.components.Panels.Panel.title('Live').should('exist');
e2e.components.Panels.Visualization.Table.body().find('[role="row"]').should('have.length.at.least', 5);
});
});

View File

@ -1,263 +0,0 @@
import {
addDays,
addHours,
differenceInCalendarDays,
differenceInMinutes,
format,
isBefore,
parseISO,
toDate,
} from 'date-fns';
import { e2e } from '../utils';
describe('Dashboard time zone support', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it.skip('Tests dashboard time zone scenarios', () => {
e2e.flows.openDashboard({ uid: '5SdHCasdf' });
const fromTimeZone = 'UTC';
const toTimeZone = 'America/Chicago';
const offset = offsetBetweenTimeZones(toTimeZone, fromTimeZone);
const panelsToCheck = [
'Random walk series',
'Millisecond res x-axis and tooltip',
'2 yaxis and axis labels',
'Stacking value ontop of nulls',
'Null between points',
'Legend Table No Scroll Visible',
];
const timesInUtc: Record<string, string> = {};
for (const title of panelsToCheck) {
e2e.components.Panels.Panel.title(title)
.should('be.visible')
.within(() => {
e2e.components.Panels.Visualization.Graph.xAxis.labels().should('be.visible');
e2e.components.Panels.Visualization.Graph.xAxis
.labels()
.last()
.should((element) => {
timesInUtc[title] = element.text();
});
});
}
e2e.components.PageToolbar.item('Dashboard settings').click();
e2e.components.TimeZonePicker.containerV2()
.should('be.visible')
.within(() => {
e2e.components.Select.singleValue().should('have.text', 'Coordinated Universal Time');
e2e.components.Select.input().should('be.visible').click();
});
e2e.components.Select.option().should('be.visible').contains(toTimeZone).click();
// click to go back to the dashboard.
e2e.pages.Dashboard.Settings.Actions.close().click();
e2e.components.RefreshPicker.runButtonV2().should('be.visible').click();
for (const title of panelsToCheck) {
e2e.components.Panels.Panel.title(title)
.should('be.visible')
.within(() => {
e2e.components.Panels.Visualization.Graph.xAxis.labels().should('be.visible');
e2e.components.Panels.Visualization.Graph.xAxis
.labels()
.last()
.should((element) => {
const inUtc = timesInUtc[title];
const inTz = element.text();
const isCorrect = isTimeCorrect(inUtc, inTz, offset);
expect(isCorrect).to.be.equal(true);
});
});
}
});
it('Tests relative timezone support and overrides', () => {
// Open dashboard
e2e.flows.openDashboard({
uid: 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8',
});
cy.intercept('/api/ds/query*').as('dataQuery');
// Switch to Browser timezone
e2e.flows.setTimeRange({
from: 'now-6h',
to: 'now',
zone: 'Browser',
});
// Need to wait for 2 calls as there's 2 panels
cy.wait(['@dataQuery', '@dataQuery']);
e2e.components.Panels.Panel.title('Panel with relative time override')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
// Today so far, still in Browser timezone
e2e.flows.setTimeRange({
from: 'now/d',
to: 'now',
});
// Need to wait for 2 calls as there's 2 panels
cy.wait(['@dataQuery', '@dataQuery']);
e2e.components.Panels.Panel.title('Panel with relative time override')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
e2e.components.Panels.Panel.title('Panel in timezone')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
// Test UTC timezone
e2e.flows.setTimeRange({
from: 'now-6h',
to: 'now',
zone: 'Coordinated Universal Time',
});
// Need to wait for 2 calls as there's 2 panels
cy.wait(['@dataQuery', '@dataQuery']);
e2e.components.Panels.Panel.title('Panel with relative time override')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
// Today so far, still in UTC timezone
e2e.flows.setTimeRange({
from: 'now/d',
to: 'now',
});
// Need to wait for 2 calls as there's 2 panels
cy.wait(['@dataQuery', '@dataQuery']);
e2e.components.Panels.Panel.title('Panel with relative time override')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
e2e.components.Panels.Panel.title('Panel in timezone')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
// Test Tokyo timezone
e2e.flows.setTimeRange({
from: 'now-6h',
to: 'now',
zone: 'Asia/Tokyo',
});
// Need to wait for 2 calls as there's 2 panels
cy.wait(['@dataQuery', '@dataQuery']);
e2e.components.Panels.Panel.title('Panel with relative time override')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
// Today so far, still in Tokyo timezone
e2e.flows.setTimeRange({
from: 'now/d',
to: 'now',
});
// Need to wait for 2 calls as there's 2 panels
cy.wait(['@dataQuery', '@dataQuery']);
e2e.components.Panels.Panel.title('Panel with relative time override')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
e2e.components.Panels.Panel.title('Panel in timezone')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
// Test LA timezone
e2e.flows.setTimeRange({
from: 'now-6h',
to: 'now',
zone: 'America/Los Angeles',
});
// Need to wait for 2 calls as there's 2 panels
cy.wait(['@dataQuery', '@dataQuery']);
e2e.components.Panels.Panel.title('Panel with relative time override')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
// Today so far, still in LA timezone
e2e.flows.setTimeRange({
from: 'now/d',
to: 'now',
});
// Need to wait for 2 calls as there's 2 panels
cy.wait(['@dataQuery', '@dataQuery']);
e2e.components.Panels.Panel.title('Panel with relative time override')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
e2e.components.Panels.Panel.title('Panel in timezone')
.should('be.visible')
.within(() => {
cy.contains('[role="row"]', '00:00:00').should('be.visible');
});
});
});
const isTimeCorrect = (inUtc: string, inTz: string, offset: number): boolean => {
if (inUtc === inTz) {
// we need to catch issues when timezone isn't changed for some reason like https://github.com/grafana/grafana/issues/35504
return false;
}
const reference = format(new Date(), 'yyyy-LL-dd');
const utcDate = toDate(parseISO(`${reference} ${inUtc}`));
const utcDateWithOffset = addHours(toDate(parseISO(`${reference} ${inUtc}`)), offset);
const dayDifference = differenceInCalendarDays(utcDate, utcDateWithOffset); // if the utcDate +/- offset is the day before/after then we need to adjust reference
const dayOffset = isBefore(utcDateWithOffset, utcDate) ? dayDifference * -1 : dayDifference;
const tzDate = addDays(toDate(parseISO(`${reference} ${inTz}`)), dayOffset); // adjust tzDate with any dayOffset
const diff = Math.abs(differenceInMinutes(utcDate, tzDate)); // use Math.abs if tzDate is in future
return diff <= Math.abs(offset * 60);
};
const offsetBetweenTimeZones = (timeZone1: string, timeZone2: string, when: Date = new Date()): number => {
const t1 = convertDateToAnotherTimeZone(when, timeZone1);
const t2 = convertDateToAnotherTimeZone(when, timeZone2);
return (t1.getTime() - t2.getTime()) / (1000 * 60 * 60);
};
const convertDateToAnotherTimeZone = (date: Date, timeZone: string): Date => {
const dateString = date.toLocaleString('en-US', {
timeZone: timeZone,
});
return new Date(dateString);
};

View File

@ -1,98 +0,0 @@
import { e2e } from '../utils';
const DASHBOARD_ID = 'P2jR04WVk';
describe.skip('Geomap spatial operations', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it.skip('Tests location auto option', () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.tableNextGen': false, editPanel: 1 } });
e2e.components.Tab.title('Transform data').should('be.visible').click();
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click();
e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
e2e.components.Transforms.SpatialOperations.locationLabel().should('be.visible');
e2e.components.Transforms.SpatialOperations.locationLabel().type('Auto{enter}');
e2e.components.PanelEditor.toggleTableView().click({ force: true });
e2e.components.Panels.Visualization.Table.header()
.should('be.visible')
.within(() => {
cy.contains('Point').should('be.visible');
});
});
it('Tests location coords option', () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.tableNextGen': false, editPanel: 1 } });
e2e.components.Tab.title('Transform data').should('be.visible').click();
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click();
e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
e2e.components.Transforms.SpatialOperations.locationLabel().should('be.visible');
e2e.components.Transforms.SpatialOperations.locationLabel().type('Coords{enter}');
e2e.components.Transforms.SpatialOperations.location.coords.latitudeFieldLabel().should('be.visible');
e2e.components.Transforms.SpatialOperations.location.coords.latitudeFieldLabel().type('Lat{enter}');
e2e.components.Transforms.SpatialOperations.location.coords.longitudeFieldLabel().should('be.visible');
e2e.components.Transforms.SpatialOperations.location.coords.longitudeFieldLabel().type('Lng{enter}');
e2e.components.PanelEditor.toggleTableView().click({ force: true });
e2e.components.Panels.Visualization.Table.header()
.should('be.visible')
.within(() => {
cy.contains('Point').should('be.visible');
});
});
it('Tests geoshash field column appears in table view', () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.tableNextGen': false, editPanel: 1 } });
e2e.components.Tab.title('Transform data').should('be.visible').click();
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click();
e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
e2e.components.Transforms.SpatialOperations.locationLabel().should('be.visible');
e2e.components.Transforms.SpatialOperations.locationLabel().type('Geohash{enter}');
e2e.components.Transforms.SpatialOperations.location.geohash
.geohashFieldLabel()
.should('be.visible')
.type('State{enter}');
e2e.components.PanelEditor.toggleTableView().click({ force: true });
e2e.components.Panels.Visualization.Table.header()
.should('be.visible')
.within(() => {
cy.contains('State 1').should('be.visible');
});
});
it('Tests location lookup option', () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { '__feature.tableNextGen': false, editPanel: 1 } });
e2e.components.Tab.title('Transform data').should('be.visible').click();
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click();
e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
e2e.components.Transforms.SpatialOperations.locationLabel().should('be.visible');
e2e.components.Transforms.SpatialOperations.locationLabel().type('Lookup{enter}');
e2e.components.Transforms.SpatialOperations.location.lookup.lookupFieldLabel().should('be.visible');
e2e.components.Transforms.SpatialOperations.location.lookup.lookupFieldLabel().type('State{enter}');
e2e.components.Transforms.SpatialOperations.location.lookup.gazetteerFieldLabel().should('be.visible');
e2e.components.Transforms.SpatialOperations.location.lookup.gazetteerFieldLabel().type('USA States{enter}');
e2e.components.PanelEditor.toggleTableView().click({ force: true });
e2e.components.Panels.Visualization.Table.header()
.should('be.visible')
.within(() => {
cy.contains('Geometry').should('be.visible');
});
});
});

View File

@ -1,108 +0,0 @@
import { e2e } from '../utils';
const PANEL_UNDER_TEST = 'Lines 500 data points';
describe('Panel edit tests', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('Tests various Panel edit scenarios', () => {
cy.intercept({
pathname: '/api/ds/query',
}).as('query');
e2e.flows.openDashboard({ uid: 'TkZXxlNG3', queryParams: { '__feature.tableNextGen': false } });
cy.wait('@query');
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Edit, PANEL_UNDER_TEST);
// New panel editor opens when navigating from Panel menu
e2e.components.PanelEditor.General.content().should('be.visible');
// Queries tab is rendered and open by default
e2e.components.PanelEditor.DataPane.content()
.should('be.visible')
.within(() => {
e2e.components.Tab.title('Query').should('be.visible');
// data should be the active tab
e2e.components.Tab.active().within((li: JQuery<HTMLLIElement>) => {
expect(li.text()).equals('Query1'); // there's already a query so therefore Query + 1
});
e2e.components.QueryTab.content().should('be.visible');
e2e.components.TransformTab.content().should('not.exist');
e2e.components.AlertTab.content().should('not.exist');
e2e.components.PanelAlertTabContent.content().should('not.exist');
// Bottom pane tabs
// Can change to Transform tab
e2e.components.Tab.title('Transform data').should('be.visible').click();
e2e.components.Tab.active().within((li: JQuery<HTMLLIElement>) => {
expect(li.text()).equals('Transform data0'); // there's no transform so therefore Transform + 0
});
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible');
e2e.components.QueryTab.content().should('not.exist');
e2e.components.AlertTab.content().should('not.exist');
e2e.components.PanelAlertTabContent.content().should('not.exist');
// Can change to Alerts tab (graph panel is the default vis so the alerts tab should be rendered)
e2e.components.Tab.title('Alert').should('be.visible').click();
e2e.components.Tab.active().should('have.text', 'Alert0'); // there's no alert so therefore Alert + 0
// Needs to be disabled until Grafana EE turns unified alerting on by default
// e2e.components.AlertTab.content().should('not.exist');
e2e.components.QueryTab.content().should('not.exist');
e2e.components.TransformTab.content().should('not.exist');
// Needs to be disabled until Grafana EE turns unified alerting on by default
// e2e.components.PanelAlertTabContent.content().should('exist');
// e2e.components.PanelAlertTabContent.content().should('be.visible');
e2e.components.Tab.title('Query').should('be.visible').click();
});
// Panel sidebar is rendered open by default
e2e.components.PanelEditor.OptionsPane.content().should('be.visible');
// close options pane
e2e.components.PanelEditor.toggleVizOptions().click();
e2e.components.PanelEditor.OptionsPane.content().should('not.exist');
// open options pane
e2e.components.PanelEditor.toggleVizOptions().should('be.visible').click();
e2e.components.PanelEditor.OptionsPane.content().should('be.visible');
// Check that Time series is chosen
e2e.components.PanelEditor.toggleVizPicker().click();
e2e.components.PluginVisualization.item('Time series').should('be.visible');
e2e.components.PluginVisualization.current().should((e) => expect(e).to.contain('Time series'));
// Check that table view works
e2e.components.Panels.Panel.loadingBar().should('not.exist');
e2e.components.PanelEditor.toggleTableView().click({ force: true });
e2e.components.Panels.Visualization.Table.header()
.should('be.visible')
.within(() => {
cy.contains('A-series').should('be.visible');
});
// Change to Text panel
e2e.components.PluginVisualization.item('Text').scrollIntoView().should('be.visible').click();
e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain('Text'));
// Data pane should not be rendered
e2e.components.PanelEditor.DataPane.content().should('not.exist');
// Change to Table panel
e2e.components.PanelEditor.toggleVizPicker().click();
e2e.components.PluginVisualization.item('Table').scrollIntoView().should('be.visible').click();
e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain('Table'));
// Data pane should be rendered
e2e.components.PanelEditor.DataPane.content().should('be.visible');
// Field & Overrides tabs (need to switch to React based vis, i.e. Table)
e2e.components.PanelEditor.OptionsPane.fieldLabel('Table Show table header').should('be.visible');
e2e.components.PanelEditor.OptionsPane.fieldLabel('Table Column width').should('be.visible');
});
});

View File

@ -1,27 +0,0 @@
import { e2e } from '../utils';
describe('Visualization suggestions', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
});
it('Should be shown and clickable', () => {
e2e.flows.openDashboard({ uid: 'aBXrJ0R7z', queryParams: { '__feature.tableNextGen': false, editPanel: 9 } });
// Try visualization suggestions
e2e.components.PanelEditor.toggleVizPicker().click();
e2e.components.RadioButton.container().filter(':contains("Suggestions")').click();
// Verify we see suggestions
e2e.components.VisualizationPreview.card('Line chart').should('be.visible');
// Verify search works
cy.get('[placeholder="Search for..."]').type('Table');
// Should no longer see line chart
e2e.components.VisualizationPreview.card('Line chart').should('not.exist');
// Select a visualisation
e2e.components.VisualizationPreview.card('Table').click();
e2e.components.Panels.Visualization.Table.header().should('be.visible');
});
});

View File

@ -616,10 +616,6 @@ export interface FeatureToggles {
*/ */
newFiltersUI?: boolean; newFiltersUI?: boolean;
/** /**
* Allows access to the new react-data-grid based table component.
*/
tableNextGen?: boolean;
/**
* Uses Prometheus rules as the primary source of truth for ruler-enabled data sources * Uses Prometheus rules as the primary source of truth for ruler-enabled data sources
*/ */
alertingPrometheusRulesPrimary?: boolean; alertingPrometheusRulesPrimary?: boolean;

View File

@ -119,7 +119,7 @@ TableCellTooltipOptions: {
// The name of the field to get the tooltip content from // The name of the field to get the tooltip content from
field: string field: string
// placement of the tooltip // placement of the tooltip
placement?: TableCellTooltipPlacement & (*"auto" | _) placement?: TableCellTooltipPlacement
} }
// Field options for each field within a table (e.g 10, "The String", 64.20, etc.) // Field options for each field within a table (e.g 10, "The String", 64.20, etc.)

View File

@ -25,6 +25,12 @@ export interface Options {
* Represents the index of the selected frame * Represents the index of the selected frame
*/ */
frameIndex: number; frameIndex: number;
/**
* Defines the number of columns to freeze on the left side of the table
*/
frozenColumns?: {
left?: number;
};
/** /**
* Controls whether the panel should show the header * Controls whether the panel should show the header
*/ */

View File

@ -82,7 +82,7 @@ export const DataLinksActionsTooltip = ({ links, actions, value, coords, onToolt
return ( return (
<> <>
{/* TODO: we can remove `value` from this component when tableNextGen is fully rolled out */} {/* TODO: we can remove `value` from this component when TableRT is fully deprecated */}
{value} {value}
<Portal zIndex={theme.zIndex.tooltip}> <Portal zIndex={theme.zIndex.tooltip}>
<div <div

View File

@ -1060,13 +1060,6 @@ var (
Owner: grafanaDashboardsSquad, Owner: grafanaDashboardsSquad,
Expression: "true", // enabled by default Expression: "true", // enabled by default
}, },
{
Name: "tableNextGen",
Description: "Allows access to the new react-data-grid based table component.",
Stage: FeatureStagePublicPreview,
Owner: grafanaDatavizSquad,
FrontendOnly: true,
},
{ {
Name: "alertingPrometheusRulesPrimary", Name: "alertingPrometheusRulesPrimary",
Description: "Uses Prometheus rules as the primary source of truth for ruler-enabled data sources", Description: "Uses Prometheus rules as the primary source of truth for ruler-enabled data sources",

View File

@ -137,7 +137,6 @@ prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,fa
alertingFilterV2,experimental,@grafana/alerting-squad,false,false,false alertingFilterV2,experimental,@grafana/alerting-squad,false,false,false
dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false
newFiltersUI,GA,@grafana/dashboards-squad,false,false,false newFiltersUI,GA,@grafana/dashboards-squad,false,false,false
tableNextGen,preview,@grafana/dataviz-squad,false,false,true
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true
exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
137 alertingFilterV2 experimental @grafana/alerting-squad false false false
138 dataplaneAggregator experimental @grafana/grafana-app-platform-squad false true false
139 newFiltersUI GA @grafana/dashboards-squad false false false
tableNextGen preview @grafana/dataviz-squad false false true
140 alertingPrometheusRulesPrimary experimental @grafana/alerting-squad false false true
141 exploreLogsShardSplitting experimental @grafana/observability-logs false false true
142 exploreLogsAggregatedMetrics experimental @grafana/observability-logs false false true

View File

@ -559,10 +559,6 @@ const (
// Enables new combobox style UI for the Ad hoc filters variable in scenes architecture // Enables new combobox style UI for the Ad hoc filters variable in scenes architecture
FlagNewFiltersUI = "newFiltersUI" FlagNewFiltersUI = "newFiltersUI"
// FlagTableNextGen
// Allows access to the new react-data-grid based table component.
FlagTableNextGen = "tableNextGen"
// FlagAlertingPrometheusRulesPrimary // FlagAlertingPrometheusRulesPrimary
// Uses Prometheus rules as the primary source of truth for ruler-enabled data sources // Uses Prometheus rules as the primary source of truth for ruler-enabled data sources
FlagAlertingPrometheusRulesPrimary = "alertingPrometheusRulesPrimary" FlagAlertingPrometheusRulesPrimary = "alertingPrometheusRulesPrimary"

View File

@ -3055,7 +3055,8 @@
"metadata": { "metadata": {
"name": "tableNextGen", "name": "tableNextGen",
"resourceVersion": "1753448760331", "resourceVersion": "1753448760331",
"creationTimestamp": "2025-03-26T03:57:57Z" "creationTimestamp": "2025-03-26T03:57:57Z",
"deletionTimestamp": "2025-08-19T14:11:23Z"
}, },
"spec": { "spec": {
"description": "Allows access to the new react-data-grid based table component.", "description": "Allows access to the new react-data-grid based table component.",

View File

@ -1,5 +1,3 @@
import { config } from '@grafana/runtime';
const graphitePlugin = async () => const graphitePlugin = async () =>
await import(/* webpackChunkName: "graphitePlugin" */ 'app/plugins/datasource/graphite/module'); await import(/* webpackChunkName: "graphitePlugin" */ 'app/plugins/datasource/graphite/module');
const cloudwatchPlugin = async () => const cloudwatchPlugin = async () =>
@ -56,13 +54,7 @@ const stateTimelinePanel = async () =>
await import(/* webpackChunkName: "stateTimelinePanel" */ 'app/plugins/panel/state-timeline/module'); await import(/* webpackChunkName: "stateTimelinePanel" */ 'app/plugins/panel/state-timeline/module');
const statusHistoryPanel = async () => const statusHistoryPanel = async () =>
await import(/* webpackChunkName: "statusHistoryPanel" */ 'app/plugins/panel/status-history/module'); await import(/* webpackChunkName: "statusHistoryPanel" */ 'app/plugins/panel/status-history/module');
const tablePanel = async () => { const tablePanel = async () => await import(/* webpackChunkName: "tablePanel" */ 'app/plugins/panel/table/module');
if (config.featureToggles.tableNextGen) {
return await import(/* webpackChunkName: "tableNewPanel" */ 'app/plugins/panel/table/table-new/module');
} else {
return await import(/* webpackChunkName: "tablePanel" */ 'app/plugins/panel/table/module');
}
};
const textPanel = async () => await import(/* webpackChunkName: "textPanel" */ 'app/plugins/panel/text/module'); const textPanel = async () => await import(/* webpackChunkName: "textPanel" */ 'app/plugins/panel/text/module');
const timeseriesPanel = async () => const timeseriesPanel = async () =>
await import(/* webpackChunkName: "timeseriesPanel" */ 'app/plugins/panel/timeseries/module'); await import(/* webpackChunkName: "timeseriesPanel" */ 'app/plugins/panel/timeseries/module');

View File

@ -1,15 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import { StandardEditorProps } from '@grafana/data'; import { StandardEditorProps } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Switch } from '@grafana/ui'; import { Switch } from '@grafana/ui';
export function PaginationEditor({ onChange, value, context }: StandardEditorProps<boolean>) { export function PaginationEditor({ onChange, value }: StandardEditorProps<boolean>) {
const changeValue = (event: React.FormEvent<HTMLInputElement> | undefined) => { const changeValue = (event: React.FormEvent<HTMLInputElement> | undefined) => {
if (event?.currentTarget.checked) {
context.options.footer.show = false;
}
onChange(event?.currentTarget.checked); onChange(event?.currentTarget.checked);
}; };
return <Switch value={Boolean(value)} onChange={changeValue} />; return (
<Switch
label={selectors.components.PanelEditor.OptionsPane.fieldLabel(`Enable pagination`)}
value={Boolean(value)}
onChange={changeValue}
/>
);
} }

View File

@ -2,15 +2,17 @@ import { css } from '@emotion/css';
import { merge } from 'lodash'; import { merge } from 'lodash';
import { useState } from 'react'; import { useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { TableCellOptions } from '@grafana/schema'; import { t } from '@grafana/i18n';
import { Field, Select, TableCellDisplayMode, useStyles2 } from '@grafana/ui'; import { TableCellOptions, TableWrapTextOptions } from '@grafana/schema';
import { Combobox, ComboboxOption, Field, TableCellDisplayMode, useStyles2 } from '@grafana/ui';
import { AutoCellOptionsEditor } from './cells/AutoCellOptionsEditor';
import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor'; import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor';
import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor'; import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor';
import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor'; import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor';
import { MarkdownCellOptionsEditor } from './cells/MarkdownCellOptionsEditor';
import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor'; import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor';
import { TextWrapOptionsEditor } from './cells/TextWrapOptionsEditor';
// The props that any cell type editor are expected // The props that any cell type editor are expected
// to handle. In this case the generic type should // to handle. In this case the generic type should
@ -25,18 +27,48 @@ interface Props {
onChange: (v: TableCellOptions) => void; onChange: (v: TableCellOptions) => void;
} }
const TEXT_WRAP_CELL_TYPES = new Set([
TableCellDisplayMode.Auto,
TableCellDisplayMode.Sparkline,
TableCellDisplayMode.ColorText,
TableCellDisplayMode.ColorBackground,
TableCellDisplayMode.DataLinks,
TableCellDisplayMode.Pill,
]);
function isTextWrapCellType(value: TableCellOptions): value is TableCellOptions & TableWrapTextOptions {
return TEXT_WRAP_CELL_TYPES.has(value.type);
}
export const TableCellOptionEditor = ({ value, onChange }: Props) => { export const TableCellOptionEditor = ({ value, onChange }: Props) => {
const cellType = value.type; const cellType = value.type;
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const currentMode = cellDisplayModeOptions.find((o) => o.value!.type === cellType)!; const cellDisplayModeOptions: Array<ComboboxOption<TableCellOptions['type']>> = [
{ value: TableCellDisplayMode.Auto, label: t('table.cell-types.auto', 'Auto') },
{ value: TableCellDisplayMode.ColorText, label: t('table.cell-types.color-text', 'Colored text') },
{
value: TableCellDisplayMode.ColorBackground,
label: t('table.cell-types.color-background', 'Colored background'),
},
{ value: TableCellDisplayMode.DataLinks, label: t('table.cell-types.data-links', 'Data links') },
{ value: TableCellDisplayMode.Gauge, label: t('table.cell-types.gauge', 'Gauge') },
{ value: TableCellDisplayMode.Sparkline, label: t('table.cell-types.sparkline', 'Sparkline') },
{ value: TableCellDisplayMode.JSONView, label: t('table.cell-types.json', 'JSON View') },
{ value: TableCellDisplayMode.Pill, label: t('table.cell-types.pill', 'Pill') },
{ value: TableCellDisplayMode.Markdown, label: t('table.cell-types.markdown', 'Markdown + HTML') },
{ value: TableCellDisplayMode.Image, label: t('table.cell-types.image', 'Image') },
{ value: TableCellDisplayMode.Actions, label: t('table.cell-types.actions', 'Actions') },
];
const currentMode = cellDisplayModeOptions.find((o) => o.value === cellType)!;
let [settingCache, setSettingCache] = useState<Record<string, TableCellOptions>>({}); let [settingCache, setSettingCache] = useState<Record<string, TableCellOptions>>({});
// Update display mode on change // Update display mode on change
const onCellTypeChange = (v: SelectableValue<TableCellOptions>) => { const onCellTypeChange = (v: ComboboxOption<TableCellOptions['type']>) => {
if (v.value !== undefined) { if (v !== null) {
// Set the new type of cell starting // Set the new type of cell starting
// with default settings // with default settings
value = v.value; value = { type: v.value };
// When changing cell type see if there were previously stored // When changing cell type see if there were previously stored
// settings and merge those with the changed value // settings and merge those with the changed value
@ -60,11 +92,9 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
return ( return (
<div className={styles.fixBottomMargin}> <div className={styles.fixBottomMargin}>
<Field> <Field>
<Select options={cellDisplayModeOptions} value={currentMode} onChange={onCellTypeChange} /> <Combobox options={cellDisplayModeOptions} value={currentMode} onChange={onCellTypeChange} />
</Field> </Field>
{(cellType === TableCellDisplayMode.Auto || cellType === TableCellDisplayMode.ColorText) && ( {isTextWrapCellType(value) && <TextWrapOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />}
<AutoCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
{cellType === TableCellDisplayMode.Gauge && ( {cellType === TableCellDisplayMode.Gauge && (
<BarGaugeCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} /> <BarGaugeCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)} )}
@ -77,24 +107,16 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
{cellType === TableCellDisplayMode.Image && ( {cellType === TableCellDisplayMode.Image && (
<ImageCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} /> <ImageCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)} )}
{cellType === TableCellDisplayMode.Markdown && (
<MarkdownCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
</div> </div>
); );
}; };
let cellDisplayModeOptions: Array<SelectableValue<TableCellOptions>> = [
{ value: { type: TableCellDisplayMode.Auto }, label: 'Auto' },
{ value: { type: TableCellDisplayMode.ColorText }, label: 'Colored text' },
{ value: { type: TableCellDisplayMode.ColorBackground }, label: 'Colored background' },
{ value: { type: TableCellDisplayMode.DataLinks }, label: 'Data links' },
{ value: { type: TableCellDisplayMode.Gauge }, label: 'Gauge' },
{ value: { type: TableCellDisplayMode.Sparkline }, label: 'Sparkline' },
{ value: { type: TableCellDisplayMode.JSONView }, label: 'JSON View' },
{ value: { type: TableCellDisplayMode.Image }, label: 'Image' },
{ value: { type: TableCellDisplayMode.Actions }, label: 'Actions' },
];
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
fixBottomMargin: css({ fixBottomMargin: css({
position: 'relative',
marginBottom: theme.spacing(-2), marginBottom: theme.spacing(-2),
}), }),
}); });

View File

@ -1,4 +1,5 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useCallback, useMemo } from 'react';
import { import {
ActionModel, ActionModel,
@ -10,10 +11,13 @@ import {
PanelProps, PanelProps,
SelectableValue, SelectableValue,
Field, Field,
cacheFieldDisplayNames,
} from '@grafana/data'; } from '@grafana/data';
import { config, PanelDataErrorView } from '@grafana/runtime'; import { config, PanelDataErrorView } from '@grafana/runtime';
import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui'; import { Select, usePanelContext, useTheme2 } from '@grafana/ui';
import { TableSortByFieldState } from '@grafana/ui/internal'; import { TableSortByFieldState } from '@grafana/ui/internal';
import { TableNG } from '@grafana/ui/unstable';
import { getConfig } from 'app/core/config';
import { getActions } from '../../../features/actions/utils'; import { getActions } from '../../../features/actions/utils';
@ -23,10 +27,18 @@ import { Options } from './panelcfg.gen';
interface Props extends PanelProps<Options> {} interface Props extends PanelProps<Options> {}
export function TablePanel(props: Props) { export function TablePanel(props: Props) {
const { data, height, width, options, fieldConfig, id, timeRange, replaceVariables } = props; const { data, height, width, options, fieldConfig, id, timeRange, replaceVariables, transparent } = props;
useMemo(() => {
cacheFieldDisplayNames(data.series);
}, [data.series]);
const theme = useTheme2(); const theme = useTheme2();
const panelContext = usePanelContext(); const panelContext = usePanelContext();
const _getActions = useCallback(
(frame: DataFrame, field: Field, rowIndex: number) => getCellActions(frame, field, rowIndex, replaceVariables),
[replaceVariables]
);
const frames = hasDeprecatedParentRowIndex(data.series) const frames = hasDeprecatedParentRowIndex(data.series)
? migrateFromParentRowIndexToNestedFrames(data.series) ? migrateFromParentRowIndexToNestedFrames(data.series)
: data.series; : data.series;
@ -50,8 +62,10 @@ export function TablePanel(props: Props) {
const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off; const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off;
const disableSanitizeHtml = getConfig().disableSanitizeHtml;
const tableElement = ( const tableElement = (
<Table <TableNG
height={tableHeight} height={tableHeight}
width={width} width={width}
data={main} data={main}
@ -63,13 +77,16 @@ export function TablePanel(props: Props) {
onColumnResize={(displayName, resizedWidth) => onColumnResize(displayName, resizedWidth, props)} onColumnResize={(displayName, resizedWidth) => onColumnResize(displayName, resizedWidth, props)}
onCellFilterAdded={panelContext.onAddAdHocFilter} onCellFilterAdded={panelContext.onAddAdHocFilter}
footerOptions={options.footer} footerOptions={options.footer}
frozenColumns={options.frozenColumns?.left}
enablePagination={options.footer?.enablePagination} enablePagination={options.footer?.enablePagination}
cellHeight={options.cellHeight} cellHeight={options.cellHeight}
timeRange={timeRange} timeRange={timeRange}
enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair} enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair}
fieldConfig={fieldConfig} fieldConfig={fieldConfig}
getActions={getCellActions} getActions={_getActions}
replaceVariables={replaceVariables} structureRev={data.structureRev}
transparent={transparent}
disableSanitizeHtml={disableSanitizeHtml}
/> />
); );
@ -151,28 +168,39 @@ const getCellActions = (
field: Field, field: Field,
rowIndex: number, rowIndex: number,
replaceVariables: InterpolateFunction | undefined replaceVariables: InterpolateFunction | undefined
) => { ): Array<ActionModel<Field>> => {
const actions: Array<ActionModel<Field>> = []; const numActions = field.config.actions?.length ?? 0;
const actionLookup = new Set<string>();
const actionsModel = getActions( if (numActions > 0) {
dataFrame, const actions = getActions(
field, dataFrame,
field.state!.scopedVars!, field,
replaceVariables ?? replaceVars, field.state!.scopedVars!,
field.config.actions ?? [], replaceVariables ?? replaceVars,
{ valueRowIndex: rowIndex } field.config.actions ?? [],
); { valueRowIndex: rowIndex }
);
actionsModel.forEach((action) => { if (actions.length === 1) {
const key = `${action.title}`; return actions;
if (!actionLookup.has(key)) { } else {
actions.push(action); const actionsOut: Array<ActionModel<Field>> = [];
actionLookup.add(key); const actionLookup = new Set<string>();
actions.forEach((action) => {
const key = action.title;
if (!actionLookup.has(key)) {
actionsOut.push(action);
actionLookup.add(key);
}
});
return actionsOut;
} }
}); }
return actions; return [];
}; };
const tableStyles = { const tableStyles = {

View File

@ -1,27 +0,0 @@
import { t } from '@grafana/i18n';
import { TableAutoCellOptions, TableColorTextCellOptions } from '@grafana/schema';
import { Field, Switch } from '@grafana/ui';
import { TableCellEditorProps } from '../TableCellOptionEditor';
export const AutoCellOptionsEditor = ({
cellOptions,
onChange,
}: TableCellEditorProps<TableAutoCellOptions | TableColorTextCellOptions>) => {
const onWrapTextChange = () => {
cellOptions.wrapText = !cellOptions.wrapText;
onChange(cellOptions);
};
return (
<Field
label={t('table.auto-cell-options-editor.label-wrap-text', 'Wrap text')}
description={t(
'table.auto-cell-options-editor.description-wrap-text',
'If selected text will be wrapped to the width of text in the configured column'
)}
>
<Switch value={cellOptions.wrapText} onChange={onWrapTextChange} />
</Field>
);
};

View File

@ -1,63 +1,44 @@
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { Trans, t } from '@grafana/i18n'; import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { TableCellBackgroundDisplayMode, TableColoredBackgroundCellOptions } from '@grafana/schema'; import { TableCellBackgroundDisplayMode, TableColoredBackgroundCellOptions } from '@grafana/schema';
import { Field, RadioButtonGroup, Switch, Label, Badge } from '@grafana/ui'; import { Field, RadioButtonGroup, Switch } from '@grafana/ui';
import { TableCellEditorProps } from '../TableCellOptionEditor'; import { TableCellEditorProps } from '../TableCellOptionEditor';
import { TextWrapOptionsEditor } from './TextWrapOptionsEditor';
const colorBackgroundOpts: Array<SelectableValue<TableCellBackgroundDisplayMode>> = [ const colorBackgroundOpts: Array<SelectableValue<TableCellBackgroundDisplayMode>> = [
{ value: TableCellBackgroundDisplayMode.Basic, label: 'Basic' }, { value: TableCellBackgroundDisplayMode.Basic, label: 'Basic' },
{ value: TableCellBackgroundDisplayMode.Gradient, label: 'Gradient' }, { value: TableCellBackgroundDisplayMode.Gradient, label: 'Gradient' },
]; ];
export const ColorBackgroundCellOptionsEditor = ({ export const ColorBackgroundCellOptionsEditor = ({
cellOptions, cellOptions,
onChange, onChange,
}: TableCellEditorProps<TableColoredBackgroundCellOptions>) => { }: TableCellEditorProps<TableColoredBackgroundCellOptions>) => {
// Set the display mode on change // Set the display mode on change
const onCellOptionsChange = (v: TableCellBackgroundDisplayMode) => { const onCellOptionsChange = (v: TableCellBackgroundDisplayMode) => {
cellOptions.mode = v; cellOptions.mode = v;
onChange(cellOptions); onChange(cellOptions);
}; };
const onColorRowChange = () => { const onColorRowChange = () => {
cellOptions.applyToRow = !cellOptions.applyToRow; cellOptions.applyToRow = !cellOptions.applyToRow;
onChange(cellOptions); onChange(cellOptions);
}; };
const onWrapTextChange = () => {
cellOptions.wrapText = !cellOptions.wrapText;
onChange(cellOptions);
};
const label = (
<Label
description={t(
'table.color-background-cell-options-editor.description-wrap-text',
'If selected text will be wrapped to the width of text in the configured column'
)}
>
<Trans i18nKey="table.color-background-cell-options-editor.wrap-text">Wrap text</Trans>{' '}
<Badge
text={t('table.color-background-cell-options-editor.label.text-alpha', 'Alpha')}
color="blue"
style={{ fontSize: '11px', marginLeft: '5px', lineHeight: '1.2' }}
/>
</Label>
);
return ( return (
<> <>
<Field <Field
label={t('table.color-background-cell-options-editor.label-background-display-mode', 'Background display mode')} label={t('table.color-background-cell-options-editor.label-background-display-mode', 'Background display mode')}
> >
<RadioButtonGroup <RadioButtonGroup
aria-label={selectors.components.PanelEditor.OptionsPane.fieldLabel(`Background display mode`)}
value={cellOptions?.mode ?? TableCellBackgroundDisplayMode.Gradient} value={cellOptions?.mode ?? TableCellBackgroundDisplayMode.Gradient}
onChange={onCellOptionsChange} onChange={onCellOptionsChange}
options={colorBackgroundOpts} options={colorBackgroundOpts}
/> />
</Field> </Field>
<Field <Field
label={t('table.color-background-cell-options-editor.label-apply-to-entire-row', 'Apply to entire row')} label={t('table.color-background-cell-options-editor.label-apply-to-entire-row', 'Apply to entire row')}
description={t( description={t(
@ -65,11 +46,20 @@ export const ColorBackgroundCellOptionsEditor = ({
'If selected the entire row will be colored as this cell would be.' 'If selected the entire row will be colored as this cell would be.'
)} )}
> >
<Switch value={cellOptions.applyToRow} onChange={onColorRowChange} /> <Switch
</Field> label={selectors.components.PanelEditor.OptionsPane.fieldLabel(`Apply to entire row`)}
<Field label={label}> value={cellOptions.applyToRow}
<Switch value={cellOptions.wrapText} onChange={onWrapTextChange} /> onChange={onColorRowChange}
/>
</Field> </Field>
<TextWrapOptionsEditor
cellOptions={cellOptions}
onChange={(updatedCellOptions) => {
cellOptions.wrapText = updatedCellOptions.wrapText;
onChange(cellOptions);
}}
/>
</> </>
); );
}; };

View File

@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { createFieldConfigRegistry, SetFieldConfigOptionsArgs } from '@grafana/data'; import { createFieldConfigRegistry, SetFieldConfigOptionsArgs } from '@grafana/data';
import { GraphFieldConfig, TableSparklineCellOptions } from '@grafana/schema'; import { GraphFieldConfig, TableSparklineCellOptions } from '@grafana/schema';
import { Stack, Field, useStyles2 } from '@grafana/ui'; import { Field, useStyles2, Stack } from '@grafana/ui';
import { defaultSparklineCellConfig } from '@grafana/ui/internal'; import { defaultSparklineCellConfig } from '@grafana/ui/internal';
import { getGraphFieldConfig } from '../../timeseries/config'; import { getGraphFieldConfig } from '../../timeseries/config';
@ -52,7 +52,7 @@ export const SparklineCellOptionsEditor = (props: TableCellEditorProps<TableSpar
const values = { ...defaultSparklineCellConfig, ...cellOptions }; const values = { ...defaultSparklineCellConfig, ...cellOptions };
return ( return (
<Stack direction="column" gap={0}> <Stack direction="column">
{registry.list(optionIds.map((id) => `custom.${id}`)).map((item) => { {registry.list(optionIds.map((id) => `custom.${id}`)).map((item) => {
if (item.showIf && !item.showIf(values)) { if (item.showIf && !item.showIf(values)) {
return null; return null;

View File

@ -9,7 +9,13 @@ import {
FieldConfigProperty, FieldConfigProperty,
} from '@grafana/data'; } from '@grafana/data';
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
import { TableCellOptions, TableCellDisplayMode, defaultTableFieldOptions, TableCellHeight } from '@grafana/schema'; import {
TableCellOptions,
TableCellDisplayMode,
defaultTableFieldOptions,
TableCellHeight,
TableCellTooltipPlacement,
} from '@grafana/schema';
import { PaginationEditor } from './PaginationEditor'; import { PaginationEditor } from './PaginationEditor';
import { TableCellOptionEditor } from './TableCellOptionEditor'; import { TableCellOptionEditor } from './TableCellOptionEditor';
@ -102,18 +108,64 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
description: t('table.description-column-filter', 'Enables/disables field filters in table'), description: t('table.description-column-filter', 'Enables/disables field filters in table'),
defaultValue: defaultTableFieldOptions.filterable, defaultValue: defaultTableFieldOptions.filterable,
}) })
.addBooleanSwitch({
path: 'wrapHeaderText',
name: t('table.name-wrap-header-text', 'Wrap header text'),
description: t('table.description-wrap-header-text', 'Enables text wrapping for column headers'),
category,
defaultValue: undefined,
})
.addBooleanSwitch({ .addBooleanSwitch({
path: 'hidden', path: 'hidden',
name: t('table.name-hide-in-table', 'Hide in table'), name: t('table.name-hide-in-table', 'Hide in table'),
category, category,
defaultValue: undefined, defaultValue: undefined,
hideFromDefaults: true, hideFromDefaults: true,
})
.addFieldNamePicker({
path: 'tooltip.field',
name: t('table.name-tooltip-from-field', 'Tooltip from field'),
description: t(
'table.description-tooltip-from-field',
'Render a cell from a field (hidden or visible) in a tooltip'
),
category: cellCategory,
})
.addSelect({
path: 'tooltip.placement',
name: t('table.name-tooltip-placement', 'Tooltip placement'),
category: cellCategory,
settings: {
options: [
{
label: t('table.tooltip-placement-options.label-auto', 'Auto'),
value: TableCellTooltipPlacement.Auto,
},
{
label: t('table.tooltip-placement-options.label-top', 'Top'),
value: TableCellTooltipPlacement.Top,
},
{
label: t('table.tooltip-placement-options.label-right', 'Right'),
value: TableCellTooltipPlacement.Right,
},
{
label: t('table.tooltip-placement-options.label-bottom', 'Bottom'),
value: TableCellTooltipPlacement.Bottom,
},
{
label: t('table.tooltip-placement-options.label-left', 'Left'),
value: TableCellTooltipPlacement.Left,
},
],
},
showIf: (cfg) => cfg.tooltip?.field !== undefined,
}); });
}, },
}) })
.setPanelOptions((builder) => { .setPanelOptions((builder) => {
const category = [t('table.category-table', 'Table')];
const footerCategory = [t('table.category-table-footer', 'Table footer')]; const footerCategory = [t('table.category-table-footer', 'Table footer')];
const category = [t('table.category-table', 'Table')];
builder builder
.addBooleanSwitch({ .addBooleanSwitch({
path: 'showHeader', path: 'showHeader',
@ -121,6 +173,15 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
category, category,
defaultValue: defaultOptions.showHeader, defaultValue: defaultOptions.showHeader,
}) })
.addNumberInput({
path: 'frozenColumns.left',
name: t('table.name-frozen-columns', 'Frozen columns'),
description: t('table.description-frozen-columns', 'Columns are frozen from the left side of the table'),
settings: {
placeholder: 'none',
},
category,
})
.addRadio({ .addRadio({
path: 'cellHeight', path: 'cellHeight',
name: t('table.name-cell-height', 'Cell height'), name: t('table.name-cell-height', 'Cell height'),
@ -188,7 +249,7 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
.addCustomEditor({ .addCustomEditor({
id: 'footer.enablePagination', id: 'footer.enablePagination',
path: 'footer.enablePagination', path: 'footer.enablePagination',
name: t('table.name-enable-paginations', 'Enable pagination'), name: t('table.name-enable-pagination', 'Enable pagination'),
category, category,
editor: PaginationEditor, editor: PaginationEditor,
}); });

View File

@ -44,6 +44,10 @@ composableKinds: PanelCfg: {
} }
// Controls the height of the rows // Controls the height of the rows
cellHeight?: ui.TableCellHeight & (*"sm" | _) cellHeight?: ui.TableCellHeight & (*"sm" | _)
// Defines the number of columns to freeze on the left side of the table
frozenColumns?: {
left?: number | *0
}
} @cuetsy(kind="interface") } @cuetsy(kind="interface")
FieldConfig: { FieldConfig: {
ui.TableFieldOptions ui.TableFieldOptions

View File

@ -23,6 +23,12 @@ export interface Options {
* Represents the index of the selected frame * Represents the index of the selected frame
*/ */
frameIndex: number; frameIndex: number;
/**
* Defines the number of columns to freeze on the left side of the table
*/
frozenColumns?: {
left?: number;
};
/** /**
* Controls whether the panel should show the header * Controls whether the panel should show the header
*/ */

View File

@ -1,19 +0,0 @@
import * as React from 'react';
import { StandardEditorProps } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Switch } from '@grafana/ui';
export function PaginationEditor({ onChange, value }: StandardEditorProps<boolean>) {
const changeValue = (event: React.FormEvent<HTMLInputElement> | undefined) => {
onChange(event?.currentTarget.checked);
};
return (
<Switch
label={selectors.components.PanelEditor.OptionsPane.fieldLabel(`Enable pagination`)}
value={Boolean(value)}
onChange={changeValue}
/>
);
}

View File

@ -1,9 +0,0 @@
# Table Panel - Native Plugin
The Table Panel is **included** with Grafana.
The table panel is very flexible, supporting both multiple modes for time series as well as for table, annotation and raw JSON data. It also provides date formatting and value formatting and coloring options.
Check out the [Table Panel Showcase in the Grafana Playground](https://play.grafana.org/d/U_bZIMRMk/7-table-panel-showcase) or read more about it here:
[https://grafana.com/docs/grafana/latest/features/panels/table_panel/](https://grafana.com/docs/grafana/latest/features/panels/table_panel/)

View File

@ -1,122 +0,0 @@
import { css } from '@emotion/css';
import { merge } from 'lodash';
import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { TableCellOptions, TableWrapTextOptions } from '@grafana/schema';
import { Combobox, ComboboxOption, Field, TableCellDisplayMode, useStyles2 } from '@grafana/ui';
import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor';
import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor';
import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor';
import { MarkdownCellOptionsEditor } from './cells/MarkdownCellOptionsEditor';
import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor';
import { TextWrapOptionsEditor } from './cells/TextWrapOptionsEditor';
// The props that any cell type editor are expected
// to handle. In this case the generic type should
// be a discriminated interface of TableCellOptions
export interface TableCellEditorProps<T> {
cellOptions: T;
onChange: (value: T) => void;
}
interface Props {
value: TableCellOptions;
onChange: (v: TableCellOptions) => void;
}
const TEXT_WRAP_CELL_TYPES = new Set([
TableCellDisplayMode.Auto,
TableCellDisplayMode.Sparkline,
TableCellDisplayMode.ColorText,
TableCellDisplayMode.ColorBackground,
TableCellDisplayMode.DataLinks,
TableCellDisplayMode.Pill,
]);
function isTextWrapCellType(value: TableCellOptions): value is TableCellOptions & TableWrapTextOptions {
return TEXT_WRAP_CELL_TYPES.has(value.type);
}
export const TableCellOptionEditor = ({ value, onChange }: Props) => {
const cellType = value.type;
const styles = useStyles2(getStyles);
const cellDisplayModeOptions: Array<ComboboxOption<TableCellOptions['type']>> = [
{ value: TableCellDisplayMode.Auto, label: t('table.cell-types.auto', 'Auto') },
{ value: TableCellDisplayMode.ColorText, label: t('table.cell-types.color-text', 'Colored text') },
{
value: TableCellDisplayMode.ColorBackground,
label: t('table.cell-types.color-background', 'Colored background'),
},
{ value: TableCellDisplayMode.DataLinks, label: t('table.cell-types.data-links', 'Data links') },
{ value: TableCellDisplayMode.Gauge, label: t('table.cell-types.gauge', 'Gauge') },
{ value: TableCellDisplayMode.Sparkline, label: t('table.cell-types.sparkline', 'Sparkline') },
{ value: TableCellDisplayMode.JSONView, label: t('table.cell-types.json', 'JSON View') },
{ value: TableCellDisplayMode.Pill, label: t('table.cell-types.pill', 'Pill') },
{ value: TableCellDisplayMode.Markdown, label: t('table.cell-types.markdown', 'Markdown + HTML') },
{ value: TableCellDisplayMode.Image, label: t('table.cell-types.image', 'Image') },
{ value: TableCellDisplayMode.Actions, label: t('table.cell-types.actions', 'Actions') },
];
const currentMode = cellDisplayModeOptions.find((o) => o.value === cellType)!;
let [settingCache, setSettingCache] = useState<Record<string, TableCellOptions>>({});
// Update display mode on change
const onCellTypeChange = (v: ComboboxOption<TableCellOptions['type']>) => {
if (v !== null) {
// Set the new type of cell starting
// with default settings
value = { type: v.value };
// When changing cell type see if there were previously stored
// settings and merge those with the changed value
if (settingCache[value.type] !== undefined && Object.keys(settingCache[value.type]).length > 1) {
value = merge({}, value, settingCache[value.type]);
}
onChange(value);
}
};
// When options for a cell change we merge
// any option changes with our options object
const onCellOptionsChange = (options: TableCellOptions) => {
settingCache[value.type] = merge({}, value, options);
setSettingCache(settingCache);
onChange(settingCache[value.type]);
};
// Setup and inject editor
return (
<div className={styles.fixBottomMargin}>
<Field>
<Combobox options={cellDisplayModeOptions} value={currentMode} onChange={onCellTypeChange} />
</Field>
{isTextWrapCellType(value) && <TextWrapOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />}
{cellType === TableCellDisplayMode.Gauge && (
<BarGaugeCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
{cellType === TableCellDisplayMode.ColorBackground && (
<ColorBackgroundCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
{cellType === TableCellDisplayMode.Sparkline && (
<SparklineCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
{cellType === TableCellDisplayMode.Image && (
<ImageCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
{cellType === TableCellDisplayMode.Markdown && (
<MarkdownCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
fixBottomMargin: css({
position: 'relative',
marginBottom: theme.spacing(-2),
}),
});

View File

@ -1,236 +0,0 @@
import { css } from '@emotion/css';
import { useCallback, useMemo } from 'react';
import {
ActionModel,
DashboardCursorSync,
DataFrame,
FieldMatcherID,
getFrameDisplayName,
InterpolateFunction,
PanelProps,
SelectableValue,
Field,
cacheFieldDisplayNames,
} from '@grafana/data';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { Select, usePanelContext, useTheme2 } from '@grafana/ui';
import { TableSortByFieldState } from '@grafana/ui/internal';
import { TableNG } from '@grafana/ui/unstable';
import { getConfig } from 'app/core/config';
import { getActions } from '../../../../features/actions/utils';
import { hasDeprecatedParentRowIndex, migrateFromParentRowIndexToNestedFrames } from './migrations';
import { Options } from './panelcfg.gen';
interface Props extends PanelProps<Options> {}
export function TablePanel(props: Props) {
const {
data,
height,
width,
options,
onFieldConfigChange,
onOptionsChange,
fieldConfig,
id,
timeRange,
replaceVariables,
transparent,
} = props;
useMemo(() => {
cacheFieldDisplayNames(data.series);
}, [data.series]);
const theme = useTheme2();
const panelContext = usePanelContext();
const _getActions = useCallback(
(frame: DataFrame, field: Field, rowIndex: number) => getCellActions(frame, field, rowIndex, replaceVariables),
[replaceVariables]
);
const frames = hasDeprecatedParentRowIndex(data.series)
? migrateFromParentRowIndexToNestedFrames(data.series)
: data.series;
const count = frames?.length;
const hasFields = frames.some((frame) => frame.fields.length > 0);
const currentIndex = getCurrentFrameIndex(frames, options);
const main = frames[currentIndex];
const onColumnResize = useCallback(
(fieldDisplayName: string, width: number) => {
const { overrides } = fieldConfig;
const matcherId = FieldMatcherID.byName;
const propId = 'custom.width';
// look for existing override
const override = overrides.find((o) => o.matcher.id === matcherId && o.matcher.options === fieldDisplayName);
if (override) {
// look for existing property
const property = override.properties.find((prop) => prop.id === propId);
if (property) {
property.value = width;
} else {
override.properties.push({ id: propId, value: width });
}
} else {
overrides.push({
matcher: { id: matcherId, options: fieldDisplayName },
properties: [{ id: propId, value: width }],
});
}
onFieldConfigChange({
...fieldConfig,
overrides,
});
},
[fieldConfig, onFieldConfigChange]
);
const onSortByChange = useCallback(
(sortBy: TableSortByFieldState[]) => {
onOptionsChange({
...options,
sortBy,
});
},
[options, onOptionsChange]
);
const onChangeTableSelection = useCallback(
(val: SelectableValue<number>) => {
onOptionsChange({
...options,
frameIndex: val.value || 0,
});
},
[options, onOptionsChange]
);
let tableHeight = height;
if (!count || !hasFields) {
return <PanelDataErrorView panelId={id} fieldConfig={fieldConfig} data={data} />;
}
if (count > 1) {
const inputHeight = theme.spacing.gridSize * theme.components.height.md;
const padding = theme.spacing.gridSize;
tableHeight = height - inputHeight - padding;
}
const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off;
const disableSanitizeHtml = getConfig().disableSanitizeHtml;
const tableElement = (
<TableNG
height={tableHeight}
width={width}
data={main}
noHeader={!options.showHeader}
showTypeIcons={options.showTypeIcons}
resizable={true}
initialSortBy={options.sortBy}
onSortByChange={onSortByChange}
onColumnResize={onColumnResize}
onCellFilterAdded={panelContext.onAddAdHocFilter}
footerOptions={options.footer}
frozenColumns={options.frozenColumns?.left}
enablePagination={options.footer?.enablePagination}
cellHeight={options.cellHeight}
timeRange={timeRange}
enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair}
fieldConfig={fieldConfig}
getActions={_getActions}
structureRev={data.structureRev}
transparent={transparent}
disableSanitizeHtml={disableSanitizeHtml}
/>
);
if (count === 1) {
return tableElement;
}
const names = frames.map((frame, index) => {
return {
label: getFrameDisplayName(frame),
value: index,
};
});
return (
<div className={tableStyles.wrapper}>
{tableElement}
<div className={tableStyles.selectWrapper}>
<Select options={names} value={names[currentIndex]} onChange={onChangeTableSelection} />
</div>
</div>
);
}
function getCurrentFrameIndex(frames: DataFrame[], options: Options) {
return options.frameIndex > 0 && options.frameIndex < frames.length ? options.frameIndex : 0;
}
// placeholder function; assuming the values are already interpolated
const replaceVars: InterpolateFunction = (value: string) => value;
const getCellActions = (
dataFrame: DataFrame,
field: Field,
rowIndex: number,
replaceVariables: InterpolateFunction | undefined
): Array<ActionModel<Field>> => {
const numActions = field.config.actions?.length ?? 0;
if (numActions > 0) {
const actions = getActions(
dataFrame,
field,
field.state!.scopedVars!,
replaceVariables ?? replaceVars,
field.config.actions ?? [],
{ valueRowIndex: rowIndex }
);
if (actions.length === 1) {
return actions;
} else {
const actionsOut: Array<ActionModel<Field>> = [];
const actionLookup = new Set<string>();
actions.forEach((action) => {
const key = action.title;
if (!actionLookup.has(key)) {
actionsOut.push(action);
actionLookup.add(key);
}
});
return actionsOut;
}
}
return [];
};
const tableStyles = {
wrapper: css({
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height: '100%',
}),
selectWrapper: css({
padding: '8px 8px 0px 8px',
}),
};

View File

@ -1,82 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Table Migrations migrates transform out to core transforms 1`] = `
{
"fieldConfig": {
"defaults": {
"custom": {},
},
"overrides": [],
},
"transformations": [
{
"id": "seriesToColumns",
"options": {
"reducers": [],
},
},
],
}
`;
exports[`Table Migrations migrates transform out to core transforms 2`] = `
{
"fieldConfig": {
"defaults": {
"custom": {},
},
"overrides": [],
},
"transformations": [
{
"id": "seriesToRows",
"options": {
"reducers": [],
},
},
],
}
`;
exports[`Table Migrations migrates transform out to core transforms 3`] = `
{
"fieldConfig": {
"defaults": {
"custom": {},
},
"overrides": [],
},
"transformations": [
{
"id": "reduce",
"options": {
"includeTimeField": false,
"reducers": [
"mean",
"max",
"lastNotNull",
],
},
},
],
}
`;
exports[`Table Migrations migrates transform out to core transforms 4`] = `
{
"fieldConfig": {
"defaults": {
"custom": {},
},
"overrides": [],
},
"transformations": [
{
"id": "merge",
"options": {
"reducers": [],
},
},
],
}
`;

View File

@ -1,53 +0,0 @@
import { SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { BarGaugeDisplayMode, BarGaugeValueMode, TableBarGaugeCellOptions } from '@grafana/schema';
import { Field, RadioButtonGroup, Stack } from '@grafana/ui';
import { TableCellEditorProps } from '../TableCellOptionEditor';
type Props = TableCellEditorProps<TableBarGaugeCellOptions>;
export function BarGaugeCellOptionsEditor({ cellOptions, onChange }: Props) {
// Set the display mode on change
const onCellOptionsChange = (v: BarGaugeDisplayMode) => {
cellOptions.mode = v;
onChange(cellOptions);
};
const onValueModeChange = (v: BarGaugeValueMode) => {
cellOptions.valueDisplayMode = v;
onChange(cellOptions);
};
return (
<Stack direction="column" gap={0}>
<Field label={t('table.bar-gauge-cell-options-editor.label-gauge-display-mode', 'Gauge display mode')}>
<RadioButtonGroup
value={cellOptions?.mode ?? BarGaugeDisplayMode.Gradient}
onChange={onCellOptionsChange}
options={barGaugeOpts}
/>
</Field>
<Field label={t('table.bar-gauge-cell-options-editor.label-value-display', 'Value display')}>
<RadioButtonGroup
value={cellOptions?.valueDisplayMode ?? BarGaugeValueMode.Text}
onChange={onValueModeChange}
options={valueModes}
/>
</Field>
</Stack>
);
}
const barGaugeOpts: SelectableValue[] = [
{ value: BarGaugeDisplayMode.Basic, label: 'Basic' },
{ value: BarGaugeDisplayMode.Gradient, label: 'Gradient' },
{ value: BarGaugeDisplayMode.Lcd, label: 'Retro LCD' },
];
const valueModes: SelectableValue[] = [
{ value: BarGaugeValueMode.Color, label: 'Value color' },
{ value: BarGaugeValueMode.Text, label: 'Text color' },
{ value: BarGaugeValueMode.Hidden, label: 'Hidden' },
];

View File

@ -1,65 +0,0 @@
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { TableCellBackgroundDisplayMode, TableColoredBackgroundCellOptions } from '@grafana/schema';
import { Field, RadioButtonGroup, Switch } from '@grafana/ui';
import { TableCellEditorProps } from '../TableCellOptionEditor';
import { TextWrapOptionsEditor } from './TextWrapOptionsEditor';
const colorBackgroundOpts: Array<SelectableValue<TableCellBackgroundDisplayMode>> = [
{ value: TableCellBackgroundDisplayMode.Basic, label: 'Basic' },
{ value: TableCellBackgroundDisplayMode.Gradient, label: 'Gradient' },
];
export const ColorBackgroundCellOptionsEditor = ({
cellOptions,
onChange,
}: TableCellEditorProps<TableColoredBackgroundCellOptions>) => {
// Set the display mode on change
const onCellOptionsChange = (v: TableCellBackgroundDisplayMode) => {
cellOptions.mode = v;
onChange(cellOptions);
};
const onColorRowChange = () => {
cellOptions.applyToRow = !cellOptions.applyToRow;
onChange(cellOptions);
};
return (
<>
<Field
label={t('table.color-background-cell-options-editor.label-background-display-mode', 'Background display mode')}
>
<RadioButtonGroup
aria-label={selectors.components.PanelEditor.OptionsPane.fieldLabel(`Background display mode`)}
value={cellOptions?.mode ?? TableCellBackgroundDisplayMode.Gradient}
onChange={onCellOptionsChange}
options={colorBackgroundOpts}
/>
</Field>
<Field
label={t('table.color-background-cell-options-editor.label-apply-to-entire-row', 'Apply to entire row')}
description={t(
'table.color-background-cell-options-editor.description-apply-to-entire-row',
'If selected the entire row will be colored as this cell would be.'
)}
>
<Switch
label={selectors.components.PanelEditor.OptionsPane.fieldLabel(`Apply to entire row`)}
value={cellOptions.applyToRow}
onChange={onColorRowChange}
/>
</Field>
<TextWrapOptionsEditor
cellOptions={cellOptions}
onChange={(updatedCellOptions) => {
cellOptions.wrapText = updatedCellOptions.wrapText;
onChange(cellOptions);
}}
/>
</>
);
};

View File

@ -1,43 +0,0 @@
import { FormEvent } from 'react';
import { t } from '@grafana/i18n';
import { TableImageCellOptions } from '@grafana/schema';
import { Field, Input } from '@grafana/ui';
import { TableCellEditorProps } from '../TableCellOptionEditor';
export const ImageCellOptionsEditor = ({ cellOptions, onChange }: TableCellEditorProps<TableImageCellOptions>) => {
const onAltChange = (e: FormEvent<HTMLInputElement>) => {
cellOptions.alt = e.currentTarget.value;
onChange(cellOptions);
};
const onTitleChange = (e: FormEvent<HTMLInputElement>) => {
cellOptions.title = e.currentTarget.value;
onChange(cellOptions);
};
return (
<>
<Field
label={t('table.image-cell-options-editor.label-alt-text', 'Alt text')}
description={t(
'table.image-cell-options-editor.description-alt-text',
"Alternative text that will be displayed if an image can't be displayed or for users who use a screen reader"
)}
>
<Input onChange={onAltChange} defaultValue={cellOptions.alt} />
</Field>
<Field
label={t('table.image-cell-options-editor.label-title-text', 'Title text')}
description={t(
'table.image-cell-options-editor.description-title-text',
'Text that will be displayed when the image is hovered by a cursor'
)}
>
<Input onChange={onTitleChange} defaultValue={cellOptions.title} />
</Field>
</>
);
};

View File

@ -1,94 +0,0 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { createFieldConfigRegistry, SetFieldConfigOptionsArgs } from '@grafana/data';
import { GraphFieldConfig, TableSparklineCellOptions } from '@grafana/schema';
import { Field, useStyles2, Stack } from '@grafana/ui';
import { defaultSparklineCellConfig } from '@grafana/ui/internal';
import { getGraphFieldConfig } from '../../../timeseries/config';
import { TableCellEditorProps } from '../TableCellOptionEditor';
type OptionKey = keyof TableSparklineCellOptions;
const optionIds: Array<keyof TableSparklineCellOptions> = [
'hideValue',
'drawStyle',
'lineInterpolation',
'barAlignment',
'lineWidth',
'fillOpacity',
'gradientMode',
'lineStyle',
'spanNulls',
'showPoints',
'pointSize',
];
function getChartCellConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs<GraphFieldConfig> {
const graphFieldConfig = getGraphFieldConfig(cfg);
return {
...graphFieldConfig,
useCustomConfig: (builder) => {
graphFieldConfig.useCustomConfig?.(builder);
builder.addBooleanSwitch({
path: 'hideValue',
name: 'Hide value',
});
},
};
}
export const SparklineCellOptionsEditor = (props: TableCellEditorProps<TableSparklineCellOptions>) => {
const { cellOptions, onChange } = props;
const registry = useMemo(() => {
const config = getChartCellConfig(defaultSparklineCellConfig);
return createFieldConfigRegistry(config, 'ChartCell');
}, []);
const style = useStyles2(getStyles);
const values = { ...defaultSparklineCellConfig, ...cellOptions };
return (
<Stack direction="column">
{registry.list(optionIds.map((id) => `custom.${id}`)).map((item) => {
if (item.showIf && !item.showIf(values)) {
return null;
}
const Editor = item.editor;
const path = item.path;
return (
<Field label={item.name} key={item.id} className={style.field}>
<Editor
onChange={(val) => onChange({ ...cellOptions, [path]: val })}
value={(isOptionKey(path, values) ? values[path] : undefined) ?? item.defaultValue}
item={item}
context={{ data: [] }}
/>
</Field>
);
})}
</Stack>
);
};
// jumping through hoops to avoid using "any"
function isOptionKey(key: string, options: TableSparklineCellOptions): key is OptionKey {
return key in options;
}
const getStyles = () => ({
field: css({
width: '100%',
// @TODO don't show "scheme" option for custom gradient mode.
// it needs thresholds to work, which are not supported
// for area chart cell right now
"[title='Use color scheme to define gradient']": {
display: 'none',
},
}),
});

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 79.8 78.47"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:url(#linear-gradient-2);}.cls-3{fill:url(#linear-gradient-3);}.cls-4{fill:#84aff1;}.cls-5{fill:#3865ab;}</style><linearGradient id="linear-gradient" y1="6.25" x2="23.93" y2="6.25" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient><linearGradient id="linear-gradient-2" x1="55.87" y1="6.25" x2="79.8" y2="6.25" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-3" x1="27.93" y1="6.25" x2="51.87" y2="6.25" xlink:href="#linear-gradient"/></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M0,1V12.49H23.93V0H1A1,1,0,0,0,0,1Z"/><path class="cls-2" d="M55.87,12.49H79.8V1a1,1,0,0,0-1-1H55.87Z"/><rect class="cls-3" x="27.93" width="23.93" height="12.49"/><rect class="cls-4" x="27.93" y="16.49" width="23.93" height="12.49"/><rect class="cls-4" y="16.49" width="23.93" height="12.49"/><rect class="cls-4" x="55.87" y="16.49" width="23.93" height="12.49"/><rect class="cls-5" x="55.87" y="32.99" width="23.93" height="12.5"/><rect class="cls-5" x="27.93" y="32.99" width="23.93" height="12.5"/><rect class="cls-5" y="32.99" width="23.93" height="12.5"/><rect class="cls-4" x="27.93" y="49.48" width="23.93" height="12.49"/><rect class="cls-4" x="55.87" y="49.48" width="23.93" height="12.49"/><rect class="cls-4" y="49.48" width="23.93" height="12.49"/><rect class="cls-5" x="27.93" y="65.98" width="23.93" height="12.49"/><path class="cls-5" d="M79.8,77.47V66H55.87V78.47H78.8A1,1,0,0,0,79.8,77.47Z"/><path class="cls-5" d="M23.93,78.47V66H0V77.47a1,1,0,0,0,1,1Z"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,364 +0,0 @@
import { createDataFrame, FieldType, PanelModel } from '@grafana/data';
import { migrateFromParentRowIndexToNestedFrames, tablePanelChangedHandler } from './migrations';
describe('Table Migrations', () => {
it('migrates transform out to core transforms', () => {
const toColumns = {
angular: {
columns: [],
styles: [],
transform: 'timeseries_to_columns',
options: {},
},
};
const toRows = {
angular: {
columns: [],
styles: [],
transform: 'timeseries_to_rows',
options: {},
},
};
const aggregations = {
angular: {
columns: [
{
text: 'Avg',
value: 'avg',
$$hashKey: 'object:82',
},
{
text: 'Max',
value: 'max',
$$hashKey: 'object:83',
},
{
text: 'Current',
value: 'current',
$$hashKey: 'object:84',
},
],
styles: [],
transform: 'timeseries_aggregations',
options: {},
},
};
const table = {
angular: {
columns: [],
styles: [],
transform: 'table',
options: {},
},
};
const columnsPanel = {} as PanelModel;
tablePanelChangedHandler(columnsPanel, 'table-old', toColumns);
expect(columnsPanel).toMatchSnapshot();
const rowsPanel = {} as PanelModel;
tablePanelChangedHandler(rowsPanel, 'table-old', toRows);
expect(rowsPanel).toMatchSnapshot();
const aggregationsPanel = {} as PanelModel;
tablePanelChangedHandler(aggregationsPanel, 'table-old', aggregations);
expect(aggregationsPanel).toMatchSnapshot();
const tablePanel = {} as PanelModel;
tablePanelChangedHandler(tablePanel, 'table-old', table);
expect(tablePanel).toMatchSnapshot();
});
it('migrates styles to field config overrides and defaults', () => {
const oldStyles = {
angular: {
columns: [],
styles: [
{
alias: 'Time',
align: 'auto',
dateFormat: 'YYYY-MM-DD HH:mm:ss',
pattern: 'Time',
type: 'date',
$$hashKey: 'object:195',
},
{
alias: '',
align: 'left',
colorMode: 'cell',
colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'],
dateFormat: 'YYYY-MM-DD HH:mm:ss',
decimals: 2,
mappingType: 1,
pattern: 'ColorCell',
thresholds: ['5', '10'],
type: 'number',
unit: 'currencyUSD',
$$hashKey: 'object:196',
},
{
alias: '',
align: 'auto',
colorMode: 'value',
colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'],
dateFormat: 'YYYY-MM-DD HH:mm:ss',
decimals: 2,
link: true,
linkTargetBlank: true,
linkTooltip: '',
linkUrl: 'http://www.grafana.com',
mappingType: 1,
pattern: 'ColorValue',
thresholds: ['5', '10'],
type: 'number',
unit: 'Bps',
$$hashKey: 'object:197',
},
{
unit: 'short',
type: 'number',
alias: '',
decimals: 2,
colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'],
colorMode: null,
pattern: '/.*/',
thresholds: [],
align: 'right',
},
],
},
};
const panel = {} as PanelModel;
tablePanelChangedHandler(panel, 'table-old', oldStyles);
expect(panel).toMatchInlineSnapshot(`
{
"fieldConfig": {
"defaults": {
"custom": {
"align": "right",
},
"decimals": 2,
"displayName": "",
"unit": "short",
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Time",
},
"properties": [
{
"id": "displayName",
"value": "Time",
},
{
"id": "unit",
"value": "time: YYYY-MM-DD HH:mm:ss",
},
{
"id": "custom.align",
"value": null,
},
],
},
{
"matcher": {
"id": "byName",
"options": "ColorCell",
},
"properties": [
{
"id": "unit",
"value": "currencyUSD",
},
{
"id": "decimals",
"value": 2,
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-background",
},
},
{
"id": "custom.align",
"value": "left",
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "rgba(245, 54, 54, 0.9)",
"value": -Infinity,
},
{
"color": "rgba(237, 129, 40, 0.89)",
"value": 5,
},
{
"color": "rgba(50, 172, 45, 0.97)",
"value": 10,
},
],
},
},
],
},
{
"matcher": {
"id": "byName",
"options": "ColorValue",
},
"properties": [
{
"id": "unit",
"value": "Bps",
},
{
"id": "decimals",
"value": 2,
},
{
"id": "links",
"value": [
{
"targetBlank": true,
"title": "",
"url": "http://www.grafana.com",
},
],
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-text",
},
},
{
"id": "custom.align",
"value": null,
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "rgba(245, 54, 54, 0.9)",
"value": -Infinity,
},
{
"color": "rgba(237, 129, 40, 0.89)",
"value": 5,
},
{
"color": "rgba(50, 172, 45, 0.97)",
"value": 10,
},
],
},
},
],
},
],
},
"transformations": [],
}
`);
});
it('migrates hidden fields to override', () => {
const oldStyles = {
angular: {
columns: [],
styles: [
{
dateFormat: 'YYYY-MM-DD HH:mm:ss',
pattern: 'time',
type: 'hidden',
},
],
},
};
const panel = {} as PanelModel;
tablePanelChangedHandler(panel, 'table-old', oldStyles);
expect(panel.fieldConfig.overrides).toEqual([
{
matcher: {
id: 'byName',
options: 'time',
},
properties: [
{
id: 'custom.hidden',
value: true,
},
],
},
]);
});
it('migrates DataFrame[] from format using meta.custom.parentRowIndex to format using FieldType.nestedFrames', () => {
const mainFrame = (refId: string) => {
return createDataFrame({
refId,
fields: [
{
name: 'field',
type: FieldType.string,
config: {},
values: ['a', 'b', 'c'],
},
],
meta: {
preferredVisualisationType: 'table',
},
});
};
const subFrame = (index: number) => {
return createDataFrame({
refId: 'B',
fields: [
{
name: `field_${index}`,
type: FieldType.string,
config: {},
values: [`${index}_subA`, 'subB', 'subC'],
},
],
meta: {
preferredVisualisationType: 'table',
custom: {
parentRowIndex: index,
},
},
});
};
const oldFormat = [mainFrame('A'), mainFrame('B'), subFrame(0), subFrame(1)];
const newFormat = migrateFromParentRowIndexToNestedFrames(oldFormat);
expect(newFormat.length).toBe(2);
expect(newFormat[0].refId).toBe('A');
expect(newFormat[1].refId).toBe('B');
expect(newFormat[0].fields.length).toBe(1);
expect(newFormat[1].fields.length).toBe(2);
expect(newFormat[0].fields[0].name).toBe('field');
expect(newFormat[1].fields[0].name).toBe('field');
expect(newFormat[1].fields[1].name).toBe('nested');
expect(newFormat[1].fields[1].type).toBe(FieldType.nestedFrames);
expect(newFormat[1].fields[1].values.length).toBe(2);
expect(newFormat[1].fields[1].values[0][0].refId).toBe('B');
expect(newFormat[1].fields[1].values[1][0].refId).toBe('B');
expect(newFormat[1].fields[1].values[0][0].length).toBe(3);
expect(newFormat[1].fields[1].values[0][0].length).toBe(3);
expect(newFormat[1].fields[1].values[0][0].fields[0].name).toBe('field_0');
expect(newFormat[1].fields[1].values[1][0].fields[0].name).toBe('field_1');
expect(newFormat[1].fields[1].values[0][0].fields[0].values[0]).toBe('0_subA');
expect(newFormat[1].fields[1].values[1][0].fields[0].values[0]).toBe('1_subA');
});
});

View File

@ -1,299 +0,0 @@
import { omitBy, isNil, isNumber, defaultTo, groupBy } from 'lodash';
import {
PanelModel,
FieldMatcherID,
ConfigOverrideRule,
ThresholdsMode,
ThresholdsConfig,
FieldConfig,
DataFrame,
FieldType,
} from '@grafana/data';
import { ReduceTransformerOptions } from '@grafana/data/internal';
import { Options } from './panelcfg.gen';
/**
* At 7.0, the `table` panel was swapped from an angular implementation to a react one.
* The models do not match, so this process will delegate to the old implementation when
* a saved table configuration exists.
*/
export const tableMigrationHandler = (panel: PanelModel<Options>): Partial<Options> => {
// Table was saved as an angular table, lets just swap to the 'table-old' panel
if (!panel.pluginVersion && 'columns' in panel) {
console.log('Was angular table', panel);
}
// Nothing changed
return panel.options;
};
const transformsMap = {
timeseries_to_rows: 'seriesToRows',
timeseries_to_columns: 'seriesToColumns',
timeseries_aggregations: 'reduce',
table: 'merge',
};
const columnsMap = {
avg: 'mean',
min: 'min',
max: 'max',
total: 'sum',
current: 'lastNotNull',
count: 'count',
};
const colorModeMap = {
cell: 'color-background',
row: 'color-background',
value: 'color-text',
};
type Transformations = keyof typeof transformsMap;
type Transformation = {
id: string;
options: ReduceTransformerOptions;
};
type Columns = keyof typeof columnsMap;
type Column = {
value: Columns;
text: string;
};
type ColorModes = keyof typeof colorModeMap;
const generateThresholds = (thresholds: string[], colors: string[]) => {
return [-Infinity, ...thresholds].map((threshold, idx) => ({
color: colors[idx],
value: isNumber(threshold) ? threshold : parseInt(threshold, 10),
}));
};
const migrateTransformations = (
panel: PanelModel<Partial<Options>>,
oldOpts: { columns: any; transform: Transformations }
) => {
const transformations: Transformation[] = panel.transformations ?? [];
if (Object.keys(transformsMap).includes(oldOpts.transform)) {
const opts: ReduceTransformerOptions = {
reducers: [],
};
if (oldOpts.transform === 'timeseries_aggregations') {
opts.includeTimeField = false;
opts.reducers = oldOpts.columns.map((column: Column) => columnsMap[column.value]);
}
transformations.push({
id: transformsMap[oldOpts.transform],
options: opts,
});
}
return transformations;
};
type Style = {
unit: string;
type: string;
alias: string;
decimals: number;
colors: string[];
colorMode: ColorModes;
pattern: string;
thresholds: string[];
align?: string;
dateFormat: string;
link: boolean;
linkTargetBlank?: boolean;
linkTooltip?: string;
linkUrl?: string;
};
const migrateTableStyleToOverride = (style: Style) => {
const fieldMatcherId = /^\/.*\/$/.test(style.pattern) ? FieldMatcherID.byRegexp : FieldMatcherID.byName;
const override: ConfigOverrideRule = {
matcher: {
id: fieldMatcherId,
options: style.pattern,
},
properties: [],
};
if (style.alias) {
override.properties.push({
id: 'displayName',
value: style.alias,
});
}
if (style.unit) {
override.properties.push({
id: 'unit',
value: style.unit,
});
}
if (style.decimals) {
override.properties.push({
id: 'decimals',
value: style.decimals,
});
}
if (style.type === 'date') {
override.properties.push({
id: 'unit',
value: `time: ${style.dateFormat}`,
});
}
if (style.type === 'hidden') {
override.properties.push({
id: 'custom.hidden',
value: true,
});
}
if (style.link) {
override.properties.push({
id: 'links',
value: [
{
title: defaultTo(style.linkTooltip, ''),
url: defaultTo(style.linkUrl, ''),
targetBlank: defaultTo(style.linkTargetBlank, false),
},
],
});
}
if (style.colorMode) {
override.properties.push({
id: 'custom.cellOptions',
value: {
type: colorModeMap[style.colorMode],
},
});
}
if (style.align) {
override.properties.push({
id: 'custom.align',
value: style.align === 'auto' ? null : style.align,
});
}
if (style.thresholds?.length) {
override.properties.push({
id: 'thresholds',
value: {
mode: ThresholdsMode.Absolute,
steps: generateThresholds(style.thresholds, style.colors),
},
});
}
return override;
};
const migrateDefaults = (prevDefaults: Style) => {
let defaults: FieldConfig = {
custom: {},
};
if (prevDefaults) {
defaults = omitBy(
{
unit: prevDefaults.unit,
decimals: prevDefaults.decimals,
displayName: prevDefaults.alias,
custom: {
align: prevDefaults.align === 'auto' ? null : prevDefaults.align,
},
},
isNil
);
if (prevDefaults.thresholds.length) {
const thresholds: ThresholdsConfig = {
mode: ThresholdsMode.Absolute,
steps: generateThresholds(prevDefaults.thresholds, prevDefaults.colors),
};
defaults.thresholds = thresholds;
}
if (prevDefaults.colorMode) {
defaults.custom.cellOptions = {
type: colorModeMap[prevDefaults.colorMode],
};
}
}
return defaults;
};
/**
* This is called when the panel changes from another panel
*/
export const tablePanelChangedHandler = (
panel: PanelModel<Partial<Options>>,
prevPluginId: string,
prevOptions: any
) => {
// Changing from angular table panel
if (prevPluginId === 'table-old' && prevOptions.angular) {
const oldOpts = prevOptions.angular;
const transformations = migrateTransformations(panel, oldOpts);
const prevDefaults = oldOpts.styles.find((style: any) => style.pattern === '/.*/');
const defaults = migrateDefaults(prevDefaults);
const overrides = oldOpts.styles.filter((style: any) => style.pattern !== '/.*/').map(migrateTableStyleToOverride);
panel.transformations = transformations;
panel.fieldConfig = {
defaults,
overrides,
};
}
return {};
};
const getMainFrames = (frames: DataFrame[] | null) => {
return frames?.filter((df) => df.meta?.custom?.parentRowIndex === undefined) || [frames?.[0]];
};
/**
* In 9.3 meta.custom.parentRowIndex was introduced to support sub-tables.
* In 10.2 meta.custom.parentRowIndex was deprecated in favor of FieldType.nestedFrames, which supports multiple nested frames.
* Migrate DataFrame[] from using meta.custom.parentRowIndex to using FieldType.nestedFrames
*/
export const migrateFromParentRowIndexToNestedFrames = (frames: DataFrame[] | null) => {
const migratedFrames: DataFrame[] = [];
const mainFrames = getMainFrames(frames).filter(
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
mainFrames?.forEach((frame) => {
const subFrames = frames?.filter((df) => frame.refId === df.refId && df.meta?.custom?.parentRowIndex !== undefined);
const subFramesGrouped = groupBy(subFrames, (frame: DataFrame) => frame.meta?.custom?.parentRowIndex);
const subFramesByIndex = Object.keys(subFramesGrouped).map((key) => subFramesGrouped[key]);
const migratedFrame = { ...frame };
if (subFrames && subFrames.length > 0) {
migratedFrame.fields.push({
name: 'nested',
type: FieldType.nestedFrames,
config: {},
values: subFramesByIndex,
});
}
migratedFrames.push(migratedFrame);
});
return migratedFrames;
};
export const hasDeprecatedParentRowIndex = (frames: DataFrame[] | null) => {
return frames?.some((df) => df.meta?.custom?.parentRowIndex !== undefined);
};

View File

@ -1,258 +0,0 @@
import {
FieldOverrideContext,
FieldType,
getFieldDisplayName,
PanelPlugin,
ReducerID,
standardEditorsRegistry,
identityOverrideProcessor,
FieldConfigProperty,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import {
TableCellOptions,
TableCellDisplayMode,
defaultTableFieldOptions,
TableCellHeight,
TableCellTooltipPlacement,
} from '@grafana/schema';
import { PaginationEditor } from './PaginationEditor';
import { TableCellOptionEditor } from './TableCellOptionEditor';
import { TablePanel } from './TablePanel';
import { tableMigrationHandler, tablePanelChangedHandler } from './migrations';
import { Options, defaultOptions, FieldConfig } from './panelcfg.gen';
import { TableSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
.setPanelChangeHandler(tablePanelChangedHandler)
.setMigrationHandler(tableMigrationHandler)
.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Actions]: {
hideFromDefaults: false,
},
},
useCustomConfig: (builder) => {
const category = [t('table-new.category-table', 'Table')];
const cellCategory = [t('table-new.category-cell-options', 'Cell options')];
builder
.addNumberInput({
path: 'minWidth',
name: t('table-new.name-min-column-width', 'Minimum column width'),
category,
description: t('table-new.description-min-column-width', 'The minimum width for column auto resizing'),
settings: {
placeholder: '150',
min: 50,
max: 500,
},
shouldApply: () => true,
defaultValue: defaultTableFieldOptions.minWidth,
})
.addNumberInput({
path: 'width',
name: t('table-new.name-column-width', 'Column width'),
category,
settings: {
placeholder: t('table-new.placeholder-column-width', 'auto'),
min: 20,
},
shouldApply: () => true,
defaultValue: defaultTableFieldOptions.width,
})
.addRadio({
path: 'align',
name: t('table-new.name-column-alignment', 'Column alignment'),
category,
settings: {
options: [
{ label: t('table-new.column-alignment-options.label-auto', 'Auto'), value: 'auto' },
{ label: t('table-new.column-alignment-options.label-left', 'Left'), value: 'left' },
{ label: t('table-new.column-alignment-options.label-center', 'Center'), value: 'center' },
{ label: t('table-new.column-alignment-options.label-right', 'Right'), value: 'right' },
],
},
defaultValue: defaultTableFieldOptions.align,
})
.addCustomEditor<void, TableCellOptions>({
id: 'cellOptions',
path: 'cellOptions',
name: t('table-new.name-cell-type', 'Cell type'),
editor: TableCellOptionEditor,
override: TableCellOptionEditor,
defaultValue: defaultTableFieldOptions.cellOptions,
process: identityOverrideProcessor,
category: cellCategory,
shouldApply: () => true,
})
.addBooleanSwitch({
path: 'inspect',
name: t('table-new.name-cell-value-inspect', 'Cell value inspect'),
description: t('table-new.description-cell-value-inspect', 'Enable cell value inspection in a modal window'),
defaultValue: false,
category: cellCategory,
showIf: (cfg) => {
return (
cfg.cellOptions.type === TableCellDisplayMode.Auto ||
cfg.cellOptions.type === TableCellDisplayMode.JSONView ||
cfg.cellOptions.type === TableCellDisplayMode.ColorText ||
cfg.cellOptions.type === TableCellDisplayMode.ColorBackground
);
},
})
.addBooleanSwitch({
path: 'filterable',
name: t('table-new.name-column-filter', 'Column filter'),
category,
description: t('table-new.description-column-filter', 'Enables/disables field filters in table'),
defaultValue: defaultTableFieldOptions.filterable,
})
.addBooleanSwitch({
path: 'wrapHeaderText',
name: t('table.name-wrap-header-text', 'Wrap header text'),
description: t('table.description-wrap-header-text', 'Enables text wrapping for column headers'),
category,
defaultValue: defaultTableFieldOptions.wrapHeaderText,
})
.addBooleanSwitch({
path: 'hidden',
name: t('table-new.name-hide-in-table', 'Hide in table'),
category,
defaultValue: undefined,
hideFromDefaults: true,
})
.addFieldNamePicker({
path: 'tooltip.field',
name: t('table-new.name-tooltip-from-field', 'Tooltip from field'),
description: t(
'table-new.description-tooltip-from-field',
'Render a cell from a field (hidden or visible) in a tooltip'
),
category: cellCategory,
})
.addSelect({
path: 'tooltip.placement',
name: t('table-new.name-tooltip-placement', 'Tooltip placement'),
category: cellCategory,
settings: {
options: [
{
label: t('table-new.tooltip-placement-options.label-auto', 'Auto'),
value: TableCellTooltipPlacement.Auto,
},
{
label: t('table-new.tooltip-placement-options.label-top', 'Top'),
value: TableCellTooltipPlacement.Top,
},
{
label: t('table-new.tooltip-placement-options.label-right', 'Right'),
value: TableCellTooltipPlacement.Right,
},
{
label: t('table-new.tooltip-placement-options.label-bottom', 'Bottom'),
value: TableCellTooltipPlacement.Bottom,
},
{
label: t('table-new.tooltip-placement-options.label-left', 'Left'),
value: TableCellTooltipPlacement.Left,
},
],
},
defaultValue: 'auto',
showIf: (cfg) => cfg.tooltip?.field !== undefined,
});
},
})
.setPanelOptions((builder) => {
const footerCategory = [t('table-new.category-table-footer', 'Table footer')];
const category = [t('table-new.category-table', 'Table')];
builder
.addBooleanSwitch({
path: 'showHeader',
name: t('table-new.name-show-table-header', 'Show table header'),
category,
defaultValue: defaultOptions.showHeader,
})
.addNumberInput({
path: 'frozenColumns.left',
name: t('table-new.name-frozen-columns', 'Frozen columns'),
description: t('table-new.description-frozen-columns', 'Columns are frozen from the left side of the table'),
settings: {
placeholder: 'none',
},
category,
})
.addRadio({
path: 'cellHeight',
name: t('table-new.name-cell-height', 'Cell height'),
category,
defaultValue: defaultOptions.cellHeight,
settings: {
options: [
{ value: TableCellHeight.Sm, label: t('table-new.cell-height-options.label-small', 'Small') },
{ value: TableCellHeight.Md, label: t('table-new.cell-height-options.label-medium', 'Medium') },
{ value: TableCellHeight.Lg, label: t('table-new.cell-height-options.label-large', 'Large') },
],
},
})
.addBooleanSwitch({
path: 'footer.show',
category: footerCategory,
name: t('table-new.name-show-table-footer', 'Show table footer'),
defaultValue: defaultOptions.footer?.show,
})
.addCustomEditor({
id: 'footer.reducer',
category: footerCategory,
path: 'footer.reducer',
name: t('table-new.name-calculation', 'Calculation'),
description: t('table-new.description-calculation', 'Choose a reducer function / calculation'),
editor: standardEditorsRegistry.get('stats-picker').editor,
defaultValue: [ReducerID.sum],
showIf: (cfg) => cfg.footer?.show,
})
.addBooleanSwitch({
path: 'footer.countRows',
category: footerCategory,
name: t('table-new.name-count-rows', 'Count rows'),
description: t('table-new.description-count-rows', 'Display a single count for all data rows'),
defaultValue: defaultOptions.footer?.countRows,
showIf: (cfg) => cfg.footer?.reducer?.length === 1 && cfg.footer?.reducer[0] === ReducerID.count,
})
.addMultiSelect({
path: 'footer.fields',
category: footerCategory,
name: t('table-new.name-fields', 'Fields'),
description: t('table-new.description-fields', 'Select the fields that should be calculated'),
settings: {
allowCustomValue: false,
options: [],
placeholder: t('table-new.placeholder-fields', 'All Numeric Fields'),
getOptions: async (context: FieldOverrideContext) => {
const options = [];
if (context && context.data && context.data.length > 0) {
const frame = context.data[0];
for (const field of frame.fields) {
if (field.type === FieldType.number) {
const name = getFieldDisplayName(field, frame, context.data);
const value = field.name;
options.push({ value, label: name });
}
}
}
return options;
},
},
defaultValue: '',
showIf: (cfg) => cfg.footer?.show && !(cfg.footer?.countRows && cfg.footer?.reducer.includes(ReducerID.count)),
})
.addCustomEditor({
id: 'footer.enablePagination',
path: 'footer.enablePagination',
name: t('table-new.name-enable-pagination', 'Enable pagination'),
category,
editor: PaginationEditor,
});
})
.setSuggestionsSupplier(new TableSuggestionsSupplier());

View File

@ -1,59 +0,0 @@
// Copyright 2021 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaplugin
import (
ui "github.com/grafana/grafana/packages/grafana-schema/src/common"
)
composableKinds: PanelCfg: {
maturity: "experimental"
lineage: {
schemas: [{
version: [0, 0]
schema: {
Options: {
// Represents the index of the selected frame
frameIndex: number | *0
// Controls whether the panel should show the header
showHeader: bool | *true
// Controls whether the header should show icons for the column types
showTypeIcons?: bool | *false
// Used to control row sorting
sortBy?: [...ui.TableSortByFieldState]
// Controls footer options
footer?: ui.TableFooterOptions | *{
// Controls whether the footer should be shown
show: false
// Controls whether the footer should show the total number of rows on Count calculation
countRows: false
// Represents the selected calculations
reducer: []
}
// Controls the height of the rows
cellHeight?: ui.TableCellHeight & (*"sm" | _)
// Defines the number of columns to freeze on the left side of the table
frozenColumns?: {
left?: number | *0
}
} @cuetsy(kind="interface")
FieldConfig: {
ui.TableFieldOptions
} @cuetsy(kind="interface")
}
}]
lenses: []
}
}

View File

@ -1,68 +0,0 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTsTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
import * as ui from '@grafana/schema';
export interface Options {
/**
* Controls the height of the rows
*/
cellHeight?: ui.TableCellHeight;
/**
* Controls footer options
*/
footer?: ui.TableFooterOptions;
/**
* Represents the index of the selected frame
*/
frameIndex: number;
/**
* number of columns on the left side of the table that should be frozen
*/
frozenColumns?: {
left?: number;
};
/**
* Controls whether the panel should show the header
*/
showHeader: boolean;
/**
* Controls whether the header should show icons for the column types
*/
showTypeIcons?: boolean;
/**
* Used to control row sorting
*/
sortBy?: Array<ui.TableSortByFieldState>;
}
export const defaultOptions: Partial<Options> = {
cellHeight: ui.TableCellHeight.Sm,
footer: {
/**
* Controls whether the footer should be shown
*/
show: false,
/**
* Controls whether the footer should show the total number of rows on Count calculation
*/
countRows: false,
/**
* Represents the selected calculations
*/
reducer: [],
},
frameIndex: 0,
showHeader: true,
showTypeIcons: false,
sortBy: [],
};
export interface FieldConfig extends ui.TableFieldOptions {}

View File

@ -1,25 +0,0 @@
{
"type": "panel",
"name": "Table",
"id": "table",
"state": "beta",
"info": {
"description": "Supports many column styles",
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"small": "img/icn-table-panel.svg",
"large": "img/icn-table-panel.svg"
},
"links": [
{ "name": "Raise issue", "url": "https://github.com/grafana/grafana/issues/new" },
{
"name": "Documentation",
"url": "https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/table/"
}
]
}
}

View File

@ -1,38 +0,0 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import { TableFieldOptions } from '@grafana/schema';
import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg';
import { SuggestionName } from 'app/types/suggestions';
import { Options } from './panelcfg.gen';
export class TableSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const list = builder.getListAppender<Options, TableFieldOptions>({
name: SuggestionName.Table,
pluginId: 'table',
options: {},
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
cardOptions: {
previewModifier: (s) => {
s.fieldConfig!.defaults.custom!.minWidth = 50;
},
},
});
// If there are not data suggest table anyway but use icon instead of real preview
if (builder.dataSummary.fieldCount === 0) {
list.append({
cardOptions: {
imgSrc: icnTablePanelSvg,
},
});
} else {
list.append({});
}
}
}

View File

@ -12708,10 +12708,6 @@
"login": "Login" "login": "Login"
}, },
"table": { "table": {
"auto-cell-options-editor": {
"description-wrap-text": "If selected text will be wrapped to the width of text in the configured column",
"label-wrap-text": "Wrap text"
},
"bar-gauge-cell-options-editor": { "bar-gauge-cell-options-editor": {
"label-gauge-display-mode": "Gauge display mode", "label-gauge-display-mode": "Gauge display mode",
"label-value-display": "Value display" "label-value-display": "Value display"
@ -12739,13 +12735,8 @@
}, },
"color-background-cell-options-editor": { "color-background-cell-options-editor": {
"description-apply-to-entire-row": "If selected the entire row will be colored as this cell would be.", "description-apply-to-entire-row": "If selected the entire row will be colored as this cell would be.",
"description-wrap-text": "If selected text will be wrapped to the width of text in the configured column",
"label": {
"text-alpha": "Alpha"
},
"label-apply-to-entire-row": "Apply to entire row", "label-apply-to-entire-row": "Apply to entire row",
"label-background-display-mode": "Background display mode", "label-background-display-mode": "Background display mode"
"wrap-text": "Wrap text"
}, },
"column-alignment-options": { "column-alignment-options": {
"label-auto": "Auto", "label-auto": "Auto",
@ -12763,7 +12754,9 @@
"description-column-filter": "Enables/disables field filters in table", "description-column-filter": "Enables/disables field filters in table",
"description-count-rows": "Display a single count for all data rows", "description-count-rows": "Display a single count for all data rows",
"description-fields": "Select the fields that should be calculated", "description-fields": "Select the fields that should be calculated",
"description-frozen-columns": "Columns are frozen from the left side of the table",
"description-min-column-width": "The minimum width for column auto resizing", "description-min-column-width": "The minimum width for column auto resizing",
"description-tooltip-from-field": "Render a cell from a field (hidden or visible) in a tooltip",
"description-wrap-header-text": "Enables text wrapping for column headers", "description-wrap-header-text": "Enables text wrapping for column headers",
"image-cell-options-editor": { "image-cell-options-editor": {
"description-alt-text": "Alternative text that will be displayed if an image can't be displayed or for users who use a screen reader", "description-alt-text": "Alternative text that will be displayed if an image can't be displayed or for users who use a screen reader",
@ -12789,50 +12782,6 @@
"name-column-filter": "Column filter", "name-column-filter": "Column filter",
"name-column-width": "Column width", "name-column-width": "Column width",
"name-count-rows": "Count rows", "name-count-rows": "Count rows",
"name-enable-paginations": "Enable pagination",
"name-fields": "Fields",
"name-hide-in-table": "Hide in table",
"name-min-column-width": "Minimum column width",
"name-show-table-footer": "Show table footer",
"name-show-table-header": "Show table header",
"name-wrap-header-text": "Wrap header text",
"placeholder-column-width": "auto",
"placeholder-fields": "All Numeric Fields",
"text-wrap-options": {
"label-wrap-text": "Wrap text"
}
},
"table-new": {
"category-cell-options": "Cell options",
"category-table": "Table",
"category-table-footer": "Table footer",
"cell-height-options": {
"label-large": "Large",
"label-medium": "Medium",
"label-small": "Small"
},
"column-alignment-options": {
"label-auto": "Auto",
"label-center": "Center",
"label-left": "Left",
"label-right": "Right"
},
"description-calculation": "Choose a reducer function / calculation",
"description-cell-value-inspect": "Enable cell value inspection in a modal window",
"description-column-filter": "Enables/disables field filters in table",
"description-count-rows": "Display a single count for all data rows",
"description-fields": "Select the fields that should be calculated",
"description-frozen-columns": "Columns are frozen from the left side of the table",
"description-min-column-width": "The minimum width for column auto resizing",
"description-tooltip-from-field": "Render a cell from a field (hidden or visible) in a tooltip",
"name-calculation": "Calculation",
"name-cell-height": "Cell height",
"name-cell-type": "Cell type",
"name-cell-value-inspect": "Cell value inspect",
"name-column-alignment": "Column alignment",
"name-column-filter": "Column filter",
"name-column-width": "Column width",
"name-count-rows": "Count rows",
"name-enable-pagination": "Enable pagination", "name-enable-pagination": "Enable pagination",
"name-fields": "Fields", "name-fields": "Fields",
"name-frozen-columns": "Frozen columns", "name-frozen-columns": "Frozen columns",
@ -12842,8 +12791,12 @@
"name-show-table-header": "Show table header", "name-show-table-header": "Show table header",
"name-tooltip-from-field": "Tooltip from field", "name-tooltip-from-field": "Tooltip from field",
"name-tooltip-placement": "Tooltip placement", "name-tooltip-placement": "Tooltip placement",
"name-wrap-header-text": "Wrap header text",
"placeholder-column-width": "auto", "placeholder-column-width": "auto",
"placeholder-fields": "All Numeric Fields", "placeholder-fields": "All Numeric Fields",
"text-wrap-options": {
"label-wrap-text": "Wrap text"
},
"tooltip-placement-options": { "tooltip-placement-options": {
"label-auto": "Auto", "label-auto": "Auto",
"label-bottom": "Bottom", "label-bottom": "Bottom",