diff --git a/e2e/visual/empty-ui.spec.js-snapshots/empty-playground-1-chromium-linux.png b/e2e/visual/empty-ui.spec.js-snapshots/empty-playground-1-chromium-linux.png index 32d4554cf..042b4c781 100644 Binary files a/e2e/visual/empty-ui.spec.js-snapshots/empty-playground-1-chromium-linux.png and b/e2e/visual/empty-ui.spec.js-snapshots/empty-playground-1-chromium-linux.png differ diff --git a/e2e/visual/empty-ui.spec.js-snapshots/empty-playground-1-webkit-linux.png b/e2e/visual/empty-ui.spec.js-snapshots/empty-playground-1-webkit-linux.png index 6c7406a72..d8c00d673 100644 Binary files a/e2e/visual/empty-ui.spec.js-snapshots/empty-playground-1-webkit-linux.png and b/e2e/visual/empty-ui.spec.js-snapshots/empty-playground-1-webkit-linux.png differ diff --git a/e2e/visual/groups-ui.spec.js-snapshots/groups-playground-1-chromium-linux.png b/e2e/visual/groups-ui.spec.js-snapshots/groups-playground-1-chromium-linux.png index 7257136ac..41a5e37bd 100644 Binary files a/e2e/visual/groups-ui.spec.js-snapshots/groups-playground-1-chromium-linux.png and b/e2e/visual/groups-ui.spec.js-snapshots/groups-playground-1-chromium-linux.png differ diff --git a/e2e/visual/groups-ui.spec.js-snapshots/groups-playground-1-webkit-linux.png b/e2e/visual/groups-ui.spec.js-snapshots/groups-playground-1-webkit-linux.png index c747f881e..6df618694 100644 Binary files a/e2e/visual/groups-ui.spec.js-snapshots/groups-playground-1-webkit-linux.png and b/e2e/visual/groups-ui.spec.js-snapshots/groups-playground-1-webkit-linux.png differ diff --git a/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-chromium-linux.png b/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-chromium-linux.png index 4bd82ec87..592b2014e 100644 Binary files a/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-chromium-linux.png and b/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-chromium-linux.png differ diff --git a/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-firefox-linux.png b/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-firefox-linux.png index d062d245c..86e13f055 100644 Binary files a/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-firefox-linux.png and b/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-firefox-linux.png differ diff --git a/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-webkit-linux.png b/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-webkit-linux.png index ac2322a61..3b1088d53 100644 Binary files a/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-webkit-linux.png and b/e2e/visual/no-theme.spec.js-snapshots/no-theme---editor-1-webkit-linux.png differ diff --git a/e2e/visual/theming.spec.js-snapshots/theming---editor-1-chromium-linux.png b/e2e/visual/theming.spec.js-snapshots/theming---editor-1-chromium-linux.png index 53348036f..e7bca89db 100644 Binary files a/e2e/visual/theming.spec.js-snapshots/theming---editor-1-chromium-linux.png and b/e2e/visual/theming.spec.js-snapshots/theming---editor-1-chromium-linux.png differ diff --git a/e2e/visual/theming.spec.js-snapshots/theming---editor-1-firefox-linux.png b/e2e/visual/theming.spec.js-snapshots/theming---editor-1-firefox-linux.png index 835271b03..593305c8e 100644 Binary files a/e2e/visual/theming.spec.js-snapshots/theming---editor-1-firefox-linux.png and b/e2e/visual/theming.spec.js-snapshots/theming---editor-1-firefox-linux.png differ diff --git a/e2e/visual/theming.spec.js-snapshots/theming---editor-1-webkit-linux.png b/e2e/visual/theming.spec.js-snapshots/theming---editor-1-webkit-linux.png index 03990b1de..b319d4dc3 100644 Binary files a/e2e/visual/theming.spec.js-snapshots/theming---editor-1-webkit-linux.png and b/e2e/visual/theming.spec.js-snapshots/theming---editor-1-webkit-linux.png differ diff --git a/package-lock.json b/package-lock.json index 5c3edb9c6..f8997ec88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "karma-firefox-launcher": "^2.1.1", "karma-mocha": "^2.0.1", "karma-sinon-chai": "^2.0.2", + "karma-spec-reporter": "^0.0.36", "karma-webpack": "^5.0.0", "lerna": "^8.0.0", "mocha": "^10.0.0", @@ -8857,6 +8858,15 @@ "dev": true, "license": "MIT" }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/columnify": { "version": "1.6.0", "dev": true, @@ -13880,6 +13890,18 @@ "sinon-chai": ">=2.9.0" } }, + "node_modules/karma-spec-reporter": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/karma-spec-reporter/-/karma-spec-reporter-0.0.36.tgz", + "integrity": "sha512-11bvOl1x6ryKZph7kmbmMpbi8vsngEGxGOoeTlIcDaH3ab3j8aPJnZ+r+K/SS0sBSGy5VGkGYO2+hLct7hw/6w==", + "dev": true, + "dependencies": { + "colors": "1.4.0" + }, + "peerDependencies": { + "karma": ">=0.9" + } + }, "node_modules/karma-webpack": { "version": "5.0.0", "dev": true, @@ -28448,6 +28470,12 @@ "version": "1.2.2", "dev": true }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true + }, "columnify": { "version": "1.6.0", "dev": true, @@ -31847,6 +31875,15 @@ "dev": true, "requires": {} }, + "karma-spec-reporter": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/karma-spec-reporter/-/karma-spec-reporter-0.0.36.tgz", + "integrity": "sha512-11bvOl1x6ryKZph7kmbmMpbi8vsngEGxGOoeTlIcDaH3ab3j8aPJnZ+r+K/SS0sBSGy5VGkGYO2+hLct7hw/6w==", + "dev": true, + "requires": { + "colors": "1.4.0" + } + }, "karma-webpack": { "version": "5.0.0", "dev": true, diff --git a/package.json b/package.json index 52b63240f..f4042cd99 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "karma-firefox-launcher": "^2.1.1", "karma-mocha": "^2.0.1", "karma-sinon-chai": "^2.0.2", + "karma-spec-reporter": "^0.0.36", "karma-webpack": "^5.0.0", "lerna": "^8.0.0", "mocha": "^10.0.0", diff --git a/packages/form-js-carbon-styles/karma.conf.js b/packages/form-js-carbon-styles/karma.conf.js index 717669f5c..4a6e439f5 100644 --- a/packages/form-js-carbon-styles/karma.conf.js +++ b/packages/form-js-carbon-styles/karma.conf.js @@ -29,7 +29,19 @@ module.exports = function(karma) { [ suite ]: [ 'webpack', 'env' ] }, - reporters: [ 'progress' ], + reporters: [ 'spec' ], + + specReporter: { + maxLogLines: 10, + suppressSummary: true, + suppressErrorSummary: false, + suppressFailed: false, + suppressPassed: false, + suppressSkipped: true, + showBrowser: false, + showSpecTiming: false, + failFast: false + }, browsers, diff --git a/packages/form-js-carbon-styles/src/carbon-styles.js b/packages/form-js-carbon-styles/src/carbon-styles.js index 5cae151c5..6fcbffb6c 100644 --- a/packages/form-js-carbon-styles/src/carbon-styles.js +++ b/packages/form-js-carbon-styles/src/carbon-styles.js @@ -390,7 +390,7 @@ const LABEL_DESCRIPTION_ERROR_STYLES = css` letter-spacing: var(--cds-label-01-letter-spacing); } - .fjs-form-field:not(.fjs-form-field-checkbox, .fjs-form-field-group) + .fjs-form-field:not(.fjs-form-field-checkbox, .fjs-form-field-grouplike) .fjs-form-field-label:first-child { margin: 0; margin-bottom: var(--cds-spacing-03); diff --git a/packages/form-js-carbon-styles/src/carbon-styles.scss b/packages/form-js-carbon-styles/src/carbon-styles.scss index ff7694df0..78e719f59 100644 --- a/packages/form-js-carbon-styles/src/carbon-styles.scss +++ b/packages/form-js-carbon-styles/src/carbon-styles.scss @@ -401,6 +401,17 @@ padding: 2px 0px; } } + + &.fjs-form-field-dynamiclist button.fjs-repeat-render-add, + &.fjs-form-field-dynamiclist button .fjs-repeat-row-remove-icon-container { + color: var(--cds-text-on-color-disabled); + + &:hover { + color: var(--cds-text-on-color-disabled); + background-color: transparent; + cursor: not-allowed; + } + } } } @@ -492,7 +503,7 @@ letter-spacing: var(--cds-label-01-letter-spacing); } - .fjs-form-field:not(.fjs-form-field-checkbox, .fjs-form-field-group, .fjs-form-field-table) .fjs-form-field-label:first-child { + .fjs-form-field:not(.fjs-form-field-checkbox, .fjs-form-field-grouplike, .fjs-form-field-table) .fjs-form-field-label:first-child { margin: 0; margin-bottom: var(--cds-spacing-03); } @@ -1312,4 +1323,51 @@ .fjs-table-nav-button { border-left: 1px solid var(--cds-border-subtle-02); } +} + +// Dynamic lists //////////// + + +.fjs-container .fjs-form-field-dynamiclist { + .fjs-repeat-row-container .fjs-repeat-row-remove-icon-container { + border-radius: 0; + color: var(--cds-button-danger-secondary); + min-width: rem(40); + min-height: rem(40); + + &:hover { + color: var(--cds-text-on-color); + background-color: var(--cds-button-danger-hover); + } + } + + .fjs-repeat-row-container .fjs-repeat-row-remove:focus-visible .fjs-repeat-row-remove-icon-container { + outline: 2px solid var(--cds-focus); + outline-offset: 1px; + border-radius: 0; + } + + .fjs-repeat-render-footer { + padding-left: 8px; + padding-right: 8px; + margin-right: 0; + } + + .fjs-repeat-render-add, + .fjs-repeat-render-collapse { + min-height: rem(40); + padding-inline-end: rem(15); + + &:focus { + outline: 2px solid var(--cds-focus); + outline-offset: 1px; + border: none; + border-radius: 0; + } + + &:hover { + color: var(--cds-link-primary-hover); + background-color: var(--cds-background-hover); + } + } } \ No newline at end of file diff --git a/packages/form-js-carbon-styles/test/spec/complex.json b/packages/form-js-carbon-styles/test/spec/complex.json index 0ae385f19..d2ee03bc8 100644 --- a/packages/form-js-carbon-styles/test/spec/complex.json +++ b/packages/form-js-carbon-styles/test/spec/complex.json @@ -41,6 +41,30 @@ "type": "iframe", "label": "I am an iframe" }, + { + "id": "DynamicList_1a82jj2", + "type": "dynamiclist", + "label": "Clients", + "path": "clients", + "showOutline": true, + "isRepeating": true, + "defaultRepetitions": 2, + "allowAddRemove": true, + "components": [ + { + "id": "DynamicListTextField_1", + "type": "textfield", + "key": "clientSurname", + "label": "Surname" + }, + { + "id": "DynamicListTextField_2", + "type": "textfield", + "key": "clientName", + "label": "Name" + } + ] + }, { "label": "I am a textfield", "type": "textfield", diff --git a/packages/form-js-editor/assets/form-js-editor-base.css b/packages/form-js-editor/assets/form-js-editor-base.css index f713cc353..704f9528d 100644 --- a/packages/form-js-editor/assets/form-js-editor-base.css +++ b/packages/form-js-editor/assets/form-js-editor-base.css @@ -268,6 +268,7 @@ .fjs-editor-container .fjs-form > .fjs-element { border: none; + outline: none; } .fjs-editor-container .fjs-form-field:not(.fjs-powered-by) { @@ -288,29 +289,31 @@ border-color: var(--color-children-hover-border); } -.fjs-editor-container .fjs-layout-column:first-child > .fjs-element[data-field-type="group"] { +.fjs-editor-container .fjs-layout-column:first-child > .fjs-element[data-field-type="group"], + .fjs-editor-container .fjs-layout-column:first-child > .fjs-element[data-field-type="dynamiclist"] { margin-left: -6px; } -.fjs-editor-container .fjs-layout-column:last-child > .fjs-element[data-field-type="group"] { +.fjs-editor-container .fjs-layout-column:last-child > .fjs-element[data-field-type="group"], +.fjs-editor-container .fjs-layout-column:last-child > .fjs-element[data-field-type="dynamiclist"] { margin-right: -6px; } -.fjs-editor-container .fjs-form-field-group, -.fjs-editor-container .fjs-form-field-group .fjs-form-field-group .fjs-form-field-group { +.fjs-editor-container .fjs-form-field-grouplike, +.fjs-editor-container .fjs-form-field-grouplike .fjs-form-field-grouplike .fjs-form-field-grouplike { margin: 1px 6px; padding: 0px; } -.fjs-editor-container .fjs-form-field-group.fjs-outlined { +.fjs-editor-container .fjs-form-field-grouplike.fjs-outlined { outline: none; } -.fjs-editor-container .fjs-form-field-group .cds--grid { +.fjs-editor-container .fjs-form-field-grouplike .cds--grid { padding: 0 2rem; } -.fjs-editor-container .fjs-form-field-group > label { +.fjs-editor-container .fjs-form-field-grouplike > label { margin-top: 6px; } @@ -331,6 +334,19 @@ user-select: none; } +.fjs-editor-container .fjs-empty-component { + display: flex; + justify-content: center; + align-items: center; + height: 80px; + width: calc(100% - 4rem); + position: absolute; +} + +.fjs-editor-container .fjs-empty-component span { + color: var(--cds-text-disabled, var(--color-grey-225-10-55)); +} + .fjs-editor-container .fjs-empty-editor { display: flex; align-items: center; @@ -442,6 +458,21 @@ top: 0; } +.fjs-editor-container .fjs-repeat-render-footer { + font-size: var(--font-size-label); + background: var(--cds-field, var(--color-background-disabled)); + color: var(--cds-text-disabled, var(--color-grey-225-10-45)); + padding: 3px; + display: flex; + align-items: center; + justify-content: center; + margin: 0px 5px 3px 5px; +} + +.fjs-editor-container .fjs-repeat-render-footer svg { + margin-right: 4px; +} + /* do not show resize handles on small screens */ @media (max-width: 66rem) { .fjs-editor-container .fjs-children .fjs-editor-selected .fjs-field-resize-handle { diff --git a/packages/form-js-editor/karma.conf.js b/packages/form-js-editor/karma.conf.js index 35c9bffd9..4b19a2acf 100644 --- a/packages/form-js-editor/karma.conf.js +++ b/packages/form-js-editor/karma.conf.js @@ -39,7 +39,19 @@ module.exports = function(karma) { 'NODE_ENV' ], - reporters: [ 'progress' ].concat(coverage ? 'coverage' : []), + reporters: [ 'spec' ].concat(coverage ? 'coverage' : []), + + specReporter: { + maxLogLines: 10, + suppressSummary: true, + suppressErrorSummary: false, + suppressFailed: false, + suppressPassed: false, + suppressSkipped: true, + showBrowser: false, + showSpecTiming: false, + failFast: false + }, coverageReporter: { reporters: [ diff --git a/packages/form-js-editor/src/FormEditor.js b/packages/form-js-editor/src/FormEditor.js index a55a2c639..41b64910b 100644 --- a/packages/form-js-editor/src/FormEditor.js +++ b/packages/form-js-editor/src/FormEditor.js @@ -12,6 +12,7 @@ import SelectionModule from './features/selection'; import PaletteModule from './features/palette'; import PropertiesPanelModule from './features/properties-panel'; import RenderInjectionModule from './features/render-injection'; +import RepeatRenderManagerModule from './features/repeat-render'; import ExpressionLanguageModule from './features/expression-language'; import { MarkdownModule } from '@bpmn-io/form-js-viewer'; @@ -309,7 +310,8 @@ export default class FormEditor { ExpressionLanguageModule, MarkdownModule, PropertiesPanelModule, - RenderInjectionModule + RenderInjectionModule, + RepeatRenderManagerModule ]; } diff --git a/packages/form-js-editor/src/features/dragging/Dragging.js b/packages/form-js-editor/src/features/dragging/Dragging.js index 38cd7bb06..5afeae167 100644 --- a/packages/form-js-editor/src/features/dragging/Dragging.js +++ b/packages/form-js-editor/src/features/dragging/Dragging.js @@ -1,6 +1,7 @@ import dragula from '@bpmn-io/draggle'; import { set as setCursor } from '../../render/util/Cursor'; +import { getAncestryList } from '@bpmn-io/form-js-viewer'; export const DRAG_CONTAINER_CLS = 'fjs-drag-container'; export const DROP_CONTAINER_VERTICAL_CLS = 'fjs-drop-container-vertical'; @@ -111,13 +112,13 @@ export default class Dragging { if (targetParentPath.join('.') !== currentParentPath.join('.')) { - const isDropAllowedByPathRegistry = this._pathRegistry.executeRecursivelyOnFields(formField, ({ field, isClosed }) => { + const isDropAllowedByPathRegistry = this._pathRegistry.executeRecursivelyOnFields(formField, ({ field, isClosed, isRepeatable }) => { const options = { cutoffNode: currentParentFormField.id, }; const fieldPath = this._pathRegistry.getValuePath(field, options); - return this._pathRegistry.canClaimPath([ ...targetParentPath, ...fieldPath ], isClosed); + return this._pathRegistry.canClaimPath([ ...targetParentPath, ...fieldPath ], { isClosed, isRepeatable, knownAncestorIds: getAncestryList(targetParentId, this._formFieldRegistry) }); }); if (!isDropAllowedByPathRegistry) { diff --git a/packages/form-js-editor/src/features/modeling/behavior/ValuesSourceBehavior.js b/packages/form-js-editor/src/features/modeling/behavior/OptionsSourceBehavior.js similarity index 66% rename from packages/form-js-editor/src/features/modeling/behavior/ValuesSourceBehavior.js rename to packages/form-js-editor/src/features/modeling/behavior/OptionsSourceBehavior.js index b7bc4eee5..41bebeb8b 100644 --- a/packages/form-js-editor/src/features/modeling/behavior/ValuesSourceBehavior.js +++ b/packages/form-js-editor/src/features/modeling/behavior/OptionsSourceBehavior.js @@ -3,11 +3,11 @@ import CommandInterceptor from 'diagram-js/lib/command/CommandInterceptor'; import { get } from 'min-dash'; import { - VALUES_SOURCES, - VALUES_SOURCES_PATHS + OPTIONS_SOURCES, + OPTIONS_SOURCES_PATHS } from '@bpmn-io/form-js-viewer'; -export default class ValuesSourceBehavior extends CommandInterceptor { +export default class OptionsSourceBehavior extends CommandInterceptor { constructor(eventBus) { super(eventBus); @@ -27,17 +27,17 @@ export default class ValuesSourceBehavior extends CommandInterceptor { } // clean up value sources that are not to going to be set - Object.values(VALUES_SOURCES).forEach(source => { - const path = VALUES_SOURCES_PATHS[source]; + Object.values(OPTIONS_SOURCES).forEach(source => { + const path = OPTIONS_SOURCES_PATHS[source]; if (get(properties, path) == undefined) { - newProperties[VALUES_SOURCES_PATHS[source]] = undefined; + newProperties[OPTIONS_SOURCES_PATHS[source]] = undefined; } }); // clean up default value if ( - get(properties, VALUES_SOURCES_PATHS[VALUES_SOURCES.EXPRESSION]) !== undefined || - get(properties, VALUES_SOURCES_PATHS[VALUES_SOURCES.INPUT]) !== undefined + get(properties, OPTIONS_SOURCES_PATHS[OPTIONS_SOURCES.EXPRESSION]) !== undefined || + get(properties, OPTIONS_SOURCES_PATHS[OPTIONS_SOURCES.INPUT]) !== undefined ) { newProperties['defaultValue'] = undefined; } @@ -50,12 +50,12 @@ export default class ValuesSourceBehavior extends CommandInterceptor { } } -ValuesSourceBehavior.$inject = [ 'eventBus' ]; +OptionsSourceBehavior.$inject = [ 'eventBus' ]; // helper /////////////////// function isValuesSourceUpdate(properties) { - return Object.values(VALUES_SOURCES_PATHS).some(path => { + return Object.values(OPTIONS_SOURCES_PATHS).some(path => { return get(properties, path) !== undefined; }); } \ No newline at end of file diff --git a/packages/form-js-editor/src/features/modeling/behavior/index.js b/packages/form-js-editor/src/features/modeling/behavior/index.js index 739974071..ef3347b2a 100644 --- a/packages/form-js-editor/src/features/modeling/behavior/index.js +++ b/packages/form-js-editor/src/features/modeling/behavior/index.js @@ -2,7 +2,7 @@ import IdBehavior from './IdBehavior'; import KeyBehavior from './KeyBehavior'; import PathBehavior from './PathBehavior'; import ValidateBehavior from './ValidateBehavior'; -import ValuesSourceBehavior from './ValuesSourceBehavior'; +import OptionsSourceBehavior from './OptionsSourceBehavior'; import { ColumnsSourceBehavior } from './ColumnsSourceBehavior'; import { TableDataSourceBehavior } from './TableDataSourceBehavior'; @@ -12,7 +12,7 @@ export default { 'keyBehavior', 'pathBehavior', 'validateBehavior', - 'valuesSourceBehavior', + 'optionsSourceBehavior', 'columnsSourceBehavior', 'tableDataSourceBehavior' ], @@ -20,7 +20,7 @@ export default { keyBehavior: [ 'type', KeyBehavior ], pathBehavior: [ 'type', PathBehavior ], validateBehavior: [ 'type', ValidateBehavior ], - valuesSourceBehavior: [ 'type', ValuesSourceBehavior ], + optionsSourceBehavior: [ 'type', OptionsSourceBehavior ], columnsSourceBehavior: [ 'type', ColumnsSourceBehavior ], tableDataSourceBehavior: [ 'type', TableDataSourceBehavior ] }; diff --git a/packages/form-js-editor/src/features/modeling/cmd/MoveFormFieldHandler.js b/packages/form-js-editor/src/features/modeling/cmd/MoveFormFieldHandler.js index 8110a5a44..5e93799d5 100644 --- a/packages/form-js-editor/src/features/modeling/cmd/MoveFormFieldHandler.js +++ b/packages/form-js-editor/src/features/modeling/cmd/MoveFormFieldHandler.js @@ -112,8 +112,12 @@ export default class MoveFormFieldHandler { get(schema, targetPath).forEach((formField, index) => updatePath(this._formFieldRegistry, formField, index)); // (7) Reregister form field (and children) from path registry - this._pathRegistry.executeRecursivelyOnFields(formField, ({ field, isClosed }) => { - this._pathRegistry.claimPath(this._pathRegistry.getValuePath(field), isClosed); + this._pathRegistry.executeRecursivelyOnFields(formField, ({ field, isClosed, isRepeatable }) => { + this._pathRegistry.claimPath(this._pathRegistry.getValuePath(field), { + isClosed, + isRepeatable, + claimerId: field.id + }); }); } diff --git a/packages/form-js-editor/src/features/modeling/cmd/UpdateKeyClaimHandler.js b/packages/form-js-editor/src/features/modeling/cmd/UpdateKeyClaimHandler.js index 616504d21..6a07cd1cd 100644 --- a/packages/form-js-editor/src/features/modeling/cmd/UpdateKeyClaimHandler.js +++ b/packages/form-js-editor/src/features/modeling/cmd/UpdateKeyClaimHandler.js @@ -24,7 +24,7 @@ export default class UpdateKeyClaimHandler { const valuePath = this._pathRegistry.getValuePath(formField, options); if (claiming) { - this._pathRegistry.claimPath(valuePath, true); + this._pathRegistry.claimPath(valuePath, { isClosed: true, claimerId: formField.id }); } else { this._pathRegistry.unclaimPath(valuePath); } @@ -36,13 +36,14 @@ export default class UpdateKeyClaimHandler { revert(context) { const { claiming, + formField, valuePath } = context; if (claiming) { this._pathRegistry.unclaimPath(valuePath); } else { - this._pathRegistry.claimPath(valuePath, true); + this._pathRegistry.claimPath(valuePath, { isClosed: true, claimerId: formField.id }); } } } diff --git a/packages/form-js-editor/src/features/modeling/cmd/UpdatePathClaimHandler.js b/packages/form-js-editor/src/features/modeling/cmd/UpdatePathClaimHandler.js index ecc387c6a..dbdbea990 100644 --- a/packages/form-js-editor/src/features/modeling/cmd/UpdatePathClaimHandler.js +++ b/packages/form-js-editor/src/features/modeling/cmd/UpdatePathClaimHandler.js @@ -24,15 +24,15 @@ export default class UpdatePathClaimHandler { const valuePaths = []; if (claiming) { - this._pathRegistry.executeRecursivelyOnFields(formField, ({ field, isClosed }) => { + this._pathRegistry.executeRecursivelyOnFields(formField, ({ field, isClosed, isRepeatable }) => { const valuePath = this._pathRegistry.getValuePath(field, options); - valuePaths.push({ valuePath, isClosed }); - this._pathRegistry.claimPath(valuePath, isClosed); + valuePaths.push({ valuePath, isClosed, isRepeatable, claimerId: field.id }); + this._pathRegistry.claimPath(valuePath, { isClosed, isRepeatable, claimerId: field.id }); }); } else { - this._pathRegistry.executeRecursivelyOnFields(formField, ({ field, isClosed }) => { + this._pathRegistry.executeRecursivelyOnFields(formField, ({ field, isClosed, isRepeatable }) => { const valuePath = this._pathRegistry.getValuePath(field, options); - valuePaths.push({ valuePath, isClosed }); + valuePaths.push({ valuePath, isClosed, isRepeatable, claimerId: field.id }); this._pathRegistry.unclaimPath(valuePath); }); } @@ -52,8 +52,12 @@ export default class UpdatePathClaimHandler { this._pathRegistry.unclaimPath(valuePath); }); } else { - valuePaths.forEach(({ valuePath, isClosed }) => { - this._pathRegistry.claimPath(valuePath, isClosed); + valuePaths.forEach(({ valuePath, isClosed, isRepeatable, claimerId }) => { + this._pathRegistry.claimPath(valuePath, { + isClosed, + isRepeatable, + claimerId + }); }); } } diff --git a/packages/form-js-editor/src/features/properties-panel/PropertiesProvider.js b/packages/form-js-editor/src/features/properties-panel/PropertiesProvider.js index 5f7fe0df3..2040e9b6e 100644 --- a/packages/form-js-editor/src/features/properties-panel/PropertiesProvider.js +++ b/packages/form-js-editor/src/features/properties-panel/PropertiesProvider.js @@ -6,7 +6,7 @@ import { SerializationGroup, ConstraintsGroup, ValidationGroup, - ValuesGroups, + OptionsGroups, LayoutGroup, TableHeaderGroups } from './groups'; @@ -67,7 +67,7 @@ export default class PropertiesProvider { LayoutGroup(field, editField), AppearanceGroup(field, editField), SerializationGroup(field, editField), - ...ValuesGroups(field, editField, getService), + ...OptionsGroups(field, editField, getService), ConstraintsGroup(field, editField), ValidationGroup(field, editField), CustomPropertiesGroup(field, editField) diff --git a/packages/form-js-editor/src/features/properties-panel/Util.js b/packages/form-js-editor/src/features/properties-panel/Util.js index 74f65801d..0e06f7019 100644 --- a/packages/form-js-editor/src/features/properties-panel/Util.js +++ b/packages/form-js-editor/src/features/properties-panel/Util.js @@ -63,6 +63,13 @@ export function isValidDotPath(path) { return /^\w+(\.\w+)*$/.test(path); } +export const LABELED_NON_INPUTS = [ + 'button', + 'group', + 'dynamiclist', + 'iframe' +]; + export const INPUTS = [ 'checkbox', 'checklist', @@ -75,7 +82,7 @@ export const INPUTS = [ 'textarea' ]; -export const VALUES_INPUTS = [ +export const OPTIONS_INPUTS = [ 'checklist', 'radio', 'select', @@ -92,7 +99,7 @@ export function hasEntryConfigured(formFieldDefinition, entryId) { return propertiesPanelEntries.some(id => id === entryId); } -export function hasValuesGroupsConfigured(formFieldDefinition) { +export function hasOptionsGroupsConfigured(formFieldDefinition) { const { propertiesPanelEntries = [] } = formFieldDefinition; if (!propertiesPanelEntries.length) { 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 c5d996de1..5f24f9979 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 @@ -3,8 +3,7 @@ import { get } from 'min-dash'; import { useService, useVariables } from '../hooks'; - -export function ConditionEntry(props) { +export default function ConditionEntry(props) { const { editField, field diff --git a/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js index bce2d34ba..fa45bead7 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/DefaultValueEntry.js @@ -13,7 +13,7 @@ import Big from 'big.js'; import { useService } from '../hooks'; -import { countDecimals, INPUTS, isValidNumber, VALUES_INPUTS } from '../Util'; +import { countDecimals, INPUTS, isValidNumber, OPTIONS_INPUTS } from '../Util'; export const EMPTY_OPTION = null; @@ -33,7 +33,7 @@ export default function DefaultOptionEntry(props) { return (field) => { // Only make default values available when they are statically defined - if (!INPUTS.includes(type) || VALUES_INPUTS.includes(type) && !field.values) { + if (!INPUTS.includes(type) || OPTIONS_INPUTS.includes(type) && !field.values) { return false; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/GroupEntries.js b/packages/form-js-editor/src/features/properties-panel/entries/GroupAppearanceEntry.js similarity index 75% rename from packages/form-js-editor/src/features/properties-panel/entries/GroupEntries.js rename to packages/form-js-editor/src/features/properties-panel/entries/GroupAppearanceEntry.js index c22e06a09..7db6dea9d 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/GroupEntries.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/GroupAppearanceEntry.js @@ -1,7 +1,6 @@ - import { simpleBoolEntryFactory } from './factories'; -export default function GroupEntries(props) { +export default function GroupAppearanceEntry(props) { const { field, } = props; @@ -10,7 +9,7 @@ export default function GroupEntries(props) { type } = field; - if (type !== 'group') { + if (![ 'group', 'dynamiclist' ].includes(type)) { return []; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/InputKeyValuesSourceEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/InputKeyOptionsSourceEntry.js similarity index 87% rename from packages/form-js-editor/src/features/properties-panel/entries/InputKeyValuesSourceEntry.js rename to packages/form-js-editor/src/features/properties-panel/entries/InputKeyOptionsSourceEntry.js index 0bd3ba9a9..92f42d1a6 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/InputKeyValuesSourceEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/InputKeyOptionsSourceEntry.js @@ -1,10 +1,10 @@ import { TextFieldEntry, isTextFieldEntryEdited } from '@bpmn-io/properties-panel'; import { get, isUndefined } from 'min-dash'; import { useService } from '../hooks'; -import { VALUES_SOURCES, VALUES_SOURCES_PATHS } from '@bpmn-io/form-js-viewer'; +import { OPTIONS_SOURCES, OPTIONS_SOURCES_PATHS } from '@bpmn-io/form-js-viewer'; -export default function InputKeyValuesSourceEntry(props) { +export default function InputKeyOptionsSourceEntry(props) { const { editField, field, @@ -31,7 +31,7 @@ function InputValuesKey(props) { const debounce = useService('debounce'); - const path = VALUES_SOURCES_PATHS[VALUES_SOURCES.INPUT]; + const path = OPTIONS_SOURCES_PATHS[OPTIONS_SOURCES.INPUT]; const schema = '[\n {\n "label": "dollar",\n "value": "$"\n }\n]'; 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 6283ededb..3e79a7aad 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 @@ -1,6 +1,6 @@ import { isString, get } from 'min-dash'; -import { INPUTS, hasIntegerPathSegment, isValidDotPath } from '../Util'; +import { hasIntegerPathSegment, isValidDotPath } from '../Util'; import { useService } from '../hooks'; @@ -10,7 +10,8 @@ import { TextFieldEntry, isTextFieldEntryEdited } from '@bpmn-io/properties-pane export default function KeyEntry(props) { const { editField, - field + field, + getService } = props; const entries = []; @@ -21,7 +22,11 @@ export default function KeyEntry(props) { editField: editField, field: field, isEdited: isTextFieldEntryEdited, - isDefaultVisible: (field) => INPUTS.includes(field.type) + isDefaultVisible: (field) => { + const formFields = getService('formFields'); + const { config } = formFields.get(field.type); + return config.keyed; + } }); return entries; @@ -79,8 +84,8 @@ function Key(props) { // unclaim temporarily to avoid self-conflicts pathRegistry.unclaimPath(oldPath); - const canClaim = pathRegistry.canClaimPath(newPath, true); - pathRegistry.claimPath(oldPath, true); + const canClaim = pathRegistry.canClaimPath(newPath, { isClosed: true, claimerId: field.id }); + pathRegistry.claimPath(oldPath, { isClosed: true, claimerId: field.id }); return canClaim ? null : 'Must not conflict with other key/path assignments.'; }; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/LabelEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/LabelEntry.js index f28c25c4d..1b4c69563 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/LabelEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/LabelEntry.js @@ -51,7 +51,7 @@ export default function LabelEntry(props) { editField, field, isEdited: isFeelEntryEdited, - isDefaultVisible: (field) => [ ...INPUTS, 'button', 'group', 'table', 'iframe' ].includes(field.type) + isDefaultVisible: (field) => [ ...INPUTS, 'button', 'group', 'table', 'iframe', 'dynamiclist' ].includes(field.type) } ); @@ -169,6 +169,7 @@ function TimeLabel(props) { function getLabelText(type) { switch (type) { case 'group': + case 'dynamiclist': return 'Group label'; case 'table': return 'Table label'; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/LayouterAppearanceEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/LayouterAppearanceEntry.js new file mode 100644 index 000000000..26669a099 --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/LayouterAppearanceEntry.js @@ -0,0 +1,27 @@ +import { simpleSelectEntryFactory } from './factories'; + +export default function LayouterAppearanceEntry(props) { + const { + field + } = props; + + if (![ 'group', 'dynamiclist' ].includes(field.type)) { + return []; + } + + const entries = [ + simpleSelectEntryFactory({ + id: 'verticalAlignment', + path: [ 'verticalAlignment' ], + label: 'Vertical alignment', + optionsArray: [ + { value: 'start', label: 'Top' }, + { value: 'center', label: 'Center' }, + { value: 'end', label: 'Bottom' } + ], + props + }), + ]; + + return entries; +} \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/entries/ValuesExpressionEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/OptionsExpressionEntry.js similarity index 80% rename from packages/form-js-editor/src/features/properties-panel/entries/ValuesExpressionEntry.js rename to packages/form-js-editor/src/features/properties-panel/entries/OptionsExpressionEntry.js index c5b1a4972..6d2009c67 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/ValuesExpressionEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/OptionsExpressionEntry.js @@ -1,10 +1,10 @@ import { FeelEntry, isFeelEntryEdited } from '@bpmn-io/properties-panel'; import { get } from 'min-dash'; import { useService, useVariables } from '../hooks'; -import { VALUES_SOURCES, VALUES_SOURCES_PATHS } from '@bpmn-io/form-js-viewer'; +import { OPTIONS_SOURCES, OPTIONS_SOURCES_PATHS } from '@bpmn-io/form-js-viewer'; -export default function ValuesExpressionEntry(props) { +export default function OptionsExpressionEntry(props) { const { editField, field, @@ -14,7 +14,7 @@ export default function ValuesExpressionEntry(props) { return [ { id: id + '-expression', - component: ValuesExpression, + component: OptionsExpression, isEdited: isFeelEntryEdited, editField, field @@ -22,7 +22,7 @@ export default function ValuesExpressionEntry(props) { ]; } -function ValuesExpression(props) { +function OptionsExpression(props) { const { editField, field, @@ -33,7 +33,7 @@ function ValuesExpression(props) { const variables = useVariables().map(name => ({ name })); - const path = VALUES_SOURCES_PATHS[VALUES_SOURCES.EXPRESSION]; + const path = OPTIONS_SOURCES_PATHS[OPTIONS_SOURCES.EXPRESSION]; const schema = '[\n {\n "label": "dollar",\n "value": "$"\n }\n]'; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/ValuesSourceSelectEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/OptionsSourceSelectEntry.js similarity index 52% rename from packages/form-js-editor/src/features/properties-panel/entries/ValuesSourceSelectEntry.js rename to packages/form-js-editor/src/features/properties-panel/entries/OptionsSourceSelectEntry.js index a106f1665..2ffdea6c8 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/ValuesSourceSelectEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/OptionsSourceSelectEntry.js @@ -3,15 +3,15 @@ import { isSelectEntryEdited } from '@bpmn-io/properties-panel'; import { AutoFocusSelectEntry } from '../components'; import { - getValuesSource, - VALUES_SOURCES, - VALUES_SOURCES_DEFAULTS, - VALUES_SOURCES_LABELS, - VALUES_SOURCES_PATHS + getOptionsSource, + OPTIONS_SOURCES, + OPTIONS_SOURCES_DEFAULTS, + OPTIONS_SOURCES_LABELS, + OPTIONS_SOURCES_PATHS } from '@bpmn-io/form-js-viewer'; -export default function ValuesSourceSelectEntry(props) { +export default function OptionsSourceSelectEntry(props) { const { editField, field, @@ -37,7 +37,7 @@ function ValuesSourceSelect(props) { id } = props; - const getValue = getValuesSource; + const getValue = getOptionsSource; const setValue = (value) => { @@ -45,16 +45,16 @@ function ValuesSourceSelect(props) { const newProperties = {}; - newProperties[VALUES_SOURCES_PATHS[value]] = VALUES_SOURCES_DEFAULTS[value]; + newProperties[OPTIONS_SOURCES_PATHS[value]] = OPTIONS_SOURCES_DEFAULTS[value]; newField = editField(field, newProperties); return newField; }; - const getValuesSourceOptions = () => { + const getOptionsSourceOptions = () => { - return Object.values(VALUES_SOURCES).map((valueSource) => ({ - label: VALUES_SOURCES_LABELS[valueSource], + return Object.values(OPTIONS_SOURCES).map((valueSource) => ({ + label: OPTIONS_SOURCES_LABELS[valueSource], value: valueSource })); }; @@ -63,7 +63,7 @@ function ValuesSourceSelect(props) { autoFocusEntry: getAutoFocusEntryId(field), label: 'Type', element: field, - getOptions: getValuesSourceOptions, + getOptions: getOptionsSourceOptions, getValue, id, setValue @@ -73,14 +73,14 @@ function ValuesSourceSelect(props) { // helpers ////////// function getAutoFocusEntryId(field) { - const valuesSource = getValuesSource(field); - - if (valuesSource === VALUES_SOURCES.EXPRESSION) { - return `${field.id}-valuesExpression-expression`; - } else if (valuesSource === VALUES_SOURCES.INPUT) { - return `${field.id}-dynamicValues-key`; - } else if (valuesSource === VALUES_SOURCES.STATIC) { - return `${field.id}-staticValues-0-label`; + const valuesSource = getOptionsSource(field); + + if (valuesSource === OPTIONS_SOURCES.EXPRESSION) { + return 'optionsExpression-expression'; + } else if (valuesSource === OPTIONS_SOURCES.INPUT) { + return 'dynamicOptions-key'; + } else if (valuesSource === OPTIONS_SOURCES.STATIC) { + return 'staticOptions-0-label'; } return null; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/PathEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/PathEntry.js index e281ff952..60e8911a6 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/PathEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/PathEntry.js @@ -10,7 +10,8 @@ import { isValidDotPath } from '../Util'; export default function PathEntry(props) { const { editField, - field + field, + getService } = props; const { @@ -19,7 +20,9 @@ export default function PathEntry(props) { const entries = []; - if (type === 'group') { + const formFieldDefinition = getService('formFields').get(type); + + if (formFieldDefinition && formFieldDefinition.config.pathed) { entries.push({ id: 'path', component: Path, @@ -41,6 +44,8 @@ function Path(props) { const debounce = useService('debounce'); const pathRegistry = useService('pathRegistry'); + const fieldConfig = useService('formFields').get(field.type).config; + const isRepeating = fieldConfig.repeatable && field.isRepeating; const path = [ 'path' ]; @@ -58,35 +63,51 @@ function Path(props) { const validate = (value) => { - if (!value || value === field.path) { + if (!value && isRepeating) { + return 'Must not be empty'; + } + + // Early return for empty value in non-repeating cases or if the field path hasn't changed + if (!value && !isRepeating || value === field.path) { return null; } - if (value && !isValidDotPath(value)) { - return 'Must be empty, a variable or a dot separated path'; + // Validate dot-separated path format + if (!isValidDotPath(value)) { + const msg = isRepeating ? 'Must be a variable or a dot-separated path' : 'Must be empty, a variable or a dot-separated path'; + return msg; } - const hasIntegerPathSegment = value && value.split('.').some(segment => /^\d+$/.test(segment)); + // Check for integer segments in the path + const hasIntegerPathSegment = value.split('.').some(segment => /^\d+$/.test(segment)); if (hasIntegerPathSegment) { return 'Must not contain numerical path segments.'; } - const options = value && { + // Check for path collisions + const options = { replacements: { - [ field.id ]: [ value ] + [field.id]: value.split('.') } - } || {}; + }; - const canClaim = pathRegistry.executeRecursivelyOnFields(field, ({ field, isClosed }) => { + const canClaim = pathRegistry.executeRecursivelyOnFields(field, ({ field, isClosed, isRepeatable }) => { const path = pathRegistry.getValuePath(field, options); - return pathRegistry.canClaimPath(path, isClosed); + return pathRegistry.canClaimPath(path, { isClosed, isRepeatable, claimerId: field.id }); }); if (!canClaim) { - return 'Must not cause two binding paths to colide'; + return 'Must not cause two binding paths to collide'; } + + // If all checks pass + return null; }; + const tooltip = isRepeating + ? 'Routes the children of this component into a form variable, may be left empty to route at the root level.' + : 'Routes the children of this component into a form variable.'; + return TextFieldEntry({ debounce, description: 'Where the child variables of this component are pathed to.', @@ -94,7 +115,7 @@ function Path(props) { getValue, id, label: 'Path', - tooltip: 'Routes the children of this component into a form variable, may be left empty to route at the root level.', + tooltip, setValue, validate }); diff --git a/packages/form-js-editor/src/features/properties-panel/entries/RepeatableEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/RepeatableEntry.js new file mode 100644 index 000000000..20d8911ef --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/RepeatableEntry.js @@ -0,0 +1,56 @@ +import { simpleRangeIntegerEntryFactory, simpleBoolEntryFactory } from './factories'; + +export default function RepeatableEntry(props) { + const { + field, + getService + } = props; + + const { + type + } = field; + + const formFieldDefinition = getService('formFields').get(type); + + if (!formFieldDefinition || !formFieldDefinition.config.repeatable) { + return []; + } + + const entries = [ + simpleRangeIntegerEntryFactory({ + id: 'defaultRepetitions', + path: [ 'defaultRepetitions' ], + label: 'Default number of items', + min: 0, + max: 20, + props + }), + simpleBoolEntryFactory({ + id: 'allowAddRemove', + path: [ 'allowAddRemove' ], + label: 'Allow add/delete items', + props + }), + simpleBoolEntryFactory({ + id: 'disableCollapse', + path: [ 'disableCollapse' ], + label: 'Disable collapse', + props + }) + ]; + + if (!field.disableCollapse) { + const nonCollapseItemsEntry = simpleRangeIntegerEntryFactory({ + id: 'nonCollapsedItems', + path: [ 'nonCollapsedItems' ], + label: 'Number of non-collapsing items', + min: 1, + defaultValue: 5, + props + }); + + entries.push(nonCollapseItemsEntry); + } + + return entries; +} \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/entries/StaticValuesSourceEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/StaticOptionsSourceEntry.js similarity index 83% rename from packages/form-js-editor/src/features/properties-panel/entries/StaticValuesSourceEntry.js rename to packages/form-js-editor/src/features/properties-panel/entries/StaticOptionsSourceEntry.js index 3a9a3cde0..c6a5800b3 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/StaticValuesSourceEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/StaticOptionsSourceEntry.js @@ -1,9 +1,9 @@ import { isUndefined, without } from 'min-dash'; import { arrayAdd } from '../Util'; import ValueEntry from './ValueEntry'; -import { VALUES_SOURCES, VALUES_SOURCES_PATHS } from '@bpmn-io/form-js-viewer'; +import { OPTIONS_SOURCES, OPTIONS_SOURCES_PATHS } from '@bpmn-io/form-js-viewer'; -export default function StaticValuesSourceEntry(props) { +export default function StaticOptionsSourceEntry(props) { const { editField, field, @@ -22,11 +22,11 @@ export default function StaticValuesSourceEntry(props) { const entry = getIndexedEntry(index, values); - editField(field, VALUES_SOURCES_PATHS[VALUES_SOURCES.STATIC], arrayAdd(values, values.length, entry)); + editField(field, OPTIONS_SOURCES_PATHS[OPTIONS_SOURCES.STATIC], arrayAdd(values, values.length, entry)); }; const removeEntry = (entry) => { - editField(field, VALUES_SOURCES_PATHS[VALUES_SOURCES.STATIC], without(values, entry)); + editField(field, OPTIONS_SOURCES_PATHS[OPTIONS_SOURCES.STATIC], without(values, entry)); }; const validateFactory = (key, getValue) => { diff --git a/packages/form-js-editor/src/features/properties-panel/entries/factories/index.js b/packages/form-js-editor/src/features/properties-panel/entries/factories/index.js index 8e819913b..8d1cceee5 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/factories/index.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/factories/index.js @@ -1,2 +1,5 @@ export { default as simpleStringEntryFactory } from './simpleStringEntryFactory'; export { default as simpleBoolEntryFactory } from './simpleBoolEntryFactory'; +export { default as zeroPositiveIntegerEntryFactory } from './zeroPositiveIntegerEntryFactory'; +export { default as simpleSelectEntryFactory } from './simpleSelectEntryFactory'; +export { simpleRangeIntegerEntryFactory } from './simpleRangeIntegerEntryFactory'; 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 new file mode 100644 index 000000000..6b8d0bc4b --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleRangeIntegerEntryFactory.js @@ -0,0 +1,69 @@ +import { get } from 'min-dash'; +import { useService } from '../../hooks'; +import { NumberFieldEntry, isNumberFieldEntryEdited } from '@bpmn-io/properties-panel'; + +export function simpleRangeIntegerEntryFactory(options) { + const { + id, + label, + path, + props, + min, + max, + defaultValue + } = options; + + const { + editField, + field + } = props; + + return { + id, + label, + path, + field, + editField, + min, + max, + defaultValue, + component: SimpleRangeIntegerEntry, + isEdited: isNumberFieldEntryEdited + }; +} + +const SimpleRangeIntegerEntry = (props) => { + const { + id, + label, + path, + field, + editField, + min, + max, + defaultValue + } = props; + + const debounce = useService('debounce'); + + const getValue = () => { + const value = get(field, path, defaultValue); + return Number.isInteger(value) ? value : defaultValue; + }; + + const setValue = (value) => { + editField(field, path, value); + }; + + return NumberFieldEntry({ + debounce, + label, + element: field, + step: 1, + min, + max, + getValue, + id, + setValue + }); +}; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleSelectEntryFactory.js b/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleSelectEntryFactory.js new file mode 100644 index 000000000..b9d344cef --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleSelectEntryFactory.js @@ -0,0 +1,54 @@ +import { get } from 'min-dash'; +import { isSelectEntryEdited, SelectEntry } from '@bpmn-io/properties-panel'; + +export default function simpleSelectEntryFactory(options) { + const { + id, + label, + path, + props, + optionsArray + } = options; + + const { + editField, + field + } = props; + + return { + id, + label, + path, + field, + editField, + optionsArray, + component: SimpleSelectComponent, + isEdited: isSelectEntryEdited, + }; +} + +const SimpleSelectComponent = (props) => { + const { + id, + label, + path, + field, + editField, + optionsArray + } = props; + + const getValue = () => get(field, path, ''); + + const setValue = (value) => editField(field, path, value); + + const getOptions = () => optionsArray; + + return SelectEntry({ + label, + element: field, + getOptions, + getValue, + id, + setValue + }); +}; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/factories/zeroPositiveIntegerEntryFactory.js b/packages/form-js-editor/src/features/properties-panel/entries/factories/zeroPositiveIntegerEntryFactory.js new file mode 100644 index 000000000..0d473b252 --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/entries/factories/zeroPositiveIntegerEntryFactory.js @@ -0,0 +1,61 @@ +import { get } from 'min-dash'; +import { useService } from '../../hooks'; +import { NumberFieldEntry, isNumberFieldEntryEdited } from '@bpmn-io/properties-panel'; + +export default function zeroPositiveIntegerEntryFactory(options) { + const { + id, + label, + path, + props + } = options; + + const { + editField, + field + } = props; + + return { + id, + label, + path, + field, + editField, + component: ZeroPositiveIntegerEntry, + isEdited: isNumberFieldEntryEdited + }; +} + +const ZeroPositiveIntegerEntry = (props) => { + const { + id, + label, + path, + field, + editField + } = props; + + const debounce = useService('debounce'); + + const getValue = () => { + const value = get(field, path, 0); + return Number.isInteger(value) ? value : 0; + }; + + const setValue = (value) => { + if (Number.isInteger(value) && value >= 0) { + editField(field, path, value); + } + }; + + return NumberFieldEntry({ + debounce, + label, + element: field, + step: 1, + min: 0, + getValue, + id, + setValue + }); +}; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/index.js b/packages/form-js-editor/src/features/properties-panel/entries/index.js index 6fd0eb67c..df6d6525e 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 @@ -7,7 +7,7 @@ export { default as DisabledEntry } from './DisabledEntry'; export { default as IdEntry } from './IdEntry'; export { default as KeyEntry } from './KeyEntry'; export { default as PathEntry } from './PathEntry'; -export { default as GroupEntries } from './GroupEntries'; +export { default as GroupAppearanceEntry } from './GroupAppearanceEntry'; export { default as LabelEntry } from './LabelEntry'; export { default as IFrameHeightEntry } from './IFrameHeightEntry'; export { default as IFrameUrlEntry } from './IFrameUrlEntry'; @@ -22,16 +22,18 @@ export { default as DateTimeSerializationEntry } from './DateTimeSerializationEn export { default as SelectEntries } from './SelectEntries'; export { default as ValueEntry } from './ValueEntry'; export { default as CustomValueEntry } from './CustomValueEntry'; -export { default as ValuesSourceSelectEntry } from './ValuesSourceSelectEntry'; -export { default as InputKeyValuesSourceEntry } from './InputKeyValuesSourceEntry'; -export { default as StaticValuesSourceEntry } from './StaticValuesSourceEntry'; +export { default as OptionsSourceSelectEntry } from './OptionsSourceSelectEntry'; +export { default as InputKeyOptionsSourceEntry } from './InputKeyOptionsSourceEntry'; +export { default as StaticOptionsSourceEntry } from './StaticOptionsSourceEntry'; export { default as AdornerEntry } from './AdornerEntry'; export { default as ReadonlyEntry } from './ReadonlyEntry'; -export { ConditionEntry } from './ConditionEntry'; -export { default as ValuesExpressionEntry } from './ValuesExpressionEntry'; +export { default as LayouterAppearanceEntry } from './LayouterAppearanceEntry'; +export { default as RepeatableEntry } from './RepeatableEntry'; +export { default as ConditionEntry } from './ConditionEntry'; +export { default as OptionsExpressionEntry } from './OptionsExpressionEntry'; export { TableDataSourceEntry } from './TableDataSourceEntry'; export { PaginationEntry } from './PaginationEntry'; export { RowCountEntry } from './RowCountEntry'; export { HeadersSourceSelectEntry } from './HeadersSourceSelectEntry'; export { ColumnsExpressionEntry } from './ColumnsExpressionEntry'; -export { StaticColumnsSourceEntry } from './StaticColumnsSourceEntry'; \ No newline at end of file +export { StaticColumnsSourceEntry } from './StaticColumnsSourceEntry'; diff --git a/packages/form-js-editor/src/features/properties-panel/groups/AppearanceGroup.js b/packages/form-js-editor/src/features/properties-panel/groups/AppearanceGroup.js index 58a110750..2314ee162 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/AppearanceGroup.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/AppearanceGroup.js @@ -1,12 +1,16 @@ import { - AdornerEntry + AdornerEntry, + GroupAppearanceEntry, + LayouterAppearanceEntry } from '../entries'; -export default function AppearanceGroup(field, editField) { +export default function AppearanceGroup(field, editField, getService) { const entries = [ - ...AdornerEntry({ field, editField }) + ...AdornerEntry({ field, editField }), + ...GroupAppearanceEntry({ field, editField }), + ...LayouterAppearanceEntry({ field, editField }) ]; if (!entries.length) { diff --git a/packages/form-js-editor/src/features/properties-panel/groups/CustomPropertiesGroup.js b/packages/form-js-editor/src/features/properties-panel/groups/CustomPropertiesGroup.js index d16ab6aed..407677728 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/CustomPropertiesGroup.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/CustomPropertiesGroup.js @@ -52,7 +52,7 @@ export default function CustomPropertiesGroup(field, editField) { return editField(field, [ 'properties' ], removeKey(properties, key)); }; - const id = `${ field.id }-property-${ index }`; + const id = `property-${ index }`; return { autoFocusEntry: id + '-key', 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 49e5d259e..76bf533f0 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 @@ -10,7 +10,7 @@ import { ImageSourceEntry, KeyEntry, PathEntry, - GroupEntries, + RepeatableEntry, LabelEntry, ReadonlyEntry, SelectEntries, @@ -30,9 +30,9 @@ export default function GeneralGroup(field, editField, getService) { ...IdEntry({ field, editField }), ...LabelEntry({ field, editField }), ...DescriptionEntry({ field, editField }), - ...KeyEntry({ field, editField }), - ...PathEntry({ field, editField }), - ...GroupEntries({ field, editField }), + ...KeyEntry({ field, editField, getService }), + ...PathEntry({ field, editField, getService }), + ...RepeatableEntry({ field, editField, getService }), ...DefaultValueEntry({ field, editField }), ...ActionEntry({ field, editField }), ...DateTimeEntry({ field, editField }), diff --git a/packages/form-js-editor/src/features/properties-panel/groups/OptionsGroups.js b/packages/form-js-editor/src/features/properties-panel/groups/OptionsGroups.js new file mode 100644 index 000000000..d8789bdba --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/groups/OptionsGroups.js @@ -0,0 +1,83 @@ +import { + OptionsSourceSelectEntry, + StaticOptionsSourceEntry, + InputKeyOptionsSourceEntry, + OptionsExpressionEntry +} from '../entries'; + +import { getOptionsSource, OPTIONS_SOURCES } from '@bpmn-io/form-js-viewer'; + +import { Group, ListGroup } from '@bpmn-io/properties-panel'; + +import { + OPTIONS_INPUTS, + hasOptionsGroupsConfigured +} from '../Util'; + +export default function OptionsGroups(field, editField, getService) { + const { + type + } = field; + + const formFields = getService('formFields'); + + const fieldDefinition = formFields.get(type).config; + + if (!OPTIONS_INPUTS.includes(type) && !hasOptionsGroupsConfigured(fieldDefinition)) { + return []; + } + + const context = { editField, field }; + const id = 'valuesSource'; + + /** + * @type {Array} + */ + const groups = [ + { + id, + label: 'Options source', + tooltip: getValuesTooltip(), + component: Group, + entries: OptionsSourceSelectEntry({ ...context, id }) + } + ]; + + const valuesSource = getOptionsSource(field); + + if (valuesSource === OPTIONS_SOURCES.INPUT) { + const id = 'dynamicOptions'; + groups.push({ + id, + label: 'Dynamic options', + component: Group, + entries: InputKeyOptionsSourceEntry({ ...context, id }) + }); + } else if (valuesSource === OPTIONS_SOURCES.STATIC) { + const id = 'staticOptions'; + groups.push({ + id, + label: 'Static options', + component: ListGroup, + ...StaticOptionsSourceEntry({ ...context, id }) + }); + } else if (valuesSource === OPTIONS_SOURCES.EXPRESSION) { + const id = 'optionsExpression'; + groups.push({ + id, + label: 'Options expression', + component: Group, + entries: OptionsExpressionEntry({ ...context, id }) + }); + } + + return groups; +} + +// helpers ////////// + +function getValuesTooltip() { + return '"Static" defines a constant, predefined set of form options.\n\n' + + '"Input data" defines options that are populated dynamically, adjusting based on variable data for flexible responses to different conditions or inputs.\n\n' + + '"Expression" defines options that are populated from a FEEL expression.'; +} \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/groups/ValuesGroups.js b/packages/form-js-editor/src/features/properties-panel/groups/ValuesGroups.js deleted file mode 100644 index 5964b6428..000000000 --- a/packages/form-js-editor/src/features/properties-panel/groups/ValuesGroups.js +++ /dev/null @@ -1,84 +0,0 @@ -import { - ValuesSourceSelectEntry, - StaticValuesSourceEntry, - InputKeyValuesSourceEntry, - ValuesExpressionEntry -} from '../entries'; - -import { getValuesSource, VALUES_SOURCES } from '@bpmn-io/form-js-viewer'; - -import { Group, ListGroup } from '@bpmn-io/properties-panel'; - -import { - VALUES_INPUTS, - hasValuesGroupsConfigured -} from '../Util'; - -export default function ValuesGroups(field, editField, getService) { - const { - type, - id: fieldId - } = field; - - const formFields = getService('formFields'); - - const fieldDefinition = formFields.get(type).config; - - if (!VALUES_INPUTS.includes(type) && !hasValuesGroupsConfigured(fieldDefinition)) { - return []; - } - - const context = { editField, field }; - const valuesSourceId = `${fieldId}-valuesSource`; - - /** - * @type {Array} - */ - const groups = [ - { - id: valuesSourceId, - label: 'Options source', - tooltip: getValuesTooltip(), - component: Group, - entries: ValuesSourceSelectEntry({ ...context, id: valuesSourceId }) - } - ]; - - const valuesSource = getValuesSource(field); - - if (valuesSource === VALUES_SOURCES.INPUT) { - const dynamicValuesId = `${fieldId}-dynamicValues`; - groups.push({ - id: dynamicValuesId, - label: 'Dynamic options', - component: Group, - entries: InputKeyValuesSourceEntry({ ...context, id: dynamicValuesId }) - }); - } else if (valuesSource === VALUES_SOURCES.STATIC) { - const staticValuesId = `${fieldId}-staticValues`; - groups.push({ - id: staticValuesId, - label: 'Static options', - component: ListGroup, - ...StaticValuesSourceEntry({ ...context, id: staticValuesId }) - }); - } else if (valuesSource === VALUES_SOURCES.EXPRESSION) { - const valuesExpressionId = `${fieldId}-valuesExpression`; - groups.push({ - id: valuesExpressionId, - label: 'Options expression', - component: Group, - entries: ValuesExpressionEntry({ ...context, id: valuesExpressionId }) - }); - } - - return groups; -} - -// helpers ////////// - -function getValuesTooltip() { - return '"Static" defines a constant, predefined set of form options.\n\n' + - '"Input data" defines options that are populated dynamically, adjusting based on variable data for flexible responses to different conditions or inputs.\n\n' + - '"Expression" defines options that are populated from a FEEL expression.'; -} \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/groups/index.js b/packages/form-js-editor/src/features/properties-panel/groups/index.js index abc801f15..e473bd2bd 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/index.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/index.js @@ -2,7 +2,7 @@ export { default as GeneralGroup } from './GeneralGroup'; export { default as SerializationGroup } from './SerializationGroup'; export { default as ConstraintsGroup } from './ConstraintsGroup'; export { default as ValidationGroup } from './ValidationGroup'; -export { default as ValuesGroups } from './ValuesGroups'; +export { default as OptionsGroups } from './OptionsGroups'; export { default as CustomPropertiesGroup } from './CustomPropertiesGroup'; export { default as AppearanceGroup } from './AppearanceGroup'; export { default as LayoutGroup } from './LayoutGroup'; diff --git a/packages/form-js-editor/src/features/repeat-render/EditorRepeatRenderManager.js b/packages/form-js-editor/src/features/repeat-render/EditorRepeatRenderManager.js new file mode 100644 index 000000000..5337f57bc --- /dev/null +++ b/packages/form-js-editor/src/features/repeat-render/EditorRepeatRenderManager.js @@ -0,0 +1,34 @@ +import RepeatSvg from '../../render/components/icons/Repeat.svg'; + +export default class RepeatRenderManager { + + constructor(formFields, formFieldRegistry) { + this._formFields = formFields; + this._formFieldRegistry = formFieldRegistry; + this.RepeatFooter = this.RepeatFooter.bind(this); + } + + /** + * Checks whether a field should be repeatable. + * + * @param {string} id - The id of the field to check + * @returns {boolean} - True if repeatable, false otherwise + */ + isFieldRepeating(id) { + + if (!id) { + return false; + } + + const formField = this._formFieldRegistry.get(id); + const formFieldDefinition = this._formFields.get(formField.type); + return formFieldDefinition.config.repeatable && formField.isRepeating; + } + + RepeatFooter() { + return
Repeatable
; + } + +} + +RepeatRenderManager.$inject = [ 'formFields', 'formFieldRegistry' ]; \ No newline at end of file diff --git a/packages/form-js-editor/src/features/repeat-render/index.js b/packages/form-js-editor/src/features/repeat-render/index.js new file mode 100644 index 000000000..7d8bb2dd0 --- /dev/null +++ b/packages/form-js-editor/src/features/repeat-render/index.js @@ -0,0 +1,8 @@ +import EditorRepeatRenderManager from './EditorRepeatRenderManager'; + +export default { + __init__: [ 'repeatRenderManager' ], + repeatRenderManager: [ 'type', EditorRepeatRenderManager ], +}; + +export { EditorRepeatRenderManager }; \ No newline at end of file diff --git a/packages/form-js-editor/src/render/components/FormEditor.js b/packages/form-js-editor/src/render/components/FormEditor.js index 221df97f8..77ca3b8bc 100644 --- a/packages/form-js-editor/src/render/components/FormEditor.js +++ b/packages/form-js-editor/src/render/components/FormEditor.js @@ -65,17 +65,27 @@ function ContextPad(props) { ); } -function Empty() { return null; } - -function EmptyRoot(props) { - return
-
- -

Build your form

- Drag and drop components here to start designing. - Use the preview window to test your form. -
-
; +function Empty(props) { + if (props.field.type === 'default') { + return
+
+ +

Build your form

+ Drag and drop components here to start designing. + Use the preview window to test your form. +
+
; + } + + if (props.field.type === 'group') { + return
Drag and drop components here.
; + } + + if (props.field.type === 'dynamiclist') { + return
Drag and drop components here
to create a repeatable list item.
; + } + + return null; } function Element(props) { @@ -86,7 +96,7 @@ function Element(props) { modeling = useService('modeling'), selection = useService('selection'); - const { hoveredId, setHoveredId } = useContext(FormRenderContext); + const { hoverInfo } = useContext(FormRenderContext); const { field } = props; @@ -98,6 +108,8 @@ function Element(props) { const ref = useRef(); + const [ hovered, setHovered ] = useState(false); + function scrollIntoView({ selection }) { if (!selection || selection.id !== id || !ref.current) { return; @@ -132,23 +144,31 @@ function Element(props) { ref.current.focus(); } - const classes = []; + const isSelected = selection.isSelected(field); - if (props.class) { - classes.push(...props.class.split(' ')); - } + const classString = useMemo(() => { - if (selection.isSelected(field)) { - classes.push('fjs-editor-selected'); - } + const classes = []; - if (showOutline) { - classes.push('fjs-outlined'); - } + if (props.class) { + classes.push(...props.class.split(' ')); + } - if (hoveredId === field.id) { - classes.push('fjs-editor-hovered'); - } + if (isSelected) { + classes.push('fjs-editor-selected'); + } + + if (showOutline) { + classes.push('fjs-outlined'); + } + + if (hovered) { + classes.push('fjs-editor-hovered'); + } + + return classes.join(' '); + + }, [ hovered, isSelected, props.class, showOutline ]); const onRemove = (event) => { event.stopPropagation(); @@ -169,7 +189,7 @@ function Element(props) { return (
{ + if (hoverInfo.cleanup) { + hoverInfo.cleanup(); + } - // @ts-ignore - setHoveredId(field.id); + setHovered(true); + hoverInfo.cleanup = () => setHovered(false); e.stopPropagation(); } } @@ -267,6 +290,7 @@ function Row(props) {
{ props.children }
@@ -351,7 +375,6 @@ export default function FormEditor(props) { const onDetach = () => { if (dragulaInstance) { dragulaInstance.destroy(); - eventBus.fire('dragula.destroyed'); } }; @@ -407,18 +430,14 @@ export default function FormEditor(props) { eventBus.fire('formEditor.rendered'); }, []); - const [ hoveredId, setHoveredId ] = useState(null); - const formRenderContext = useMemo(() => ({ Children, Column, Element, Empty, - EmptyRoot, Row, - hoveredId, - setHoveredId - }), [ hoveredId ]); + hoverInfo: {} + }), []); const formContext = useMemo(() => ({ getService(type, strict = true) { diff --git a/packages/form-js-editor/src/render/components/icons/Repeat.svg b/packages/form-js-editor/src/render/components/icons/Repeat.svg new file mode 100644 index 000000000..86d18efa5 --- /dev/null +++ b/packages/form-js-editor/src/render/components/icons/Repeat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/form-js-editor/test/helper/index.js b/packages/form-js-editor/test/helper/index.js index 4548918e5..b42263074 100644 --- a/packages/form-js-editor/test/helper/index.js +++ b/packages/form-js-editor/test/helper/index.js @@ -4,6 +4,10 @@ import { merge } from 'min-dash'; +import { FormEditorContext } from '../../src/render/context'; + +import { createMockInjector } from './mocks'; + import { act } from 'preact/test-utils'; import TestContainer from 'mocha-test-container-support'; @@ -179,3 +183,31 @@ export async function setEditorValue(editor, value) { } export { expectNoViolations } from '../../../form-js-viewer/test/helper'; + +export function countComponents(root) { + + if (!Array.isArray(root.components)) { + return 1; + } + + return root.components.reduce((count, component) => count + countComponents(component), 1); +} + +export const MockEditorContext = (props) => { + + const { + services, + options + } = props; + + const formEditorContext = { + getService: (type, strict) => createMockInjector(services, options).get(type, strict), + }; + + return ( + + { props.children } + + ); + +}; \ No newline at end of file diff --git a/packages/form-js-editor/test/helper/mocks/index.js b/packages/form-js-editor/test/helper/mocks/index.js new file mode 100644 index 000000000..741db224d --- /dev/null +++ b/packages/form-js-editor/test/helper/mocks/index.js @@ -0,0 +1,165 @@ + +import { Injector } from 'didi'; +import { isUndefined } from 'min-dash'; + +import EditorFormFields from '../../../src/render/EditorFormFields'; + +const EDITOR_CONFIG = { + propertiesPanel: { + debounce: false + } +}; + +export function createMockInjector(services = {}, options = {}) { + const injector = new Injector([ _createEditorMockModule(services, options) ]); + injector.init(); + return injector; +} + +function _createEditorMockModule(services, options) { + return { + formEditor: [ 'value', services.formEditor || new FormEditorMock(options) ], + formLayoutValidator: [ 'value', services.formLayoutValidator || new FormLayoutValidatorMock(options) ], + eventBus: [ 'value', services.eventBus || new EventBusMock(options) ], + propertiesPanel: [ 'value', services.propertiesPanel || new PropertiesPanelMock(options) ], + expressionLanguage: [ 'value', services.expressionLanguage || new ExpressionLanguageMock(options) ], + modeling: [ 'value', services.modeling || new ModelingMock(options) ], + selection: [ 'value', services.selection || new SelectionMock(options) ], + templating: [ 'value', services.templating || new TemplatingMock(options) ], + formFieldRegistry: [ 'value', services.formFieldRegistry || new FormFieldRegistryMock(options) ], + pathRegistry: [ 'value', services.pathRegistry || new PathRegistryMock(options) ], + debounce: [ 'value', services.debounce || (fn => fn) ], + config: [ 'value', services.config || EDITOR_CONFIG ], + + // using actual implementations in testing + formFields: services.formFields ? [ 'value', services.formFields ] : [ 'type', EditorFormFields ], + }; +} + +export class FormEditorMock { + + constructor(options = {}) { + this._state = { + schema: isUndefined(options.schema) ? {} : options.schema, + properties: options.properties || {} + }; + } + + getSchema() { + return this._state.schema; + } + + _getState() { + return this._state; + } +} + +export class ModelingMock { + editFormField() {} +} + +export class PropertiesPanelMock { + registerProvider() {} +} + +export class SelectionMock { + constructor(options = {}) { + this._selection = options.field; + } + + get() { + return this._selection; + } +} + +export class FormFieldRegistryMock { + + constructor() { + this._ids = { + assigned() { + return false; + } + }; + + } + + add() {} + remove() {} + get() {} + getAll() { + return []; + } + forEach() {} + clear() {} +} + +export class PathRegistryMock { + + constructor(options) { + this._valuePaths = options.valuePaths || {}; + this._claimedPaths = options.claimedPaths || []; + } + + getValuePath(field) { + + if (this._valuePaths[ field.id ]) { + return this._valuePaths[ field.id ]; + } + + return [ field.key ]; + } + + canClaimPath(path) { + return !this._claimedPaths.some(claimedPath => path.join('.') === claimedPath); + } + + unclaimPath() {} + claimPath() {} +} + +export class EventBusMock { + constructor() { + this.listeners = {}; + } + + on(event, priority, callback) { + if (!callback) { + callback = priority; + } + + if (!this.listeners[ event ]) { + this.listeners[ event ] = []; + } + + this.listeners[ event ].push(callback); + } + + off() {} + + fire(event, context) { + if (this.listeners[ event ]) { + this.listeners[ event ].forEach(callback => callback(context)); + } + } +} + +export class ExpressionLanguageMock { + isExpression() {} +} + +export class FormLayoutValidatorMock { + validateField() {} +} + +export class TemplatingMock { + constructor(options = {}) { + + const { + isTemplate = () => false, + evaluate = (value) => `Evaluation of "${value}"` + } = options; + + this.isTemplate = isTemplate; + this.evaluate = evaluate; + } +} diff --git a/packages/form-js-editor/test/spec/FormEditor.spec.js b/packages/form-js-editor/test/spec/FormEditor.spec.js index cc8114476..d7905e2d2 100644 --- a/packages/form-js-editor/test/spec/FormEditor.spec.js +++ b/packages/form-js-editor/test/spec/FormEditor.spec.js @@ -19,6 +19,7 @@ import { insertStyles, insertTheme, isSingleStart, + countComponents, expectNoViolations } from '../TestHelper'; @@ -38,12 +39,18 @@ const singleStartNoTheme = isSingleStart('no-theme'); const singleStart = singleStartBasic || singleStartRows || singleStartTheme || singleStartNoTheme; - describe('FormEditor', function() { let container, formEditor; + const bootstrapFormEditor = ({ bootstrapExecute = () => {}, ...options }) => { + return act(async () => { + formEditor = await createFormEditor(options); + bootstrapExecute(formEditor); + }); + }; + beforeEach(function() { container = document.createElement('div'); @@ -54,14 +61,14 @@ describe('FormEditor', function() { !singleStart && afterEach(function() { document.body.removeChild(container); - formEditor && formEditor.destroy(); + formEditor = null; }); (singleStartBasic ? it.only : it)('should render', async function() { // when - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema, keyboard: { @@ -74,14 +81,14 @@ describe('FormEditor', function() { }); // then - expect(formEditor.get('formFieldRegistry').getAll()).to.have.length(20); + expect(formEditor.get('formFieldRegistry').getAll()).to.have.length(countComponents(schema)); }); (singleStartRows ? it.only : it)('should render rows layout', async function() { // when - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema: schemaRows, keyboard: { @@ -106,7 +113,7 @@ describe('FormEditor', function() { insertTheme(); // when - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema, keyboard: { @@ -115,7 +122,7 @@ describe('FormEditor', function() { }); // then - expect(formEditor.get('formFieldRegistry').getAll()).to.have.length(20); + expect(formEditor.get('formFieldRegistry').getAll()).to.have.length(countComponents(schema)); }); @@ -126,7 +133,7 @@ describe('FormEditor', function() { container.style.backgroundColor = 'white'; insertTheme(); - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema, keyboard: { @@ -138,85 +145,78 @@ describe('FormEditor', function() { container.querySelector('.fjs-container').classList.add('fjs-no-theme'); // then - expect(formEditor.get('formFieldRegistry').getAll()).to.have.length(20); + expect(formEditor.get('formFieldRegistry').getAll()).to.have.length(countComponents(schema)); }); - it('should render compact', async function() { // when - await act(async () => { - formEditor = await createFormEditor({ - container, - schema, - debounce: true, - renderer: { - compact: true - }, - keyboard: { - bindTo: document - } - }); + await bootstrapFormEditor({ + container, + schema, + debounce: true, + renderer: { + compact: true + }, + keyboard: { + bindTo: document + } }); // then - let editorContainer; await waitFor(() => { - editorContainer = container.querySelector('.fjs-editor-container'); + const editorContainer = container.querySelector('.fjs-editor-container'); expect(editorContainer).to.exist; + expect(editorContainer.matches('.fjs-editor-compact')).to.be.true; }); - expect(editorContainer.matches('.fjs-editor-compact')).to.be.true; }); it('should render empty placeholder', async function() { // when - await act(async () => { - formEditor = await createFormEditor({ - container, - schema: { - type: 'default' - }, - debounce: true, - keyboard: { - bindTo: document - } - }); + await bootstrapFormEditor({ + container, + schema: { + type: 'default' + }, + debounce: true, + keyboard: { + bindTo: document + } }); // then await waitFor(() => { const editorContainer = container.querySelector('.fjs-editor-container'); expect(editorContainer).to.exist; + + const emptyEditorContainer = container.querySelector('.fjs-empty-editor'); + expect(emptyEditorContainer).to.exist; }); - const emptyEditorContainer = container.querySelector('.fjs-empty-editor'); - expect(emptyEditorContainer).to.exist; }); it('should NOT render empty placeholder', async function() { // when - await act(async () => { - formEditor = await createFormEditor({ - container, - schema: { - type: 'default', - components: [ - { - type: 'textfield' - } - ] - }, - debounce: true, - keyboard: { - bindTo: document - } - }); + await bootstrapFormEditor({ + container, + schema: { + type: 'default', + components: [ + { + type: 'textfield' + } + ] + }, + debounce: true, + keyboard: { + bindTo: document + } }); // then @@ -257,7 +257,7 @@ describe('FormEditor', function() { await formEditor.importSchema(schema); // then - expect(formEditor.get('formFieldRegistry').getAll()).to.have.length(20); + expect(formEditor.get('formFieldRegistry').getAll()).to.have.length(countComponents(schema)); }); @@ -277,7 +277,7 @@ describe('FormEditor', function() { // when try { - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -357,7 +357,7 @@ describe('FormEditor', function() { it('should attach', async function() { // when - formEditor = await createFormEditor({ + await bootstrapFormEditor({ schema }); @@ -375,7 +375,7 @@ describe('FormEditor', function() { it('should detach', async function() { // when - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -394,7 +394,7 @@ describe('FormEditor', function() { it('#saveSchema', async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -415,7 +415,7 @@ describe('FormEditor', function() { it('#clear', async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -442,7 +442,7 @@ describe('FormEditor', function() { it('#destroy', async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -467,7 +467,7 @@ describe('FormEditor', function() { it('#on', async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -487,7 +487,7 @@ describe('FormEditor', function() { it('#off', async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -536,7 +536,7 @@ describe('FormEditor', function() { it('should expose schema', async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -562,7 +562,7 @@ describe('FormEditor', function() { version: 'bar' }; - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema, exporter @@ -591,7 +591,7 @@ describe('FormEditor', function() { const taggedSchema = exportTagged(schema, oldExporter); - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema: taggedSchema, exporter: newExporter @@ -612,7 +612,7 @@ describe('FormEditor', function() { expect(schemaNoIds.id).not.to.exist; // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema: schemaNoIds }); @@ -646,7 +646,7 @@ describe('FormEditor', function() { }; // when - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -674,7 +674,7 @@ describe('FormEditor', function() { it('should provide palette module', async function() { // when - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -685,17 +685,17 @@ describe('FormEditor', function() { it('should render palette per default', async function() { - // given - formEditor = await createFormEditor({ + // when + await bootstrapFormEditor({ container, schema }); - // when - const paletteContainer = domQuery('.fjs-palette-container', container); - // then - expect(paletteContainer).to.exist; + await waitFor(() => { + const paletteContainer = domQuery('.fjs-palette-container', container); + expect(paletteContainer).to.exist; + }); }); @@ -705,7 +705,8 @@ describe('FormEditor', function() { const paletteParent = document.createElement('div'); document.body.appendChild(paletteParent); - formEditor = await createFormEditor({ + // when + await bootstrapFormEditor({ container, schema, palette: { @@ -713,12 +714,13 @@ describe('FormEditor', function() { } }); - // when - const paletteContainer = domQuery('.fjs-palette-container', paletteParent); - // then - expect(paletteContainer).to.exist; + await waitFor(() => { + const paletteContainer = paletteParent.querySelector('.fjs-palette-container'); + expect(paletteContainer).to.exist; + }); + // cleanup document.body.removeChild(paletteParent); }); @@ -730,7 +732,7 @@ describe('FormEditor', function() { it('should provide propertiesPanel module', async function() { // when - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -745,7 +747,7 @@ describe('FormEditor', function() { const propertiesParent = document.createElement('div'); document.body.appendChild(propertiesParent); - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema, propertiesPanel: { @@ -782,7 +784,7 @@ describe('FormEditor', function() { it('should show schema per default', async function() { // when - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -798,7 +800,7 @@ describe('FormEditor', function() { it('should update on selection changed', async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ container, schema }); @@ -837,7 +839,7 @@ describe('FormEditor', function() { it('should emit event on properties panel focus', async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ schema, container }); @@ -871,7 +873,7 @@ describe('FormEditor', function() { it('should emit event on properties panel blur', async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ schema, container }); @@ -913,18 +915,17 @@ describe('FormEditor', function() { it('should enable drag and drop on mount', async function() { // given - formEditor = await createFormEditor({ - schema, - container - }); - const dragulaCreatedSpy = spy(), dragulaDestroyedSpy = spy(); - formEditor.on('dragula.created', dragulaCreatedSpy); - formEditor.on('dragula.destroyed', dragulaDestroyedSpy); - - await waitFor(() => expect(dragulaCreatedSpy).to.have.been.calledOnce); + await bootstrapFormEditor({ + schema, + container, + bootstrapExecute: editor => { + editor.on('dragula.created', dragulaCreatedSpy); + editor.on('dragula.destroyed', dragulaDestroyedSpy); + } + }); // then expect(dragulaCreatedSpy).to.have.been.calledOnce; @@ -935,40 +936,46 @@ describe('FormEditor', function() { it('should enable drag and drop on attach', async function() { // given - formEditor = await createFormEditor({ - schema - }); - const dragulaCreatedSpy = spy(), dragulaDestroyedSpy = spy(); - formEditor.on('dragula.created', dragulaCreatedSpy); - formEditor.on('dragula.destroyed', dragulaDestroyedSpy); + await bootstrapFormEditor({ + schema, + container, + bootstrapExecute: editor => { + editor.on('dragula.created', dragulaCreatedSpy); + editor.on('dragula.destroyed', dragulaDestroyedSpy); + } + }); - await waitFor(() => expect(dragulaCreatedSpy).to.have.been.calledOnce); + expect(dragulaCreatedSpy).to.have.been.calledOnce; + expect(dragulaDestroyedSpy).not.to.have.been.called; // when - formEditor.attachTo(container); + act(() => formEditor.attachTo(container)); // then + // todo (@skaiir): investigate why this is called twice + // expect(dragulaDestroyedSpy).to.have.been.calledOnce; + expect(dragulaDestroyedSpy).to.have.been.calledTwice; expect(dragulaCreatedSpy).to.have.been.calledTwice; - expect(dragulaDestroyedSpy).to.have.been.calledOnce; }); it('should disable drag and drop on detach', async function() { // given - formEditor = await createFormEditor({ - schema, - container - }); - const dragulaCreatedSpy = spy(), dragulaDestroyedSpy = spy(); - formEditor.on('dragula.created', dragulaCreatedSpy); - formEditor.on('dragula.destroyed', dragulaDestroyedSpy); + await bootstrapFormEditor({ + schema, + container, + bootstrapExecute: editor => { + editor.on('dragula.created', dragulaCreatedSpy); + editor.on('dragula.destroyed', dragulaDestroyedSpy); + } + }); await waitFor(() => expect(dragulaCreatedSpy).to.have.been.calledOnce); @@ -984,17 +991,22 @@ describe('FormEditor', function() { it('should create and select new form field', async function() { // given - formEditor = await createFormEditor({ + let dragulaCreated = false; + + await bootstrapFormEditor({ schema, - container + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + } }); - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // assume const formFieldRegistry = formEditor.get('formFieldRegistry'); - expect(formFieldRegistry.getAll()).to.have.length(20); + expect(formFieldRegistry.getAll()).to.have.length(countComponents(schema)); // when startDragging(container); @@ -1002,7 +1014,7 @@ describe('FormEditor', function() { endDragging(container); // then - expect(formFieldRegistry.getAll()).to.have.length(21); + expect(formFieldRegistry.getAll()).to.have.length(countComponents(schema) + 1); const selection = formEditor.get('selection'); @@ -1013,14 +1025,19 @@ describe('FormEditor', function() { it('should move form field', async function() { // given - formEditor = await createFormEditor({ + let dragulaCreated = false; + + await bootstrapFormEditor({ schema: schemaRows, - container + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + } }); const formFieldRegistry = formEditor.get('formFieldRegistry'); - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // assume expectLayout(formFieldRegistry.get('Textfield_1'), { @@ -1053,14 +1070,19 @@ describe('FormEditor', function() { it('should move form field into group', async function() { // given - formEditor = await createFormEditor({ + let dragulaCreated = false; + + await bootstrapFormEditor({ schema: schemaGroup, - container + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + } }); const formFieldRegistry = formEditor.get('formFieldRegistry'); - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // assume expectLayout(formFieldRegistry.get('Textfield_1'), { @@ -1093,14 +1115,19 @@ describe('FormEditor', function() { it('should NOT move form field - invalid', async function() { // given - formEditor = await createFormEditor({ + let dragulaCreated = false; + + await bootstrapFormEditor({ schema: schemaRows, - container + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + } }); const formFieldRegistry = formEditor.get('formFieldRegistry'); - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // assume expectLayout(formFieldRegistry.get('Textfield_1'), { @@ -1129,23 +1156,28 @@ describe('FormEditor', function() { }); - // flaky, skipped for now until we migrate it to playwright - it.skip('should move row', async function() { + it('should move row', async function() { // given - formEditor = await createFormEditor({ + let dragulaCreated = false; + + await bootstrapFormEditor({ schema: schemaRows, - container + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + } }); - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // assume expect(getRowOrder(container)).to.eql([ 'Row_1', 'Row_2', 'Row_3', - 'Row_4' + 'Row_4', + 'Row_5' ]); const row = container.querySelector('[data-row-id="Row_1"]'); @@ -1168,7 +1200,8 @@ describe('FormEditor', function() { 'Row_2', 'Row_1', 'Row_3', - 'Row_4' + 'Row_4', + 'Row_5' ]); }); @@ -1178,16 +1211,19 @@ describe('FormEditor', function() { it('should emit ', async function() { // given - formEditor = await createFormEditor({ - schema, - container - }); - + let dragulaCreated = false; const draggerSpy = spy(); - formEditor.on('drag.start', draggerSpy); + await bootstrapFormEditor({ + schema, + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + editor.on('drag.start', draggerSpy); + } + }); - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // when startDragging(container); @@ -1205,16 +1241,19 @@ describe('FormEditor', function() { it('should emit ', async function() { // given - formEditor = await createFormEditor({ - schema, - container - }); - + let dragulaCreated = false; const draggerSpy = spy(); - formEditor.on('drag.end', draggerSpy); + await bootstrapFormEditor({ + schema, + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + editor.on('drag.end', draggerSpy); + } + }); - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // when startDragging(container); @@ -1232,16 +1271,19 @@ describe('FormEditor', function() { it('should emit ', async function() { // given - formEditor = await createFormEditor({ - schema, - container - }); - + let dragulaCreated = false; const draggerSpy = spy(); - formEditor.on('drag.drop', draggerSpy); + await bootstrapFormEditor({ + schema, + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + editor.on('drag.drop', draggerSpy); + } + }); - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // when startDragging(container); @@ -1262,44 +1304,19 @@ describe('FormEditor', function() { it('should emit ', async function() { // given - formEditor = await createFormEditor({ - schema, - container - }); - + let dragulaCreated = false; const draggerSpy = spy(); - formEditor.on('drag.hover', draggerSpy); - - await expectDragulaCreated(formEditor); - - // when - startDragging(container); - moveDragging(container); - - // then - expect(draggerSpy).to.have.been.called; - - const context = draggerSpy.args[0][1]; - expect(context.element).to.exist; - expect(context.container).to.exist; - expect(context.source).to.exist; - }); - - - it('should emit ', async function() { - - // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ schema, - container + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + editor.on('drag.hover', draggerSpy); + } }); - const draggerSpy = spy(); - - formEditor.on('drag.hover', draggerSpy); - - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // when startDragging(container); @@ -1319,16 +1336,19 @@ describe('FormEditor', function() { it('should emit ', async function() { // given - formEditor = await createFormEditor({ - schema, - container - }); - + let dragulaCreated = false; const draggerSpy = spy(); - formEditor.on('drag.out', draggerSpy); + await bootstrapFormEditor({ + schema, + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + editor.on('drag.out', draggerSpy); + } + }); - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // when startDragging(container); @@ -1348,16 +1368,19 @@ describe('FormEditor', function() { it('should emit ', async function() { // given - formEditor = await createFormEditor({ - schema, - container - }); - + let dragulaCreated = false; const draggerSpy = spy(); - formEditor.on('drag.cancel', draggerSpy); + await bootstrapFormEditor({ + schema, + container, + bootstrapExecute: editor => { + editor.on('dragula.created', () => { dragulaCreated = true; }); + editor.on('drag.cancel', draggerSpy); + } + }); - await expectDragulaCreated(formEditor); + expect(dragulaCreated).to.be.true; // when startDragging(container); @@ -1385,7 +1408,7 @@ describe('FormEditor', function() { it(test, async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ schema: schemaRows, container }); @@ -1442,7 +1465,7 @@ describe('FormEditor', function() { it('render resize handles', async function() { // given - formEditor = await createFormEditor({ + await bootstrapFormEditor({ schema, container }); @@ -1536,7 +1559,7 @@ describe('FormEditor', function() { // given this.timeout(5000); - await createFormEditor({ + await bootstrapFormEditor({ schema, container }); @@ -1580,18 +1603,6 @@ function expectLayout(field, layout) { expect(field.layout).to.eql(layout); } -async function expectDragulaCreated(formEditor) { - let dragulaCreated = false; - - formEditor.on('dragula.created', () => { - dragulaCreated = true; - }); - - await waitFor(() => { - expect(dragulaCreated).to.be.true; - }); -} - function dispatchEvent(element, type, options = {}) { const event = document.createEvent('Event'); diff --git a/packages/form-js-editor/test/spec/features/modeling/behavior/KeyBehavior.spec.js b/packages/form-js-editor/test/spec/features/modeling/behavior/KeyBehavior.spec.js index 5f2759fb6..0be73e854 100644 --- a/packages/form-js-editor/test/spec/features/modeling/behavior/KeyBehavior.spec.js +++ b/packages/form-js-editor/test/spec/features/modeling/behavior/KeyBehavior.spec.js @@ -52,7 +52,7 @@ describe('features/modeling - KeyBehavior', function() { // then expect(pathRegistry.canClaimPath([ oldKey ])).to.be.true; - expect(pathRegistry.canClaimPath([ 'user' ], true)).to.be.false; + expect(pathRegistry.canClaimPath([ 'user' ], { isClosed: true })).to.be.false; expect(pathRegistry.canClaimPath([ 'user' ])).to.be.true; expect(pathRegistry.canClaimPath([ 'user', 'creditor' ])).to.be.false; })); diff --git a/packages/form-js-editor/test/spec/features/modeling/behavior/ValuesSourceBehavior.spec.js b/packages/form-js-editor/test/spec/features/modeling/behavior/OptionsSourceBehavior.spec.js similarity index 97% rename from packages/form-js-editor/test/spec/features/modeling/behavior/ValuesSourceBehavior.spec.js rename to packages/form-js-editor/test/spec/features/modeling/behavior/OptionsSourceBehavior.spec.js index dc8161c5d..2fafae197 100644 --- a/packages/form-js-editor/test/spec/features/modeling/behavior/ValuesSourceBehavior.spec.js +++ b/packages/form-js-editor/test/spec/features/modeling/behavior/OptionsSourceBehavior.spec.js @@ -7,7 +7,7 @@ import modelingModule from 'src/features/modeling'; import schema from '../../../defaultValues.json'; -describe('features/modeling - ValuesSourceBehavior', function() { +describe('features/modeling - OptionsSourceBehavior', function() { beforeEach(bootstrapFormEditor(schema, { additionalModules: [ diff --git a/packages/form-js-editor/test/spec/features/modeling/behavior/PathBehavior.spec.js b/packages/form-js-editor/test/spec/features/modeling/behavior/PathBehavior.spec.js index 742b06aa7..78852bebe 100644 --- a/packages/form-js-editor/test/spec/features/modeling/behavior/PathBehavior.spec.js +++ b/packages/form-js-editor/test/spec/features/modeling/behavior/PathBehavior.spec.js @@ -24,9 +24,9 @@ describe('features/modeling - PathBehavior', function() { sourceIndex = parent.components.indexOf(group); // then - expect(pathRegistry.canClaimPath([ 'invoiceDetails' ], true)).to.be.false; - expect(pathRegistry.canClaimPath([ 'invoiceDetails', 'supplementaryInfo1' ], true)).to.be.false; - expect(pathRegistry.canClaimPath([ 'invoiceDetails', 'supplementaryInfo2' ], true)).to.be.false; + expect(pathRegistry.canClaimPath([ 'invoiceDetails' ], { isClosed: true })).to.be.false; + expect(pathRegistry.canClaimPath([ 'invoiceDetails', 'supplementaryInfo1' ], { isClosed: true })).to.be.false; + expect(pathRegistry.canClaimPath([ 'invoiceDetails', 'supplementaryInfo2' ], { isClosed: true })).to.be.false; // but when modeling.removeFormField( @@ -56,13 +56,13 @@ describe('features/modeling - PathBehavior', function() { ); // then - expect(pathRegistry.canClaimPath([ oldPath ], true)).to.be.true; - expect(pathRegistry.canClaimPath([ oldPath, 'supplementaryInfo1' ], true)).to.be.true; - expect(pathRegistry.canClaimPath([ oldPath, 'supplementaryInfo2' ], true)).to.be.true; + expect(pathRegistry.canClaimPath([ oldPath ], { isClosed: true })).to.be.true; + expect(pathRegistry.canClaimPath([ oldPath, 'supplementaryInfo1' ], { isClosed: true })).to.be.true; + expect(pathRegistry.canClaimPath([ oldPath, 'supplementaryInfo2' ], { isClosed: true })).to.be.true; - expect(pathRegistry.canClaimPath([ 'invoiceDetails2' ], true)).to.be.false; - expect(pathRegistry.canClaimPath([ 'invoiceDetails2', 'supplementaryInfo1' ], true)).to.be.false; - expect(pathRegistry.canClaimPath([ 'invoiceDetails2', 'supplementaryInfo2' ], true)).to.be.false; + expect(pathRegistry.canClaimPath([ 'invoiceDetails2' ], { isClosed: true })).to.be.false; + expect(pathRegistry.canClaimPath([ 'invoiceDetails2', 'supplementaryInfo1' ], { isClosed: true })).to.be.false; + expect(pathRegistry.canClaimPath([ 'invoiceDetails2', 'supplementaryInfo2' ], { isClosed: true })).to.be.false; })); }); diff --git a/packages/form-js-editor/test/spec/features/palette/Palette.spec.js b/packages/form-js-editor/test/spec/features/palette/Palette.spec.js index 8ae5aa19e..e6b4b019a 100644 --- a/packages/form-js-editor/test/spec/features/palette/Palette.spec.js +++ b/packages/form-js-editor/test/spec/features/palette/Palette.spec.js @@ -9,7 +9,7 @@ import Palette, { import { expectNoViolations, insertStyles } from '../../../TestHelper'; -import { WithFormEditorContext } from '../properties-panel/helper'; +import { MockEditorContext } from '../../../helper'; insertStyles(); @@ -191,8 +191,10 @@ describe('palette', function() { const result = createPalette({ container, - modeling: { addFormField: spy }, - formEditor: { _getState: () => ({ schema }) } + services: { + modeling: { addFormField: spy }, + formEditor: { _getState: () => ({ schema }) } + } }); const entry = result.container.querySelector('[data-field-type="textfield"]'); @@ -220,8 +222,10 @@ describe('palette', function() { const result = createPalette({ container, - modeling: { addFormField: spy }, - formEditor: { _getState: () => ({ schema }) } + services: { + modeling: { addFormField: spy }, + formEditor: { _getState: () => ({ schema }) } + } }); const entry = result.container.querySelector('[data-field-type="textfield"]'); @@ -286,7 +290,7 @@ describe('palette', function() { formFields.register('custom', extension); // given - const result = createPalette({ container, formFields }); + const result = createPalette({ container, services: { formFields } }); const paletteEntries = collectPaletteEntries(formFields); @@ -317,7 +321,7 @@ describe('palette', function() { formFields.register('custom', extension); // given - const result = createPalette({ container, formFields }); + const result = createPalette({ container, services: { formFields } }); // then expect(result.container.querySelector('.custom-icon')).to.exist; @@ -340,7 +344,7 @@ describe('palette', function() { formFields.register('custom', extension); // given - const result = createPalette({ container, formFields }); + const result = createPalette({ container, services: { formFields } }); const iconImage = result.container.querySelector('.fjs-field-icon-image'); @@ -356,14 +360,13 @@ describe('palette', function() { // helper /////////////// -function createPalette(options = {}) { - const { - container, - ...rest - } = options; +function createPalette({ services, container, ...restOptions } = {}) { + return render( - WithFormEditorContext(, rest), + + + , { container } 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 bdcf1dafc..3997f09f3 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 @@ -9,7 +9,7 @@ import { import { query as domQuery } from 'min-dom'; import PropertiesPanel from '../../../../src/features/properties-panel/PropertiesPanel'; -import { VALUES_SOURCES, VALUES_SOURCES_DEFAULTS } from '@bpmn-io/form-js-viewer'; +import { OPTIONS_SOURCES, OPTIONS_SOURCES_DEFAULTS } from '@bpmn-io/form-js-viewer'; import { removeKey } from '../../../../src/features/properties-panel/groups/CustomPropertiesGroup'; import PropertiesProvider from '../../../../src/features/properties-panel/PropertiesProvider'; @@ -17,16 +17,10 @@ import PropertiesProvider from '../../../../src/features/properties-panel/Proper import { FormFields } from '@bpmn-io/form-js-viewer'; import { - EventBus as eventBusMock, - FormEditor as formEditorMock, - FormLayoutValidator as formLayoutValidatorMock, - Selection as selectionMock, - Modeling as modelingMock, - Templating as templatingMock, - PathRegistry as pathRegistryMock, - Injector as injectorMock, - PropertiesPanelMock as propertiesPanelMock -} from './helper'; + EventBusMock, + PropertiesPanelMock, + createMockInjector +} from './helper/mocks'; import schema from '../../form.json'; import defaultValuesSchema from '../../defaultValues.json'; @@ -45,8 +39,14 @@ const spy = sinon.spy; describe('properties panel', function() { - let parent, - container; + let parent, container, propertiesPanel; + + const bootstrapPropertiesPanel = ({ bootstrapExecute = () => {}, ...options }) => { + return act(() => { + propertiesPanel = createPropertiesPanel(options); + bootstrapExecute(propertiesPanel); + }); + }; beforeEach(function() { parent = document.createElement('div'); @@ -73,10 +73,10 @@ describe('properties panel', function() { it('should render (no field)', async function() { // given - const result = createPropertiesPanel({ container, schema: null }); + bootstrapPropertiesPanel({ container, schema: null }); // then - const placeholder = result.container.querySelector('.bio-properties-panel-placeholder'); + const placeholder = propertiesPanel.container.querySelector('.bio-properties-panel-placeholder'); const text = placeholder.querySelector('.bio-properties-panel-placeholder-text'); expect(placeholder).to.exist; @@ -92,13 +92,13 @@ describe('properties panel', function() { schema.components.find(({ key }) => key === 'invoiceNumber'), ]; - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - const placeholder = result.container.querySelector('.bio-properties-panel-placeholder'); + const placeholder = container.querySelector('.bio-properties-panel-placeholder'); const text = placeholder.querySelector('.bio-properties-panel-placeholder-text'); expect(placeholder).to.exist; @@ -111,16 +111,16 @@ describe('properties panel', function() { // given const field = schema.components.find(({ key }) => key === 'creditor'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expect(result.container.querySelector('.fjs-properties-panel-placeholder')).not.to.exist; + expect(container.querySelector('.fjs-properties-panel-placeholder')).not.to.exist; - expect(result.container.querySelector('.bio-properties-panel-header-type')).to.exist; - expect(result.container.querySelector('.bio-properties-panel-group')).to.exist; + expect(container.querySelector('.bio-properties-panel-header-type')).to.exist; + expect(container.querySelector('.bio-properties-panel-group')).to.exist; }); @@ -131,17 +131,17 @@ describe('properties panel', function() { // given const field = schema; - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'ID' ]); }); @@ -163,7 +163,7 @@ describe('properties panel', function() { // given const editFieldSpy = spy(); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field: schema @@ -191,7 +191,7 @@ describe('properties panel', function() { // given const editFieldSpy = spy(); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field: schema @@ -219,14 +219,16 @@ describe('properties panel', function() { // given const editFieldSpy = spy(); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field: schema, - formFieldRegistry: { - _ids: { - assigned(id) { - return schema.components.find((component) => component.id === id); + services: { + formFieldRegistry: { + _ids: { + assigned(id) { + return schema.components.find((component) => component.id === id); + } } } } @@ -254,7 +256,7 @@ describe('properties panel', function() { // given const editFieldSpy = spy(); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field: schema @@ -286,19 +288,19 @@ describe('properties panel', function() { // given const field = schema.components.find(({ action }) => action === 'submit'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Action' ]); @@ -314,7 +316,7 @@ describe('properties panel', function() { const field = schema.components.find(({ action }) => action === 'reset'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -345,20 +347,20 @@ describe('properties panel', function() { // given const field = schema.components.find(({ key }) => key === 'approved'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Validation', 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', @@ -366,7 +368,7 @@ describe('properties panel', function() { 'Disabled' ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required' ]); }); @@ -381,7 +383,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'approved'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -412,13 +414,13 @@ describe('properties panel', function() { // given const field = schema.components.find(({ key }) => key === 'product'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Options source', @@ -427,7 +429,7 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', @@ -435,16 +437,16 @@ describe('properties panel', function() { 'Disabled' ]); - expectGroupEntries(result.container, 'Options source', [ + expectGroupEntries(container, 'Options source', [ 'Type' ]); - expectGroupEntries(result.container, 'Static options', [ + expectGroupEntries(container, 'Static options', [ [ 'Label', 2 ], [ 'Value', 2 ] ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required' ]); }); @@ -459,7 +461,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'product'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -486,7 +488,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'product'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -508,7 +510,7 @@ describe('properties panel', function() { }); - describe('values', function() { + describe('options', function() { it('should NOT order alphanumerical', function() { @@ -517,14 +519,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'product'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); const list = group.querySelector('.bio-properties-panel-list'); @@ -537,20 +539,20 @@ describe('properties panel', function() { }); - it('should add value', function() { + it('should add option', function() { // given const editFieldSpy = spy(); const field = schema.components.find(({ key }) => key === 'product'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); // when const addEntry = group.querySelector('.bio-properties-panel-add-entry'); @@ -568,20 +570,20 @@ describe('properties panel', function() { }); - it('should add value with different index if already used', function() { + it('should add option with different index if already used', function() { // given const editFieldSpy = spy(); const field = redundantValuesSchema.components.find(({ key }) => key === 'redundantValues'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); // when const addEntry = group.querySelector('.bio-properties-panel-add-entry'); @@ -598,20 +600,20 @@ describe('properties panel', function() { }); - it('should remove value', function() { + it('should remove option', function() { // given const editFieldSpy = spy(); const field = schema.components.find(({ key }) => key === 'product'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); // when const removeEntry = group.querySelector('.bio-properties-panel-remove-entry'); @@ -636,14 +638,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'product'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-Radio_1-staticValues-0-value' }); + const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-staticOptions-0-value' }); fireEvent.input(input, { target: { value: '' } }); @@ -663,14 +665,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'product'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-Radio_1-staticValues-0-value' }); + const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-staticOptions-0-value' }); fireEvent.input(input, { target: { value: 'camunda-cloud' } }); @@ -694,14 +696,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'product'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Label', { selector: '#bio-properties-panel-Radio_1-staticValues-0-label' }); + const input = screen.getByLabelText('Label', { selector: '#bio-properties-panel-staticOptions-0-label' }); fireEvent.input(input, { target: { value: '' } }); @@ -722,14 +724,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'product'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Label', { selector: '#bio-properties-panel-Radio_1-staticValues-0-label' }); + const input = screen.getByLabelText('Label', { selector: '#bio-properties-panel-staticOptions-0-label' }); fireEvent.input(input, { target: { value: 'Camunda Cloud' } }); @@ -757,7 +759,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'product'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -766,16 +768,16 @@ describe('properties panel', function() { // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.INPUT } }); - fireEvent.input(input, { target: { value: VALUES_SOURCES.STATIC } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.INPUT } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.STATIC } }); // then expect(editFieldSpy).to.have.been.calledTwice; expect(editFieldSpy).to.have.been.calledWith(field, { - values: VALUES_SOURCES_DEFAULTS[VALUES_SOURCES.STATIC] + values: OPTIONS_SOURCES_DEFAULTS[OPTIONS_SOURCES.STATIC] }); }); }); @@ -790,7 +792,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'product'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -799,10 +801,10 @@ describe('properties panel', function() { // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.INPUT } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.INPUT } }); // then expect(editFieldSpy).to.have.been.calledOnce; @@ -820,7 +822,7 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'product'); field = { ...field, values: undefined, valuesKey: '' }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -849,7 +851,7 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'product'); field = { ...field, values: undefined, valuesKey: '' }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -880,7 +882,7 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'product'); field = { ...field, values: undefined, valuesKey: '' }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -909,13 +911,13 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'product'); field = { ...field, values: undefined, valuesKey: '' }; - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Options source', @@ -924,22 +926,22 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', 'Disabled' ]); - expectGroupEntries(result.container, 'Options source', [ + expectGroupEntries(container, 'Options source', [ 'Type' ]); - expectGroupEntries(result.container, 'Dynamic options', [ + expectGroupEntries(container, 'Dynamic options', [ 'Input values key' ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required' ]); }); @@ -956,13 +958,13 @@ describe('properties panel', function() { // given const field = schema.components.find(({ key }) => key === 'mailto'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Options source', @@ -971,45 +973,45 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', 'Disabled' ]); - expectGroupEntries(result.container, 'Options source', [ + expectGroupEntries(container, 'Options source', [ 'Type' ]); - expectGroupEntries(result.container, 'Static options', [ + expectGroupEntries(container, 'Static options', [ [ 'Label', 3 ], [ 'Value', 3 ] ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required' ]); }); - describe('values', function() { + describe('options', function() { - it('should add value', function() { + it('should add option', function() { // given const editFieldSpy = spy(); const field = schema.components.find(({ key }) => key === 'mailto'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); // when const addEntry = group.querySelector('.bio-properties-panel-add-entry'); @@ -1027,20 +1029,20 @@ describe('properties panel', function() { }); - it('should remove value', function() { + it('should remove option', function() { // given const editFieldSpy = spy(); const field = schema.components.find(({ key }) => key === 'mailto'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); // when const removeEntry = group.querySelector('.bio-properties-panel-remove-entry'); @@ -1066,14 +1068,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'mailto'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-Checklist_1-staticValues-0-value' }); + const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-staticOptions-0-value' }); fireEvent.input(input, { target: { value: '' } }); @@ -1093,14 +1095,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'mailto'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-Checklist_1-staticValues-0-value' }); + const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-staticOptions-0-value' }); fireEvent.input(input, { target: { value: 'manager' } }); @@ -1128,7 +1130,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'mailto'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -1137,10 +1139,10 @@ describe('properties panel', function() { // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.INPUT } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.INPUT } }); // then expect(editFieldSpy).to.have.been.calledOnce; @@ -1158,7 +1160,7 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'mailto'); field = { ...field, values: undefined, valuesKey: '' }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -1185,13 +1187,13 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'mailto'); field = { ...field, values: undefined, valuesKey: '' }; - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Options source', @@ -1199,18 +1201,18 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', 'Disabled' ]); - expectGroupEntries(result.container, 'Options source', [ + expectGroupEntries(container, 'Options source', [ 'Type' ]); - expectGroupEntries(result.container, 'Dynamic options', [ + expectGroupEntries(container, 'Dynamic options', [ 'Input values key' ]); }); @@ -1227,13 +1229,13 @@ describe('properties panel', function() { // given const field = schema.components.find(({ key }) => key === 'tags'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Options source', @@ -1242,44 +1244,44 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', 'Disabled' ]); - expectGroupEntries(result.container, 'Options source', [ + expectGroupEntries(container, 'Options source', [ 'Type' ]); - expectGroupEntries(result.container, 'Static options', [ + expectGroupEntries(container, 'Static options', [ [ 'Label', 11 ], [ 'Value', 11 ] ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required' ]); }); - describe('values', function() { + describe('options', function() { - it('should add value', function() { + it('should add option', function() { // given const editFieldSpy = spy(); const field = schema.components.find(({ key }) => key === 'tags'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); // when const addEntry = group.querySelector('.bio-properties-panel-add-entry'); @@ -1297,20 +1299,20 @@ describe('properties panel', function() { }); - it('should remove value', function() { + it('should remove option', function() { // given const editFieldSpy = spy(); const field = schema.components.find(({ key }) => key === 'tags'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); // when const removeEntry = group.querySelector('.bio-properties-panel-remove-entry'); @@ -1336,14 +1338,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'tags'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-Taglist_1-staticValues-0-value' }); + const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-staticOptions-0-value' }); fireEvent.input(input, { target: { value: '' } }); @@ -1363,14 +1365,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'tags'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-Taglist_1-staticValues-0-value' }); + const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-staticOptions-0-value' }); fireEvent.input(input, { target: { value: 'tag2' } }); @@ -1398,7 +1400,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'tags'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -1407,10 +1409,10 @@ describe('properties panel', function() { // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.INPUT } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.INPUT } }); // then expect(editFieldSpy).to.have.been.calledOnce; @@ -1428,7 +1430,7 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'tags'); field = { ...field, values: undefined, valuesKey: '' }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -1453,30 +1455,30 @@ describe('properties panel', function() { // given let field = schema.components.find(({ key }) => key === 'tags'); - const eventBus = new eventBusMock(); - - const selection = { - get: () => field - }; + const eventBus = new EventBusMock(); const editField = () => { field = { ...field, values: undefined, valuesKey: '' }; }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField, - eventBus, field, - selection + services: { + eventBus, + selection: { + get: () => field + } + } }); // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.EXPRESSION } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.INPUT } }); await act(() => eventBus.fire('changed')); // then @@ -1494,13 +1496,13 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'tags'); field = { ...field, values: undefined, valuesKey: '' }; - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Options source', @@ -1508,18 +1510,18 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', 'Disabled' ]); - expectGroupEntries(result.container, 'Options source', [ + expectGroupEntries(container, 'Options source', [ 'Type' ]); - expectGroupEntries(result.container, 'Dynamic options', [ + expectGroupEntries(container, 'Dynamic options', [ 'Input values key' ]); @@ -1537,7 +1539,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'tags'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -1546,10 +1548,10 @@ describe('properties panel', function() { // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.EXPRESSION } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.EXPRESSION } }); // then expect(editFieldSpy).to.have.been.calledOnce; @@ -1567,14 +1569,14 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'tags'); field = { ...field, values: undefined, valuesExpression: '=' }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // assume - const input = findTextbox(`${field.id}-valuesExpression-expression`, container); + const input = findTextbox('optionsExpression-expression', container); expect(input.textContent).to.equal(''); @@ -1592,34 +1594,36 @@ describe('properties panel', function() { // given let field = schema.components.find(({ key }) => key === 'tags'); - const eventBus = new eventBusMock(); - - const selection = { - get: () => field - }; + const eventBus = new EventBusMock(); const editField = () => { field = { ...field, values: undefined, valuesExpression: '=' }; }; - createPropertiesPanel({ + const selection = { + get: () => field + }; + + bootstrapPropertiesPanel({ container, editField, - eventBus, field, - selection + services: { + eventBus, + selection + } }); // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.EXPRESSION } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.EXPRESSION } }); await act(() => eventBus.fire('changed')); // then - const editor = findTextbox(`${field.id}-valuesExpression-expression`, container); + const editor = findTextbox('optionsExpression-expression', container); await waitFor(() => { expect(document.activeElement).to.eql(editor); @@ -1633,13 +1637,13 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'tags'); field = { ...field, values: undefined, valuesExpression: '=' }; - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Options source', @@ -1648,22 +1652,22 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', 'Disabled' ]); - expectGroupEntries(result.container, 'Options source', [ + expectGroupEntries(container, 'Options source', [ 'Type' ]); - expectGroupEntries(result.container, 'Options expression', [ + expectGroupEntries(container, 'Options expression', [ 'Options expression' ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required' ]); }); @@ -1680,13 +1684,13 @@ describe('properties panel', function() { // given const field = schema.components.find(({ key }) => key === 'conversation'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Serialization', @@ -1695,7 +1699,7 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Date label', 'Time label', 'Field description', @@ -1705,16 +1709,16 @@ describe('properties panel', function() { 'Disabled' ]); - expectGroupEntries(result.container, 'Serialization', [ + expectGroupEntries(container, 'Serialization', [ 'Time format' ]); - expectGroupEntries(result.container, 'Constraints', [ + expectGroupEntries(container, 'Constraints', [ 'Time interval', 'Disallow past dates' ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required' ]); @@ -1729,13 +1733,13 @@ describe('properties panel', function() { // given const field = schema.components.find(({ key }) => key === 'language'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Options source', @@ -1744,7 +1748,7 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', @@ -1753,16 +1757,16 @@ describe('properties panel', function() { 'Disabled' ]); - expectGroupEntries(result.container, 'Options source', [ + expectGroupEntries(container, 'Options source', [ 'Type' ]); - expectGroupEntries(result.container, 'Static options', [ + expectGroupEntries(container, 'Static options', [ [ 'Label', 2 ], [ 'Value', 2 ] ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required' ]); }); @@ -1777,7 +1781,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'language'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -1804,7 +1808,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'language'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -1831,7 +1835,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'language'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -1853,7 +1857,7 @@ describe('properties panel', function() { }); - describe('values', function() { + describe('options', function() { it('should NOT order alphanumerical', function() { @@ -1862,14 +1866,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'language'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); const list = group.querySelector('.bio-properties-panel-list'); @@ -1892,30 +1896,30 @@ describe('properties panel', function() { valuesKey: 'queriedDRIs' }; - const eventBus = new eventBusMock(); - - const selection = { - get: () => field - }; + const eventBus = new EventBusMock(); const editField = () => { field = { ...field, values: [ 'foo' ], valuesKey: undefined }; }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField, - eventBus, field, - selection + services: { + eventBus, + selection: { + get: () => field + } + } }); // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.INPUT); + expect(input.value).to.equal(OPTIONS_SOURCES.INPUT); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.STATIC } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.STATIC } }); await act(() => eventBus.fire('changed')); // then @@ -1927,20 +1931,20 @@ describe('properties panel', function() { }); - it('should add value', function() { + it('should add option', function() { // given const editFieldSpy = spy(); const field = schema.components.find(({ key }) => key === 'language'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); // when const addEntry = group.querySelector('.bio-properties-panel-add-entry'); @@ -1958,20 +1962,20 @@ describe('properties panel', function() { }); - it('should remove value', function() { + it('should remove option', function() { // given const editFieldSpy = spy(); const field = schema.components.find(({ key }) => key === 'language'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Static options'); + const group = findGroup(container, 'Static options'); // when const removeEntry = group.querySelector('.bio-properties-panel-remove-entry'); @@ -1996,14 +2000,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'language'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-Select_1-staticValues-0-value' }); + const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-staticOptions-0-value' }); fireEvent.input(input, { target: { value: '' } }); @@ -2023,14 +2027,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'language'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-Select_1-staticValues-0-value' }); + const input = screen.getByLabelText('Value', { selector: '#bio-properties-panel-staticOptions-0-value' }); fireEvent.input(input, { target: { value: 'english' } }); @@ -2058,7 +2062,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'language'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2067,10 +2071,10 @@ describe('properties panel', function() { // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.INPUT } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.INPUT } }); // then expect(editFieldSpy).to.have.been.calledOnce; @@ -2088,7 +2092,7 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'language'); field = { ...field, values: undefined, valuesKey: '' }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2113,30 +2117,30 @@ describe('properties panel', function() { // given let field = schema.components.find(({ key }) => key === 'language'); - const eventBus = new eventBusMock(); - - const selection = { - get: () => field - }; + const eventBus = new EventBusMock(); const editField = () => { field = { ...field, values: undefined, valuesKey: '' }; }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField, - eventBus, field, - selection + services: { + eventBus, + selection: { + get: () => field + } + } }); // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.EXPRESSION } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.EXPRESSION } }); await act(() => eventBus.fire('changed')); // then @@ -2154,13 +2158,13 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'language'); field = { ...field, values: undefined, valuesKey: '' }; - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Options source', @@ -2169,22 +2173,22 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', 'Disabled' ]); - expectGroupEntries(result.container, 'Options source', [ + expectGroupEntries(container, 'Options source', [ 'Type' ]); - expectGroupEntries(result.container, 'Dynamic options', [ + expectGroupEntries(container, 'Dynamic options', [ 'Input values key' ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required' ]); }); @@ -2201,7 +2205,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'language'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2210,10 +2214,10 @@ describe('properties panel', function() { // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.EXPRESSION } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.EXPRESSION } }); // then expect(editFieldSpy).to.have.been.calledOnce; @@ -2231,14 +2235,14 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'language'); field = { ...field, values: undefined, valuesExpression: '=' }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // assume - const input = findTextbox(`${field.id}-valuesExpression-expression`, container); + const input = findTextbox('optionsExpression-expression', container); expect(input.textContent).to.equal(''); @@ -2256,34 +2260,34 @@ describe('properties panel', function() { // given let field = schema.components.find(({ key }) => key === 'language'); - const eventBus = new eventBusMock(); - - const selection = { - get: () => field - }; + const eventBus = new EventBusMock(); const editField = () => { field = { ...field, values: undefined, valuesExpression: '=' }; }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField, - eventBus, field, - selection + services: { + eventBus, + selection: { + get: () => field + } + } }); // assume const input = screen.getByLabelText('Type'); - expect(input.value).to.equal(VALUES_SOURCES.STATIC); + expect(input.value).to.equal(OPTIONS_SOURCES.STATIC); // when - fireEvent.input(input, { target: { value: VALUES_SOURCES.EXPRESSION } }); + fireEvent.input(input, { target: { value: OPTIONS_SOURCES.EXPRESSION } }); await act(() => eventBus.fire('changed')); // then - const editor = findTextbox(`${field.id}-valuesExpression-expression`, container); + const editor = findTextbox('optionsExpression-expression', container); await waitFor(() => { expect(document.activeElement).to.eql(editor); @@ -2297,13 +2301,13 @@ describe('properties panel', function() { let field = schema.components.find(({ key }) => key === 'language'); field = { ...field, values: undefined, valuesExpression: '=' }; - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Options source', @@ -2312,22 +2316,22 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', 'Disabled' ]); - expectGroupEntries(result.container, 'Options source', [ + expectGroupEntries(container, 'Options source', [ 'Type' ]); - expectGroupEntries(result.container, 'Options expression', [ + expectGroupEntries(container, 'Options expression', [ 'Options expression' ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required' ]); }); @@ -2344,19 +2348,19 @@ describe('properties panel', function() { // given const field = schema.components.find(({ type }) => type === 'text'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Text' ]); }); @@ -2371,20 +2375,20 @@ describe('properties panel', function() { // given const field = schema.components.find(({ type }) => type === 'spacer'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Layout', 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Height' ]); @@ -2400,33 +2404,86 @@ describe('properties panel', function() { // given const field = schema.components.find(({ type }) => type === 'group'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ + container, + field + }); + + // then + expectGroups(container, [ + 'General', + 'Condition', + 'Layout', + 'Appearance', + 'Custom properties' + ]); + + expectGroupEntries(container, 'General', [ + 'Group label', + 'Path' + ]); + + expectGroupEntries(container, 'Condition', [ + 'Hide if' + ]); + + expectGroupEntries(container, 'Layout', [ + 'Columns' + ]); + + expectGroupEntries(container, 'Appearance', [ + 'Show outline', + 'Vertical alignment' + ]); + + }); + + }); + + + describe('dynamiclist', function() { + + it('entries', function() { + + // given + const field = schema.components.find(({ type }) => type === 'dynamiclist'); + + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Layout', + 'Appearance', 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Group label', 'Path', - 'Show outline' + 'Default number of items', + 'Allow add/delete items', + 'Disable collapse', + 'Number of non-collapsing items' ]); - expectGroupEntries(result.container, 'Condition', [ + expectGroupEntries(container, 'Condition', [ 'Hide if' ]); - expectGroupEntries(result.container, 'Layout', [ + expectGroupEntries(container, 'Layout', [ 'Columns' ]); + expectGroupEntries(container, 'Appearance', [ + 'Show outline', + 'Vertical alignment' + ]); + }); }); @@ -2439,20 +2496,20 @@ describe('properties panel', function() { // given const field = schema.components.find(({ key }) => key === 'creditor'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Validation', 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', @@ -2460,7 +2517,7 @@ describe('properties panel', function() { 'Disabled' ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required', 'Minimum length', 'Maximum length', @@ -2479,7 +2536,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2506,7 +2563,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'creditor'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2539,7 +2596,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2577,7 +2634,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2613,7 +2670,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2643,7 +2700,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2673,7 +2730,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field, @@ -2707,7 +2764,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2744,13 +2801,13 @@ describe('properties panel', function() { // given const field = schema.components.find(({ key }) => key === 'amount'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Serialization', @@ -2758,7 +2815,7 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description', 'Key', @@ -2768,11 +2825,11 @@ describe('properties panel', function() { 'Disabled' ]); - expectGroupEntries(result.container, 'Serialization', [ + expectGroupEntries(container, 'Serialization', [ 'Output as string' ]); - expectGroupEntries(result.container, 'Validation', [ + expectGroupEntries(container, 'Validation', [ 'Required', 'Minimum', 'Maximum' @@ -2789,7 +2846,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2816,7 +2873,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field: { @@ -2850,7 +2907,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2877,7 +2934,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2904,7 +2961,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2930,7 +2987,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2962,7 +3019,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -2990,7 +3047,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field: { @@ -3026,7 +3083,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3054,7 +3111,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3079,7 +3136,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3104,7 +3161,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3129,7 +3186,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3154,7 +3211,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field: { @@ -3185,7 +3242,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3217,7 +3274,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3245,7 +3302,7 @@ describe('properties panel', function() { const field = defaultValuesSchema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3276,7 +3333,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3306,7 +3363,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3336,7 +3393,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field, @@ -3372,7 +3429,7 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'amount'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3409,19 +3466,19 @@ describe('properties panel', function() { // given const field = schema.components.find(({ source }) => source === '=logo'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Condition', 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Image source', 'Alternative text', ]); @@ -3437,7 +3494,7 @@ describe('properties panel', function() { const field = schema.components.find(({ source }) => source === '=logo'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3463,7 +3520,7 @@ describe('properties panel', function() { const field = schema.components.find(({ source }) => source === '=logo'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3493,7 +3550,7 @@ describe('properties panel', function() { const field = schema.components.find(({ source }) => source === '=logo'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3521,7 +3578,7 @@ describe('properties panel', function() { const field = schema.components.find(({ source }) => source === '=logo'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3553,19 +3610,19 @@ describe('properties panel', function() { // given const field = iframeSchema.components.find(({ url }) => url === 'https://bpmn.io/'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Layout', 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Title', 'URL', 'Height', @@ -3582,7 +3639,7 @@ describe('properties panel', function() { const field = iframeSchema.components.find(({ url }) => url === 'https://bpmn.io/'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3610,7 +3667,7 @@ describe('properties panel', function() { const field = iframeSchema.components.find(({ url }) => url === 'https://bpmn.io/'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3633,7 +3690,7 @@ describe('properties panel', function() { // given const field = iframeSchema.components.find(({ url }) => url === 'https://bpmn.io/'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); @@ -3664,13 +3721,13 @@ describe('properties panel', function() { // given const field = tableSchema.components.find(({ label }) => label === 'static-headers-table'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Headers source', 'Header items', @@ -3679,18 +3736,18 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Table label', 'Data source', 'Pagination', 'Number of rows per page' ]); - expectGroupEntries(result.container, 'Headers source', [ + expectGroupEntries(container, 'Headers source', [ 'Type' ]); - expectGroupEntries(result.container, 'Header items', [ + expectGroupEntries(container, 'Header items', [ [ 'Label', 3 ], [ 'Key', 3 ] ]); @@ -3702,13 +3759,13 @@ describe('properties panel', function() { // given const field = tableSchema.components.find(({ label }) => label === 'dynamic-headers-table'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'General', 'Headers source', 'Condition', @@ -3716,14 +3773,14 @@ describe('properties panel', function() { 'Custom properties' ]); - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Table label', 'Data source', 'Pagination', 'Number of rows per page' ]); - expectGroupEntries(result.container, 'Headers source', [ + expectGroupEntries(container, 'Headers source', [ 'Type', 'Expression' ]); @@ -3732,7 +3789,6 @@ describe('properties panel', function() { describe('columns', function() { - it('should auto focus other entry', async function() { // given @@ -3744,7 +3800,7 @@ describe('properties panel', function() { columnsExpression: '=tableHeaders', }; - const eventBus = new eventBusMock(); + const eventBus = new EventBusMock(); const selection = { get: () => field @@ -3763,12 +3819,14 @@ describe('properties panel', function() { }; }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField, - eventBus, field, - selection + services: { + eventBus, + selection + } }); // assume @@ -3794,13 +3852,13 @@ describe('properties panel', function() { const field = tableSchema.components.find(({ label }) => label === 'static-headers-table'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Header items'); + const group = findGroup(container, 'Header items'); // when const addEntry = group.querySelector('.bio-properties-panel-add-entry'); @@ -3825,13 +3883,13 @@ describe('properties panel', function() { const field = tableSchema.components.find(({ label }) => label === 'static-headers-table'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Header items'); + const group = findGroup(container, 'Header items'); // when const removeEntry = group.querySelector('.bio-properties-panel-remove-entry'); @@ -3863,7 +3921,7 @@ describe('properties panel', function() { const field = tableSchema.components.find(({ label }) => label === 'static-headers-table'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3898,7 +3956,7 @@ describe('properties panel', function() { const field = tableSchema.components.find(({ label }) => label === 'static-headers-table'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3927,7 +3985,7 @@ describe('properties panel', function() { const field = tableSchema.components.find(({ label }) => label === 'dynamic-headers-table'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field @@ -3952,7 +4010,7 @@ describe('properties panel', function() { // given let field = tableSchema.components.find(({ label }) => label === 'static-headers-table'); - const eventBus = new eventBusMock(); + const eventBus = new EventBusMock(); const selection = { get: () => field @@ -3963,12 +4021,14 @@ describe('properties panel', function() { field = { ...renderedField, columnsExpression: '=' }; }; - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField, - eventBus, field, - selection + services: { + eventBus, + selection + } }); // assume @@ -4003,13 +4063,13 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Custom properties'); + const group = findGroup(container, 'Custom properties'); // when const addEntry = group.querySelector('.bio-properties-panel-add-entry'); @@ -4032,13 +4092,13 @@ describe('properties panel', function() { const field = redundantValuesSchema.components.find(({ key }) => key === 'redundantValues'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Custom properties'); + const group = findGroup(container, 'Custom properties'); // when const addEntry = group.querySelector('.bio-properties-panel-add-entry'); @@ -4059,13 +4119,13 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); - const group = findGroup(result.container, 'Custom properties'); + const group = findGroup(container, 'Custom properties'); // when const removeEntry = group.querySelector('.bio-properties-panel-remove-entry'); @@ -4090,14 +4150,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Key', { selector: '#bio-properties-panel-Textfield_1-property-0-key' }); + const input = screen.getByLabelText('Key', { selector: '#bio-properties-panel-property-0-key' }); fireEvent.input(input, { target: { value: '' } }); @@ -4117,14 +4177,14 @@ describe('properties panel', function() { const field = schema.components.find(({ key }) => key === 'creditor'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field }); // when - const input = screen.getByLabelText('Key', { selector: '#bio-properties-panel-Textfield_1-property-0-key' }); + const input = screen.getByLabelText('Key', { selector: '#bio-properties-panel-property-0-key' }); fireEvent.input(input, { target: { value: 'middleName' } }); @@ -4152,12 +4212,16 @@ describe('properties panel', function() { const field = schema.components.find(({ source }) => source === '=logo'); - createPropertiesPanel({ + bootstrapPropertiesPanel({ container, editField: editFieldSpy, field, - propertiesPanelConfig: { - feelPopupContainer: container + services: { + config: { + propertiesPanel: { + feelPopupContainer: container + } + } } }); @@ -4200,14 +4264,16 @@ describe('properties panel', function() { const formFields = new FormFields(); formFields.register('custom', extension); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field, - formFields + services: { + formFields + } }); // then - expectGroupEntries(result.container, 'General', [ + expectGroupEntries(container, 'General', [ 'Field label', 'Field description' ]); @@ -4235,14 +4301,16 @@ describe('properties panel', function() { const formFields = new FormFields(); formFields.register('custom', extension); - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field, - formFields + services: { + formFields + } }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'Condition', 'Layout', 'Options source', @@ -4278,7 +4346,7 @@ describe('properties panel', function() { values: [] }; - const result = createPropertiesPanel({ + bootstrapPropertiesPanel({ container, field, propertiesProviders: [ @@ -4287,7 +4355,7 @@ describe('properties panel', function() { }); // then - expectGroups(result.container, [ + expectGroups(container, [ 'Condition', 'Layout', 'Custom properties', @@ -4303,97 +4371,44 @@ describe('properties panel', function() { // helpers ////////////// -function createPropertiesPanel(options = {}, renderFn = render) { - const { - container, - editField = () => {}, - isTemplate = () => false, - evaluateTemplate = (value) => `Evaluation of "${value}"`, - valuePaths = {}, - claimedPaths = [], - propertiesProviders = [], - field = null - } = options; - - let { - eventBus, - formEditor, - formLayoutValidator, - pathRegistry, - formFields, - modeling, - selection, - templating - } = options; - - if (!eventBus) { - eventBus = new eventBusMock(); - } - - if (!formEditor) { - formEditor = new formEditorMock({ - state: { schema: options.schema !== undefined ? options.schema : schema } - }); - } - - if (!formLayoutValidator) { - formLayoutValidator = new formLayoutValidatorMock(); - } - - if (!formFields) { - formFields = new FormFields(); - } - - if (!modeling) { - modeling = new modelingMock({ - editFormField: editField - }); - } - - if (!selection) { - selection = new selectionMock({ - selection: field - }); - } - - if (!templating) { - templating = new templatingMock({ - isTemplate, - evaluate: evaluateTemplate - }); - } +function createPropertiesPanel({ services, ...restOptions } = {}, renderFn = render) { + + const options = { + editField: () => {}, + isTemplate: () => false, + evaluateTemplate: (value) => `Evaluation of "${value}"`, + valuePaths: {}, + claimedPaths: [], + propertiesProviders: [], + field: null, + ...restOptions + }; - if (!pathRegistry) { - pathRegistry = new pathRegistryMock({ - valuePaths, - claimedPaths - }); - } + const defaultedServices = { + eventBus: new EventBusMock(), + propertiesPanel: new PropertiesPanelMock(), + modeling: { + editFormField(...args) { + return options.editField(...args); + } + }, + ...services + }; - const injector = new injectorMock({ - ...options, - eventBus, - formEditor, - formLayoutValidator, - formFields, - modeling, - selection, - templating, - pathRegistry - }); + const injector = createMockInjector(defaultedServices, options); - const propertiesPanel = new propertiesPanelMock(); + const container = options.container; const getProviders = () => { return [ - new PropertiesProvider(propertiesPanel, injector), - ...propertiesProviders + new PropertiesProvider(defaultedServices.propertiesPanel, injector), + ...options.propertiesProviders ]; }; return renderFn(, { container diff --git a/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanelHeaderProvider.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanelHeaderProvider.spec.js index 9a5b9ec12..2e145b41b 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanelHeaderProvider.spec.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanelHeaderProvider.spec.js @@ -7,7 +7,7 @@ import { FormFields } from '@bpmn-io/form-js-viewer'; import { PropertiesPanelHeaderProvider } from '../../../../src/features/properties-panel/PropertiesPanelHeaderProvider'; -import { WithPropertiesPanelContext, WithPropertiesPanel } from './helper'; +import { MockPropertiesPanelContext, TestPropertiesPanel } from './helper'; describe('PropertiesPanelHeaderProvider', function() { @@ -80,7 +80,7 @@ describe('PropertiesPanelHeaderProvider', function() { const field = { type: 'custom' }; // when - const { container } = renderHeader({ field, formFields }); + const { container } = renderHeader({ field, services: { formFields } }); // then const label = container.querySelector('.bio-properties-panel-header-type'); @@ -108,7 +108,7 @@ describe('PropertiesPanelHeaderProvider', function() { const field = { type: 'custom' }; // when - const { container } = renderHeader({ field, formFields }); + const { container } = renderHeader({ field, services: { formFields } }); // then const customIcon = container.querySelector('.custom-icon'); @@ -135,7 +135,7 @@ describe('PropertiesPanelHeaderProvider', function() { const field = { type: 'custom' }; // when - const { container } = renderHeader({ field, formFields }); + const { container } = renderHeader({ field, services: { formFields } }); // then const customIcon = container.querySelector('.fjs-field-icon-image'); @@ -150,16 +150,18 @@ describe('PropertiesPanelHeaderProvider', function() { // helpers ///////// -function renderHeader(options) { - const { - field, - formFields - } = options; - - return render(WithPropertiesPanelContext(WithPropertiesPanel({ - field, - headerProvider: PropertiesPanelHeaderProvider - }), { - formFields - })); +function renderHeader({ services, ...restOptions }) { + + const defaultField = { type: 'textfield' }; + + const options = { + field: defaultField, + ...restOptions + }; + + return render( + + + + ); } diff --git a/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanelModule.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanelModule.spec.js index 278eb84eb..27bc7a0a1 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanelModule.spec.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanelModule.spec.js @@ -181,22 +181,19 @@ describe('features/propertiesPanel', function() { describe('event emitting', function() { - it.skip('should fire ', async function() { + it('should fire ', async function() { // given let formEditor; + const spy = sinon.spy(); + await act(async () => { const result = await createEditor(schema); formEditor = result.formEditor; + formEditor.get('eventBus').on('propertiesPanel.rendered', spy); }); - const eventBus = formEditor.get('eventBus'); - - const spy = sinon.spy(); - - eventBus.on('propertiesPanel.rendered', spy); - const propertiesPanel = formEditor.get('propertiesPanel'); // when diff --git a/packages/form-js-editor/test/spec/features/properties-panel/groups/AppearanceGroup.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/groups/AppearanceGroup.spec.js index a9f3a08a5..a04974232 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/groups/AppearanceGroup.spec.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/groups/AppearanceGroup.spec.js @@ -5,7 +5,7 @@ import { import { AppearanceGroup } from '../../../../../src/features/properties-panel/groups'; -import { WithPropertiesPanelContext, WithPropertiesPanel } from '../helper'; +import { MockPropertiesPanelContext, TestPropertiesPanel } from '../helper'; import { setEditorValue } from '../../../../helper'; @@ -224,10 +224,11 @@ function renderAppearanceGroup(options) { const groups = [ AppearanceGroup(field, editField) ]; - return render(WithPropertiesPanelContext(WithPropertiesPanel({ - field, - groups - }))); + return render( + + + + ); } function findFeelers(id, container) { diff --git a/packages/form-js-editor/test/spec/features/properties-panel/groups/ConditionGroup.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/groups/ConditionGroup.spec.js index 6c920645e..d6018ec81 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/groups/ConditionGroup.spec.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/groups/ConditionGroup.spec.js @@ -6,7 +6,7 @@ import { import { ConditionGroup } from '../../../../../src/features/properties-panel/groups'; -import { WithPropertiesPanelContext, WithPropertiesPanel } from '../helper'; +import { MockPropertiesPanelContext, TestPropertiesPanel } from '../helper'; import { INPUTS } from '../../../../../src/features/properties-panel/Util'; @@ -135,10 +135,11 @@ function renderConditionGroup(options) { const groups = [ ConditionGroup(field, editField) ]; - return render(WithPropertiesPanelContext(WithPropertiesPanel({ - field, - groups - }))); + return render( + + + + ); } function changeInput(element, value) { diff --git a/packages/form-js-editor/test/spec/features/properties-panel/groups/GeneralGroup.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/groups/GeneralGroup.spec.js index efb92c124..3815b6ab7 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/groups/GeneralGroup.spec.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/groups/GeneralGroup.spec.js @@ -6,13 +6,14 @@ import { import { GeneralGroup } from '../../../../../src/features/properties-panel/groups'; -import { WithPropertiesPanelContext, WithPropertiesPanel } from '../helper'; +import { MockPropertiesPanelContext, TestPropertiesPanel } from '../helper'; +import { createMockInjector } from '../helper/mocks'; import { setEditorValue } from '../../../../helper'; import { set } from 'min-dash'; -import { INPUTS, VALUES_INPUTS } from '../../../../../src/features/properties-panel/Util'; +import { INPUTS, OPTIONS_INPUTS } from '../../../../../src/features/properties-panel/Util'; describe('GeneralGroup', function() { @@ -588,7 +589,7 @@ describe('GeneralGroup', function() { describe('for all other INPUTS', () => { - const otherInputTypes = INPUTS.filter(i => !VALUES_INPUTS.includes(i)); + const otherInputTypes = INPUTS.filter(i => !OPTIONS_INPUTS.includes(i)); it('should render', function() { @@ -1231,38 +1232,37 @@ describe('GeneralGroup', function() { // helper /////////////// -function _getService(type, options = {}) { - if (type === 'templating') { - return { +function buildGeneralGroupServiceMocks(options = {}) { + return { + templating: { isTemplate: options.isTemplate || (() => false) - }; - } - - if (type === 'pathRegistry') { - return { + }, + pathRegistry: { getValuePath: options.getValuePath || ((field) => [ field.key ]), canClaimPath: options.canClaimPath || (() => true), claimPath: options.claimPath || (() => {}), unclaimPath: options.unclaimPath || (() => {}) - }; - } + } + }; } -function renderGeneralGroup(options) { +function renderGeneralGroup({ services, ...options }) { const { editField, - field, - getService = (type) => _getService(type, options), + field } = options; - const groups = [ GeneralGroup(field, editField, getService) ]; + const defaultedServices = { ...buildGeneralGroupServiceMocks(options), ...services }; + + const injector = createMockInjector(defaultedServices, options); + + const groups = [ GeneralGroup(field, editField, (type, strict) => injector.get(type, strict)) ]; - return render(WithPropertiesPanelContext(WithPropertiesPanel({ - field, - groups - }), { - [ 'pathRegistry' ] : getService('pathRegistry'), - })); + return render( + + + + ); } function findEntry(id, container) { diff --git a/packages/form-js-editor/test/spec/features/properties-panel/groups/LayoutGroup.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/groups/LayoutGroup.spec.js index 927486e75..2722bce71 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/groups/LayoutGroup.spec.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/groups/LayoutGroup.spec.js @@ -9,7 +9,7 @@ import { LayoutGroup } from '../../../../../src/features/properties-panel/groups import { AUTO_OPTION_VALUE } from '../../../../../src/features/properties-panel/entries/ColumnsEntry'; -import { WithPropertiesPanelContext, WithPropertiesPanel } from '../helper'; +import { TestPropertiesPanel, MockPropertiesPanelContext } from '../helper'; describe('LayoutGroup', function() { @@ -192,10 +192,13 @@ function renderLayoutGroup(options) { const groups = [ LayoutGroup(field, editField) ]; - return render(WithPropertiesPanelContext(WithPropertiesPanel({ - field, - groups - }), services)); + return render( + + + + ); } function findSelect(id, container) { diff --git a/packages/form-js-editor/test/spec/features/properties-panel/groups/SerializationGroup.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/groups/SerializationGroup.spec.js index 2f716f4c2..915337fcc 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/groups/SerializationGroup.spec.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/groups/SerializationGroup.spec.js @@ -6,7 +6,7 @@ import { import { SerializationGroup } from '../../../../../src/features/properties-panel/groups'; -import { WithPropertiesPanelContext, WithPropertiesPanel } from '../helper'; +import { TestPropertiesPanel, MockPropertiesPanelContext } from '../helper'; import { set } from 'min-dash'; @@ -186,10 +186,13 @@ function renderSerializationGroup(options) { const groups = [ SerializationGroup(field, editField) ]; - return render(WithPropertiesPanelContext(WithPropertiesPanel({ - field, - groups - }))); + return render( + + + + ); } function findInput(id, container) { diff --git a/packages/form-js-editor/test/spec/features/properties-panel/groups/ValidationGroup.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/groups/ValidationGroup.spec.js index 858d9e4f0..209e684ab 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/groups/ValidationGroup.spec.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/groups/ValidationGroup.spec.js @@ -6,7 +6,7 @@ import { import { ValidationGroup } from '../../../../../src/features/properties-panel/groups'; -import { WithPropertiesPanelContext, WithPropertiesPanel } from '../helper'; +import { TestPropertiesPanel, MockPropertiesPanelContext } from '../helper'; import { setEditorValue } from '../../../../helper'; @@ -720,10 +720,13 @@ function renderValidationGroup(options) { const groups = [ ValidationGroup(field, editField) ]; - return render(WithPropertiesPanelContext(WithPropertiesPanel({ - field, - groups - }))); + return render( + + + + ); } function findInput(id, container) { diff --git a/packages/form-js-editor/test/spec/features/properties-panel/helper/index.js b/packages/form-js-editor/test/spec/features/properties-panel/helper/index.js index 5ca17b57f..cd8ccdabc 100644 --- a/packages/form-js-editor/test/spec/features/properties-panel/helper/index.js +++ b/packages/form-js-editor/test/spec/features/properties-panel/helper/index.js @@ -1,13 +1,30 @@ import { PropertiesPanel } from '@bpmn-io/properties-panel'; - -import { FormFields } from '@bpmn-io/form-js-viewer'; - -import { FormEditorContext } from '../../../../../src/render/context'; - import { FormPropertiesPanelContext } from '../../../../../src/features/properties-panel/context'; +import { createMockInjector } from '../../../../helper/mocks'; + +// to delete once we have unified the context of the properties panel and editors +export const MockPropertiesPanelContext = (props) => { + + const { + options = {}, + services = {} + } = props; + + const propertiesPanelContext = { + getService: (type, strict) => createMockInjector(services, options).get(type, strict), + }; + + return ( + + { props.children } + + ); + +}; + const noop = () => {}; const noopField = { @@ -21,324 +38,16 @@ const noopHeaderProvider = { getTypeLabel: noop }; -export class Modeling { - constructor(options = {}) { - this._editFormField = options.editFormField || noop; - } - - editFormField(formField, key, value) { - return this._editFormField(formField, key, value); - } -} - -export class Selection { - constructor(options = {}) { - this._selection = options.selection; - } - - get() { - return this._selection; - } -} - -export class FormFieldRegistry { - - constructor() { - this._ids = { - assigned() { - return false; - } - }; - - } - - add() {} - remove() {} - get() {} - getAll() { - return []; - } - forEach() {} - clear() {} -} - -export class PathRegistry { - - constructor(options) { - this._valuePaths = options.valuePaths || {}; - this._claimedPaths = options.claimedPaths || []; - } - - getValuePath(field) { - - if (this._valuePaths[ field.id ]) { - return this._valuePaths[ field.id ]; - } - - return [ field.key ]; - } - - canClaimPath(path) { - return !this._claimedPaths.some(claimedPath => path.join('.') === claimedPath); - } - - unclaimPath() {} - claimPath() {} -} - -export class FormEditor { - - constructor(options = {}) { - this._state = options.state || { - schema: {}, - properties: {} - }; - } - - getSchema() { - return this._state.schema; - } - - _getState() { - return this._state; - } -} - -export class EventBus { - constructor() { - this.listeners = {}; - } - - on(event, priority, callback) { - if (!callback) { - callback = priority; - } - - if (!this.listeners[ event ]) { - this.listeners[ event ] = []; - } - - this.listeners[ event ].push(callback); - } - - off() {} - - fire(event, context) { - if (this.listeners[ event ]) { - this.listeners[ event ].forEach(callback => callback(context)); - } - } -} - -export class Injector { - - constructor(options = {}) { - this._options = options; - } - - get(type) { - - if (type === 'formEditor') { - return this._options.formEditor || new FormEditor(); - } - - if (type === 'formLayoutValidator') { - return this._options.formLayoutValidator || new FormLayoutValidator(); - } - - if (type === 'eventBus') { - return this._options.eventBus || new EventBus(); - } - - if (type === 'modeling') { - return this._options.modeling || new Modeling(); - } - - if (type === 'selection') { - return this._options.selection || new Selection(); - } - - if (type === 'templating') { - return this._options.templating || new Templating(); - } - - if (type === 'debounce') { - return fn => fn; - } - - if (type === 'formFieldRegistry') { - return this._options.formFieldRegistry || new FormFieldRegistry(); - } - - if (type === 'pathRegistry') { - return this._options.pathRegistry || new PathRegistry(); - } - - if (type === 'config.propertiesPanel') { - return this._options.propertiesPanelConfig || {}; - } - - if (type === 'formFields') { - return this._options.formFields || new FormFields(); - } - } -} - -export class FormLayoutValidator { - validateField() {} -} -export class Templating { - - constructor(options = {}) { - this.isTemplate = options.isTemplate || (() => false); - this.evaluate = options.evaluate || ((value) => `Evaluation of "${value}"`); - } -} - -export class PropertiesPanelMock { - registerProvider() {} -} - -export function WithFormEditorContext(Component, services = {}) { - const formEditorContext = { - getService(type, strict) { - if (services[ type ]) { - return services[ type ]; - } - - if (type === 'config') { - return { - propertiesPanel: { - debounce: false - } - }; - } else if (type === 'debounce') { - return fn => fn; - } else if (type === 'eventBus') { - return { - fire() {}, - on() {}, - off() {} - }; - } else if (type === 'formFieldRegistry') { - return { - add() {}, - remove() {}, - get() {}, - getAll() { - return []; - }, - forEach() {}, - clear() {}, - _ids: { - assigned() { - return false; - } - }, - _keys: { - assigned() { - return false; - } - }, - }; - } else if (type === 'formEditor') { - return new FormEditor(); - } else if (type === 'formLayoutValidator') { - return { - validateField() {} - }; - } else if (type === 'expressionLanguage') { - return { - isExpression: () => false - }; - } else if (type === 'formFields') { - return new FormFields(); - } - } - }; - - return ( - - { Component } - - ); -} - -export function WithPropertiesPanelContext(Component, services = {}) { - const propertiesPanelContext = { - getService(type, strict) { - if (services[ type ]) { - return services[ type ]; - } - - if (type === 'config') { - return { - propertiesPanel: { - debounce: false - } - }; - } else if (type === 'debounce') { - return fn => fn; - } else if (type === 'eventBus') { - return { - fire() {}, - on() {}, - off() {} - }; - } else if (type === 'formFieldRegistry') { - return { - add() {}, - remove() {}, - get() {}, - getAll() { - return []; - }, - forEach() {}, - clear() {}, - _ids: { - assigned() { - return false; - } - }, - _keys: { - assigned() { - return false; - } - }, - }; - } else if (type === 'formEditor') { - return new FormEditor(); - } else if (type === 'formLayoutValidator') { - return { - validateField() {} - }; - } else if (type === 'expressionLanguage') { - return { - isExpression: () => false - }; - } else if (type === 'formFields') { - return new FormFields(); - } - } - }; - - return ( - - { Component } - - ); -} - -export function WithPropertiesPanel(options = {}) { +export const TestPropertiesPanel = (props) => { const { field = noopField, headerProvider = noopHeaderProvider - } = options; + } = props; let { groups = [] - } = options; + } = props; groups = applyDefaultVisible(field, groups); @@ -349,7 +58,7 @@ export function WithPropertiesPanel(options = {}) { headerProvider={ headerProvider } /> ); -} +}; // helpers ////////////////////// @@ -378,4 +87,4 @@ function applyDefaultVisible(field, groups) { }); return groups.filter(group => group.entries && group.entries.length); -} \ No newline at end of file +} diff --git a/packages/form-js-editor/test/spec/features/properties-panel/helper/mocks/index.js b/packages/form-js-editor/test/spec/features/properties-panel/helper/mocks/index.js new file mode 100644 index 000000000..be0f87ecc --- /dev/null +++ b/packages/form-js-editor/test/spec/features/properties-panel/helper/mocks/index.js @@ -0,0 +1 @@ +export * from '../../../../../helper/mocks'; \ No newline at end of file diff --git a/packages/form-js-editor/test/spec/form.json b/packages/form-js-editor/test/spec/form.json index 1e388254e..9c1e76042 100644 --- a/packages/form-js-editor/test/spec/form.json +++ b/packages/form-js-editor/test/spec/form.json @@ -29,6 +29,30 @@ } ] }, + { + "id": "DynamicList_1", + "type": "dynamiclist", + "label": "Clients", + "path": "clients", + "showOutline": true, + "isRepeating": true, + "defaultRepetitions": 2, + "allowAddRemove": true, + "components": [ + { + "id": "DynamicListTextField_1", + "type": "textfield", + "key": "clientSurname", + "label": "Surname" + }, + { + "id": "DynamicListTextField_2", + "type": "textfield", + "key": "clientName", + "label": "Name" + } + ] + }, { "id": "Textfield_1", "key": "creditor", diff --git a/packages/form-js-editor/test/spec/import/Importer.spec.js b/packages/form-js-editor/test/spec/import/Importer.spec.js index 29f110e07..3140407b2 100644 --- a/packages/form-js-editor/test/spec/import/Importer.spec.js +++ b/packages/form-js-editor/test/spec/import/Importer.spec.js @@ -1,6 +1,7 @@ import { bootstrapFormEditor, getFormEditor, + countComponents, inject } from 'test/TestHelper'; @@ -30,7 +31,7 @@ describe('Importer', function() { // then expect(warnings).to.be.empty; - expect(formFieldRegistry.getAll()).to.have.length(20); + expect(formFieldRegistry.getAll()).to.have.length(countComponents(schema)); })); @@ -40,7 +41,7 @@ describe('Importer', function() { await formEditor.importSchema(schema); // assume - expect(formFieldRegistry.getAll()).to.have.length(20); + expect(formFieldRegistry.getAll()).to.have.length(countComponents(schema)); // when const result = await formEditor.importSchema(other); diff --git a/packages/form-js-playground/karma.conf.js b/packages/form-js-playground/karma.conf.js index 1440a922e..fea4b1e84 100644 --- a/packages/form-js-playground/karma.conf.js +++ b/packages/form-js-playground/karma.conf.js @@ -35,7 +35,19 @@ module.exports = function(karma) { [ suite ]: [ 'webpack', 'env' ] }, - reporters: [ 'progress' ].concat(coverage ? 'coverage' : []), + reporters: [ 'spec' ].concat(coverage ? 'coverage' : []), + + specReporter: { + maxLogLines: 10, + suppressSummary: true, + suppressErrorSummary: false, + suppressFailed: false, + suppressPassed: false, + suppressSkipped: true, + showBrowser: false, + showSpecTiming: false, + failFast: false + }, coverageReporter: { reporters: [ diff --git a/packages/form-js-playground/test/spec/form.json b/packages/form-js-playground/test/spec/form.json index ed04518c1..57d2dae4b 100644 --- a/packages/form-js-playground/test/spec/form.json +++ b/packages/form-js-playground/test/spec/form.json @@ -10,7 +10,11 @@ } }, { - "id": "Group_1", + "type": "iframe", + "label": "The bpmn-io web page", + "url": "https://bpmn.io/" + }, + { "type": "group", "label": "Supplementary Information", "path": "invoiceDetails", @@ -31,9 +35,27 @@ ] }, { - "type": "iframe", - "label": "The bpmn-io web page", - "url": "https://bpmn.io/" + "type": "dynamiclist", + "label": "Clients", + "path": "clients", + "showOutline": true, + "isRepeating": true, + "defaultRepetitions": 2, + "allowAddRemove": true, + "components": [ + { + "id": "DynamicListTextField_1", + "type": "textfield", + "key": "clientSurname", + "label": "Surname" + }, + { + "id": "DynamicListTextField_2", + "type": "textfield", + "key": "clientName", + "label": "Name" + } + ] }, { "key": "creditor", diff --git a/packages/form-js-viewer/assets/form-js-base.css b/packages/form-js-viewer/assets/form-js-base.css index 3346167d2..72edb4469 100644 --- a/packages/form-js-viewer/assets/form-js-base.css +++ b/packages/form-js-viewer/assets/form-js-base.css @@ -110,6 +110,7 @@ --border-definition: 1px solid var(--color-borders); --border-definition-adornment: 1px solid var(--color-borders-adornment); --outline-definition: 1px solid var(--cds-focus, var(--color-borders)); + --button-warning-outline-definition: 2px solid var(--color-warning); --border-definition-disabled: 1px solid var(--color-borders-disabled); --border-definition-readonly: 1px solid var(--color-borders-readonly); @@ -320,47 +321,47 @@ color: var(--color-text-lighter); } -.fjs-container .fjs-form-field-group { +.fjs-container .fjs-form-field-grouplike { padding: 10px 6px 0 6px; margin: 0 10px; } -.fjs-container .fjs-form-field-group .cds--grid { +.fjs-container .fjs-form-field-grouplike .cds--grid { padding: 4px 16px; } -.fjs-container .fjs-form-field-group .fjs-form-field-group .fjs-layout-column:first-child > .fjs-element > .fjs-form-field-group:not(.fjs-editor-container .fjs-form-field-group), -.fjs-container .fjs-layout-column:first-child > .fjs-element > .fjs-form-field-group:not(.fjs-editor-container .fjs-form-field-group) { +.fjs-container .fjs-form-field-grouplike .fjs-form-field-grouplike .fjs-layout-column:first-child > .fjs-element > .fjs-form-field-grouplike:not(.fjs-editor-container .fjs-form-field-grouplike), +.fjs-container .fjs-layout-column:first-child > .fjs-element > .fjs-form-field-grouplike:not(.fjs-editor-container .fjs-form-field-grouplike) { margin-left: -6px; } -.fjs-container .fjs-form-field-group .fjs-form-field-group .fjs-layout-column:last-child > .fjs-element > .fjs-form-field-group:not(.fjs-editor-container .fjs-form-field-group), -.fjs-container .fjs-layout-column:last-child > .fjs-element > .fjs-form-field-group:not(.fjs-editor-container .fjs-form-field-group) { +.fjs-container .fjs-form-field-grouplike .fjs-form-field-grouplike .fjs-layout-column:last-child > .fjs-element > .fjs-form-field-grouplike:not(.fjs-editor-container .fjs-form-field-grouplike), +.fjs-container .fjs-layout-column:last-child > .fjs-element > .fjs-form-field-grouplike:not(.fjs-editor-container .fjs-form-field-grouplike) { margin-right: -6px; } -.fjs-container .fjs-form-field-group .fjs-layout-column:first-child > .fjs-element > .fjs-form-field-group:not(.fjs-editor-container .fjs-form-field-group) { +.fjs-container .fjs-form-field-grouplike .fjs-layout-column:first-child > .fjs-element > .fjs-form-field-grouplike:not(.fjs-editor-container .fjs-form-field-grouplike) { margin-left: 11px; } -.fjs-container .fjs-form-field-group .fjs-layout-column:last-child > .fjs-element > .fjs-form-field-group:not(.fjs-editor-container .fjs-form-field-group) { +.fjs-container .fjs-form-field-grouplike .fjs-layout-column:last-child > .fjs-element > .fjs-form-field-grouplike:not(.fjs-editor-container .fjs-form-field-grouplike) { margin-right: 11px; } -.fjs-container .fjs-form-field-group.fjs-outlined { +.fjs-container .fjs-form-field-grouplike.fjs-outlined { outline: solid var(--color-borders-group) 2px; } -.fjs-container .fjs-form-field-group label { +.fjs-container .fjs-form-field-grouplike label { font-size: var(--font-size-label); } -.fjs-container .fjs-form-field-group .fjs-form-field-group .cds--grid { +.fjs-container .fjs-form-field-grouplike .fjs-form-field-grouplike .cds--grid { padding-left: 2rem; padding-right: 2rem; } -.fjs-container .fjs-form-field-group > label { +.fjs-container .fjs-form-field-grouplike > label { font-size: var(--font-size-group); line-height: var(--line-height-input); margin-left: 7px; @@ -1113,6 +1114,85 @@ width: 16px; } +.fjs-container .fjs-repeat-row-container { + display: flex; + flex-direction: row; + gap: 1rem; +} + +.fjs-container .fjs-repeat-row-rows { + flex: 1; + margin-right: 1rem; +} + +.fjs-container .fjs-repeat-row-container .fjs-repeat-row-remove { + display: flex; + cursor: pointer; + background: transparent; + border: none; + width: 32px; + color: var(--color-icon-base); + align-items: center; + justify-content: center; + padding: 0; +} + +.fjs-container .fjs-repeat-row-container .fjs-repeat-row-remove:focus-visible { + outline: none; +} + +.fjs-container .fjs-repeat-row-container .fjs-repeat-row-remove .fjs-repeat-row-remove-icon-container { + display: flex; + width: 24px; + height: 24px; + border-radius: 2px; + align-items: center; + justify-content: center; +} + +.fjs-container .fjs-repeat-row-container .fjs-repeat-row-remove:focus-visible .fjs-repeat-row-remove-icon-container { + outline: var(--button-warning-outline-definition); +} + +.fjs-container .fjs-repeat-row-container:hover .fjs-repeat-render-footer-spacer { + width: 24px; +} + +.fjs-container .fjs-repeat-row-container .fjs-repeat-row-remove:hover, +.fjs-container .fjs-repeat-row-container .fjs-repeat-row-remove:focus-visible { + color: var(--color-warning); +} + +.fjs-container .fjs-repeat-render-footer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + padding: 12px 4px; +} + +.fjs-container .fjs-repeat-render-footer.fjs-remove-allowed { + margin-right: 32px; + justify-content: space-between; +} + +.fjs-container .fjs-repeat-render-footer button { + background: none; + border: none; + padding: 4px; + margin: 0; + font-family: inherit; + font-size: inherit; + cursor: pointer; + color: var(--color-accent); + display: flex; + align-items: center; +} + +.fjs-container .fjs-repeat-render-footer button svg { + margin-right: 4px; +} + /** * Flatpickr style adjustments */ diff --git a/packages/form-js-viewer/karma.conf.js b/packages/form-js-viewer/karma.conf.js index 0ba79aa6e..a4bcf47a8 100644 --- a/packages/form-js-viewer/karma.conf.js +++ b/packages/form-js-viewer/karma.conf.js @@ -29,7 +29,19 @@ module.exports = function(karma) { [ suite ]: [ 'webpack', 'env' ] }, - reporters: [ 'progress' ].concat(coverage ? 'coverage' : []), + reporters: [ 'spec' ].concat(coverage ? 'coverage' : []), + + specReporter: { + maxLogLines: 10, + suppressSummary: true, + suppressErrorSummary: false, + suppressFailed: false, + suppressPassed: false, + suppressSkipped: true, + showBrowser: false, + showSpecTiming: false, + failFast: false + }, coverageReporter: { reporters: [ diff --git a/packages/form-js-viewer/src/Form.js b/packages/form-js-viewer/src/Form.js index 7af68f833..c493c0389 100644 --- a/packages/form-js-viewer/src/Form.js +++ b/packages/form-js-viewer/src/Form.js @@ -1,10 +1,11 @@ import Ids from 'ids'; -import { get, isString, isUndefined, set } from 'min-dash'; +import { get, isObject, isString, isUndefined, set } from 'min-dash'; import { ExpressionLanguageModule, MarkdownModule, - ViewerCommandsModule + ViewerCommandsModule, + RepeatRenderModule } from './features'; import core from './core'; @@ -136,7 +137,7 @@ export default class Form { warnings } = this.get('importer').importSchema(schema); - const initializedData = this._initializeFieldData(clone(data)); + const initializedData = this._getInitializedFieldData(clone(data)); this._setState({ data: initializedData, @@ -201,32 +202,61 @@ export default class Form { * @returns {Errors} */ validate() { - const formFieldRegistry = this.get('formFieldRegistry'), + const formFields = this.get('formFields'), + formFieldRegistry = this.get('formFieldRegistry'), pathRegistry = this.get('pathRegistry'), validator = this.get('validator'); const { data } = this._getState(); - const getErrorPath = (field) => [ field.id ]; + const getErrorPath = (field, indexes) => [ field.id, ...Object.values(indexes || {}) ]; - const errors = formFieldRegistry.getAll().reduce((errors, field) => { - const { - disabled - } = field; + function validateFieldRecursively(errors, field, indexes) { + const { disabled, type, isRepeating } = field; + const { config: fieldConfig } = formFields.get(type); + // (1) Skip disabled fields if (disabled) { - return errors; + return; + } + + // (2) Validate the field + const valuePath = pathRegistry.getValuePath(field, { indexes }); + const valueData = get(data, valuePath); + const fieldErrors = validator.validateField(field, valueData); + + if (fieldErrors.length) { + set(errors, getErrorPath(field, indexes), fieldErrors); + } + + // (3) Process parents + if (!Array.isArray(field.components)) { + return; } - const value = get(data, pathRegistry.getValuePath(field)); + // (4a) Recurse repeatable parents both across the indexes of repetition and the children + if (fieldConfig.repeatable && isRepeating) { - const fieldErrors = validator.validateField(field, value); + if (!Array.isArray(valueData)) { + return; + } + + valueData.forEach((_, index) => { + field.components.forEach((component) => { + validateFieldRecursively(errors, component, { ...indexes, [field.id]: index }); + }); + }); - return set(errors, getErrorPath(field), fieldErrors.length ? fieldErrors : undefined); - }, /** @type {Errors} */ ({})); + return; + } - const filteredErrors = this._applyConditions(errors, data, { getFilterPath: getErrorPath }); + // (4b) Recurse non-repeatable parents only across the children + field.components.forEach((component) => validateFieldRecursively(errors, component, indexes)); + } + const workingErrors = {}; + validateFieldRecursively(workingErrors, formFieldRegistry.getForm()); + const filteredErrors = this._applyConditions(workingErrors, data, { getFilterPath: getErrorPath, leafNodeDeletionOnly: true }); this._setState({ errors: filteredErrors }); return filteredErrors; @@ -334,11 +364,12 @@ export default class Form { /** * @internal * - * @param { { add?: boolean, field: any, remove?: number, value?: any } } update + * @param { { add?: boolean, field: any, indexes: object, remove?: number, value?: any } } update */ _update(update) { const { field, + indexes, value } = update; @@ -352,9 +383,11 @@ export default class Form { const fieldErrors = validator.validateField(field, value); - set(data, pathRegistry.getValuePath(field), value); + const valuePath = pathRegistry.getValuePath(field, { indexes }); + + set(data, valuePath, value); - set(errors, [ field.id ], fieldErrors.length ? fieldErrors : undefined); + set(errors, [ field.id, ...Object.values(indexes || {}) ], fieldErrors.length ? fieldErrors : undefined); this._setState({ data: clone(data), @@ -388,7 +421,8 @@ export default class Form { return [ ExpressionLanguageModule, MarkdownModule, - ViewerCommandsModule + ViewerCommandsModule, + RepeatRenderModule ]; } @@ -403,34 +437,52 @@ export default class Form { * @internal */ _getSubmitData() { - - const formFieldRegistry = this.get('formFieldRegistry'), - pathRegistry = this.get('pathRegistry'), - formFields = this.get('formFields'); + const formFieldRegistry = this.get('formFieldRegistry'); + const formFields = this.get('formFields'); + const pathRegistry = this.get('pathRegistry'); const formData = this._getState().data; - const submitData = formFieldRegistry.getAll().reduce((previous, field) => { - const { - disabled, - type - } = field; - + function collectSubmitDataRecursively(submitData, formField, indexes) { + const { disabled, type } = formField; const { config: fieldConfig } = formFields.get(type); - // do not submit disabled form fields or routing fields - if (disabled || !fieldConfig.keyed) { - return previous; + // (1) Process keyed fields + if (!disabled && fieldConfig.keyed) { + const valuePath = pathRegistry.getValuePath(formField, { indexes }); + const value = get(formData, valuePath); + set(submitData, valuePath, value); } - const valuePath = pathRegistry.getValuePath(field); + // (2) Process parents + if (!Array.isArray(formField.components)) { + return; + } + + // (3a) Recurse repeatable parents both across the indexes of repetition and the children + if (fieldConfig.repeatable && formField.isRepeating) { - const value = get(formData, valuePath); - return set(previous, valuePath, value); - }, {}); + const valueData = get(formData, pathRegistry.getValuePath(formField, { indexes })); - const filteredSubmitData = this._applyConditions(submitData, formData); + if (!Array.isArray(valueData)) { + return; + } - return filteredSubmitData; + valueData.forEach((_, index) => { + formField.components.forEach((component) => { + collectSubmitDataRecursively(submitData, component, { ...indexes, [formField.id]: index }); + }); + }); + + return; + } + + // (3b) Recurse non-repeatable parents only across the children + formField.components.forEach((component) => collectSubmitDataRecursively(submitData, component, indexes)); + } + + const workingSubmitData = {}; + collectSubmitDataRecursively(workingSubmitData, formFieldRegistry.getForm(), {}); + return this._applyConditions(workingSubmitData, formData); } /** @@ -444,39 +496,83 @@ export default class Form { /** * @internal */ - _initializeFieldData(data) { - const formFieldRegistry = this.get('formFieldRegistry'), - formFields = this.get('formFields'), - pathRegistry = this.get('pathRegistry'); - - return formFieldRegistry.getAll().reduce((initializedData, formField) => { - const { - defaultValue, - type - } = formField; + _getInitializedFieldData(data, options = {}) { + const formFieldRegistry = this.get('formFieldRegistry'); + const formFields = this.get('formFields'); + const pathRegistry = this.get('pathRegistry'); - // try to get value from data - // if unavailable - try to get default value from form field - // if unavailable - get empty value from form field - - const valuePath = pathRegistry.getValuePath(formField); + function initializeFieldDataRecursively(initializedData, formField, indexes) { + const { defaultValue, type, isRepeating } = formField; + const { config: fieldConfig } = formFields.get(type); - if (valuePath) { + const valuePath = pathRegistry.getValuePath(formField, { indexes }); + let valueData = get(data, valuePath); - const { config: fieldConfig } = formFields.get(type); - let valueData = get(data, valuePath); + // (1) Process keyed fields + if (fieldConfig.keyed) { + // (a) Retrieve and sanitize data from input if (!isUndefined(valueData) && fieldConfig.sanitizeValue) { valueData = fieldConfig.sanitizeValue({ formField, data, value: valueData }); } + // (b) Initialize field value in output data const initializedFieldValue = !isUndefined(valueData) ? valueData : (!isUndefined(defaultValue) ? defaultValue : fieldConfig.emptyValue); + set(initializedData, valuePath, initializedFieldValue); + } + + // (2) Process parents + if (!Array.isArray(formField.components)) { + return; + } + + if (fieldConfig.repeatable && isRepeating) { + + // (a) Sanitize repeatable parents data if it is not an array + if (!valueData || !Array.isArray(valueData)) { + valueData = new Array(isUndefined(formField.defaultRepetitions) ? 1 : formField.defaultRepetitions).fill().map(_ => ({})) || []; + } - return set(initializedData, valuePath, initializedFieldValue); + // (b) Ensure all elements of the array are objects + valueData = valueData.map((val) => isObject(val) ? val : {}); + + // (c) Initialize field value in output data + set(initializedData, valuePath, valueData); + + // (d) If indexed ahead of time, recurse repeatable simply across the children + if (!isUndefined(indexes[formField.id])) { + formField.components.forEach( + (component) => initializeFieldDataRecursively(initializedData, component, { ...indexes }) + ); + + return; + } + + // (e1) Recurse repeatable parents both across the indexes of repetition and the children + valueData.forEach((_, index) => { + formField.components.forEach( + (component) => initializeFieldDataRecursively(initializedData, component, { ...indexes, [formField.id]: index }) + ); + }); + + return; } - return initializedData; + // (e2) Recurse non-repeatable parents only across the children + formField.components.forEach((component) => initializeFieldDataRecursively(initializedData, component, indexes)); + } + + // allows definition of a specific subfield to generate the data for + const container = options.container || formFieldRegistry.getForm(); + const indexes = options.indexes || {}; + const basePath = pathRegistry.getValuePath(container, { indexes }) || []; - }, data); + // if indexing ahead of time, we must add this index to the data path at the end + const path = !isUndefined(indexes[container.id]) ? [ ...basePath, indexes[container.id] ] : basePath; + + const workingData = clone(data); + initializeFieldDataRecursively(workingData, container, indexes); + return get(workingData, path, {}); } + } diff --git a/packages/form-js-viewer/src/core/FieldFactory.js b/packages/form-js-viewer/src/core/FieldFactory.js index 9eb1c9824..affdf5c66 100644 --- a/packages/form-js-viewer/src/core/FieldFactory.js +++ b/packages/form-js-viewer/src/core/FieldFactory.js @@ -1,3 +1,5 @@ +import { getAncestryList } from '../util'; + export default class FieldFactory { /** @@ -41,14 +43,24 @@ export default class FieldFactory { // ensure that we can claim the path const parent = _parent && this._formFieldRegistry.get(_parent); - const parentPath = parent && this._pathRegistry.getValuePath(parent) || []; - - if (config.keyed && key && !this._pathRegistry.canClaimPath([ ...parentPath, ...key.split('.') ], true)) { + const knownAncestorIds = getAncestryList(_parent, this._formFieldRegistry); + + if (config.keyed && key && !this._pathRegistry.canClaimPath([ ...parentPath, ...key.split('.') ], + { + isClosed: true, + knownAncestorIds + }) + ) { throw new Error(`binding path '${ [ ...parentPath, key ].join('.') }' is already claimed`); } - if (config.pathed && path && !this._pathRegistry.canClaimPath([ ...parentPath, ...path.split('.') ], false)) { + if (config.pathed && path && !this._pathRegistry.canClaimPath([ ...parentPath, ...path.split('.') ], + { + isRepeatable: config.repeatable, + knownAncestorIds + }) + ) { throw new Error(`binding path '${ [ ...parentPath, ...path.split('.') ].join('.') }' is already claimed`); } @@ -65,10 +77,17 @@ export default class FieldFactory { if (config.keyed) { this._ensureKey(field); + this._pathRegistry.claimPath(this._pathRegistry.getValuePath(field), { isClosed: true, claimerId: field.id, knownAncestorIds: getAncestryList(_parent, this._formFieldRegistry) }); } - if (config.pathed && path) { - this._pathRegistry.claimPath(this._pathRegistry.getValuePath(field), false); + if (config.pathed) { + if (config.repeatable) { + this._enforceDefaultPath(field); + } + + if (field.path) { + this._pathRegistry.claimPath(this._pathRegistry.getValuePath(field), { isRepeatable: config.repeatable, claimerId: field.id, knownAncestorIds: getAncestryList(_parent, this._formFieldRegistry) }); + } } return field; @@ -92,22 +111,30 @@ export default class FieldFactory { } _ensureKey(field) { - if (!field.key) { + field.key = this._getUniqueKeyPath(field); + } + } + + _enforceDefaultPath(field) { + if (!field.path) { + field.path = this._getUniqueKeyPath(field); + } + } - let random; - const parent = this._formFieldRegistry.get(field._parent); + _getUniqueKeyPath(field) { - // ensure key uniqueness at level - do { - random = Math.random().toString(36).substring(7); - } while (parent && parent.components.some(child => child.key === random)); + let random; + const parent = this._formFieldRegistry.get(field._parent); - field.key = `${field.type}_${random}`; - } + // ensure key uniqueness at level + do { + random = Math.random().toString(36).substring(7); + } while (parent && parent.components.some(child => child.key === random)); - this._pathRegistry.claimPath(this._pathRegistry.getValuePath(field), true); + return `${field.type}_${random}`; } + } diff --git a/packages/form-js-viewer/src/core/FormFieldRegistry.js b/packages/form-js-viewer/src/core/FormFieldRegistry.js index 5794a2a10..f3aad4db2 100644 --- a/packages/form-js-viewer/src/core/FormFieldRegistry.js +++ b/packages/form-js-viewer/src/core/FormFieldRegistry.js @@ -43,6 +43,10 @@ export default class FormFieldRegistry { return Object.values(this._formFields); } + getForm() { + return this.getAll().find((formField) => formField.type === 'default'); + } + forEach(callback) { this.getAll().forEach((formField) => callback(formField)); } diff --git a/packages/form-js-viewer/src/core/FormLayouter.js b/packages/form-js-viewer/src/core/FormLayouter.js index aec88638b..1bc75d522 100644 --- a/packages/form-js-viewer/src/core/FormLayouter.js +++ b/packages/form-js-viewer/src/core/FormLayouter.js @@ -102,7 +102,7 @@ export default class FormLayouter { components } = formField; - if (type !== 'default' && type !== 'group' || !components) { + if (![ 'default', 'group', 'dynamiclist' ].includes(type) || !components) { return; } diff --git a/packages/form-js-viewer/src/core/PathRegistry.js b/packages/form-js-viewer/src/core/PathRegistry.js index 296232681..90e2d416a 100644 --- a/packages/form-js-viewer/src/core/PathRegistry.js +++ b/packages/form-js-viewer/src/core/PathRegistry.js @@ -1,5 +1,5 @@ import { isArray } from 'min-dash'; -import { clone } from '../util'; +import { clone, getAncestryList } from '../util'; /** * The PathRegistry class manages a hierarchical structure of paths associated with form fields. @@ -33,39 +33,70 @@ import { clone } from '../util'; * ] */ export default class PathRegistry { - constructor(formFieldRegistry, formFields) { + constructor(formFieldRegistry, formFields, injector) { this._formFieldRegistry = formFieldRegistry; this._formFields = formFields; + this._injector = injector; this._dataPaths = []; } - canClaimPath(path, closed = false) { + canClaimPath(path, options = {}) { + + const { + isClosed = false, + isRepeatable = false, + skipAncestryCheck = false, + claimerId = null, + knownAncestorIds = [] + } = options; let node = { children: this._dataPaths }; + // (1) if we reach a leaf node, we cannot claim it, if we reach an open node, we can + // if we reach a repeatable node, we need to ensure that the claimer is (or will be) an ancestor of the repeater for (const segment of path) { node = _getNextSegment(node, segment); - // if no node at that path, we can claim it no matter what if (!node) { return true; } - // if we reach a leaf node, definitely not claimable + if (node.isRepeatable && !skipAncestryCheck) { + + if (!(claimerId || knownAncestorIds.length)) { + throw new Error('cannot claim a path that contains a repeater without specifying a claimerId or knownAncestorIds'); + } + + const isValidRepeatClaim = + knownAncestorIds.includes(node.repeaterId) || + claimerId && getAncestryList(claimerId, this._formFieldRegistry).includes(node.repeaterId); + + if (!isValidRepeatClaim) { + return false; + } + } + if (node.children === null) { return false; } } - // if after all segments we reach a node with children, we can claim it only openly - return !closed; + // (2) if the path lands in the middle of the tree, we can only claim an open, non-repeatable path + return !(isClosed || isRepeatable); } - claimPath(path, closed = false) { + claimPath(path, options = {}) { - if (!this.canClaimPath(path, closed)) { + const { + isClosed = false, + isRepeatable = false, + claimerId = null, + knownAncestorIds = [] + } = options; + + if (!this.canClaimPath(path, { isClosed, isRepeatable, claimerId, knownAncestorIds })) { throw new Error(`cannot claim path '${ path.join('.') }'`); } @@ -86,9 +117,16 @@ export default class PathRegistry { node = child; } - if (closed) { + if (isClosed) { node.children = null; } + + // add some additional info when we make a repeatable path claim + if (isRepeatable) { + node.isRepeatable = true; + node.repeaterId = claimerId; + } + } unclaimPath(path) { @@ -137,20 +175,25 @@ export default class PathRegistry { const formFieldConfig = this._formFields.get(field.type).config; if (formFieldConfig.keyed) { - const callResult = fn({ field, isClosed: true, context }); + const callResult = fn({ field, isClosed: true, isRepeatable: false, context }); return result && callResult; } else if (formFieldConfig.pathed) { - const callResult = fn({ field, isClosed: false, context }); + const callResult = fn({ field, isClosed: false, isRepeatable: formFieldConfig.repeatable, context }); result = result && callResult; } - if (field.components) { + // stop executing if false is specifically returned or if preventing recursion + if (result === false || context.preventRecursion) { + return result; + } + + if (Array.isArray(field.components)) { for (const child of field.components) { const callResult = this.executeRecursivelyOnFields(child, fn, clone(context)); result = result && callResult; - // only stop executing if false is specifically returned, not if undefined + // stop executing if false is specifically returned if (result === false) { return result; } @@ -166,6 +209,7 @@ export default class PathRegistry { * @param {Object} field - The field object with properties: `key`, `path`, `id`, and optionally `_parent`. * @param {Object} [options={}] - Configuration options. * @param {Object} [options.replacements={}] - A map of field IDs to alternative path arrays. + * @param {Object} [options.indexes=null] - A map of parent IDs to the index of the field within said parent, leave null to get an unindexed path. * @param {Object} [options.cutoffNode] - The ID of the parent field at which to stop generating the path. * * @returns {(Array|undefined)} An array of strings representing the binding path, or undefined if not determinable. @@ -173,6 +217,7 @@ export default class PathRegistry { getValuePath(field, options = {}) { const { replacements = {}, + indexes = null, cutoffNode = null } = options; @@ -181,6 +226,7 @@ export default class PathRegistry { const hasReplacement = Object.prototype.hasOwnProperty.call(replacements, field.id); const formFieldConfig = this._formFields.get(field.type).config; + // uses path overrides instead of true path to calculate a potential value path if (hasReplacement) { const replacement = replacements[field.id]; @@ -200,6 +246,12 @@ export default class PathRegistry { localValuePath = field.path.split('.'); } + // add potential indexes of repeated fields + if (indexes) { + localValuePath = this._addIndexes(localValuePath, field, indexes); + } + + // if parent exists and isn't cutoff node, add parent's value path if (field._parent && field._parent !== cutoffNode) { const parent = this._formFieldRegistry.get(field._parent); return [ ...(this.getValuePath(parent, options) || []), ...localValuePath ]; @@ -211,6 +263,17 @@ export default class PathRegistry { clear() { this._dataPaths = []; } + + _addIndexes(localValuePath, field, indexes) { + + const repeatRenderManager = this._injector.get('repeatRenderManager', false); + + if (repeatRenderManager && repeatRenderManager.isFieldRepeating(field._parent)) { + return [ indexes[field._parent], ...localValuePath ]; + } + + return localValuePath; + } } const _getNextSegment = (node, segment) => { @@ -218,4 +281,4 @@ const _getNextSegment = (node, segment) => { return null; }; -PathRegistry.$inject = [ 'formFieldRegistry', 'formFields' ]; \ No newline at end of file +PathRegistry.$inject = [ 'formFieldRegistry', 'formFields', 'injector' ]; \ No newline at end of file diff --git a/packages/form-js-viewer/src/features/expression-language/ConditionChecker.js b/packages/form-js-viewer/src/features/expression-language/ConditionChecker.js deleted file mode 100644 index 22ece1f67..000000000 --- a/packages/form-js-viewer/src/features/expression-language/ConditionChecker.js +++ /dev/null @@ -1,116 +0,0 @@ -import { unaryTest } from 'feelin'; -import { get, isString, set, values, isObject } from 'min-dash'; -import { clone } from '../../util'; - -/** - * @typedef {object} Condition - * @property {string} [hide] - */ - -export default class ConditionChecker { - constructor(formFieldRegistry, pathRegistry, eventBus) { - this._formFieldRegistry = formFieldRegistry; - this._pathRegistry = pathRegistry; - this._eventBus = eventBus; - } - - /** - * For given data, remove properties based on condition. - * - * @param {Object} properties - * @param {Object} data - * @param {Object} [options] - * @param {Function} [options.getFilterPath] - */ - applyConditions(properties, data = {}, options = {}) { - - const newProperties = clone(properties); - - const { - getFilterPath = (field) => this._pathRegistry.getValuePath(field) - } = options; - - const form = this._formFieldRegistry.getAll().find((field) => field.type === 'default'); - - if (!form) { - throw new Error('form field registry has no form'); - } - - this._pathRegistry.executeRecursivelyOnFields(form, ({ field, isClosed, context }) => { - const { conditional: condition } = field; - - context.isHidden = context.isHidden || (condition && this._checkHideCondition(condition, data)); - - // only clear the leaf nodes, as groups may both point to the same path - if (context.isHidden && isClosed) { - this._clearObjectValueRecursively(getFilterPath(field), newProperties); - } - }); - - return newProperties; - } - - /** - * Check if given condition is met. Returns null for invalid/missing conditions. - * - * @param {string} condition - * @param {import('../../types').Data} [data] - * - * @returns {boolean|null} - */ - check(condition, data = {}) { - if (!condition) { - return null; - } - - if (!isString(condition) || !condition.startsWith('=')) { - return null; - } - - try { - - // cut off initial '=' - const result = unaryTest(condition.slice(1), data); - - return result; - } catch (error) { - this._eventBus.fire('error', { error }); - return null; - } - } - - /** - * Check if hide condition is met. - * - * @param {Condition} condition - * @param {Object} data - * @returns {boolean} - */ - _checkHideCondition(condition, data) { - if (!condition.hide) { - return false; - } - - const result = this.check(condition.hide, data); - - return result === true; - } - - _clearObjectValueRecursively(valuePath, obj) { - const workingValuePath = [ ...valuePath ]; - let recurse = false; - - do { - set(obj, workingValuePath, undefined); - workingValuePath.pop(); - const parentObject = get(obj, workingValuePath); - recurse = isObject(parentObject) && !values(parentObject).length && !!workingValuePath.length; - } while (recurse); - } -} - -ConditionChecker.$inject = [ - 'formFieldRegistry', - 'pathRegistry', - 'eventBus' -]; diff --git a/packages/form-js-viewer/src/features/expressionLanguage/ConditionChecker.js b/packages/form-js-viewer/src/features/expressionLanguage/ConditionChecker.js new file mode 100644 index 000000000..a5430fb71 --- /dev/null +++ b/packages/form-js-viewer/src/features/expressionLanguage/ConditionChecker.js @@ -0,0 +1,195 @@ +import { unaryTest } from 'feelin'; +import { get, isString, set, values, isObject } from 'min-dash'; +import { buildExpressionContext, clone } from '../../util'; + +/** + * @typedef {object} Condition + * @property {string} [hide] + */ + +export default class ConditionChecker { + constructor(formFieldRegistry, pathRegistry, eventBus) { + this._formFieldRegistry = formFieldRegistry; + this._pathRegistry = pathRegistry; + this._eventBus = eventBus; + } + + /** + * For given data, remove properties based on condition. + * + * @param {Object} data + * @param {Object} contextData + * @param {Object} [options] + * @param {Function} [options.getFilterPath] + * @param {boolean} [options.leafNodeDeletionOnly] + */ + applyConditions(data, contextData = {}, options = {}) { + + const workingData = clone(data); + + const { + getFilterPath = (field, indexes) => this._pathRegistry.getValuePath(field, { indexes }), + leafNodeDeletionOnly = false + } = options; + + const _applyConditionsWithinScope = (rootField, scopeContext, startHidden = false) => { + + const { + indexes = {}, + expressionIndexes = [], + scopeData = contextData, + parentScopeData = null + } = scopeContext; + + this._pathRegistry.executeRecursivelyOnFields(rootField, ({ field, isClosed, isRepeatable, context }) => { + + const { + conditional, + components, + id + } = field; + + // build the expression context in the right format + const localExpressionContext = buildExpressionContext({ + this: scopeData, + data: contextData, + i: expressionIndexes, + parent: parentScopeData + }); + + context.isHidden = startHidden || context.isHidden || (conditional && this._checkHideCondition(conditional, localExpressionContext)); + + // if a field is repeatable and visible, we need to implement custom recursion on its children + if (isRepeatable && (!context.isHidden || leafNodeDeletionOnly)) { + + // prevent the regular recursion behavior of executeRecursivelyOnFields + context.preventRecursion = true; + + const repeaterValuePath = this._pathRegistry.getValuePath(field, { indexes }); + const repeaterValue = get(contextData, repeaterValuePath); + + // quit early if there are no children or data associated with the repeater + if (!Array.isArray(repeaterValue) || !repeaterValue.length || !Array.isArray(components) || !components.length) { + return; + } + + for (let i = 0; i < repeaterValue.length; i++) { + + // create a new scope context for each index + const newScopeContext = { + indexes: { ...indexes, [id]: i }, + expressionIndexes: [ ...expressionIndexes, i + 1 ], + scopeData: repeaterValue[i], + parentScopeData: scopeData + }; + + // for each child component, apply conditions within the new repetition scope + components.forEach(component => { + _applyConditionsWithinScope(component, newScopeContext, context.isHidden); + }); + + } + + } + + // if we have a hidden repeatable field, and the data structure allows, we clear it directly at the root and stop recursion + if (context.isHidden && !leafNodeDeletionOnly && isRepeatable) { + context.preventRecursion = true; + this._cleanlyClearDataAtPath(getFilterPath(field, indexes), workingData); + } + + // for simple leaf fields, we always clear + if (context.isHidden && isClosed) { + this._cleanlyClearDataAtPath(getFilterPath(field, indexes), workingData); + } + + }); + + }; + + // apply conditions starting with the root of the form + const form = this._formFieldRegistry.getForm(); + + if (!form) { + throw new Error('form field registry has no form'); + } + + _applyConditionsWithinScope(form, { + scopeData: contextData + }); + + return workingData; + } + + /** + * Check if given condition is met. Returns null for invalid/missing conditions. + * + * @param {string} condition + * @param {import('../../types').Data} [data] + * + * @returns {boolean|null} + */ + check(condition, data = {}) { + if (!condition) { + return null; + } + + if (!isString(condition) || !condition.startsWith('=')) { + return null; + } + + try { + + // cut off initial '=' + const result = unaryTest(condition.slice(1), data); + + return result; + } catch (error) { + this._eventBus.fire('error', { error }); + return null; + } + } + + /** + * Check if hide condition is met. + * + * @param {Condition} condition + * @param {Object} data + * @returns {boolean} + */ + _checkHideCondition(condition, data) { + if (!condition.hide) { + return false; + } + + const result = this.check(condition.hide, data); + + return result === true; + } + + _cleanlyClearDataAtPath(valuePath, obj) { + const workingValuePath = [ ...valuePath ]; + let recurse = false; + + do { + set(obj, workingValuePath, undefined); + workingValuePath.pop(); + const parentObject = get(obj, workingValuePath); + recurse = !!workingValuePath.length && (this._isEmptyObject(parentObject) || this._isEmptyArray(parentObject)); + } while (recurse); + } + + _isEmptyObject(parentObject) { + return isObject(parentObject) && !values(parentObject).length; + } + + _isEmptyArray(parentObject) { + return Array.isArray(parentObject) && (!parentObject.length || parentObject.every(item => item === undefined)); + } +} + +ConditionChecker.$inject = [ + 'formFieldRegistry', + 'pathRegistry', + 'eventBus' +]; diff --git a/packages/form-js-viewer/src/features/expression-language/FeelExpressionLanguage.js b/packages/form-js-viewer/src/features/expressionLanguage/FeelExpressionLanguage.js similarity index 100% rename from packages/form-js-viewer/src/features/expression-language/FeelExpressionLanguage.js rename to packages/form-js-viewer/src/features/expressionLanguage/FeelExpressionLanguage.js diff --git a/packages/form-js-viewer/src/features/expression-language/FeelersTemplating.js b/packages/form-js-viewer/src/features/expressionLanguage/FeelersTemplating.js similarity index 100% rename from packages/form-js-viewer/src/features/expression-language/FeelersTemplating.js rename to packages/form-js-viewer/src/features/expressionLanguage/FeelersTemplating.js diff --git a/packages/form-js-viewer/src/features/expression-language/index.js b/packages/form-js-viewer/src/features/expressionLanguage/index.js similarity index 100% rename from packages/form-js-viewer/src/features/expression-language/index.js rename to packages/form-js-viewer/src/features/expressionLanguage/index.js diff --git a/packages/form-js-viewer/src/features/expression-language/variableExtractionHelpers.js b/packages/form-js-viewer/src/features/expressionLanguage/variableExtractionHelpers.js similarity index 100% rename from packages/form-js-viewer/src/features/expression-language/variableExtractionHelpers.js rename to packages/form-js-viewer/src/features/expressionLanguage/variableExtractionHelpers.js diff --git a/packages/form-js-viewer/src/features/index.js b/packages/form-js-viewer/src/features/index.js index daf35a86d..ed35d55ba 100644 --- a/packages/form-js-viewer/src/features/index.js +++ b/packages/form-js-viewer/src/features/index.js @@ -1,7 +1,9 @@ -export { default as ExpressionLanguageModule } from './expression-language'; +export { default as ExpressionLanguageModule } from './expressionLanguage'; export { default as MarkdownModule } from './markdown'; export { default as ViewerCommandsModule } from './viewerCommands'; +export { default as RepeatRenderModule } from './repeatRender'; -export * from './expression-language'; +export * from './expressionLanguage'; export * from './markdown'; -export * from './viewerCommands'; \ No newline at end of file +export * from './viewerCommands'; +export * from './repeatRender'; \ No newline at end of file diff --git a/packages/form-js-viewer/src/features/repeatRender/RepeatRenderManager.js b/packages/form-js-viewer/src/features/repeatRender/RepeatRenderManager.js new file mode 100644 index 000000000..ddb3fef5b --- /dev/null +++ b/packages/form-js-viewer/src/features/repeatRender/RepeatRenderManager.js @@ -0,0 +1,199 @@ +// disable react hook rules as the linter is confusing the functional components within a class as class components +/* eslint-disable react-hooks/rules-of-hooks */ + +import { get } from 'min-dash'; +import { useContext, useMemo, useRef } from 'preact/hooks'; +import LocalExpressionContext from '../../render/context/LocalExpressionContext'; + +import ExpandSvg from '../../render/components/form-fields/icons/Expand.svg'; +import CollapseSvg from '../../render/components/form-fields/icons/Collapse.svg'; +import AddSvg from '../../render/components/form-fields/icons/Add.svg'; +import DeleteSvg from '../../render/components/form-fields/icons/Delete.svg'; + +import { buildExpressionContext } from '../../util'; +import { useScrollIntoView } from '../../render/hooks'; +import classNames from 'classnames'; + +export default class RepeatRenderManager { + + constructor(form, formFields, formFieldRegistry, pathRegistry) { + this._form = form; + this._formFields = formFields; + this._formFieldRegistry = formFieldRegistry; + this._pathRegistry = pathRegistry; + this.Repeater = this.Repeater.bind(this); + this.RepeatFooter = this.RepeatFooter.bind(this); + } + + /** + * Checks whether a field is currently repeating its children. + * + * @param {string} id - The id of the field to check + * @returns {boolean} - True if repeatable, false otherwise + */ + isFieldRepeating(id) { + + if (!id) { + return false; + } + + const formField = this._formFieldRegistry.get(id); + const formFieldDefinition = this._formFields.get(formField.type); + return formFieldDefinition.config.repeatable && formField.isRepeating; + } + + Repeater(props) { + + const { RowsRenderer, indexes, useSharedState, ...restProps } = props; + + const [ sharedRepeatState ] = useSharedState; + + const { data } = this._form._getState(); + + const repeaterField = props.field; + const dataPath = this._pathRegistry.getValuePath(repeaterField, { indexes }); + const values = get(data, dataPath) || []; + + const nonCollapsedItems = this._getNonCollapsedItems(repeaterField); + const collapseEnabled = !repeaterField.disableCollapse && (values.length > nonCollapsedItems); + const isCollapsed = collapseEnabled && sharedRepeatState.isCollapsed; + + const hasChildren = repeaterField.components && repeaterField.components.length > 0; + const showRemove = repeaterField.allowAddRemove && hasChildren; + + const displayValues = isCollapsed ? values.slice(0, nonCollapsedItems) : values; + + const onDeleteItem = (index) => { + + const updatedValues = values.slice(); + updatedValues.splice(index, 1); + + props.onChange({ + field: repeaterField, + value: updatedValues, + indexes + }); + }; + + const parentExpressionContextInfo = useContext(LocalExpressionContext); + + return ( + <> + {displayValues.map((value, index) => { + const elementProps = { + ...restProps, + indexes: { ...(indexes || {}), [ repeaterField.id ]: index }, + }; + + const localExpressionContextInfo = useMemo(() => ({ + data: parentExpressionContextInfo.data, + this: value, + parent: buildExpressionContext(parentExpressionContextInfo), + i: [ ...parentExpressionContextInfo.i , index + 1 ] + }), [ index, value ]); + + return !showRemove ? + + + : +
+
+ + + +
+ +
; + })} + + ); + } + + RepeatFooter(props) { + + const addButtonRef = useRef(null); + const { useSharedState, indexes, field: repeaterField, readonly, disabled } = props; + const [ sharedRepeatState, setSharedRepeatState ] = useSharedState; + + const { data } = this._form._getState(); + + const dataPath = this._pathRegistry.getValuePath(repeaterField, { indexes }); + const values = get(data, dataPath) || []; + + const nonCollapsedItems = this._getNonCollapsedItems(repeaterField); + const collapseEnabled = !repeaterField.disableCollapse && (values.length > nonCollapsedItems); + const isCollapsed = collapseEnabled && sharedRepeatState.isCollapsed; + + const hasChildren = repeaterField.components && repeaterField.components.length > 0; + const showAdd = repeaterField.allowAddRemove && hasChildren; + + const toggle = () => { + setSharedRepeatState(state => ({ ...state, isCollapsed: !isCollapsed })); + }; + + const shouldScroll = useRef(false); + + const onAddItem = () => { + const updatedValues = values.slice(); + const newItem = this._form._getInitializedFieldData(this._form._getState().data, { + container: repeaterField, + indexes: { ...indexes, [ repeaterField.id ]: updatedValues.length } + }); + + updatedValues.push(newItem); + + shouldScroll.current = true; + + props.onChange({ + field: repeaterField, + value: updatedValues, + indexes + }); + + setSharedRepeatState(state => ({ ...state, isCollapsed: false })); + }; + + useScrollIntoView(addButtonRef, [ values.length ], { + align: 'bottom', + behavior: 'auto', + offset: 20 + }, [ shouldScroll ]); + + return
+ { + showAdd ? : null + } + { + collapseEnabled ? : null + } +
; + } + + _getNonCollapsedItems(field) { + const DEFAULT_NON_COLLAPSED_ITEMS = 5; + + const { nonCollapsedItems } = field; + + return nonCollapsedItems ? nonCollapsedItems : DEFAULT_NON_COLLAPSED_ITEMS; + } + +} + +RepeatRenderManager.$inject = [ 'form', 'formFields', 'formFieldRegistry', 'pathRegistry' ]; \ No newline at end of file diff --git a/packages/form-js-viewer/src/features/repeatRender/index.js b/packages/form-js-viewer/src/features/repeatRender/index.js new file mode 100644 index 000000000..cb6c41759 --- /dev/null +++ b/packages/form-js-viewer/src/features/repeatRender/index.js @@ -0,0 +1,8 @@ +import RepeatRenderManager from './RepeatRenderManager'; + +export default { + __init__: [ 'repeatRenderManager' ], + repeatRenderManager: [ 'type', RepeatRenderManager ], +}; + +export { RepeatRenderManager }; \ No newline at end of file diff --git a/packages/form-js-viewer/src/features/viewerCommands/ViewerCommands.js b/packages/form-js-viewer/src/features/viewerCommands/ViewerCommands.js index 69cb2e2ca..00d0b47bc 100644 --- a/packages/form-js-viewer/src/features/viewerCommands/ViewerCommands.js +++ b/packages/form-js-viewer/src/features/viewerCommands/ViewerCommands.js @@ -21,10 +21,11 @@ export default class ViewerCommands { }; } - updateFieldValidation(field, value) { + updateFieldValidation(field, value, indexes) { const context = { field, - value + value, + indexes }; this._commandStack.execute('formField.validation.update', context); diff --git a/packages/form-js-viewer/src/features/viewerCommands/cmd/UpdateFieldValidationHandler.js b/packages/form-js-viewer/src/features/viewerCommands/cmd/UpdateFieldValidationHandler.js index a405044b7..bdfcf2f02 100644 --- a/packages/form-js-viewer/src/features/viewerCommands/cmd/UpdateFieldValidationHandler.js +++ b/packages/form-js-viewer/src/features/viewerCommands/cmd/UpdateFieldValidationHandler.js @@ -9,13 +9,13 @@ export default class UpdateFieldValidationHandler { } execute(context) { - const { field, value } = context; + const { field, value, indexes } = context; const { errors } = this._form._getState(); context.oldErrors = clone(errors); const fieldErrors = this._validator.validateField(field, value); - const updatedErrors = set(errors, [ field.id ], fieldErrors.length ? fieldErrors : undefined); + const updatedErrors = set(errors, [ field.id, ...Object.values(indexes || {}) ], fieldErrors.length ? fieldErrors : undefined); this._form._setState({ errors: updatedErrors }); } diff --git a/packages/form-js-viewer/src/render/components/FormComponent.js b/packages/form-js-viewer/src/render/components/FormComponent.js index 57cdda8b3..15a6c751e 100644 --- a/packages/form-js-viewer/src/render/components/FormComponent.js +++ b/packages/form-js-viewer/src/render/components/FormComponent.js @@ -1,8 +1,9 @@ import FormField from './FormField'; - import PoweredBy from './PoweredBy'; +import LocalExpressionContext from '../context/LocalExpressionContext'; -import useService from '../hooks/useService'; +import { useMemo } from 'preact/hooks'; +import { useFilteredFormData, useService } from '../hooks'; const noop = () => {}; @@ -31,6 +32,15 @@ export default function FormComponent(props) { onReset(); }; + const filteredFormData = useFilteredFormData(); + + const localExpressionContext = useMemo(() => ({ + data: filteredFormData, + parent: null, + this: filteredFormData, + i: [] + }), [ filteredFormData ]); + return (
- - + + + ); diff --git a/packages/form-js-viewer/src/render/components/FormField.js b/packages/form-js-viewer/src/render/components/FormField.js index 7f7777b4c..971f1c7f8 100644 --- a/packages/form-js-viewer/src/render/components/FormField.js +++ b/packages/form-js-viewer/src/render/components/FormField.js @@ -2,7 +2,7 @@ import { useCallback, useContext, useEffect, useMemo } from 'preact/hooks'; import { get } from 'min-dash'; -import { FormRenderContext } from '../context'; +import { FormContext, FormRenderContext } from '../context'; import { useCondition, @@ -10,7 +10,7 @@ import { useService } from '../hooks'; -import { gridColumnClasses } from './Util'; +import { gridColumnClasses, prefixId } from './Util'; const noop = () => false; @@ -18,6 +18,7 @@ const noop = () => false; export default function FormField(props) { const { field, + indexes, onChange } = props; @@ -36,17 +37,19 @@ export default function FormField(props) { const { Element, - Empty, + Hidden, Column } = useContext(FormRenderContext); + const { formId } = useContext(FormContext); + const FormFieldComponent = formFields.get(field.type); if (!FormFieldComponent) { throw new Error(`cannot render field <${field.type}>`); } - const valuePath = useMemo(() => pathRegistry.getValuePath(field), [ field, pathRegistry ]); + const valuePath = useMemo(() => pathRegistry.getValuePath(field, { indexes }), [ field, indexes, pathRegistry ]); const initialValue = useMemo(() => get(initialData, valuePath), [ initialData, valuePath ]); @@ -61,10 +64,10 @@ export default function FormField(props) { const onBlur = useCallback(() => { if (viewerCommands) { - viewerCommands.updateFieldValidation(field, value); + viewerCommands.updateFieldValidation(field, value, indexes); } eventBus.fire('formField.blur', { formField: field }); - }, [ eventBus, viewerCommands, field, value ]); + }, [ eventBus, viewerCommands, field, value, indexes ]); const onFocus = useCallback(() => { eventBus.fire('formField.focus', { formField: field }); @@ -72,24 +75,36 @@ export default function FormField(props) { useEffect(() => { if (viewerCommands && initialValue) { - viewerCommands.updateFieldValidation(field, initialValue); + viewerCommands.updateFieldValidation(field, initialValue, indexes); } - }, [ viewerCommands, field, initialValue ]); + }, [ viewerCommands, field, initialValue, JSON.stringify(indexes) ]); const hidden = useCondition(field.conditional && field.conditional.hide || null); + const onChangeIndexed = useCallback((update) => { + + // add indexes of the keyed field to the update, if any + onChange(FormFieldComponent.config.keyed ? { ...update, indexes } : update); + }, [ onChange, FormFieldComponent.config.keyed, indexes ]); + if (hidden) { - return ; + return ; } + const domId = `${prefixId(field.id, formId, indexes)}`; + const fieldErrors = get(errors, [ field.id, ...Object.values(indexes || {}) ]) || []; + const errorMessageId = errors.length === 0 ? undefined : `${domId}-error-message`; + return ( { + result += `_${index}`; + }); + + return result; } diff --git a/packages/form-js-viewer/src/render/components/form-fields/Checkbox.js b/packages/form-js-viewer/src/render/components/form-fields/Checkbox.js index d4a14c3b8..7179bbeb1 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/Checkbox.js +++ b/packages/form-js-viewer/src/render/components/form-fields/Checkbox.js @@ -1,24 +1,21 @@ -import { useContext } from 'preact/hooks'; - -import { FormContext } from '../../context'; - import Description from '../Description'; import Errors from '../Errors'; import Label from '../Label'; import { - formFieldClasses, - prefixId + formFieldClasses } from '../Util'; + import classNames from 'classnames'; const type = 'checkbox'; - export default function Checkbox(props) { const { disabled, errors = [], + errorMessageId, + domId, onBlur, onFocus, field, @@ -28,7 +25,6 @@ export default function Checkbox(props) { const { description, - id, label, validate = {} } = field; @@ -42,12 +38,9 @@ export default function Checkbox(props) { }); }; - const { formId } = useContext(FormContext); - const errorMessageId = errors.length === 0 ? undefined : `${prefixId(id, formId)}-error-message`; - return