diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0f591315db7..e94ce11486b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -353,6 +353,7 @@ lerna.json @grafana/frontend-ops /public/app/features/dashboard/ @grafana/dashboards-squad /public/app/features/datasources/ @grafana/user-essentials /public/app/features/dimensions/ @grafana/grafana-edge-squad +/public/app/features/dataframe-import/ @grafana/grafana-bi-squad /public/app/features/explore/ @grafana/explore-squad /public/app/features/expressions/ @grafana/observability-metrics /public/app/features/folders/ @grafana/user-essentials diff --git a/package.json b/package.json index 88c0bb30ba4..b2b4f022f52 100644 --- a/package.json +++ b/package.json @@ -374,6 +374,7 @@ "react-diff-viewer": "^3.1.1", "react-dom": "17.0.2", "react-draggable": "4.4.5", + "react-dropzone": "^14.2.3", "react-grid-layout": "1.3.4", "react-highlight-words": "0.20.0", "react-hook-form": "7.5.3", diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 50f0ea674c7..3e5ee3daa8e 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -1,11 +1,23 @@ -import { cx } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React, { PureComponent } from 'react'; +import DropZone, { FileRejection, DropEvent, ErrorCode } from 'react-dropzone'; import { connect, ConnectedProps } from 'react-redux'; -import { NavModel, NavModelItem, TimeRange, PageLayoutType, locationUtil } from '@grafana/data'; +import { + NavModel, + NavModelItem, + TimeRange, + PageLayoutType, + locationUtil, + dataFrameToJSON, + DataFrameJSON, + GrafanaTheme2, + getValueFormat, + formattedValueToString, +} from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, locationService } from '@grafana/runtime'; -import { Themeable2, withTheme2 } from '@grafana/ui'; +import { Icon, Themeable2, withTheme2 } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import { Page } from 'app/core/components/Page/Page'; import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext'; @@ -14,8 +26,10 @@ import { getKioskMode } from 'app/core/navigation/kiosk'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { PanelModel } from 'app/features/dashboard/state'; +import * as DFImport from 'app/features/dataframe-import'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { getPageNavFromSlug, getRootContentNavModel } from 'app/features/storage/StorageFolderPage'; +import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { DashboardRoutes, KioskMode, StoreState } from 'app/types'; import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events'; @@ -98,6 +112,60 @@ export class UnthemedDashboardPage extends PureComponent { private forceRouteReloadCounter = 0; state: State = this.getCleanState(); + onFileDrop = (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => { + const grafanaDS = { + type: 'grafana', + uid: 'grafana', + }; + DFImport.filesToDataframes(acceptedFiles).subscribe((next) => { + const snapshot: DataFrameJSON[] = []; + next.dataFrames.forEach((df) => { + const dataframeJson = dataFrameToJSON(df); + snapshot.push(dataframeJson); + }); + this.props.dashboard?.addPanel({ + type: 'table', + gridPos: { x: 0, y: 0, w: 12, h: 8 }, + title: next.file.name, + datasource: grafanaDS, + targets: [ + { + queryType: GrafanaQueryType.Snapshot, + snapshot, + file: { name: next.file.name, size: next.file.size }, + datasource: grafanaDS, + }, + ], + }); + }); + + fileRejections.forEach((fileRejection) => { + const errors = fileRejection.errors.map((error) => { + switch (error.code) { + case ErrorCode.FileTooLarge: + const formattedSize = getValueFormat('decbytes')(DFImport.maxFileSize); + return `File size must be less than ${formattedValueToString(formattedSize)}.`; + case ErrorCode.FileInvalidType: + return `File type must be one of the following types ${DFImport.formatFileTypes(DFImport.acceptedFiles)}.`; + default: + return error.message; + } + }); + this.props.notifyApp( + createErrorNotification( + `Failed to load ${fileRejection.file.name}`, + undefined, + undefined, + + ) + ); + }); + }; + getCleanState(): State { return { editPanel: null, @@ -378,21 +446,47 @@ export class UnthemedDashboardPage extends PureComponent { scrollTop={updateScrollTop} > - {initError && } {showSubMenu && (
)} - - - + {config.featureToggles.editPanelCSVDragAndDrop ? ( + + {({ getRootProps, isDragActive }) => { + const styles = getStyles(this.props.theme, isDragActive); + return ( +
+
+
+ +

Create tables from spreadsheets

+
+
+ +
+ ); + }} +
+ ) : ( + + )} {inspectPanel && } {editPanel && ( @@ -480,6 +574,32 @@ function updateStatePageNavFromProps(props: Props, state: State): State { }; } +function getStyles(theme: GrafanaTheme2, isDragActive: boolean) { + return { + dropZone: css` + height: 100%; + `, + dropOverlay: css` + background-color: ${isDragActive ? theme.colors.action.hover : `inherit`}; + border: ${isDragActive ? `2px dashed ${theme.colors.border.medium}` : 0}; + position: absolute; + display: ${isDragActive ? 'flex' : 'none'}; + z-index: ${theme.zIndex.modal}; + top: 0px; + left: 0px; + height: 100%; + width: 100%; + align-items: center; + justify-content: center; + `, + dropHint: css` + align-items: center; + display: flex; + flex-direction: column; + `, + }; +} + export const DashboardPage = withTheme2(UnthemedDashboardPage); DashboardPage.displayName = 'DashboardPage'; export default connector(DashboardPage); diff --git a/public/app/features/dataframe-import/constants.ts b/public/app/features/dataframe-import/constants.ts new file mode 100644 index 00000000000..d0ae1ec0de1 --- /dev/null +++ b/public/app/features/dataframe-import/constants.ts @@ -0,0 +1,13 @@ +import { Accept } from 'react-dropzone'; + +export const acceptedFiles: Accept = { + 'text/plain': ['.csv', '.txt'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.apple.numbers': ['.numbers'], + 'application/vnd.oasis.opendocument.spreadsheet': ['.ods'], + 'application/json': ['.json'], +}; + +//This should probably set from grafana conf +export const maxFileSize = 1000000; diff --git a/public/app/features/dataframe-import/index.ts b/public/app/features/dataframe-import/index.ts new file mode 100644 index 00000000000..3f51aca9553 --- /dev/null +++ b/public/app/features/dataframe-import/index.ts @@ -0,0 +1,2 @@ +export * from './utils'; +export * from './constants'; diff --git a/public/app/features/dataframe-import/types.ts b/public/app/features/dataframe-import/types.ts new file mode 100644 index 00000000000..02ad8df89bb --- /dev/null +++ b/public/app/features/dataframe-import/types.ts @@ -0,0 +1,6 @@ +import { DataFrame } from '@grafana/data'; + +export interface FileImportResult { + dataFrames: DataFrame[]; + file: File; +} diff --git a/public/app/features/dataframe-import/utils.test.ts b/public/app/features/dataframe-import/utils.test.ts new file mode 100644 index 00000000000..20631933136 --- /dev/null +++ b/public/app/features/dataframe-import/utils.test.ts @@ -0,0 +1,39 @@ +import { formatFileTypes } from './utils'; + +describe('Dataframe import / Utils', () => { + describe('formatFileTypes', () => { + it('should nicely format file extensions', () => { + expect( + formatFileTypes({ + 'text/plain': ['.csv', '.txt'], + 'application/json': ['.json'], + }) + ).toBe('.csv, .txt or .json'); + }); + + it('should remove duplicates', () => { + expect( + formatFileTypes({ + 'text/plain': ['.csv', '.txt'], + 'application/json': ['.json', '.txt'], + }) + ).toBe('.csv, .txt or .json'); + }); + + it('should nicely format a single file type extension', () => { + expect( + formatFileTypes({ + 'text/plain': ['.txt'], + }) + ).toBe('.txt'); + }); + + it('should nicely format two file type extension', () => { + expect( + formatFileTypes({ + 'text/plain': ['.txt', '.csv'], + }) + ).toBe('.txt or .csv'); + }); + }); +}); diff --git a/public/app/features/dataframe-import/utils.ts b/public/app/features/dataframe-import/utils.ts new file mode 100644 index 00000000000..f69af7040f2 --- /dev/null +++ b/public/app/features/dataframe-import/utils.ts @@ -0,0 +1,50 @@ +import { Accept } from 'react-dropzone'; +import { Observable } from 'rxjs'; + +import { toDataFrame } from '@grafana/data'; +import { readSpreadsheet } from 'app/core/utils/sheet'; + +import { FileImportResult } from './types'; + +function getFileExtensions(acceptedFiles: Accept) { + const fileExtentions = new Set(); + Object.keys(acceptedFiles).forEach((v) => { + acceptedFiles[v].forEach((extension) => { + fileExtentions.add(extension); + }); + }); + return fileExtentions; +} + +export function formatFileTypes(acceptedFiles: Accept) { + const fileExtentions = Array.from(getFileExtensions(acceptedFiles)); + if (fileExtentions.length === 1) { + return fileExtentions[0]; + } + return `${fileExtentions.slice(0, -1).join(', ')} or ${fileExtentions.slice(-1)}`; +} + +export function filesToDataframes(files: File[]): Observable { + return new Observable((subscriber) => { + let completedFiles = 0; + files.forEach((file) => { + const reader = new FileReader(); + reader.readAsArrayBuffer(file); + reader.onload = () => { + const result = reader.result; + if (result && result instanceof ArrayBuffer) { + if (file.type === 'application/json') { + const decoder = new TextDecoder('utf-8'); + const json = JSON.parse(decoder.decode(result)); + subscriber.next({ dataFrames: [toDataFrame(json)], file: file }); + } else { + subscriber.next({ dataFrames: readSpreadsheet(result), file: file }); + } + if (++completedFiles >= files.length) { + subscriber.complete(); + } + } + }; + }); + }); +} diff --git a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx index 11d337879e1..8fc7d07ec7a 100644 --- a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/css'; import pluralize from 'pluralize'; import React, { PureComponent } from 'react'; +import { DropEvent, FileRejection } from 'react-dropzone'; import { QueryEditorProps, @@ -30,7 +31,7 @@ import { withTheme2, } from '@grafana/ui'; import { hasAlphaPanels } from 'app/core/config'; -import { readSpreadsheet } from 'app/core/utils/sheet'; +import * as DFImport from 'app/features/dataframe-import'; import { SearchQuery } from 'app/features/search/service'; import { GrafanaDatasource } from '../datasource'; @@ -376,8 +377,21 @@ export class UnthemedQueryEditor extends PureComponent { return null; }; - onDropAccepted = (files: File[]) => { - this.props.onChange({ ...this.props.query, file: { name: files[0].name, size: files[0].size } }); + onFileDrop = (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => { + DFImport.filesToDataframes(acceptedFiles).subscribe((next) => { + const snapshot: DataFrameJSON[] = []; + next.dataFrames.forEach((df) => { + const dataframeJson = dataFrameToJSON(df); + snapshot.push(dataframeJson); + }); + this.props.onChange({ + ...this.props.query, + file: { name: next.file.name, size: next.file.size }, + queryType: GrafanaQueryType.Snapshot, + snapshot, + }); + this.props.onRunQuery(); + }); }; renderSnapshotQuery() { @@ -399,18 +413,11 @@ export class UnthemedQueryEditor extends PureComponent { readAs="readAsArrayBuffer" fileListRenderer={this.fileListRenderer} options={{ - onDropAccepted: this.onDropAccepted, - maxSize: 200000, + onDrop: this.onFileDrop, + maxSize: DFImport.maxFileSize, multiple: false, - accept: { - 'text/plain': ['.csv', '.txt'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.apple.numbers': ['.numbers'], - 'application/vnd.oasis.opendocument.spreadsheet': ['.ods'], - }, + accept: DFImport.acceptedFiles, }} - onLoad={this.onFileDrop} > @@ -438,28 +445,6 @@ export class UnthemedQueryEditor extends PureComponent { onRunQuery(); }; - onFileDrop = (result: ArrayBuffer | String | null) => { - const snapshot: DataFrameJSON[] = []; - - if (result) { - if (!result || result instanceof String) { - return; - } - const dataFrames = readSpreadsheet(result); - dataFrames.forEach((df) => { - const dataframeJson = dataFrameToJSON(df); - snapshot.push(dataframeJson); - }); - } - - this.props.onChange({ - ...this.props.query, - queryType: GrafanaQueryType.Snapshot, - snapshot, - }); - this.props.onRunQuery(); - }; - render() { const query = { ...defaultQuery, diff --git a/yarn.lock b/yarn.lock index bf06d931477..1985aece3dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22209,6 +22209,7 @@ __metadata: react-diff-viewer: ^3.1.1 react-dom: 17.0.2 react-draggable: 4.4.5 + react-dropzone: ^14.2.3 react-grid-layout: 1.3.4 react-highlight-words: 0.20.0 react-hook-form: 7.5.3 @@ -32732,7 +32733,7 @@ __metadata: languageName: node linkType: hard -"react-dropzone@npm:14.2.3": +"react-dropzone@npm:14.2.3, react-dropzone@npm:^14.2.3": version: 14.2.3 resolution: "react-dropzone@npm:14.2.3" dependencies: