Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom form fields support #776

Merged
merged 11 commits into from
Oct 20, 2023
3 changes: 2 additions & 1 deletion packages/form-js-editor/assets/form-js-editor-base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
80 changes: 67 additions & 13 deletions packages/form-js-editor/src/features/palette/components/Palette.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand All @@ -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 = useRef(collectPaletteEntries(formFields));

const [ paletteEntries, setPaletteEntries ] = useState(initialPaletteEntries.current);

const [ searchTerm, setSearchTerm ] = useState('');

const inputRef = useRef();

const groups = groupEntries(entries);
const groups = groupEntries(paletteEntries);

const simplifyString = useCallback((str) => {
return str
Expand All @@ -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.current.filter(filter);
setPaletteEntries(entries);
}, [ filter, searchTerm ]);

const handleInput = useCallback(event => {
Expand Down Expand Up @@ -124,6 +135,7 @@ export default function Palette(props) {
entries.map(entry => {
return (
<PaletteEntry
getPaletteIcon={ getPaletteIcon }
{ ...entry }
/>
);
Expand Down Expand Up @@ -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<PaletteEntry>}
*/
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
vsgoulart marked this conversation as resolved.
Show resolved Hide resolved
* 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 = () => <img class="fjs-field-icon-image" width={ 36 } style={ { margin: 'auto' } } alt={ label } src={ sanitizeImageSource(iconUrl) } />;
} else {
Icon = icon || iconsByType(type);
}

return Icon;
}
Original file line number Diff line number Diff line change
@@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 (
<div
class="fjs-properties-panel"
Expand All @@ -116,7 +103,7 @@ export default function FormPropertiesPanel(props) {
<PropertiesPanel
element={ selectedFormField }
eventBus={ eventBus }
groups={ getGroups(selectedFormField, editField, getService) }
groups={ groups }
headerProvider={ PropertiesPanelHeaderProvider }
placeholderProvider={ PropertiesPanelPlaceholderProvider }
feelPopupContainer={ feelPopupContainer }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {

Expand Down Expand Up @@ -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 react-hooks/rules-of-hooks
const fieldDefinition = useService('formFields').get(type).config;

const Icon = fieldDefinition.icon || iconsByType(type);

if (Icon) {
return () => <Icon width="36" height="36" viewBox="0 0 54 54" />;
} else if (fieldDefinition.iconUrl) {
return getPaletteIcon({ iconUrl: fieldDefinition.iconUrl, label: fieldDefinition.label });
}
},

Expand All @@ -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 react-hooks/rules-of-hooks
const fieldDefinition = useService('formFields').get(type).config;

return fieldDefinition.label || type;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import {
query as domQuery
} from 'min-dom';

const DEFAULT_PRIORITY = 1000;

/**
* @typedef { { parent: Element } } PropertiesPanelConfig
* @typedef { import('../../core/EventBus').default } EventBus
* @typedef { import('../../types').Injector } Injector
* @typedef { { getGroups: ({ formField, editFormField }) => ({ groups}) => Array } } PropertiesProvider
*/

/**
Expand Down Expand Up @@ -82,6 +85,7 @@ export default class PropertiesPanelRenderer {
_render() {
render(
<PropertiesPanel
getProviders={ this._getProviders.bind(this) }
eventBus={ this._eventBus }
injector={ this._injector }
/>,
Expand All @@ -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' ];
Loading
Loading