diff --git a/packages/form-js-editor/src/features/properties-panel/entries/JSFunctionEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/JSFunctionEntry.js index ccd86fee3..e1f987060 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/JSFunctionEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/JSFunctionEntry.js @@ -1,4 +1,4 @@ -import { FeelEntry, isFeelEntryEdited, TextAreaEntry, isTextAreaEntryEdited } from '@bpmn-io/properties-panel'; +import { FeelEntry, isFeelEntryEdited, TextAreaEntry, isTextAreaEntryEdited, ToggleSwitchEntry, isToggleSwitchEntryEdited } from '@bpmn-io/properties-panel'; import { get } from 'min-dash'; import { useService, useVariables } from '../hooks'; @@ -12,7 +12,7 @@ export function JSFunctionEntry(props) { const entries = [ { id: 'variable-mappings', - component: VariableMappings, + component: FunctionParameters, editField: editField, field: field, isEdited: isFeelEntryEdited, @@ -25,13 +25,21 @@ export function JSFunctionEntry(props) { field: field, isEdited: isTextAreaEntryEdited, isDefaultVisible: (field) => field.type === 'jsfunc' + }, + { + id: 'on-load-only', + component: OnLoadOnlyEntry, + editField: editField, + field: field, + isEdited: isToggleSwitchEntryEdited, + isDefaultVisible: (field) => field.type === 'jsfunc' } ]; return entries; } -function VariableMappings(props) { +function FunctionParameters(props) { const { editField, field, @@ -42,7 +50,7 @@ function VariableMappings(props) { const variables = useVariables().map(name => ({ name })); - const path = [ 'variableMappings' ]; + const path = [ 'functionParameters' ]; const getValue = () => { return get(field, path, ''); @@ -52,13 +60,23 @@ function VariableMappings(props) { return editField(field, path, value || ''); }; + const tooltip =
+ Functions parameters should be described as an object, e.g.: +
{`{
+      name: user.name,
+      age: user.age
+    }`}
+
; + return FeelEntry({ debounce, feel: 'required', element: field, getValue, id, - label: 'Variable mappings', + label: 'Function parameters', + tooltip, + description: 'Define the parameters to pass to the javascript context.', setValue, variables }); @@ -87,8 +105,35 @@ function FunctionDefinition(props) { debounce, element: field, getValue, + description: 'Access function parameters via `data`, set results with `setValue`, and register cleanup functions with `onCleanup`.', + id, + label: 'Javascript code', + setValue + }); +} + +function OnLoadOnlyEntry(props) { + const { + editField, + field, + id + } = props; + + const path = [ 'onLoadOnly' ]; + + const getValue = () => { + return !!get(field, path, false); + }; + + const setValue = (value) => { + editField(field, path, value); + }; + + return ToggleSwitchEntry({ + element: field, id, - label: 'Function', + label: 'Execute on load only', + getValue, setValue }); } diff --git a/packages/form-js-viewer/src/render/components/form-fields/JSFunctionField.js b/packages/form-js-viewer/src/render/components/form-fields/JSFunctionField.js index 651249273..ec31603b8 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/JSFunctionField.js +++ b/packages/form-js-viewer/src/render/components/form-fields/JSFunctionField.js @@ -1,26 +1,27 @@ -import { useCallback, useEffect } from 'preact/hooks'; -import { useExpressionEvaluation, useDeepCompareMemoize } from '../../hooks'; +import { useCallback, useEffect, useState } from 'preact/hooks'; +import { useExpressionEvaluation, useDeepCompareMemoize, usePrevious } from '../../hooks'; import { isObject } from 'min-dash'; const type = 'jsfunc'; export function JSFunctionField(props) { const { field, onChange } = props; - const { jsFunction, variableMappings } = field; + const { jsFunction, functionParameters, onLoadOnly } = field; - const data = useExpressionEvaluation(variableMappings); - const dataMemo = useDeepCompareMemoize(data); + const [ loadLatch, setLoadLatch ] = useState(false); - const evaluateExpression = useCallback(() => { - try { + const paramsEval = useExpressionEvaluation(functionParameters); + const params = useDeepCompareMemoize(isObject(paramsEval) ? paramsEval : {}); - // return early if no dependency data - if (!isObject(dataMemo) || !Object.keys(dataMemo).length) { - return; - } + const functionMemo = useCallback((params) => { + + const cleanupCallbacks = []; - const func = new Function('data', 'setResult', jsFunction); - func(dataMemo, value => onChange({ field, value })); + try { + + setLoadLatch(true); + const func = new Function('data', 'setValue', 'onCleanup', jsFunction); + func(params, value => onChange({ field, value }), callback => cleanupCallbacks.push(callback)); } catch (error) { @@ -32,11 +33,34 @@ export function JSFunctionField(props) { console.error('Error evaluating expression:', error); onChange({ field, value: null }); } - }, [ jsFunction, dataMemo, field, onChange ]); + + return () => { + cleanupCallbacks.forEach(fn => fn()); + }; + + }, [ jsFunction, field, onChange ]); + + const previousFunctionMemo = usePrevious(functionMemo); + const previousParams = usePrevious(params); useEffect(() => { - evaluateExpression(); - }, [ evaluateExpression ]); + + // reset load latch + if (!onLoadOnly && loadLatch) { + setLoadLatch(false); + } + + const functionChanged = previousFunctionMemo !== functionMemo; + const paramsChanged = previousParams !== params; + const alreadyLoaded = onLoadOnly && loadLatch; + + const shouldExecute = functionChanged || paramsChanged && !alreadyLoaded; + + if (shouldExecute) { + return functionMemo(params); + } + + }, [ previousFunctionMemo, functionMemo, previousParams, params, loadLatch, onLoadOnly ]); return null; } @@ -48,6 +72,8 @@ JSFunctionField.config = { keyed: true, escapeGridRender: true, create: (options = {}) => ({ + jsFunction: 'setValue(data.value)', + functionParameters: '={\n value: 42\n}', ...options, }) };