Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelogs/CHANGELOG_alpha.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# [Unreleased]

## Features

* Keep cell selected by objectId and field name after data browser refresh ([#2631](https://github.com/parse-community/parse-dashboard/issues/2631))

# [9.1.0-alpha.7](https://github.com/parse-community/parse-dashboard/compare/9.1.0-alpha.6...9.1.0-alpha.7) (2026-03-02)


Expand Down
4 changes: 4 additions & 0 deletions src/components/BrowserCell/BrowserCell.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ export default class BrowserCell extends Component {
row,
col,
field,
objectId,
onEditSelectedRow,
isRequired,
markRequiredFieldRow,
Expand Down Expand Up @@ -733,6 +734,9 @@ export default class BrowserCell extends Component {
ref={this.cellRef}
className={classes.join(' ')}
style={style}
{...(current && { 'data-current-cell': 'true' })}
{...(objectId && { 'data-object-id': objectId })}
{...(field && { 'data-field': field })}
onClick={e => {
if (e.metaKey === true && type === 'Pointer') {
onPointerCmdClick(value);
Expand Down
48 changes: 47 additions & 1 deletion src/dashboard/Data/Browser/DataBrowser.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export default class DataBrowser extends React.Component {
_objectsToFetch: [], // Temporary field for async fetch handling
loadingObjectIds: new Set(),
keyboardShortcuts: null, // Keyboard shortcuts from server
pendingRestore: null, // Stores { objectId, fieldName } to re-select the same cell after a refresh
showScriptConfirmationDialog: false,
selectedScript: null,
contextMenuX: null,
Expand Down Expand Up @@ -502,19 +503,64 @@ export default class DataBrowser extends React.Component {
// Note: We intentionally do NOT clear selectedObjectId when current becomes null.
// Clicking toolbar menus sets current=null, but the info panel should persist.

// When a refresh starts, data becomes null. Save the currently selected cell's
// objectId and field name so we can restore the selection once new data loads.
if (
this.props.data === null &&
prevProps.data !== null &&
this.props.className === prevProps.className
) {
const { current, order } = this.state;
if (current !== null) {
const objectId = prevProps.data[current.row]?.id;
const fieldName = order[current.col]?.name;
if (objectId && fieldName) {
this.setState({ pendingRestore: { objectId, fieldName } });
}
}
}

// If the class changed while a restore was pending, discard it to avoid firing
// against a different class's data. Otherwise, when a refresh delivers new data,
// use the saved objectId + fieldName to restore the selection in its new position.
// If the document is no longer present, deselect to avoid highlighting the wrong cell.
if (this.props.className !== prevProps.className && this.state.pendingRestore) {
this.setState({ pendingRestore: null });
} else if (
this.props.data !== null &&
prevProps.data === null &&
this.state.pendingRestore
) {
const { objectId, fieldName } = this.state.pendingRestore;
const newRowIndex = this.props.data.findIndex(obj => obj.id === objectId);
const newColIndex = this.state.order.findIndex(col => col.name === fieldName);

if (newRowIndex !== -1 && newColIndex !== -1) {
this.setCurrent({ row: newRowIndex, col: newColIndex });
this.setState({ pendingRestore: null });
this.setSelectedObjectId(objectId);
this.handleCallCloudFunction(objectId, this.props.className, this.props.app.applicationId);
} else {
this.setState({ current: null, pendingRestore: null });
this.setSelectedObjectId(undefined);
}
}

if (this.state.current && this.state.current !== prevState.current) {
if (this.state.current.col !== this.state.lastSelectedCol) {
this.setState({ lastSelectedCol: this.state.current.col });
}
}

// Auto-load first row if enabled and conditions are met
// Auto-load first row if enabled and conditions are met.
// Skip when a cell restore is pending to avoid overwriting the restored selection.
if (
this.state.autoLoadFirstRow &&
this.state.isPanelVisible &&
this.props.data &&
this.props.data.length > 0 &&
!this.state.selectedObjectId &&
!this.state.pendingRestore &&
((!prevProps.data || prevProps.data.length === 0) ||
prevProps.className !== this.props.className ||
prevState.isPanelVisible !== this.state.isPanelVisible)
Expand Down
225 changes: 225 additions & 0 deletions src/lib/tests/e2e/DataBrowser.pendingRestore.e2e.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/*
* 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.
*/

/**
* E2E tests for the Data Browser "pendingRestore" feature: the selected cell
* (by objectId and field name) is restored after a refresh. These tests drive
* the real Parse Server, Dashboard, and DataBrowser in a browser so we
* exercise the actual componentDidUpdate path instead of a mock.
*
* To run the full e2e (with Parse Server and MongoDB):
* MONGODB_URI=mongodb://localhost:27017/pending_restore_e2e \
* NODE_OPTIONS=--experimental-vm-modules \
* npx jest --testPathPatterns=DataBrowser.pendingRestore.e2e --forceExit
*
* Without MongoDB or the flag, the tests are skipped and pass (no assertions run).
*/

jest.disableAutomock();
jest.setTimeout(60_000);

const express = require('express');
const ParseServer = require('parse-server').ParseServer;
const ParseDashboard = require('../../../../Parse-Dashboard/app');
const puppeteer = require('puppeteer');
const Parse = require('parse/node');

const APP_ID = 'pendingRestoreE2EAppId';
const MASTER_KEY = 'masterKeyE2E';
const SERVER_PORT = 5150;
const DASH_PORT = 5151;
const SERVER_URL = `http://localhost:${SERVER_PORT}/parse`;
const MOUNT = '/dashboard';
const DASH_URL = `http://localhost:${DASH_PORT}${MOUNT}`;
const CLASS_NAME = 'PendingRestoreE2ETest';

let parseServerInstance;
let parseHttpServer;
let dashboardServer;
let browser;

async function startParseServer() {
const api = new ParseServer({
databaseURI: process.env.MONGODB_URI || 'mongodb://localhost:27017/pending_restore_e2e',
appId: APP_ID,
masterKey: MASTER_KEY,
serverURL: SERVER_URL,
});
await api.start();
const app = express();
app.use('/parse', api.app);
return new Promise((resolve, reject) => {
parseHttpServer = app.listen(SERVER_PORT, () => {
parseServerInstance = api;
resolve();
});
parseHttpServer.on('error', reject);
});
}

async function startDashboard() {
const dashApp = express();
dashApp.use(
MOUNT,
ParseDashboard(
{
apps: [
{
serverURL: SERVER_URL,
appId: APP_ID,
masterKey: MASTER_KEY,
appName: 'TestApp',
},
],
trustProxy: 1,
},
{ allowInsecureHTTP: true }
)
);
return new Promise((resolve, reject) => {
dashboardServer = dashApp.listen(DASH_PORT, resolve);
dashboardServer.on('error', reject);
});
}

async function seedTestObject() {
Parse.initialize(APP_ID, undefined, MASTER_KEY);
Parse.serverURL = SERVER_URL;
const obj = new Parse.Object(CLASS_NAME);
obj.set('title', 'E2E Hello');
obj.set('count', 42);
await obj.save(null, { useMasterKey: true });
return obj.id;
}

let e2eSkipped = false;

function shouldSkipE2E(err) {
const msg = err && (err.message || err.toString());
if (
msg &&
(msg.includes('connect ECONNREFUSED') ||
msg.includes('MongoServerSelectionError') ||
msg.includes('MongoNetworkError'))
) {
console.warn('DataBrowser.pendingRestore e2e: MongoDB not available, skipping.');
return true;
}
if (msg && msg.includes('experimental-vm-modules')) {
console.warn(
'DataBrowser.pendingRestore e2e: Run with NODE_OPTIONS=--experimental-vm-modules and MongoDB for full e2e.'
);
return true;
}
return false;
}

beforeAll(async () => {
try {
await startParseServer();
} catch (e) {
if (shouldSkipE2E(e)) {
e2eSkipped = true;
return;
}
throw e;
}
await startDashboard();
Parse.initialize(APP_ID, undefined, MASTER_KEY);
Parse.serverURL = SERVER_URL;
browser = await puppeteer.launch({ args: ['--no-sandbox'] });
});

afterAll(async () => {
if (browser) {
await browser.close();
}
if (dashboardServer) {
await new Promise(resolve => dashboardServer.close(resolve));
}
if (parseHttpServer) {
await new Promise(resolve => parseHttpServer.close(resolve));
}
if (parseServerInstance && typeof parseServerInstance.handleShutdown === 'function') {
await parseServerInstance.handleShutdown();
}
});

describe('DataBrowser pendingRestore e2e', () => {
it('keeps the correct cell selected after a data refresh', async () => {
if (e2eSkipped || !parseServerInstance) {
return;
}
const objectId = await seedTestObject();
const page = await browser.newPage();

await page.goto(`${DASH_URL}/apps/TestApp/browser/${CLASS_NAME}`, {
waitUntil: 'networkidle2',
timeout: 15000,
});

await page.waitForSelector('[data-object-id]', { timeout: 10000 });

const cellSelector = `[data-object-id="${objectId}"][data-field="title"]`;
await page.waitForSelector(cellSelector, { timeout: 5000 });
await page.click(cellSelector);

const hasCurrentBefore = (await page.$('[data-current-cell="true"]')) !== null;
expect(hasCurrentBefore).toBe(true);

const refreshByText = await page.$x("//a[.//span[text()='Refresh']]").then(nodes => nodes[0]);
expect(refreshByText).toBeTruthy();
await refreshByText.click();

await page.waitForSelector('[data-object-id]', { timeout: 10000 });

const hasCurrentAfter = (await page.$('[data-current-cell="true"]')) !== null;
expect(hasCurrentAfter).toBe(true);

const currentCell = await page.$('[data-current-cell="true"]');
expect(currentCell).toBeTruthy();
const dataObjectId = await currentCell.getAttribute('data-object-id');
const dataField = await currentCell.getAttribute('data-field');
expect(dataObjectId).toBe(objectId);
expect(dataField).toBe('title');

await page.close();
});

it('clears selection when the object no longer exists after refresh', async () => {
if (e2eSkipped || !parseServerInstance) {
return;
}
const objectId = await seedTestObject();
const page = await browser.newPage();

await page.goto(`${DASH_URL}/apps/TestApp/browser/${CLASS_NAME}`, {
waitUntil: 'networkidle2',
timeout: 15000,
});
await page.waitForSelector('[data-object-id]', { timeout: 10000 });

const cellSelector = `[data-object-id="${objectId}"][data-field="title"]`;
await page.waitForSelector(cellSelector, { timeout: 5000 });
await page.click(cellSelector);

const obj = await new Parse.Query(CLASS_NAME).get(objectId, { useMasterKey: true });
await obj.destroy({ useMasterKey: true });

const refreshByText = await page.$x("//a[.//span[text()='Refresh']]").then(nodes => nodes[0]);
expect(refreshByText).toBeTruthy();
await refreshByText.click();

await page.waitForSelector('[data-object-id]', { timeout: 10000 });

const anyCurrent = (await page.$('[data-current-cell="true"]')) !== null;
expect(anyCurrent).toBe(false);

await page.close();
});
});