diff --git a/src/components/BrowserCell/BrowserCell.react.js b/src/components/BrowserCell/BrowserCell.react.js index ff054ed9cb..fd815235c4 100644 --- a/src/components/BrowserCell/BrowserCell.react.js +++ b/src/components/BrowserCell/BrowserCell.react.js @@ -9,14 +9,14 @@ 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 +348,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 +375,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 +554,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 0000000000..a15643d827 --- /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 042b18811e..ebaad2729a 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -10,11 +10,14 @@ 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 { 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'; @@ -76,6 +79,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); @@ -141,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); @@ -172,6 +182,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 +973,51 @@ 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 { validScripts, validator } = getValidScripts(scripts, className, field); + + const menuItems = []; + + // 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: () => { + const selectedScript = { ...script, className, objectId }; + if (script.showConfirmationDialog) { + this.setState({ + showScriptConfirmationDialog: true, + selectedScript + }); + } else { + executeScript( + script, + className, + objectId, + this.props.showNote, + this.props.onRefresh + ); + } + }, + }; + }), + }); + } + + const { pageX, pageY } = event; + if (menuItems.length) { + this.setContextMenu(pageX, pageY, menuItems); + } + } + freezeColumns(index) { this.setState({ frozenColumnIndex: index }); } @@ -1645,6 +1701,10 @@ export default class DataBrowser extends React.Component { onMouseDown={(e) => { e.preventDefault(); }} + onContextMenu={(e) => { + e.preventDefault(); + this.handlePanelHeaderContextMenu(e, objectId); + }} > )} + {this.state.showScriptConfirmationDialog && ( + this.setState({ showScriptConfirmationDialog: false, selectedScript: null })} + onConfirm={() => { + executeScript( + this.state.selectedScript, + this.state.selectedScript.className, + this.state.selectedScript.objectId, + this.props.showNote, + this.props.onRefresh + ); + this.setState({ showScriptConfirmationDialog: false, selectedScript: null }); + }} + /> + )} ); } diff --git a/src/lib/ScriptUtils.js b/src/lib/ScriptUtils.js new file mode 100644 index 0000000000..1f3a6be475 --- /dev/null +++ b/src/lib/ScriptUtils.js @@ -0,0 +1,79 @@ +/* + * 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') { + // 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; + } + 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.error(`Could not run ${script.title}:`, e); + } +}