Skip to content

Commit 16f60f4

Browse files
authored
feat: Add support for data import in data browser (#3244)
1 parent 0a8ac38 commit 16f60f4

File tree

9 files changed

+1830
-4
lines changed

9 files changed

+1830
-4
lines changed

README.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ Parse Dashboard is a standalone dashboard for managing your [Parse Server](https
9292
- [Response Fields](#response-fields)
9393
- [Form Elements](#form-elements)
9494
- [Drop-Down](#drop-down)
95+
- [Checkbox](#checkbox)
96+
- [Toggle](#toggle)
97+
- [Text Input](#text-input)
9598
- [Graph](#graph)
9699
- [Calculated Values](#calculated-values)
97100
- [Formula Operator](#formula-operator)
@@ -103,7 +106,8 @@ Parse Dashboard is a standalone dashboard for managing your [Parse Server](https
103106
- [Browse as User](#browse-as-user)
104107
- [Change Pointer Key](#change-pointer-key)
105108
- [Limitations](#limitations)
106-
- [CSV Export](#csv-export)
109+
- [Data Export](#data-export)
110+
- [Data Import](#data-import)
107111
- [AI Agent](#ai-agent)
108112
- [Configuration](#configuration-1)
109113
- [Providers](#providers)
@@ -1731,14 +1735,35 @@ This feature allows you to change how a pointer is represented in the browser. B
17311735

17321736
> ⚠️ For each custom pointer key in each row, a server request is triggered to resolve the custom pointer key. For example, if the browser shows a class with 50 rows and each row contains 3 custom pointer keys, a total of 150 separate server requests are triggered.
17331737

1734-
### CSV Export
1738+
### Data Export
17351739

17361740
▶️ *Core > Browser > Export*
17371741

17381742
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.
17391743

17401744
> ⚠️ 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.
17411745

1746+
### Data Import
1747+
1748+
▶️ *Core > Browser > Data > Import*
1749+
1750+
Import data into a class from a JSON or CSV file. The file format is the same as the export format, so you can export data from one class and import it into another.
1751+
1752+
- **JSON** — An array of objects, e.g. `[{ "name": "Alice", "score": 100 }, ...]`. Typed fields such as `Pointer`, `Date`, `GeoPoint`, and `File` are expected in Parse `_toFullJSON()` format.
1753+
- **CSV** — Comma-separated with a header row. Column types are reconstructed from the class schema.
1754+
1755+
The import dialog provides the following options:
1756+
1757+
| Option | Description |
1758+
|---|---|
1759+
| Preserve object IDs | Use `objectId` values from the file instead of generating new ones. Requires the server option `allowCustomObjectId`. |
1760+
| Preserve timestamps | Use `createdAt` / `updatedAt` from the file. Requires `apps[].maintenanceKey` in the dashboard config. |
1761+
| Duplicate handling | When preserving object IDs: overwrite, skip, or fail on duplicates. |
1762+
| Unknown columns | Auto-create new columns, ignore them, or fail on unknown columns. |
1763+
| Continue on errors | Skip failing rows and continue, or stop on the first error. |
1764+
1765+
> ⚠️ Disabling *Preserve object IDs* means new object IDs are generated. Any `Pointer` or `Relation` fields that reference objects within the same import file will not resolve correctly.
1766+
17421767
## AI Agent
17431768

17441769
The Parse Dashboard includes an AI agent that can help manage your Parse Server data through natural language commands. The agent can perform operations like creating classes, adding data, querying records, and more.

src/components/FileInput/FileInput.react.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export default class FileInput extends React.Component {
4444
if (this.props.accept) {
4545
inputProps.accept = this.props.accept;
4646
}
47-
const label = this.renderLabel();
47+
const label = this.props.buttonText ? null : this.renderLabel();
4848
const buttonStyles = [styles.button];
4949
if (this.props.disabled || this.props.uploading) {
5050
buttonStyles.push(styles.disabled);
@@ -58,6 +58,8 @@ export default class FileInput extends React.Component {
5858
<div className={buttonStyles.join(' ')}>
5959
{this.props.uploading ? (
6060
<div className={styles.spinner}></div>
61+
) : this.props.buttonText ? (
62+
<span>{this.props.buttonText}</span>
6163
) : label ? (
6264
<span>Change file</span>
6365
) : (

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

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ import ScriptResponseModal from 'dashboard/Data/Browser/ScriptResponseModal.reac
2424
import ExportDialog from 'dashboard/Data/Browser/ExportDialog.react';
2525
import ExportSchemaDialog from 'dashboard/Data/Browser/ExportSchemaDialog.react';
2626
import ExportSelectedRowsDialog from 'dashboard/Data/Browser/ExportSelectedRowsDialog.react';
27+
import ImportDataDialog from 'dashboard/Data/Browser/ImportDataDialog.react';
28+
import {
29+
parseImportJSON,
30+
parseImportCSV,
31+
buildBatchRequests,
32+
sendBatchImport,
33+
checkDuplicates,
34+
} from 'lib/importData';
2735
import Notification from 'dashboard/Data/Browser/Notification.react';
2836
import PointerKeyDialog from 'dashboard/Data/Browser/PointerKeyDialog.react';
2937
import RemoveColumnDialog from 'dashboard/Data/Browser/RemoveColumnDialog.react';
@@ -152,6 +160,7 @@ class Browser extends DashboardView {
152160
showPointerKeyDialog: false,
153161
rowsToDelete: null,
154162
rowsToExport: null,
163+
showImportDialog: false,
155164

156165
relation: null,
157166
counts: {},
@@ -255,6 +264,9 @@ class Browser extends DashboardView {
255264
this.showExportSchemaDialog = this.showExportSchemaDialog.bind(this);
256265
this.confirmExportSelectedRows = this.confirmExportSelectedRows.bind(this);
257266
this.cancelExportSelectedRows = this.cancelExportSelectedRows.bind(this);
267+
this.showImportDialog = this.showImportDialog.bind(this);
268+
this.confirmImport = this.confirmImport.bind(this);
269+
this.cancelImportDialog = this.cancelImportDialog.bind(this);
258270
this.getClassRelationColumns = this.getClassRelationColumns.bind(this);
259271
this.showCreateClass = this.showCreateClass.bind(this);
260272
this.refresh = this.refresh.bind(this);
@@ -303,6 +315,8 @@ class Browser extends DashboardView {
303315
// Map of objectId -> promise for ongoing info panel cloud function requests
304316
this.infoPanelQueries = {};
305317

318+
this.importDialogRef = null;
319+
306320
this.dataBrowserRef = React.createRef();
307321

308322
window.addEventListener('popstate', () => {
@@ -2071,6 +2085,7 @@ class Browser extends DashboardView {
20712085
this.state.showEditRowDialog ||
20722086
this.state.showPermissionsDialog ||
20732087
this.state.showExportSelectedRowsDialog ||
2088+
this.state.showImportDialog ||
20742089
this.state.showAddToConfigDialog
20752090
);
20762091
}
@@ -2420,6 +2435,149 @@ class Browser extends DashboardView {
24202435
});
24212436
}
24222437

2438+
showImportDialog() {
2439+
this.setState({ showImportDialog: true });
2440+
}
2441+
2442+
cancelImportDialog() {
2443+
this.setState({ showImportDialog: false });
2444+
this.refresh();
2445+
}
2446+
2447+
async confirmImport(importOptions) {
2448+
const className = this.props.params.className;
2449+
const classColumns = this.getClassColumns(className, false);
2450+
const schema = {};
2451+
classColumns.forEach(column => {
2452+
schema[column.name] = column;
2453+
});
2454+
const knownColumns = classColumns.map(c => c.name);
2455+
2456+
// Parse the file
2457+
let parseResult;
2458+
if (importOptions.fileType === '.json') {
2459+
parseResult = parseImportJSON(importOptions.content);
2460+
} else {
2461+
parseResult = parseImportCSV(importOptions.content, schema);
2462+
}
2463+
2464+
if (parseResult.error) {
2465+
this.showNote(parseResult.error, true);
2466+
if (this.importDialogRef) {
2467+
this.importDialogRef.resetForm();
2468+
}
2469+
return;
2470+
}
2471+
2472+
// Check for unknown columns if fail mode
2473+
if (importOptions.unknownColumns === 'fail') {
2474+
const unknownCols = [];
2475+
for (const row of parseResult.rows) {
2476+
for (const key of Object.keys(row)) {
2477+
if (!knownColumns.includes(key) && !unknownCols.includes(key)) {
2478+
unknownCols.push(key);
2479+
}
2480+
}
2481+
}
2482+
if (unknownCols.length > 0) {
2483+
this.showNote(`Unknown columns found: ${unknownCols.join(', ')}. Import aborted.`, true);
2484+
if (this.importDialogRef) {
2485+
this.importDialogRef.resetForm();
2486+
}
2487+
return;
2488+
}
2489+
}
2490+
2491+
try {
2492+
// Track rows before duplicate filtering for skip count
2493+
const totalRowsBefore = parseResult.rows.length;
2494+
2495+
// Check for duplicates if needed
2496+
if (importOptions.preserveObjectIds && importOptions.duplicateHandling !== 'overwrite') {
2497+
const objectIds = parseResult.rows
2498+
.map(r => r.objectId)
2499+
.filter(Boolean);
2500+
2501+
if (objectIds.length > 0) {
2502+
const existing = await checkDuplicates(objectIds, className);
2503+
2504+
if (importOptions.duplicateHandling === 'fail' && existing.length > 0) {
2505+
this.showNote(
2506+
`Duplicate objectIds found: ${existing.slice(0, 5).join(', ')}${existing.length > 5 ? '...' : ''}. Import aborted.`,
2507+
true
2508+
);
2509+
if (this.importDialogRef) {
2510+
this.importDialogRef.resetForm();
2511+
}
2512+
return;
2513+
}
2514+
2515+
if (importOptions.duplicateHandling === 'skip') {
2516+
const existingSet = new Set(existing);
2517+
parseResult.rows = parseResult.rows.filter(r => !existingSet.has(r.objectId));
2518+
if (parseResult.rows.length === 0) {
2519+
this.showNote('All rows already exist. Nothing to import.', true);
2520+
if (this.importDialogRef) {
2521+
this.importDialogRef.resetForm();
2522+
}
2523+
return;
2524+
}
2525+
}
2526+
}
2527+
}
2528+
2529+
const skippedCount = totalRowsBefore - parseResult.rows.length;
2530+
2531+
// Build batch requests
2532+
const requests = buildBatchRequests(parseResult.rows, className, {
2533+
preserveObjectIds: importOptions.preserveObjectIds,
2534+
preserveTimestamps: importOptions.preserveTimestamps,
2535+
duplicateHandling: importOptions.duplicateHandling,
2536+
unknownColumns: importOptions.unknownColumns,
2537+
knownColumns,
2538+
});
2539+
2540+
// Set dialog to importing state
2541+
if (this.importDialogRef) {
2542+
this.importDialogRef.setImporting();
2543+
}
2544+
2545+
// Send batch import
2546+
const results = await sendBatchImport(requests, {
2547+
serverURL: this.context.serverURL,
2548+
applicationId: this.context.applicationId,
2549+
masterKey: this.context.masterKey,
2550+
maintenanceKey: importOptions.preserveTimestamps ? this.context.maintenanceKey : undefined,
2551+
continueOnError: importOptions.continueOnError,
2552+
onProgress: (progress) => {
2553+
if (this.importDialogRef) {
2554+
this.importDialogRef.setProgress(progress);
2555+
}
2556+
},
2557+
});
2558+
2559+
// Show results in dialog with skip count included
2560+
if (this.importDialogRef) {
2561+
this.importDialogRef.setResults({
2562+
...results,
2563+
skipped: results.skipped + skippedCount,
2564+
});
2565+
}
2566+
} catch (error) {
2567+
const msg = typeof error === 'string' ? error : error.message;
2568+
this.showNote(msg || 'Import failed due to a network error.', true);
2569+
if (this.importDialogRef) {
2570+
this.importDialogRef.setResults({
2571+
imported: 0,
2572+
skipped: 0,
2573+
failed: 0,
2574+
errors: [],
2575+
stopped: true,
2576+
});
2577+
}
2578+
}
2579+
}
2580+
24232581
cancelExportSelectedRows() {
24242582
this.setState({
24252583
rowsToExport: null,
@@ -2957,6 +3115,7 @@ class Browser extends DashboardView {
29573115
onEditPermissions={this.onDialogToggle}
29583116
onExportSelectedRows={this.showExportSelectedRowsDialog}
29593117
onExportSchema={this.showExportSchemaDialog}
3118+
onImport={this.showImportDialog}
29603119
onSaveNewRow={this.saveNewRow}
29613120
onShowPointerKey={this.showPointerKeyDialog}
29623121
onAbortAddRow={this.abortAddRow}
@@ -3247,6 +3406,16 @@ class Browser extends DashboardView {
32473406
}
32483407
/>
32493408
);
3409+
} else if (this.state.showImportDialog) {
3410+
extras = (
3411+
<ImportDataDialog
3412+
ref={(ref) => { this.importDialogRef = ref; }}
3413+
className={className}
3414+
maintenanceKey={this.context.maintenanceKey}
3415+
onCancel={this.cancelImportDialog}
3416+
onConfirm={this.confirmImport}
3417+
/>
3418+
);
32503419
} else if (this.state.showAddToConfigDialog) {
32513420
extras = (
32523421
<AddArrayEntryDialog

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const BrowserToolbar = ({
4444
onCloneSelectedRows,
4545
onExportSelectedRows,
4646
onExportSchema,
47+
onImport,
4748
onExport,
4849
onRemoveColumn,
4950
onDeleteRows,
@@ -378,7 +379,7 @@ const BrowserToolbar = ({
378379
{onAddRow && <div className={styles.toolbarSeparator} />}
379380
{onAddRow && (
380381
<BrowserMenu
381-
title="Export"
382+
title="Data"
382383
icon="down-solid"
383384
disabled={isUnique || isPendingEditCloneRows}
384385
setCurrent={setCurrent}
@@ -390,6 +391,12 @@ const BrowserToolbar = ({
390391
/>
391392
<MenuItem text={'Export all rows'} onClick={() => onExportSelectedRows({ '*': true })} />
392393
<MenuItem text={'Export schema'} onClick={() => onExportSchema()} />
394+
{!relation && (
395+
<>
396+
<Separator />
397+
<MenuItem text={'Import'} onClick={() => onImport()} />
398+
</>
399+
)}
393400
</BrowserMenu>
394401
)}
395402
{onAddRow && <div className={styles.toolbarSeparator} />}

0 commit comments

Comments
 (0)