From 2d797d6856aa49f27cb880ca45560b66139d109e Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 25 Mar 2024 05:51:48 +0100 Subject: [PATCH 01/11] feat: implement script component (viewer) Related to #1102 --- package-lock.json | 73 ++++---- .../form-js-viewer/assets/form-js-base.css | 4 + packages/form-js-viewer/package.json | 4 +- packages/form-js-viewer/rollup.config.js | 1 + packages/form-js-viewer/src/Form.js | 22 +-- .../components/form-fields/ExpressionField.js | 3 +- .../components/form-fields/JSFunctionField.js | 161 ++++++++++++++++++ .../render/components/icons/JSFunction.svg | 3 + .../src/render/components/icons/index.js | 2 + .../src/render/components/index.js | 3 + .../src/render/hooks/useFlushDebounce.js | 15 +- 11 files changed, 237 insertions(+), 54 deletions(-) create mode 100644 packages/form-js-viewer/src/render/components/form-fields/JSFunctionField.js create mode 100644 packages/form-js-viewer/src/render/components/icons/JSFunction.svg diff --git a/package-lock.json b/package-lock.json index 4e8f29b3b..98b59cf86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3129,6 +3129,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, @@ -5055,19 +5060,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", @@ -15688,19 +15680,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", @@ -21216,6 +21195,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, @@ -22005,6 +21996,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.2.2", @@ -22016,7 +22008,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": { @@ -23582,6 +23575,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.2.2", @@ -23593,7 +23587,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": { @@ -24277,6 +24272,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, @@ -25720,12 +25720,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", @@ -33106,12 +33100,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", @@ -36880,6 +36868,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-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 1c8222e73..281710f29 100644 --- a/packages/form-js-viewer/package.json +++ b/packages/form-js-viewer/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@carbon/grid": "^11.11.0", + "@jetbrains/websandbox": "^1.0.10", "big.js": "^6.2.1", "classnames": "^2.3.1", "didi": "^10.2.2", @@ -57,7 +58,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/rollup.config.js b/packages/form-js-viewer/rollup.config.js index 93ae40da4..3e4654155 100644 --- a/packages/form-js-viewer/rollup.config.js +++ b/packages/form-js-viewer/rollup.config.js @@ -58,6 +58,7 @@ export default [ 'flatpickr', 'marked', '@carbon/grid', + '@jetbrains/websandbox', 'feelers', 'dompurify' ], 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 085b4cebc..034be11c6 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 @@ -41,9 +41,10 @@ export function ExpressionField(props) { ExpressionField.config = { type, label: 'Expression', - group: 'basic-input', + group: 'advanced', keyed: true, emptyValue: null, + 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..3810620ef --- /dev/null +++ b/packages/form-js-viewer/src/render/components/form-fields/JSFunctionField.js @@ -0,0 +1,161 @@ +import Sandbox from '@jetbrains/websandbox'; +import { useCallback, useEffect, useRef, 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 iframeContainerRef = useRef(null); + + const paramsEval = useExpressionEvaluation(paramsDefinition); + const params = useDeepCompareMemoize(isObject(paramsEval) ? paramsEval : {}); + + const clearValue = useCallback(() => onChange({ field, value: undefined }), [ field, onChange ]); + + const sandboxError = useCallback((errorType, ...details) => { + + const baseError = `Sandbox error (${field.key}) - ${errorType}`; + + if (details.length) { + console.error(baseError, '-', ...details); + } else { + console.error(baseError); + } + + }, [ field.key ]); + + 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) { + sandboxError('Unparsable return value'); + clearValue(); + } + } + + }, [ onChange, field, sandboxError, clearValue ]); + + useEffect(() => { + + // (1) check for syntax validity of user code + try { + new Function(functionDefinition); + } catch (e) { + + if (e instanceof SyntaxError) { + sandboxError('Invalid syntax', e.message); + } + + return; + } + + // (2) create a new sandbox instance + const hostAPI = { + setValue: safeSetValue, + runtimeError: (e) => { + sandboxError('Runtime error', e.message); + clearValue(); + } + }; + + const wrappedUserCode = ` + const ___executeUserCode___ = (data) => { + try { + const setValue = Websandbox.connection.remote.setValue; + ${functionDefinition} + } + catch (e) { + Websandbox.connection.remote.runtimeError(e); + } + } + + Websandbox.connection.setLocalApi({ compute: ___executeUserCode___ }); + `; + + const _sandbox = Sandbox.create(hostAPI, { + frameContainer: `#${iframeContainerId}`, + frameClassName: 'fjs-sandbox-iframe' + }); + + const iframe = iframeContainerRef.current.querySelector('iframe'); + iframe.removeAttribute('allow'); + + // (3) run user code in sandbox + _sandbox.promise.then((sandboxInstance) => { + sandboxInstance + + // @ts-ignore + .run(wrappedUserCode) + .then(() => { setSandbox(sandboxInstance); setHasRunLoad(false); }); + }); + + return () => { + _sandbox.destroy(); + }; + }, [ iframeContainerId, functionDefinition, onChange, field, paramsDefinition, computeOn, interval, safeSetValue, clearValue, sandboxError ]); + + 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: 'change', + 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 9d93b65f3..6c1adbcb1 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 ]; } From 1065dbe7764ec12028aacead3686a9300aa018e1 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 25 Mar 2024 05:52:14 +0100 Subject: [PATCH 02/11] feat: implement script component (editor) Related to #1102 --- .../EditorJSFunctionField.js | 30 +++++++++++++++++++ .../components/editor-form-fields/index.js | 4 ++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/form-js-editor/src/render/components/editor-form-fields/EditorJSFunctionField.js 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 From 05a32833d006f583fe038da5217754fcc3258ad2 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 25 Mar 2024 05:53:17 +0100 Subject: [PATCH 03/11] chore: add advanced group to palette Related to #1102 --- .../form-js-editor/src/features/palette/components/Palette.js | 4 ++++ 1 file changed, 4 insertions(+) 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' From 557864724f15e43f899417c76b4e6bf0d9009536 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 25 Mar 2024 18:30:08 +0100 Subject: [PATCH 04/11] feat: implement script component properties panel Related to #1102 --- .../entries/ConditionEntry.js | 2 +- .../entries/DoNotSubmitEntry.js | 31 ++++ .../entries/JSFunctionEntry.js | 164 ++++++++++++++++++ .../properties-panel/entries/KeyEntry.js | 28 +-- .../factories/simpleBoolEntryFactory.js | 2 + .../simpleRangeIntegerEntryFactory.js | 6 +- .../properties-panel/entries/index.js | 2 + .../properties-panel/groups/GeneralGroup.js | 6 +- 8 files changed, 223 insertions(+), 18 deletions(-) create mode 100644 packages/form-js-editor/src/features/properties-panel/entries/DoNotSubmitEntry.js create mode 100644 packages/form-js-editor/src/features/properties-panel/entries/JSFunctionEntry.js 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/DoNotSubmitEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/DoNotSubmitEntry.js new file mode 100644 index 000000000..ee4bcf33b --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/DoNotSubmitEntry.js @@ -0,0 +1,31 @@ +import { simpleBoolEntryFactory } from './factories'; + +export function DoNotSubmitEntry(props) { + const { + field, + getService + } = props; + + const formFields = getService('formFields'); + + const fieldDescriptors = { + script: "function's", + expression: "expression's", + }; + + const entries = [ + simpleBoolEntryFactory({ + id: 'doNotSubmit', + label: `Do not submit the ${fieldDescriptors[field.type] || "field's"} result with the form submission`, + 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; +} \ No newline at end of file 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..86f13fd87 --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/JSFunctionEntry.js @@ -0,0 +1,164 @@ +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 function.', + 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, error) => { + if (error) { + return; + } + + return editField(field, path, value || ''); + }; + + const validate = (value) => { + + try { + new Function(value); + } catch (e) { + return `Invalid syntax: ${e.message}`; + } + + return null; + }; + + return TextAreaEntry({ + debounce, + element: field, + getValue, + validate, + 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 b91604052..f0f7bd3b6 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 @@ -7,7 +7,6 @@ import { useService } from '../hooks'; import { TextFieldEntry, isTextFieldEntryEdited } from '@bpmn-io/properties-panel'; import { useCallback } from 'preact/hooks'; - export function KeyEntry(props) { const { editField, @@ -15,20 +14,21 @@ 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; + } } - }); + ]; 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..673c7e5f4 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 @@ -6,6 +6,7 @@ export { DefaultValueEntry } from './DefaultValueEntry'; export { DisabledEntry } from './DisabledEntry'; export { IdEntry } from './IdEntry'; export { KeyEntry } from './KeyEntry'; +export { DoNotSubmitEntry } from './DoNotSubmitEntry'; export { PathEntry } from './PathEntry'; export { GroupAppearanceEntry } from './GroupAppearanceEntry'; export { LabelEntry } from './LabelEntry'; @@ -14,6 +15,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..eb278a450 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 @@ -9,6 +9,7 @@ import { IFrameHeightEntry, ImageSourceEntry, KeyEntry, + DoNotSubmitEntry, PathEntry, RepeatableEntry, LabelEntry, @@ -19,6 +20,7 @@ import { HeightEntry, NumberEntries, ExpressionFieldEntries, + JSFunctionEntry, DateTimeEntry, TableDataSourceEntry, PaginationEntry, @@ -45,6 +47,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 }), @@ -52,7 +55,8 @@ export function GeneralGroup(field, editField, getService) { ...ReadonlyEntry({ field, editField }), ...TableDataSourceEntry({ field, editField }), ...PaginationEntry({ field, editField }), - ...RowCountEntry({ field, editField }) + ...RowCountEntry({ field, editField }), + ...DoNotSubmitEntry({ field, editField, getService }), ]; if (entries.length === 0) { From de470a352f55b7112c48ab21f3443d8628c182b2 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Tue, 2 Apr 2024 08:04:11 +0200 Subject: [PATCH 05/11] feat: script component schema definition Related to #1102 --- .../src/defs/field-types/inputs.json | 3 +- .../defs/rules/rules-required-properties.json | 40 +++++++++++ packages/form-json-schema/src/defs/type.json | 3 +- .../test/fixtures/js-interval-no-interval.js | 43 ++++++++++++ .../test/fixtures/js-no-props.js | 67 +++++++++++++++++++ .../test/spec/validation.spec.js | 13 ++-- 6 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 packages/form-json-schema/test/fixtures/js-interval-no-interval.js create mode 100644 packages/form-json-schema/test/fixtures/js-no-props.js diff --git a/packages/form-json-schema/src/defs/field-types/inputs.json b/packages/form-json-schema/src/defs/field-types/inputs.json index 75148ef7d..483ef8779 100644 --- a/packages/form-json-schema/src/defs/field-types/inputs.json +++ b/packages/form-json-schema/src/defs/field-types/inputs.json @@ -11,7 +11,8 @@ "taglist", "textfield", "textarea", - "expression" + "expression", + "script" ] } }, diff --git a/packages/form-json-schema/src/defs/rules/rules-required-properties.json b/packages/form-json-schema/src/defs/rules/rules-required-properties.json index 08f2fd7e8..c1767ab97 100644 --- a/packages/form-json-schema/src/defs/rules/rules-required-properties.json +++ b/packages/form-json-schema/src/defs/rules/rules-required-properties.json @@ -49,6 +49,46 @@ "computeOn" ] } + }, + { + "if": { + "properties": { + "type": { + "const": "script" + } + }, + "required": [ + "type" + ] + }, + "then": { + "allOf": [ + { + "required": [ + "jsFunction", + "functionParameters", + "computeOn" + ] + }, + { + "if": { + "properties": { + "computeOn": { + "const": "interval" + } + }, + "required": [ + "computeOn" + ] + }, + "then": { + "required": [ + "interval" + ] + } + } + ] + } } ] } diff --git a/packages/form-json-schema/src/defs/type.json b/packages/form-json-schema/src/defs/type.json index edd87a18b..097ad54b4 100644 --- a/packages/form-json-schema/src/defs/type.json +++ b/packages/form-json-schema/src/defs/type.json @@ -22,6 +22,7 @@ "separator", "table", "iframe", - "expression" + "expression", + "script" ] } \ No newline at end of file diff --git a/packages/form-json-schema/test/fixtures/js-interval-no-interval.js b/packages/form-json-schema/test/fixtures/js-interval-no-interval.js new file mode 100644 index 000000000..7787120c3 --- /dev/null +++ b/packages/form-json-schema/test/fixtures/js-interval-no-interval.js @@ -0,0 +1,43 @@ +export const form = { + type: 'default', + 'components': [ + { + type: 'script', + key: 'myField', + jsFunction: 'return 42', + functionParameters: '={\n value: 42\n}', + computeOn: 'interval' + } + ] +}; + +export const errors = [ + { + instancePath: '/components/0', + keyword: 'required', + message: "must have required property 'interval'", + params: { + missingProperty: 'interval' + }, + schemaPath: '#/properties/components/items/allOf/0/allOf/3/then/allOf/1/then/required' + }, + { + instancePath: '/components/0', + keyword: 'if', + message: 'must match "then" schema', + params: { + failingKeyword: 'then' + }, + schemaPath: '#/properties/components/items/allOf/0/allOf/3/then/allOf/1/if' + }, + { + instancePath: '/components/0', + keyword: 'if', + message: 'must match "then" schema', + params: { + failingKeyword: 'then' + }, + schemaPath: '#/properties/components/items/allOf/0/allOf/3/if' + } +]; + diff --git a/packages/form-json-schema/test/fixtures/js-no-props.js b/packages/form-json-schema/test/fixtures/js-no-props.js new file mode 100644 index 000000000..669361edf --- /dev/null +++ b/packages/form-json-schema/test/fixtures/js-no-props.js @@ -0,0 +1,67 @@ +export const form = { + type: 'default', + 'components': [ + { + type: 'script', + } + ] +}; + +export const errors = [ + { + instancePath: '/components/0', + keyword: 'required', + message: "must have required property 'key'", + params: { + missingProperty: 'key' + }, + schemaPath: '#/properties/components/items/allOf/0/allOf/0/then/required' + }, + { + instancePath: '/components/0', + keyword: 'if', + message: 'must match "then" schema', + params: { + failingKeyword: 'then' + }, + schemaPath: '#/properties/components/items/allOf/0/allOf/0/if' + }, + { + instancePath: '/components/0', + keyword: 'required', + message: "must have required property 'jsFunction'", + params: { + missingProperty: 'jsFunction' + }, + schemaPath: '#/properties/components/items/allOf/0/allOf/3/then/allOf/0/required' + }, + { + instancePath: '/components/0', + keyword: 'required', + message: "must have required property 'functionParameters'", + params: { + missingProperty: 'functionParameters' + }, + schemaPath: '#/properties/components/items/allOf/0/allOf/3/then/allOf/0/required' + }, + { + instancePath: '/components/0', + keyword: 'required', + message: "must have required property 'computeOn'", + params: { + missingProperty: 'computeOn' + }, + schemaPath: '#/properties/components/items/allOf/0/allOf/3/then/allOf/0/required' + }, + { + instancePath: '/components/0', + keyword: 'if', + message: 'must match "then" schema', + params: { + failingKeyword: 'then' + }, + schemaPath: '#/properties/components/items/allOf/0/allOf/3/if' + } +]; + + diff --git a/packages/form-json-schema/test/spec/validation.spec.js b/packages/form-json-schema/test/spec/validation.spec.js index 58d585907..7bae65ab0 100644 --- a/packages/form-json-schema/test/spec/validation.spec.js +++ b/packages/form-json-schema/test/spec/validation.spec.js @@ -110,9 +110,17 @@ describe('validation', function() { describe('rules - required properties', function() { - testForm('no-key'); + + testForm('expression-field-expression-required'); + + + testForm('js-interval-no-interval'); + + + testForm('js-no-props'); + }); @@ -136,9 +144,6 @@ describe('validation', function() { testForm('disabled-not-allowed'); - testForm('expression-field-expression-required'); - - testForm('action-not-allowed'); From dcbe010199441027210cbe2c01a63f67c30697c7 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 25 Mar 2024 18:32:13 +0100 Subject: [PATCH 06/11] chore: script component tests Related to #1102 --- .../properties-panel/PropertiesPanel.spec.js | 38 +++- packages/form-js-editor/test/spec/form.json | 9 + .../form-js-playground/test/spec/form.json | 8 + .../form-fields/JSFunctionField.spec.js | 163 ++++++++++++++++++ 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js diff --git a/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanel.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanel.spec.js index f9157d738..77a8074e6 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanel.spec.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanel.spec.js @@ -3538,7 +3538,43 @@ describe('properties panel', function() { 'General': [ 'Key', 'Target value', - 'Compute on' + 'Compute on', + "Do not submit the expression's result with the form submission" + ], + 'Condition': [ + 'Deactivate if' + ], + 'Layout': [ + 'Columns' + ], + 'Custom properties': [] + }); + + }); + + }); + + + describe('js function field', function() { + + it('entries', function() { + + // given + const field = schema.components.find(({ type }) => type === 'script'); + + bootstrapPropertiesPanel({ + container, + field + }); + + // then + expectPanelStructure(container, { + 'General': [ + 'Key', + 'Function parameters', + 'Javascript code', + 'Compute on', + "Do not submit the function's result with the form submission", ], 'Condition': [ 'Deactivate if' diff --git a/packages/form-js-editor/test/spec/form.json b/packages/form-js-editor/test/spec/form.json index 62589717b..5fb0f3908 100644 --- a/packages/form-js-editor/test/spec/form.json +++ b/packages/form-js-editor/test/spec/form.json @@ -3,6 +3,15 @@ "id": "Form_1", "type": "default", "components": [ + { + "id": "Script_1", + "type": "script", + "key": "script", + "jsFunction": "return [\"reading\", \"swimming\", \"running\"];", + "functionParameters": "={}", + "computeOn": "interval", + "interval": 1000 + }, { "id": "ExpressionField_1", "type": "expression", diff --git a/packages/form-js-playground/test/spec/form.json b/packages/form-js-playground/test/spec/form.json index b507bed89..3daeef092 100644 --- a/packages/form-js-playground/test/spec/form.json +++ b/packages/form-js-playground/test/spec/form.json @@ -1,6 +1,14 @@ { "$schema": "../../../form-json-schema/resources/schema.json", "components": [ + { + "type": "script", + "key": "otherHobbies", + "jsFunction": "return [\"reading\", \"swimming\", \"running\"];", + "functionParameters": "={}", + "computeOn": "change", + "interval": 1000 + }, { "type": "expression", "key": "expressionResult", diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js new file mode 100644 index 000000000..e15ca6398 --- /dev/null +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js @@ -0,0 +1,163 @@ +import { + render +} from '@testing-library/preact/pure'; + +import { JSFunctionField } from '../../../../../src/render/components/form-fields/JSFunctionField'; + +import { MockFormContext } from '../helper'; + +import { act } from 'preact/test-utils'; + +import { + createFormContainer +} from '../../../../TestHelper'; + +let container; + +describe('JSFunctionField', function() { + + beforeEach(function() { + container = createFormContainer(); + }); + + + afterEach(function() { + container.remove(); + }); + + + it('should evaluate with setValue', async function() { + + // given + const onChangeSpy = sinon.spy(); + const field = defaultField; + const passedData = { value : 42 }; + + const services = { + expressionLanguage: { + isExpression: () => true, + evaluate: () => { + return passedData; + } + } + }; + + // when + act(() => { + createJSFunctionField({ field, onChange: onChangeSpy, services }); + }); + + // wait for the iframe to compute the expression and pass it back + await new Promise(r => setTimeout(r, 100)).then(() => { + + // then + expect(onChangeSpy).to.be.calledOnce; + expect(onChangeSpy).to.be.calledWith({ field, value: 42 }); + }); + + }); + + + it('should evaluate with return', async function() { + + // given + const onChangeSpy = sinon.spy(); + const field = { + ...defaultField, + jsFunction: 'return data.value' + }; + const passedData = { value : 42 }; + + const services = { + expressionLanguage: { + isExpression: () => true, + evaluate: () => { + return passedData; + } + } + }; + + // when + act(() => { + createJSFunctionField({ field, onChange: onChangeSpy, services }); + }); + + // wait for the iframe to compute the expression and pass it back + await new Promise(r => setTimeout(r, 100)).then(() => { + + // then + expect(onChangeSpy).to.be.calledOnce; + expect(onChangeSpy).to.be.calledWith({ field, value: 42 }); + }); + + }); + + + it('should evaluate multiple times when using interval', async function() { + + // given + const onChangeSpy = sinon.spy(); + const field = { + ...defaultField, + computeOn: 'interval', + interval: 100 + }; + const passedData = { value : 42 }; + + const services = { + expressionLanguage: { + isExpression: () => true, + evaluate: () => { + return passedData; + } + } + }; + + // when + act(() => { + createJSFunctionField({ field, onChange: onChangeSpy, services }); + }); + + // wait for the iframe to compute the expression and pass it back + await new Promise(r => setTimeout(r, 500)).then(() => { + + // then + + // deliberately underestimating the number of calls to account for potential timing issues + expect(onChangeSpy.callCount > 3).to.be.true; + expect(onChangeSpy).to.be.calledWith({ field, value: 42 }); + }); + + + }); + +}); + +// helpers ////////// + +const defaultField = { + type: 'script', + key: 'jsfunction', + jsFunction: 'setValue(data.value)', + computeOn: 'load' +}; + +function createJSFunctionField({ services, ...restOptions } = {}) { + const options = { + field: defaultField, + onChange: () => {}, + ...restOptions + }; + + return render( + + + , { + container: options.container || container.querySelector('.fjs-form') + } + ); +} \ No newline at end of file From 4f833f4bf09cef3d71f3ae72c4dbf2d77feb7a2d Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 8 Apr 2024 09:12:42 +0200 Subject: [PATCH 07/11] wip review comments --- .../editor-form-fields/EditorJSFunctionField.js | 4 ++-- .../render/components/form-fields/JSFunctionField.js | 8 ++++---- .../src/render/components/icons/JSFunction.svg | 4 +--- .../components/form-fields/JSFunctionField.spec.js | 11 +++++------ 4 files changed, 12 insertions(+), 15 deletions(-) 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 index ee45e0834..faa2065fc 100644 --- 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 @@ -5,14 +5,14 @@ const type = 'script'; export function EditorJSFunctionField(props) { const { field } = props; - const { jsFunction = '' } = field; + const { jsFunction = '', key } = field; const Icon = iconsByType(type); let placeholderContent = 'JS function is empty'; if (jsFunction.trim()) { - placeholderContent = 'JS function'; + placeholderContent = `JS function for '${key}'`; } return ( 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 3810620ef..41eb37d70 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 @@ -43,7 +43,7 @@ export function JSFunctionField(props) { try { value = JSON.parse(JSON.stringify(value)); onChange({ field, value }); - } catch (e) { + } catch { sandboxError('Unparsable return value'); clearValue(); } @@ -96,7 +96,7 @@ export function JSFunctionField(props) { const iframe = iframeContainerRef.current.querySelector('iframe'); iframe.removeAttribute('allow'); - // (3) run user code in sandbox + // (3) load user code in sandbox _sandbox.promise.then((sandboxInstance) => { sandboxInstance @@ -108,7 +108,7 @@ export function JSFunctionField(props) { return () => { _sandbox.destroy(); }; - }, [ iframeContainerId, functionDefinition, onChange, field, paramsDefinition, computeOn, interval, safeSetValue, clearValue, sandboxError ]); + }, [ clearValue, functionDefinition, iframeContainerId, safeSetValue, sandboxError ]); const prevParams = usePrevious(params); const prevSandbox = usePrevious(sandbox); @@ -137,7 +137,7 @@ export function JSFunctionField(props) { return () => clearInterval(intervalId); } - }, [ params, prevParams, sandbox, prevSandbox, onChange, field, computeOn, hasRunLoad, interval, clearValue, safeSetValue ]); + }, [ params, prevParams, sandbox, prevSandbox, field, computeOn, hasRunLoad, interval, clearValue, safeSetValue ]); return (
diff --git a/packages/form-js-viewer/src/render/components/icons/JSFunction.svg b/packages/form-js-viewer/src/render/components/icons/JSFunction.svg index 65659169d..b46dbfe8d 100644 --- a/packages/form-js-viewer/src/render/components/icons/JSFunction.svg +++ b/packages/form-js-viewer/src/render/components/icons/JSFunction.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js index e15ca6398..bbe5e1f06 100644 --- a/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js @@ -1,5 +1,6 @@ import { - render + render, + waitFor } from '@testing-library/preact/pure'; import { JSFunctionField } from '../../../../../src/render/components/form-fields/JSFunctionField'; @@ -43,14 +44,12 @@ describe('JSFunctionField', function() { }; // when - act(() => { + await act(() => { createJSFunctionField({ field, onChange: onChangeSpy, services }); }); - // wait for the iframe to compute the expression and pass it back - await new Promise(r => setTimeout(r, 100)).then(() => { - - // then + // then + await waitFor(() => { expect(onChangeSpy).to.be.calledOnce; expect(onChangeSpy).to.be.calledWith({ field, value: 42 }); }); From c571d95e9acfe10881858b3092ce19d06caf6887 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 8 Apr 2024 10:15:22 +0200 Subject: [PATCH 08/11] chore: use lodash equality in expression field --- .../src/render/components/form-fields/ExpressionField.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 034be11c6..19e401ebf 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 @@ -1,5 +1,6 @@ import { useCallback, useEffect } from 'preact/hooks'; import { useExpressionEvaluation, useDeepCompareMemoize, useService } from '../../hooks'; +import { isEqual } from 'lodash'; const type = 'expression'; @@ -24,7 +25,7 @@ export function ExpressionField(props) { }, [ field, evaluationMemo, onChange ]); useEffect(() => { - if (computeOn !== 'change' || evaluationMemo === value) { return; } + if (computeOn !== 'change' || isEqual(evaluationMemo, value)) { return; } sendValue(); }, [ computeOn, evaluationMemo, sendValue, value ]); From 2f9cae0b082f29404be9bfe610328245497984ca Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 8 Apr 2024 12:20:49 +0200 Subject: [PATCH 09/11] chore: prevent unecessary script component updates Related to #1102 --- .../components/form-fields/JSFunctionField.js | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) 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 41eb37d70..9433d3c25 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 @@ -2,10 +2,11 @@ import Sandbox from '@jetbrains/websandbox'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useExpressionEvaluation, useDeepCompareMemoize, usePrevious } from '../../hooks'; import { isObject } from 'min-dash'; +import { isEqual } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; export function JSFunctionField(props) { - const { field, onChange } = props; + const { field, onChange, value } = props; const { jsFunction: functionDefinition, functionParameters: paramsDefinition, @@ -35,20 +36,33 @@ export function JSFunctionField(props) { }, [ field.key ]); - const safeSetValue = useCallback((value) => { + const valueRef = useRef(value); - if (value !== undefined) { + useEffect(() => { + valueRef.current = value; + }, [ value ]); - // strip out functions and handle unserializeable objects - try { - value = JSON.parse(JSON.stringify(value)); - onChange({ field, value }); - } catch { - sandboxError('Unparsable return value'); - clearValue(); - } + const safeSetValue = useCallback((newValue) => { + + if (newValue === undefined) { + return; + } + + // strip out functions and handle unserializeable objects + try { + newValue = JSON.parse(JSON.stringify(newValue)); + } catch { + sandboxError('Unparsable return value'); + clearValue(); } + // prevent unnecessary updates + if (isEqual(valueRef.current, newValue)) { + return; + } + + onChange({ field, value: newValue }); + }, [ onChange, field, sandboxError, clearValue ]); useEffect(() => { @@ -93,9 +107,6 @@ export function JSFunctionField(props) { frameClassName: 'fjs-sandbox-iframe' }); - const iframe = iframeContainerRef.current.querySelector('iframe'); - iframe.removeAttribute('allow'); - // (3) load user code in sandbox _sandbox.promise.then((sandboxInstance) => { sandboxInstance From f809482393b20878d7473d4093b8dc6493443598 Mon Sep 17 00:00:00 2001 From: Valentin Serra Date: Mon, 8 Apr 2024 13:30:32 +0200 Subject: [PATCH 10/11] chore: memoized indexes in formfield to prevent unneccessary updates Related to #1102 --- packages/form-js-viewer/src/render/components/FormField.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/form-js-viewer/src/render/components/FormField.js b/packages/form-js-viewer/src/render/components/FormField.js index 7033d1b90..8ac4c2457 100644 --- a/packages/form-js-viewer/src/render/components/FormField.js +++ b/packages/form-js-viewer/src/render/components/FormField.js @@ -7,6 +7,7 @@ import { FormContext, FormRenderContext } from '../context'; import { useCondition, + useDeepCompareMemoize, useReadonly, useService } from '../hooks'; @@ -19,7 +20,7 @@ const noop = () => false; export function FormField(props) { const { field, - indexes, + indexes: _indexes, onChange } = props; @@ -44,6 +45,8 @@ export function FormField(props) { const { formId } = useContext(FormContext); + const indexes = useDeepCompareMemoize(_indexes || {}); + // track whether we should trigger initial validation on certain actions, e.g. field blur // disabled straight away, if viewerCommands are not available const [ initialValidationTrigger, setInitialValidationTrigger ] = useState(!!viewerCommands); @@ -134,7 +137,7 @@ export function FormField(props) { } const domId = `${prefixId(field.id, formId, indexes)}`; - const fieldErrors = get(errors, [ field.id, ...Object.values(indexes || {}) ]) || []; + const fieldErrors = get(errors, [ field.id, ...Object.values(indexes) ]) || []; const formFieldElement = ( Date: Mon, 8 Apr 2024 13:33:49 +0200 Subject: [PATCH 11/11] chore: removed uuid dependency Related to #1103 --- package-lock.json | 10 +++++----- packages/form-js-viewer/package.json | 3 +-- .../components/form-fields/JSFunctionField.js | 17 +++++++++++------ .../form-fields/JSFunctionField.spec.js | 1 + 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 98b59cf86..68ea4179d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21199,6 +21199,7 @@ "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" @@ -22008,8 +22009,7 @@ "lodash": "^4.5.0", "marked": "^12.0.1", "min-dash": "^4.2.1", - "preact": "^10.5.14", - "uuid": "^9.0.1" + "preact": "^10.5.14" } }, "packages/form-js-viewer/node_modules/big.js": { @@ -23587,8 +23587,7 @@ "lodash": "^4.5.0", "marked": "^12.0.1", "min-dash": "^4.2.1", - "preact": "^10.5.14", - "uuid": "^9.0.1" + "preact": "^10.5.14" }, "dependencies": { "big.js": { @@ -36871,7 +36870,8 @@ "uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true }, "v8-compile-cache": { "version": "2.3.0", diff --git a/packages/form-js-viewer/package.json b/packages/form-js-viewer/package.json index 281710f29..13626b83a 100644 --- a/packages/form-js-viewer/package.json +++ b/packages/form-js-viewer/package.json @@ -58,8 +58,7 @@ "lodash": "^4.5.0", "marked": "^12.0.1", "min-dash": "^4.2.1", - "preact": "^10.5.14", - "uuid": "^9.0.1" + "preact": "^10.5.14" }, "sideEffects": [ "*.css" 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 9433d3c25..8c60f4c4d 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 @@ -3,10 +3,16 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useExpressionEvaluation, useDeepCompareMemoize, usePrevious } from '../../hooks'; import { isObject } from 'min-dash'; import { isEqual } from 'lodash'; -import { v4 as uuidv4 } from 'uuid'; export function JSFunctionField(props) { - const { field, onChange, value } = props; + + const { + field, + onChange, + value, + domId + } = props; + const { jsFunction: functionDefinition, functionParameters: paramsDefinition, @@ -16,7 +22,6 @@ export function JSFunctionField(props) { const [ sandbox, setSandbox ] = useState(null); const [ hasRunLoad, setHasRunLoad ] = useState(false); - const [ iframeContainerId ] = useState(`fjs-sandbox-iframe-container_${uuidv4()}`); const iframeContainerRef = useRef(null); const paramsEval = useExpressionEvaluation(paramsDefinition); @@ -103,7 +108,7 @@ export function JSFunctionField(props) { `; const _sandbox = Sandbox.create(hostAPI, { - frameContainer: `#${iframeContainerId}`, + frameContainer: `#${domId}`, frameClassName: 'fjs-sandbox-iframe' }); @@ -119,7 +124,7 @@ export function JSFunctionField(props) { return () => { _sandbox.destroy(); }; - }, [ clearValue, functionDefinition, iframeContainerId, safeSetValue, sandboxError ]); + }, [ clearValue, functionDefinition, domId, safeSetValue, sandboxError ]); const prevParams = usePrevious(params); const prevSandbox = usePrevious(sandbox); @@ -151,7 +156,7 @@ export function JSFunctionField(props) { }, [ params, prevParams, sandbox, prevSandbox, field, computeOn, hasRunLoad, interval, clearValue, safeSetValue ]); return ( -
+
); } diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js index bbe5e1f06..3b935a23c 100644 --- a/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js @@ -154,6 +154,7 @@ function createJSFunctionField({ services, ...restOptions } = {}) { options={ options }> , { container: options.container || container.querySelector('.fjs-form')