├── .babelrc.json ├── .github └── workflows │ ├── CI.yml │ └── CODE_SCANNING.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets └── properties-panel.css ├── codecov.yml ├── eslint.config.mjs ├── karma.config.js ├── package-lock.json ├── package.json ├── renovate.json ├── rollup.config.js ├── src ├── PropertiesPanel.js ├── assets │ └── properties-panel.css ├── components │ ├── DropdownButton.js │ ├── Group.js │ ├── Header.js │ ├── HeaderButton.js │ ├── ListGroup.js │ ├── ListItem.js │ ├── Placeholder.js │ ├── Popup.js │ ├── entries │ │ ├── Checkbox.js │ │ ├── Collapsible.js │ │ ├── Description.js │ │ ├── FEEL │ │ │ ├── Feel.js │ │ │ ├── FeelEditor.js │ │ │ ├── FeelIcon.js │ │ │ ├── FeelIndicator.js │ │ │ ├── FeelPopup.js │ │ │ ├── context │ │ │ │ ├── FeelPopupContext.js │ │ │ │ └── index.js │ │ │ └── index.js │ │ ├── List.js │ │ ├── NumberField.js │ │ ├── Select.js │ │ ├── Simple.js │ │ ├── TextArea.js │ │ ├── TextField.js │ │ ├── ToggleSwitch.js │ │ ├── Tooltip.js │ │ ├── index.js │ │ └── templating │ │ │ ├── Templating.js │ │ │ ├── TemplatingEditor.js │ │ │ └── index.js │ ├── icons │ │ ├── Arrow.svg │ │ ├── Close.svg │ │ ├── Create.svg │ │ ├── Delete.svg │ │ ├── Drag.svg │ │ ├── ExternalLink.svg │ │ ├── Feel.svg │ │ ├── Launch.svg │ │ ├── Popup.svg │ │ └── index.js │ ├── index.js │ └── util │ │ ├── dragger.js │ │ └── translateFallback.js ├── context │ ├── DescriptionContext.js │ ├── ErrorsContext.js │ ├── EventContext.js │ ├── LayoutContext.js │ ├── PropertiesPanelContext.js │ ├── TooltipContext.js │ └── index.js ├── features │ ├── debounce-input │ │ ├── debounceInput.js │ │ └── index.js │ ├── feel-popup │ │ ├── FeelPopupModule.js │ │ └── index.js │ └── index.js ├── hooks │ ├── index.js │ ├── useDescriptionContext.js │ ├── useElementVisible.js │ ├── useError.js │ ├── useEvent.js │ ├── useKeyFactory.js │ ├── useLayoutState.js │ ├── usePrevious.js │ ├── useShowEntryEvent.js │ ├── useStaticCallback.js │ ├── useStickyIntersectionObserver.js │ └── useTooltipContext.js └── index.js └── test ├── TestHelper.js ├── coverageBundle.js ├── spec ├── PropertiesPanel.spec.js ├── components │ ├── Checkbox.spec.js │ ├── Collapsible.spec.js │ ├── DropdownButton.spec.js │ ├── Feel.spec.js │ ├── FeelEditor.spec.js │ ├── FeelPopup.spec.js │ ├── Group.spec.js │ ├── Header.spec.js │ ├── HeaderButton.spec.js │ ├── List.spec.js │ ├── ListGroup.spec.js │ ├── ListItem.spec.js │ ├── NumberField.spec.js │ ├── Placeholder.spec.js │ ├── Popup.spec.js │ ├── Select.spec.js │ ├── Simple.spec.js │ ├── Templating.spec.js │ ├── TemplatingEditor.spec.js │ ├── TextArea.spec.js │ ├── TextField.spec.js │ ├── ToggleSwitch.spec.js │ └── Tooltip.spec.js ├── hooks │ ├── useDescriptionContext.spec.js │ ├── useElementVisible.spec.js │ ├── useError.spec.js │ ├── useEvent.spec.js │ ├── useKeyFactory.spec.js │ ├── useLayoutState.spec.js │ ├── useShowEntryEvent.spec.js │ ├── useStickyIntersectionObserver.spec.js │ └── useTooltipContext.spec.js └── mocks │ └── index.js ├── test.css └── testBundle.js /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ "@babel/plugin-transform-react-jsx", { 4 | "importSource": "preact", 5 | "runtime": "automatic" 6 | } ], 7 | "inline-react-svg", 8 | [ "module-resolver", { 9 | "alias": { 10 | "preact": "./preact", 11 | "react": "./preact/compat" 12 | } 13 | } ] 14 | ] 15 | } -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [ push, pull_request ] 3 | jobs: 4 | Build: 5 | 6 | strategy: 7 | matrix: 8 | os: [ macos-latest, ubuntu-latest, windows-latest ] 9 | node-version: [ 20 ] 10 | fail-fast: false 11 | 12 | runs-on: ${{ matrix.os }} 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: Use Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: 'npm' 22 | - name: Install dependencies 23 | run: npm ci 24 | - name: Setup project 25 | uses: bpmn-io/actions/setup@latest 26 | - name: Build 27 | if: runner.os == 'Linux' 28 | env: 29 | COVERAGE: 1 30 | TEST_BROWSERS: ChromeHeadless 31 | run: npm run all 32 | - name: Build 33 | if: runner.os != 'Linux' 34 | env: 35 | TEST_BROWSERS: ChromeHeadless 36 | run: npm run all 37 | - name: Upload coverage 38 | if: runner.os == 'Linux' 39 | uses: codecov/codecov-action@v5 40 | with: 41 | fail_ci_if_error: true 42 | env: 43 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 44 | -------------------------------------------------------------------------------- /.github/workflows/CODE_SCANNING.yml: -------------------------------------------------------------------------------- 1 | 2 | name: "Code Scanning" 3 | 4 | on: 5 | push: 6 | branches: [ main, develop ] 7 | pull_request: 8 | branches: [ main, develop ] 9 | paths-ignore: 10 | - '**/*.md' 11 | 12 | jobs: 13 | CodeQL-Build: 14 | # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest 15 | runs-on: ubuntu-latest 16 | 17 | permissions: 18 | # required for all workflows 19 | security-events: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | # Initializes the CodeQL tools for scanning. 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v3 28 | with: 29 | languages: javascript 30 | config: | 31 | paths-ignore: 32 | - '**/test' 33 | 34 | - name: Perform CodeQL Analysis 35 | uses: github/codeql-action/analyze@v3 36 | 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /preact 4 | /dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present Camunda Services GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @bpmn-io/properties-panel 2 | 3 | [![CI](https://github.com/bpmn-io/properties-panel/workflows/CI/badge.svg)](https://github.com/bpmn-io/properties-panel/actions?query=workflow%3ACI) 4 | 5 | 6 | Library for creating bpmn-io properties panels. 7 | 8 | ## Resources 9 | 10 | * [Changelog](./CHANGELOG.md) 11 | 12 | ## Build and Run 13 | 14 | Prepare the project by installing all dependencies: 15 | 16 | ```sh 17 | npm install 18 | ``` 19 | 20 | Then, depending on your use-case you may run any of the following commands: 21 | 22 | ```sh 23 | # build the library and run all tests 24 | npm run all 25 | 26 | # run the full development setup 27 | npm run dev 28 | ``` 29 | 30 | Expose an environment variable `TEST_BROWSERS=(Chrome|Firefox|IE)` to execute the tests in a non-headless browser. 31 | 32 | 33 | ## License 34 | 35 | MIT 36 | -------------------------------------------------------------------------------- /assets/properties-panel.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Compatibility with @bpmn-io/properties-panel < v3.4.0 3 | */ 4 | @import '../dist/assets/properties-panel.css' -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | 3 | parsers: 4 | javascript: 5 | enable_partials: yes -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import bpmnIoPlugin from 'eslint-plugin-bpmn-io'; 2 | import reactHooks from 'eslint-plugin-react-hooks'; 3 | import eslintImport from 'eslint-plugin-import'; 4 | 5 | const files = { 6 | build: [ 7 | '*.js', 8 | '*.mjs' 9 | ], 10 | test: [ 11 | 'test/**/*.js' 12 | ], 13 | ignored: [ 14 | 'preact', 15 | 'dist' 16 | ] 17 | }; 18 | 19 | 20 | export default [ 21 | { 22 | ignores: files.ignored, 23 | }, 24 | 25 | // build 26 | ...bpmnIoPlugin.configs.node.map(config => { 27 | return { 28 | ...config, 29 | files: files.build 30 | }; 31 | }), 32 | 33 | // lib + test 34 | ...bpmnIoPlugin.configs.browser.map(config => { 35 | return { 36 | ...config, 37 | ignores: files.build 38 | }; 39 | }), 40 | ...bpmnIoPlugin.configs.jsx.map(config => { 41 | return { 42 | ...config, 43 | ignores: files.build 44 | }; 45 | }), 46 | { 47 | plugins: { 48 | 'react-hooks': reactHooks, 49 | 'import': eslintImport 50 | }, 51 | rules: { 52 | ...reactHooks.configs.recommended.rules, 53 | 'import/first': 'error', 54 | 'import/no-amd': 'error', 55 | 'import/no-webpack-loader-syntax': 'error', 56 | 'react-hooks/exhaustive-deps': 'off', 57 | 'react/display-name': 'off', 58 | 'react/no-unknown-property': 'off', 59 | }, 60 | }, 61 | 62 | // test 63 | ...bpmnIoPlugin.configs.mocha.map(config => { 64 | return { 65 | ...config, 66 | files: files.test 67 | }; 68 | }), 69 | { 70 | languageOptions: { 71 | globals: { 72 | sinon: true, 73 | require: true, 74 | global: true 75 | }, 76 | }, 77 | files: files.test 78 | } 79 | ]; -------------------------------------------------------------------------------- /karma.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const path = require('path'); 4 | const { DefinePlugin } = require('webpack'); 5 | 6 | const babelConfig = require('./.babelrc'); 7 | 8 | const basePath = '.'; 9 | 10 | // configures browsers to run test against 11 | // any of [ 'ChromeHeadless', 'Chrome', 'Firefox', 'IE', 'PhantomJS' ] 12 | const browsers = (process.env.TEST_BROWSERS || 'ChromeHeadless').split(','); 13 | 14 | const singleStart = process.env.SINGLE_START; 15 | 16 | const coverage = process.env.COVERAGE; 17 | 18 | const absoluteBasePath = path.resolve(path.join(__dirname, basePath)); 19 | 20 | // use puppeteer provided Chrome for testing 21 | process.env.CHROME_BIN = require('puppeteer').executablePath(); 22 | 23 | const suite = coverage ? 'test/coverageBundle.js' : 'test/testBundle.js'; 24 | 25 | module.exports = function(karma) { 26 | 27 | const config = { 28 | 29 | basePath, 30 | 31 | frameworks: [ 32 | 'webpack', 33 | 'mocha', 34 | 'sinon-chai' 35 | ], 36 | 37 | files: [ 38 | suite 39 | ], 40 | 41 | preprocessors: { 42 | [ suite ]: [ 'webpack', 'env' ] 43 | }, 44 | 45 | reporters: [ 'progress' ].concat(coverage ? 'coverage' : []), 46 | 47 | coverageReporter: { 48 | reporters: [ 49 | { type: 'lcov', subdir: '.' }, 50 | ] 51 | }, 52 | 53 | browsers, 54 | 55 | singleRun: true, 56 | autoWatch: false, 57 | 58 | webpack: { 59 | mode: 'development', 60 | module: { 61 | rules: [ 62 | { 63 | test: /\.(css)$/, 64 | use: 'raw-loader' 65 | }, 66 | { 67 | test: /\.m?js$/, 68 | exclude: /node_modules/, 69 | use: { 70 | loader: 'babel-loader', 71 | options: { 72 | ...babelConfig, 73 | plugins: babelConfig.plugins.concat(coverage ? ( 74 | [ 75 | [ 'istanbul', { 76 | include: [ 77 | 'src/**' 78 | ] 79 | } ] 80 | ] 81 | ) : []) 82 | } 83 | } 84 | } 85 | ] 86 | }, 87 | plugins: [ 88 | new DefinePlugin({ 89 | 90 | // @barmac: process.env has to be defined to make @testing-library/preact work 91 | 'process.env': {} 92 | }) 93 | ], 94 | resolve: { 95 | mainFields: [ 96 | 'browser', 97 | 'module', 98 | 'main' 99 | ], 100 | alias: { 101 | 'preact': '/preact', 102 | 'react': '/preact/compat', 103 | 'react-dom': '/preact/compat' 104 | }, 105 | modules: [ 106 | 'node_modules', 107 | absoluteBasePath 108 | ], 109 | fallback: { 110 | 'crypto': false 111 | } 112 | }, 113 | devtool: 'eval-source-map' 114 | } 115 | }; 116 | 117 | if (singleStart) { 118 | config.browsers = [].concat(config.browsers, 'Debug'); 119 | config.envPreprocessor = [].concat(config.envPreprocessor || [], 'SINGLE_START'); 120 | } 121 | 122 | karma.set(config); 123 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bpmn-io/properties-panel", 3 | "version": "3.27.3", 4 | "description": "Library for creating bpmn-io properties panels.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.esm.js", 7 | "files": [ 8 | "dist", 9 | "assets", 10 | "preact" 11 | ], 12 | "scripts": { 13 | "all": "run-s lint clean build test", 14 | "clean": "del-cli preact dist", 15 | "build": "rollup -c --bundleConfigAsCjs", 16 | "build:watch": "npm run build -- --watch", 17 | "lint": "eslint .", 18 | "dev": "npm test -- --auto-watch --no-single-run", 19 | "test": "karma start karma.config.js", 20 | "prepare": "run-s build" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/bpmn-io/properties-panel.git" 25 | }, 26 | "publishConfig": { 27 | "access": "public" 28 | }, 29 | "engines": { 30 | "node": "*" 31 | }, 32 | "author": "Niklas Kiefer (https://github.com/pinussilvestrus)", 33 | "contributors": [ 34 | { 35 | "name": "bpmn.io contributors", 36 | "url": "https://github.com/bpmn-io" 37 | } 38 | ], 39 | "license": "MIT", 40 | "dependencies": { 41 | "@bpmn-io/feel-editor": "^1.10.1", 42 | "@codemirror/view": "^6.28.1", 43 | "classnames": "^2.3.1", 44 | "feelers": "^1.4.0", 45 | "focus-trap": "^7.5.2", 46 | "min-dash": "^4.1.1", 47 | "min-dom": "^4.0.3" 48 | }, 49 | "devDependencies": { 50 | "@babel/core": "^7.26.0", 51 | "@babel/plugin-transform-react-jsx": "^7.25.9", 52 | "@rollup/plugin-babel": "^6.0.4", 53 | "@rollup/plugin-json": "^6.1.0", 54 | "@rollup/plugin-node-resolve": "^16.0.0", 55 | "@testing-library/preact": "^3.2.4", 56 | "axe-core": "^4.10.3", 57 | "babel-loader": "^10.0.0", 58 | "babel-plugin-inline-react-svg": "^2.0.2", 59 | "babel-plugin-istanbul": "^7.0.0", 60 | "babel-plugin-module-resolver": "^5.0.2", 61 | "chai": "^4.5.0", 62 | "copy-webpack-plugin": "^13.0.0", 63 | "cross-env": "^7.0.3", 64 | "del-cli": "^6.0.0", 65 | "diagram-js": "^15.1.0", 66 | "eslint": "^9.18.0", 67 | "eslint-plugin-bpmn-io": "^2.1.0", 68 | "eslint-plugin-import": "^2.30.0", 69 | "eslint-plugin-react-hooks": "^5.1.0", 70 | "karma": "^6.4.4", 71 | "karma-chrome-launcher": "^3.2.0", 72 | "karma-coverage": "^2.2.1", 73 | "karma-debug-launcher": "^0.0.5", 74 | "karma-env-preprocessor": "^0.1.1", 75 | "karma-mocha": "^2.0.1", 76 | "karma-sinon-chai": "^2.0.2", 77 | "karma-webpack": "^5.0.1", 78 | "mocha": "^10.8.2", 79 | "mocha-test-container-support": "^0.2.0", 80 | "npm-run-all2": "^8.0.0", 81 | "preact": "^10.19.3", 82 | "puppeteer": "^24.0.0", 83 | "raw-loader": "^4.0.2", 84 | "replace-in-file": "^7.0.2", 85 | "rollup": "^4.9.1", 86 | "rollup-plugin-copy": "^3.5.0", 87 | "sinon": "^17.0.1", 88 | "sinon-chai": "^3.7.0", 89 | "webpack": "^5.97.1" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>bpmn-io/renovate-config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path, { parse as parsePath, relative as relativePath } from 'path'; 2 | import { replaceInFile } from 'replace-in-file'; 3 | 4 | import babel from '@rollup/plugin-babel'; 5 | import copy from 'rollup-plugin-copy'; 6 | import json from '@rollup/plugin-json'; 7 | import resolve from '@rollup/plugin-node-resolve'; 8 | 9 | import pkg from './package.json'; 10 | import babelConfig from './.babelrc.json'; 11 | 12 | const nonbundledDependencies = Object.keys({ ...pkg.dependencies }); 13 | 14 | export default [ 15 | { 16 | input: 'src/index.js', 17 | output: [ 18 | { 19 | sourcemap: true, 20 | format: 'commonjs', 21 | file: pkg.main 22 | }, 23 | { 24 | sourcemap: true, 25 | format: 'esm', 26 | file: pkg.module 27 | } 28 | ], 29 | external: [ 30 | ...nonbundledDependencies, 31 | 32 | // exclude local preact copy to share it with extensions 33 | /\.\/preact/ 34 | ], 35 | plugins: [ 36 | copy({ 37 | 38 | // hook name provided to make sure next plugin has files to replace 39 | hook: 'buildStart', 40 | targets: [ 41 | { src: 'node_modules/preact', dest: '.' }, 42 | { src: 'src/assets', dest: 'dist' } 43 | ] 44 | }), 45 | rewirePreactSubpackages(), 46 | babel({ ...babelConfig, babelHelpers: 'bundled' }), 47 | json(), 48 | resolve() 49 | ] 50 | } 51 | ]; 52 | 53 | /** 54 | * Monkey-patch preact subpackages to import from the local package via relative path. 55 | */ 56 | function rewirePreactSubpackages() { 57 | return { 58 | async buildEnd() { 59 | await replaceInFile({ 60 | files: './preact/**/*.{js,mjs,js.map}', 61 | from: [ /(import\s*['"])preact([/'"])/g, /(from\s*['"])preact([/'"])/g, /(require\(['"])preact([/'"])/g ], 62 | to: function(...args) { 63 | const importGroup = args[1], 64 | afterImport = args[2], 65 | filePath = args.pop(); 66 | 67 | const { dir } = parsePath(filePath); 68 | const posixPathToPreact = relativePath(dir, './preact').split(path.sep).join(path.posix.sep); 69 | return `${importGroup}${posixPathToPreact}${afterImport}`; 70 | } 71 | }); 72 | } 73 | }; 74 | } -------------------------------------------------------------------------------- /src/components/DropdownButton.js: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useRef, 4 | useState 5 | } from 'preact/hooks'; 6 | 7 | import classnames from 'classnames'; 8 | 9 | /** 10 | * 11 | * @param {object} props 12 | * @param {string} [props.class] 13 | * @param {import('preact').Component[]} [props.menuItems] 14 | * @returns 15 | */ 16 | export function DropdownButton(props) { 17 | const { 18 | class: className, 19 | children, 20 | menuItems = [] 21 | } = props; 22 | 23 | const dropdownRef = useRef(null); 24 | const menuRef = useRef(null); 25 | 26 | const [ open, setOpen ] = useState(false); 27 | const close = () => setOpen(false); 28 | 29 | function onDropdownToggle(event) { 30 | if (menuRef.current && menuRef.current.contains(event.target)) { 31 | return; 32 | } 33 | 34 | event.stopPropagation(); 35 | 36 | setOpen(open => !open); 37 | } 38 | 39 | function onActionClick(event, action) { 40 | event.stopPropagation(); 41 | 42 | close(); 43 | action(); 44 | } 45 | 46 | useGlobalClick([ dropdownRef.current ], () => close()); 47 | 48 | return ( 49 |
54 | { children } 55 |
56 | { menuItems.map((item, index) => ( 57 | 58 | )) } 59 |
60 |
61 | ); 62 | } 63 | 64 | function MenuItem({ item, onClick }) { 65 | if (item.separator) { 66 | return
; 67 | } 68 | 69 | if (item.action) { 70 | return (); 77 | } 78 | 79 | return
82 | {item.entry} 83 |
; 84 | } 85 | 86 | /** 87 | * 88 | * @param {Array} ignoredElements 89 | * @param {Function} callback 90 | */ 91 | function useGlobalClick(ignoredElements, callback) { 92 | useEffect(() => { 93 | 94 | /** 95 | * @param {MouseEvent} event 96 | */ 97 | function listener(event) { 98 | if (ignoredElements.some(element => element && element.contains(event.target))) { 99 | return; 100 | } 101 | 102 | callback(); 103 | } 104 | 105 | document.addEventListener('click', listener, { capture: true }); 106 | 107 | return () => document.removeEventListener('click', listener, { capture: true }); 108 | }, [ ...ignoredElements, callback ]); 109 | } 110 | -------------------------------------------------------------------------------- /src/components/Group.js: -------------------------------------------------------------------------------- 1 | import { 2 | useCallback, 3 | useContext, 4 | useEffect, 5 | useRef, 6 | useState 7 | } from 'preact/hooks'; 8 | 9 | import Tooltip from './entries/Tooltip'; 10 | 11 | import classnames from 'classnames'; 12 | 13 | import { 14 | query as domQuery 15 | } from 'min-dom'; 16 | 17 | import { 18 | isFunction 19 | } from 'min-dash'; 20 | 21 | import { 22 | useErrors, 23 | useLayoutState 24 | } from '../hooks'; 25 | 26 | import { PropertiesPanelContext } from '../context'; 27 | 28 | import { useStickyIntersectionObserver } from '../hooks'; 29 | 30 | import { ArrowIcon } from './icons'; 31 | 32 | /** 33 | * @param {import('../PropertiesPanel').GroupDefinition} props 34 | */ 35 | export default function Group(props) { 36 | const { 37 | element, 38 | entries = [], 39 | id, 40 | label, 41 | shouldOpen = false, 42 | } = props; 43 | 44 | const groupRef = useRef(null); 45 | 46 | const [ open, setOpen ] = useLayoutState( 47 | [ 'groups', id, 'open' ], 48 | shouldOpen 49 | ); 50 | 51 | const onShow = useCallback(() => setOpen(true), [ setOpen ]); 52 | 53 | const toggleOpen = () => setOpen(!open); 54 | 55 | const [ edited, setEdited ] = useState(false); 56 | 57 | const [ sticky, setSticky ] = useState(false); 58 | 59 | // set edited state depending on all entries 60 | useEffect(() => { 61 | 62 | // TODO(@barmac): replace with CSS when `:has()` is supported in all major browsers, or rewrite as in https://github.com/camunda/camunda-modeler/issues/3815#issuecomment-1733038161 63 | const scheduled = requestAnimationFrame(() => { 64 | const hasOneEditedEntry = entries.find(entry => { 65 | const { 66 | id, 67 | isEdited 68 | } = entry; 69 | 70 | const entryNode = domQuery(`[data-entry-id="${id}"]`); 71 | 72 | if (!isFunction(isEdited) || !entryNode) { 73 | return false; 74 | } 75 | 76 | const inputNode = domQuery('.bio-properties-panel-input', entryNode); 77 | 78 | return isEdited(inputNode); 79 | }); 80 | 81 | setEdited(hasOneEditedEntry); 82 | }); 83 | 84 | return () => cancelAnimationFrame(scheduled); 85 | }, [ entries, setEdited ]); 86 | 87 | // set error state depending on all entries 88 | const allErrors = useErrors(); 89 | const hasErrors = entries.some(entry => allErrors[entry.id]); 90 | 91 | // set css class when group is sticky to top 92 | useStickyIntersectionObserver(groupRef, 'div.bio-properties-panel-scroll-container', setSticky); 93 | 94 | const propertiesPanelContext = { 95 | ...useContext(PropertiesPanelContext), 96 | onShow 97 | }; 98 | 99 | return
100 |
106 |
111 | 112 | { label } 113 | 114 |
115 |
116 | { 117 | 121 | } 122 | 129 |
130 |
131 |
135 | 136 | { 137 | entries.map(entry => { 138 | const { 139 | component: Component, 140 | id 141 | } = entry; 142 | 143 | return ( 144 | 148 | ); 149 | }) 150 | } 151 | 152 |
153 |
; 154 | } 155 | 156 | function DataMarker(props) { 157 | const { 158 | edited, 159 | hasErrors 160 | } = props; 161 | 162 | if (hasErrors) { 163 | return ( 164 |
165 | ); 166 | } 167 | 168 | if (edited) { 169 | return ( 170 |
171 | ); 172 | } 173 | return null; 174 | } -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import { ExternalLinkIcon } from './icons'; 2 | 3 | /** 4 | * @typedef { { 5 | * getElementLabel: (element: object) => string, 6 | * getTypeLabel: (element: object) => string, 7 | * getElementIcon: (element: object) => import('preact').Component, 8 | * getDocumentationRef: (element: object) => string 9 | * } } HeaderProvider 10 | */ 11 | 12 | /** 13 | * @param {Object} props 14 | * @param {Object} props.element, 15 | * @param {HeaderProvider} props.headerProvider 16 | */ 17 | export default function Header(props) { 18 | 19 | const { 20 | element, 21 | headerProvider 22 | } = props; 23 | 24 | const { 25 | getElementIcon, 26 | getDocumentationRef, 27 | getElementLabel, 28 | getTypeLabel, 29 | } = headerProvider; 30 | 31 | const label = getElementLabel(element); 32 | const type = getTypeLabel(element); 33 | const documentationRef = getDocumentationRef && getDocumentationRef(element); 34 | 35 | const ElementIcon = getElementIcon(element); 36 | 37 | return (
38 |
39 | { ElementIcon && } 40 |
41 |
42 |
{ type }
43 | { label ? 44 |
{ label }
: 45 | null 46 | } 47 |
48 |
49 | { documentationRef ? 50 | 56 | 57 | : 58 | null 59 | } 60 |
61 |
); 62 | } -------------------------------------------------------------------------------- /src/components/HeaderButton.js: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | 3 | export function HeaderButton(props) { 4 | const { 5 | children = null, 6 | class: classname, 7 | onClick = () => {}, 8 | ...otherProps 9 | } = props; 10 | 11 | return ; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/ListGroup.js: -------------------------------------------------------------------------------- 1 | import { 2 | useCallback, 3 | useContext, 4 | useEffect, 5 | useRef, 6 | useState 7 | } from 'preact/hooks'; 8 | 9 | import Tooltip from './entries/Tooltip'; 10 | 11 | import classnames from 'classnames'; 12 | 13 | import { 14 | useErrors, 15 | useLayoutState, 16 | usePrevious 17 | } from '../hooks'; 18 | 19 | import ListItem from './ListItem'; 20 | 21 | import { 22 | ArrowIcon, 23 | CreateIcon 24 | } from './icons'; 25 | 26 | import { PropertiesPanelContext } from '../context'; 27 | 28 | import { useStickyIntersectionObserver } from '../hooks'; 29 | 30 | import translateFallback from './util/translateFallback'; 31 | 32 | const noop = () => {}; 33 | 34 | /** 35 | * @param {import('../PropertiesPanel').ListGroupDefinition} props 36 | */ 37 | export default function ListGroup(props) { 38 | const { 39 | add, 40 | element, 41 | id, 42 | items, 43 | label, 44 | shouldOpen = false, 45 | translate = translateFallback 46 | } = props; 47 | 48 | useEffect(() => { 49 | if (props.shouldSort != undefined) { 50 | console.warn('the property \'shouldSort\' is no longer supported'); 51 | } 52 | }, [ props.shouldSort ]); 53 | 54 | const groupRef = useRef(null); 55 | 56 | const [ open, setOpen ] = useLayoutState( 57 | [ 'groups', id, 'open' ], 58 | shouldOpen 59 | ); 60 | 61 | const [ sticky, setSticky ] = useState(false); 62 | 63 | const onShow = useCallback(() => setOpen(true), [ setOpen ]); 64 | 65 | const [ localItems, setLocalItems ] = useState([]); 66 | 67 | // Flag to mark that add button was clicked in the last render cycle 68 | const [ addTriggered, setAddTriggered ] = useState(false); 69 | 70 | const prevElement = usePrevious(element); 71 | 72 | const toggleOpen = useCallback(() => setOpen(!open), [ open ]); 73 | 74 | const openItemIds = (element === prevElement && open && addTriggered) 75 | ? getNewItemIds(items, localItems) 76 | : []; 77 | 78 | // reset local state after items changed 79 | useEffect(() => { 80 | setLocalItems(items); 81 | setAddTriggered(false); 82 | }, [ items ]); 83 | 84 | // set css class when group is sticky to top 85 | useStickyIntersectionObserver(groupRef, 'div.bio-properties-panel-scroll-container', setSticky); 86 | 87 | const hasItems = !!items.length; 88 | 89 | const propertiesPanelContext = { 90 | ...useContext(PropertiesPanelContext), 91 | onShow 92 | }; 93 | 94 | const handleAddClick = e => { 95 | setAddTriggered(true); 96 | setOpen(true); 97 | 98 | add(e); 99 | }; 100 | 101 | const allErrors = useErrors(); 102 | const hasError = items.some(item => { 103 | if (allErrors[item.id]) { 104 | return true; 105 | } 106 | 107 | if (!item.entries) { 108 | return; 109 | } 110 | 111 | // also check if the error is nested, e.g. for name-value entries 112 | return item.entries.some(entry => allErrors[entry.id]); 113 | }); 114 | 115 | 116 | return
117 |
125 |
130 | 131 | { label } 132 | 133 |
134 |
135 | { 136 | add 137 | ? ( 138 | 152 | ) 153 | : null 154 | } 155 | { 156 | hasItems 157 | ? ( 158 |
167 | { items.length } 168 |
169 | ) 170 | : null 171 | } 172 | { 173 | hasItems 174 | ? ( 175 | 182 | ) 183 | : null 184 | } 185 |
186 |
187 |
191 | 192 | 193 | { 194 | items.map((item, index) => { 195 | if (!item) { 196 | return; 197 | } 198 | 199 | const { id } = item; 200 | 201 | // if item was added, open it 202 | // existing items will not be affected as autoOpen 203 | // is only applied on first render 204 | const autoOpen = openItemIds.includes(item.id); 205 | 206 | return ( 207 | 214 | ); 215 | }) 216 | } 217 | 218 |
219 |
; 220 | } 221 | 222 | 223 | function getNewItemIds(newItems, oldItems) { 224 | const newIds = newItems.map(item => item.id); 225 | const oldIds = oldItems.map(item => item.id); 226 | 227 | return newIds.filter(itemId => !oldIds.includes(itemId)); 228 | } -------------------------------------------------------------------------------- /src/components/ListItem.js: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect 3 | } from 'preact/hooks'; 4 | 5 | import { 6 | query as domQuery 7 | } from 'min-dom'; 8 | 9 | import { isFunction } from 'min-dash'; 10 | 11 | import CollapsibleEntry from './entries/Collapsible'; 12 | import translateFallback from './util/translateFallback'; 13 | 14 | /** 15 | * @param {import('../PropertiesPanel').ListItemDefinition} props 16 | */ 17 | export default function ListItem(props) { 18 | const { 19 | autoFocusEntry, 20 | autoOpen, 21 | translate = translateFallback 22 | } = props; 23 | 24 | // focus specified entry on auto open 25 | useEffect(() => { 26 | if (autoOpen && autoFocusEntry) { 27 | const entry = domQuery(`[data-entry-id="${autoFocusEntry}"]`); 28 | 29 | const focusableInput = domQuery('.bio-properties-panel-input', entry); 30 | 31 | if (focusableInput) { 32 | 33 | if (isFunction(focusableInput.select)) { 34 | focusableInput.select(); 35 | } else if (isFunction(focusableInput.focus)) { 36 | focusableInput.focus(); 37 | } 38 | 39 | focusableInput.scrollIntoView(); 40 | } 41 | } 42 | }, [ autoOpen, autoFocusEntry ]); 43 | 44 | 45 | return ( 46 |
47 | 52 |
53 | ); 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Placeholder.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef { { 3 | * text: (element: object) => string, 4 | * icon?: (element: Object) => import('preact').Component 5 | * } } PlaceholderDefinition 6 | * 7 | * @param { PlaceholderDefinition } props 8 | */ 9 | export default function Placeholder(props) { 10 | const { 11 | text, 12 | icon: Icon 13 | } = props; 14 | 15 | return ( 16 |
17 |
18 | { Icon && } 19 |

{ text }

20 |
21 |
22 | ); 23 | } -------------------------------------------------------------------------------- /src/components/Popup.js: -------------------------------------------------------------------------------- 1 | import { createPortal, forwardRef } from 'preact/compat'; 2 | 3 | import { useEffect, useMemo, useRef } from 'preact/hooks'; 4 | 5 | import classNames from 'classnames'; 6 | 7 | import { query as domQuery } from 'min-dom'; 8 | 9 | import * as focusTrap from 'focus-trap'; 10 | 11 | import { DragIcon, CloseIcon } from './icons'; 12 | 13 | import { createDragger } from './util/dragger'; 14 | import { useEvent } from '../hooks/useEvent'; 15 | 16 | 17 | const noop = () => {}; 18 | 19 | /** 20 | * A generic popup component. 21 | * 22 | * @param {Object} props 23 | * @param {HTMLElement} [props.container] 24 | * @param {string} [props.className] 25 | * @param {boolean} [props.delayInitialFocus] 26 | * @param {{x: number, y: number}} [props.position] 27 | * @param {number} [props.width] 28 | * @param {number} [props.height] 29 | * @param {Function} props.onClose 30 | * @param {Function} [props.onPostActivate] 31 | * @param {Function} [props.onPostDeactivate] 32 | * @param {boolean} [props.returnFocus] 33 | * @param {boolean} [props.closeOnEscape] 34 | * @param {string} props.title 35 | * @param {Ref} [ref] 36 | */ 37 | function PopupComponent(props, globalRef) { 38 | 39 | const { 40 | container, 41 | className, 42 | delayInitialFocus, 43 | position, 44 | width, 45 | height, 46 | onClose, 47 | onPostActivate = noop, 48 | onPostDeactivate = noop, 49 | returnFocus = true, 50 | closeOnEscape = true, 51 | title 52 | } = props; 53 | 54 | const focusTrapRef = useRef(null); 55 | const localRef = useRef(null); 56 | const popupRef = globalRef || localRef; 57 | 58 | const containerNode = useMemo(() => getContainerNode(container), [ container ]); 59 | 60 | const handleKeydown = event => { 61 | 62 | // do not allow keyboard events to bubble 63 | event.stopPropagation(); 64 | 65 | if (closeOnEscape && event.key === 'Escape') { 66 | onClose(); 67 | } 68 | }; 69 | 70 | // re-activate focus trap on focus 71 | const handleFocus = () => { 72 | if (focusTrapRef.current) { 73 | focusTrapRef.current.activate(); 74 | } 75 | }; 76 | 77 | let style = {}; 78 | 79 | if (position) { 80 | style = { 81 | ...style, 82 | top: position.top + 'px', 83 | left: position.left + 'px' 84 | }; 85 | } 86 | 87 | if (width) { 88 | style.width = width + 'px'; 89 | } 90 | 91 | if (height) { 92 | style.height = height + 'px'; 93 | } 94 | 95 | useEffect(() => { 96 | if (popupRef.current) { 97 | popupRef.current.addEventListener('focusin', handleFocus); 98 | } 99 | 100 | return () => { popupRef.current.removeEventListener('focusin', handleFocus); }; 101 | }, [ popupRef ]); 102 | 103 | useEffect(() => { 104 | if (popupRef.current) { 105 | focusTrapRef.current = focusTrap.createFocusTrap(popupRef.current, { 106 | clickOutsideDeactivates: true, 107 | delayInitialFocus, 108 | fallbackFocus: popupRef.current, 109 | onPostActivate, 110 | onPostDeactivate, 111 | returnFocusOnDeactivate: returnFocus 112 | }); 113 | 114 | focusTrapRef.current.activate(); 115 | } 116 | 117 | return () => focusTrapRef.current && focusTrapRef.current.deactivate(); 118 | }, [ popupRef ]); 119 | 120 | useEvent('propertiesPanel.detach', onClose); 121 | 122 | return createPortal( 123 | 131 | , containerNode || document.body 132 | ); 133 | } 134 | 135 | export const Popup = forwardRef(PopupComponent); 136 | 137 | Popup.Title = Title; 138 | Popup.Body = Body; 139 | Popup.Footer = Footer; 140 | 141 | function Title(props) { 142 | const { 143 | children, 144 | className, 145 | draggable, 146 | emit = () => {}, 147 | title, 148 | showCloseButton = false, 149 | closeButtonTooltip = 'Close popup', 150 | onClose, 151 | ...rest 152 | } = props; 153 | 154 | // we can't use state as we need to 155 | // manipulate this inside dragging events 156 | const context = useRef({ 157 | startPosition: null, 158 | newPosition: null 159 | }); 160 | 161 | const dragPreviewRef = useRef(); 162 | 163 | const titleRef = useRef(); 164 | 165 | const onMove = (event, delta) => { 166 | cancel(event); 167 | 168 | const { x: dx, y: dy } = delta; 169 | 170 | const newPosition = { 171 | x: context.current.startPosition.x + dx, 172 | y: context.current.startPosition.y + dy 173 | }; 174 | 175 | const popupParent = getPopupParent(titleRef.current); 176 | 177 | popupParent.style.top = newPosition.y + 'px'; 178 | popupParent.style.left = newPosition.x + 'px'; 179 | 180 | // notify interested parties 181 | emit('dragover', { newPosition, delta }); 182 | }; 183 | 184 | const onMoveStart = (event) => { 185 | 186 | // initialize drag handler 187 | const onDragStart = createDragger(onMove, dragPreviewRef.current); 188 | onDragStart(event); 189 | 190 | event.stopPropagation(); 191 | 192 | const popupParent = getPopupParent(titleRef.current); 193 | 194 | const bounds = popupParent.getBoundingClientRect(); 195 | context.current.startPosition = { 196 | x: bounds.left, 197 | y: bounds.top 198 | }; 199 | 200 | // notify interested parties 201 | emit('dragstart'); 202 | }; 203 | 204 | const onMoveEnd = () => { 205 | context.current.newPosition = null; 206 | 207 | // notify interested parties 208 | emit('dragend'); 209 | }; 210 | 211 | return ( 212 |
223 | { draggable && ( 224 | <> 225 |
226 |
227 | 228 |
229 | 230 | )} 231 |
{ title }
232 | { children } 233 | { showCloseButton && ( 234 | 240 | )} 241 |
242 | ); 243 | } 244 | 245 | function Body(props) { 246 | const { 247 | children, 248 | className, 249 | ...rest 250 | } = props; 251 | 252 | return ( 253 |
254 | { children } 255 |
256 | ); 257 | } 258 | 259 | function Footer(props) { 260 | const { 261 | children, 262 | className, 263 | ...rest 264 | } = props; 265 | 266 | return ( 267 |
268 | { props.children } 269 |
270 | ); 271 | } 272 | 273 | 274 | // helpers ////////////////////// 275 | 276 | function getPopupParent(node) { 277 | return node.closest('.bio-properties-panel-popup'); 278 | } 279 | 280 | function cancel(event) { 281 | event.preventDefault(); 282 | event.stopPropagation(); 283 | } 284 | 285 | function getContainerNode(node) { 286 | if (typeof node === 'string') { 287 | return domQuery(node); 288 | } 289 | 290 | return node; 291 | } -------------------------------------------------------------------------------- /src/components/entries/Checkbox.js: -------------------------------------------------------------------------------- 1 | import { 2 | useError, 3 | useShowEntryEvent 4 | } from '../../hooks'; 5 | 6 | import { 7 | useEffect, 8 | useState 9 | } from 'preact/hooks'; 10 | 11 | import Description from './Description'; 12 | import Tooltip from './Tooltip'; 13 | 14 | function Checkbox(props) { 15 | const { 16 | id, 17 | label, 18 | onChange, 19 | disabled, 20 | value = false, 21 | onFocus, 22 | onBlur, 23 | tooltip 24 | } = props; 25 | 26 | const [ localValue, setLocalValue ] = useState(value); 27 | 28 | const handleChangeCallback = ({ target }) => { 29 | onChange(target.checked); 30 | }; 31 | 32 | const handleChange = e => { 33 | handleChangeCallback(e); 34 | setLocalValue(e.target.value); 35 | }; 36 | 37 | useEffect(() => { 38 | if (value === localValue) { 39 | return; 40 | } 41 | 42 | setLocalValue(value); 43 | }, [ value ]); 44 | 45 | const ref = useShowEntryEvent(id); 46 | 47 | return ( 48 |
49 | 60 | 65 |
66 | ); 67 | } 68 | 69 | 70 | /** 71 | * @param {Object} props 72 | * @param {Object} props.element 73 | * @param {String} props.id 74 | * @param {String} props.description 75 | * @param {String} props.label 76 | * @param {Function} props.getValue 77 | * @param {Function} props.setValue 78 | * @param {Function} props.onFocus 79 | * @param {Function} props.onBlur 80 | * @param {string|import('preact').Component} props.tooltip 81 | * @param {boolean} [props.disabled] 82 | */ 83 | export default function CheckboxEntry(props) { 84 | const { 85 | element, 86 | id, 87 | description, 88 | label, 89 | getValue, 90 | setValue, 91 | disabled, 92 | onFocus, 93 | onBlur, 94 | tooltip 95 | } = props; 96 | 97 | const value = getValue(element); 98 | 99 | const error = useError(id); 100 | 101 | return ( 102 |
103 | 114 | { error &&
{ error }
} 115 | 116 |
117 | ); 118 | } 119 | 120 | export function isEdited(node) { 121 | return node && !!node.checked; 122 | } 123 | 124 | 125 | // helpers ///////////////// 126 | 127 | function prefixId(id) { 128 | return `bio-properties-panel-${ id }`; 129 | } 130 | -------------------------------------------------------------------------------- /src/components/entries/Collapsible.js: -------------------------------------------------------------------------------- 1 | import { 2 | useCallback, 3 | useContext, 4 | useState 5 | } from 'preact/hooks'; 6 | 7 | import classnames from 'classnames'; 8 | 9 | import { isFunction } from 'min-dash'; 10 | 11 | import { 12 | ArrowIcon, 13 | DeleteIcon, 14 | } from '../icons'; 15 | 16 | import { PropertiesPanelContext } from '../../context'; 17 | 18 | import translateFallback from '../util/translateFallback'; 19 | 20 | 21 | export default function CollapsibleEntry(props) { 22 | const { 23 | element, 24 | entries = [], 25 | id, 26 | label, 27 | open: shouldOpen, 28 | remove, 29 | translate = translateFallback 30 | } = props; 31 | 32 | const [ open, setOpen ] = useState(shouldOpen); 33 | 34 | const toggleOpen = () => setOpen(!open); 35 | 36 | const { onShow } = useContext(PropertiesPanelContext); 37 | 38 | const propertiesPanelContext = { 39 | ...useContext(PropertiesPanelContext), 40 | onShow: useCallback(() => { 41 | setOpen(true); 42 | 43 | if (isFunction(onShow)) { 44 | onShow(); 45 | } 46 | }, [ onShow, setOpen ]) 47 | }; 48 | 49 | 50 | const placeholderLabel = translate(''); 51 | 52 | return ( 53 |
59 |
60 |
66 | { label || placeholderLabel } 67 |
68 | 75 | { 76 | remove 77 | ? 78 | ( 79 | 82 | ) 83 | : null 84 | } 85 |
86 |
90 | 91 | { 92 | entries.map(entry => { 93 | const { 94 | component: Component, 95 | id 96 | } = entry; 97 | 98 | return ( 99 | 103 | ); 104 | }) 105 | } 106 | 107 |
108 |
109 | ); 110 | } -------------------------------------------------------------------------------- /src/components/entries/Description.js: -------------------------------------------------------------------------------- 1 | import { 2 | useDescriptionContext 3 | } from '../../hooks'; 4 | 5 | /** 6 | * @param {Object} props 7 | * @param {Object} props.element 8 | * @param {String} props.forId - id of the entry the description is used for 9 | * @param {String} props.value 10 | */ 11 | export default function Description(props) { 12 | const { 13 | element, 14 | forId, 15 | value 16 | } = props; 17 | 18 | const contextDescription = useDescriptionContext(forId, element); 19 | 20 | const description = value || contextDescription; 21 | 22 | if (description) { 23 | return ( 24 |
25 | { description } 26 |
27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/entries/FEEL/FeelEditor.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; 3 | import { forwardRef } from 'preact/compat'; 4 | 5 | import FeelEditor from '@bpmn-io/feel-editor'; 6 | 7 | import { EditorView, lineNumbers } from '@codemirror/view'; 8 | 9 | import { useStaticCallback } from '../../../hooks'; 10 | 11 | import { PopupIcon } from '../../icons'; 12 | 13 | const noop = () => {}; 14 | 15 | /** 16 | * Buffer `.focus()` calls while the editor is not initialized. 17 | * Set Focus inside when the editor is ready. 18 | */ 19 | const useBufferedFocus = function(editor, ref) { 20 | 21 | const [ buffer, setBuffer ] = useState(undefined); 22 | 23 | ref.current = useMemo(() => ({ 24 | focus: (offset) => { 25 | if (editor) { 26 | editor.focus(offset); 27 | } else { 28 | if (typeof offset === 'undefined') { 29 | offset = Infinity; 30 | } 31 | setBuffer(offset); 32 | } 33 | } 34 | }), [ editor ]); 35 | 36 | useEffect(() => { 37 | if (typeof buffer !== 'undefined' && editor) { 38 | editor.focus(buffer); 39 | setBuffer(false); 40 | } 41 | }, [ editor, buffer ]); 42 | }; 43 | 44 | const CodeEditor = forwardRef((props, ref) => { 45 | 46 | const { 47 | contentAttributes, 48 | enableGutters, 49 | value, 50 | onInput, 51 | onFeelToggle = noop, 52 | onLint = noop, 53 | onPopupOpen = noop, 54 | placeholder, 55 | popupOpen, 56 | disabled, 57 | tooltipContainer, 58 | variables 59 | } = props; 60 | 61 | const inputRef = useRef(); 62 | const [ editor, setEditor ] = useState(); 63 | const [ localValue, setLocalValue ] = useState(value || ''); 64 | 65 | useBufferedFocus(editor, ref); 66 | 67 | const handleInput = useStaticCallback(newValue => { 68 | onInput(newValue); 69 | setLocalValue(newValue); 70 | }); 71 | 72 | useEffect(() => { 73 | 74 | let editor; 75 | 76 | /* Trigger FEEL toggle when 77 | * 78 | * - `backspace` is pressed 79 | * - AND the cursor is at the beginning of the input 80 | */ 81 | const onKeyDown = e => { 82 | if (e.key !== 'Backspace' || !editor) { 83 | return; 84 | } 85 | 86 | const selection = editor.getSelection(); 87 | const range = selection.ranges[selection.mainIndex]; 88 | 89 | if (range.from === 0 && range.to === 0) { 90 | onFeelToggle(); 91 | } 92 | }; 93 | 94 | editor = new FeelEditor({ 95 | container: inputRef.current, 96 | onChange: handleInput, 97 | onKeyDown: onKeyDown, 98 | onLint: onLint, 99 | placeholder: placeholder, 100 | tooltipContainer: tooltipContainer, 101 | value: localValue, 102 | variables: variables, 103 | extensions: [ 104 | ...enableGutters ? [ lineNumbers() ] : [], 105 | EditorView.lineWrapping 106 | ], 107 | contentAttributes 108 | }); 109 | 110 | setEditor( 111 | editor 112 | ); 113 | 114 | return () => { 115 | onLint([]); 116 | inputRef.current.innerHTML = ''; 117 | setEditor(null); 118 | }; 119 | }, []); 120 | 121 | useEffect(() => { 122 | if (!editor) { 123 | return; 124 | } 125 | 126 | if (value === localValue) { 127 | return; 128 | } 129 | 130 | editor.setValue(value); 131 | setLocalValue(value); 132 | }, [ value ]); 133 | 134 | useEffect(() => { 135 | if (!editor) { 136 | return; 137 | } 138 | 139 | editor.setVariables(variables); 140 | }, [ variables ]); 141 | 142 | useEffect(() => { 143 | if (!editor) { 144 | return; 145 | } 146 | 147 | editor.setPlaceholder(placeholder); 148 | }, [ placeholder ]); 149 | 150 | const handleClick = () => { 151 | ref.current.focus(); 152 | }; 153 | 154 | return
159 |
Opened in editor
160 |
166 | 171 |
; 172 | }); 173 | 174 | export default CodeEditor; -------------------------------------------------------------------------------- /src/components/entries/FEEL/FeelIcon.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { FeelIcon as FeelIconSvg } from '../../icons'; 3 | 4 | const noop = () => {}; 5 | 6 | /** 7 | * @param {Object} props 8 | * @param {Object} props.label 9 | * @param {String} props.feel 10 | */ 11 | export default function FeelIcon(props) { 12 | 13 | const { 14 | feel = false, 15 | active, 16 | disabled = false, 17 | onClick = noop 18 | } = props; 19 | 20 | const feelRequiredLabel = 'FEEL expression is mandatory'; 21 | const feelOptionalLabel = `Click to ${active ? 'remove' : 'set a'} dynamic value with FEEL expression`; 22 | 23 | const handleClick = e => { 24 | onClick(e); 25 | 26 | // when pointer event was created from keyboard, keep focus on button 27 | if (!e.pointerType) { 28 | e.stopPropagation(); 29 | } 30 | }; 31 | 32 | return ( 33 | 46 | ); 47 | } -------------------------------------------------------------------------------- /src/components/entries/FEEL/FeelIndicator.js: -------------------------------------------------------------------------------- 1 | export function FeelIndicator(props) { 2 | const { 3 | active 4 | } = props; 5 | 6 | if (!active) { 7 | return null; 8 | } 9 | 10 | return 11 | = 12 | ; 13 | } -------------------------------------------------------------------------------- /src/components/entries/FEEL/FeelPopup.js: -------------------------------------------------------------------------------- 1 | import { 2 | useCallback, 3 | useEffect, 4 | useRef, 5 | useState 6 | } from 'preact/hooks'; 7 | 8 | import { FeelPopupContext } from './context'; 9 | 10 | import { usePrevious } from '../../../hooks'; 11 | 12 | import { Popup } from '../../Popup'; 13 | 14 | import CodeEditor from './FeelEditor'; 15 | 16 | import TemplatingEditor from '../templating/TemplatingEditor'; 17 | import { LaunchIcon } from '../../icons'; 18 | 19 | export const FEEL_POPUP_WIDTH = 700; 20 | export const FEEL_POPUP_HEIGHT = 250; 21 | 22 | 23 | /** 24 | * FEEL popup component, built as a singleton. Emits lifecycle events as follows: 25 | * - `feelPopup.open` - fired before the popup is mounted 26 | * - `feelPopup.opened` - fired after the popup is mounted. Event context contains the DOM node of the popup 27 | * - `feelPopup.close` - fired before the popup is unmounted. Event context contains the DOM node of the popup 28 | * - `feelPopup.closed` - fired after the popup is unmounted 29 | */ 30 | export default function FEELPopupRoot(props) { 31 | const { 32 | element, 33 | eventBus = { fire() {}, on() {}, off() {} }, 34 | popupContainer, 35 | getPopupLinks = () => [] 36 | } = props; 37 | 38 | const prevElement = usePrevious(element); 39 | 40 | const [ popupConfig, setPopupConfig ] = useState({}); 41 | const [ open, setOpen ] = useState(false); 42 | const [ source, setSource ] = useState(null); 43 | const [ sourceElement, setSourceElement ] = useState(null); 44 | 45 | const emit = (type, context) => { 46 | eventBus.fire('feelPopup.' + type, context); 47 | }; 48 | 49 | const isOpen = useCallback(() => { 50 | return !!open; 51 | }, [ open ]); 52 | 53 | useUpdateEffect(() => { 54 | if (!open) { 55 | emit('closed'); 56 | } 57 | }, [ open ]); 58 | 59 | const handleOpen = (entryId, config, _sourceElement) => { 60 | setSource(entryId); 61 | setPopupConfig(config); 62 | setOpen(true); 63 | setSourceElement(_sourceElement); 64 | emit('open'); 65 | }; 66 | 67 | const handleClose = (event = {}) => { 68 | const { id } = event; 69 | if (id && id !== source) { 70 | return; 71 | } 72 | 73 | setOpen(false); 74 | setSource(null); 75 | }; 76 | 77 | const feelPopupContext = { 78 | open: handleOpen, 79 | close: handleClose, 80 | source 81 | }; 82 | 83 | // close popup on element change, cf. https://github.com/bpmn-io/properties-panel/issues/270 84 | useEffect(() => { 85 | if (element && prevElement && element !== prevElement) { 86 | handleClose(); 87 | } 88 | }, [ element ]); 89 | 90 | // allow close and open via events 91 | useEffect(() => { 92 | 93 | const handlePopupOpen = (context) => { 94 | const { 95 | entryId, 96 | popupConfig, 97 | sourceElement 98 | } = context; 99 | 100 | handleOpen(entryId, popupConfig, sourceElement); 101 | }; 102 | 103 | const handleIsOpen = () => { 104 | return isOpen(); 105 | }; 106 | 107 | eventBus.on('feelPopup._close', handleClose); 108 | eventBus.on('feelPopup._open', handlePopupOpen); 109 | eventBus.on('feelPopup._isOpen', handleIsOpen); 110 | 111 | return () => { 112 | eventBus.off('feelPopup._close', handleClose); 113 | eventBus.off('feelPopup._open', handleOpen); 114 | eventBus.off('feelPopup._isOpen', handleIsOpen); 115 | }; 116 | 117 | }, [ eventBus, isOpen ]); 118 | 119 | return ( 120 | 121 | {open && ( 122 | 129 | )} 130 | {props.children} 131 | 132 | ); 133 | } 134 | 135 | function FeelPopupComponent(props) { 136 | const { 137 | container, 138 | getLinks, 139 | id, 140 | hostLanguage, 141 | onInput, 142 | onClose, 143 | position, 144 | singleLine, 145 | sourceElement, 146 | title, 147 | tooltipContainer, 148 | type, 149 | value, 150 | variables, 151 | emit 152 | } = props; 153 | 154 | const editorRef = useRef(); 155 | const popupRef = useRef(); 156 | 157 | const isAutoCompletionOpen = useRef(false); 158 | 159 | const handleSetReturnFocus = () => { 160 | sourceElement && sourceElement.focus(); 161 | }; 162 | 163 | const onKeyDownCapture = (event) => { 164 | 165 | // we use capture here to make sure we handle the event before the editor does 166 | if (event.key === 'Escape') { 167 | isAutoCompletionOpen.current = autoCompletionOpen(event.target); 168 | } 169 | }; 170 | 171 | const onKeyDown = (event) => { 172 | 173 | if (event.key === 'Escape') { 174 | 175 | // close popup only if auto completion is not open 176 | // we need to do check this because the editor is not 177 | // stop propagating the keydown event 178 | // cf. https://discuss.codemirror.net/t/how-can-i-replace-the-default-autocompletion-keymap-v6/3322/5 179 | if (!isAutoCompletionOpen.current) { 180 | onClose(); 181 | isAutoCompletionOpen.current = false; 182 | } 183 | } 184 | }; 185 | 186 | useEffect(() => { 187 | emit('opened', { domNode: popupRef.current }); 188 | return () => emit('close', { domNode: popupRef.current }); 189 | }, []); 190 | 191 | useEffect(() => { 192 | 193 | // Set focus on editor when popup is opened 194 | if (editorRef.current) { 195 | editorRef.current.focus(); 196 | } 197 | }, [ editorRef ]); 198 | 199 | return ( 200 | 217 | 224 | <> 225 | { 226 | getLinks(type).map((link, index) => { 227 | return 228 | { link.title} 229 | 230 | ; 231 | }) 232 | } 233 | 234 | 235 | 236 |
240 | 241 | { 242 | type === 'feel' && ( 243 | 253 | ) 254 | } 255 | 256 | { 257 | type === 'feelers' && ( 258 | 270 | ) 271 | } 272 |
273 |
274 |
275 | ); 276 | } 277 | 278 | // helpers ///////////////// 279 | 280 | function prefixId(id) { 281 | return `bio-properties-panel-${id}`; 282 | } 283 | 284 | function autoCompletionOpen(element) { 285 | return element.closest('.cm-editor').querySelector('.cm-tooltip-autocomplete'); 286 | } 287 | 288 | /** 289 | * This hook behaves like useEffect, but does not trigger on the first render. 290 | * 291 | * @param {Function} effect 292 | * @param {Array} deps 293 | */ 294 | function useUpdateEffect(effect, deps) { 295 | const isMounted = useRef(false); 296 | 297 | useEffect(() => { 298 | if (isMounted.current) { 299 | return effect(); 300 | } else { 301 | isMounted.current = true; 302 | } 303 | }, deps); 304 | } -------------------------------------------------------------------------------- /src/components/entries/FEEL/context/FeelPopupContext.js: -------------------------------------------------------------------------------- 1 | import { 2 | createContext 3 | } from 'preact'; 4 | 5 | const FeelPopupContext = createContext({ 6 | open: () => {}, 7 | close: () => {}, 8 | source: null 9 | }); 10 | 11 | export default FeelPopupContext; -------------------------------------------------------------------------------- /src/components/entries/FEEL/context/index.js: -------------------------------------------------------------------------------- 1 | export { default as FeelPopupContext } from './FeelPopupContext'; -------------------------------------------------------------------------------- /src/components/entries/FEEL/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Feel'; 2 | export * from './Feel'; 3 | export { default as FeelPopupRoot } from './FeelPopup'; -------------------------------------------------------------------------------- /src/components/entries/List.js: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState 4 | } from 'preact/hooks'; 5 | 6 | import { 7 | query as domQuery 8 | } from 'min-dom'; 9 | 10 | import { isFunction } from 'min-dash'; 11 | 12 | import { 13 | useKeyFactory, 14 | usePrevious 15 | } from '../../hooks'; 16 | 17 | import classnames from 'classnames'; 18 | 19 | import { 20 | ArrowIcon, 21 | CreateIcon, 22 | DeleteIcon 23 | } from '../icons'; 24 | 25 | /** 26 | * Entry for handling lists represented as nested entries. 27 | * 28 | * @template Item 29 | * @param {object} props 30 | * @param {string} props.id 31 | * @param {*} props.element 32 | * @param {Function} props.onAdd 33 | * @param {import('preact').Component} props.component 34 | * @param {string} [props.label=''] 35 | * @param {Function} [props.onRemove] 36 | * @param {Item[]} [props.items] 37 | * @param {boolean} [props.open] 38 | * @param {string|boolean} [props.autoFocusEntry] either a custom selector string or true to focus the first input 39 | * @returns 40 | */ 41 | export default function List(props) { 42 | const { 43 | id, 44 | element, 45 | items = [], 46 | component, 47 | label = '', 48 | open: shouldOpen, 49 | onAdd, 50 | onRemove, 51 | autoFocusEntry, 52 | ...restProps 53 | } = props; 54 | 55 | const [ open, setOpen ] = useState(!!shouldOpen); 56 | 57 | const hasItems = !!items.length; 58 | const toggleOpen = () => hasItems && setOpen(!open); 59 | 60 | const elementChanged = usePrevious(element) !== element; 61 | const newItems = useNewItems(items, elementChanged); 62 | 63 | useEffect(() => { 64 | if (open && !hasItems) { 65 | setOpen(false); 66 | } 67 | }, [ open, hasItems ]); 68 | 69 | /** 70 | * @param {MouseEvent} event 71 | */ 72 | function addItem(event) { 73 | event.stopPropagation(); 74 | onAdd(); 75 | 76 | if (!open) { 77 | setOpen(true); 78 | } 79 | } 80 | 81 | return ( 82 |
90 |
91 |
97 | { label } 98 |
99 |
102 | 116 | { 117 | hasItems && ( 118 |
122 | { items.length } 123 |
124 | ) 125 | } 126 | { 127 | hasItems && ( 128 | 135 | ) 136 | } 137 |
138 |
139 | { 140 | hasItems && ( 141 | 152 | ) 153 | } 154 |
155 | ); 156 | } 157 | 158 | function ItemsList(props) { 159 | const { 160 | autoFocusEntry, 161 | component: Component, 162 | element, 163 | id, 164 | items, 165 | newItems, 166 | onRemove, 167 | open, 168 | ...restProps 169 | } = props; 170 | 171 | const getKey = useKeyFactory(); 172 | 173 | const newItem = newItems[0]; 174 | 175 | useEffect(() => { 176 | if (newItem && autoFocusEntry) { 177 | 178 | // (0) select the parent entry (containing all list items) 179 | const entry = domQuery(`[data-entry-id="${id}"]`); 180 | 181 | // (1) select the first input or a custom element to be focussed 182 | const selector = typeof(autoFocusEntry) === 'boolean' ? 183 | '.bio-properties-panel-input' : 184 | autoFocusEntry; 185 | const focusableInput = domQuery(selector, entry); 186 | 187 | // (2) set focus 188 | if (focusableInput) { 189 | 190 | if (isFunction(focusableInput.select)) { 191 | focusableInput.select(); 192 | } else if (isFunction(focusableInput.focus)) { 193 | focusableInput.focus(); 194 | } 195 | 196 | } 197 | } 198 | }, [ newItem, autoFocusEntry, id ]); 199 | 200 | return
    204 | { 205 | items.map((item, index) => { 206 | const key = getKey(item); 207 | 208 | return (
  1. 209 | 216 | { 217 | onRemove && ( 218 | 224 | ) 225 | } 226 |
  2. ); 227 | }) 228 | } 229 |
; 230 | } 231 | 232 | function useNewItems(items = [], shouldReset) { 233 | const previousItems = usePrevious(items.slice()) || []; 234 | 235 | if (shouldReset) { 236 | return []; 237 | } 238 | 239 | return previousItems ? items.filter(item => !previousItems.includes(item)) : []; 240 | } 241 | -------------------------------------------------------------------------------- /src/components/entries/NumberField.js: -------------------------------------------------------------------------------- 1 | import Description from './Description'; 2 | 3 | import { 4 | useEffect, 5 | useMemo, 6 | useState 7 | } from 'preact/hooks'; 8 | 9 | import classnames from 'classnames'; 10 | 11 | import { isFunction } from 'min-dash'; 12 | 13 | import { 14 | useError 15 | } from '../../hooks'; 16 | 17 | export function NumberField(props) { 18 | 19 | const { 20 | debounce, 21 | disabled, 22 | displayLabel = true, 23 | id, 24 | inputRef, 25 | label, 26 | max, 27 | min, 28 | onInput, 29 | step, 30 | value = '', 31 | onFocus, 32 | onBlur 33 | } = props; 34 | 35 | const [ localValue, setLocalValue ] = useState(value); 36 | 37 | const handleInputCallback = useMemo(() => { 38 | return debounce((target) => { 39 | 40 | if (target.validity.valid) { 41 | onInput(target.value ? parseFloat(target.value) : undefined); 42 | } 43 | }); 44 | }, [ onInput, debounce ]); 45 | 46 | const handleInput = e => { 47 | handleInputCallback(e.target); 48 | setLocalValue(e.target.value); 49 | }; 50 | 51 | useEffect(() => { 52 | if (value === localValue) { 53 | return; 54 | } 55 | 56 | setLocalValue(value); 57 | }, [ value ]); 58 | 59 | return ( 60 |
61 | {displayLabel && } 62 | 78 |
79 | ); 80 | } 81 | 82 | /** 83 | * @param {Object} props 84 | * @param {Boolean} props.debounce 85 | * @param {String} props.description 86 | * @param {Boolean} props.disabled 87 | * @param {Object} props.element 88 | * @param {Function} props.getValue 89 | * @param {String} props.id 90 | * @param {String} props.label 91 | * @param {String} props.max 92 | * @param {String} props.min 93 | * @param {Function} props.setValue 94 | * @param {Function} props.onFocus 95 | * @param {Function} props.onBlur 96 | * @param {String} props.step 97 | * @param {Function} props.validate 98 | */ 99 | export default function NumberFieldEntry(props) { 100 | const { 101 | debounce, 102 | description, 103 | disabled, 104 | element, 105 | getValue, 106 | id, 107 | label, 108 | max, 109 | min, 110 | setValue, 111 | step, 112 | onFocus, 113 | onBlur, 114 | validate 115 | } = props; 116 | 117 | const globalError = useError(id); 118 | const [ localError, setLocalError ] = useState(null); 119 | 120 | let value = getValue(element); 121 | 122 | useEffect(() => { 123 | if (isFunction(validate)) { 124 | const newValidationError = validate(value) || null; 125 | 126 | setLocalError(newValidationError); 127 | } 128 | }, [ value, validate ]); 129 | 130 | const onInput = (newValue) => { 131 | let newValidationError = null; 132 | 133 | if (isFunction(validate)) { 134 | newValidationError = validate(newValue) || null; 135 | } 136 | 137 | setValue(newValue, newValidationError); 138 | setLocalError(newValidationError); 139 | }; 140 | 141 | const error = globalError || localError; 142 | 143 | return ( 144 |
147 | 160 | { error &&
{ error }
} 161 | 162 |
163 | ); 164 | } 165 | 166 | export function isEdited(node) { 167 | return node && !!node.value; 168 | } 169 | 170 | 171 | // helpers ///////////////// 172 | 173 | function prefixId(id) { 174 | return `bio-properties-panel-${ id }`; 175 | } 176 | -------------------------------------------------------------------------------- /src/components/entries/Select.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | 3 | import { isFunction } from 'min-dash'; 4 | 5 | import { 6 | useError, 7 | useShowEntryEvent 8 | } from '../../hooks'; 9 | 10 | import { 11 | useEffect, 12 | useState 13 | } from 'preact/hooks'; 14 | 15 | import Description from './Description'; 16 | import Tooltip from './Tooltip'; 17 | 18 | /** 19 | * @typedef { { value: string, label: string, disabled: boolean, children: { value: string, label: string, disabled: boolean } } } Option 20 | */ 21 | 22 | /** 23 | * Provides basic select input. 24 | * 25 | * @param {object} props 26 | * @param {string} props.id 27 | * @param {string[]} props.path 28 | * @param {string} props.label 29 | * @param {Function} props.onChange 30 | * @param {Function} props.onFocus 31 | * @param {Function} props.onBlur 32 | * @param {Array