diff --git a/packages/form-js-editor/src/features/properties-panel/entries/ConditionEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/ConditionEntry.js index ee6331f2e..4c7f70649 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/ConditionEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/ConditionEntry.js @@ -50,7 +50,7 @@ function Condition(props) { let description = 'Condition under which the field is hidden'; // special case for expression fields which do not render - if (field.type === 'expression') { + if ([ 'expression', 'script' ].includes(field.type)) { label = 'Deactivate if'; description = 'Condition under which the field is deactivated'; } 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 new file mode 100644 index 000000000..0169c2af8 --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/JSFunctionEntry.js @@ -0,0 +1,139 @@ +import { FeelEntry, isFeelEntryEdited, TextAreaEntry, isTextAreaEntryEdited, ToggleSwitchEntry, isToggleSwitchEntryEdited } from '@bpmn-io/properties-panel'; +import { get } from 'min-dash'; + +import { useService, useVariables } from '../hooks'; + +export function JSFunctionEntry(props) { + const { + editField, + field + } = props; + + const entries = [ + { + id: 'variable-mappings', + component: FunctionParameters, + editField: editField, + field: field, + isEdited: isFeelEntryEdited, + isDefaultVisible: (field) => field.type === 'script' + }, + { + id: 'function', + component: FunctionDefinition, + editField: editField, + field: field, + isEdited: isTextAreaEntryEdited, + isDefaultVisible: (field) => field.type === 'script' + }, + { + id: 'on-load-only', + component: OnLoadOnlyEntry, + editField: editField, + field: field, + isEdited: isToggleSwitchEntryEdited, + isDefaultVisible: (field) => field.type === 'script' + } + ]; + + return entries; +} + +function FunctionParameters(props) { + const { + editField, + field, + id + } = props; + + const debounce = useService('debounce'); + + const variables = useVariables().map(name => ({ name })); + + const path = [ 'functionParameters' ]; + + const getValue = () => { + return get(field, path, ''); + }; + + const setValue = (value) => { + 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: 'Function parameters', + tooltip, + description: 'Define the parameters to pass to the javascript context.', + setValue, + variables + }); +} + +function FunctionDefinition(props) { + const { + editField, + field, + id + } = props; + + const debounce = useService('debounce'); + + const path = [ 'jsFunction' ]; + + const getValue = () => { + return get(field, path, ''); + }; + + const setValue = (value) => { + return editField(field, path, value || ''); + }; + + return TextAreaEntry({ + 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: 'Execute on load only', + getValue, + setValue + }); +} diff --git a/packages/form-js-editor/src/features/properties-panel/entries/index.js b/packages/form-js-editor/src/features/properties-panel/entries/index.js index bb6c79066..1a243a84f 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/index.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/index.js @@ -14,6 +14,7 @@ export { IFrameUrlEntry } from './IFrameUrlEntry'; export { ImageSourceEntry } from './ImageSourceEntry'; export { TextEntry } from './TextEntry'; export { HtmlEntry } from './HtmlEntry'; +export { JSFunctionEntry } from './JSFunctionEntry'; export { HeightEntry } from './HeightEntry'; export { NumberEntries } from './NumberEntries'; export { ExpressionFieldEntries } from './ExpressionFieldEntries'; diff --git a/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js b/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js index 79e497715..7f2e9621b 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/GeneralGroup.js @@ -19,6 +19,7 @@ import { HeightEntry, NumberEntries, ExpressionFieldEntries, + JSFunctionEntry, DateTimeEntry, TableDataSourceEntry, PaginationEntry, @@ -45,6 +46,7 @@ export function GeneralGroup(field, editField, getService) { ...HeightEntry({ field, editField }), ...NumberEntries({ field, editField }), ...ExpressionFieldEntries({ field, editField }), + ...JSFunctionEntry({ field, editField }), ...ImageSourceEntry({ field, editField }), ...AltTextEntry({ field, editField }), ...SelectEntries({ field, editField }), diff --git a/packages/form-js-editor/src/render/components/editor-form-fields/EditorJSFunctionField.js b/packages/form-js-editor/src/render/components/editor-form-fields/EditorJSFunctionField.js new file mode 100644 index 000000000..ee45e0834 --- /dev/null +++ b/packages/form-js-editor/src/render/components/editor-form-fields/EditorJSFunctionField.js @@ -0,0 +1,30 @@ +import { JSFunctionField, iconsByType } from '@bpmn-io/form-js-viewer'; +import { editorFormFieldClasses } from '../Util'; + +const type = 'script'; + +export function EditorJSFunctionField(props) { + const { field } = props; + const { jsFunction = '' } = field; + + const Icon = iconsByType(type); + + let placeholderContent = 'JS function is empty'; + + if (jsFunction.trim()) { + placeholderContent = 'JS function'; + } + + return ( +
+
+ {placeholderContent} +
+
+ ); +} + +EditorJSFunctionField.config = { + ...JSFunctionField.config, + escapeGridRender: false +}; diff --git a/packages/form-js-editor/src/render/components/editor-form-fields/index.js b/packages/form-js-editor/src/render/components/editor-form-fields/index.js index bbd17c9e7..8db5ab652 100644 --- a/packages/form-js-editor/src/render/components/editor-form-fields/index.js +++ b/packages/form-js-editor/src/render/components/editor-form-fields/index.js @@ -3,11 +3,13 @@ import { EditorText } from './EditorText'; import { EditorHtml } from './EditorHtml'; import { EditorTable } from './EditorTable'; import { EditorExpressionField } from './EditorExpressionField'; +import { EditorJSFunctionField } from './EditorJSFunctionField'; export const editorFormFields = [ EditorIFrame, EditorText, EditorHtml, EditorTable, - EditorExpressionField + EditorExpressionField, + EditorJSFunctionField ]; \ No newline at end of file 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 new file mode 100644 index 000000000..6d8c3d3fc --- /dev/null +++ b/packages/form-js-viewer/src/render/components/form-fields/JSFunctionField.js @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from 'preact/hooks'; +import { useExpressionEvaluation, useDeepCompareMemoize, usePrevious } from '../../hooks'; +import { isObject } from 'min-dash'; + +const type = 'script'; + +export function JSFunctionField(props) { + const { field, onChange } = props; + const { jsFunction, functionParameters, onLoadOnly } = field; + + const [ loadLatch, setLoadLatch ] = useState(false); + + const paramsEval = useExpressionEvaluation(functionParameters); + const params = useDeepCompareMemoize(isObject(paramsEval) ? paramsEval : {}); + + const functionMemo = useCallback((params) => { + + const cleanupCallbacks = []; + + try { + + setLoadLatch(true); + const func = new Function('data', 'setValue', 'onCleanup', jsFunction); + func(params, value => onChange({ field, value }), callback => cleanupCallbacks.push(callback)); + + } catch (error) { + + // invalid expression definition, may happen during editing + if (error instanceof SyntaxError) { + return; + } + + console.error('Error evaluating expression:', error); + onChange({ field, value: null }); + } + + return () => { + cleanupCallbacks.forEach(fn => fn()); + }; + + }, [ jsFunction, field, onChange ]); + + const previousFunctionMemo = usePrevious(functionMemo); + const previousParams = usePrevious(params); + + useEffect(() => { + + // 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; +} + +JSFunctionField.config = { + type, + label: 'JS Function', + group: 'basic-input', + keyed: true, + escapeGridRender: true, + create: (options = {}) => ({ + jsFunction: 'setValue(data.value)', + functionParameters: '={\n value: 42\n}', + ...options, + }) +}; diff --git a/packages/form-js-viewer/src/render/components/icons/JSFunction.svg b/packages/form-js-viewer/src/render/components/icons/JSFunction.svg new file mode 100644 index 000000000..b8376fa18 --- /dev/null +++ b/packages/form-js-viewer/src/render/components/icons/JSFunction.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/form-js-viewer/src/render/components/icons/index.js b/packages/form-js-viewer/src/render/components/icons/index.js index f10b67051..c6760d48b 100644 --- a/packages/form-js-viewer/src/render/components/icons/index.js +++ b/packages/form-js-viewer/src/render/components/icons/index.js @@ -13,6 +13,7 @@ import SpacerIcon from './Spacer.svg'; import DynamicListIcon from './DynamicList.svg'; import TextIcon from './Text.svg'; import HTMLIcon from './HTML.svg'; +import JsFunctionIcon from './JSFunction.svg'; import ExpressionFieldIcon from './ExpressionField.svg'; import TextfieldIcon from './Textfield.svg'; import TextareaIcon from './Textarea.svg'; @@ -41,6 +42,7 @@ export const iconsByType = (type) => { taglist: TaglistIcon, text: TextIcon, html: HTMLIcon, + script: JsFunctionIcon, textfield: TextfieldIcon, textarea: TextareaIcon, table: TableIcon, diff --git a/packages/form-js-viewer/src/render/components/index.js b/packages/form-js-viewer/src/render/components/index.js index feaeea903..edab8d83a 100644 --- a/packages/form-js-viewer/src/render/components/index.js +++ b/packages/form-js-viewer/src/render/components/index.js @@ -16,6 +16,7 @@ import { Taglist } from './form-fields/Taglist'; import { Text } from './form-fields/Text'; import { Html } from './form-fields/Html'; import { ExpressionField } from './form-fields/ExpressionField'; +import { JSFunctionField } from './form-fields/JSFunctionField'; import { Textfield } from './form-fields/Textfield'; import { Textarea } from './form-fields/Textarea'; import { Table } from './form-fields/Table'; @@ -46,6 +47,7 @@ export { Image, Numberfield, ExpressionField, + JSFunctionField, Radio, Select, Separator, @@ -72,6 +74,7 @@ export const formFields = [ Textfield, Textarea, ExpressionField, + JSFunctionField, Text, Image, Table,