Datagrid Panel: Edit data within your dashboards (#66353)

* wip

* Datagrid WIP: snapshotting when data edited, better UI for adding column/rows, refactors

* wip

* WIP

* wip

* Add series selector

* Delete selection on key press

* wip

* Multiple row select and delete

* wip

* draggable rows/columns, better column width calculator

* bug fixes

* scrollbars

* add feature flag

* bugfixes

* bugfixes

* bugfixes

* bugfixes

* Add possibility to rename column

* Input fixes

* bugfixes

* bugfixes

* performance optimisations

* WIP component refactoring and optimisations

* comment bit of code to remove error for testing

* fix column move and payload types

* WIP refactors and tests

* e2e tests

* queryGroup subscription refactor

* queryGroup - add component on update, fix failing tests

* refactor querygroup

* querygroup refactor

* tests

* fix codeowners validation

* lint fixes

* revert convertFieldType modification in favor of already merged mod + re-add mistakenly deleted line

* remove //ts-ignores

* Minor style tweaks

* fix

* align colors with theme

* fixes

* refactor

* add test for convertFieldType transformer and write todo

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Victor Marin 2023-04-24 17:46:31 +03:00 committed by GitHub
parent c5ea3cd7e0
commit efd0e9cbea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 2565 additions and 10 deletions

View File

@ -5230,6 +5230,25 @@ exports[`better eslint`] = {
"public/app/plugins/panel/dashlist/module.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/datagrid/DataGridPanel.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/datagrid/components/RenameColumnCell.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/datagrid/components/SimpleInput.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/datagrid/state.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"],
[0, 0, 0, "Do not use any type assertions.", "5"],
[0, 0, 0, "Do not use any type assertions.", "6"],
[0, 0, 0, "Do not use any type assertions.", "7"]
],
"public/app/plugins/panel/debug/CursorView.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]

1
.github/CODEOWNERS vendored
View File

@ -389,6 +389,7 @@ lerna.json @grafana/frontend-ops
/public/app/plugins/panel/bargauge/ @grafana/grafana-bi-squad
/public/app/plugins/panel/dashlist/ @grafana/grafana-frontend-platform
/public/app/plugins/panel/debug/ @ryantxu
/public/app/plugins/panel/datagrid/ @grafana/grafana-bi-squad
/public/app/plugins/panel/gauge/ @grafana/grafana-bi-squad
/public/app/plugins/panel/gettingstarted/ @grafana/grafana-frontend-platform
/public/app/plugins/panel/graph/ @grafana/dataviz-squad

View File

@ -0,0 +1,79 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"selectedSeries": 0
},
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"refId": "A",
"scenarioId": "csv_metric_values",
"stringInput": "1,20,90,30,5,0"
},
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"refId": "B",
"scenarioId": "csv_metric_values",
"stringInput": "5,10,20,30,40,50"
}
],
"title": "Datagrid with CSV metric values",
"type": "datagrid"
}
],
"refresh": "",
"schemaVersion": 38,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Datagrid example",
"version": 0,
"weekStart": ""
}

View File

@ -163,6 +163,13 @@ local dashboard = grafana.dashboard;
id: 0,
}
},
dashboard.new('datagrid_metric_values', import '../dev-dashboards/panel-datagrid/datagrid_metric_values.json') +
resource.addMetadata('folder', 'dev-dashboards') +
{
spec+: {
id: 0,
}
},
dashboard.new('demo1', import '../dev-dashboards/datasource-testdata/demo1.json') +
resource.addMetadata('folder', 'dev-dashboards') +
{

View File

@ -56,6 +56,7 @@ Some stable features are enabled by default. You can disable a stable feature by
| `alertingNoNormalState` | Stop maintaining state of alerts that are not firing |
| `renderAuthJWT` | Uses JWT-based auth for rendering instead of relying on remote cache |
| `enableElasticsearchBackendQuerying` | Enable the processing of queries and responses in the Elasticsearch data source through backend |
| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel |
## Alpha feature toggles

View File

@ -0,0 +1,34 @@
import { e2e } from '@grafana/e2e';
const DASHBOARD_ID = 'a70ecb44-6c31-412d-ae74-d6306303ce37';
const DATAGRID_SELECT_SERIES = 'Datagrid Select series';
e2e.scenario({
describeName: 'Datagrid data changes',
itName: 'Tests changing data in the grid',
addScenarioDataSource: false,
addScenarioDashBoard: false,
skipScenario: false,
scenario: () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
// Check that the data is series A
e2e.components.PanelEditor.OptionsPane.fieldLabel(DATAGRID_SELECT_SERIES).should('be.visible');
cy.get('[data-testid="glide-cell-2-0"]').should('have.text', '1');
cy.get('[data-testid="glide-cell-2-1"]').should('have.text', '20');
cy.get('[data-testid="glide-cell-2-2"]').should('have.text', '90');
// Change the series to B
e2e.components.PanelEditor.OptionsPane.fieldLabel(DATAGRID_SELECT_SERIES).find('input').type('B {enter}');
cy.get('[data-testid="glide-cell-2-3"]').should('have.text', '30');
cy.get('[data-testid="glide-cell-2-4"]').should('have.text', '40');
cy.get('[data-testid="glide-cell-2-5"]').should('have.text', '50');
// Edit datagrid which triggers a snapshot query
cy.get('.dvn-scroller').click(200, 100);
cy.get('[data-testid="glide-cell-2-1"]').should('have.attr', 'aria-selected', 'true');
cy.get('body').type('123455{enter}', { delay: 1000 });
cy.get('[data-testid="query-editor-row"]').contains('Spreadsheet or snapshot');
},
});

View File

@ -0,0 +1,150 @@
import { e2e } from '@grafana/e2e';
const DASHBOARD_ID = 'a70ecb44-6c31-412d-ae74-d6306303ce37';
const DATAGRID_CANVAS = 'data-grid-canvas';
e2e.scenario({
describeName: 'Datagrid data changes',
itName: 'Tests changing data in the grid',
addScenarioDataSource: false,
addScenarioDashBoard: false,
skipScenario: false,
scenario: () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
// Edit datagrid which triggers a snapshot query
cy.get('.dvn-scroller').click(200, 100);
cy.get('[data-testid="glide-cell-2-1"]').should('have.attr', 'aria-selected', 'true');
cy.get('body').type('1{enter}');
// Delete a cell
cy.get('.dvn-scroller').click(200, 200);
cy.get('body').type('{del}');
cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0);
// Delete a selection
cy.get('.dvn-scroller').click(50, 100, { shiftKey: true });
cy.get('body').type('{del}');
cy.get('[data-testid="glide-cell-2-3"]').should('have.text', 0);
cy.get('[data-testid="glide-cell-2-2"]').should('have.text', 0);
cy.get('[data-testid="glide-cell-2-1"]').should('have.text', 0);
cy.get('[data-testid="glide-cell-2-0"]').should('have.text', 1);
cy.get('[data-testid="glide-cell-1-3"]').should('have.text', '');
cy.get('[data-testid="glide-cell-1-2"]').should('have.text', '');
cy.get('[data-testid="glide-cell-1-1"]').should('have.text', '');
cy.get('[data-testid="glide-cell-1-0"]').should('not.have.text', '');
// Clear column through context menu
cy.get('.dvn-scroller').rightclick(200, 100);
cy.get('[aria-label="Context menu"]').click(100, 120); // click clear column
cy.get('[data-testid="glide-cell-2-0"]').should('have.text', 0);
cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0);
// Clear row through context menu
cy.get('.dvn-scroller').click(200, 220);
cy.get('body').type('1123{enter}', { delay: 500 });
cy.get('.dvn-scroller').rightclick(200, 220);
cy.get('[aria-label="Context menu"]').click(100, 100); // click clear row
cy.get('[data-testid="glide-cell-1-4"]').should('have.text', '');
cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0);
// get the data back
cy.reload();
// Clear row through row selector
cy.get('.dvn-scroller').click(20, 190, { waitForAnimations: true });
cy.get('.dvn-scroller').click(20, 90, { shiftKey: true, waitForAnimations: true }); // with shift to select all rows between clicks
cy.get('body').type('{del}');
cy.get('[data-testid="glide-cell-1-4"]').should('have.text', '');
cy.get('[data-testid="glide-cell-1-3"]').should('have.text', '');
cy.get('[data-testid="glide-cell-1-2"]').should('have.text', '');
cy.get('[data-testid="glide-cell-1-1"]').should('have.text', '');
cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0);
cy.get('[data-testid="glide-cell-2-3"]').should('have.text', 0);
cy.get('[data-testid="glide-cell-2-2"]').should('have.text', 0);
cy.get('[data-testid="glide-cell-2-1"]').should('have.text', 0);
cy.wait(1000);
cy.reload();
cy.get('.dvn-scroller').click(20, 190, { waitForAnimations: true });
cy.get('.dvn-scroller').click(20, 90, { commandKey: true, waitForAnimations: true }); // with cmd to select only clicked rows
cy.get('body').type('{del}');
cy.get('[data-testid="glide-cell-1-1"]').should('have.text', '');
cy.get('[data-testid="glide-cell-2-1"]').should('have.text', 0);
cy.get('[data-testid="glide-cell-2-4"]').should('have.text', 0);
cy.get('[data-testid="glide-cell-1-4"]').should('have.text', '');
// Remove all data
cy.get('.dvn-scroller').rightclick(100, 100);
cy.get('body').click(150, 420);
cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).should('have.length', 0);
cy.reload();
// Delete column through header dropdown menu
cy.get('.dvn-scroller').click(250, 15); // click header dropdown
cy.get('body').click(450, 420); // click delete column
cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).should('have.length', 1);
// Delete row through context menu
cy.get('.dvn-scroller').rightclick(100, 100);
cy.get('[aria-label="Context menu"]').click(10, 10);
cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).should('have.length', 6); // there are 5 data rows + 1 for the add new row btns
// Delete rows through row selector
cy.get('.dvn-scroller').click(20, 190, { waitForAnimations: true });
cy.get('.dvn-scroller').click(20, 90, { shiftKey: true, waitForAnimations: true }); // with shift to select all rows between clicks
cy.get('.dvn-scroller').rightclick(100, 100);
cy.get('[aria-label="Context menu"]').click(10, 10);
cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).should('have.length', 2); // there are 1 data rows + 1 for the add new row btns
cy.reload();
cy.get('.dvn-scroller').click(20, 190, { waitForAnimations: true });
cy.get('.dvn-scroller').click(20, 90, { commandKey: true, waitForAnimations: true }); // with shift to select all rows between clicks
cy.get('.dvn-scroller').rightclick(40, 90);
cy.get('[aria-label="Context menu"]').click(10, 10);
cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).should('have.length', 5); // there are 5 data rows + 1 for the add new row btns
// Delete column through context menu
cy.get('.dvn-scroller').rightclick(100, 100);
cy.get('[aria-label="Context menu"]').click(10, 50);
cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).should('have.length', 1);
cy.reload();
cy.wait(3000);
// Add a new column
cy.get('body').click(350, 200).type('New Column{enter}');
cy.get('body')
.click(350, 230)
.type('Value 1{enter}')
.type('Value 2{enter}')
.type('Value 3{enter}')
.type('Value 4{enter}')
.type('Value 5{enter}')
.type('Value 6{enter}');
cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).should('have.length', 3);
// Rename a column
cy.get('.dvn-scroller').click(250, 15); // click header dropdown
cy.get('body').click(450, 380).type('{selectall}{backspace}Renamed column{enter}');
cy.get(`[data-testid="${DATAGRID_CANVAS}"] th`).contains('Renamed column');
// Change column field type
cy.get('.dvn-scroller').click(250, 15);
cy.get('[aria-label="Context menu"]').click(50, 50);
cy.get('.dvn-scroller').click(200, 100);
cy.get('body').type('Str Value{enter}');
cy.get(`[data-testid="${DATAGRID_CANVAS}"] tr`).contains('Str Value');
// Select all rows through row selection
cy.get('.dvn-scroller').click(10, 10, { waitForAnimations: true });
cy.get(`[data-testid="${DATAGRID_CANVAS}"] [aria-selected="true"]`).should('have.length', 6);
// Add a new row
cy.get('.dvn-scroller').click(200, 250);
cy.get('body').type('123');
cy.get('.dvn-scroller').click(50, 250);
cy.get('body').type('Val{enter}');
cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).contains('Val');
cy.get(`[data-testid="${DATAGRID_CANVAS}"] tbody tr`).should('have.length', 8);
},
});

View File

@ -134,6 +134,7 @@
"@types/jsurl": "^1.2.28",
"@types/lodash": "4.14.191",
"@types/logfmt": "^1.2.3",
"@types/marked": "^4",
"@types/mousetrap": "1.6.11",
"@types/node": "18.14.6",
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.0.9",
@ -250,6 +251,7 @@
"@daybrush/utils": "1.10.2",
"@emotion/css": "11.10.6",
"@emotion/react": "11.10.6",
"@glideapps/glide-data-grid": "^5.2.1",
"@grafana/aws-sdk": "0.0.44",
"@grafana/data": "workspace:*",
"@grafana/e2e-selectors": "workspace:*",
@ -340,6 +342,7 @@
"logfmt": "^1.3.2",
"lru-cache": "7.17.0",
"lru-memoize": "^1.1.0",
"marked": "^4.3.0",
"memoize-one": "6.0.0",
"moment": "2.29.4",
"moment-timezone": "0.5.41",
@ -379,6 +382,7 @@
"react-popper-tooltip": "4.4.2",
"react-redux": "7.2.6",
"react-resizable": "3.0.4",
"react-responsive-carousel": "^3.2.23",
"react-router-dom": "5.3.3",
"react-select": "5.7.0",
"react-split-pane": "0.1.92",

View File

@ -100,6 +100,27 @@ describe('field convert type', () => {
});
});
it('can convert proper numeric strings to numbers, but also treat edge-cases', () => {
const options = { targetField: 'stringy nums', destinationType: FieldType.number };
//there are scenarios where string fields have numeric values, or the strings are non-numeric and cannot pe converted
const stringyNumbers = {
name: 'stringy nums',
type: FieldType.string,
values: ['10', '1asd2', '30', 14, 10, '23', '', null],
config: {},
};
const numbers = convertFieldType(stringyNumbers, options);
expect(numbers).toEqual({
name: 'stringy nums',
type: FieldType.number,
values: [10, null, 30, 14, 10, 23, 0, 0],
config: {},
});
});
it('can convert strings with commas to numbers', () => {
const options = { targetField: 'stringy nums', destinationType: FieldType.number };

View File

@ -147,7 +147,7 @@ function fieldToNumberField(field: Field): Field {
for (let n = 0; n < numValues.length; n++) {
let toBeConverted = numValues[n];
if (valuesAsStrings && toBeConverted != null) {
if (valuesAsStrings && toBeConverted != null && typeof toBeConverted === 'string') {
// some numbers returned from datasources have commas
// strip the commas, coerce the string to a number
toBeConverted = toBeConverted.replace(/,/g, '');

View File

@ -98,4 +98,5 @@ export interface FeatureToggles {
pluginsAPIManifestKey?: boolean;
advancedDataSourcePicker?: boolean;
opensearchDetectVersion?: boolean;
enableDatagridEditing?: boolean;
}

View File

@ -192,6 +192,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *store.Serv
"trend": {},
"welcome": {},
"xychart": {},
"datagrid": {},
}
expDataSources := map[string]struct{}{

View File

@ -57,6 +57,7 @@ func corePlugins(rt *thema.Runtime) []pfs.ParsedPlugin {
parsePluginOrPanic("public/app/plugins/panel/barchart", "barchart", rt),
parsePluginOrPanic("public/app/plugins/panel/bargauge", "bargauge", rt),
parsePluginOrPanic("public/app/plugins/panel/dashlist", "dashlist", rt),
parsePluginOrPanic("public/app/plugins/panel/datagrid", "datagrid", rt),
parsePluginOrPanic("public/app/plugins/panel/debug", "debug", rt),
parsePluginOrPanic("public/app/plugins/panel/flamegraph", "flamegraph", rt),
parsePluginOrPanic("public/app/plugins/panel/gauge", "gauge", rt),

View File

@ -17,7 +17,7 @@ seqs: [
// grafana.com, then the plugin `id` has to follow the naming
// conventions.
id: string & strings.MinRunes(1)
id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|gauge|geomap|gettingstarted|graph|heatmap|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|trend|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|testdata|zipkin|phlare|parca)$"
id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|datagrid|gauge|geomap|gettingstarted|graph|heatmap|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|trend|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|testdata|zipkin|phlare|parca)$"
// Human-readable name of the plugin that is shown to the user in
// the UI.

View File

@ -532,5 +532,12 @@ var (
FrontendOnly: true,
Owner: awsPluginsSquad,
},
{
Name: "enableDatagridEditing",
Description: "Enables the edit functionality in the datagrid panel",
FrontendOnly: true,
State: FeatureStateBeta,
Owner: grafanaBiSquad,
},
}
)

View File

@ -79,3 +79,4 @@ authenticationConfigUI,alpha,@grafana/grafana-authnz-team,false,false,false,fals
pluginsAPIManifestKey,alpha,@grafana/plugins-platform-backend,false,false,false,false
advancedDataSourcePicker,alpha,@grafana/dashboards-squad,false,false,false,true
opensearchDetectVersion,alpha,@grafana/aws-plugins,false,false,false,true
enableDatagridEditing,beta,@grafana/grafana-bi-squad,false,false,false,true

1 Name State Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
79 pluginsAPIManifestKey alpha @grafana/plugins-platform-backend false false false false
80 advancedDataSourcePicker alpha @grafana/dashboards-squad false false false true
81 opensearchDetectVersion alpha @grafana/aws-plugins false false false true
82 enableDatagridEditing beta @grafana/grafana-bi-squad false false false true

View File

@ -326,4 +326,8 @@ const (
// FlagOpensearchDetectVersion
// Enable version detection in OpenSearch
FlagOpensearchDetectVersion = "opensearchDetectVersion"
// FlagEnableDatagridEditing
// Enables the edit functionality in the datagrid panel
FlagEnableDatagridEditing = "enableDatagridEditing"
)

View File

@ -386,6 +386,42 @@
"signatureType": "",
"signatureOrg": ""
},
{
"name": "Datagrid",
"type": "panel",
"id": "datagrid",
"enabled": true,
"pinned": false,
"info": {
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"description": "",
"links": null,
"logos": {
"small": "public/app/plugins/panel/datagrid/img/icn-table-panel.svg",
"large": "public/app/plugins/panel/datagrid/img/icn-table-panel.svg"
},
"build": {},
"screenshots": null,
"version": "",
"updated": ""
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
},
"latestVersion": "",
"hasUpdate": false,
"defaultNavUrl": "/plugins/datagrid/",
"category": "",
"state": "beta",
"signature": "internal",
"signatureType": "",
"signatureOrg": ""
},
{
"name": "Elasticsearch",
"type": "datasource",

View File

@ -49,6 +49,7 @@ import * as barChartPanel from 'app/plugins/panel/barchart/module';
import * as barGaugePanel from 'app/plugins/panel/bargauge/module';
import * as candlestickPanel from 'app/plugins/panel/candlestick/module';
import * as dashListPanel from 'app/plugins/panel/dashlist/module';
import * as dataGridPanel from 'app/plugins/panel/datagrid/module';
import * as debugPanel from 'app/plugins/panel/debug/module';
import * as flamegraphPanel from 'app/plugins/panel/flamegraph/module';
import * as gaugePanel from 'app/plugins/panel/gauge/module';
@ -125,6 +126,7 @@ const builtInPlugins: any = {
'app/plugins/panel/news/module': newsPanel,
'app/plugins/panel/live/module': livePanel,
'app/plugins/panel/stat/module': statPanel,
'app/plugins/panel/datagrid/module': dataGridPanel,
'app/plugins/panel/debug/module': debugPanel,
'app/plugins/panel/flamegraph/module': flamegraphPanel,
'app/plugins/panel/gettingstarted/module': gettingStartedPanel,

View File

@ -86,6 +86,25 @@ export class QueryGroup extends PureComponent<Props, State> {
next: (data: PanelData) => this.onPanelDataUpdate(data),
});
this.setNewQueriesAndDatasource(options);
}
componentWillUnmount() {
if (this.querySubscription) {
this.querySubscription.unsubscribe();
this.querySubscription = null;
}
}
async componentDidUpdate() {
const { options } = this.props;
if (this.state.dataSource && options.dataSource.uid !== this.state.dataSource?.uid) {
this.setNewQueriesAndDatasource(options);
}
}
async setNewQueriesAndDatasource(options: QueryGroupOptions) {
try {
const ds = await this.dataSourceSrv.get(options.dataSource);
const dsSettings = this.dataSourceSrv.getInstanceSettings(options.dataSource);
@ -113,13 +132,6 @@ export class QueryGroup extends PureComponent<Props, State> {
}
}
componentWillUnmount() {
if (this.querySubscription) {
this.querySubscription.unsubscribe();
this.querySubscription = null;
}
}
onPanelDataUpdate(data: PanelData) {
this.setState({ data });
}

View File

@ -0,0 +1,601 @@
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import * as React from 'react';
import { ArrayVector, DataFrame, dateTime, EventBus, FieldType, LoadingState, MutableDataFrame } from '@grafana/data';
import { DataGridPanel, DataGridProps } from './DataGridPanel';
import * as utils from './utils';
jest.mock('./featureFlagUtils', () => {
return {
isDatagridEditEnabled: jest.fn().mockReturnValue(true),
};
});
jest.mock('./utils', () => {
const originalModule = jest.requireActual('./utils');
return {
...originalModule,
deleteRows: jest.fn(),
clearCellsFromRangeSelection: jest.fn(),
publishSnapshot: jest.fn(),
};
});
describe('DataGrid', () => {
describe('when there is no data', () => {
it('renders without error', () => {
window.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
const props = buildPanelProps();
render(<DataGridPanel {...props} />);
expect(screen.getByText(/Unable to render data/i)).toBeInTheDocument();
});
});
describe('when there is data', () => {
let props: DataGridProps;
beforeEach(() => {
dataGridMocks();
props = buildPanelProps({
fields: [
{ name: 'A', type: FieldType.number, values: [1, 2, 3, 4], config: {} },
{ name: 'B', type: FieldType.string, values: ['a', 'b', 'c', 'd'], config: {} },
{ name: 'C', type: FieldType.string, values: ['a', 'b', 'c', 'd'], config: {} },
],
length: 4,
});
});
afterEach(() => {
jest.clearAllTimers();
jest.clearAllMocks();
});
it('converts dataframe values to cell values properly', () => {
jest.useFakeTimers();
render(<DataGridPanel {...props} />);
prep(false);
expect(screen.getByTestId('glide-cell-1-0')).toHaveTextContent('1');
expect(screen.getByTestId('glide-cell-2-1')).toHaveTextContent('b');
expect(screen.getByTestId('glide-cell-3-2')).toHaveTextContent('c');
expect(screen.getByTestId('glide-cell-3-3')).toHaveTextContent('d');
});
it('should open context menu on right click', async () => {
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
const scroller = prep();
if (!scroller) {
return;
}
screen.getByTestId('data-grid-canvas');
fireEvent.contextMenu(scroller, {
clientX: 30,
clientY: 36 + 32 + 16,
});
expect(screen.getByLabelText('Context menu')).toBeInTheDocument();
// on right clicking row checkboxes, only row options should be open
expect(screen.getByText('Delete row')).toBeInTheDocument();
expect(screen.getByText('Clear row')).toBeInTheDocument();
expect(screen.getByText('Remove all data')).toBeInTheDocument();
expect(screen.getByText('Search...')).toBeInTheDocument();
// no column options should be available at this point
expect(screen.queryByText('Delete column')).not.toBeInTheDocument();
// click on a column cell should show both row and column options
fireEvent.contextMenu(scroller, {
clientX: 50,
clientY: 36 + 32 + 16,
});
expect(screen.getByText('Delete row')).toBeInTheDocument();
expect(screen.getByText('Clear row')).toBeInTheDocument();
expect(screen.getByText('Delete column')).toBeInTheDocument();
expect(screen.getByText('Clear column')).toBeInTheDocument();
expect(screen.getByText('Remove all data')).toBeInTheDocument();
expect(screen.getByText('Search...')).toBeInTheDocument();
// click on header cell should show only column options
fireEvent.contextMenu(scroller, {
clientX: 50,
clientY: 36,
});
expect(screen.getByText('Delete column')).toBeInTheDocument();
expect(screen.getByText('Clear column')).toBeInTheDocument();
expect(screen.getByText('Remove all data')).toBeInTheDocument();
expect(screen.getByText('Search...')).toBeInTheDocument();
// no row options should be available at this point
expect(screen.queryByText('Delete row')).not.toBeInTheDocument();
});
it('should show correct deletion values when selection multiple rows', async () => {
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
const scroller = prep();
if (!scroller) {
return;
}
const canvas = screen.getByTestId('data-grid-canvas');
sendClick(canvas, {
clientX: 30,
clientY: 36 + 32 + 16,
shiftKey: true,
});
sendClick(canvas, {
clientX: 30,
clientY: 36 + 32 + 16 + 30,
shiftKey: true,
});
fireEvent.contextMenu(scroller, {
clientX: 30,
clientY: 36 + 32 + 16 + 30,
});
expect(screen.queryByText('Delete 2 rows')).toBeInTheDocument();
});
it('should show correct deletion values when selection multiple columns', async () => {
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
const scroller = prep();
if (!scroller) {
return;
}
const canvas = screen.getByTestId('data-grid-canvas');
sendClick(canvas, {
clientX: 40,
clientY: 36,
shiftKey: true,
});
sendClick(canvas, {
clientX: 120,
clientY: 36,
shiftKey: true,
});
fireEvent.contextMenu(scroller, {
clientX: 120,
clientY: 36,
});
expect(screen.queryByText('Delete 2 columns')).toBeInTheDocument();
});
it('editing a cell triggers publishing the snapshot', async () => {
const spy = jest.spyOn(utils, 'publishSnapshot');
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
prep();
const canvas = screen.getByTestId('data-grid-canvas');
//click on cell
sendClick(canvas, {
clientX: 50,
clientY: 36 + 32 + 16,
});
//press key, this will open the overlay input and set it to the pressed key value
fireEvent.keyDown(canvas, {
keyCode: 74,
key: '9',
});
const expectedField = {
...props.data.series[0].fields[0],
};
expectedField.values = new ArrayVector([1, 9, 3, 4]);
await waitFor(() => {
const overlay = screen.getByDisplayValue('9');
// press enter to commit the value and close the overlay
jest.useFakeTimers();
fireEvent.keyDown(overlay, {
key: 'Enter',
});
act(() => {
jest.runAllTimers();
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
fields: expect.arrayContaining([expectedField]),
}),
1
);
});
});
it('should not be able to a edit cell when there is a grid selection', async () => {
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
prep();
const canvas = screen.getByTestId('data-grid-canvas');
//2 clicks with shift to select multiple cells
sendClick(canvas, {
clientX: 50,
clientY: 36 + 32 + 16,
shiftKey: true,
});
sendClick(canvas, {
clientX: 120,
clientY: 36 + 32 + 40,
shiftKey: true,
});
// keydown to trigger overlay input on cell edit. since
// there is a selection the overlay should not be visible
fireEvent.keyDown(canvas, {
keyCode: 74,
key: '1',
});
await waitFor(() => {
expect(screen.queryByDisplayValue('1')).not.toBeInTheDocument();
});
});
it('should add a new column', async () => {
const spy = jest.spyOn(utils, 'publishSnapshot');
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
prep();
const addColumnBtn = screen.getByText('+');
fireEvent.click(addColumnBtn);
const columnInput = screen.getByTestId('column-input');
fireEvent.change(columnInput, {
target: { value: 'newColumn' },
});
fireEvent.blur(columnInput);
expect(spy).toBeCalledWith(
expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
name: 'newColumn',
type: 'string',
values: new ArrayVector(['', '', '', '']),
}),
]),
}),
1
);
});
it('should not add a new column if input is empty', async () => {
const spy = jest.spyOn(utils, 'publishSnapshot');
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
prep();
const addColumnBtn = screen.getByText('+');
fireEvent.click(addColumnBtn);
const columnInput = screen.getByTestId('column-input');
fireEvent.blur(columnInput);
expect(spy).not.toBeCalled();
});
it('should add a new row', async () => {
const spy = jest.spyOn(utils, 'publishSnapshot');
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
prep();
const canvas = screen.getByTestId('data-grid-canvas');
sendClick(canvas, {
clientX: 60,
clientY: 36 + 32 * 5, //click add row
});
expect(spy).toBeCalled();
});
it('should close context menu on right click', async () => {
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
const scroller = prep();
if (!scroller) {
return;
}
const canvas = screen.getByTestId('data-grid-canvas');
fireEvent.contextMenu(scroller, {
clientX: 30,
clientY: 36 + 32 + 16,
});
expect(screen.getByLabelText('Context menu')).toBeInTheDocument();
sendClick(canvas, {
clientX: 30,
clientY: 36 + 32 + 16,
});
expect(screen.queryByLabelText('Context menu')).not.toBeInTheDocument();
});
it('should clear cell when cell is selected and delete button clicked', async () => {
const spyClearingCells = jest.spyOn(utils, 'clearCellsFromRangeSelection');
const spyDeleteRows = jest.spyOn(utils, 'deleteRows');
const spy = jest.spyOn(utils, 'publishSnapshot');
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
const scroller = prep();
if (!scroller) {
return;
}
const canvas = screen.getByTestId('data-grid-canvas');
sendClick(canvas, {
clientX: 60,
clientY: 36 + 32 + 16,
});
fireEvent.keyDown(canvas, {
key: 'Delete',
});
expect(spy).toBeCalled();
expect(spyClearingCells).toBeCalled();
expect(spyDeleteRows).not.toBeCalled();
});
it('should clear row when row is selected delete button clicked', async () => {
const spyClearingCells = jest.spyOn(utils, 'clearCellsFromRangeSelection');
const spyDeleteRows = jest.spyOn(utils, 'deleteRows');
const spy = jest.spyOn(utils, 'publishSnapshot');
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
prep();
const canvas = screen.getByTestId('data-grid-canvas');
sendClick(canvas, {
clientX: 30,
clientY: 36 + 32 + 16,
});
fireEvent.keyDown(canvas, {
key: 'Delete',
});
expect(spy).toBeCalled();
expect(spyClearingCells).not.toBeCalled();
expect(spyDeleteRows).toBeCalled();
});
it('should move column when column dragged and dropped', async () => {
const spy = jest.spyOn(utils, 'publishSnapshot');
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
prep();
const canvas = screen.getByTestId('data-grid-canvas');
fireEvent.mouseDown(canvas, {
clientX: 50,
clientY: 36,
});
fireEvent.mouseMove(canvas, {
clientX: 120,
clientY: 36,
});
fireEvent.mouseUp(canvas);
const df = new MutableDataFrame(props.data.series[0]);
df.fields = [df.fields[1], df.fields[0], df.fields[2]];
const received = spy.mock.calls[spy.mock.calls.length - 1][0].fields.map((f) => f.name);
expect(received).toEqual(df.fields.map((f) => f.name));
});
it('should move row when row dragged and dropped', async () => {
const spy = jest.spyOn(utils, 'publishSnapshot');
jest.useFakeTimers();
render(<DataGridPanel {...props} />, {
wrapper: Context,
});
prep();
const canvas = screen.getByTestId('data-grid-canvas');
fireEvent.mouseDown(canvas, {
clientX: 30,
clientY: 36 + 16,
});
fireEvent.mouseMove(canvas, {
clientX: 30,
clientY: 36 + 32 + 32 + 16,
});
fireEvent.mouseUp(canvas);
const received: DataFrame = spy.mock.calls[spy.mock.calls.length - 1][0];
expect(received.fields[0].values[0]).toEqual(2);
expect(received.fields[0].values[1]).toEqual(3);
expect(received.fields[0].values[2]).toEqual(1);
expect(received.fields[0].values[3]).toEqual(4);
expect(received.fields[1].values[0]).toEqual('b');
expect(received.fields[1].values[1]).toEqual('c');
expect(received.fields[1].values[2]).toEqual('a');
expect(received.fields[1].values[3]).toEqual('d');
expect(received.fields[2].values[0]).toEqual('b');
expect(received.fields[2].values[1]).toEqual('c');
expect(received.fields[2].values[2]).toEqual('a');
expect(received.fields[2].values[3]).toEqual('d');
});
});
});
const buildPanelProps = (...df: DataFrame[]) => {
const timeRange = {
from: dateTime(),
to: dateTime(),
raw: {
from: dateTime(),
to: dateTime(),
},
};
return {
id: 1,
title: 'DataGrid',
options: { selectedSeries: 0 },
data: {
series: df,
state: LoadingState.Done,
timeRange,
},
timeRange,
timeZone: 'browser',
width: 500,
height: 500,
transparent: false,
renderCounter: 0,
onOptionsChange: jest.fn(),
onFieldConfigChange: jest.fn(),
onChangeTimeRange: jest.fn(),
replaceVariables: jest.fn(),
eventBus: {} as EventBus,
fieldConfig: {
defaults: {},
overrides: [],
},
};
};
const prep = (resetTimers = true) => {
const scroller = document.getElementsByClassName('dvn-scroller').item(0);
if (scroller !== null) {
jest.spyOn(scroller, 'clientWidth', 'get').mockImplementation(() => 1000);
jest.spyOn(scroller, 'clientHeight', 'get').mockImplementation(() => 1000);
}
act(() => {
jest.runAllTimers();
});
if (resetTimers) {
jest.useRealTimers();
} else {
act(() => {
jest.runAllTimers();
});
}
return scroller;
};
const dataGridMocks = () => {
window.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
Element.prototype.scrollTo = jest.fn();
Element.prototype.scrollBy = jest.fn();
Element.prototype.getBoundingClientRect = () => ({
bottom: 1000,
height: 1000,
left: 0,
right: 1000,
top: 0,
width: 1000,
x: 0,
y: 0,
toJSON: () => '',
});
Object.defineProperties(HTMLElement.prototype, {
offsetWidth: {
get() {
return 1000;
},
},
});
Image.prototype.decode = jest.fn();
};
const sendClick = (el: Element | Node | Document | Window, options?: {}): void => {
fireEvent.mouseDown(el, options);
fireEvent.mouseUp(el, options);
fireEvent.click(el, options);
};
const Context = (p: any) => {
return (
<>
{p.children}
<div id="grafana-portal-container"></div>
</>
);
};

View File

@ -0,0 +1,281 @@
import DataEditor, {
GridCell,
Item,
GridColumn,
EditableGridCell,
GridSelection,
CellClickedEventArgs,
Rectangle,
HeaderClickedEventArgs,
} from '@glideapps/glide-data-grid';
import React, { useEffect, useReducer } from 'react';
import { ArrayVector, Field, MutableDataFrame, PanelProps, FieldType } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { useTheme2 } from '@grafana/ui';
import '@glideapps/glide-data-grid/dist/index.css';
import { AddColumn } from './components/AddColumn';
import { DatagridContextMenu } from './components/DatagridContextMenu';
import { RenameColumnCell } from './components/RenameColumnCell';
import { isDatagridEditEnabled } from './featureFlagUtils';
import { PanelOptions } from './panelcfg.gen';
import { DatagridActionType, datagridReducer, initialState } from './state';
import {
clearCellsFromRangeSelection,
deleteRows,
EMPTY_CELL,
getGridCellKind,
getGridTheme,
publishSnapshot,
RIGHT_ELEMENT_PROPS,
TRAILING_ROW_OPTIONS,
getStyles,
ROW_MARKER_BOTH,
ROW_MARKER_NUMBER,
hasGridSelection,
} from './utils';
export interface DataGridProps extends PanelProps<PanelOptions> {}
export function DataGridPanel({ options, data, id, fieldConfig, width, height }: DataGridProps) {
const [state, dispatch] = useReducer(datagridReducer, initialState);
const {
columns,
contextMenuData,
renameColumnInputData,
gridSelection,
columnFreezeIndex,
toggleSearch,
isResizeInProgress,
} = state;
const frame = data.series[options.selectedSeries ?? 0];
const theme = useTheme2();
const gridTheme = getGridTheme(theme);
useEffect(() => {
if (!frame) {
return;
}
dispatch({ type: DatagridActionType.updateColumns, payload: { frame } });
}, [frame]);
const getCellContent = ([col, row]: Item): GridCell => {
const field: Field = frame.fields[col];
if (!field || row > frame.length) {
return EMPTY_CELL;
}
return getGridCellKind(field, row, hasGridSelection(gridSelection));
};
const onCellEdited = (cell: Item, newValue: EditableGridCell) => {
const [col, row] = cell;
const field: Field = frame.fields[col];
if (!field) {
return;
}
const values = field.values.toArray();
values[row] = newValue.data;
field.values = new ArrayVector(values);
publishSnapshot(new MutableDataFrame(frame), id);
};
const onColumnInputBlur = (columnName: string) => {
const len = frame.length ?? 0;
const newFrame = new MutableDataFrame(frame);
const field: Field = {
name: columnName,
type: FieldType.string,
config: {},
values: new ArrayVector(new Array(len).fill('')),
};
newFrame.addField(field);
publishSnapshot(newFrame, id);
};
const addNewRow = () => {
//TODO use .appendRow() after fieldValues refactor is finished
const newFrame = new MutableDataFrame(frame);
newFrame.fields.map((field) => {
field.values = new ArrayVector([...field.values.toArray(), null]);
});
publishSnapshot(newFrame, id);
};
const onColumnResize = (column: GridColumn, width: number, columnIndex: number, newSizeWithGrow: number) => {
dispatch({ type: DatagridActionType.columnResizeStart, payload: { columnIndex, width } });
};
//Hack used to allow resizing last column, near add column btn. This is a workaround for a bug in the grid component
const onColumnResizeEnd = (column: GridColumn, newSize: number, colIndex: number, newSizeWithGrow: number) => {
dispatch({ type: DatagridActionType.columnResizeEnd });
};
const closeContextMenu = () => {
dispatch({ type: DatagridActionType.closeContextMenu });
};
const onDeletePressed = (selection: GridSelection) => {
if (selection.current && selection.current.range) {
publishSnapshot(clearCellsFromRangeSelection(frame, selection.current.range), id);
return true;
}
if (selection.rows) {
publishSnapshot(deleteRows(frame, selection.rows.toArray()), id);
return true;
}
return false;
};
const onCellContextMenu = (cell: Item, event: CellClickedEventArgs) => {
event.preventDefault();
dispatch({ type: DatagridActionType.openCellContextMenu, payload: { event, cell } });
};
const onHeaderContextMenu = (columnIndex: number, event: HeaderClickedEventArgs) => {
event.preventDefault();
dispatch({ type: DatagridActionType.openHeaderContextMenu, payload: { event, columnIndex } });
};
const onHeaderMenuClick = (col: number, screenPosition: Rectangle) => {
dispatch({
type: DatagridActionType.openHeaderDropdownMenu,
payload: { screenPosition, columnIndex: col, value: frame.fields[col].name },
});
};
const onColumnMove = (from: number, to: number) => {
const newFrame = new MutableDataFrame(frame);
const field = newFrame.fields[from];
newFrame.fields.splice(from, 1);
newFrame.fields.splice(to, 0, field);
dispatch({ type: DatagridActionType.columnMove, payload: { from, to } });
publishSnapshot(newFrame, id);
};
const onRowMove = (from: number, to: number) => {
const newFrame = new MutableDataFrame(frame);
for (const field of newFrame.fields) {
const values = field.values.toArray();
const value = values[from];
values.splice(from, 1);
values.splice(to, 0, value);
field.values = new ArrayVector(values);
}
publishSnapshot(newFrame, id);
};
const onColumnRename = () => {
dispatch({ type: DatagridActionType.showColumnRenameInput });
};
const onRenameInputBlur = (columnName: string, columnIdx: number) => {
const newFrame = new MutableDataFrame(frame);
newFrame.fields[columnIdx].name = columnName;
dispatch({ type: DatagridActionType.hideColumnRenameInput });
publishSnapshot(newFrame, id);
};
const onSearchClose = () => {
dispatch({ type: DatagridActionType.closeSearch });
};
const onGridSelectionChange = (selection: GridSelection) => {
dispatch({ type: DatagridActionType.multipleCellsSelected, payload: { selection } });
};
if (!frame) {
return <PanelDataErrorView panelId={id} fieldConfig={fieldConfig} data={data} />;
}
if (!document.getElementById('portal')) {
const portal = document.createElement('div');
portal.id = 'portal';
document.body.appendChild(portal);
}
const styles = getStyles(theme, isResizeInProgress);
return (
<>
<DataEditor
className={styles.dataEditor}
getCellContent={getCellContent}
columns={columns}
rows={frame.length}
width={width}
height={height}
initialSize={[width, height]}
theme={gridTheme}
smoothScrollX
smoothScrollY
overscrollY={50}
onCellEdited={isDatagridEditEnabled() ? onCellEdited : undefined}
getCellsForSelection={isDatagridEditEnabled() ? true : undefined}
showSearch={isDatagridEditEnabled() ? toggleSearch : false}
onSearchClose={onSearchClose}
onPaste={isDatagridEditEnabled() ? true : undefined}
gridSelection={gridSelection}
onGridSelectionChange={isDatagridEditEnabled() ? onGridSelectionChange : undefined}
onRowAppended={isDatagridEditEnabled() ? addNewRow : undefined}
onDelete={isDatagridEditEnabled() ? onDeletePressed : undefined}
rowMarkers={isDatagridEditEnabled() ? ROW_MARKER_BOTH : ROW_MARKER_NUMBER}
onColumnResize={onColumnResize}
onColumnResizeEnd={onColumnResizeEnd}
onCellContextMenu={isDatagridEditEnabled() ? onCellContextMenu : undefined}
onHeaderContextMenu={isDatagridEditEnabled() ? onHeaderContextMenu : undefined}
onHeaderMenuClick={isDatagridEditEnabled() ? onHeaderMenuClick : undefined}
trailingRowOptions={TRAILING_ROW_OPTIONS}
rightElement={
isDatagridEditEnabled() ? (
<AddColumn onColumnInputBlur={onColumnInputBlur} divStyle={styles.addColumnDiv} />
) : null
}
rightElementProps={RIGHT_ELEMENT_PROPS}
freezeColumns={columnFreezeIndex}
onRowMoved={isDatagridEditEnabled() ? onRowMove : undefined}
onColumnMoved={isDatagridEditEnabled() ? onColumnMove : undefined}
/>
{contextMenuData.isContextMenuOpen && (
<DatagridContextMenu
menuData={contextMenuData}
data={frame}
saveData={(data) => publishSnapshot(data, id)}
closeContextMenu={closeContextMenu}
dispatch={dispatch}
gridSelection={gridSelection}
columnFreezeIndex={columnFreezeIndex}
renameColumnClicked={onColumnRename}
/>
)}
{renameColumnInputData.isInputOpen ? (
<RenameColumnCell
onColumnInputBlur={onRenameInputBlur}
renameColumnData={renameColumnInputData}
classStyle={styles.renameColumnInput}
/>
) : null}
</>
);
}

View File

@ -0,0 +1,5 @@
# Datagrid Panel - Native Plugin
This Datagrid panel is **included** with Grafana.
The datagrid panel allows users to edit their data.

View File

@ -0,0 +1,35 @@
import React, { useState } from 'react';
import { SimpleInput } from './SimpleInput';
interface AddColumnProps {
divStyle: string;
onColumnInputBlur: (columnName: string) => void;
}
export const AddColumn = ({ divStyle, onColumnInputBlur }: AddColumnProps) => {
const [showInput, setShowInput] = useState<boolean>(false);
const setupColumnInput = () => {
setShowInput(true);
};
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const columnName = e.target.value;
if (columnName) {
onColumnInputBlur(columnName);
}
setShowInput(false);
};
return (
<div className={divStyle}>
{showInput ? (
<SimpleInput placeholder="Column Name" onBlur={onBlur} />
) : (
<button onClick={setupColumnInput}>+</button>
)}
</div>
);
};

View File

@ -0,0 +1,247 @@
import { GridSelection } from '@glideapps/glide-data-grid';
import { capitalize } from 'lodash';
import React from 'react';
import { ArrayVector, DataFrame, FieldType } from '@grafana/data';
import { convertFieldType } from '@grafana/data/src/transformations/transformers/convertFieldType';
import { ContextMenu, MenuGroup, MenuItem } from '@grafana/ui';
import { MenuDivider } from '@grafana/ui/src/components/Menu/MenuDivider';
import { DatagridAction, DatagridActionType } from '../state';
import { cleanStringFieldAfterConversion, DatagridContextMenuData, deleteRows, EMPTY_DF } from '../utils';
interface ContextMenuProps {
menuData: DatagridContextMenuData;
data: DataFrame;
saveData: (data: DataFrame) => void;
dispatch: React.Dispatch<DatagridAction>;
closeContextMenu: () => void;
gridSelection: GridSelection;
columnFreezeIndex: number;
renameColumnClicked: () => void;
}
export const DatagridContextMenu = ({
menuData,
data,
saveData,
closeContextMenu,
dispatch,
gridSelection,
columnFreezeIndex,
renameColumnClicked,
}: ContextMenuProps) => {
let selectedRows: number[] = [];
let selectedColumns: number[] = [];
const { row, column, x, y, isHeaderMenu } = menuData;
if (gridSelection.rows) {
selectedRows = gridSelection.rows.toArray();
}
if (gridSelection.columns) {
selectedColumns = gridSelection.columns.toArray();
}
let rowDeletionLabel = 'Delete row';
if (selectedRows.length && selectedRows.length > 1) {
rowDeletionLabel = `Delete ${selectedRows.length} rows`;
}
let columnDeletionLabel = 'Delete column';
if (selectedColumns.length && selectedColumns.length > 1) {
columnDeletionLabel = `Delete ${selectedColumns.length} columns`;
}
const showDeleteRow = (row !== undefined && row >= 0) || selectedRows.length;
const showDeleteColumn = (column !== undefined && column >= 0) || selectedColumns.length;
const showClearRow = row !== undefined && row >= 0 && !selectedRows.length;
const showClearColumn = column !== undefined && column >= 0 && !selectedColumns.length;
const renderContextMenuItems = () => (
<>
{showDeleteRow ? (
<MenuItem
label={rowDeletionLabel}
onClick={() => {
if (selectedRows.length) {
saveData(deleteRows(data, selectedRows, true));
dispatch({ type: DatagridActionType.gridSelectionCleared });
return;
}
if (row !== undefined && row >= 0) {
saveData(deleteRows(data, [row], true));
}
}}
/>
) : null}
{showDeleteColumn ? (
<MenuItem
label={columnDeletionLabel}
onClick={() => {
if (selectedColumns.length) {
saveData({
...data,
fields: data.fields.filter((_, index) => !selectedColumns.includes(index)),
});
return;
}
if (column !== undefined && column >= 0) {
saveData({
...data,
fields: data.fields.filter((_, index) => index !== column),
});
}
}}
/>
) : null}
{showDeleteColumn || showDeleteRow ? <MenuDivider /> : null}
{showClearRow ? (
<MenuItem
label="Clear row"
onClick={() => {
saveData(deleteRows(data, [row]));
}}
/>
) : null}
{showClearColumn ? (
<MenuItem
label="Clear column"
onClick={() => {
const field = data.fields[column];
field.values = new ArrayVector(field.values.toArray().map(() => null));
saveData({
...data,
});
}}
/>
) : null}
{showClearRow || showClearColumn ? <MenuDivider /> : null}
<MenuItem
label="Remove all data"
onClick={() => {
saveData(EMPTY_DF);
}}
/>
<MenuItem label="Search..." onClick={() => dispatch({ type: DatagridActionType.openSearch })} />
</>
);
const renderHeaderMenuItems = () => {
if (column === null || column === undefined) {
return null;
}
const fieldType = data.fields[column].type;
const fieldTypeConversionData: Array<{
label: string;
options: {
targetField: string;
destinationType: FieldType;
};
}> = [];
const addToConversionData = (fieldType: FieldType) => {
fieldTypeConversionData.push({
label: capitalize(fieldType),
options: {
targetField: data.fields[column].name,
destinationType: fieldType,
},
});
};
if (fieldType === FieldType.string) {
addToConversionData(FieldType.number);
addToConversionData(FieldType.boolean);
} else if (fieldType === FieldType.number) {
addToConversionData(FieldType.string);
addToConversionData(FieldType.boolean);
} else if (fieldType === FieldType.boolean) {
addToConversionData(FieldType.number);
addToConversionData(FieldType.string);
} else {
addToConversionData(FieldType.string);
addToConversionData(FieldType.number);
addToConversionData(FieldType.boolean);
}
let columnFreezeLabel = 'Set column freeze position';
const columnIndex = column + 1;
if (columnFreezeIndex === columnIndex) {
columnFreezeLabel = 'Unset column freeze';
}
return (
<>
{fieldTypeConversionData.length ? (
<MenuGroup label="Set field type">
{fieldTypeConversionData.map((conversionData, index) => (
<MenuItem
key={index}
label={conversionData.label}
onClick={() => {
const field = convertFieldType(data.fields[column], conversionData.options);
if (conversionData.options.destinationType === FieldType.string) {
cleanStringFieldAfterConversion(field);
}
const copy = {
name: data.name,
fields: [...data.fields],
length: data.length,
};
copy.fields[column] = field;
saveData(copy);
}}
/>
))}
</MenuGroup>
) : null}
<MenuDivider />
<MenuItem
label={columnFreezeLabel}
onClick={() => {
if (columnFreezeIndex === columnIndex) {
dispatch({ type: DatagridActionType.columnFreezeReset });
} else {
dispatch({ type: DatagridActionType.columnFreezeChanged, payload: { columnIndex } });
}
}}
/>
<MenuItem label="Rename column" onClick={renameColumnClicked} />
<MenuDivider />
<MenuItem
label={columnDeletionLabel}
onClick={() => {
if (selectedColumns.length) {
saveData({
...data,
fields: data.fields.filter((_, index) => !selectedColumns.includes(index)),
});
return;
}
saveData({
...data,
fields: data.fields.filter((_, index) => index !== column),
});
}}
/>
</>
);
};
return (
<ContextMenu
renderMenuItems={isHeaderMenu ? renderHeaderMenuItems : renderContextMenuItems}
x={x!}
y={y!}
onClose={closeContextMenu}
/>
);
};

View File

@ -0,0 +1,71 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import { Portal } from '@grafana/ui';
import { RenameColumnInputData } from '../utils';
interface RenameColumnProps {
renameColumnData: RenameColumnInputData;
onColumnInputBlur: (columnName: string, columnIdx: number) => void;
classStyle?: string;
}
export const RenameColumnCell = ({ renameColumnData, onColumnInputBlur, classStyle }: RenameColumnProps) => {
const { x, y, width, height, inputValue, columnIdx } = renameColumnData;
const [styles, setStyles] = useState({});
const [value, setValue] = useState<string>(inputValue!);
const ref = useRef<HTMLInputElement>(null);
useLayoutEffect(() => {
ref.current?.focus();
const rect = ref.current?.getBoundingClientRect();
if (rect) {
const collisions = {
right: window.innerWidth < x! + rect.width,
bottom: window.innerHeight < y! + rect.height,
};
setStyles({
position: 'fixed',
left: collisions.right ? x! - rect.width : x!,
top: collisions.bottom ? y! - rect.height : y!,
width: width,
height: height,
});
}
}, [height, width, x, y]);
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const columnName = e.target.value;
if (columnName) {
onColumnInputBlur(columnName, columnIdx!);
}
};
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const target = e.target as HTMLInputElement;
target.blur();
}
};
return (
<Portal>
<input
type="text"
className={classStyle}
value={value}
onBlur={onBlur}
ref={ref}
style={styles}
onChange={onChange}
onKeyDown={onKeyDown}
/>
</Portal>
);
};

View File

@ -0,0 +1,36 @@
import React, { useRef, useEffect } from 'react';
interface InputProps {
onBlur: (e: React.FocusEvent<HTMLInputElement>) => void;
placeholder: string;
}
export const SimpleInput = ({ onBlur, placeholder }: InputProps) => {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!ref.current) {
return;
}
ref.current.focus();
});
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const target = e.target as HTMLInputElement;
target.blur();
}
};
return (
<input
type="text"
placeholder={placeholder}
onBlur={onBlur}
ref={ref}
onKeyDown={onKeyDown}
data-testid="column-input"
/>
);
};

View File

@ -0,0 +1,5 @@
import { config } from '@grafana/runtime';
export const isDatagridEditEnabled = () => {
return config.featureToggles.enableDatagridEditing;
};

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,24 @@
import { PanelPlugin } from '@grafana/data';
import { DataGridPanel } from './DataGridPanel';
import { defaultPanelOptions, PanelOptions } from './panelcfg.gen';
export const plugin = new PanelPlugin<PanelOptions>(DataGridPanel).setPanelOptions((builder, context) => {
const seriesOptions = context.data.map((frame, idx) => ({ value: idx, label: frame.refId }));
if (
context.options &&
!seriesOptions.map((s: { value: number }) => s.value).includes(context.options.selectedSeries ?? 0)
) {
context.options.selectedSeries = defaultPanelOptions.selectedSeries!;
}
return builder.addSelect({
path: 'selectedSeries',
name: 'Select series',
defaultValue: defaultPanelOptions.selectedSeries,
settings: {
options: seriesOptions,
},
});
});

View File

@ -0,0 +1,33 @@
// 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
composableKinds: PanelCfg: {
maturity: "experimental"
lineage: {
seqs: [
{
schemas: [
{
PanelOptions: {
selectedSeries: int32 & >=0 | *0
} @cuetsy(kind="interface")
},
]
},
]
}
}

View File

@ -0,0 +1,19 @@
// 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.
export const PanelCfgModelVersion = Object.freeze([0, 0]);
export interface PanelOptions {
selectedSeries: number;
}
export const defaultPanelOptions: Partial<PanelOptions> = {
selectedSeries: 0,
};

View File

@ -0,0 +1,17 @@
{
"type": "panel",
"name": "Datagrid",
"id": "datagrid",
"state": "beta",
"info": {
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"small": "img/icn-table-panel.svg",
"large": "img/icn-table-panel.svg"
}
}
}

View File

@ -0,0 +1,299 @@
import {
CellClickedEventArgs,
GridColumnIcon,
GridSelection,
HeaderClickedEventArgs,
Item,
Rectangle,
SizedGridColumn,
} from '@glideapps/glide-data-grid';
import { DataFrame, Field, FieldType, getFieldDisplayName } from '@grafana/data';
import { isDatagridEditEnabled } from './featureFlagUtils';
import {
DatagridContextMenuData,
DEFAULT_CONTEXT_MENU,
DEFAULT_RENAME_INPUT_DATA,
EMPTY_GRID_SELECTION,
getCellWidth,
RenameColumnInputData,
} from './utils';
interface DatagridState {
columns: SizedGridColumn[];
contextMenuData: DatagridContextMenuData;
renameColumnInputData: RenameColumnInputData;
gridSelection: GridSelection;
columnFreezeIndex: number;
toggleSearch: boolean;
isResizeInProgress: boolean;
}
interface UpdateColumnsPayload {
frame: DataFrame;
}
interface ColumnResizeStartPayload {
columnIndex: number;
width: number;
}
interface OpenCellContextMenuPayload {
event: CellClickedEventArgs;
cell: Item;
}
interface OpenHeaderContextMenuPayload {
event: HeaderClickedEventArgs;
columnIndex: number;
}
interface OpenHeaderDropdownMenuPayload {
screenPosition: Rectangle;
columnIndex: number;
value: string;
}
interface ColumnMovePayload {
from: number;
to: number;
}
interface MultipleCellsSelectedPayload {
selection: GridSelection;
}
interface ColumnFreezeChangedPayload {
columnIndex: number;
}
export interface DatagridAction {
type: DatagridActionType;
payload?:
| UpdateColumnsPayload
| ColumnResizeStartPayload
| OpenCellContextMenuPayload
| OpenHeaderContextMenuPayload
| OpenHeaderDropdownMenuPayload
| ColumnMovePayload
| MultipleCellsSelectedPayload
| ColumnFreezeChangedPayload;
}
export enum DatagridActionType {
columnResizeStart = 'columnResizeStart',
columnResizeEnd = 'columnResizeEnd',
columnMove = 'columnMove',
updateColumns = 'updateColumns',
showColumnRenameInput = 'showColumnRenameInput',
hideColumnRenameInput = 'hideColumnRenameInput',
openCellContextMenu = 'openCellContextMenu',
openHeaderContextMenu = 'openHeaderContextMenu',
openHeaderDropdownMenu = 'openHeaderDropdownMenu',
closeContextMenu = 'closeContextMenu',
multipleCellsSelected = 'multipleCellsSelected',
gridSelectionCleared = 'gridSelectionCleared',
columnFreezeReset = 'columnFreezeReset',
columnFreezeChanged = 'columnFreezeChanged',
openSearch = 'openSearch',
closeSearch = 'closeSearch',
}
export const initialState: DatagridState = {
columns: [],
contextMenuData: DEFAULT_CONTEXT_MENU,
renameColumnInputData: DEFAULT_RENAME_INPUT_DATA,
gridSelection: EMPTY_GRID_SELECTION,
columnFreezeIndex: 0,
toggleSearch: false,
isResizeInProgress: false,
};
const typeToIconMap: Map<string, GridColumnIcon> = new Map([
[FieldType.number, GridColumnIcon.HeaderNumber],
[FieldType.string, GridColumnIcon.HeaderTextTemplate],
[FieldType.boolean, GridColumnIcon.HeaderBoolean],
[FieldType.time, GridColumnIcon.HeaderDate],
[FieldType.other, GridColumnIcon.HeaderReference],
]);
export const datagridReducer = (state: DatagridState, action: DatagridAction): DatagridState => {
let columns: SizedGridColumn[] = [];
switch (action.type) {
case DatagridActionType.columnResizeStart:
columns = [...state.columns];
const columnResizeStartPayload: ColumnResizeStartPayload = action.payload as ColumnResizeStartPayload;
columns[columnResizeStartPayload.columnIndex] = {
...state.columns[columnResizeStartPayload.columnIndex],
width: columnResizeStartPayload.width,
};
return {
...state,
columns,
isResizeInProgress: true,
};
case DatagridActionType.columnMove:
columns = [...state.columns];
const columnMovePayload: ColumnMovePayload = action.payload as ColumnMovePayload;
const widthFrom = state.columns[columnMovePayload.from].width;
let fromColumn = columns.splice(columnMovePayload.from, 1)[0];
fromColumn = {
...fromColumn,
width: widthFrom,
};
columns.splice(columnMovePayload.to, 0, fromColumn);
return {
...state,
columns,
};
case DatagridActionType.columnResizeEnd:
return {
...state,
isResizeInProgress: false,
};
case DatagridActionType.updateColumns:
const updateColumnsPayload: UpdateColumnsPayload = action.payload as UpdateColumnsPayload;
columns = [
...updateColumnsPayload.frame.fields.map((field: Field, index: number) => {
const displayName = getFieldDisplayName(field, updateColumnsPayload.frame);
return {
title: displayName,
width: state.columns[index]?.width ?? getCellWidth(field),
icon: typeToIconMap.get(field.type),
hasMenu: isDatagridEditEnabled(),
trailingRowOptions: { targetColumn: --index },
};
}),
];
return {
...state,
columns,
};
case DatagridActionType.showColumnRenameInput:
return {
...state,
renameColumnInputData: {
...state.renameColumnInputData,
isInputOpen: true,
},
};
case DatagridActionType.hideColumnRenameInput:
return {
...state,
renameColumnInputData: {
...state.renameColumnInputData,
isInputOpen: false,
},
};
case DatagridActionType.openCellContextMenu:
const openCellContextMenuPayload: OpenCellContextMenuPayload = action.payload as OpenCellContextMenuPayload;
const cellEvent: CellClickedEventArgs = openCellContextMenuPayload.event;
const cell: Item = openCellContextMenuPayload.cell;
return {
...state,
contextMenuData: {
x: cellEvent.bounds.x + cellEvent.localEventX,
y: cellEvent.bounds.y + cellEvent.localEventY,
column: cell[0] === -1 ? undefined : cell[0], //row numbers,
row: cell[1],
isContextMenuOpen: true,
isHeaderMenu: false,
},
};
case DatagridActionType.openHeaderContextMenu:
const openHeaderContextMenuPayload: OpenHeaderContextMenuPayload = action.payload as OpenHeaderContextMenuPayload;
const headerEvent: HeaderClickedEventArgs = openHeaderContextMenuPayload.event;
return {
...state,
contextMenuData: {
x: headerEvent.bounds.x + headerEvent.localEventX,
y: headerEvent.bounds.y + headerEvent.localEventY,
column: openHeaderContextMenuPayload.columnIndex,
row: undefined, //header
isContextMenuOpen: true,
isHeaderMenu: false,
},
};
case DatagridActionType.openHeaderDropdownMenu:
const openHeaderDropdownMenuPayload: OpenHeaderDropdownMenuPayload =
action.payload as OpenHeaderDropdownMenuPayload;
const screenPosition: Rectangle = openHeaderDropdownMenuPayload.screenPosition;
return {
...state,
contextMenuData: {
x: screenPosition.x + screenPosition.width,
y: screenPosition.y + screenPosition.height,
column: openHeaderDropdownMenuPayload.columnIndex,
row: undefined, //header
isContextMenuOpen: true,
isHeaderMenu: true,
},
renameColumnInputData: {
x: screenPosition.x,
y: screenPosition.y,
width: screenPosition.width,
height: screenPosition.height,
columnIdx: openHeaderDropdownMenuPayload.columnIndex,
isInputOpen: false,
inputValue: openHeaderDropdownMenuPayload.value,
},
};
case DatagridActionType.closeContextMenu:
return {
...state,
contextMenuData: {
isContextMenuOpen: false,
},
};
case DatagridActionType.closeSearch:
return {
...state,
toggleSearch: false,
};
case DatagridActionType.openSearch:
return {
...state,
toggleSearch: true,
};
case DatagridActionType.multipleCellsSelected:
const multipleCellsSelectedPayload: MultipleCellsSelectedPayload = action.payload as MultipleCellsSelectedPayload;
return {
...state,
gridSelection: multipleCellsSelectedPayload.selection,
};
case DatagridActionType.gridSelectionCleared:
return {
...state,
gridSelection: EMPTY_GRID_SELECTION,
};
case DatagridActionType.columnFreezeReset:
return {
...state,
columnFreezeIndex: 0,
};
case DatagridActionType.columnFreezeChanged:
const columnFreezeChangedPayload: ColumnFreezeChangedPayload = action.payload as ColumnFreezeChangedPayload;
return {
...state,
columnFreezeIndex: columnFreezeChangedPayload.columnIndex,
};
default:
return state;
}
};

View File

@ -0,0 +1,122 @@
import { ArrayVector, DataFrame, FieldType } from '@grafana/data';
import { clearCellsFromRangeSelection, deleteRows } from './utils';
describe('when deleting rows', () => {
let df: DataFrame;
beforeEach(() => {
df = {
name: 'test',
length: 5,
fields: [
{
name: 'test1',
type: FieldType.string,
values: new ArrayVector(['a', 'b', 'c', 'd', 'e']),
config: {},
},
{
name: 'test2',
type: FieldType.number,
values: new ArrayVector([1, 2, 3, 4, 5]),
config: {},
},
{
name: 'test3',
type: FieldType.string,
values: new ArrayVector(['a', 'b', 'c', 'd', 'e']),
config: {},
},
],
};
});
it('should return same dataframe if no rows are selected', () => {
const newDf = deleteRows(df, []);
expect(newDf.fields).toEqual(df.fields);
});
it('should soft delete selected rows', () => {
const newDf = deleteRows(df, [1, 3]);
expect(newDf.fields[0].values.toArray()).toEqual(['a', null, 'c', null, 'e']);
expect(newDf.fields[1].values.toArray()).toEqual([1, null, 3, null, 5]);
expect(newDf.fields[2].values.toArray()).toEqual(['a', null, 'c', null, 'e']);
expect(newDf.length).toEqual(5);
});
it('should remove selected rows', () => {
let newDf = deleteRows(df, [1, 3], true);
expect(newDf.fields[0].values.toArray()).toEqual(['a', 'c', 'e']);
expect(newDf.fields[1].values.toArray()).toEqual([1, 3, 5]);
expect(newDf.fields[2].values.toArray()).toEqual(['a', 'c', 'e']);
expect(newDf.length).toEqual(3);
newDf = deleteRows(df, [2], true);
expect(newDf.fields[0].values.toArray()).toEqual(['a', 'c']);
expect(newDf.fields[1].values.toArray()).toEqual([1, 3]);
expect(newDf.fields[2].values.toArray()).toEqual(['a', 'c']);
expect(newDf.length).toEqual(2);
});
it('should remove all rows when all rows are selected', () => {
const newDf = deleteRows(df, [0, 1, 2, 3, 4], true);
expect(newDf.fields[0].values.toArray()).toEqual([]);
expect(newDf.fields[1].values.toArray()).toEqual([]);
expect(newDf.fields[2].values.toArray()).toEqual([]);
expect(newDf.length).toEqual(0);
});
});
describe('when clearing cells from range selection', () => {
let df: DataFrame;
beforeEach(() => {
df = {
name: 'test',
length: 5,
fields: [
{
name: 'test1',
type: FieldType.string,
values: new ArrayVector(['a', 'b', 'c', 'd', 'e']),
config: {},
},
{
name: 'test2',
type: FieldType.number,
values: new ArrayVector([1, 2, 3, 4, 5]),
config: {},
},
{
name: 'test3',
type: FieldType.string,
values: new ArrayVector(['a', 'b', 'c', 'd', 'e']),
config: {},
},
],
};
});
it('should clear cells from range selection', () => {
const newDf = clearCellsFromRangeSelection(df, { x: 0, y: 0, width: 2, height: 2 });
expect(newDf.fields[0].values.toArray()).toEqual([null, null, 'c', 'd', 'e']);
expect(newDf.fields[1].values.toArray()).toEqual([null, null, 3, 4, 5]);
expect(newDf.fields[2].values.toArray()).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(newDf.length).toEqual(5);
});
it('should clear single cell when only one is selected', () => {
const newDf = clearCellsFromRangeSelection(df, { x: 1, y: 1, width: 1, height: 1 });
expect(newDf.fields[0].values.toArray()).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(newDf.fields[1].values.toArray()).toEqual([1, null, 3, 4, 5]);
expect(newDf.fields[2].values.toArray()).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(newDf.length).toEqual(5);
});
});

View File

@ -0,0 +1,310 @@
import { css } from '@emotion/css';
import { CompactSelection, GridCell, GridCellKind, GridSelection, Theme } from '@glideapps/glide-data-grid';
import {
ArrayVector,
DataFrame,
DataFrameJSON,
dataFrameToJSON,
MutableDataFrame,
Field,
GrafanaTheme2,
FieldType,
} from '@grafana/data';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { GrafanaQuery, GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { isDatagridEditEnabled } from './featureFlagUtils';
const HEADER_FONT_FAMILY = '600 13px Inter';
const CELL_FONT_FAMILY = '400 13px Inter';
const TEXT_CANVAS = document.createElement('canvas');
export const CELL_PADDING = 20;
export const MAX_COLUMN_WIDTH = 300;
export const ICON_AND_MENU_WIDTH = 65;
export const ROW_MARKER_BOTH = 'both';
export const ROW_MARKER_NUMBER = 'number';
export const DEFAULT_CONTEXT_MENU = { isContextMenuOpen: false };
export const DEFAULT_RENAME_INPUT_DATA = { isInputOpen: false };
export const EMPTY_DF = {
name: 'A',
fields: [],
length: 0,
};
export const GRAFANA_DS = {
type: 'grafana',
uid: 'grafana',
};
export const EMPTY_CELL: GridCell = {
kind: GridCellKind.Text,
data: '',
allowOverlay: true,
readonly: false,
displayData: '',
};
export const EMPTY_GRID_SELECTION = {
columns: CompactSelection.empty(),
rows: CompactSelection.empty(),
};
export const TRAILING_ROW_OPTIONS = {
sticky: false,
tint: true,
};
export const RIGHT_ELEMENT_PROPS = {
fill: true,
sticky: false,
};
export interface DatagridContextMenuData {
x?: number;
y?: number;
column?: number;
row?: number;
isHeaderMenu?: boolean;
isContextMenuOpen: boolean;
}
export interface RenameColumnInputData {
x?: number;
y?: number;
width?: number;
height?: number;
isInputOpen: boolean;
inputValue?: string;
columnIdx?: number;
}
interface CellRange {
x: number;
y: number;
width: number;
height: number;
}
export const getTextWidth = (text: string, isHeader = false): number => {
const context = TEXT_CANVAS.getContext('2d');
context!.font = isHeader ? HEADER_FONT_FAMILY : CELL_FONT_FAMILY;
const metrics = context!.measureText(text);
return metrics.width;
};
export const getCellWidth = (field: Field): number => {
//If header is longer than cell text, get header width that will always fully show the header text
//otherwise get the longest cell text width if it's shorter than the max column width, or the max column width
return Math.max(
getTextWidth(field.name, true) + ICON_AND_MENU_WIDTH, //header text
Math.min(
MAX_COLUMN_WIDTH,
field.values.toArray().reduce((acc: number, val: string | number) => {
const textWidth = getTextWidth(val?.toString() ?? '');
if (textWidth > acc) {
return textWidth;
}
return acc;
}, 0) + CELL_PADDING //cell text
)
);
};
export const deleteRows = (gridData: DataFrame, rows: number[], hardDelete = false): DataFrame => {
for (const field of gridData.fields) {
const valuesArray = field.values.toArray();
//delete from the end of the array to avoid index shifting
for (let i = rows.length - 1; i >= 0; i--) {
if (hardDelete) {
valuesArray.splice(rows[i], 1);
} else {
valuesArray.splice(rows[i], 1, null);
}
}
field.values = new ArrayVector(valuesArray);
}
return new MutableDataFrame(gridData);
};
export const clearCellsFromRangeSelection = (gridData: DataFrame, range: CellRange): DataFrame => {
const colFrom: number = range.x;
const rowFrom: number = range.y;
const colTo: number = range.x + range.width - 1;
for (let i = colFrom; i <= colTo; i++) {
const field = gridData.fields[i];
const valuesArray = field.values.toArray();
valuesArray.splice(rowFrom, range.height, ...new Array(range.height).fill(null));
field.values = new ArrayVector(valuesArray);
}
return new MutableDataFrame(gridData);
};
export const publishSnapshot = (data: DataFrame, panelID: number): void => {
if (!isDatagridEditEnabled()) {
return;
}
const snapshot: DataFrameJSON[] = [dataFrameToJSON(data)];
const dashboard = getDashboardSrv().getCurrent();
const panelModel = dashboard?.getPanelById(panelID);
const query: GrafanaQuery = {
refId: 'A',
queryType: GrafanaQueryType.Snapshot,
snapshot,
datasource: GRAFANA_DS,
};
panelModel!.updateQueries({
dataSource: GRAFANA_DS,
queries: [query],
});
panelModel!.refresh();
};
//Converting an array of nulls or undefineds returns them as strings and prints them in the cells instead of empty cells. Thus the cleanup func
export const cleanStringFieldAfterConversion = (field: Field): void => {
const valuesArray = field.values.toArray();
field.values = new ArrayVector(valuesArray.map((val) => (val === 'undefined' || val === 'null' ? null : val)));
return;
};
export function getGridTheme(theme: GrafanaTheme2): Partial<Theme> {
return {
accentColor: theme.colors.primary.main,
accentFg: theme.colors.secondary.main,
textDark: theme.colors.text.primary,
textMedium: theme.colors.text.secondary,
textLight: theme.colors.text.secondary,
textBubble: theme.colors.text.primary,
textHeader: theme.colors.text.primary,
bgCell: theme.colors.background.primary,
bgCellMedium: theme.colors.background.primary,
bgHeader: theme.colors.background.primary,
bgHeaderHasFocus: theme.colors.background.secondary,
bgHeaderHovered: theme.colors.background.secondary,
linkColor: theme.colors.text.link,
fontFamily: theme.typography.fontFamily,
headerFontStyle: `${theme.typography.fontWeightMedium} ${theme.typography.fontSize}px`,
fgIconHeader: theme.colors.secondary.contrastText,
bgIconHeader: theme.colors.secondary.main,
};
}
export const getGridCellKind = (field: Field, row: number, hasGridSelection = false): GridCell => {
const value = field.values.get(row);
switch (field.type) {
case FieldType.boolean:
return {
kind: GridCellKind.Boolean,
data: value ? value : false,
allowOverlay: false,
readonly: false,
};
case FieldType.number:
return {
kind: GridCellKind.Number,
data: value ? value : 0,
allowOverlay: isDatagridEditEnabled()! && !hasGridSelection,
readonly: false,
displayData: value !== null && value !== undefined ? value.toString() : '',
};
case FieldType.string:
return {
kind: GridCellKind.Text,
data: value ? value : '',
allowOverlay: isDatagridEditEnabled()! && !hasGridSelection,
readonly: false,
displayData: value !== null && value !== undefined ? value.toString() : '',
};
default:
return {
kind: GridCellKind.Text,
data: value ? value : '',
allowOverlay: isDatagridEditEnabled()! && !hasGridSelection,
readonly: false,
displayData: value !== null && value !== undefined ? value.toString() : '',
};
}
};
export const getStyles = (theme: GrafanaTheme2, isResizeInProgress: boolean) => {
return {
dataEditor: css`
.dvn-scroll-inner > div:nth-child(2) {
pointer-events: none !important;
}
scrollbar-color: ${theme.colors.background.secondary} ${theme.colors.background.primary};
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: ${theme.colors.background.primary};
}
::-webkit-scrollbar-thumb {
border-radius: 10px;
}
::-webkit-scrollbar-corner {
display: none;
}
`,
addColumnDiv: css`
width: 120px;
display: flex;
flex-direction: column;
background-color: ${theme.colors.background.primary};
button {
pointer-events: ${isResizeInProgress ? 'none' : 'auto'};
border: none;
outline: none;
height: 37px;
font-size: 20px;
background-color: ${theme.colors.background.primary};
color: ${theme.colors.text.primary};
border-right: 1px solid ${theme.components.panel.borderColor};
border-bottom: 1px solid ${theme.components.panel.borderColor};
transition: background-color 200ms;
cursor: pointer;
:hover {
background-color: ${theme.colors.secondary.shade};
}
}
input {
height: 37px;
border: 1px solid ${theme.colors.primary.main};
:focus {
outline: none;
}
}
`,
renameColumnInput: css`
height: 37px;
border: 1px solid ${theme.colors.primary.main};
:focus {
outline: none;
}
`,
};
};
export const hasGridSelection = (gridSelection: GridSelection): boolean => {
if (!gridSelection.current) {
return false;
}
return gridSelection.current.range && gridSelection.current.range.height > 1 && gridSelection.current.range.width > 1;
};

View File

@ -2984,6 +2984,22 @@ __metadata:
languageName: node
linkType: hard
"@glideapps/glide-data-grid@npm:^5.2.1":
version: 5.2.1
resolution: "@glideapps/glide-data-grid@npm:5.2.1"
dependencies:
canvas-hypertxt: ^0.0.7
react-number-format: ^5.0.0
peerDependencies:
lodash: ^4.17.19
marked: ^4.0.10
react: ^16.12.0 || 17.x || 18.x
react-dom: ^16.12.0 || 17.x || 18.x
react-responsive-carousel: ^3.2.7
checksum: a91d4049e7c6e1db8a8c00326adbc71c52f90d66398e9ea4b85218b8cf9ffd421a18188cb6e5abf6765ebcc73b376389222e42a87dd5ec441a22f8f197775e7c
languageName: node
linkType: hard
"@grafana-plugins/input-datasource@workspace:plugins-bundled/internal/input-datasource":
version: 0.0.0-use.local
resolution: "@grafana-plugins/input-datasource@workspace:plugins-bundled/internal/input-datasource"
@ -9795,7 +9811,7 @@ __metadata:
languageName: node
linkType: hard
"@types/marked@npm:4.0.8":
"@types/marked@npm:4.0.8, @types/marked@npm:^4":
version: 4.0.8
resolution: "@types/marked@npm:4.0.8"
checksum: 68278fa7acaa5d920cdc239d675b5daf842e0ad4779e4848cd617d9baf2ac1afccb5a264c331e37d80031d647e1640cb983cd31e73d45b28552670b4853fad8e
@ -13661,6 +13677,13 @@ __metadata:
languageName: node
linkType: hard
"canvas-hypertxt@npm:^0.0.7":
version: 0.0.7
resolution: "canvas-hypertxt@npm:0.0.7"
checksum: 161ee134a4c7f0f75d569c432ee906b19661824cb8b959a7ce932b9cca0eb05cd42937a40ac6b6105f2f591046d714c5f13c1f8bbb0b1d0002eb600ee2dbb9a3
languageName: node
linkType: hard
"canvg@npm:^3.0.6":
version: 3.0.10
resolution: "canvg@npm:3.0.10"
@ -20132,6 +20155,7 @@ __metadata:
"@emotion/css": 11.10.6
"@emotion/eslint-plugin": 11.10.0
"@emotion/react": 11.10.6
"@glideapps/glide-data-grid": ^5.2.1
"@grafana/aws-sdk": 0.0.44
"@grafana/data": "workspace:*"
"@grafana/e2e": "workspace:*"
@ -20210,6 +20234,7 @@ __metadata:
"@types/jsurl": ^1.2.28
"@types/lodash": 4.14.191
"@types/logfmt": ^1.2.3
"@types/marked": ^4
"@types/mousetrap": 1.6.11
"@types/node": 18.14.6
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.0.9"
@ -20334,6 +20359,7 @@ __metadata:
logfmt: ^1.3.2
lru-cache: 7.17.0
lru-memoize: ^1.1.0
marked: ^4.3.0
memoize-one: 6.0.0
mini-css-extract-plugin: 2.7.2
moment: 2.29.4
@ -20384,6 +20410,7 @@ __metadata:
react-redux: 7.2.6
react-refresh: 0.14.0
react-resizable: 3.0.4
react-responsive-carousel: ^3.2.23
react-router-dom: 5.3.3
react-select: 5.7.0
react-select-event: 5.5.1
@ -25343,6 +25370,15 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:^4.3.0":
version: 4.3.0
resolution: "marked@npm:4.3.0"
bin:
marked: bin/marked.js
checksum: 0db6817893952c3ec710eb9ceafb8468bf5ae38cb0f92b7b083baa13d70b19774674be04db5b817681fa7c5c6a088f61300815e4dd75a59696f4716ad69f6260
languageName: node
linkType: hard
"matcher-collection@npm:^2.0.0":
version: 2.0.1
resolution: "matcher-collection@npm:2.0.1"
@ -30849,6 +30885,15 @@ __metadata:
languageName: node
linkType: hard
"react-easy-swipe@npm:^0.0.21":
version: 0.0.21
resolution: "react-easy-swipe@npm:0.0.21"
dependencies:
prop-types: ^15.5.8
checksum: 225f12a9dd410db1c790220867ab1eb58e2ef0a2bdae8541330805fc5b9905e242ab307b019f9aaed76473849a753f363baff03fa8a77e7a1860d7b41dc83ec0
languageName: node
linkType: hard
"react-element-to-jsx-string@npm:^14.3.4":
version: 14.3.4
resolution: "react-element-to-jsx-string@npm:14.3.4"
@ -31059,6 +31104,18 @@ __metadata:
languageName: node
linkType: hard
"react-number-format@npm:^5.0.0":
version: 5.1.4
resolution: "react-number-format@npm:5.1.4"
dependencies:
prop-types: ^15.7.2
peerDependencies:
react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
checksum: 770a1e86cd05d9df28aa5e6a12c5731351dd4a301464edd0ac5dcbc68d2f07eae37781ff14f6153566cbb7cccb28f13f0826d1844600d25ae6e12da060814282
languageName: node
linkType: hard
"react-popper-tooltip@npm:4.4.2":
version: 4.4.2
resolution: "react-popper-tooltip@npm:4.4.2"
@ -31155,6 +31212,17 @@ __metadata:
languageName: node
linkType: hard
"react-responsive-carousel@npm:^3.2.23":
version: 3.2.23
resolution: "react-responsive-carousel@npm:3.2.23"
dependencies:
classnames: ^2.2.5
prop-types: ^15.5.8
react-easy-swipe: ^0.0.21
checksum: 8a5b915f140a05425554a0c108c60a0b103dae49808486d39147df09c832ad712c7d7f4a66f0d0c39529ca095b4a327f0cc440d91b5f7a39ab13076931f74bd0
languageName: node
linkType: hard
"react-router-dom@npm:5.3.3":
version: 5.3.3
resolution: "react-router-dom@npm:5.3.3"