From 4ad36a782906b274531569eb9ae95741871a4cad Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:16:57 +0100 Subject: [PATCH 1/6] feat --- .../Data/Browser/DataBrowser.react.js | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 042b18811..aee3b9c8e 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -10,6 +10,7 @@ import copy from 'copy-to-clipboard'; import BrowserTable from 'dashboard/Data/Browser/BrowserTable.react'; import BrowserToolbar from 'dashboard/Data/Browser/BrowserToolbar.react'; import * as ColumnPreferences from 'lib/ColumnPreferences'; +import { CurrentApp } from 'context/currentApp'; import { dateStringUTC } from 'lib/DateUtils'; import getFileName from 'lib/getFileName'; import Parse from 'parse'; @@ -76,6 +77,8 @@ function formatValueForCopy(value, type) { * and the keyboard interactions for the data table. */ export default class DataBrowser extends React.Component { + static contextType = CurrentApp; + constructor(props) { super(props); @@ -172,6 +175,7 @@ export default class DataBrowser extends React.Component { this.addPanel = this.addPanel.bind(this); this.removePanel = this.removePanel.bind(this); this.handlePanelScroll = this.handlePanelScroll.bind(this); + this.handlePanelHeaderContextMenu = this.handlePanelHeaderContextMenu.bind(this); this.handleWrapperWheel = this.handleWrapperWheel.bind(this); this.saveOrderTimeout = null; this.aggregationPanelRef = React.createRef(); @@ -962,6 +966,79 @@ export default class DataBrowser extends React.Component { this.setState({ contextMenuX, contextMenuY, contextMenuItems }); } + handlePanelHeaderContextMenu(event, objectId) { + const { scripts = [] } = this.context || {}; + const className = this.props.className; + const field = 'objectId'; + + const menuItems = []; + let validator = null; + + // Filter scripts valid for this class and field + const validScripts = (scripts || []).filter(script => { + if (script.classes?.includes(className)) { + return true; + } + for (const scriptClass of script?.classes || []) { + if (scriptClass?.name !== className) { + continue; + } + const fields = scriptClass?.fields || []; + if (scriptClass?.fields.includes(field) || scriptClass?.fields.includes('*')) { + return true; + } + for (const currentField of fields) { + if (Object.prototype.toString.call(currentField) === '[object Object]') { + if (currentField.name === field) { + if (typeof currentField.validator === 'string') { + validator = eval(currentField.validator); + } else { + validator = currentField.validator; + } + return true; + } + } + } + } + return false; + }); + + // Add Scripts menu if there are valid scripts + if (validScripts.length && this.props.onEditSelectedRow) { + menuItems.push({ + text: 'Scripts', + items: validScripts.map(script => { + return { + text: script.title, + disabled: validator?.(objectId, field) === false, + callback: async () => { + try { + const object = Parse.Object.extend(className).createWithoutData(objectId); + const response = await Parse.Cloud.run( + script.cloudCodeFunction, + { object: object.toPointer() }, + { useMasterKey: true } + ); + this.props.showNote( + response || `Ran script "${script.title}" on "${className}" object "${objectId}".` + ); + this.props.onRefresh(); + } catch (e) { + this.props.showNote(e.message, true); + console.log(`Could not run ${script.title}: ${e}`); + } + }, + }; + }), + }); + } + + const { pageX, pageY } = event; + if (menuItems.length) { + this.setContextMenu(pageX, pageY, menuItems); + } + } + freezeColumns(index) { this.setState({ frozenColumnIndex: index }); } @@ -1645,6 +1722,10 @@ export default class DataBrowser extends React.Component { onMouseDown={(e) => { e.preventDefault(); }} + onContextMenu={(e) => { + e.preventDefault(); + this.handlePanelHeaderContextMenu(e, objectId); + }} > Date: Sat, 13 Dec 2025 20:27:28 +0100 Subject: [PATCH 2/6] optim --- .../BrowserCell/BrowserCell.react.js | 77 ++++------------- .../ScriptConfirmationModal.react.js | 36 ++++++++ .../Data/Browser/DataBrowser.react.js | 83 +++++++++---------- src/lib/ScriptUtils.js | 74 +++++++++++++++++ 4 files changed, 164 insertions(+), 106 deletions(-) create mode 100644 src/components/ScriptConfirmationModal/ScriptConfirmationModal.react.js create mode 100644 src/lib/ScriptUtils.js diff --git a/src/components/BrowserCell/BrowserCell.react.js b/src/components/BrowserCell/BrowserCell.react.js index ff054ed9c..ab593414d 100644 --- a/src/components/BrowserCell/BrowserCell.react.js +++ b/src/components/BrowserCell/BrowserCell.react.js @@ -9,14 +9,15 @@ import * as Filters from 'lib/Filters'; import { List, Map } from 'immutable'; import { dateStringUTC } from 'lib/DateUtils'; import getFileName from 'lib/getFileName'; +import { getValidScripts, executeScript } from 'lib/ScriptUtils'; import Parse from 'parse'; import Pill from 'components/Pill/Pill.react'; import React, { Component } from 'react'; +import ScriptConfirmationModal from 'components/ScriptConfirmationModal/ScriptConfirmationModal.react'; import styles from 'components/BrowserCell/BrowserCell.scss'; import baseStyles from 'stylesheets/base.scss'; import * as ColumnPreferences from 'lib/ColumnPreferences'; import labelStyles from 'components/Label/Label.scss'; -import Modal from 'components/Modal/Modal.react'; export default class BrowserCell extends Component { constructor() { @@ -348,33 +349,8 @@ export default class BrowserCell extends Component { } const { className, objectId, field, scripts = [], rowValue } = this.props; - let validator = null; - const validScripts = (scripts || []).filter(script => { - if (script.classes?.includes(className)) { - return true; - } - for (const script of script?.classes || []) { - if (script?.name !== className) { - continue; - } - const fields = script?.fields || []; - if (script?.fields.includes(field) || script?.fields.includes('*')) { - return true; - } - for (const currentField of fields) { - if (Object.prototype.toString.call(currentField) === '[object Object]') { - if (currentField.name === field) { - if (typeof currentField.validator === 'string') { - validator = eval(currentField.validator); - } else { - validator = currentField.validator; - } - return true; - } - } - } - } - }); + const { validScripts, validator } = getValidScripts(scripts, className, field); + if (validScripts.length) { onEditSelectedRow && contextMenuOptions.push({ @@ -400,24 +376,13 @@ export default class BrowserCell extends Component { } async executeScript(script) { - try { - const object = Parse.Object.extend(this.props.className).createWithoutData( - this.props.objectId - ); - const response = await Parse.Cloud.run( - script.cloudCodeFunction, - { object: object.toPointer() }, - { useMasterKey: true } - ); - this.props.showNote( - response || - `Ran script "${script.title}" on "${this.props.className}" object "${object.id}".` - ); - this.props.onRefresh(); - } catch (e) { - this.props.showNote(e.message, true); - console.log(`Could not run ${script.title}: ${e}`); - } + await executeScript( + script, + this.props.className, + this.props.objectId, + this.props.showNote, + this.props.onRefresh + ); } toggleConfirmationDialog() { @@ -590,26 +555,14 @@ export default class BrowserCell extends Component { let extras = null; if (this.state.showConfirmationDialog) { extras = ( - this.toggleConfirmationDialog()} onConfirm={() => { - this.executeSript(this.selectedScript); + this.executeScript(this.selectedScript); this.toggleConfirmationDialog(); }} - > -
- {`Do you want to run script "${this.selectedScript.title}" on "${this.selectedScript.className}" object "${this.selectedScript.objectId}"?`} -
-
+ /> ); } diff --git a/src/components/ScriptConfirmationModal/ScriptConfirmationModal.react.js b/src/components/ScriptConfirmationModal/ScriptConfirmationModal.react.js new file mode 100644 index 000000000..a15643d82 --- /dev/null +++ b/src/components/ScriptConfirmationModal/ScriptConfirmationModal.react.js @@ -0,0 +1,36 @@ +/* + * 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 React from 'react'; +import Modal from 'components/Modal/Modal.react'; +import labelStyles from 'components/Label/Label.scss'; +import browserCellStyles from 'components/BrowserCell/BrowserCell.scss'; + +/** + * Confirmation dialog for executing scripts + */ +export default function ScriptConfirmationModal({ script, onConfirm, onCancel }) { + if (!script) { + return null; + } + + return ( + +
+ {`Do you want to run script "${script.title}" on "${script.className}" object "${script.objectId}"?`} +
+
+ ); +} diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index aee3b9c8e..9ebddf9a6 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -13,9 +13,11 @@ import * as ColumnPreferences from 'lib/ColumnPreferences'; import { CurrentApp } from 'context/currentApp'; import { dateStringUTC } from 'lib/DateUtils'; import getFileName from 'lib/getFileName'; +import { getValidScripts, executeScript } from '../../../lib/ScriptUtils'; import Parse from 'parse'; import React from 'react'; import { ResizableBox } from 'react-resizable'; +import ScriptConfirmationModal from '../../../components/ScriptConfirmationModal/ScriptConfirmationModal.react'; import styles from './Databrowser.scss'; import AggregationPanel from '../../../components/AggregationPanel/AggregationPanel'; @@ -144,6 +146,11 @@ export default class DataBrowser extends React.Component { multiPanelData: {}, // Object mapping objectId to panel data _objectsToFetch: [], // Temporary field for async fetch handling loadingObjectIds: new Set(), + showScriptConfirmationDialog: false, + selectedScript: null, + contextMenuX: null, + contextMenuY: null, + contextMenuItems: null, }; this.handleResizeDiv = this.handleResizeDiv.bind(this); @@ -971,37 +978,9 @@ export default class DataBrowser extends React.Component { const className = this.props.className; const field = 'objectId'; - const menuItems = []; - let validator = null; + const { validScripts, validator } = getValidScripts(scripts, className, field); - // Filter scripts valid for this class and field - const validScripts = (scripts || []).filter(script => { - if (script.classes?.includes(className)) { - return true; - } - for (const scriptClass of script?.classes || []) { - if (scriptClass?.name !== className) { - continue; - } - const fields = scriptClass?.fields || []; - if (scriptClass?.fields.includes(field) || scriptClass?.fields.includes('*')) { - return true; - } - for (const currentField of fields) { - if (Object.prototype.toString.call(currentField) === '[object Object]') { - if (currentField.name === field) { - if (typeof currentField.validator === 'string') { - validator = eval(currentField.validator); - } else { - validator = currentField.validator; - } - return true; - } - } - } - } - return false; - }); + const menuItems = []; // Add Scripts menu if there are valid scripts if (validScripts.length && this.props.onEditSelectedRow) { @@ -1011,21 +990,21 @@ export default class DataBrowser extends React.Component { return { text: script.title, disabled: validator?.(objectId, field) === false, - callback: async () => { - try { - const object = Parse.Object.extend(className).createWithoutData(objectId); - const response = await Parse.Cloud.run( - script.cloudCodeFunction, - { object: object.toPointer() }, - { useMasterKey: true } - ); - this.props.showNote( - response || `Ran script "${script.title}" on "${className}" object "${objectId}".` + callback: () => { + const selectedScript = { ...script, className, objectId }; + if (script.showConfirmationDialog) { + this.setState({ + showScriptConfirmationDialog: true, + selectedScript + }); + } else { + executeScript( + script, + className, + objectId, + this.props.showNote, + this.props.onRefresh ); - this.props.onRefresh(); - } catch (e) { - this.props.showNote(e.message, true); - console.log(`Could not run ${script.title}: ${e}`); } }, }; @@ -1827,6 +1806,22 @@ export default class DataBrowser extends React.Component { items={this.state.contextMenuItems} /> )} + {this.state.showScriptConfirmationDialog && ( + this.setState({ showScriptConfirmationDialog: false })} + onConfirm={() => { + executeScript( + this.state.selectedScript, + this.state.selectedScript.className, + this.state.selectedScript.objectId, + this.props.showNote, + this.props.onRefresh + ); + this.setState({ showScriptConfirmationDialog: false }); + }} + /> + )} ); } diff --git a/src/lib/ScriptUtils.js b/src/lib/ScriptUtils.js new file mode 100644 index 000000000..27293f81f --- /dev/null +++ b/src/lib/ScriptUtils.js @@ -0,0 +1,74 @@ +/* + * 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 Parse from 'parse'; + +/** + * Filters scripts to only those valid for the given className and field + * @param {Array} scripts - Array of script configurations + * @param {string} className - The Parse class name + * @param {string} field - The field name + * @returns {Object} - { validScripts: Array, validator: Function|null } + */ +export function getValidScripts(scripts, className, field) { + let validator = null; + const validScripts = (scripts || []).filter(script => { + if (script.classes?.includes(className)) { + return true; + } + for (const scriptClass of script?.classes || []) { + if (scriptClass?.name !== className) { + continue; + } + const fields = scriptClass?.fields || []; + if (scriptClass?.fields.includes(field) || scriptClass?.fields.includes('*')) { + return true; + } + for (const currentField of fields) { + if (Object.prototype.toString.call(currentField) === '[object Object]') { + if (currentField.name === field) { + if (typeof currentField.validator === 'string') { + validator = eval(currentField.validator); + } else { + validator = currentField.validator; + } + return true; + } + } + } + } + return false; + }); + + return { validScripts, validator }; +} + +/** + * Executes a Parse Cloud Code script + * @param {Object} script - The script configuration + * @param {string} className - The Parse class name + * @param {string} objectId - The object ID + * @param {Function} showNote - Callback to show notification + * @param {Function} onRefresh - Callback to refresh data + */ +export async function executeScript(script, className, objectId, showNote, onRefresh) { + try { + const object = Parse.Object.extend(className).createWithoutData(objectId); + const response = await Parse.Cloud.run( + script.cloudCodeFunction, + { object: object.toPointer() }, + { useMasterKey: true } + ); + showNote( + response || `Ran script "${script.title}" on "${className}" object "${object.id}".` + ); + onRefresh(); + } catch (e) { + showNote(e.message, true); + console.log(`Could not run ${script.title}: ${e}`); + } +} From 6773f2e2e06d649a0aefbe8758fe1144934c9feb Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:38:15 +0100 Subject: [PATCH 3/6] lint --- src/components/BrowserCell/BrowserCell.react.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/BrowserCell/BrowserCell.react.js b/src/components/BrowserCell/BrowserCell.react.js index ab593414d..fd815235c 100644 --- a/src/components/BrowserCell/BrowserCell.react.js +++ b/src/components/BrowserCell/BrowserCell.react.js @@ -17,7 +17,6 @@ import ScriptConfirmationModal from 'components/ScriptConfirmationModal/ScriptCo import styles from 'components/BrowserCell/BrowserCell.scss'; import baseStyles from 'stylesheets/base.scss'; import * as ColumnPreferences from 'lib/ColumnPreferences'; -import labelStyles from 'components/Label/Label.scss'; export default class BrowserCell extends Component { constructor() { From 5647559fb85b7ec772190559dd75b5b7aefaa914 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:40:50 +0100 Subject: [PATCH 4/6] feedback --- src/dashboard/Data/Browser/DataBrowser.react.js | 4 ++-- src/lib/ScriptUtils.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 9ebddf9a6..ebaad2729 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -1809,7 +1809,7 @@ export default class DataBrowser extends React.Component { {this.state.showScriptConfirmationDialog && ( this.setState({ showScriptConfirmationDialog: false })} + onCancel={() => this.setState({ showScriptConfirmationDialog: false, selectedScript: null })} onConfirm={() => { executeScript( this.state.selectedScript, @@ -1818,7 +1818,7 @@ export default class DataBrowser extends React.Component { this.props.showNote, this.props.onRefresh ); - this.setState({ showScriptConfirmationDialog: false }); + this.setState({ showScriptConfirmationDialog: false, selectedScript: null }); }} /> )} diff --git a/src/lib/ScriptUtils.js b/src/lib/ScriptUtils.js index 27293f81f..11b19bc97 100644 --- a/src/lib/ScriptUtils.js +++ b/src/lib/ScriptUtils.js @@ -69,6 +69,6 @@ export async function executeScript(script, className, objectId, showNote, onRef onRefresh(); } catch (e) { showNote(e.message, true); - console.log(`Could not run ${script.title}: ${e}`); + console.error(`Could not run ${script.title}:`, e); } } From 0b2946d91bcdedcf209b5f070b1811cda6f34cd9 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:47:16 +0100 Subject: [PATCH 5/6] fix --- src/lib/ScriptUtils.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/ScriptUtils.js b/src/lib/ScriptUtils.js index 11b19bc97..0eb3cd76c 100644 --- a/src/lib/ScriptUtils.js +++ b/src/lib/ScriptUtils.js @@ -63,12 +63,12 @@ export async function executeScript(script, className, objectId, showNote, onRef { object: object.toPointer() }, { useMasterKey: true } ); - showNote( + showNote?.( response || `Ran script "${script.title}" on "${className}" object "${object.id}".` ); - onRefresh(); + onRefresh?.(); } catch (e) { - showNote(e.message, true); + showNote?.(e.message, true); console.error(`Could not run ${script.title}:`, e); } } From 66046b52af3eb058e50f049e470d103d8fcfa2e2 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:50:52 +0100 Subject: [PATCH 6/6] doc --- src/lib/ScriptUtils.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/ScriptUtils.js b/src/lib/ScriptUtils.js index 0eb3cd76c..1f3a6be47 100644 --- a/src/lib/ScriptUtils.js +++ b/src/lib/ScriptUtils.js @@ -32,6 +32,11 @@ export function getValidScripts(scripts, className, field) { if (Object.prototype.toString.call(currentField) === '[object Object]') { if (currentField.name === field) { if (typeof currentField.validator === 'string') { + // SAFETY: eval() is used here on validator strings from trusted admin-controlled + // dashboard configuration only (not user input). These validators are used solely + // for UI validation logic to enable/disable script menu items. This is an accepted + // tradeoff in this trusted admin context. If requirements change, consider replacing + // with Function constructor or a safer expression parser. validator = eval(currentField.validator); } else { validator = currentField.validator;