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,