diff --git a/CHANGELOG.md b/CHANGELOG.md index 918a0d4f54..6e32318af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ### master [Full Changelog](https://github.com/parse-community/parse-dashboard/compare/2.1.0...master) +__New features:__ +* Added data export in CSV format for classes ([#1494](https://github.com/parse-community/parse-dashboard/pull/1494)), thanks to [Cory Imdieke](https://github.com/Vortec4800), [Manuel Trezza](https://github.com/mtrezza). + ### 2.1.0 [Full Changelog](https://github.com/parse-community/parse-dashboard/compare/2.0.5...2.1.0) diff --git a/README.md b/README.md index 72694d8167..1c9dd5817a 100644 --- a/README.md +++ b/README.md @@ -570,6 +570,14 @@ This feature allows you to use the data browser as another user, respecting that > ⚠️ Logging in as another user will trigger the same Cloud Triggers as if the user logged in themselves using any other login method. Logging in as another user requires to enter that user's password. +## CSV Export + +▶️ *Core > Browser > Export* + +This feature will take either selected rows or all rows of an individual class and saves them to a CSV file, which is then downloaded. CSV headers are added to the top of the file matching the column names. + +> ⚠️ There is currently a 10,000 row limit when exporting all data. If more than 10,000 rows are present in the class, the CSV file will only contain 10,000 rows. + # Contributing We really want Parse to be yours, to see it grow and thrive in the open source community. Please see the [Contributing to Parse Dashboard guide](CONTRIBUTING.md). diff --git a/src/dashboard/Data/Browser/Browser.react.js b/src/dashboard/Data/Browser/Browser.react.js index d680135f52..c712d5b1b7 100644 --- a/src/dashboard/Data/Browser/Browser.react.js +++ b/src/dashboard/Data/Browser/Browser.react.js @@ -20,6 +20,7 @@ import AttachRowsDialog from 'dashboard/Data/Browser/AttachRow import AttachSelectedRowsDialog from 'dashboard/Data/Browser/AttachSelectedRowsDialog.react'; import CloneSelectedRowsDialog from 'dashboard/Data/Browser/CloneSelectedRowsDialog.react'; import EditRowDialog from 'dashboard/Data/Browser/EditRowDialog.react'; +import ExportSelectedRowsDialog from 'dashboard/Data/Browser/ExportSelectedRowsDialog.react'; import history from 'dashboard/history'; import { List, Map } from 'immutable'; import Notification from 'dashboard/Data/Browser/Notification.react'; @@ -59,6 +60,7 @@ class Browser extends DashboardView { showAttachRowsDialog: false, showEditRowDialog: false, rowsToDelete: null, + rowsToExport: null, relation: null, counts: {}, @@ -110,6 +112,9 @@ class Browser extends DashboardView { this.showCloneSelectedRowsDialog = this.showCloneSelectedRowsDialog.bind(this); this.confirmCloneSelectedRows = this.confirmCloneSelectedRows.bind(this); this.cancelCloneSelectedRows = this.cancelCloneSelectedRows.bind(this); + this.showExportSelectedRowsDialog = this.showExportSelectedRowsDialog.bind(this); + this.confirmExportSelectedRows = this.confirmExportSelectedRows.bind(this); + this.cancelExportSelectedRows = this.cancelExportSelectedRows.bind(this); this.getClassRelationColumns = this.getClassRelationColumns.bind(this); this.showCreateClass = this.showCreateClass.bind(this); this.refresh = this.refresh.bind(this); @@ -1063,7 +1068,8 @@ class Browser extends DashboardView { this.state.showAttachSelectedRowsDialog || this.state.showCloneSelectedRowsDialog || this.state.showEditRowDialog || - this.state.showPermissionsDialog + this.state.showPermissionsDialog || + this.state.showExportSelectedRowsDialog ); } @@ -1211,6 +1217,106 @@ class Browser extends DashboardView { } } + showExportSelectedRowsDialog(rows) { + this.setState({ + rowsToExport: rows + }); + } + + cancelExportSelectedRows() { + this.setState({ + rowsToExport: null + }); + } + + async confirmExportSelectedRows(rows) { + this.setState({ rowsToExport: null }); + const className = this.props.params.className; + const query = new Parse.Query(className); + + if (rows['*']) { + // Export all + query.limit(10000); + } else { + // Export selected + const objectIds = []; + for (const objectId in this.state.rowsToExport) { + objectIds.push(objectId); + } + query.containedIn('objectId', objectIds); + } + + const classColumns = this.getClassColumns(className, false); + // create object with classColumns as property keys needed for ColumnPreferences.getOrder function + const columnsObject = {}; + classColumns.forEach((column) => { + columnsObject[column.name] = column; + }); + // get ordered list of class columns + const columns = ColumnPreferences.getOrder( + columnsObject, + this.context.currentApp.applicationId, + className + ).filter(column => column.visible); + + const objects = await query.find({ useMasterKey: true }); + let csvString = columns.map(column => column.name).join(',') + '\n'; + for (const object of objects) { + const row = columns.map(column => { + const type = columnsObject[column.name].type; + if (column.name === 'objectId') { + return object.id; + } else if (type === 'Relation' || type === 'Pointer') { + if (object.get(column.name)) { + return object.get(column.name).id + } else { + return '' + } + } else { + let colValue; + if (column.name === 'ACL') { + colValue = object.getACL(); + } else { + colValue = object.get(column.name); + } + // Stringify objects and arrays + if (Object.prototype.toString.call(colValue) === '[object Object]' || Object.prototype.toString.call(colValue) === '[object Array]') { + colValue = JSON.stringify(colValue); + } + if(typeof colValue === 'string') { + if (colValue.includes('"')) { + // Has quote in data, escape and quote + // If the value contains both a quote and delimiter, adding quotes and escaping will take care of both scenarios + colValue = colValue.split('"').join('""'); + return `"${colValue}"`; + } else if (colValue.includes(',')) { + // Has delimiter in data, surround with quote (which the value doesn't already contain) + return `"${colValue}"`; + } else { + // No quote or delimiter, just include plainly + return `${colValue}`; + } + } else if (colValue === undefined) { + // Export as empty CSV field + return ''; + } else { + return `${colValue}`; + } + } + }).join(','); + csvString += row + '\n'; + } + + // Deliver to browser to download file + const element = document.createElement('a'); + const file = new Blob([csvString], { type: 'text/csv' }); + element.href = URL.createObjectURL(file); + element.download = `${className}.csv`; + document.body.appendChild(element); // Required for this to work in FireFox + element.click(); + document.body.removeChild(element); + } + getClassRelationColumns(className) { const currentClassName = this.props.params.className; return this.getClassColumns(className, false) @@ -1393,6 +1499,8 @@ class Browser extends DashboardView { onCloneSelectedRows={this.showCloneSelectedRowsDialog} onEditSelectedRow={this.showEditRowDialog} onEditPermissions={this.onDialogToggle} + onExportSelectedRows={this.showExportSelectedRowsDialog} + onSaveNewRow={this.saveNewRow} onAbortAddRow={this.abortAddRow} onSaveEditCloneRow={this.saveEditCloneRow} @@ -1583,6 +1691,15 @@ class Browser extends DashboardView { useMasterKey={this.state.useMasterKey} /> ) + } else if (this.state.rowsToExport) { + extras = ( + this.confirmExportSelectedRows(this.state.rowsToExport)} + /> + ); } let notification = null; diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index 96698e5d49..3cfe11679b 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -39,6 +39,7 @@ let BrowserToolbar = ({ onAttachRows, onAttachSelectedRows, onCloneSelectedRows, + onExportSelectedRows, onExport, onRemoveColumn, onDeleteRows, @@ -242,6 +243,20 @@ let BrowserToolbar = ({ )} {onAddRow &&
} + {onAddRow && ( + + onExportSelectedRows(selection)} + /> + onExportSelectedRows({ '*': true })} + /> + + )} + {onAddRow &&
} Refresh diff --git a/src/dashboard/Data/Browser/ExportSelectedRowsDialog.react.js b/src/dashboard/Data/Browser/ExportSelectedRowsDialog.react.js new file mode 100644 index 0000000000..1e4b5bf2db --- /dev/null +++ b/src/dashboard/Data/Browser/ExportSelectedRowsDialog.react.js @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import Modal from 'components/Modal/Modal.react'; +import React from 'react'; + +export default class ExportSelectedRowsDialog extends React.Component { + constructor() { + super(); + + this.state = { + confirmation: '' + }; + } + + valid() { + return true; + } + + render() { + let selectionLength = Object.keys(this.props.selection).length; + return ( + + {} + + ); + } +}