Skip to content

Commit 0c5bafd

Browse files
authored
CSV Export of class data (#1494)
1 parent 2506945 commit 0c5bafd

File tree

5 files changed

+185
-1
lines changed

5 files changed

+185
-1
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
### master
44
[Full Changelog](https://github.com/parse-community/parse-dashboard/compare/2.1.0...master)
55

6+
__New features:__
7+
* 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).
8+
69
### 2.1.0
710
[Full Changelog](https://github.com/parse-community/parse-dashboard/compare/2.0.5...2.1.0)
811

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,14 @@ This feature allows you to use the data browser as another user, respecting that
570570

571571
> ⚠️ 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.
572572
573+
## CSV Export
574+
575+
▶️ *Core > Browser > Export*
576+
577+
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.
578+
579+
> ⚠️ 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.
580+
573581
# Contributing
574582

575583
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).

src/dashboard/Data/Browser/Browser.react.js

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import AttachRowsDialog from 'dashboard/Data/Browser/AttachRow
2020
import AttachSelectedRowsDialog from 'dashboard/Data/Browser/AttachSelectedRowsDialog.react';
2121
import CloneSelectedRowsDialog from 'dashboard/Data/Browser/CloneSelectedRowsDialog.react';
2222
import EditRowDialog from 'dashboard/Data/Browser/EditRowDialog.react';
23+
import ExportSelectedRowsDialog from 'dashboard/Data/Browser/ExportSelectedRowsDialog.react';
2324
import history from 'dashboard/history';
2425
import { List, Map } from 'immutable';
2526
import Notification from 'dashboard/Data/Browser/Notification.react';
@@ -59,6 +60,7 @@ class Browser extends DashboardView {
5960
showAttachRowsDialog: false,
6061
showEditRowDialog: false,
6162
rowsToDelete: null,
63+
rowsToExport: null,
6264

6365
relation: null,
6466
counts: {},
@@ -110,6 +112,9 @@ class Browser extends DashboardView {
110112
this.showCloneSelectedRowsDialog = this.showCloneSelectedRowsDialog.bind(this);
111113
this.confirmCloneSelectedRows = this.confirmCloneSelectedRows.bind(this);
112114
this.cancelCloneSelectedRows = this.cancelCloneSelectedRows.bind(this);
115+
this.showExportSelectedRowsDialog = this.showExportSelectedRowsDialog.bind(this);
116+
this.confirmExportSelectedRows = this.confirmExportSelectedRows.bind(this);
117+
this.cancelExportSelectedRows = this.cancelExportSelectedRows.bind(this);
113118
this.getClassRelationColumns = this.getClassRelationColumns.bind(this);
114119
this.showCreateClass = this.showCreateClass.bind(this);
115120
this.refresh = this.refresh.bind(this);
@@ -1063,7 +1068,8 @@ class Browser extends DashboardView {
10631068
this.state.showAttachSelectedRowsDialog ||
10641069
this.state.showCloneSelectedRowsDialog ||
10651070
this.state.showEditRowDialog ||
1066-
this.state.showPermissionsDialog
1071+
this.state.showPermissionsDialog ||
1072+
this.state.showExportSelectedRowsDialog
10671073
);
10681074
}
10691075

@@ -1211,6 +1217,106 @@ class Browser extends DashboardView {
12111217
}
12121218
}
12131219

1220+
showExportSelectedRowsDialog(rows) {
1221+
this.setState({
1222+
rowsToExport: rows
1223+
});
1224+
}
1225+
1226+
cancelExportSelectedRows() {
1227+
this.setState({
1228+
rowsToExport: null
1229+
});
1230+
}
1231+
1232+
async confirmExportSelectedRows(rows) {
1233+
this.setState({ rowsToExport: null });
1234+
const className = this.props.params.className;
1235+
const query = new Parse.Query(className);
1236+
1237+
if (rows['*']) {
1238+
// Export all
1239+
query.limit(10000);
1240+
} else {
1241+
// Export selected
1242+
const objectIds = [];
1243+
for (const objectId in this.state.rowsToExport) {
1244+
objectIds.push(objectId);
1245+
}
1246+
query.containedIn('objectId', objectIds);
1247+
}
1248+
1249+
const classColumns = this.getClassColumns(className, false);
1250+
// create object with classColumns as property keys needed for ColumnPreferences.getOrder function
1251+
const columnsObject = {};
1252+
classColumns.forEach((column) => {
1253+
columnsObject[column.name] = column;
1254+
});
1255+
// get ordered list of class columns
1256+
const columns = ColumnPreferences.getOrder(
1257+
columnsObject,
1258+
this.context.currentApp.applicationId,
1259+
className
1260+
).filter(column => column.visible);
1261+
1262+
const objects = await query.find({ useMasterKey: true });
1263+
let csvString = columns.map(column => column.name).join(',') + '\n';
1264+
for (const object of objects) {
1265+
const row = columns.map(column => {
1266+
const type = columnsObject[column.name].type;
1267+
if (column.name === 'objectId') {
1268+
return object.id;
1269+
} else if (type === 'Relation' || type === 'Pointer') {
1270+
if (object.get(column.name)) {
1271+
return object.get(column.name).id
1272+
} else {
1273+
return ''
1274+
}
1275+
} else {
1276+
let colValue;
1277+
if (column.name === 'ACL') {
1278+
colValue = object.getACL();
1279+
} else {
1280+
colValue = object.get(column.name);
1281+
}
1282+
// Stringify objects and arrays
1283+
if (Object.prototype.toString.call(colValue) === '[object Object]' || Object.prototype.toString.call(colValue) === '[object Array]') {
1284+
colValue = JSON.stringify(colValue);
1285+
}
1286+
if(typeof colValue === 'string') {
1287+
if (colValue.includes('"')) {
1288+
// Has quote in data, escape and quote
1289+
// If the value contains both a quote and delimiter, adding quotes and escaping will take care of both scenarios
1290+
colValue = colValue.split('"').join('""');
1291+
return `"${colValue}"`;
1292+
} else if (colValue.includes(',')) {
1293+
// Has delimiter in data, surround with quote (which the value doesn't already contain)
1294+
return `"${colValue}"`;
1295+
} else {
1296+
// No quote or delimiter, just include plainly
1297+
return `${colValue}`;
1298+
}
1299+
} else if (colValue === undefined) {
1300+
// Export as empty CSV field
1301+
return '';
1302+
} else {
1303+
return `${colValue}`;
1304+
}
1305+
}
1306+
}).join(',');
1307+
csvString += row + '\n';
1308+
}
1309+
1310+
// Deliver to browser to download file
1311+
const element = document.createElement('a');
1312+
const file = new Blob([csvString], { type: 'text/csv' });
1313+
element.href = URL.createObjectURL(file);
1314+
element.download = `${className}.csv`;
1315+
document.body.appendChild(element); // Required for this to work in FireFox
1316+
element.click();
1317+
document.body.removeChild(element);
1318+
}
1319+
12141320
getClassRelationColumns(className) {
12151321
const currentClassName = this.props.params.className;
12161322
return this.getClassColumns(className, false)
@@ -1393,6 +1499,8 @@ class Browser extends DashboardView {
13931499
onCloneSelectedRows={this.showCloneSelectedRowsDialog}
13941500
onEditSelectedRow={this.showEditRowDialog}
13951501
onEditPermissions={this.onDialogToggle}
1502+
onExportSelectedRows={this.showExportSelectedRowsDialog}
1503+
13961504
onSaveNewRow={this.saveNewRow}
13971505
onAbortAddRow={this.abortAddRow}
13981506
onSaveEditCloneRow={this.saveEditCloneRow}
@@ -1583,6 +1691,15 @@ class Browser extends DashboardView {
15831691
useMasterKey={this.state.useMasterKey}
15841692
/>
15851693
)
1694+
} else if (this.state.rowsToExport) {
1695+
extras = (
1696+
<ExportSelectedRowsDialog
1697+
className={SpecialClasses[className] || className}
1698+
selection={this.state.rowsToExport}
1699+
onCancel={this.cancelExportSelectedRows}
1700+
onConfirm={() => this.confirmExportSelectedRows(this.state.rowsToExport)}
1701+
/>
1702+
);
15861703
}
15871704

15881705
let notification = null;

src/dashboard/Data/Browser/BrowserToolbar.react.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ let BrowserToolbar = ({
3939
onAttachRows,
4040
onAttachSelectedRows,
4141
onCloneSelectedRows,
42+
onExportSelectedRows,
4243
onExport,
4344
onRemoveColumn,
4445
onDeleteRows,
@@ -242,6 +243,20 @@ let BrowserToolbar = ({
242243
</BrowserMenu>
243244
)}
244245
{onAddRow && <div className={styles.toolbarSeparator} />}
246+
{onAddRow && (
247+
<BrowserMenu title='Export' icon='down-solid' disabled={isUnique || isPendingEditCloneRows} setCurrent={setCurrent}>
248+
<MenuItem
249+
disabled={!selectionLength}
250+
text={`Export ${selectionLength} selected ${selectionLength <= 1 ? 'row' : 'rows'}`}
251+
onClick={() => onExportSelectedRows(selection)}
252+
/>
253+
<MenuItem
254+
text={'Export all rows'}
255+
onClick={() => onExportSelectedRows({ '*': true })}
256+
/>
257+
</BrowserMenu>
258+
)}
259+
{onAddRow && <div className={styles.toolbarSeparator} />}
245260
<a className={classes.join(' ')} onClick={isPendingEditCloneRows ? null : onRefresh}>
246261
<Icon name="refresh-solid" width={14} height={14} />
247262
<span>Refresh</span>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) 2016-present, Parse, LLC
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the license found in the LICENSE file in
6+
* the root directory of this source tree.
7+
*/
8+
import Modal from 'components/Modal/Modal.react';
9+
import React from 'react';
10+
11+
export default class ExportSelectedRowsDialog extends React.Component {
12+
constructor() {
13+
super();
14+
15+
this.state = {
16+
confirmation: ''
17+
};
18+
}
19+
20+
valid() {
21+
return true;
22+
}
23+
24+
render() {
25+
let selectionLength = Object.keys(this.props.selection).length;
26+
return (
27+
<Modal
28+
type={Modal.Types.INFO}
29+
icon='warn-outline'
30+
title={this.props.selection['*'] ? 'Export all rows?' : (selectionLength === 1 ? `Export 1 selected row?` : `Export ${selectionLength} selected rows?`)}
31+
subtitle={this.props.selection['*'] ? 'Note: Exporting is limited to the first 10,000 rows.' : ''}
32+
disabled={!this.valid()}
33+
confirmText={'Yes export'}
34+
cancelText={'Never mind, don\u2019t.'}
35+
onCancel={this.props.onCancel}
36+
onConfirm={this.props.onConfirm}>
37+
{}
38+
</Modal>
39+
);
40+
}
41+
}

0 commit comments

Comments
 (0)