Skip to content

Commit 2983741

Browse files
authored
feat: Execute scripts via right-click on info panel header column (#3068)
1 parent a1d1f01 commit 2983741

File tree

4 files changed

+206
-63
lines changed

4 files changed

+206
-63
lines changed

src/components/BrowserCell/BrowserCell.react.js

Lines changed: 15 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import * as Filters from 'lib/Filters';
99
import { List, Map } from 'immutable';
1010
import { dateStringUTC } from 'lib/DateUtils';
1111
import getFileName from 'lib/getFileName';
12+
import { getValidScripts, executeScript } from 'lib/ScriptUtils';
1213
import Parse from 'parse';
1314
import Pill from 'components/Pill/Pill.react';
1415
import React, { Component } from 'react';
16+
import ScriptConfirmationModal from 'components/ScriptConfirmationModal/ScriptConfirmationModal.react';
1517
import styles from 'components/BrowserCell/BrowserCell.scss';
1618
import baseStyles from 'stylesheets/base.scss';
1719
import * as ColumnPreferences from 'lib/ColumnPreferences';
18-
import labelStyles from 'components/Label/Label.scss';
19-
import Modal from 'components/Modal/Modal.react';
2020

2121
export default class BrowserCell extends Component {
2222
constructor() {
@@ -348,33 +348,8 @@ export default class BrowserCell extends Component {
348348
}
349349

350350
const { className, objectId, field, scripts = [], rowValue } = this.props;
351-
let validator = null;
352-
const validScripts = (scripts || []).filter(script => {
353-
if (script.classes?.includes(className)) {
354-
return true;
355-
}
356-
for (const script of script?.classes || []) {
357-
if (script?.name !== className) {
358-
continue;
359-
}
360-
const fields = script?.fields || [];
361-
if (script?.fields.includes(field) || script?.fields.includes('*')) {
362-
return true;
363-
}
364-
for (const currentField of fields) {
365-
if (Object.prototype.toString.call(currentField) === '[object Object]') {
366-
if (currentField.name === field) {
367-
if (typeof currentField.validator === 'string') {
368-
validator = eval(currentField.validator);
369-
} else {
370-
validator = currentField.validator;
371-
}
372-
return true;
373-
}
374-
}
375-
}
376-
}
377-
});
351+
const { validScripts, validator } = getValidScripts(scripts, className, field);
352+
378353
if (validScripts.length) {
379354
onEditSelectedRow &&
380355
contextMenuOptions.push({
@@ -400,24 +375,13 @@ export default class BrowserCell extends Component {
400375
}
401376

402377
async executeScript(script) {
403-
try {
404-
const object = Parse.Object.extend(this.props.className).createWithoutData(
405-
this.props.objectId
406-
);
407-
const response = await Parse.Cloud.run(
408-
script.cloudCodeFunction,
409-
{ object: object.toPointer() },
410-
{ useMasterKey: true }
411-
);
412-
this.props.showNote(
413-
response ||
414-
`Ran script "${script.title}" on "${this.props.className}" object "${object.id}".`
415-
);
416-
this.props.onRefresh();
417-
} catch (e) {
418-
this.props.showNote(e.message, true);
419-
console.log(`Could not run ${script.title}: ${e}`);
420-
}
378+
await executeScript(
379+
script,
380+
this.props.className,
381+
this.props.objectId,
382+
this.props.showNote,
383+
this.props.onRefresh
384+
);
421385
}
422386

423387
toggleConfirmationDialog() {
@@ -590,26 +554,14 @@ export default class BrowserCell extends Component {
590554
let extras = null;
591555
if (this.state.showConfirmationDialog) {
592556
extras = (
593-
<Modal
594-
type={
595-
this.selectedScript.confirmationDialogStyle === 'critical'
596-
? Modal.Types.DANGER
597-
: Modal.Types.INFO
598-
}
599-
icon="warn-outline"
600-
title={this.selectedScript.title}
601-
confirmText="Continue"
602-
cancelText="Cancel"
557+
<ScriptConfirmationModal
558+
script={this.selectedScript}
603559
onCancel={() => this.toggleConfirmationDialog()}
604560
onConfirm={() => {
605-
this.executeSript(this.selectedScript);
561+
this.executeScript(this.selectedScript);
606562
this.toggleConfirmationDialog();
607563
}}
608-
>
609-
<div className={[labelStyles.label, labelStyles.text, styles.action].join(' ')}>
610-
{`Do you want to run script "${this.selectedScript.title}" on "${this.selectedScript.className}" object "${this.selectedScript.objectId}"?`}
611-
</div>
612-
</Modal>
564+
/>
613565
);
614566
}
615567

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright (c) 2016-present, Parse, LLC
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the license found in the LICENSE file in
6+
* the root directory of this source tree.
7+
*/
8+
import React from 'react';
9+
import Modal from 'components/Modal/Modal.react';
10+
import labelStyles from 'components/Label/Label.scss';
11+
import browserCellStyles from 'components/BrowserCell/BrowserCell.scss';
12+
13+
/**
14+
* Confirmation dialog for executing scripts
15+
*/
16+
export default function ScriptConfirmationModal({ script, onConfirm, onCancel }) {
17+
if (!script) {
18+
return null;
19+
}
20+
21+
return (
22+
<Modal
23+
type={script.confirmationDialogStyle === 'critical' ? Modal.Types.DANGER : Modal.Types.INFO}
24+
icon="warn-outline"
25+
title={script.title}
26+
confirmText="Continue"
27+
cancelText="Cancel"
28+
onCancel={onCancel}
29+
onConfirm={onConfirm}
30+
>
31+
<div className={[labelStyles.label, labelStyles.text, browserCellStyles.action].join(' ')}>
32+
{`Do you want to run script "${script.title}" on "${script.className}" object "${script.objectId}"?`}
33+
</div>
34+
</Modal>
35+
);
36+
}

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import copy from 'copy-to-clipboard';
1010
import BrowserTable from 'dashboard/Data/Browser/BrowserTable.react';
1111
import BrowserToolbar from 'dashboard/Data/Browser/BrowserToolbar.react';
1212
import * as ColumnPreferences from 'lib/ColumnPreferences';
13+
import { CurrentApp } from 'context/currentApp';
1314
import { dateStringUTC } from 'lib/DateUtils';
1415
import getFileName from 'lib/getFileName';
16+
import { getValidScripts, executeScript } from '../../../lib/ScriptUtils';
1517
import Parse from 'parse';
1618
import React from 'react';
1719
import { ResizableBox } from 'react-resizable';
20+
import ScriptConfirmationModal from '../../../components/ScriptConfirmationModal/ScriptConfirmationModal.react';
1821
import styles from './Databrowser.scss';
1922

2023
import AggregationPanel from '../../../components/AggregationPanel/AggregationPanel';
@@ -76,6 +79,8 @@ function formatValueForCopy(value, type) {
7679
* and the keyboard interactions for the data table.
7780
*/
7881
export default class DataBrowser extends React.Component {
82+
static contextType = CurrentApp;
83+
7984
constructor(props) {
8085
super(props);
8186

@@ -141,6 +146,11 @@ export default class DataBrowser extends React.Component {
141146
multiPanelData: {}, // Object mapping objectId to panel data
142147
_objectsToFetch: [], // Temporary field for async fetch handling
143148
loadingObjectIds: new Set(),
149+
showScriptConfirmationDialog: false,
150+
selectedScript: null,
151+
contextMenuX: null,
152+
contextMenuY: null,
153+
contextMenuItems: null,
144154
};
145155

146156
this.handleResizeDiv = this.handleResizeDiv.bind(this);
@@ -172,6 +182,7 @@ export default class DataBrowser extends React.Component {
172182
this.addPanel = this.addPanel.bind(this);
173183
this.removePanel = this.removePanel.bind(this);
174184
this.handlePanelScroll = this.handlePanelScroll.bind(this);
185+
this.handlePanelHeaderContextMenu = this.handlePanelHeaderContextMenu.bind(this);
175186
this.handleWrapperWheel = this.handleWrapperWheel.bind(this);
176187
this.saveOrderTimeout = null;
177188
this.aggregationPanelRef = React.createRef();
@@ -962,6 +973,51 @@ export default class DataBrowser extends React.Component {
962973
this.setState({ contextMenuX, contextMenuY, contextMenuItems });
963974
}
964975

976+
handlePanelHeaderContextMenu(event, objectId) {
977+
const { scripts = [] } = this.context || {};
978+
const className = this.props.className;
979+
const field = 'objectId';
980+
981+
const { validScripts, validator } = getValidScripts(scripts, className, field);
982+
983+
const menuItems = [];
984+
985+
// Add Scripts menu if there are valid scripts
986+
if (validScripts.length && this.props.onEditSelectedRow) {
987+
menuItems.push({
988+
text: 'Scripts',
989+
items: validScripts.map(script => {
990+
return {
991+
text: script.title,
992+
disabled: validator?.(objectId, field) === false,
993+
callback: () => {
994+
const selectedScript = { ...script, className, objectId };
995+
if (script.showConfirmationDialog) {
996+
this.setState({
997+
showScriptConfirmationDialog: true,
998+
selectedScript
999+
});
1000+
} else {
1001+
executeScript(
1002+
script,
1003+
className,
1004+
objectId,
1005+
this.props.showNote,
1006+
this.props.onRefresh
1007+
);
1008+
}
1009+
},
1010+
};
1011+
}),
1012+
});
1013+
}
1014+
1015+
const { pageX, pageY } = event;
1016+
if (menuItems.length) {
1017+
this.setContextMenu(pageX, pageY, menuItems);
1018+
}
1019+
}
1020+
9651021
freezeColumns(index) {
9661022
this.setState({ frozenColumnIndex: index });
9671023
}
@@ -1645,6 +1701,10 @@ export default class DataBrowser extends React.Component {
16451701
onMouseDown={(e) => {
16461702
e.preventDefault();
16471703
}}
1704+
onContextMenu={(e) => {
1705+
e.preventDefault();
1706+
this.handlePanelHeaderContextMenu(e, objectId);
1707+
}}
16481708
>
16491709
<input
16501710
type="checkbox"
@@ -1746,6 +1806,22 @@ export default class DataBrowser extends React.Component {
17461806
items={this.state.contextMenuItems}
17471807
/>
17481808
)}
1809+
{this.state.showScriptConfirmationDialog && (
1810+
<ScriptConfirmationModal
1811+
script={this.state.selectedScript}
1812+
onCancel={() => this.setState({ showScriptConfirmationDialog: false, selectedScript: null })}
1813+
onConfirm={() => {
1814+
executeScript(
1815+
this.state.selectedScript,
1816+
this.state.selectedScript.className,
1817+
this.state.selectedScript.objectId,
1818+
this.props.showNote,
1819+
this.props.onRefresh
1820+
);
1821+
this.setState({ showScriptConfirmationDialog: false, selectedScript: null });
1822+
}}
1823+
/>
1824+
)}
17491825
</div>
17501826
);
17511827
}

src/lib/ScriptUtils.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright (c) 2016-present, Parse, LLC
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the license found in the LICENSE file in
6+
* the root directory of this source tree.
7+
*/
8+
import Parse from 'parse';
9+
10+
/**
11+
* Filters scripts to only those valid for the given className and field
12+
* @param {Array} scripts - Array of script configurations
13+
* @param {string} className - The Parse class name
14+
* @param {string} field - The field name
15+
* @returns {Object} - { validScripts: Array, validator: Function|null }
16+
*/
17+
export function getValidScripts(scripts, className, field) {
18+
let validator = null;
19+
const validScripts = (scripts || []).filter(script => {
20+
if (script.classes?.includes(className)) {
21+
return true;
22+
}
23+
for (const scriptClass of script?.classes || []) {
24+
if (scriptClass?.name !== className) {
25+
continue;
26+
}
27+
const fields = scriptClass?.fields || [];
28+
if (scriptClass?.fields.includes(field) || scriptClass?.fields.includes('*')) {
29+
return true;
30+
}
31+
for (const currentField of fields) {
32+
if (Object.prototype.toString.call(currentField) === '[object Object]') {
33+
if (currentField.name === field) {
34+
if (typeof currentField.validator === 'string') {
35+
// SAFETY: eval() is used here on validator strings from trusted admin-controlled
36+
// dashboard configuration only (not user input). These validators are used solely
37+
// for UI validation logic to enable/disable script menu items. This is an accepted
38+
// tradeoff in this trusted admin context. If requirements change, consider replacing
39+
// with Function constructor or a safer expression parser.
40+
validator = eval(currentField.validator);
41+
} else {
42+
validator = currentField.validator;
43+
}
44+
return true;
45+
}
46+
}
47+
}
48+
}
49+
return false;
50+
});
51+
52+
return { validScripts, validator };
53+
}
54+
55+
/**
56+
* Executes a Parse Cloud Code script
57+
* @param {Object} script - The script configuration
58+
* @param {string} className - The Parse class name
59+
* @param {string} objectId - The object ID
60+
* @param {Function} showNote - Callback to show notification
61+
* @param {Function} onRefresh - Callback to refresh data
62+
*/
63+
export async function executeScript(script, className, objectId, showNote, onRefresh) {
64+
try {
65+
const object = Parse.Object.extend(className).createWithoutData(objectId);
66+
const response = await Parse.Cloud.run(
67+
script.cloudCodeFunction,
68+
{ object: object.toPointer() },
69+
{ useMasterKey: true }
70+
);
71+
showNote?.(
72+
response || `Ran script "${script.title}" on "${className}" object "${object.id}".`
73+
);
74+
onRefresh?.();
75+
} catch (e) {
76+
showNote?.(e.message, true);
77+
console.error(`Could not run ${script.title}:`, e);
78+
}
79+
}

0 commit comments

Comments
 (0)