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 ]; }