From 2b656f6fbbe1062a3763569857aeaceee62cf3a5 Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Thu, 14 Sep 2023 14:08:09 +0200 Subject: [PATCH 01/11] chore: re-export all editor and viewer exports --- packages/form-js-editor/src/index.js | 11 +++++++++++ packages/form-js/src/editor.js | 6 +----- packages/form-js/src/index.js | 2 +- packages/form-js/src/viewer.js | 7 +------ 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/form-js-editor/src/index.js b/packages/form-js-editor/src/index.js index 1dda29573..43508eecb 100644 --- a/packages/form-js-editor/src/index.js +++ b/packages/form-js-editor/src/index.js @@ -7,6 +7,17 @@ export { schemaVersion }; +export { + useDebounce, + usePrevious, + useService +} from './render/hooks'; + +export { + useService as usePropertiesPanelService, + useVariables +} from './features/properties-panel/hooks'; + /** * @typedef { import('./types').CreateFormEditorOptions } CreateFormEditorOptions */ diff --git a/packages/form-js/src/editor.js b/packages/form-js/src/editor.js index 15c10b048..8bf8c3bbb 100644 --- a/packages/form-js/src/editor.js +++ b/packages/form-js/src/editor.js @@ -1,5 +1 @@ -export { - createFormEditor, - FormEditor, - schemaVersion -} from '@bpmn-io/form-js-editor'; \ No newline at end of file +export * from '@bpmn-io/form-js-editor'; \ No newline at end of file diff --git a/packages/form-js/src/index.js b/packages/form-js/src/index.js index 2c178dcb5..a6759fee4 100644 --- a/packages/form-js/src/index.js +++ b/packages/form-js/src/index.js @@ -1,3 +1,3 @@ export * from './viewer'; -export { createFormEditor, FormEditor } from './editor'; +export * from './editor'; export * from './playground'; \ No newline at end of file diff --git a/packages/form-js/src/viewer.js b/packages/form-js/src/viewer.js index c6ca12878..0cbd0625f 100644 --- a/packages/form-js/src/viewer.js +++ b/packages/form-js/src/viewer.js @@ -1,6 +1 @@ -export { - createForm, - Form, - schemaVersion, - getSchemaVariables -} from '@bpmn-io/form-js-viewer'; \ No newline at end of file +export * from '@bpmn-io/form-js-viewer'; \ No newline at end of file From ef3c0aa8df3b1fa12147a1608355c2bbe09089a2 Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Thu, 14 Sep 2023 14:10:10 +0200 Subject: [PATCH 02/11] feat(palette): collect entries dynamically from form fields definitions --- .../features/palette/components/Palette.js | 80 +++++++++++--- .../palette/components/PaletteEntry.js | 11 +- .../src/render/components/FormEditor.js | 20 ++-- .../spec/features/palette/Palette.spec.js | 103 +++++++++++++++++- 4 files changed, 182 insertions(+), 32 deletions(-) diff --git a/packages/form-js-editor/src/features/palette/components/Palette.js b/packages/form-js-editor/src/features/palette/components/Palette.js index e13102a84..0c5c18c64 100644 --- a/packages/form-js-editor/src/features/palette/components/Palette.js +++ b/packages/form-js-editor/src/features/palette/components/Palette.js @@ -5,26 +5,33 @@ import { useState } from 'preact/hooks'; +import { useService } from '../../../render/hooks'; + import { Slot } from '../../render-injection/slot-fill'; import { CloseIcon, - SearchIcon + SearchIcon, + iconsByType } from '../../../render/components/icons'; import PaletteEntry from './PaletteEntry'; -import { formFields } from '@bpmn-io/form-js-viewer'; +import { sanitizeImageSource } from '@bpmn-io/form-js-viewer'; -export const PALETTE_ENTRIES = formFields.filter(({ config: fieldConfig }) => fieldConfig.type !== 'default').map(({ config: fieldConfig }) => { - return { - label: fieldConfig.label, - type: fieldConfig.type, - group: fieldConfig.group - }; -}); +/** + * @typedef { import('@bpmn-io/form-js-viewer').FormFields } FormFields + * + * @typedef { { + * label: string, + * type: string, + * group: ('basic-input'|'selection'|'presentation'|'action'), + * icon: preact.FunctionalComponent, + * iconUrl: string + * } } PaletteEntry + */ export const PALETTE_GROUPS = [ { @@ -47,13 +54,17 @@ export const PALETTE_GROUPS = [ export default function Palette(props) { - const [ entries, setEntries ] = useState(PALETTE_ENTRIES); + const formFields = useService('formFields'); + + const initialPaletteEntries = collectPaletteEntries(formFields); + + const [ paletteEntries, setPaletteEntries ] = useState(initialPaletteEntries); const [ searchTerm, setSearchTerm ] = useState(''); const inputRef = useRef(); - const groups = groupEntries(entries); + const groups = groupEntries(paletteEntries); const simplifyString = useCallback((str) => { return str @@ -79,8 +90,8 @@ export default function Palette(props) { // filter entries on search change useEffect(() => { - const entries = PALETTE_ENTRIES.filter(filter); - setEntries(entries); + const entries = initialPaletteEntries.filter(filter); + setPaletteEntries(entries); }, [ filter, searchTerm ]); const handleInput = useCallback(event => { @@ -124,6 +135,7 @@ export default function Palette(props) { entries.map(entry => { return ( ); @@ -166,4 +178,46 @@ function groupEntries(entries) { }); return groups.filter(g => g.entries.length); +} + +/** + * Returns a list of palette entries. + * + * @param {FormFields} formFields + * @returns {Array} + */ +export function collectPaletteEntries(formFields) { + return Object.entries(formFields._formFields).map(([ type, formField ]) => { + + const { config: fieldConfig } = formField; + + return { + label: fieldConfig.label, + type: type, + group: fieldConfig.group, + icon: fieldConfig.icon, + iconUrl: fieldConfig.iconUrl + }; + }).filter(({ type }) => type !== 'default'); +} + +/** + * There are various options to specify an icon for a palette entry. + * + * a) via `iconUrl` property in a form field config + * b) via `icon` property in a form field config + * c) via statically defined iconsByType (fallback) + */ +export function getPaletteIcon(entry) { + const { icon, iconUrl, type, label } = entry; + + let Icon; + + if (iconUrl) { + Icon = () => {; + } else { + Icon = icon || iconsByType(type); + } + + return Icon; } \ No newline at end of file diff --git a/packages/form-js-editor/src/features/palette/components/PaletteEntry.js b/packages/form-js-editor/src/features/palette/components/PaletteEntry.js index 1a284e97a..8d55a8de7 100644 --- a/packages/form-js-editor/src/features/palette/components/PaletteEntry.js +++ b/packages/form-js-editor/src/features/palette/components/PaletteEntry.js @@ -1,19 +1,18 @@ -import { - iconsByType -} from '../../../render/components/icons'; - import { useService } from '../../../render/hooks'; export default function PaletteEntry(props) { const { type, - label + label, + icon, + iconUrl, + getPaletteIcon } = props; const modeling = useService('modeling'); const formEditor = useService('formEditor'); - const Icon = iconsByType(type); + const Icon = getPaletteIcon({ icon, iconUrl, label, type }); const onKeyDown = (event) => { if (event.code === 'Enter') { diff --git a/packages/form-js-editor/src/render/components/FormEditor.js b/packages/form-js-editor/src/render/components/FormEditor.js index 18b83a1c7..64e977e9e 100644 --- a/packages/form-js-editor/src/render/components/FormEditor.js +++ b/packages/form-js-editor/src/render/components/FormEditor.js @@ -28,7 +28,7 @@ import { DragAndDropContext } from '../context'; import { DeleteIcon, DraggableIcon } from './icons'; import ModularSection from './ModularSection'; -import Palette, { PALETTE_ENTRIES } from '../../features/palette/components/Palette'; +import Palette, { collectPaletteEntries, getPaletteIcon } from '../../features/palette/components/Palette'; import InjectedRendersRoot from '../../features/render-injection/components/InjectedRendersRoot'; import { SlotFillRoot } from '../../features/render-injection/slot-fill'; @@ -51,8 +51,6 @@ import { import { set as setCursor, unset as unsetCursor } from '../util/Cursor'; -import { iconsByType } from './icons'; - function ContextPad(props) { if (!props.children) { return null; @@ -503,6 +501,8 @@ function CreatePreview(props) { const { drake } = useContext(DragAndDropContext); + const formFields = useService('formFields'); + function handleCloned(clone, original, type) { const fieldType = clone.dataset.fieldType; @@ -510,9 +510,15 @@ function CreatePreview(props) { // (1) field preview if (fieldType) { - const { label } = findPaletteEntry(fieldType); + const paletteEntry = findPaletteEntry(fieldType, formFields); + + if (!paletteEntry) { + return; + } + + const { label } = paletteEntry; - const Icon = iconsByType(fieldType); + const Icon = getPaletteIcon(paletteEntry); clone.innerHTML = ''; @@ -567,8 +573,8 @@ function CreatePreview(props) { // helper ////// -function findPaletteEntry(type) { - return PALETTE_ENTRIES.find(entry => entry.type === type); +function findPaletteEntry(type, formFields) { + return collectPaletteEntries(formFields).find(entry => entry.type === type); } function defaultPropertiesPanel(propertiesPanelConfig) { 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 d88048c45..8ae5aa19e 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 @@ -1,7 +1,9 @@ import { render, fireEvent } from '@testing-library/preact/pure'; +import { FormFields } from '@bpmn-io/form-js-viewer'; + import Palette, { - PALETTE_ENTRIES, + collectPaletteEntries, PALETTE_GROUPS } from '../../../../src/features/palette/components/Palette'; @@ -43,10 +45,12 @@ describe('palette', function() { // given const result = createPalette({ container }); + const paletteEntries = collectPaletteEntries(new FormFields()); + // then - expect(result.container.querySelectorAll('.fjs-palette-field')).to.have.length(PALETTE_ENTRIES.length); + expect(result.container.querySelectorAll('.fjs-palette-field')).to.have.length(paletteEntries.length); - expectEntries(result.container, PALETTE_ENTRIES); + expectEntries(result.container, paletteEntries); }); @@ -167,7 +171,7 @@ describe('palette', function() { fireEvent.click(clear); // then - expectEntries(result.container, PALETTE_ENTRIES); + expectEntries(result.container, collectPaletteEntries(new FormFields())); }); }); @@ -263,16 +267,103 @@ describe('palette', function() { }); }); + + describe('extension support', function() { + + it('should render custom entry from extension', function() { + + // given + const extension = { + config: { + label: 'Custom', + group: 'basic-input', + iconUrl: 'foo-bar' + } + }; + + const formFields = new FormFields(); + + formFields.register('custom', extension); + + // given + const result = createPalette({ container, formFields }); + + const paletteEntries = collectPaletteEntries(formFields); + + // then + expect(result.container.querySelectorAll('.fjs-palette-field')).to.have.length(paletteEntries.length); + + expectEntries(result.container, [ + ...paletteEntries, + { type: 'custom' } + ]); + + }); + + + it('should render custom entry icon from icon', function() { + + // given + const extension = { + config: { + label: 'Custom', + group: 'basic-input', + icon: () =>
+ } + }; + + const formFields = new FormFields(); + + formFields.register('custom', extension); + + // given + const result = createPalette({ container, formFields }); + + // then + expect(result.container.querySelector('.custom-icon')).to.exist; + }); + + + it('should render custom entry icon from iconUrl', function() { + + // given + const extension = { + config: { + label: 'Custom', + group: 'basic-input', + iconUrl: 'https://foo.bar/baz.png' + } + }; + + const formFields = new FormFields(); + + formFields.register('custom', extension); + + // given + const result = createPalette({ container, formFields }); + + const iconImage = result.container.querySelector('.fjs-field-icon-image'); + + // then + expect(iconImage).to.exist; + expect(iconImage.src).to.eql(extension.config.iconUrl); + }); + + }); + }); // helper /////////////// function createPalette(options = {}) { - const { container } = options; + const { + container, + ...rest + } = options; return render( - WithFormEditorContext(, options), + WithFormEditorContext(, rest), { container } From e884c65ae834fac76d9bbbf3fbdd7f2806678103 Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Thu, 14 Sep 2023 14:11:37 +0200 Subject: [PATCH 03/11] feat(properties-panel): introduce providers concept Mirror of the providers concept of bpmn-io/bpmn-js-properties-panel --- .../properties-panel/PropertiesPanel.js | 57 +++---- .../PropertiesPanelRenderer.js | 42 +++++ .../properties-panel/PropertiesProvider.js | 84 ++++++++++ .../src/features/properties-panel/index.js | 6 +- .../properties-panel/PropertiesPanel.spec.js | 145 +++++++++++++++++- .../PropertiesPanelModule.spec.js | 78 +++++++++- .../features/properties-panel/helper/index.js | 52 ++++++- 7 files changed, 424 insertions(+), 40 deletions(-) create mode 100644 packages/form-js-editor/src/features/properties-panel/PropertiesProvider.js diff --git a/packages/form-js-editor/src/features/properties-panel/PropertiesPanel.js b/packages/form-js-editor/src/features/properties-panel/PropertiesPanel.js index 0d379be53..4816af615 100644 --- a/packages/form-js-editor/src/features/properties-panel/PropertiesPanel.js +++ b/packages/form-js-editor/src/features/properties-panel/PropertiesPanel.js @@ -2,52 +2,22 @@ import { PropertiesPanel } from '@bpmn-io/properties-panel'; import { useCallback, + useMemo, useState, useLayoutEffect } from 'preact/hooks'; +import { reduce, isArray } from 'min-dash'; + import { FormPropertiesPanelContext } from './context'; import { PropertiesPanelHeaderProvider } from './PropertiesPanelHeaderProvider'; import { PropertiesPanelPlaceholderProvider } from './PropertiesPanelPlaceholderProvider'; -import { - ConditionGroup, - AppearanceGroup, - CustomPropertiesGroup, - GeneralGroup, - SerializationGroup, - ConstraintsGroup, - ValidationGroup, - ValuesGroups, - LayoutGroup -} from './groups'; - -function getGroups(field, editField, getService) { - - if (!field) { - return []; - } - - const groups = [ - GeneralGroup(field, editField, getService), - ConditionGroup(field, editField), - LayoutGroup(field, editField), - AppearanceGroup(field, editField), - SerializationGroup(field, editField), - ...ValuesGroups(field, editField), - ConstraintsGroup(field, editField), - ValidationGroup(field, editField), - CustomPropertiesGroup(field, editField) - ]; - - // contract: if a group returns null, it should not be displayed at all - return groups.filter(group => group !== null); -} - export default function FormPropertiesPanel(props) { const { eventBus, + getProviders, injector } = props; @@ -105,6 +75,23 @@ export default function FormPropertiesPanel(props) { const editField = useCallback((formField, key, value) => modeling.editFormField(formField, key, value), [ modeling ]); + // retrieve groups for selected form field + const providers = getProviders(selectedFormField); + + const groups = useMemo(() => { + return reduce(providers, function(groups, provider) { + + // do not collect groups for multi element state + if (isArray(selectedFormField)) { + return []; + } + + const updater = provider.getGroups(selectedFormField, editField); + + return updater(groups); + }, []); + }, [ providers, selectedFormField, editField ]); + return (
({ groups}) => Array } } PropertiesProvider */ /** @@ -82,6 +85,7 @@ export default class PropertiesPanelRenderer { _render() { render( , @@ -98,6 +102,44 @@ export default class PropertiesPanelRenderer { this._eventBus.fire('propertiesPanel.destroyed'); } } + + /** + * Register a new properties provider to the properties panel. + * + * @param {PropertiesProvider} provider + * @param {Number} [priority] + */ + registerProvider(provider, priority) { + + if (!priority) { + priority = DEFAULT_PRIORITY; + } + + if (typeof provider.getGroups !== 'function') { + console.error( + 'Properties provider does not implement #getGroups(element) API' + ); + + return; + } + + this._eventBus.on('propertiesPanel.getProviders', priority, function(event) { + event.providers.push(provider); + }); + + this._eventBus.fire('propertiesPanel.providersChanged'); + } + + _getProviders() { + const event = this._eventBus.createEvent({ + type: 'propertiesPanel.getProviders', + providers: [] + }); + + this._eventBus.fire(event); + + return event.providers; + } } PropertiesPanelRenderer.$inject = [ 'config.propertiesPanel', 'injector', 'eventBus' ]; \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/PropertiesProvider.js b/packages/form-js-editor/src/features/properties-panel/PropertiesProvider.js new file mode 100644 index 000000000..70c1ef17d --- /dev/null +++ b/packages/form-js-editor/src/features/properties-panel/PropertiesProvider.js @@ -0,0 +1,84 @@ +import { + ConditionGroup, + AppearanceGroup, + CustomPropertiesGroup, + GeneralGroup, + SerializationGroup, + ConstraintsGroup, + ValidationGroup, + ValuesGroups, + LayoutGroup +} from './groups'; + +import { hasEntryConfigured } from './Util'; + +export default class PropertiesProvider { + constructor(propertiesPanel, injector) { + this._injector = injector; + propertiesPanel.registerProvider(this); + } + + _filterVisibleEntries(groups, field, getService) { + return groups.forEach(group => { + const { + entries + } = group; + + const { + type + } = field; + + const formFields = getService('formFields'); + + const fieldDefinition = formFields.get(type).config; + + if (!entries) { + return; + } + + group.entries = entries.filter(entry => { + const { + isDefaultVisible + } = entry; + + if (!isDefaultVisible) { + return true; + } + + return isDefaultVisible(field) || hasEntryConfigured(fieldDefinition, entry.id); + }); + }); + } + + getGroups(field, editField) { + return (groups) => { + if (!field) { + return groups; + } + + const getService = (type, strict = true) => this._injector.get(type, strict); + + groups = [ + ...groups, + GeneralGroup(field, editField, getService), + ConditionGroup(field, editField), + LayoutGroup(field, editField), + AppearanceGroup(field, editField), + SerializationGroup(field, editField), + ...ValuesGroups(field, editField, getService), + ConstraintsGroup(field, editField), + ValidationGroup(field, editField), + CustomPropertiesGroup(field, editField) + ].filter(group => group != null); + + this._filterVisibleEntries(groups, field, getService); + + // contract: if a group has no entries or items, it should not be displayed at all + return groups.filter(group => { + return group.items || group.entries && group.entries.length; + }); + }; + } +} + +PropertiesProvider.$inject = [ 'propertiesPanel', 'injector' ]; \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/index.js b/packages/form-js-editor/src/features/properties-panel/index.js index b627b003d..119f88f39 100644 --- a/packages/form-js-editor/src/features/properties-panel/index.js +++ b/packages/form-js-editor/src/features/properties-panel/index.js @@ -1,4 +1,5 @@ import PropertiesPanelModule from './PropertiesPanelRenderer'; +import PropertiesProvider from './PropertiesProvider'; import { FeelPopupModule } from '@bpmn-io/properties-panel'; @@ -6,6 +7,7 @@ export default { __depends__: [ FeelPopupModule ], - __init__: [ 'propertiesPanel' ], - propertiesPanel: [ 'type', PropertiesPanelModule ] + __init__: [ 'propertiesPanel', 'propertiesProvider' ], + propertiesPanel: [ 'type', PropertiesPanelModule ], + propertiesProvider: [ 'type', PropertiesProvider ] }; \ No newline at end of file diff --git a/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanel.spec.js b/packages/form-js-editor/test/spec/features/properties-panel/PropertiesPanel.spec.js index c94613c5a..3560f3e13 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 @@ -12,6 +12,10 @@ import PropertiesPanel from '../../../../src/features/properties-panel/Propertie import { VALUES_SOURCES, VALUES_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'; + +import { FormFields } from '@bpmn-io/form-js-viewer'; + import { EventBus as eventBusMock, FormEditor as formEditorMock, @@ -20,7 +24,8 @@ import { Modeling as modelingMock, Templating as templatingMock, PathRegistry as pathRegistryMock, - Injector as injectorMock + Injector as injectorMock, + PropertiesPanelMock as propertiesPanelMock } from './helper'; import schema from '../../form.json'; @@ -3738,6 +3743,127 @@ describe('properties panel', function() { }); + + describe('extension support', function() { + + it('should render configured propertiesPanelEntries', function() { + + // given + const field = { + id: 'Custom_1', + type: 'custom' + }; + + const extension = { + config: { + propertiesPanelEntries: [ + 'label', + 'description' + ] + } + }; + + const formFields = new FormFields(); + formFields.register('custom', extension); + + const result = createPropertiesPanel({ + container, + field, + formFields + }); + + // then + expectGroupEntries(result.container, 'General', [ + 'Field label', + 'Field description' + ]); + + }); + + + it('should render configured values groups', function() { + + // given + const field = { + id: 'Custom_1', + type: 'custom', + values: [] + }; + + const extension = { + config: { + propertiesPanelEntries: [ + 'values' + ] + } + }; + + const formFields = new FormFields(); + formFields.register('custom', extension); + + const result = createPropertiesPanel({ + container, + field, + formFields + }); + + // then + expectGroups(result.container, [ + 'Condition', + 'Layout', + 'Options source', + 'Static options', + 'Custom properties' + ]); + + }); + + + it('should render from provider', function() { + + // given + const propertiesProvider = { + getGroups(element) { + return (groups) => { + + return [ + ...groups, + { + id: 'custom', + label: 'Custom group', + entries: [] + } + ]; + }; + } + }; + + const field = { + id: 'Custom_1', + type: 'textfield', + values: [] + }; + + const result = createPropertiesPanel({ + container, + field, + propertiesProviders: [ + propertiesProvider + ] + }); + + // then + expectGroups(result.container, [ + 'Condition', + 'Layout', + 'Custom properties', + 'Custom group' + ]); + + }); + + }); + }); @@ -3751,6 +3877,7 @@ function createPropertiesPanel(options = {}, renderFn = render) { evaluateTemplate = (value) => `Evaluation of "${value}"`, valuePaths = {}, claimedPaths = [], + propertiesProviders = [], field = null } = options; @@ -3759,6 +3886,7 @@ function createPropertiesPanel(options = {}, renderFn = render) { formEditor, formLayoutValidator, pathRegistry, + formFields, modeling, selection, templating @@ -3778,6 +3906,10 @@ function createPropertiesPanel(options = {}, renderFn = render) { formLayoutValidator = new formLayoutValidatorMock(); } + if (!formFields) { + formFields = new FormFields(); + } + if (!modeling) { modeling = new modelingMock({ editFormField: editField @@ -3809,13 +3941,24 @@ function createPropertiesPanel(options = {}, renderFn = render) { eventBus, formEditor, formLayoutValidator, + formFields, modeling, selection, templating, pathRegistry }); + const propertiesPanel = new propertiesPanelMock(); + + const getProviders = () => { + return [ + new PropertiesProvider(propertiesPanel, injector), + ...propertiesProviders + ]; + }; + return renderFn(, { 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 4c23a2a4a..278eb84eb 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 @@ -47,7 +47,8 @@ describe('features/propertiesPanel', function() { async function createEditor(schema, options = {}) { const { additionalModules = [ - propertiesPanelModule + propertiesPanelModule, + ...options.additionalModules || [] ] } = options; @@ -308,4 +309,79 @@ describe('features/propertiesPanel', function() { }); + + describe('providers', function() { + + it('should register provider', async function() { + + // given + const provider = { + getGroups() { + return () => []; + } + }; + + const { formEditor } = await createEditor(schema); + + const propertiesPanel = formEditor.get('propertiesPanel'); + + // assume + expect(propertiesPanel._getProviders()).to.have.length(1); + + // when + propertiesPanel.registerProvider(provider); + + // then + expect(propertiesPanel._getProviders()).to.have.length(2); + }); + + + it('should ignore incompatible provider', async function() { + + // given + const incompatibleProvider = {}; + + const { formEditor } = await createEditor(schema); + + const propertiesPanel = formEditor.get('propertiesPanel'); + + // assume + expect(propertiesPanel._getProviders()).to.have.length(1); + + // when + propertiesPanel.registerProvider(incompatibleProvider); + + // then + // incompatible provider was not added + expect(propertiesPanel._getProviders()).to.have.length(1); + }); + + + it('should fire ', async function() { + + // given + const provider = { + getGroups() { + return () => []; + } + }; + + const { formEditor } = await createEditor(schema); + + const eventBus = formEditor.get('eventBus'); + + const propertiesPanel = formEditor.get('propertiesPanel'); + + const spy = sinon.spy(); + + // when + eventBus.on('propertiesPanel.providersChanged', spy); + propertiesPanel.registerProvider(provider); + + // then + expect(spy).to.have.been.called; + }); + + }); + }); \ No newline at end of file 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 a290bb4c4..5ca17b57f 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,5 +1,9 @@ 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'; @@ -173,6 +177,10 @@ export class Injector { if (type === 'config.propertiesPanel') { return this._options.propertiesPanelConfig || {}; } + + if (type === 'formFields') { + return this._options.formFields || new FormFields(); + } } } @@ -187,6 +195,10 @@ export class Templating { } } +export class PropertiesPanelMock { + registerProvider() {} +} + export function WithFormEditorContext(Component, services = {}) { const formEditorContext = { getService(type, strict) { @@ -239,6 +251,8 @@ export function WithFormEditorContext(Component, services = {}) { return { isExpression: () => false }; + } else if (type === 'formFields') { + return new FormFields(); } } }; @@ -302,6 +316,8 @@ export function WithPropertiesPanelContext(Component, services = {}) { return { isExpression: () => false }; + } else if (type === 'formFields') { + return new FormFields(); } } }; @@ -317,10 +333,15 @@ export function WithPropertiesPanel(options = {}) { const { field = noopField, - groups = [], headerProvider = noopHeaderProvider } = options; + let { + groups = [] + } = options; + + groups = applyDefaultVisible(field, groups); + return ( ); +} + +// helpers ////////////////////// + +function applyDefaultVisible(field, groups) { + + groups.forEach(group => { + const { + entries + } = group; + + if (!entries || !entries.length) { + return true; + } + + group.entries = entries.filter(entry => { + const { + isDefaultVisible + } = entry; + + if (!isDefaultVisible) { + return true; + } + + return isDefaultVisible(field); + }); + }); + + return groups.filter(group => group.entries && group.entries.length); } \ No newline at end of file From 09d2ef62e0b8b025a5a7c1b2265b63effae5b80f Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Thu, 14 Sep 2023 14:12:36 +0200 Subject: [PATCH 04/11] refactor(properties-panel): control entries visibility via `isDefaultVisible` --- .../src/features/properties-panel/Util.js | 22 ++- .../properties-panel/entries/ActionEntry.js | 21 +-- .../properties-panel/entries/AdornerEntry.js | 46 +++--- .../properties-panel/entries/AltTextEntry.js | 21 +-- .../entries/DateTimeConstraintsEntry.js | 51 +++--- .../properties-panel/entries/DateTimeEntry.js | 33 ++-- .../entries/DateTimeSerializationEntry.js | 28 ++-- .../entries/DefaultValueEntry.js | 79 +++++----- .../entries/DescriptionEntry.js | 21 +-- .../properties-panel/entries/DisabledEntry.js | 21 +-- .../properties-panel/entries/IdEntry.js | 17 +- .../entries/ImageSourceEntry.js | 22 +-- .../properties-panel/entries/KeyEntry.js | 21 +-- .../properties-panel/entries/LabelEntry.js | 78 +++++----- .../properties-panel/entries/NumberEntries.js | 14 +- .../entries/NumberSerializationEntry.js | 11 +- .../properties-panel/entries/ReadonlyEntry.js | 21 +-- .../properties-panel/entries/SelectEntries.js | 15 +- .../properties-panel/entries/SpacerEntry.js | 11 +- .../properties-panel/entries/TextEntry.js | 15 +- .../factories/simpleBoolEntryFactory.js | 6 +- .../groups/ValidationGroup.js | 147 +++++++++--------- .../properties-panel/groups/ValuesGroups.js | 11 +- .../groups/AppearanceGroup.spec.js | 8 +- .../groups/SerializationGroup.spec.js | 12 +- 25 files changed, 347 insertions(+), 405 deletions(-) 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 8a43b5e20..1d3933f76 100644 --- a/packages/form-js-editor/src/features/properties-panel/Util.js +++ b/packages/form-js-editor/src/features/properties-panel/Util.js @@ -77,4 +77,24 @@ export const VALUES_INPUTS = [ 'radio', 'select', 'taglist' -]; \ No newline at end of file +]; + +export function hasEntryConfigured(formFieldDefinition, entryId) { + const { propertiesPanelEntries = [] } = formFieldDefinition; + + if (!propertiesPanelEntries.length) { + return false; + } + + return propertiesPanelEntries.some(id => id === entryId); +} + +export function hasValuesGroupsConfigured(formFieldDefinition) { + const { propertiesPanelEntries = [] } = formFieldDefinition; + + if (!propertiesPanelEntries.length) { + return false; + } + + return propertiesPanelEntries.some(id => id === 'values'); +} \ No newline at end of file diff --git a/packages/form-js-editor/src/features/properties-panel/entries/ActionEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/ActionEntry.js index 474589f45..364987891 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/ActionEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/ActionEntry.js @@ -9,21 +9,16 @@ export default function ActionEntry(props) { field } = props; - const { - type - } = field; - const entries = []; - if (type === 'button') { - entries.push({ - id: 'action', - component: Action, - editField: editField, - field: field, - isEdited: isSelectEntryEdited - }); - } + entries.push({ + id: 'action', + component: Action, + editField: editField, + field: field, + isEdited: isSelectEntryEdited, + isDefaultVisible: (field) => field.type === 'button' + }); return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/AdornerEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/AdornerEntry.js index 8ddfb42c0..a9e28ca6b 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/AdornerEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/AdornerEntry.js @@ -9,10 +9,6 @@ export default function AdornerEntry(props) { field } = props; - const { - type - } = field; - const entries = []; const onChange = (key) => { @@ -29,27 +25,27 @@ export default function AdornerEntry(props) { }; }; - if ([ 'number', 'textfield' ].includes(type)) { - entries.push({ - id: 'prefix-adorner', - component: PrefixAdorner, - isEdited: isFeelEntryEdited, - editField, - field, - onChange, - getValue - }); - - entries.push({ - id: 'suffix-adorner', - component: SuffixAdorner, - isEdited: isFeelEntryEdited, - editField, - field, - onChange, - getValue - }); - } + entries.push({ + id: 'prefix-adorner', + component: PrefixAdorner, + isEdited: isFeelEntryEdited, + editField, + field, + onChange, + getValue, + isDefaultVisible: (field) => [ 'number', 'textfield' ].includes(field.type) + }); + + entries.push({ + id: 'suffix-adorner', + component: SuffixAdorner, + isEdited: isFeelEntryEdited, + editField, + field, + onChange, + getValue, + isDefaultVisible: (field) => [ 'number', 'textfield' ].includes(field.type) + }); return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/AltTextEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/AltTextEntry.js index bb734f614..dcd6e6716 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/AltTextEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/AltTextEntry.js @@ -10,21 +10,16 @@ export default function AltTextEntry(props) { field } = props; - const { - type - } = field; - const entries = []; - if (type === 'image') { - entries.push({ - id: 'alt', - component: AltText, - editField: editField, - field: field, - isEdited: isFeelEntryEdited - }); - } + entries.push({ + id: 'alt', + component: AltText, + editField: editField, + field: field, + isEdited: isFeelEntryEdited, + isDefaultVisible: (field) => [ 'image' ].includes(field.type) + }); return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/DateTimeConstraintsEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/DateTimeConstraintsEntry.js index 5b9b1c449..098d2f976 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/DateTimeConstraintsEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/DateTimeConstraintsEntry.js @@ -11,36 +11,37 @@ export default function DateTimeConstraintsEntry(props) { id } = props; - const { - type, - subtype - } = field; - - if (type !== 'datetime') { - return []; + function isDefaultVisible(subtypes) { + return (field) => { + if (field.type !== 'datetime') { + return false; + } + + return subtypes.includes(field.subtype); + }; } const entries = []; - if (subtype === DATETIME_SUBTYPES.TIME || subtype === DATETIME_SUBTYPES.DATETIME) { - entries.push({ - id: id + '-timeInterval', - component: TimeIntervalSelect, - isEdited: isSelectEntryEdited, - editField, - field - }); - } + entries.push({ + id: id + '-timeInterval', + component: TimeIntervalSelect, + isEdited: isSelectEntryEdited, + editField, + field, + isDefaultVisible: isDefaultVisible([ DATETIME_SUBTYPES.TIME, DATETIME_SUBTYPES.DATETIME ]) + }); + + + entries.push({ + id: id + '-disallowPassedDates', + component: DisallowPassedDates, + isEdited: isCheckboxEntryEdited, + editField, + field, + isDefaultVisible: isDefaultVisible([ DATETIME_SUBTYPES.DATE, DATETIME_SUBTYPES.DATETIME ]) + }); - if (subtype === DATETIME_SUBTYPES.DATE || subtype === DATETIME_SUBTYPES.DATETIME) { - entries.push({ - id: id + '-disallowPassedDates', - component: DisallowPassedDates, - isEdited: isCheckboxEntryEdited, - editField, - field - }); - } return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/DateTimeEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/DateTimeEntry.js index 770547cf8..ff9d245b4 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/DateTimeEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/DateTimeEntry.js @@ -21,36 +21,27 @@ export default function DateTimeEntry(props) { field } = props; - const { - type, - subtype - } = field; - - if (type !== 'datetime') { - return []; - } - const entries = [ { id: 'subtype', component: DateTimeSubtypeSelect, isEdited: isSelectEntryEdited, editField, - field + field, + isDefaultVisible: (field) => field.type === 'datetime' } ]; - if (subtype === DATETIME_SUBTYPES.TIME || subtype === DATETIME_SUBTYPES.DATETIME) { - - entries.push({ - id: 'use24h', - component: Use24h, - isEdited: isCheckboxEntryEdited, - editField, - field - }); - - } + entries.push({ + id: 'use24h', + component: Use24h, + isEdited: isCheckboxEntryEdited, + editField, + field, + isDefaultVisible: (field) => field.type === 'datetime' && ( + field.subtype === DATETIME_SUBTYPES.TIME || field.subtype === DATETIME_SUBTYPES.DATETIME + ) + }); return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/DateTimeSerializationEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/DateTimeSerializationEntry.js index c244e45f3..06f269f77 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/DateTimeSerializationEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/DateTimeSerializationEntry.js @@ -10,26 +10,18 @@ export default function DateTimeFormatEntry(props) { field } = props; - const { - type, - subtype - } = field; - - if (type !== 'datetime') { - return []; - } - const entries = []; - if (subtype === DATETIME_SUBTYPES.TIME || subtype === DATETIME_SUBTYPES.DATETIME) { - entries.push({ - id: 'time-format', - component: TimeFormatSelect, - isEdited: isSelectEntryEdited, - editField, - field - }); - } + entries.push({ + id: 'time-format', + component: TimeFormatSelect, + isEdited: isSelectEntryEdited, + editField, + field, + isDefaultVisible: (field) => field.type === 'datetime' && ( + field.subtype === DATETIME_SUBTYPES.TIME || field.subtype === DATETIME_SUBTYPES.DATETIME + ) + }); return entries; } 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 d46e176cb..bce2d34ba 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 @@ -29,9 +29,17 @@ export default function DefaultOptionEntry(props) { const entries = []; - // Only make default values available when they are statically defined - if (!INPUTS.includes(type) || VALUES_INPUTS.includes(type) && !field.values) { - return entries; + function isDefaultVisible(matchers) { + return (field) => { + + // Only make default values available when they are statically defined + if (!INPUTS.includes(type) || VALUES_INPUTS.includes(type) && !field.values) { + return false; + } + + return matchers(field); + }; + } const defaultOptions = { @@ -41,47 +49,42 @@ export default function DefaultOptionEntry(props) { label: 'Default value' }; - if (type === 'checkbox') { - entries.push({ - ...defaultOptions, - component: DefaultValueCheckbox, - isEdited: isSelectEntryEdited - }); - } + entries.push({ + ...defaultOptions, + component: DefaultValueCheckbox, + isEdited: isSelectEntryEdited, + isDefaultVisible: isDefaultVisible((field) => field.type === 'checkbox') + }); - if (type === 'number') { - entries.push({ - ...defaultOptions, - component: DefaultValueNumber, - isEdited: isTextFieldEntryEdited - }); - } + entries.push({ + ...defaultOptions, + component: DefaultValueNumber, + isEdited: isTextFieldEntryEdited, + isDefaultVisible: isDefaultVisible((field) => field.type === 'number') + }); - if (type === 'radio' || type === 'select') { - entries.push({ - ...defaultOptions, - component: DefaultValueSingleSelect, - isEdited: isSelectEntryEdited - }); - } + entries.push({ + ...defaultOptions, + component: DefaultValueSingleSelect, + isEdited: isSelectEntryEdited, + isDefaultVisible: isDefaultVisible((field) => field.type === 'radio' || field.type === 'select') + }); // todo(Skaiir): implement a multiselect equivalent (cf. https://github.com/bpmn-io/form-js/issues/265) - if (type === 'textfield') { - entries.push({ - ...defaultOptions, - component: DefaultValueTextfield, - isEdited: isTextFieldEntryEdited - }); - } + entries.push({ + ...defaultOptions, + component: DefaultValueTextfield, + isEdited: isTextFieldEntryEdited, + isDefaultVisible: isDefaultVisible((field) => field.type === 'textfield') + }); - if (type === 'textarea') { - entries.push({ - ...defaultOptions, - component: DefaultValueTextarea, - isEdited: isTextAreaEntryEdited - }); - } + entries.push({ + ...defaultOptions, + component: DefaultValueTextarea, + isEdited: isTextAreaEntryEdited, + isDefaultVisible: isDefaultVisible((field) => field.type === 'textarea') + }); return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/DescriptionEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/DescriptionEntry.js index 8634090d0..16925f2e8 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/DescriptionEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/DescriptionEntry.js @@ -13,21 +13,16 @@ export default function DescriptionEntry(props) { field } = props; - const { - type - } = field; - const entries = []; - if (INPUTS.includes(type)) { - entries.push({ - id: 'description', - component: Description, - editField: editField, - field: field, - isEdited: isFeelEntryEdited - }); - } + entries.push({ + id: 'description', + component: Description, + editField: editField, + field: field, + isEdited: isFeelEntryEdited, + isDefaultVisible: (field) => INPUTS.includes(field.type) + }); return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/DisabledEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/DisabledEntry.js index 7333dbdb6..7a72872c4 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/DisabledEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/DisabledEntry.js @@ -11,21 +11,16 @@ export default function DisabledEntry(props) { field } = props; - const { - type - } = field; - const entries = []; - if (INPUTS.includes(type)) { - entries.push({ - id: 'disabled', - component: Disabled, - editField: editField, - field: field, - isEdited: isToggleSwitchEntryEdited - }); - } + entries.push({ + id: 'disabled', + component: Disabled, + editField: editField, + field: field, + isEdited: isToggleSwitchEntryEdited, + isDefaultVisible: (field) => INPUTS.includes(field.type) + }); return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/IdEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/IdEntry.js index 739a8f760..e571e692a 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/IdEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/IdEntry.js @@ -13,15 +13,14 @@ export default function IdEntry(props) { const entries = []; - if (field.type === 'default') { - entries.push({ - id: 'id', - component: Id, - editField: editField, - field: field, - isEdited: isTextFieldEntryEdited - }); - } + entries.push({ + id: 'id', + component: Id, + editField: editField, + field: field, + isEdited: isTextFieldEntryEdited, + isDefaultVisible: (field) => field.type === 'default' + }); return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/ImageSourceEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/ImageSourceEntry.js index 3b39f432d..4461f85f7 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/ImageSourceEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/ImageSourceEntry.js @@ -10,21 +10,15 @@ export default function SourceEntry(props) { field } = props; - const { - type - } = field; - const entries = []; - - if (type === 'image') { - entries.push({ - id: 'source', - component: Source, - editField: editField, - field: field, - isEdited: isFeelEntryEdited - }); - } + entries.push({ + id: 'source', + component: Source, + editField: editField, + field: field, + isEdited: isFeelEntryEdited, + isDefaultVisible: (field) => field.type === 'image' + }); return entries; } 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 bfdc78b4f..046da2cf4 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 @@ -15,21 +15,16 @@ export default function KeyEntry(props) { field } = props; - const { - type - } = field; - const entries = []; - if (INPUTS.includes(type)) { - entries.push({ - id: 'key', - component: Key, - editField: editField, - field: field, - isEdited: isTextFieldEntryEdited - }); - } + entries.push({ + id: 'key', + component: Key, + editField: editField, + field: field, + isEdited: isTextFieldEntryEdited, + isDefaultVisible: (field) => INPUTS.includes(field.type) + }); return entries; } 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 7a0c4f257..b565dec3e 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 @@ -10,48 +10,50 @@ export default function LabelEntry(props) { editField } = props; - const { - type, - subtype - } = field; - const entries = []; - if (type === 'datetime') { - if (subtype === DATETIME_SUBTYPES.DATE || subtype === DATETIME_SUBTYPES.DATETIME) { - entries.push( - { - id: 'date-label', - component: DateLabel, - editField, - field, - isEdited: isFeelEntryEdited - } - ); - } - if (subtype === DATETIME_SUBTYPES.TIME || subtype === DATETIME_SUBTYPES.DATETIME) { - entries.push( - { - id: 'time-label', - component: TimeLabel, - editField, - field, - isEdited: isFeelEntryEdited - } - ); + entries.push( + { + id: 'date-label', + component: DateLabel, + editField, + field, + isEdited: isFeelEntryEdited, + isDefaultVisible: function(field) { + return ( + field.type === 'datetime' && + (field.subtype === DATETIME_SUBTYPES.DATE || field.subtype === DATETIME_SUBTYPES.DATETIME) + ); + } } - } - else if (INPUTS.includes(type) || type === 'button' || type === 'group') { - entries.push( - { - id: 'label', - component: Label, - editField, - field, - isEdited: isFeelEntryEdited + ); + + entries.push( + { + id: 'time-label', + component: TimeLabel, + editField, + field, + isEdited: isFeelEntryEdited, + isDefaultVisible: function(field) { + return ( + field.type === 'datetime' && + (field.subtype === DATETIME_SUBTYPES.TIME || field.subtype === DATETIME_SUBTYPES.DATETIME) + ); } - ); - } + } + ); + + entries.push( + { + id: 'label', + component: Label, + editField, + field, + isEdited: isFeelEntryEdited, + isDefaultVisible: (field) => INPUTS.includes(field.type) || field.type === 'button' || field.type === 'group' + } + ); return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js b/packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js index 531c974dd..e407e5c0c 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/NumberEntries.js @@ -12,14 +12,6 @@ export default function NumberEntries(props) { id } = props; - const { - type - } = field; - - if (type !== 'number') { - return []; - } - const entries = []; entries.push({ @@ -27,7 +19,8 @@ export default function NumberEntries(props) { component: NumberDecimalDigits, isEdited: isNumberFieldEntryEdited, editField, - field + field, + isDefaultVisible: (field) => field.type === 'number' }); entries.push({ @@ -35,7 +28,8 @@ export default function NumberEntries(props) { component: NumberArrowStep, isEdited: isTextFieldEntryEdited, editField, - field + field, + isDefaultVisible: (field) => field.type === 'number' }); return entries; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/NumberSerializationEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/NumberSerializationEntry.js index f0de90c66..600417e13 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/NumberSerializationEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/NumberSerializationEntry.js @@ -10,14 +10,6 @@ export default function NumberSerializationEntry(props) { field } = props; - const { - type - } = field; - - if (type !== 'number') { - return []; - } - const entries = []; entries.push({ @@ -25,7 +17,8 @@ export default function NumberSerializationEntry(props) { component: SerializeToString, isEdited: isCheckboxEntryEdited, editField, - field + field, + isDefaultVisible: (field) => field.type === 'number' }); return entries; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/ReadonlyEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/ReadonlyEntry.js index 65f5bc1f9..eb15a30dc 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/ReadonlyEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/ReadonlyEntry.js @@ -13,21 +13,16 @@ export default function ReadonlyEntry(props) { field } = props; - const { - type - } = field; - const entries = []; - if (INPUTS.includes(type)) { - entries.push({ - id: 'readonly', - component: Readonly, - editField: editField, - field: field, - isEdited: isFeelEntryEdited - }); - } + entries.push({ + id: 'readonly', + component: Readonly, + editField: editField, + field: field, + isEdited: isFeelEntryEdited, + isDefaultVisible: (field) => INPUTS.includes(field.type) + }); return entries; } diff --git a/packages/form-js-editor/src/features/properties-panel/entries/SelectEntries.js b/packages/form-js-editor/src/features/properties-panel/entries/SelectEntries.js index 879826464..9b3a45f01 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/SelectEntries.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/SelectEntries.js @@ -2,24 +2,13 @@ import { simpleBoolEntryFactory } from './factories'; export default function SelectEntries(props) { - const { - field, - } = props; - - const { - type - } = field; - - if (type !== 'select') { - return []; - } - const entries = [ simpleBoolEntryFactory({ id: 'searchable', path: [ 'searchable' ], label: 'Searchable', - props + props, + isDefaultVisible: (field) => field.type === 'select' }) ]; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/SpacerEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/SpacerEntry.js index d691db375..a2870534e 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/SpacerEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/SpacerEntry.js @@ -10,14 +10,6 @@ export default function SpacerEntry(props) { id } = props; - const { - type - } = field; - - if (type !== 'spacer') { - return []; - } - const entries = []; entries.push({ @@ -25,7 +17,8 @@ export default function SpacerEntry(props) { component: SpacerHeight, isEdited: isNumberFieldEntryEdited, editField, - field + field, + isDefaultVisible: (field) => field.type === 'spacer' }); return entries; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/TextEntry.js b/packages/form-js-editor/src/features/properties-panel/entries/TextEntry.js index 7d3cf1d6a..aa0375495 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/TextEntry.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/TextEntry.js @@ -11,28 +11,17 @@ import { useMemo } from 'preact/hooks'; export default function TextEntry(props) { const { editField, - - /* getService, */ field } = props; - const { - type - } = field; - - // const templating = getService('templating'); - - if (type !== 'text') { - return []; - } - const entries = [ { id: 'text', component: Text, editField: editField, field: field, - isEdited: isFeelEntryEdited + isEdited: isFeelEntryEdited, + isDefaultVisible: (field) => field.type === 'text' } ]; diff --git a/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleBoolEntryFactory.js b/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleBoolEntryFactory.js index 883fbb0e6..b4707ca83 100644 --- a/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleBoolEntryFactory.js +++ b/packages/form-js-editor/src/features/properties-panel/entries/factories/simpleBoolEntryFactory.js @@ -7,7 +7,8 @@ export default function simpleBoolEntryFactory(options) { label, description, path, - props + props, + isDefaultVisible } = options; const { @@ -23,7 +24,8 @@ export default function simpleBoolEntryFactory(options) { editField, description, component: SimpleBoolComponent, - isEdited: isCheckboxEntryEdited + isEdited: isCheckboxEntryEdited, + isDefaultVisible }; } diff --git a/packages/form-js-editor/src/features/properties-panel/groups/ValidationGroup.js b/packages/form-js-editor/src/features/properties-panel/groups/ValidationGroup.js index 45f8e50ba..704abedfc 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/ValidationGroup.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/ValidationGroup.js @@ -37,10 +37,6 @@ export default function ValidationGroup(field, editField) { const validate = get(field, [ 'validate' ], {}); const isCustomValidation = [ undefined, VALIDATION_TYPE_OPTIONS.custom.value ].includes(validate.validationType); - if (!INPUTS.includes(type)) { - return null; - } - const onChange = (key) => { return (value) => { const validate = get(field, [ 'validate' ], {}); @@ -62,78 +58,83 @@ export default function ValidationGroup(field, editField) { getValue, field, isEdited: isCheckboxEntryEdited, - onChange + onChange, + isDefaultVisible: (field) => INPUTS.includes(field.type) } ]; - if (type === 'textfield') { - entries.push( - { - id: 'validationType', - component: ValidationType, - getValue, - field, - editField, - isEdited: isTextFieldEntryEdited, - onChange - } - ); - } - - if (type === 'textarea' || (type === 'textfield' && isCustomValidation)) { - entries.push( - { - id: 'minLength', - component: MinLength, - getValue, - field, - isEdited: isFeelEntryEdited, - onChange - }, - { - id: 'maxLength', - component: MaxLength, - getValue, - field, - isEdited: isFeelEntryEdited, - onChange - } - ); - } - - if (type === 'textfield' && isCustomValidation) { - entries.push( - { - id: 'pattern', - component: Pattern, - getValue, - field, - isEdited: isTextFieldEntryEdited, - onChange - } - ); - } - - if (type === 'number') { - entries.push( - { - id: 'min', - component: Min, - getValue, - field, - isEdited: isFeelEntryEdited, - onChange - }, - { - id: 'max', - component: Max, - getValue, - field, - isEdited: isFeelEntryEdited, - onChange - } - ); - } + entries.push( + { + id: 'validationType', + component: ValidationType, + getValue, + field, + editField, + isEdited: isTextFieldEntryEdited, + onChange, + isDefaultVisible: (field) => field.type === 'textfield' + } + ); + + entries.push( + { + id: 'minLength', + component: MinLength, + getValue, + field, + isEdited: isFeelEntryEdited, + onChange, + isDefaultVisible: (field) => INPUTS.includes(field.type) && ( + type === 'textarea' || (type === 'textfield' && isCustomValidation) + ) + }, + { + id: 'maxLength', + component: MaxLength, + getValue, + field, + isEdited: isFeelEntryEdited, + onChange, + isDefaultVisible: (field) => INPUTS.includes(field.type) && ( + type === 'textarea' || (type === 'textfield' && isCustomValidation) + ) + } + ); + + entries.push( + { + id: 'pattern', + component: Pattern, + getValue, + field, + isEdited: isTextFieldEntryEdited, + onChange, + isDefaultVisible: (field) => INPUTS.includes(field.type) && ( + type === 'textfield' && isCustomValidation + ) + } + ); + + entries.push( + { + id: 'min', + component: Min, + getValue, + field, + isEdited: isFeelEntryEdited, + onChange, + isDefaultVisible: (field) => field.type === 'number' + }, + { + id: 'max', + component: Max, + getValue, + field, + isEdited: isFeelEntryEdited, + onChange, + isDefaultVisible: (field) => field.type === 'number' + } + ); return { id: 'validation', 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 index 69842b07a..5964b6428 100644 --- a/packages/form-js-editor/src/features/properties-panel/groups/ValuesGroups.js +++ b/packages/form-js-editor/src/features/properties-panel/groups/ValuesGroups.js @@ -10,16 +10,21 @@ import { getValuesSource, VALUES_SOURCES } from '@bpmn-io/form-js-viewer'; import { Group, ListGroup } from '@bpmn-io/properties-panel'; import { - VALUES_INPUTS + VALUES_INPUTS, + hasValuesGroupsConfigured } from '../Util'; -export default function ValuesGroups(field, editField) { +export default function ValuesGroups(field, editField, getService) { const { type, id: fieldId } = field; - if (!VALUES_INPUTS.includes(type)) { + const formFields = getService('formFields'); + + const fieldDefinition = formFields.get(type).config; + + if (!VALUES_INPUTS.includes(type) && !hasValuesGroupsConfigured(fieldDefinition)) { return []; } 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 99f4ac0e8..a9f3a08a5 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 @@ -20,10 +20,10 @@ describe('AppearanceGroup', function() { // given const field = { type: 'checkbox' }; - const group = AppearanceGroup(field); + renderAppearanceGroup({ field }); // then - expect(group).to.not.exist; + expect(findGroup('appearance', document.body)).to.not.exist; }); @@ -236,4 +236,8 @@ function findFeelers(id, container) { function findTextbox(id, container) { return container.querySelector(`[name=${id}] [role="textbox"]`); +} + +function findGroup(id, container) { + return container.querySelector(`.bio-properties-panel-group [data-group-id="group-${id}"]`); } \ No newline at end of file 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 20e8ae560..2f716f4c2 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 @@ -21,8 +21,8 @@ describe('SerializationGroup', function() { // then for (const type of types) { - const group = SerializationGroup({ type }, () => {}); - expect(group).to.be.null; + renderSerializationGroup({ field : { type } }); + expect(findGroup('serialization', document.body)).to.be.null; } }); @@ -32,8 +32,8 @@ describe('SerializationGroup', function() { const field = { type: 'datetime', subtype: 'date' }; // then - const group = SerializationGroup(field, () => { }); - expect(group).to.be.null; + renderSerializationGroup({ field }); + expect(findGroup('serialization', document.body)).to.be.null; }); @@ -199,3 +199,7 @@ function findInput(id, container) { function findSelect(id, container) { return container.querySelector(`select[name="${id}"]`); } + +function findGroup(id, container) { + return container.querySelector(`.bio-properties-panel-group [data-group-id="group-${id}"]`); +} From 933d7abbbe9d86729d0f441129ddee9701a9d7b5 Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Thu, 14 Sep 2023 14:13:09 +0200 Subject: [PATCH 05/11] feat(properties-panel-header): use form field definition --- .../PropertiesPanelHeaderProvider.js | 43 ++++----- .../PropertiesPanelHeaderProvider.spec.js | 94 ++++++++++++++++++- 2 files changed, 114 insertions(+), 23 deletions(-) diff --git a/packages/form-js-editor/src/features/properties-panel/PropertiesPanelHeaderProvider.js b/packages/form-js-editor/src/features/properties-panel/PropertiesPanelHeaderProvider.js index 232a9df10..a78e876d8 100644 --- a/packages/form-js-editor/src/features/properties-panel/PropertiesPanelHeaderProvider.js +++ b/packages/form-js-editor/src/features/properties-panel/PropertiesPanelHeaderProvider.js @@ -4,25 +4,10 @@ import { import { iconsByType } from '../../render/components/icons'; -const labelsByType = { - button: 'BUTTON', - checkbox: 'CHECKBOX', - checklist: 'CHECKLIST', - columns: 'COLUMNS', - default: 'FORM', - datetime: 'DATETIME', - group: 'GROUP', - image: 'IMAGE VIEW', - number: 'NUMBER', - radio: 'RADIO', - select: 'SELECT', - separator: 'SEPARATOR', - spacer: 'SPACER', - taglist: 'TAGLIST', - text: 'TEXT VIEW', - textfield: 'TEXT FIELD', - textarea: 'TEXT AREA', -}; +import { getPaletteIcon } from '../palette/components/Palette'; + +import { useService } from './hooks'; + export const PropertiesPanelHeaderProvider = { @@ -55,10 +40,17 @@ export const PropertiesPanelHeaderProvider = { type } = field; - const Icon = iconsByType(type); + // @Note: We know that we are inside the properties panel context, + // so we can savely use the hook here. + // eslint-disable-next-line + const fieldDefinition = useService('formFields').get(type).config; + + const Icon = fieldDefinition.icon || iconsByType(type); if (Icon) { return () => ; + } else if (fieldDefinition.iconUrl) { + return getPaletteIcon({ iconUrl: fieldDefinition.iconUrl, label: fieldDefinition.label }); } }, @@ -67,6 +59,15 @@ export const PropertiesPanelHeaderProvider = { type } = field; - return labelsByType[type]; + if (type === 'default') { + return 'Form'; + } + + // @Note: We know that we are inside the properties panel context, + // so we can savely use the hook here. + // eslint-disable-next-line + const fieldDefinition = useService('formFields').get(type).config; + + return fieldDefinition.label || type; } }; \ No newline at end of file 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 fb2ff3fb6..9a5b9ec12 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 @@ -3,6 +3,8 @@ import { render } from '@testing-library/preact/pure'; +import { FormFields } from '@bpmn-io/form-js-viewer'; + import { PropertiesPanelHeaderProvider } from '../../../../src/features/properties-panel/PropertiesPanelHeaderProvider'; import { WithPropertiesPanelContext, WithPropertiesPanel } from './helper'; @@ -58,6 +60,91 @@ describe('PropertiesPanelHeaderProvider', function() { expect(label.innerText).to.eql(field.label); }); + + describe('extension support', function() { + + it('should render type label from config', function() { + + // given + const extension = { + config: { + label: 'Custom label', + group: 'basic-input' + } + }; + + const formFields = new FormFields(); + + formFields.register('custom', extension); + + const field = { type: 'custom' }; + + // when + const { container } = renderHeader({ field, formFields }); + + // then + const label = container.querySelector('.bio-properties-panel-header-type'); + + expect(label).to.exist; + expect(label.innerText).to.eql(extension.config.label.toUpperCase()); + }); + + + it('should render icon from config', function() { + + // given + const extension = { + config: { + label: 'Custom label', + group: 'basic-input', + icon: () =>
Custom Icon
+ } + }; + + const formFields = new FormFields(); + + formFields.register('custom', extension); + + const field = { type: 'custom' }; + + // when + const { container } = renderHeader({ field, formFields }); + + // then + const customIcon = container.querySelector('.custom-icon'); + + expect(customIcon).to.exist; + }); + + + it('should render iconUrl from config', function() { + + // given + const extension = { + config: { + label: 'Custom label', + group: 'basic-input', + iconUrl: 'https://example.com/icon.png' + } + }; + + const formFields = new FormFields(); + + formFields.register('custom', extension); + + const field = { type: 'custom' }; + + // when + const { container } = renderHeader({ field, formFields }); + + // then + const customIcon = container.querySelector('.fjs-field-icon-image'); + + expect(customIcon).to.exist; + }); + + }); + }); @@ -65,11 +152,14 @@ describe('PropertiesPanelHeaderProvider', function() { function renderHeader(options) { const { - field + field, + formFields } = options; return render(WithPropertiesPanelContext(WithPropertiesPanel({ field, headerProvider: PropertiesPanelHeaderProvider - }))); + }), { + formFields + })); } From 109d2dd37a3e064eb02575948fa0a88c044feb5e Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Thu, 14 Sep 2023 14:14:27 +0200 Subject: [PATCH 06/11] feat(playground): provide general `additionalModules` option This goes into both editor and viewer --- packages/form-js-playground/src/Playground.js | 1 + .../src/components/PlaygroundRoot.js | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/form-js-playground/src/Playground.js b/packages/form-js-playground/src/Playground.js index 7e8eefd55..97190b88c 100644 --- a/packages/form-js-playground/src/Playground.js +++ b/packages/form-js-playground/src/Playground.js @@ -12,6 +12,7 @@ import { PlaygroundRoot } from './components/PlaygroundRoot'; * * @typedef { { * actions?: { display: Boolean } + * additionalModules?: Array * container?: Element * data: any * editor?: { inlinePropertiesPanel: Boolean } diff --git a/packages/form-js-playground/src/components/PlaygroundRoot.js b/packages/form-js-playground/src/components/PlaygroundRoot.js index aaf0e23d7..3e3e19537 100644 --- a/packages/form-js-playground/src/components/PlaygroundRoot.js +++ b/packages/form-js-playground/src/components/PlaygroundRoot.js @@ -25,6 +25,7 @@ import './PlaygroundRoot.css'; export function PlaygroundRoot(props) { const { + additionalModules = [], // goes into both editor + viewer actions: actionsConfig = {}, emit, exporter: exporterConfig = {}, @@ -101,7 +102,10 @@ export function PlaygroundRoot(props) { }); const form = formRef.current = new Form({ - additionalModules: viewerAdditionalModules, + additionalModules: [ + ...additionalModules, + ...viewerAdditionalModules + ], properties: { ...viewerProperties, 'ariaLabel': 'Form Preview' @@ -124,7 +128,10 @@ export function PlaygroundRoot(props) { ...editorProperties, 'ariaLabel': 'Form Definition' }, - additionalModules: editorAdditionalModules + additionalModules: [ + ...additionalModules, + ...editorAdditionalModules + ] }); paletteRef.current = formEditor.get('palette'); From a717f4a118902c416e21cc80c776dc048ce66524 Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Thu, 14 Sep 2023 14:15:07 +0200 Subject: [PATCH 07/11] test: provide custom form field tests Closes #123 --- packages/form-js-playground/karma.conf.js | 34 ++++ .../form-js-playground/test/custom/editor.js | 152 ++++++++++++++++++ .../form-js-playground/test/custom/range.svg | 19 +++ .../form-js-playground/test/custom/styles.css | 4 + .../form-js-playground/test/custom/viewer.js | 122 ++++++++++++++ .../test/spec/Playground.spec.js | 37 ++++- .../form-js-playground/test/spec/custom.json | 28 ++++ .../src/render/components/index.js | 13 +- .../form-js-viewer/test/spec/Form.spec.js | 26 +-- .../form-js-viewer/test/spec/custom/index.js | 76 +++++++-- .../form-js-viewer/test/spec/customField.json | 26 +++ 11 files changed, 512 insertions(+), 25 deletions(-) create mode 100644 packages/form-js-playground/test/custom/editor.js create mode 100644 packages/form-js-playground/test/custom/range.svg create mode 100644 packages/form-js-playground/test/custom/styles.css create mode 100644 packages/form-js-playground/test/custom/viewer.js create mode 100644 packages/form-js-playground/test/spec/custom.json create mode 100644 packages/form-js-viewer/test/spec/customField.json diff --git a/packages/form-js-playground/karma.conf.js b/packages/form-js-playground/karma.conf.js index 42adc3351..1440a922e 100644 --- a/packages/form-js-playground/karma.conf.js +++ b/packages/form-js-playground/karma.conf.js @@ -1,3 +1,9 @@ +const path = require('path'); + +const { + NormalModuleReplacementPlugin +} = require('webpack'); + const coverage = process.env.COVERAGE; // configures browsers to run test against @@ -63,6 +69,10 @@ module.exports = function(karma) { 'css-loader' ] }, + { + test: /\.svg$/, + use: [ '@svgr/webpack' ] + }, { test: /\.m?js$/, exclude: /node_modules/, @@ -90,6 +100,30 @@ module.exports = function(karma) { } ] }, + plugins: [ + new NormalModuleReplacementPlugin( + /^(..\/preact|preact)(\/[^/]+)?$/, + function(resource) { + + const replMap = { + 'preact/hooks': path.resolve('../../node_modules/preact/hooks/dist/hooks.module.js'), + 'preact/jsx-runtime': path.resolve('../../node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js'), + 'preact': path.resolve('../../node_modules/preact/dist/preact.module.js'), + '../preact/hooks': path.resolve('../../node_modules/preact/hooks/dist/hooks.module.js'), + '../preact/jsx-runtime': path.resolve('../../node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js'), + '../preact': path.resolve('../../node_modules/preact/dist/preact.module.js') + }; + + const replacement = replMap[resource.request]; + + if (!replacement) { + return; + } + + resource.request = replacement; + } + ), + ], devtool: 'eval-source-map' } }; diff --git a/packages/form-js-playground/test/custom/editor.js b/packages/form-js-playground/test/custom/editor.js new file mode 100644 index 000000000..c913a77b4 --- /dev/null +++ b/packages/form-js-playground/test/custom/editor.js @@ -0,0 +1,152 @@ +import { get, set } from 'min-dash'; + +import { + NumberFieldEntry, + isNumberFieldEntryEdited +} from '@bpmn-io/properties-panel'; + + +class CustomPropertiesProvider { + constructor(propertiesPanel) { + propertiesPanel.registerProvider(this, 500); + } + + getGroups(field, editField) { + return (groups) => { + + if (field.type !== 'range') { + return groups; + } + + const generalIdx = findGroupIdx(groups, 'general'); + + groups.splice(generalIdx + 1, 0, { + id: 'range', + label: 'Range', + entries: RangeEntries(field, editField) + }); + + return groups; + }; + } +} + +CustomPropertiesProvider.$inject = [ 'propertiesPanel' ]; + +function RangeEntries(field, editField) { + + const onChange = (key) => { + return (value) => { + const range = get(field, [ 'range' ], {}); + + editField(field, [ 'range' ], set(range, [ key ], value)); + }; + }; + + const getValue = (key) => { + return () => { + return get(field, [ 'range', key ]); + }; + }; + + return [ + { + id: 'range-min', + component: Min, + getValue, + field, + isEdited: isNumberFieldEntryEdited, + onChange + }, + { + id: 'range-max', + component: Max, + getValue, + field, + isEdited: isNumberFieldEntryEdited, + onChange + }, + { + id: 'range-step', + component: Step, + getValue, + field, + isEdited: isNumberFieldEntryEdited, + onChange + } + ]; + +} + +function Min(props) { + const { + field, + getValue, + id, + onChange + } = props; + + const debounce = (fn) => fn; + + return NumberFieldEntry({ + debounce, + element: field, + getValue: getValue('min'), + id, + label: 'Minimum', + setValue: onChange('min') + }); +} + +function Max(props) { + const { + field, + getValue, + id, + onChange + } = props; + + const debounce = (fn) => fn; + + return NumberFieldEntry({ + debounce, + element: field, + getValue: getValue('max'), + id, + label: 'Maximum', + setValue: onChange('max') + }); +} + +function Step(props) { + const { + field, + getValue, + id, + onChange + } = props; + + const debounce = (fn) => fn; + + return NumberFieldEntry({ + debounce, + element: field, + getValue: getValue('step'), + id, + min: 0, + label: 'Step', + setValue: onChange('step') + }); +} + + +export default { + __init__: [ 'customPropertiesProvider' ], + customPropertiesProvider: [ 'type', CustomPropertiesProvider ] +}; + +// helper ////////////////////// + +function findGroupIdx(groups, id) { + return groups.findIndex(g => g.id === id); +} \ No newline at end of file diff --git a/packages/form-js-playground/test/custom/range.svg b/packages/form-js-playground/test/custom/range.svg new file mode 100644 index 000000000..a3dbe11f6 --- /dev/null +++ b/packages/form-js-playground/test/custom/range.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/form-js-playground/test/custom/styles.css b/packages/form-js-playground/test/custom/styles.css new file mode 100644 index 000000000..244247ef4 --- /dev/null +++ b/packages/form-js-playground/test/custom/styles.css @@ -0,0 +1,4 @@ +.range-group { + display: flex; + flex-direction: row; +} \ No newline at end of file diff --git a/packages/form-js-playground/test/custom/viewer.js b/packages/form-js-playground/test/custom/viewer.js new file mode 100644 index 000000000..8b31f5199 --- /dev/null +++ b/packages/form-js-playground/test/custom/viewer.js @@ -0,0 +1,122 @@ + +import { + Errors, + FormContext, + Numberfield, + Description, + Label +} from '@bpmn-io/form-js-viewer'; + +import { useContext } from 'preact/hooks'; + +import classNames from 'classnames'; + +import RangeIcon from './range.svg'; + +const rangeType = 'range'; + +function RangeRenderer(props) { + + const { + disabled, + errors = [], + field, + readonly, + value + } = props; + + const { + description, + range = {}, + id, + label + } = field; + + const { + min, + max, + step + } = range; + + const { formId } = useContext(FormContext); + + const errorMessageId = errors.length === 0 ? undefined : `${prefixId(id, formId)}-error-message`; + + const onChange = ({ target }) => { + props.onChange({ + field, + value: Number(target.value) + }); + }; + + return
+
; +} + +RangeRenderer.config = { + ...Numberfield.config, + type: rangeType, + keyed: true, + label: 'Range', + group: 'basic-input', + propertiesPanelEntries: [ + 'key', + 'label', + 'description', + 'min', + 'max' + ], + icon: RangeIcon +}; + +class CustomFormFields { + constructor(formFields) { + formFields.register(rangeType, RangeRenderer); + } +} + +export default { + __init__: [ 'customFormFields' ], + customFormFields: [ 'type', CustomFormFields ] +}; + + +// helper ////////////////////// + +function formFieldClasses(type, { errors = [], disabled = false, readonly = false } = {}) { + if (!type) { + throw new Error('type required'); + } + + return classNames('fjs-form-field', `fjs-form-field-${type}`, { + 'fjs-has-errors': errors.length > 0, + 'fjs-disabled': disabled, + 'fjs-readonly': readonly + }); +} + +function prefixId(id, formId) { + if (formId) { + return `fjs-form-${ formId }-${ id }`; + } + + return `fjs-form-${ id }`; +} \ No newline at end of file diff --git a/packages/form-js-playground/test/spec/Playground.spec.js b/packages/form-js-playground/test/spec/Playground.spec.js index a6f2b80a1..c291bed8a 100644 --- a/packages/form-js-playground/test/spec/Playground.spec.js +++ b/packages/form-js-playground/test/spec/Playground.spec.js @@ -17,6 +17,11 @@ import { import schema from './form.json'; import otherSchema from './other-form.json'; import rowsSchema from './rows-form.json'; +import customSchema from './custom.json'; + +import customViewerModule from '../custom/viewer'; +import customEditorModule from '../custom/editor'; +import customStyles from '../custom/styles.css'; import { insertCSS, @@ -32,9 +37,12 @@ insertCSS('Test.css', ` } `); +insertCSS('custom.css', customStyles); + const singleStartBasic = isSingleStart('basic'); const singleStartRows = isSingleStart('rows'); -const singleStart = singleStartBasic || singleStartRows; +const singleStartCustom = isSingleStart('custom'); +const singleStart = singleStartBasic || singleStartRows || singleStartCustom; describe('playground', function() { @@ -128,6 +136,33 @@ describe('playground', function() { }); + (singleStartCustom ? it.only : it)('should support custom element', async function() { + + // given + const data = { + creditor: 'John Doe Company', + amount: 25 + }; + + // when + // viewer and editor + playground = new Playground({ + container, + schema: customSchema, + data, + additionalModules: [ + customViewerModule + ], + editorAdditionalModules: [ + customEditorModule + ] + }); + + // then + expect(playground).to.exist; + }); + + it('should NOT attach to empty parent', async function() { // given diff --git a/packages/form-js-playground/test/spec/custom.json b/packages/form-js-playground/test/spec/custom.json new file mode 100644 index 000000000..3b98dbe42 --- /dev/null +++ b/packages/form-js-playground/test/spec/custom.json @@ -0,0 +1,28 @@ +{ + "components": [ + { + "key": "creditor", + "label": "Creditor", + "type": "textfield", + "validate": { + "required": true + } + }, + { + "key": "amount", + "type": "range", + "label": "Amount", + "range": { + "min": 0, + "max": 100, + "step": 5 + } + }, + { + "type": "button", + "action": "submit", + "label": "Submit" + } + ], + "type": "default" +} \ No newline at end of file diff --git a/packages/form-js-viewer/src/render/components/index.js b/packages/form-js-viewer/src/render/components/index.js index d37d65637..bf4937ecd 100644 --- a/packages/form-js-viewer/src/render/components/index.js +++ b/packages/form-js-viewer/src/render/components/index.js @@ -17,6 +17,16 @@ import Text from './form-fields/Text'; import Textfield from './form-fields/Textfield'; import Textarea from './form-fields/Textarea'; +import Label from './Label'; +import Description from './Description'; +import Errors from './Errors'; + +export { + Label, + Description, + Errors +}; + export { Button, Checkbox, @@ -57,4 +67,5 @@ export const formFields = [ Textarea ]; -export * from './icons'; \ No newline at end of file +export * from './icons'; +export * from './Sanitizer'; \ No newline at end of file diff --git a/packages/form-js-viewer/test/spec/Form.spec.js b/packages/form-js-viewer/test/spec/Form.spec.js index 55cdde6c7..548311369 100644 --- a/packages/form-js-viewer/test/spec/Form.spec.js +++ b/packages/form-js-viewer/test/spec/Form.spec.js @@ -26,6 +26,7 @@ import textTemplateSchema from './text-template.json'; import stress from './stress.json'; import rowsSchema from './rows.json'; import focusables from './focusables.json'; +import customFieldSchema from './customField.json'; import { insertCSS, @@ -44,14 +45,15 @@ const singleStartStress = isSingleStart('stress'); const singleStartRows = isSingleStart('rows'); const singleStartTheme = isSingleStart('theme'); const singleStartNoTheme = isSingleStart('no-theme'); - +const singleStartCustom = isSingleStart('custom'); const singleStart = singleStartBasic || singleStartGroups || singleStartStress || singleStartRows || singleStartTheme || - singleStartNoTheme; + singleStartNoTheme || + singleStartCustom; describe('Form', function() { @@ -958,33 +960,31 @@ describe('Form', function() { }); - it('should be customizable', async function() { + (singleStartCustom ? it.only : it)('should be customizable', async function() { // given const data = { creditor: 'John Doe Company', - amount: 456, - invoiceNumber: 'C-123', - approved: true, - approvedBy: 'John Doe', - mailto: [ 'regional-manager', 'approver' ], - product: 'camunda-cloud', - tags: [ 'tag1', 'tag2', 'tag3' ], - language: 'english' + amount: 25 }; // when - await createForm({ + const form = await createForm({ container, data, - schema, + schema: customFieldSchema, additionalModules: [ customButtonModule ] }); + form.on('changed', event => { + console.log('Form ', event); + }); + // then expect(document.querySelector('.custom-button')).to.exist; + expect(document.querySelector('.fjs-form-field-range')).to.exist; }); diff --git a/packages/form-js-viewer/test/spec/custom/index.js b/packages/form-js-viewer/test/spec/custom/index.js index 30a26a17a..15fcd7265 100644 --- a/packages/form-js-viewer/test/spec/custom/index.js +++ b/packages/form-js-viewer/test/spec/custom/index.js @@ -1,6 +1,16 @@ -import { formFieldClasses } from '../../../src/render/components/Util'; +import { + formFieldClasses, + prefixId +} from '../../../src/render/components/Util'; -const type = 'button'; +import { Numberfield, Button } from '../../../src'; + +import { FormContext } from '../../../src/render/context'; + +import { useContext } from 'preact/hooks'; + +const btnType = Button.config.type; +const rangeType = 'range'; function CustomButton(props) { const { @@ -10,29 +20,75 @@ function CustomButton(props) { const { action = 'submit' } = field; - return
+ return
; } CustomButton.config = { - type, - keyed: true, + ...Button.config, label: 'Custom Button', - group: 'action', create: (options = {}) => ({ action: 'submit', ...options }) }; -class CustomButtonRegistrer { +function Range(props) { + + const { + field, + value + } = props; + + const { + min, + max, + step, + id, + label + } = field; + + const { formId } = useContext(FormContext); + + const onChange = ({ target }) => { + props.onChange({ + field, + value: Number(target.value) + }); + }; + + return
+ + +
; +} + +Range.config = { + ...Numberfield.config, + type: rangeType, + keyed: true, + label: 'Range', + group: 'basic-input' +}; + +class CustomFormFields { constructor(formFields) { - formFields.register(type, CustomButton); + formFields.register(btnType, CustomButton); + formFields.register(rangeType, Range); } } export default { - __init__: [ 'customButtonRegisterer' ], - customButtonRegisterer: [ 'type', CustomButtonRegistrer ] + __init__: [ 'customFormFields' ], + customFormFields: [ 'type', CustomFormFields ] }; \ No newline at end of file diff --git a/packages/form-js-viewer/test/spec/customField.json b/packages/form-js-viewer/test/spec/customField.json new file mode 100644 index 000000000..635ccf621 --- /dev/null +++ b/packages/form-js-viewer/test/spec/customField.json @@ -0,0 +1,26 @@ +{ + "components": [ + { + "key": "creditor", + "label": "Creditor", + "type": "textfield", + "validate": { + "required": true + } + }, + { + "key": "amount", + "type": "range", + "label": "Amount", + "min": 0, + "max": 100, + "step": 5 + }, + { + "type": "button", + "action": "submit", + "label": "Submit" + } + ], + "type": "default" +} \ No newline at end of file From 8bf325963c756f2af219fa12a5dc7a5a21d03c1c Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Mon, 18 Sep 2023 13:06:58 +0200 Subject: [PATCH 08/11] style: align palette icon image --- packages/form-js-editor/assets/form-js-editor-base.css | 3 ++- .../form-js-editor/src/features/palette/components/Palette.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 832712f0f..5ec48cf22 100644 --- a/packages/form-js-editor/assets/form-js-editor-base.css +++ b/packages/form-js-editor/assets/form-js-editor-base.css @@ -652,7 +652,8 @@ outline: solid 1px var(--color-palette-field-focus); } -.fjs-palette-field .fjs-palette-field-icon { +.fjs-palette-field .fjs-palette-field-icon, +.fjs-palette-field .fjs-field-icon-image { margin: 0 auto; } diff --git a/packages/form-js-editor/src/features/palette/components/Palette.js b/packages/form-js-editor/src/features/palette/components/Palette.js index 0c5c18c64..69c5e68d2 100644 --- a/packages/form-js-editor/src/features/palette/components/Palette.js +++ b/packages/form-js-editor/src/features/palette/components/Palette.js @@ -214,7 +214,7 @@ export function getPaletteIcon(entry) { let Icon; if (iconUrl) { - Icon = () => {; + Icon = () => {; } else { Icon = icon || iconsByType(type); } From 41fc36ca75c85602552d24b28acba0425d265663 Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Thu, 5 Oct 2023 10:59:39 +0200 Subject: [PATCH 09/11] chore(CHANGELOG): add notes for extensions API --- packages/form-js/CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/form-js/CHANGELOG.md b/packages/form-js/CHANGELOG.md index f2eeb023b..e619bd380 100644 --- a/packages/form-js/CHANGELOG.md +++ b/packages/form-js/CHANGELOG.md @@ -6,6 +6,27 @@ All notable changes to [form-js](https://github.com/bpmn-io/form-js) are documen ___Note:__ Yet to be released changes appear here._ +### General + +* `FEAT`: support custom form fields ([#123](https://github.com/bpmn-io/form-js/issues/123)) + +### Viewer + +* `FEAT`: provide more customization options, as of ([#776](https://github.com/bpmn-io/form-js/pull/776)) + * extending form field config via `icon`, `iconUrl`, `propertiesPanelEntries` + * re-export core components as `Label`, `Description`, `Errors` + +### Editor + +* `FEAT`: add properties panel providers mechanism ([#776](https://github.com/bpmn-io/form-js/pull/776)) +* `FEAT`: collect palette entries and properties panel header information via form field configs ([#776](https://github.com/bpmn-io/form-js/pull/776)) +* `FEAT`: add `isDefaultVisible` control to all properties panel entries ([#776](https://github.com/bpmn-io/form-js/pull/776)) +* `FEAT`: re-export hooks + +### Playground + +`FEAT`: provide `additionalModules` to both viewer and editor ([#776](https://github.com/bpmn-io/form-js/pull/776)) + ## 1.3.2 ### Viewer From 86376df3c27094798eccbb9a766b4603d661e737 Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Thu, 12 Oct 2023 13:55:48 +0200 Subject: [PATCH 10/11] chore(palette): use initial palette entries reference --- .../src/features/palette/components/Palette.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/form-js-editor/src/features/palette/components/Palette.js b/packages/form-js-editor/src/features/palette/components/Palette.js index 69c5e68d2..2497307c8 100644 --- a/packages/form-js-editor/src/features/palette/components/Palette.js +++ b/packages/form-js-editor/src/features/palette/components/Palette.js @@ -56,9 +56,9 @@ export default function Palette(props) { const formFields = useService('formFields'); - const initialPaletteEntries = collectPaletteEntries(formFields); + const initialPaletteEntries = useRef(collectPaletteEntries(formFields)); - const [ paletteEntries, setPaletteEntries ] = useState(initialPaletteEntries); + const [ paletteEntries, setPaletteEntries ] = useState(initialPaletteEntries.current); const [ searchTerm, setSearchTerm ] = useState(''); @@ -90,7 +90,7 @@ export default function Palette(props) { // filter entries on search change useEffect(() => { - const entries = initialPaletteEntries.filter(filter); + const entries = initialPaletteEntries.current.filter(filter); setPaletteEntries(entries); }, [ filter, searchTerm ]); @@ -214,7 +214,7 @@ export function getPaletteIcon(entry) { let Icon; if (iconUrl) { - Icon = () => {; + Icon = () => {; } else { Icon = icon || iconsByType(type); } From ec51371b5fbf009f459ff272122f2393dc583db0 Mon Sep 17 00:00:00 2001 From: Niklas Kiefer Date: Thu, 12 Oct 2023 13:56:04 +0200 Subject: [PATCH 11/11] chore(properties-panel): disable specific eslint rule --- .../properties-panel/PropertiesPanelHeaderProvider.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/form-js-editor/src/features/properties-panel/PropertiesPanelHeaderProvider.js b/packages/form-js-editor/src/features/properties-panel/PropertiesPanelHeaderProvider.js index a78e876d8..bd3638e4b 100644 --- a/packages/form-js-editor/src/features/properties-panel/PropertiesPanelHeaderProvider.js +++ b/packages/form-js-editor/src/features/properties-panel/PropertiesPanelHeaderProvider.js @@ -42,7 +42,7 @@ export const PropertiesPanelHeaderProvider = { // @Note: We know that we are inside the properties panel context, // so we can savely use the hook here. - // eslint-disable-next-line + // eslint-disable-next-line react-hooks/rules-of-hooks const fieldDefinition = useService('formFields').get(type).config; const Icon = fieldDefinition.icon || iconsByType(type); @@ -65,7 +65,7 @@ export const PropertiesPanelHeaderProvider = { // @Note: We know that we are inside the properties panel context, // so we can savely use the hook here. - // eslint-disable-next-line + // eslint-disable-next-line react-hooks/rules-of-hooks const fieldDefinition = useService('formFields').get(type).config; return fieldDefinition.label || type;