diff --git a/package-lock.json b/package-lock.json
index 4e8f29b3b..68ea4179d 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,19 @@
"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==",
+ "dev": true,
+ "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 +21997,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",
@@ -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",
@@ -24277,6 +24271,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 +25719,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 +33099,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 +36867,12 @@
"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==",
+ "dev": true
+ },
"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/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) {
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..faa2065fc
--- /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 = '', key } = field;
+
+ const Icon = iconsByType(type);
+
+ let placeholderContent = 'JS function is empty';
+
+ if (jsFunction.trim()) {
+ placeholderContent = `JS function for '${key}'`;
+ }
+
+ 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-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/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..13626b83a 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",
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/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 = (
{
- if (computeOn !== 'change' || evaluationMemo === value) { return; }
+ if (computeOn !== 'change' || isEqual(evaluationMemo, value)) { return; }
sendValue();
}, [ computeOn, evaluationMemo, sendValue, value ]);
@@ -41,9 +42,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..8c60f4c4d
--- /dev/null
+++ b/packages/form-js-viewer/src/render/components/form-fields/JSFunctionField.js
@@ -0,0 +1,177 @@
+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';
+
+export function JSFunctionField(props) {
+
+ const {
+ field,
+ onChange,
+ value,
+ domId
+ } = props;
+
+ const {
+ jsFunction: functionDefinition,
+ functionParameters: paramsDefinition,
+ computeOn,
+ interval
+ } = field;
+
+ const [ sandbox, setSandbox ] = useState(null);
+ const [ hasRunLoad, setHasRunLoad ] = useState(false);
+ 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 valueRef = useRef(value);
+
+ useEffect(() => {
+ valueRef.current = value;
+ }, [ value ]);
+
+ 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(() => {
+
+ // (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: `#${domId}`,
+ frameClassName: 'fjs-sandbox-iframe'
+ });
+
+ // (3) load user code in sandbox
+ _sandbox.promise.then((sandboxInstance) => {
+ sandboxInstance
+
+ // @ts-ignore
+ .run(wrappedUserCode)
+ .then(() => { setSandbox(sandboxInstance); setHasRunLoad(false); });
+ });
+
+ return () => {
+ _sandbox.destroy();
+ };
+ }, [ clearValue, functionDefinition, domId, safeSetValue, 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, 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..b46dbfe8d
--- /dev/null
+++ b/packages/form-js-viewer/src/render/components/icons/JSFunction.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
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 ];
}
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..3b935a23c
--- /dev/null
+++ b/packages/form-js-viewer/test/spec/render/components/form-fields/JSFunctionField.spec.js
@@ -0,0 +1,163 @@
+import {
+ render,
+ waitFor
+} 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
+ await act(() => {
+ createJSFunctionField({ field, onChange: onChangeSpy, services });
+ });
+
+ // then
+ await waitFor(() => {
+ 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
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');