mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
c5ea3cd7e0
commit
efd0e9cbea
@ -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
1
.github/CODEOWNERS
vendored
@ -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
|
||||
|
@ -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": ""
|
||||
}
|
@ -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') +
|
||||
{
|
||||
|
@ -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
|
||||
|
||||
|
34
e2e/datagrid-suite/datagrid-data-change.spec.ts
Normal file
34
e2e/datagrid-suite/datagrid-data-change.spec.ts
Normal 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');
|
||||
},
|
||||
});
|
150
e2e/datagrid-suite/datagrid-editing-features.spec.ts
Normal file
150
e2e/datagrid-suite/datagrid-editing-features.spec.ts
Normal 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);
|
||||
},
|
||||
});
|
@ -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",
|
||||
|
@ -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 };
|
||||
|
||||
|
@ -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, '');
|
||||
|
@ -98,4 +98,5 @@ export interface FeatureToggles {
|
||||
pluginsAPIManifestKey?: boolean;
|
||||
advancedDataSourcePicker?: boolean;
|
||||
opensearchDetectVersion?: boolean;
|
||||
enableDatagridEditing?: boolean;
|
||||
}
|
||||
|
@ -192,6 +192,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *store.Serv
|
||||
"trend": {},
|
||||
"welcome": {},
|
||||
"xychart": {},
|
||||
"datagrid": {},
|
||||
}
|
||||
|
||||
expDataSources := map[string]struct{}{
|
||||
|
@ -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),
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -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
|
||||
|
|
@ -326,4 +326,8 @@ const (
|
||||
// FlagOpensearchDetectVersion
|
||||
// Enable version detection in OpenSearch
|
||||
FlagOpensearchDetectVersion = "opensearchDetectVersion"
|
||||
|
||||
// FlagEnableDatagridEditing
|
||||
// Enables the edit functionality in the datagrid panel
|
||||
FlagEnableDatagridEditing = "enableDatagridEditing"
|
||||
)
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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 });
|
||||
}
|
||||
|
601
public/app/plugins/panel/datagrid/DataGridPanel.test.tsx
Normal file
601
public/app/plugins/panel/datagrid/DataGridPanel.test.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
281
public/app/plugins/panel/datagrid/DataGridPanel.tsx
Normal file
281
public/app/plugins/panel/datagrid/DataGridPanel.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
5
public/app/plugins/panel/datagrid/README.md
Normal file
5
public/app/plugins/panel/datagrid/README.md
Normal 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.
|
35
public/app/plugins/panel/datagrid/components/AddColumn.tsx
Normal file
35
public/app/plugins/panel/datagrid/components/AddColumn.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
36
public/app/plugins/panel/datagrid/components/SimpleInput.tsx
Normal file
36
public/app/plugins/panel/datagrid/components/SimpleInput.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
};
|
5
public/app/plugins/panel/datagrid/featureFlagUtils.tsx
Normal file
5
public/app/plugins/panel/datagrid/featureFlagUtils.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
export const isDatagridEditEnabled = () => {
|
||||
return config.featureToggles.enableDatagridEditing;
|
||||
};
|
@ -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 |
24
public/app/plugins/panel/datagrid/module.tsx
Normal file
24
public/app/plugins/panel/datagrid/module.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
33
public/app/plugins/panel/datagrid/panelcfg.cue
Normal file
33
public/app/plugins/panel/datagrid/panelcfg.cue
Normal 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")
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
19
public/app/plugins/panel/datagrid/panelcfg.gen.ts
Normal file
19
public/app/plugins/panel/datagrid/panelcfg.gen.ts
Normal 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,
|
||||
};
|
17
public/app/plugins/panel/datagrid/plugin.json
Normal file
17
public/app/plugins/panel/datagrid/plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
299
public/app/plugins/panel/datagrid/state.ts
Normal file
299
public/app/plugins/panel/datagrid/state.ts
Normal 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;
|
||||
}
|
||||
};
|
122
public/app/plugins/panel/datagrid/utils.test.ts
Normal file
122
public/app/plugins/panel/datagrid/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
310
public/app/plugins/panel/datagrid/utils.ts
Normal file
310
public/app/plugins/panel/datagrid/utils.ts
Normal 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;
|
||||
};
|
70
yarn.lock
70
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user