Skip to content

Commit 9eb36a1

Browse files
authored
feat: Add export all rows of a class and export in JSON format (#2361)
1 parent d9105e7 commit 9eb36a1

File tree

3 files changed

+211
-77
lines changed

3 files changed

+211
-77
lines changed

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

+138-71
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ class Browser extends DashboardView {
7272
filters: new List(),
7373
ordering: '-createdAt',
7474
selection: {},
75+
exporting: false,
76+
exportingCount: 0,
7577

7678
data: null,
7779
lastMax: -1,
@@ -1296,15 +1298,12 @@ class Browser extends DashboardView {
12961298
});
12971299
}
12981300

1299-
async confirmExportSelectedRows(rows) {
1300-
this.setState({ rowsToExport: null });
1301+
async confirmExportSelectedRows(rows, type, indentation) {
1302+
this.setState({ rowsToExport: null, exporting: true, exportingCount: 0 });
13011303
const className = this.props.params.className;
13021304
const query = new Parse.Query(className);
13031305

1304-
if (rows['*']) {
1305-
// Export all
1306-
query.limit(10000);
1307-
} else {
1306+
if (!rows['*']) {
13081307
// Export selected
13091308
const objectIds = [];
13101309
for (const objectId in this.state.rowsToExport) {
@@ -1314,75 +1313,136 @@ class Browser extends DashboardView {
13141313
query.limit(objectIds.length);
13151314
}
13161315

1317-
const classColumns = this.getClassColumns(className, false);
1318-
// create object with classColumns as property keys needed for ColumnPreferences.getOrder function
1319-
const columnsObject = {};
1320-
classColumns.forEach((column) => {
1321-
columnsObject[column.name] = column;
1322-
});
1323-
// get ordered list of class columns
1324-
const columns = ColumnPreferences.getOrder(
1325-
columnsObject,
1326-
this.context.applicationId,
1327-
className
1328-
).filter(column => column.visible);
1316+
const processObjects = (objects) => {
1317+
const classColumns = this.getClassColumns(className, false);
1318+
// create object with classColumns as property keys needed for ColumnPreferences.getOrder function
1319+
const columnsObject = {};
1320+
classColumns.forEach((column) => {
1321+
columnsObject[column.name] = column;
1322+
});
1323+
// get ordered list of class columns
1324+
const columns = ColumnPreferences.getOrder(
1325+
columnsObject,
1326+
this.context.applicationId,
1327+
className
1328+
).filter((column) => column.visible);
1329+
1330+
if (type === '.json') {
1331+
const element = document.createElement('a');
1332+
const file = new Blob(
1333+
[
1334+
JSON.stringify(
1335+
objects.map((obj) => {
1336+
const json = obj._toFullJSON();
1337+
delete json.__type;
1338+
return json;
1339+
}),
1340+
null,
1341+
indentation ? 2 : null,
1342+
),
1343+
],
1344+
{ type: 'application/json' }
1345+
);
1346+
element.href = URL.createObjectURL(file);
1347+
element.download = `${className}.json`;
1348+
document.body.appendChild(element); // Required for this to work in FireFox
1349+
element.click();
1350+
document.body.removeChild(element);
1351+
return;
1352+
}
13291353

1330-
const objects = await query.find({ useMasterKey: true });
1331-
let csvString = columns.map(column => column.name).join(',') + '\n';
1332-
for (const object of objects) {
1333-
const row = columns.map(column => {
1334-
const type = columnsObject[column.name].type;
1335-
if (column.name === 'objectId') {
1336-
return object.id;
1337-
} else if (type === 'Relation' || type === 'Pointer') {
1338-
if (object.get(column.name)) {
1339-
return object.get(column.name).id
1340-
} else {
1341-
return ''
1342-
}
1343-
} else {
1344-
let colValue;
1345-
if (column.name === 'ACL') {
1346-
colValue = object.getACL();
1347-
} else {
1348-
colValue = object.get(column.name);
1349-
}
1350-
// Stringify objects and arrays
1351-
if (Object.prototype.toString.call(colValue) === '[object Object]' || Object.prototype.toString.call(colValue) === '[object Array]') {
1352-
colValue = JSON.stringify(colValue);
1353-
}
1354-
if(typeof colValue === 'string') {
1355-
if (colValue.includes('"')) {
1356-
// Has quote in data, escape and quote
1357-
// If the value contains both a quote and delimiter, adding quotes and escaping will take care of both scenarios
1358-
colValue = colValue.split('"').join('""');
1359-
return `"${colValue}"`;
1360-
} else if (colValue.includes(',')) {
1361-
// Has delimiter in data, surround with quote (which the value doesn't already contain)
1362-
return `"${colValue}"`;
1354+
let csvString = columns.map((column) => column.name).join(',') + '\n';
1355+
for (const object of objects) {
1356+
const row = columns
1357+
.map((column) => {
1358+
const type = columnsObject[column.name].type;
1359+
if (column.name === 'objectId') {
1360+
return object.id;
1361+
} else if (type === 'Relation' || type === 'Pointer') {
1362+
if (object.get(column.name)) {
1363+
return object.get(column.name).id;
1364+
} else {
1365+
return '';
1366+
}
13631367
} else {
1364-
// No quote or delimiter, just include plainly
1365-
return `${colValue}`;
1368+
let colValue;
1369+
if (column.name === 'ACL') {
1370+
colValue = object.getACL();
1371+
} else {
1372+
colValue = object.get(column.name);
1373+
}
1374+
// Stringify objects and arrays
1375+
if (
1376+
Object.prototype.toString.call(colValue) ===
1377+
'[object Object]' ||
1378+
Object.prototype.toString.call(colValue) === '[object Array]'
1379+
) {
1380+
colValue = JSON.stringify(colValue);
1381+
}
1382+
if (typeof colValue === 'string') {
1383+
if (colValue.includes('"')) {
1384+
// Has quote in data, escape and quote
1385+
// If the value contains both a quote and delimiter, adding quotes and escaping will take care of both scenarios
1386+
colValue = colValue.split('"').join('""');
1387+
return `"${colValue}"`;
1388+
} else if (colValue.includes(',')) {
1389+
// Has delimiter in data, surround with quote (which the value doesn't already contain)
1390+
return `"${colValue}"`;
1391+
} else {
1392+
// No quote or delimiter, just include plainly
1393+
return `${colValue}`;
1394+
}
1395+
} else if (colValue === undefined) {
1396+
// Export as empty CSV field
1397+
return '';
1398+
} else {
1399+
return `${colValue}`;
1400+
}
13661401
}
1367-
} else if (colValue === undefined) {
1368-
// Export as empty CSV field
1369-
return '';
1370-
} else {
1371-
return `${colValue}`;
1402+
})
1403+
.join(',');
1404+
csvString += row + '\n';
1405+
}
1406+
1407+
// Deliver to browser to download file
1408+
const element = document.createElement('a');
1409+
const file = new Blob([csvString], { type: 'text/csv' });
1410+
element.href = URL.createObjectURL(file);
1411+
element.download = `${className}.csv`;
1412+
document.body.appendChild(element); // Required for this to work in FireFox
1413+
element.click();
1414+
document.body.removeChild(element);
1415+
};
1416+
1417+
if (!rows['*']) {
1418+
const objects = await query.find({ useMasterKey: true });
1419+
processObjects(objects);
1420+
this.setState({ exporting: false, exportingCount: objects.length });
1421+
} else {
1422+
let batch = [];
1423+
query.eachBatch(
1424+
(obj) => {
1425+
batch.push(...obj);
1426+
if (batch.length % 10 === 0) {
1427+
this.setState({ exportingCount: batch.length });
13721428
}
1373-
}
1374-
}).join(',');
1375-
csvString += row + '\n';
1429+
const one_gigabyte = Math.pow(2, 30);
1430+
const size =
1431+
new TextEncoder().encode(JSON.stringify(batch)).length /
1432+
one_gigabyte;
1433+
if (size.length > 1) {
1434+
processObjects(batch);
1435+
batch = [];
1436+
}
1437+
if (obj.length !== 100) {
1438+
processObjects(batch);
1439+
batch = [];
1440+
this.setState({ exporting: false, exportingCount: 0 });
1441+
}
1442+
},
1443+
{ useMasterKey: true }
1444+
);
13761445
}
1377-
1378-
// Deliver to browser to download file
1379-
const element = document.createElement('a');
1380-
const file = new Blob([csvString], { type: 'text/csv' });
1381-
element.href = URL.createObjectURL(file);
1382-
element.download = `${className}.csv`;
1383-
document.body.appendChild(element); // Required for this to work in FireFox
1384-
element.click();
1385-
document.body.removeChild(element);
13861446
}
13871447

13881448
getClassRelationColumns(className) {
@@ -1804,8 +1864,10 @@ class Browser extends DashboardView {
18041864
<ExportSelectedRowsDialog
18051865
className={className}
18061866
selection={this.state.rowsToExport}
1867+
count={this.state.counts[className]}
1868+
data={this.state.data}
18071869
onCancel={this.cancelExportSelectedRows}
1808-
onConfirm={() => this.confirmExportSelectedRows(this.state.rowsToExport)}
1870+
onConfirm={(type, indentation) => this.confirmExportSelectedRows(this.state.rowsToExport, type, indentation)}
18091871
/>
18101872
);
18111873
}
@@ -1822,6 +1884,11 @@ class Browser extends DashboardView {
18221884
<Notification note={this.state.lastNote} isErrorNote={false}/>
18231885
);
18241886
}
1887+
else if (this.state.exporting) {
1888+
notification = (
1889+
<Notification note={`Exporting ${this.state.exportingCount}+ objects...`} isErrorNote={false}/>
1890+
);
1891+
}
18251892
return (
18261893
<div>
18271894
<Helmet>

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

+64-6
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,94 @@
55
* This source code is licensed under the license found in the LICENSE file in
66
* the root directory of this source tree.
77
*/
8-
import Modal from 'components/Modal/Modal.react';
9-
import React from 'react';
8+
import Modal from 'components/Modal/Modal.react';
9+
import React from 'react';
10+
import Dropdown from 'components/Dropdown/Dropdown.react';
11+
import Field from 'components/Field/Field.react';
12+
import Label from 'components/Label/Label.react';
13+
import Option from 'components/Dropdown/Option.react';
14+
import Toggle from 'components/Toggle/Toggle.react';
15+
import TextInput from 'components/TextInput/TextInput.react';
16+
import styles from 'dashboard/Data/Browser/ExportSelectedRowsDialog.scss';
1017

1118
export default class ExportSelectedRowsDialog extends React.Component {
1219
constructor() {
1320
super();
1421

1522
this.state = {
16-
confirmation: ''
23+
confirmation: '',
24+
exportType: '.csv',
25+
indentation: true,
1726
};
1827
}
1928

2029
valid() {
30+
if (!this.props.selection['*']) {
31+
return true;
32+
}
33+
if (this.state.confirmation !== 'export all') {
34+
return false;
35+
}
2136
return true;
2237
}
2338

39+
formatBytes(bytes) {
40+
if (!+bytes) return '0 Bytes'
41+
42+
const k = 1024
43+
const decimals = 2
44+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
45+
46+
const i = Math.floor(Math.log(bytes) / Math.log(k))
47+
48+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`
49+
}
50+
51+
2452
render() {
2553
let selectionLength = Object.keys(this.props.selection).length;
54+
const fileSize = new TextEncoder().encode(JSON.stringify(this.props.data, null, this.state.exportType === '.json' && this.state.indentation ? 2 : null)).length / this.props.data.length
2655
return (
2756
<Modal
2857
type={Modal.Types.INFO}
2958
icon='warn-outline'
3059
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.' : ''}
3260
disabled={!this.valid()}
3361
confirmText='Export'
3462
cancelText='Cancel'
3563
onCancel={this.props.onCancel}
36-
onConfirm={this.props.onConfirm}>
37-
{}
64+
onConfirm={() => this.props.onConfirm(this.state.exportType, this.state.indentation)}>
65+
{this.props.selection['*'] && <div className={styles.row} >
66+
<Label text="Do you really want to export all rows?" description={<span className={styles.label}>Estimated row count: {this.props.count}<br/>Estimated export size: {this.formatBytes(fileSize * this.props.count)}<br/><br/>⚠️ Exporting all rows may severely impact server or database resources.<br/>Large datasets are exported as multiple files of up to 1 GB each.</span>}/>
67+
</div>
68+
}
69+
<Field
70+
label={<Label text='Select export type' />}
71+
input={
72+
<Dropdown
73+
value={this.state.exportType}
74+
onChange={(exportType) => this.setState({ exportType })}>
75+
<Option value='.csv'>.csv</Option>
76+
<Option value='.json'>.json</Option>
77+
</Dropdown>
78+
} />
79+
{this.state.exportType === '.json' && <Field
80+
label={<Label text='Indentation' />}
81+
input={<Toggle value={this.state.indentation} type={Toggle.Types.YES_NO} onChange={(indentation) => {this.setState({indentation})}} />} />
82+
}
83+
{this.props.selection['*'] && <Field
84+
label={
85+
<Label
86+
text='Confirm this action'
87+
description='Enter "export all" to continue.' />
88+
}
89+
input={
90+
<TextInput
91+
placeholder='export all'
92+
value={this.state.confirmation}
93+
onChange={(confirmation) => this.setState({ confirmation })} />
94+
} />
95+
}
3896
</Modal>
3997
);
4098
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.row {
2+
display: block;
3+
position: relative;
4+
height: 100px;
5+
border-bottom: 1px solid #e0e0e1;
6+
}
7+
.label {
8+
line-height: 16px;
9+
}

0 commit comments

Comments
 (0)