├── starter ├── README.md ├── viewer.html └── editor.html ├── custom-components ├── app │ ├── empty.json │ ├── style.css │ ├── extension │ │ ├── render │ │ │ ├── styles.css │ │ │ ├── index.js │ │ │ ├── range.svg │ │ │ └── Range.js │ │ └── propertiesPanel │ │ │ ├── index.js │ │ │ └── CustomPropertiesProvider.js │ ├── index.html │ └── index.js ├── .eslintrc ├── docs │ └── screenshot.png ├── .gitignore ├── package.json ├── webpack.config.js └── README.md ├── custom-button ├── app │ ├── extension │ │ ├── render │ │ │ ├── styles.css │ │ │ ├── index.js │ │ │ ├── feedback.svg │ │ │ └── FeedbackButton.js │ │ └── propertiesPanel │ │ │ ├── index.js │ │ │ └── CustomPropertiesProvider.js │ ├── style.css │ ├── index.html │ ├── form.json │ └── index.js ├── .eslintrc ├── docs │ └── screenshot.png ├── .gitignore ├── package.json ├── README.md └── webpack.config.js ├── README.md ├── .github └── workflows │ └── ADD_TO_HTO_PROJECT.yml └── custom-properties ├── README.md ├── customProperties.js └── viewer.html /starter/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom-components/app/empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "default", 3 | "components": [] 4 | } -------------------------------------------------------------------------------- /custom-button/app/extension/render/styles.css: -------------------------------------------------------------------------------- 1 | .feedback-button-container { 2 | width: 100%; 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # form-js-examples 2 | 3 | Examples that illustrate uses of [form-js](https://github.com/bpmn-io/form-js). 4 | -------------------------------------------------------------------------------- /custom-button/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:bpmn-io/node", 4 | "plugin:bpmn-io/browser" 5 | ] 6 | } -------------------------------------------------------------------------------- /custom-button/docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpmn-io/form-js-examples/HEAD/custom-button/docs/screenshot.png -------------------------------------------------------------------------------- /custom-components/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:bpmn-io/node", 4 | "plugin:bpmn-io/browser" 5 | ] 6 | } -------------------------------------------------------------------------------- /custom-components/docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpmn-io/form-js-examples/HEAD/custom-components/docs/screenshot.png -------------------------------------------------------------------------------- /custom-button/app/style.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | height: 100%; 3 | margin: 0; 4 | padding: 0; 5 | font-family: sans-serif; 6 | } 7 | 8 | #form { 9 | max-width: 100%; 10 | height: 100%; 11 | } -------------------------------------------------------------------------------- /custom-components/app/style.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | height: 100%; 3 | margin: 0; 4 | padding: 0; 5 | font-family: sans-serif; 6 | } 7 | 8 | #form { 9 | max-width: 100%; 10 | height: 100%; 11 | } -------------------------------------------------------------------------------- /custom-components/app/extension/render/styles.css: -------------------------------------------------------------------------------- 1 | .range-group { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: row; 5 | } 6 | 7 | .range-group input { 8 | width: 100%; 9 | } 10 | .range-group .range-value { 11 | margin-left: 4px; 12 | } -------------------------------------------------------------------------------- /custom-components/app/extension/propertiesPanel/index.js: -------------------------------------------------------------------------------- 1 | import { CustomPropertiesProvider } from './CustomPropertiesProvider'; 2 | 3 | export default { 4 | __init__: [ 'rangePropertiesProvider' ], 5 | rangePropertiesProvider: [ 'type', CustomPropertiesProvider ] 6 | }; -------------------------------------------------------------------------------- /custom-button/app/extension/propertiesPanel/index.js: -------------------------------------------------------------------------------- 1 | import { CustomPropertiesProvider } from './CustomPropertiesProvider'; 2 | 3 | export default { 4 | __init__: [ 'feedbackButtonPropertiesProvider' ], 5 | feedbackButtonPropertiesProvider: [ 'type', CustomPropertiesProvider ] 6 | }; -------------------------------------------------------------------------------- /custom-button/app/extension/render/index.js: -------------------------------------------------------------------------------- 1 | import { FeedbackButtonRenderer, type } from './FeedbackButton'; 2 | 3 | class CustomFormFields { 4 | constructor(formFields) { 5 | formFields.register(type, FeedbackButtonRenderer); 6 | } 7 | } 8 | 9 | 10 | export default { 11 | __init__: [ 'feedbackButton' ], 12 | feedbackButton: [ 'type', CustomFormFields ] 13 | }; -------------------------------------------------------------------------------- /custom-button/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | form-js Custom Button example 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /custom-components/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | form-js Custom Components example 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /custom-button/app/form.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "default", 3 | "components": [ 4 | { 5 | "type": "textarea", 6 | "key": "message", 7 | "label": "Provide feedback" 8 | }, 9 | { 10 | "type": "feedbackButton", 11 | "label": "Send feedback", 12 | "endpoint": "https://www.example.com/feedback", 13 | "message": "=message" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /custom-button/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | public 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /custom-components/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | public 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /custom-components/app/extension/render/index.js: -------------------------------------------------------------------------------- 1 | import { RangeRenderer, rangeType } from './Range'; 2 | 3 | /* 4 | * This is the module definition of the custom field. This goes 5 | * into the Form instance via `additionalModules`. 6 | */ 7 | class CustomFormFields { 8 | constructor(formFields) { 9 | formFields.register(rangeType, RangeRenderer); 10 | } 11 | } 12 | 13 | 14 | export default { 15 | __init__: [ 'rangeField' ], 16 | rangeField: [ 'type', CustomFormFields ] 17 | }; -------------------------------------------------------------------------------- /custom-button/app/extension/render/feedback.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom-components/app/extension/render/range.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom-button/app/index.js: -------------------------------------------------------------------------------- 1 | import { FormPlayground } from '@bpmn-io/form-js'; 2 | 3 | import FeedbackButtonRenderExtension from './extension/render'; 4 | import FeedbackButtonPropertiesPanelExtension from './extension/propertiesPanel'; 5 | 6 | import '@bpmn-io/form-js/dist/assets/form-js.css'; 7 | import '@bpmn-io/form-js/dist/assets/form-js-editor.css'; 8 | import '@bpmn-io/form-js/dist/assets/form-js-playground.css'; 9 | 10 | import './style.css'; 11 | 12 | import schema from './form.json'; 13 | 14 | new FormPlayground({ 15 | container: document.querySelector('#form'), 16 | schema: schema, 17 | data: {}, 18 | additionalModules: [ 19 | FeedbackButtonRenderExtension 20 | ], 21 | editorAdditionalModules: [ 22 | FeedbackButtonPropertiesPanelExtension 23 | ] 24 | }); 25 | -------------------------------------------------------------------------------- /custom-components/app/index.js: -------------------------------------------------------------------------------- 1 | import { FormPlayground } from '@bpmn-io/form-js'; 2 | 3 | import RenderExtension from './extension/render'; 4 | import PropertiesPanelExtension from './extension/propertiesPanel'; 5 | 6 | import '@bpmn-io/form-js/dist/assets/form-js.css'; 7 | import '@bpmn-io/form-js/dist/assets/form-js-editor.css'; 8 | import '@bpmn-io/form-js/dist/assets/form-js-playground.css'; 9 | 10 | import './style.css'; 11 | 12 | import schema from './empty.json'; 13 | 14 | new FormPlayground({ 15 | container: document.querySelector('#form'), 16 | schema: schema, 17 | data: {}, 18 | 19 | // load rendering extension 20 | additionalModules: [ 21 | RenderExtension 22 | ], 23 | 24 | // load properties panel extension 25 | editorAdditionalModules: [ 26 | PropertiesPanelExtension 27 | ] 28 | }); 29 | -------------------------------------------------------------------------------- /custom-button/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-button", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "all": "run-s build", 7 | "build": "webpack", 8 | "start": "run-s build serve", 9 | "dev": "run-p \"build -- --watch\" serve", 10 | "serve": "sirv public --dev" 11 | }, 12 | "devDependencies": { 13 | "copy-webpack-plugin": "^11.0.0", 14 | "css-loader": "^6.8.1", 15 | "eslint": "^8.51.0", 16 | "eslint-plugin-bpmn-io": "^1.0.0", 17 | "npm-run-all": "^4.1.5", 18 | "raw-loader": "^4.0.2", 19 | "sirv-cli": "^2.0.2", 20 | "style-loader": "^3.3.3", 21 | "webpack": "^5.88.2", 22 | "webpack-cli": "^5.1.4" 23 | }, 24 | "dependencies": { 25 | "@bpmn-io/form-js": "^1.17.0", 26 | "@bpmn-io/properties-panel": "^3.12.0", 27 | "classnames": "^2.3.2", 28 | "diagram-js": "^12.7.0", 29 | "min-dash": "^4.1.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /custom-components/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-components", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "all": "run-s build", 7 | "build": "webpack", 8 | "start": "run-s build serve", 9 | "dev": "run-p \"build -- --watch\" serve", 10 | "serve": "sirv public --dev" 11 | }, 12 | "devDependencies": { 13 | "copy-webpack-plugin": "^11.0.0", 14 | "css-loader": "^6.8.1", 15 | "eslint": "^8.51.0", 16 | "eslint-plugin-bpmn-io": "^1.0.0", 17 | "npm-run-all": "^4.1.5", 18 | "raw-loader": "^4.0.2", 19 | "sirv-cli": "^2.0.2", 20 | "style-loader": "^3.3.3", 21 | "webpack": "^5.88.2", 22 | "webpack-cli": "^5.1.4" 23 | }, 24 | "dependencies": { 25 | "@bpmn-io/form-js": "^1.17.0", 26 | "@bpmn-io/properties-panel": "^3.12.0", 27 | "classnames": "^2.3.2", 28 | "diagram-js": "^12.7.0", 29 | "min-dash": "^4.1.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /custom-button/README.md: -------------------------------------------------------------------------------- 1 | # form-js Custom Button Example 2 | 3 | This example uses [form-js](https://github.com/bpmn-io/form-js) to implement a custom button component. 4 | 5 | ![form-js custom button example screenshot](./docs/screenshot.png) 6 | 7 | ## About 8 | 9 | This example builds on the general [custom components example](./../custom-components). 10 | 11 | It demonstrates how to implement a custom feedback button component that triggers a custom behavior on click. Furthermore, it shows how to use the form-js [FEEL tooling](https://docs.camunda.io/docs/components/modeler/feel/what-is-feel/) to make the feedback message dynamic. 12 | 13 | ## Building 14 | 15 | You need a [NodeJS](http://nodejs.org) development stack with [npm](https://npmjs.org) installed to build the project. 16 | 17 | To install all project dependencies execute 18 | 19 | ``` 20 | npm install 21 | ``` 22 | 23 | Spin up a development setup by executing 24 | 25 | ``` 26 | npm run dev 27 | ``` -------------------------------------------------------------------------------- /.github/workflows/ADD_TO_HTO_PROJECT.yml: -------------------------------------------------------------------------------- 1 | name: ADD_TO_HTO_PROJECT 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - transferred 8 | pull_request: 9 | types: 10 | - opened 11 | 12 | jobs: 13 | add-to-project: 14 | name: Add issue to project 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/add-to-project@0da8e46333d7b6e01d0e857452a1e99cb47be205 18 | name: Add to project 19 | id: add-project 20 | with: 21 | project-url: ${{ secrets.HTO_PROJECT_URL }} 22 | github-token: ${{ secrets.ADD_TO_HTO_PROJECT_PAT }} 23 | - name: Define project column 24 | run: echo "COLUMN=${{ github.event_name == 'pull_request' && 'Needs Review' || 'Inbox' }}" >> $GITHUB_ENV 25 | - uses: titoportas/update-project-fields@421a54430b3cdc9eefd8f14f9ce0142ab7678751 26 | name: Update project column 27 | id: update-column 28 | with: 29 | project-url: ${{ secrets.HTO_PROJECT_URL }} 30 | github-token: ${{ secrets.ADD_TO_HTO_PROJECT_PAT }} 31 | item-id: ${{ steps.add-project.outputs.itemId }} 32 | field-keys: Status 33 | field-values: ${{ env.COLUMN }} 34 | -------------------------------------------------------------------------------- /custom-button/app/extension/render/FeedbackButton.js: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | useTemplateEvaluation 4 | } from '@bpmn-io/form-js'; 5 | 6 | import { 7 | html, 8 | useCallback 9 | } from 'diagram-js/lib/ui'; 10 | 11 | import './styles.css'; 12 | 13 | import FeedbackIcon from './feedback.svg'; 14 | 15 | export const type = 'feedbackButton'; 16 | 17 | export function FeedbackButtonRenderer(props) { 18 | 19 | const { 20 | disabled, 21 | readonly, 22 | field 23 | } = props; 24 | 25 | const { 26 | endpoint, 27 | message 28 | } = field; 29 | 30 | const evaluatedMessage = useTemplateEvaluation(message, { debug: true, strict: true }); 31 | 32 | const onClick = useCallback(() => { 33 | 34 | if (disabled || readonly) { 35 | return; 36 | } 37 | 38 | // send the message to the configured endpoint 39 | alert(`Send message to ${ endpoint }:\n\n${ evaluatedMessage }`); 40 | }, [ disabled, readonly, endpoint, evaluatedMessage ]); 41 | 42 | return html`
43 | <${Button} 44 | {...props} 45 | field=${{ 46 | ...field, 47 | acton: 'feedback' 48 | }}> 49 |
`; 50 | } 51 | 52 | FeedbackButtonRenderer.config = { 53 | ...Button.config, 54 | type: type, 55 | label: 'Feedback', 56 | iconUrl: `data:image/svg+xml,${ encodeURIComponent(FeedbackIcon) }`, 57 | propertiesPanelEntries: [ 58 | 'label' 59 | ] 60 | }; -------------------------------------------------------------------------------- /custom-button/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const { 4 | NormalModuleReplacementPlugin 5 | } = require('webpack'); 6 | 7 | const CopyPlugin = require('copy-webpack-plugin'); 8 | 9 | module.exports = { 10 | output: { 11 | path: path.join(__dirname, 'public'), 12 | filename: 'index.js', 13 | }, 14 | mode: 'development', 15 | entry: './app/index.js', 16 | devtool: 'source-map', 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.css$/, 21 | use: [ 'style-loader', 'css-loader' ] 22 | }, 23 | { 24 | test: /\.svg$/, 25 | use: 'raw-loader' 26 | } 27 | ] 28 | }, 29 | plugins: [ 30 | new CopyPlugin({ 31 | patterns: [ 32 | { from: 'app/index.html', to: '.' } 33 | ] 34 | }), 35 | new NormalModuleReplacementPlugin( 36 | /^(..\/preact|preact)(\/[^/]+)?$/, 37 | function(resource) { 38 | 39 | const replMap = { 40 | 'preact/hooks': path.resolve('node_modules/preact/hooks/dist/hooks.module.js'), 41 | 'preact/jsx-runtime': path.resolve('node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js'), 42 | 'preact': path.resolve('node_modules/preact/dist/preact.module.js'), 43 | '../preact/hooks': path.resolve('node_modules/preact/hooks/dist/hooks.module.js'), 44 | '../preact/jsx-runtime': path.resolve('node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js'), 45 | '../preact': path.resolve('node_modules/preact/dist/preact.module.js') 46 | }; 47 | 48 | const replacement = replMap[resource.request]; 49 | 50 | if (!replacement) { 51 | return; 52 | } 53 | 54 | resource.request = replacement; 55 | } 56 | ) 57 | ] 58 | }; -------------------------------------------------------------------------------- /custom-components/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const { 4 | NormalModuleReplacementPlugin 5 | } = require('webpack'); 6 | 7 | const CopyPlugin = require('copy-webpack-plugin'); 8 | 9 | module.exports = { 10 | output: { 11 | path: path.join(__dirname, 'public'), 12 | filename: 'index.js', 13 | }, 14 | mode: 'development', 15 | entry: './app/index.js', 16 | devtool: 'source-map', 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.css$/, 21 | use: [ 'style-loader', 'css-loader' ] 22 | }, 23 | { 24 | test: /\.svg$/, 25 | use: 'raw-loader' 26 | } 27 | ] 28 | }, 29 | plugins: [ 30 | new CopyPlugin({ 31 | patterns: [ 32 | { from: 'app/index.html', to: '.' } 33 | ] 34 | }), 35 | new NormalModuleReplacementPlugin( 36 | /^(..\/preact|preact)(\/[^/]+)?$/, 37 | function(resource) { 38 | 39 | const replMap = { 40 | 'preact/hooks': path.resolve('node_modules/preact/hooks/dist/hooks.module.js'), 41 | 'preact/jsx-runtime': path.resolve('node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js'), 42 | 'preact': path.resolve('node_modules/preact/dist/preact.module.js'), 43 | '../preact/hooks': path.resolve('node_modules/preact/hooks/dist/hooks.module.js'), 44 | '../preact/jsx-runtime': path.resolve('node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js'), 45 | '../preact': path.resolve('node_modules/preact/dist/preact.module.js') 46 | }; 47 | 48 | const replacement = replMap[resource.request]; 49 | 50 | if (!replacement) { 51 | return; 52 | } 53 | 54 | resource.request = replacement; 55 | } 56 | ) 57 | ] 58 | }; -------------------------------------------------------------------------------- /custom-properties/README.md: -------------------------------------------------------------------------------- 1 | # form-js custom properties 2 | 3 | An example that showcases how to use custom properties in [form-js](https://github.com/bpmn-io/form-js) to customize the Viewer. 4 | 5 | ## About 6 | 7 | This example shows how to define and use custom properties to populate a select or radio field, by relying on an external API. 8 | 9 | ## Usage summary 10 | 11 | ### Define custom property 12 | 13 | When defining the schema, use a custom property (`externalData`) to define the API endpoint to be used in retrieving the data. 14 | 15 | ```json 16 | { 17 | ... 18 | "components": [ 19 | { 20 | "label": "Field label", 21 | "type": "select", 22 | "id": "Field_1", 23 | "key": "fielKey", 24 | "values": [], 25 | "properties": { 26 | "externalData": "https://..." 27 | } 28 | } 29 | ] 30 | ... 31 | } 32 | ``` 33 | 34 | ### Use custom property to populate field 35 | 36 | After loading the form, you can check which fields require external data and use the API endpoint defined for each to retrieve it. Then, the field's options can be updated in the schema. 37 | 38 | ```javascript 39 | if (type === "select" && properties && properties.externalData) { 40 | 41 | // use "externalData" to get API endpoint 42 | const endpoint = properties.externalData; 43 | const res = await fetch(endpoint, "GET"); 44 | 45 | // map API response to field values 46 | field.values = res.map(option => ({ 47 | value: option.id, 48 | label: option.name 49 | })); 50 | } 51 | ``` 52 | 53 | After the schema is updated accordingly, you may re-import the schema to re-render. 54 | 55 | ```javascript 56 | await form.importSchema(schema); 57 | ``` 58 | 59 | ## Run this Example 60 | Download the example and open it in a web browser. 61 | -------------------------------------------------------------------------------- /custom-properties/customProperties.js: -------------------------------------------------------------------------------- 1 | // (1) load form 2 | const schema = JSON.parse( 3 | document.querySelector('[type="application/form-schema"]').textContent 4 | ); 5 | 6 | const container = document.querySelector('#form'); 7 | 8 | const loadForm = FormViewer.createForm({ 9 | container, 10 | schema 11 | }); 12 | 13 | // (2) populate form fields with external data 14 | loadForm.then(async (form) => { 15 | 16 | const { 17 | components 18 | } = schema; 19 | 20 | for (let i = 0; i < components.length; i++) { 21 | 22 | const field = components[i]; 23 | const { 24 | properties, 25 | id, 26 | type 27 | } = field; 28 | 29 | if (type === "select" && hasExternalData(field)) { 30 | 31 | // (2.1) use custom property "externalData" to get external API endpoint 32 | const endpoint = properties.externalData; 33 | const res = await preloadData(endpoint, "GET"); 34 | 35 | // (2.2) map request response to field values 36 | field.values = res.map(option => ({ 37 | value: option.id, 38 | label: option.name 39 | })); 40 | 41 | } 42 | 43 | } 44 | 45 | // (3) re-render form with updated schema 46 | form.importSchema(schema); 47 | 48 | form.on('submit', (event) => { 49 | console.log(event.data, event.errors); 50 | alert("Form submitted!") 51 | }); 52 | }) 53 | 54 | 55 | // helpers 56 | 57 | function hasExternalData(field) { 58 | const { 59 | properties 60 | } = field; 61 | return properties && properties.externalData; 62 | } 63 | 64 | async function preloadData(url, method) { 65 | // mock api response 66 | return [{ 67 | name: "John Smith", 68 | id: "johnSmith" 69 | }, 70 | { 71 | name: "Jane Doe", 72 | id: "janeDoe" 73 | } 74 | ]; 75 | } -------------------------------------------------------------------------------- /starter/viewer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 12 | 13 | 14 | 15 |
16 | 17 | 21 | 37 | 38 | 41 | 42 | 43 | 55 | 56 | -------------------------------------------------------------------------------- /custom-properties/viewer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 12 | 13 | Custom properties form-js example 14 | 15 | 16 | 17 |
18 | 19 | 23 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /starter/editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 20 | 21 | 22 | 23 |
24 | 25 | 29 | 45 | 46 | 49 | 50 | 51 | 63 | 64 | -------------------------------------------------------------------------------- /custom-button/app/extension/propertiesPanel/CustomPropertiesProvider.js: -------------------------------------------------------------------------------- 1 | import { get } from 'min-dash'; 2 | 3 | import { useVariables } from '@bpmn-io/form-js'; 4 | 5 | import { 6 | FeelTemplatingEntry, 7 | isFeelEntryEdited, 8 | isTextFieldEntryEdited, 9 | TextFieldEntry 10 | } from '@bpmn-io/properties-panel'; 11 | 12 | 13 | export class CustomPropertiesProvider { 14 | constructor(propertiesPanel) { 15 | propertiesPanel.registerProvider(this, 500); 16 | } 17 | 18 | /** 19 | * @param {any} field 20 | * @param {function} editField 21 | * 22 | * @return {(Object[]) => (Object[])} groups middleware 23 | */ 24 | getGroups(field, editField) { 25 | 26 | /** 27 | * @param {Object[]} groups 28 | * 29 | * @return {Object[]} modified groups 30 | */ 31 | return (groups) => { 32 | 33 | if (field.type !== 'feedbackButton') { 34 | return groups; 35 | } 36 | 37 | const generalIdx = findGroupIdx(groups, 'general'); 38 | 39 | /* insert group after general */ 40 | groups.splice(generalIdx + 1, 0, { 41 | id: 'feedback', 42 | label: 'Feedback', 43 | entries: Entries(field, editField) 44 | }); 45 | 46 | return groups; 47 | }; 48 | } 49 | } 50 | 51 | CustomPropertiesProvider.$inject = [ 'propertiesPanel' ]; 52 | 53 | function Entries(field, editField) { 54 | 55 | const onChange = (key) => { 56 | return (value) => { 57 | editField(field, [ key ], value); 58 | }; 59 | }; 60 | 61 | const getValue = (key) => { 62 | return () => { 63 | return get(field, [ key ]); 64 | }; 65 | }; 66 | 67 | return [ 68 | 69 | { 70 | id: 'endpoint', 71 | component: Endpoint, 72 | getValue, 73 | field, 74 | isEdited: isTextFieldEntryEdited, 75 | onChange 76 | }, 77 | { 78 | id: 'message', 79 | component: Message, 80 | getValue, 81 | field, 82 | isEdited: isFeelEntryEdited, 83 | onChange 84 | } 85 | ]; 86 | 87 | } 88 | 89 | function Endpoint(props) { 90 | const { 91 | field, 92 | getValue, 93 | id, 94 | onChange 95 | } = props; 96 | 97 | const debounce = (fn) => fn; 98 | 99 | return TextFieldEntry({ 100 | debounce, 101 | element: field, 102 | getValue: getValue('endpoint'), 103 | id, 104 | label: 'Endpoint', 105 | setValue: onChange('endpoint') 106 | }); 107 | } 108 | 109 | function Message(props) { 110 | const { 111 | field, 112 | getValue, 113 | id, 114 | onChange 115 | } = props; 116 | 117 | const debounce = (fn) => fn; 118 | 119 | const variables = useVariables().map(name => ({ name })); 120 | 121 | return FeelTemplatingEntry({ 122 | debounce, 123 | element: field, 124 | getValue: getValue('message'), 125 | id, 126 | label: 'Message', 127 | setValue: onChange('message'), 128 | variables 129 | }); 130 | } 131 | 132 | // helper ////////////////////// 133 | 134 | function findGroupIdx(groups, id) { 135 | return groups.findIndex(g => g.id === id); 136 | } -------------------------------------------------------------------------------- /custom-components/app/extension/render/Range.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | 3 | /* 4 | * Import components and utilities from our extension API. Warning: for demo experiments only. 5 | */ 6 | import { 7 | Errors, 8 | FormContext, 9 | Numberfield, 10 | Description, 11 | Label 12 | } from '@bpmn-io/form-js'; 13 | 14 | import { 15 | html, 16 | useContext 17 | } from 'diagram-js/lib/ui'; 18 | 19 | import './styles.css'; 20 | 21 | import RangeIcon from './range.svg'; 22 | 23 | export const rangeType = 'range'; 24 | 25 | /* 26 | * This is the rendering part of the custom field. We use `htm` to 27 | * to render our components without the need of extra JSX transpilation. 28 | */ 29 | export function RangeRenderer(props) { 30 | 31 | const { 32 | disabled, 33 | errors = [], 34 | field, 35 | readonly, 36 | value 37 | } = props; 38 | 39 | const { 40 | description, 41 | range = {}, 42 | id, 43 | label 44 | } = field; 45 | 46 | const { 47 | min, 48 | max, 49 | step 50 | } = range; 51 | 52 | const { formId } = useContext(FormContext); 53 | 54 | const errorMessageId = errors.length === 0 ? undefined : `${prefixId(id, formId)}-error-message`; 55 | 56 | const onChange = ({ target }) => { 57 | props.onChange({ 58 | field, 59 | value: Number(target.value) 60 | }); 61 | }; 62 | 63 | return html`
64 | <${Label} 65 | id=${ prefixId(id, formId) } 66 | label=${ label } /> 67 |
68 | 78 |
${ value }
79 |
80 | <${Description} description=${ description } /> 81 | <${Errors} errors=${ errors } id=${ errorMessageId } /> 82 |
`; 83 | } 84 | 85 | /* 86 | * This is the configuration part of the custom field. It defines 87 | * the schema type, UI label and icon, palette group, properties panel entries 88 | * and much more. 89 | */ 90 | RangeRenderer.config = { 91 | 92 | /* we can extend the default configuration of existing fields */ 93 | ...Numberfield.config, 94 | type: rangeType, 95 | label: 'Range', 96 | iconUrl: `data:image/svg+xml,${ encodeURIComponent(RangeIcon) }`, 97 | propertiesPanelEntries: [ 98 | 'key', 99 | 'label', 100 | 'description', 101 | 'min', 102 | 'max', 103 | 'disabled', 104 | 'readonly' 105 | ] 106 | }; 107 | 108 | // helper ////////////////////// 109 | 110 | function formFieldClasses(type, { errors = [], disabled = false, readonly = false } = {}) { 111 | if (!type) { 112 | throw new Error('type required'); 113 | } 114 | 115 | return classNames('fjs-form-field', `fjs-form-field-${type}`, { 116 | 'fjs-has-errors': errors.length > 0, 117 | 'fjs-disabled': disabled, 118 | 'fjs-readonly': readonly 119 | }); 120 | } 121 | 122 | function prefixId(id, formId) { 123 | if (formId) { 124 | return `fjs-form-${ formId }-${ id }`; 125 | } 126 | 127 | return `fjs-form-${ id }`; 128 | } -------------------------------------------------------------------------------- /custom-components/app/extension/propertiesPanel/CustomPropertiesProvider.js: -------------------------------------------------------------------------------- 1 | import { get, set } from 'min-dash'; 2 | 3 | /* 4 | * Import components and utilities from our extension API. Warning: for demo experiments only. 5 | */ 6 | import { 7 | NumberFieldEntry, 8 | isNumberFieldEntryEdited 9 | } from '@bpmn-io/properties-panel'; 10 | 11 | /* 12 | * This is a custom properties provider for the properties panel. 13 | * It adds a new group `Range` with range specific properties. 14 | */ 15 | export class CustomPropertiesProvider { 16 | constructor(propertiesPanel) { 17 | propertiesPanel.registerProvider(this, 500); 18 | } 19 | 20 | /** 21 | * Return the groups provided for the given field. 22 | * 23 | * @param {any} field 24 | * @param {function} editField 25 | * 26 | * @return {(Object[]) => (Object[])} groups middleware 27 | */ 28 | getGroups(field, editField) { 29 | 30 | /** 31 | * We return a middleware that modifies 32 | * the existing groups. 33 | * 34 | * @param {Object[]} groups 35 | * 36 | * @return {Object[]} modified groups 37 | */ 38 | return (groups) => { 39 | 40 | if (field.type !== 'range') { 41 | return groups; 42 | } 43 | 44 | const generalIdx = findGroupIdx(groups, 'general'); 45 | 46 | /* insert range group after general */ 47 | groups.splice(generalIdx + 1, 0, { 48 | id: 'range', 49 | label: 'Range', 50 | entries: RangeEntries(field, editField) 51 | }); 52 | 53 | return groups; 54 | }; 55 | } 56 | } 57 | 58 | CustomPropertiesProvider.$inject = [ 'propertiesPanel' ]; 59 | 60 | /* 61 | * collect range entries for our custom group 62 | */ 63 | function RangeEntries(field, editField) { 64 | 65 | const onChange = (key) => { 66 | return (value) => { 67 | const range = get(field, [ 'range' ], {}); 68 | 69 | editField(field, [ 'range' ], set(range, [ key ], value)); 70 | }; 71 | }; 72 | 73 | const getValue = (key) => { 74 | return () => { 75 | return get(field, [ 'range', key ]); 76 | }; 77 | }; 78 | 79 | return [ 80 | 81 | { 82 | id: 'range-min', 83 | component: Min, 84 | getValue, 85 | field, 86 | isEdited: isNumberFieldEntryEdited, 87 | onChange 88 | }, 89 | { 90 | id: 'range-max', 91 | component: Max, 92 | getValue, 93 | field, 94 | isEdited: isNumberFieldEntryEdited, 95 | onChange 96 | }, 97 | { 98 | id: 'range-step', 99 | component: Step, 100 | getValue, 101 | field, 102 | isEdited: isNumberFieldEntryEdited, 103 | onChange 104 | } 105 | ]; 106 | 107 | } 108 | 109 | function Min(props) { 110 | const { 111 | field, 112 | getValue, 113 | id, 114 | onChange 115 | } = props; 116 | 117 | const debounce = (fn) => fn; 118 | 119 | return NumberFieldEntry({ 120 | debounce, 121 | element: field, 122 | getValue: getValue('min'), 123 | id, 124 | label: 'Minimum', 125 | setValue: onChange('min') 126 | }); 127 | } 128 | 129 | function Max(props) { 130 | const { 131 | field, 132 | getValue, 133 | id, 134 | onChange 135 | } = props; 136 | 137 | const debounce = (fn) => fn; 138 | 139 | return NumberFieldEntry({ 140 | debounce, 141 | element: field, 142 | getValue: getValue('max'), 143 | id, 144 | label: 'Maximum', 145 | setValue: onChange('max') 146 | }); 147 | } 148 | 149 | function Step(props) { 150 | const { 151 | field, 152 | getValue, 153 | id, 154 | onChange 155 | } = props; 156 | 157 | const debounce = (fn) => fn; 158 | 159 | return NumberFieldEntry({ 160 | debounce, 161 | element: field, 162 | getValue: getValue('step'), 163 | id, 164 | min: 0, 165 | label: 'Step', 166 | setValue: onChange('step') 167 | }); 168 | } 169 | 170 | // helper ////////////////////// 171 | 172 | function findGroupIdx(groups, id) { 173 | return groups.findIndex(g => g.id === id); 174 | } -------------------------------------------------------------------------------- /custom-components/README.md: -------------------------------------------------------------------------------- 1 | # form-js Custom Components Example 2 | 3 | This example uses [form-js](https://github.com/bpmn-io/form-js) to implement custom form components. 4 | 5 | ![form-js custom components example screenshot](./docs/screenshot.png) 6 | 7 | ## About 8 | 9 | In this example we extend form-js with a custom component that allows users to select a number from a range. To achieve that we will walk through the following steps: 10 | 11 | * Add a custom form component renderer 12 | * Add custom styles for the range component 13 | * Add custom properties panel entries to specify the min, max and step of the range 14 | 15 | An example schema of the range component looks like this: 16 | 17 | ```json 18 | { 19 | "type": "range", 20 | "label": "Range", 21 | "min": 0, 22 | "max": 100, 23 | "step": 1 24 | } 25 | ``` 26 | 27 | ### Add a custom form component renderer 28 | 29 | The first step is to add a custom form component renderer. 30 | 31 | The renderer is responsible for rendering the component in the form editor and the form preview. It also handles the interaction with the component, e.g. when the value changes or validation. 32 | 33 | We create the [`RangeRenderer`](./app/extension/render/Range.js) which defines a couple of things 34 | 35 | * a [preact](https://preactjs.com/) component that renders the component in the form editor and preview by re-using existing components like `Label`, `Errors` and `Description` 36 | 37 | ```js 38 | import { 39 | Errors, 40 | FormContext, 41 | Description, 42 | Label 43 | } from '@bpmn-io/form-js'; 44 | 45 | import { 46 | html, 47 | useContext 48 | } from 'diagram-js/lib/ui'; 49 | 50 | export function RangeRenderer(props) { 51 | 52 | const { 53 | disabled, 54 | errors = [], 55 | field, 56 | readonly, 57 | value 58 | } = props; 59 | 60 | const { 61 | description, 62 | range = {}, 63 | id, 64 | label 65 | } = field; 66 | 67 | const { 68 | min, 69 | max, 70 | step 71 | } = range; 72 | 73 | const { formId } = useContext(FormContext); 74 | 75 | const errorMessageId = errors.length === 0 ? undefined : `${prefixId(id, formId)}-error-message`; 76 | 77 | const onChange = ({ target }) => { 78 | props.onChange({ 79 | field, 80 | value: Number(target.value) 81 | }); 82 | }; 83 | 84 | return html`
85 | <${Label} 86 | id=${ prefixId(id, formId) } 87 | label=${ label } /> 88 |
89 | 99 |
${ value }
100 |
101 | <${Description} description=${ description } /> 102 | <${Errors} errors=${ errors } id=${ errorMessageId } /> 103 |
`; 104 | } 105 | ``` 106 | 107 | * a component `config` that extends the base `Numberfield` configuration and adds customizations as the icon, a custom label and the default properties panel entries to show 108 | 109 | ```js 110 | import { Numberfield } from '@bpmn-io/form-js'; 111 | 112 | RangeRenderer.config = { 113 | ...Numberfield.config, 114 | type: rangeType, 115 | label: 'Range', 116 | iconUrl: `data:image/svg+xml,${ encodeURIComponent(RangeIcon) }`, 117 | propertiesPanelEntries: [ 118 | 'key', 119 | 'label', 120 | 'description', 121 | 'min', 122 | 'max', 123 | 'disabled', 124 | 'readonly' 125 | ] 126 | }; 127 | ``` 128 | 129 | ### Register the custom renderer 130 | 131 | We use the `formFields` service to register our custom renderer for the `range` type. 132 | 133 | ```js 134 | class CustomFormFields { 135 | constructor(formFields) { 136 | formFields.register('range', RangeRenderer); 137 | } 138 | } 139 | 140 | 141 | export default { 142 | __init__: [ 'rangeField' ], 143 | rangeField: [ 'type', CustomFormFields ] 144 | }; 145 | ``` 146 | 147 | ### Add custom styles 148 | 149 | We define custom styles for the range component by adding a simple CSS file [`styles.css`](./app/extension/render/styles.css). For the example we import the styles directly to the component as we have a bundler ([webpack](https://webpack.js.org/)) in place that adds the styles to the application. 150 | 151 | ```css 152 | .range-group { 153 | width: 100%; 154 | display: flex; 155 | flex-direction: row; 156 | } 157 | 158 | .range-group input { 159 | width: 100%; 160 | } 161 | .range-group .range-value { 162 | margin-left: 4px; 163 | } 164 | ``` 165 | 166 | ### Add custom properties panel entries 167 | 168 | With `config.propertiesPanelEntries` we define the default properties panel entries to show for the component. We can also add custom entries to the properties panel. 169 | 170 | We add a [`CustomPropertiesProvider`](./app/extension/properties-panel/CustomPropertiesProvider.js) that allows users to specify the min, max and step of the range component. We place the group right after the general group. 171 | 172 | ```js 173 | export class CustomPropertiesProvider { 174 | constructor(propertiesPanel) { 175 | propertiesPanel.registerProvider(this, 500); 176 | } 177 | 178 | getGroups(field, editField) { 179 | 180 | ... 181 | return (groups) => { 182 | 183 | if (field.type !== 'range') { 184 | return groups; 185 | } 186 | 187 | const generalIdx = findGroupIdx(groups, 'general'); 188 | 189 | groups.splice(generalIdx + 1, 0, { 190 | id: 'range', 191 | label: 'Range', 192 | entries: RangeEntries(field, editField) 193 | }); 194 | 195 | return groups; 196 | }; 197 | } 198 | } 199 | ``` 200 | 201 | The [`RangeEntries`](./app/extension/properties-panel/CustomPropertiesProvider.js) function returns the entries to show for the range component. Check out the full provider to gather more insights. 202 | 203 | ```js 204 | function RangeEntries(field, editField) { 205 | 206 | const onChange = (key) => { 207 | return (value) => { 208 | const range = get(field, [ 'range' ], {}); 209 | 210 | editField(field, [ 'range' ], set(range, [ key ], value)); 211 | }; 212 | }; 213 | 214 | const getValue = (key) => { 215 | return () => { 216 | return get(field, [ 'range', key ]); 217 | }; 218 | }; 219 | 220 | return [ 221 | 222 | { 223 | id: 'range-min', 224 | component: Min, 225 | getValue, 226 | field, 227 | isEdited: isNumberFieldEntryEdited, 228 | onChange 229 | }, 230 | { 231 | id: 'range-max', 232 | component: Max, 233 | getValue, 234 | field, 235 | isEdited: isNumberFieldEntryEdited, 236 | onChange 237 | }, 238 | { 239 | id: 'range-step', 240 | component: Step, 241 | getValue, 242 | field, 243 | isEdited: isNumberFieldEntryEdited, 244 | onChange 245 | } 246 | ]; 247 | } 248 | ``` 249 | 250 | ### Plugging Everything together 251 | 252 | To embed the customizations into the form-js we need to plug everything together. We do that by including the custom renderer into both editor and preview via `additionalModules` and registering the custom properties provider to the editor via `editorAdditionalModules`. 253 | 254 | ```js 255 | import { FormPlayground } from '@bpmn-io/form-js'; 256 | 257 | import RenderExtension from './extension/render'; 258 | import PropertiesPanelExtension from './extension/propertiesPanel'; 259 | 260 | import '@bpmn-io/form-js/dist/assets/form-js.css'; 261 | import '@bpmn-io/form-js/dist/assets/form-js-editor.css'; 262 | import '@bpmn-io/form-js/dist/assets/form-js-playground.css'; 263 | 264 | new FormPlayground({ 265 | container, 266 | schema, 267 | data, 268 | additionalModules: [ 269 | RenderExtension 270 | ], 271 | editorAdditionalModules: [ 272 | PropertiesPanelExtension 273 | ] 274 | }); 275 | ``` 276 | 277 | ## Building 278 | 279 | You need a [NodeJS](http://nodejs.org) development stack with [npm](https://npmjs.org) installed to build the project. 280 | 281 | To install all project dependencies execute 282 | 283 | ``` 284 | npm install 285 | ``` 286 | 287 | Spin up a development setup by executing 288 | 289 | ``` 290 | npm run dev 291 | ``` --------------------------------------------------------------------------------