diff --git a/package-lock.json b/package-lock.json
index d304aa9d4..c25ac82b3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3127,6 +3127,11 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/@jetbrains/websandbox": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@jetbrains/websandbox/-/websandbox-1.0.10.tgz",
+ "integrity": "sha512-D4rF56fRGIY43SOHUWgg2IgtBqzgSriu5PjYeEep5Nh/YAPpaaTOpiPG/JoE6oGssW3NGSYdbubsLjXyTeLiwg=="
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.1.1",
"dev": true,
@@ -5053,19 +5058,6 @@
"node": ">= 10.0.0"
}
},
- "node_modules/@lerna/create/node_modules/uuid": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
- "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
- "dev": true,
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
"node_modules/@lerna/create/node_modules/validate-npm-package-name": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
@@ -15685,19 +15677,6 @@
"node": ">= 10.0.0"
}
},
- "node_modules/lerna/node_modules/uuid": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
- "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
- "dev": true,
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
"node_modules/lerna/node_modules/validate-npm-package-name": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
@@ -21213,6 +21192,18 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/uuid": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/v8-compile-cache": {
"version": "2.3.0",
"dev": true,
@@ -21992,6 +21983,7 @@
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@carbon/grid": "^11.11.0",
+ "@jetbrains/websandbox": "^1.0.10",
"big.js": "^6.2.1",
"classnames": "^2.3.1",
"didi": "^10.0.1",
@@ -22003,7 +21995,8 @@
"lodash": "^4.5.0",
"marked": "^12.0.1",
"min-dash": "^4.2.1",
- "preact": "^10.5.14"
+ "preact": "^10.5.14",
+ "uuid": "^9.0.1"
}
},
"packages/form-js-viewer/node_modules/big.js": {
@@ -23567,6 +23560,7 @@
"version": "file:packages/form-js-viewer",
"requires": {
"@carbon/grid": "^11.11.0",
+ "@jetbrains/websandbox": "^1.0.10",
"big.js": "^6.2.1",
"classnames": "^2.3.1",
"didi": "^10.0.1",
@@ -23578,7 +23572,8 @@
"lodash": "^4.5.0",
"marked": "^12.0.1",
"min-dash": "^4.2.1",
- "preact": "^10.5.14"
+ "preact": "^10.5.14",
+ "uuid": "^9.0.1"
},
"dependencies": {
"big.js": {
@@ -24262,6 +24257,11 @@
"@sinclair/typebox": "^0.27.8"
}
},
+ "@jetbrains/websandbox": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@jetbrains/websandbox/-/websandbox-1.0.10.tgz",
+ "integrity": "sha512-D4rF56fRGIY43SOHUWgg2IgtBqzgSriu5PjYeEep5Nh/YAPpaaTOpiPG/JoE6oGssW3NGSYdbubsLjXyTeLiwg=="
+ },
"@jridgewell/gen-mapping": {
"version": "0.1.1",
"dev": true,
@@ -25705,12 +25705,6 @@
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true
},
- "uuid": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
- "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
- "dev": true
- },
"validate-npm-package-name": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
@@ -33089,12 +33083,6 @@
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true
},
- "uuid": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
- "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
- "dev": true
- },
"validate-npm-package-name": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
@@ -36863,6 +36851,11 @@
"version": "1.0.1",
"dev": true
},
+ "uuid": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
+ },
"v8-compile-cache": {
"version": "2.3.0",
"dev": true
diff --git a/packages/form-js-editor/src/features/palette/components/Palette.js b/packages/form-js-editor/src/features/palette/components/Palette.js
index 87ae0fe59..58fcd56f6 100644
--- a/packages/form-js-editor/src/features/palette/components/Palette.js
+++ b/packages/form-js-editor/src/features/palette/components/Palette.js
@@ -41,6 +41,10 @@ export const PALETTE_GROUPS = [
label: 'Containers',
id: 'container'
},
+ {
+ label: 'Advanced',
+ id: 'advanced'
+ },
{
label: 'Action',
id: 'action'
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..fe51e5b95
--- /dev/null
+++ b/packages/form-js-editor/src/features/properties-panel/entries/JSFunctionEntry.js
@@ -0,0 +1,148 @@
+import { FeelEntry, isFeelEntryEdited, TextAreaEntry, isTextAreaEntryEdited, SelectEntry, isSelectEntryEdited } from '@bpmn-io/properties-panel';
+import { get } from 'min-dash';
+import { simpleRangeIntegerEntryFactory } from './factories';
+
+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: 'computeOn',
+ component: JSFunctionComputeOn,
+ isEdited: isSelectEntryEdited,
+ editField,
+ field,
+ isDefaultVisible: (field) => field.type === 'script'
+ },
+ simpleRangeIntegerEntryFactory({
+ id: 'interval',
+ label: 'Time interval (ms)',
+ path: [ 'interval' ],
+ min: 100,
+ max: 60000,
+ props,
+ isDefaultVisible: (field) => field.type === 'script' && field.computeOn === 'interval'
+ })
+ ];
+
+ 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 sandbox.',
+ 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: 'Define the javascript function to execute.\nAccess the `data` object and use `setValue` to update the form state.',
+ id,
+ label: 'Javascript code',
+ setValue
+ });
+}
+
+function JSFunctionComputeOn(props) {
+ const { editField, field, id } = props;
+
+ const getValue = () => field.computeOn || '';
+
+ const setValue = (value) => {
+ editField(field, [ 'computeOn' ], value);
+ };
+
+ const getOptions = () => ([
+ { value: 'load', label: 'Form load' },
+ { value: 'change', label: 'Value change' },
+ { value: 'interval', label: 'Time interval' }
+ ]);
+
+ return SelectEntry({
+ id,
+ label: 'Compute on',
+ description: 'Define when to execute the function',
+ getValue,
+ setValue,
+ getOptions
+ });
+}
diff --git a/packages/form-js-editor/src/features/properties-panel/entries/KeyEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/KeyEntry.js
index 27c5adcd4..5f7b79917 100644
--- a/packages/form-js-editor/src/features/properties-panel/entries/KeyEntry.js
+++ b/packages/form-js-editor/src/features/properties-panel/entries/KeyEntry.js
@@ -6,6 +6,7 @@ import { useService } from '../hooks';
import { TextFieldEntry, isTextFieldEntryEdited } from '@bpmn-io/properties-panel';
import { useCallback } from 'preact/hooks';
+import { simpleBoolEntryFactory } from './factories';
export function KeyEntry(props) {
@@ -15,20 +16,32 @@ export function KeyEntry(props) {
getService
} = props;
- const entries = [];
-
- entries.push({
- id: 'key',
- component: Key,
- editField: editField,
- field: field,
- isEdited: isTextFieldEntryEdited,
- isDefaultVisible: (field) => {
- const formFields = getService('formFields');
- const { config } = formFields.get(field.type);
- return config.keyed;
- }
- });
+ const formFields = getService('formFields');
+
+ const entries = [
+ {
+ id: 'key',
+ component: Key,
+ editField: editField,
+ field: field,
+ isEdited: isTextFieldEntryEdited,
+ isDefaultVisible: (field) => {
+ const { config } = formFields.get(field.type);
+ return config.keyed;
+ }
+ },
+ simpleBoolEntryFactory({
+ id: 'doNotSubmit',
+ label: 'Do not submit',
+ tooltip: 'Prevents the data associated with this form element from being submitted by the form. Use for intermediate calculations.',
+ path: [ 'doNotSubmit' ],
+ props,
+ isDefaultVisible: (field) => {
+ const { config } = formFields.get(field.type);
+ return config.keyed && config.allowDoNotSubmit;
+ }
+ })
+ ];
return entries;
}
diff --git a/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleBoolEntryFactory.js b/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleBoolEntryFactory.js
index 8daef0835..9d720f73e 100644
--- a/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleBoolEntryFactory.js
+++ b/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleBoolEntryFactory.js
@@ -6,6 +6,7 @@ export function simpleBoolEntryFactory(options) {
id,
label,
description,
+ tooltip,
path,
props,
getValue,
@@ -25,6 +26,7 @@ export function simpleBoolEntryFactory(options) {
field,
editField,
description,
+ tooltip,
component: SimpleBoolComponent,
isEdited: isToggleSwitchEntryEdited,
isDefaultVisible,
diff --git a/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleRangeIntegerEntryFactory.js b/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleRangeIntegerEntryFactory.js
index f45b58704..833ff7798 100644
--- a/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleRangeIntegerEntryFactory.js
+++ b/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleRangeIntegerEntryFactory.js
@@ -13,7 +13,8 @@ export function simpleRangeIntegerEntryFactory(options) {
path,
props,
min,
- max
+ max,
+ isDefaultVisible
} = options;
const {
@@ -30,7 +31,8 @@ export function simpleRangeIntegerEntryFactory(options) {
min,
max,
component: SimpleRangeIntegerEntry,
- isEdited: isTextFieldEntryEdited
+ isEdited: isTextFieldEntryEdited,
+ isDefaultVisible
};
}
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/assets/form-js-base.css b/packages/form-js-viewer/assets/form-js-base.css
index 677f8ca6e..975956ca8 100644
--- a/packages/form-js-viewer/assets/form-js-base.css
+++ b/packages/form-js-viewer/assets/form-js-base.css
@@ -1203,6 +1203,10 @@
margin-right: 4px;
}
+.fjs-container .fjs-sandbox-iframe-container {
+ display: none;
+}
+
/**
* Flatpickr style adjustments
*/
diff --git a/packages/form-js-viewer/package.json b/packages/form-js-viewer/package.json
index 4c5457d58..b8fd2f95d 100644
--- a/packages/form-js-viewer/package.json
+++ b/packages/form-js-viewer/package.json
@@ -45,6 +45,7 @@
},
"dependencies": {
"@carbon/grid": "^11.11.0",
+ "@jetbrains/websandbox": "^1.0.10",
"big.js": "^6.2.1",
"classnames": "^2.3.1",
"didi": "^10.0.1",
@@ -56,7 +57,8 @@
"lodash": "^4.5.0",
"marked": "^12.0.1",
"min-dash": "^4.2.1",
- "preact": "^10.5.14"
+ "preact": "^10.5.14",
+ "uuid": "^9.0.1"
},
"sideEffects": [
"*.css"
diff --git a/packages/form-js-viewer/src/Form.js b/packages/form-js-viewer/src/Form.js
index 5fcaae12f..581d7f0b4 100644
--- a/packages/form-js-viewer/src/Form.js
+++ b/packages/form-js-viewer/src/Form.js
@@ -446,34 +446,36 @@ export class Form {
const pathRegistry = this.get('pathRegistry');
const formData = this._getState().data;
- function collectSubmitDataRecursively(submitData, formField, indexes) {
- const { disabled, type } = formField;
+ function collectSubmitDataRecursively(submitData, field, indexes) {
+ const { disabled, type } = field;
const { config: fieldConfig } = formFields.get(type);
// (1) Process keyed fields
- if (!disabled && fieldConfig.keyed) {
- const valuePath = pathRegistry.getValuePath(formField, { indexes });
+ const isSubmittedKeyedField = fieldConfig.keyed && !disabled && !(fieldConfig.allowDoNotSubmit && field.doNotSubmit);
+
+ if (isSubmittedKeyedField) {
+ const valuePath = pathRegistry.getValuePath(field, { indexes });
const value = get(formData, valuePath);
set(submitData, valuePath, value);
}
// (2) Process parents
- if (!Array.isArray(formField.components)) {
+ if (!Array.isArray(field.components)) {
return;
}
// (3a) Recurse repeatable parents both across the indexes of repetition and the children
- if (fieldConfig.repeatable && formField.isRepeating) {
+ if (fieldConfig.repeatable && field.isRepeating) {
- const valueData = get(formData, pathRegistry.getValuePath(formField, { indexes }));
+ const valueData = get(formData, pathRegistry.getValuePath(field, { indexes }));
if (!Array.isArray(valueData)) {
return;
}
valueData.forEach((_, index) => {
- formField.components.forEach((component) => {
- collectSubmitDataRecursively(submitData, component, { ...indexes, [formField.id]: index });
+ field.components.forEach((component) => {
+ collectSubmitDataRecursively(submitData, component, { ...indexes, [field.id]: index });
});
});
@@ -481,7 +483,7 @@ export class Form {
}
// (3b) Recurse non-repeatable parents only across the children
- formField.components.forEach((component) => collectSubmitDataRecursively(submitData, component, indexes));
+ field.components.forEach((component) => collectSubmitDataRecursively(submitData, component, indexes));
}
const workingSubmitData = {};
diff --git a/packages/form-js-viewer/src/render/components/form-fields/ExpressionField.js b/packages/form-js-viewer/src/render/components/form-fields/ExpressionField.js
index 5cd6d5321..43adf9f58 100644
--- a/packages/form-js-viewer/src/render/components/form-fields/ExpressionField.js
+++ b/packages/form-js-viewer/src/render/components/form-fields/ExpressionField.js
@@ -40,8 +40,9 @@ export function ExpressionField(props) {
ExpressionField.config = {
type,
label: 'Expression',
- group: 'basic-input',
+ group: 'advanced',
keyed: true,
+ allowDoNotSubmit: true,
escapeGridRender: true,
create: (options = {}) => ({
computeOn: 'change',
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..b671466c6
--- /dev/null
+++ b/packages/form-js-viewer/src/render/components/form-fields/JSFunctionField.js
@@ -0,0 +1,128 @@
+import Sandbox from 'websandbox';
+import { useCallback, useEffect, useState } from 'preact/hooks';
+import { useExpressionEvaluation, useDeepCompareMemoize, usePrevious } from '../../hooks';
+import { isObject } from 'min-dash';
+import { v4 as uuidv4 } from 'uuid';
+
+export function JSFunctionField(props) {
+ const { field, onChange } = props;
+ const {
+ jsFunction: functionDefinition,
+ functionParameters: paramsDefinition,
+ computeOn,
+ interval
+ } = field;
+
+ const [ sandbox, setSandbox ] = useState(null);
+ const [ hasRunLoad, setHasRunLoad ] = useState(false);
+ const [ iframeContainerId ] = useState(`fjs-sandbox-iframe-container_${uuidv4()}`);
+
+ const paramsEval = useExpressionEvaluation(paramsDefinition);
+ const params = useDeepCompareMemoize(isObject(paramsEval) ? paramsEval : {});
+
+ const clearValue = useCallback(() => onChange({ field, value: undefined }), [ field, onChange ]);
+
+ const safeSetValue = useCallback((value) => {
+
+ if (value !== undefined) {
+
+ // strip out functions and handle unserializeable objects
+ try {
+ value = JSON.parse(JSON.stringify(value));
+ onChange({ field, value });
+ } catch (e) {
+ clearValue();
+ }
+ }
+
+ }, [ field, onChange, clearValue ]);
+
+ useEffect(() => {
+ const hostAPI = {
+ setValue: safeSetValue,
+ error: (e) => {
+ clearValue();
+ }
+ };
+
+ // @ts-ignore
+ const _sandbox = Sandbox.create(hostAPI, {
+ frameContainer: `#${iframeContainerId}`,
+ frameClassName: 'fjs-sandbox-iframe'
+ });
+
+ const wrappedUserCode = `
+ const computeCallThisFunctionIfYouWantToCrashYourBrowser = (data) => {
+ try {
+ const setValue = Websandbox.connection.remote.setValue;
+ ${functionDefinition}
+ }
+ catch (e) {
+ Websandbox.connection.remote.error(e);
+ }
+ }
+
+ Websandbox.connection.setLocalApi({ compute: computeCallThisFunctionIfYouWantToCrashYourBrowser });
+ `;
+
+ _sandbox.promise.then((sandboxInstance) => {
+ sandboxInstance
+ .run(wrappedUserCode)
+ .catch(() => { onChange({ field, value: null }); })
+ .then(() => { setSandbox(sandboxInstance); setHasRunLoad(false); });
+ });
+
+ return () => {
+ _sandbox.destroy();
+ };
+ }, [ iframeContainerId, functionDefinition, onChange, field, paramsDefinition, computeOn, interval, safeSetValue, clearValue ]);
+
+ const prevParams = usePrevious(params);
+ const prevSandbox = usePrevious(sandbox);
+
+ useEffect(() => {
+
+ if (!sandbox || !sandbox.connection.remote.compute) {
+ return;
+ }
+
+ const runCompute = () => {
+ sandbox.connection.remote.compute(params)
+ .catch(clearValue)
+ .then(safeSetValue);
+ };
+
+ if (computeOn === 'load' && !hasRunLoad) {
+ runCompute();
+ setHasRunLoad(true);
+ }
+ else if (computeOn === 'change' && (params !== prevParams || sandbox !== prevSandbox)) {
+ runCompute();
+ }
+ else if (computeOn === 'interval') {
+ const intervalId = setInterval(runCompute, interval);
+ return () => clearInterval(intervalId);
+ }
+
+ }, [ params, prevParams, sandbox, prevSandbox, onChange, field, computeOn, hasRunLoad, interval, clearValue, safeSetValue ]);
+
+ return (
+
+ );
+}
+
+JSFunctionField.config = {
+ type: 'script',
+ label: 'JS Function',
+ group: 'advanced',
+ keyed: true,
+ allowDoNotSubmit: true,
+ escapeGridRender: true,
+ create: (options = {}) => ({
+ jsFunction: 'setValue(data.value)',
+ functionParameters: '={\n value: 42\n}',
+ computeOn: 'load',
+ interval: 1000,
+ ...options,
+ })
+};
\ No newline at end of file
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..65659169d
--- /dev/null
+++ b/packages/form-js-viewer/src/render/components/icons/JSFunction.svg
@@ -0,0 +1,3 @@
+
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,
diff --git a/packages/form-js-viewer/src/render/hooks/useFlushDebounce.js b/packages/form-js-viewer/src/render/hooks/useFlushDebounce.js
index 5fad3f2ea..247b9bbfd 100644
--- a/packages/form-js-viewer/src/render/hooks/useFlushDebounce.js
+++ b/packages/form-js-viewer/src/render/hooks/useFlushDebounce.js
@@ -1,4 +1,4 @@
-import { useCallback, useRef } from 'preact/hooks';
+import { useCallback, useEffect, useRef } from 'preact/hooks';
import { useService } from './useService';
export function useFlushDebounce(func) {
@@ -6,6 +6,7 @@ export function useFlushDebounce(func) {
const timeoutRef = useRef(null);
const lastArgsRef = useRef(null);
+ const form = useService('form');
const config = useService('config', false);
const debounce = config && config.debounce;
const shouldDebounce = debounce !== false && debounce !== 0;
@@ -35,14 +36,24 @@ export function useFlushDebounce(func) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
if (lastArgsRef.current !== null) {
func(...lastArgsRef.current);
lastArgsRef.current = null;
}
- timeoutRef.current = null;
}
}, [ func ]);
+ // ensures debounce flushing on unrelated form changes
+ useEffect(() => {
+ if (form.on) {
+ form.on('changed', flushFunc);
+ return () => {
+ form.off('changed', flushFunc);
+ };
+ }
+ }, [ form, flushFunc ]);
+
return [ debounceFunc, flushFunc ];
}