From debd03089c28f19d1719ab5ebfa32487a1c9a019 Mon Sep 17 00:00:00 2001 From: Martin Stamm Date: Thu, 23 Jan 2025 17:51:49 +0100 Subject: [PATCH] feat: add RPA editor to Camunda Modeler --- app/lib/zeebe-api/zeebe-api.js | 6 + client/karma.config.js | 2 +- client/package.json | 2 + client/src/app/TabsProvider.js | 51 +- client/src/app/__tests__/EmptyTabSpec.js | 3 +- client/src/app/__tests__/TabsProviderSpec.js | 44 +- client/src/app/tabs/rpa/RPAEditor.js | 380 +++++++ client/src/app/tabs/rpa/RPAEditor.less | 88 ++ client/src/app/tabs/rpa/RPATab.js | 30 + client/src/app/tabs/rpa/RunButton.js | 81 ++ client/src/app/tabs/rpa/StatusButton.js | 71 ++ client/src/app/tabs/rpa/XMLEditor.less | 25 + .../app/tabs/rpa/__tests__/RPAEditorSpec.js | 264 +++++ .../app/tabs/rpa/__tests__/RunButtonSpec.js | 100 ++ .../tabs/rpa/__tests__/StatusButtonSpec.js | 76 ++ client/src/app/tabs/rpa/__tests__/simple.rpa | 5 + client/src/app/tabs/rpa/getRobotEditMenu.js | 74 ++ client/src/app/tabs/rpa/index.js | 11 + .../src/app/tabs/rpa/resources/TestIcon.svg | 3 + client/src/app/tabs/rpa/resources/initial.rpa | 7 + .../deployment-plugin/DeploymentPlugin.js | 11 +- client/src/util/Flags.js | 2 +- client/test/mocks/rpa/index.js | 52 + client/webpack.config.js | 2 +- package-lock.json | 978 +++++++++++++++--- resources/diagram/simple.bpmn | 2 +- 26 files changed, 2201 insertions(+), 169 deletions(-) create mode 100644 client/src/app/tabs/rpa/RPAEditor.js create mode 100644 client/src/app/tabs/rpa/RPAEditor.less create mode 100644 client/src/app/tabs/rpa/RPATab.js create mode 100644 client/src/app/tabs/rpa/RunButton.js create mode 100644 client/src/app/tabs/rpa/StatusButton.js create mode 100644 client/src/app/tabs/rpa/XMLEditor.less create mode 100644 client/src/app/tabs/rpa/__tests__/RPAEditorSpec.js create mode 100644 client/src/app/tabs/rpa/__tests__/RunButtonSpec.js create mode 100644 client/src/app/tabs/rpa/__tests__/StatusButtonSpec.js create mode 100644 client/src/app/tabs/rpa/__tests__/simple.rpa create mode 100644 client/src/app/tabs/rpa/getRobotEditMenu.js create mode 100644 client/src/app/tabs/rpa/index.js create mode 100644 client/src/app/tabs/rpa/resources/TestIcon.svg create mode 100644 client/src/app/tabs/rpa/resources/initial.rpa create mode 100644 client/test/mocks/rpa/index.js diff --git a/app/lib/zeebe-api/zeebe-api.js b/app/lib/zeebe-api/zeebe-api.js index 67b11ba15b..62d075f4fa 100644 --- a/app/lib/zeebe-api/zeebe-api.js +++ b/app/lib/zeebe-api/zeebe-api.js @@ -667,6 +667,12 @@ function getResource(parameters, contents, resourceName) { } else if (resourceType === RESOURCE_TYPES.DMN) { resource.decision = contents; } else if (resourceType === RESOURCE_TYPES.FORM) { + resource.form = contents; + } else { + + // Fallback for unknown resource, cf. + // https://github.com/camunda-community-hub/zeebe-client-node-js/blob/7969ce1808c96a87519cb1a3f279287f30637c4b/src/zb/ZBClient.ts#L873-L886 + resource.form = contents; } diff --git a/client/karma.config.js b/client/karma.config.js index 5d487044f2..7a486f88dd 100644 --- a/client/karma.config.js +++ b/client/karma.config.js @@ -98,7 +98,7 @@ module.exports = function(karma) { use: 'react-svg-loader' }, { - test: /\.(css|bpmn|cmmn|dmn|less|xml|png|svg|form)$/, + test: /\.(css|bpmn|cmmn|dmn|less|xml|png|svg|form|rpa)$/, type: 'asset/source' } ] diff --git a/client/package.json b/client/package.json index 9321374791..f4b174f4d0 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,8 @@ "@camunda/form-playground": "^0.20.0", "@camunda/improved-canvas": "^1.7.6", "@camunda/linting": "^3.31.0", + "@camunda/rpa-integration": "0.1.0", + "@carbon/icons-react": "^11.53.0", "@codemirror/commands": "^6.6.2", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-xml": "^6.1.0", diff --git a/client/src/app/TabsProvider.js b/client/src/app/TabsProvider.js index c8330bd56b..2c537fc8d7 100644 --- a/client/src/app/TabsProvider.js +++ b/client/src/app/TabsProvider.js @@ -18,6 +18,8 @@ import { import replaceIds from '@bpmn-io/replace-ids'; +import { Bot } from '@carbon/icons-react'; + import { Linter as BpmnLinter } from '@camunda/linting'; import { FormLinter } from '@camunda/form-linting/lib/FormLinter'; @@ -28,6 +30,7 @@ import dmnDiagram from './tabs/dmn/diagram.dmn'; import cloudDmnDiagram from './tabs/cloud-dmn/diagram.dmn'; import form from './tabs/form/initial.form'; import cloudForm from './tabs/form/initial-cloud.form'; +import rpaScript from './tabs/rpa/resources/initial.rpa'; import { ENGINES @@ -56,7 +59,8 @@ import Flags, { DISABLE_PLATFORM, DISABLE_CMMN, DISABLE_HTTL_HINT, - DEFAULT_HTTL + DEFAULT_HTTL, + DISABLE_RPA } from '../util/Flags'; import BPMNIcon from '../../resources/icons/file-types/BPMN.svg'; @@ -490,6 +494,43 @@ export default class TabsProvider { getLinter() { return formLinter; } + }, + 'rpa': { + name: 'RPA', + encoding: 'utf8', + exports: {}, + extensions: [ 'rpa' ], + canOpen(file) { + return file.name.endsWith('.rpa'); + }, + getComponent(options) { + return import('./tabs/rpa'); + }, + getIcon() { + return Bot; + }, + getInitialContents() { + return rpaScript; + }, + getInitialFilename(suffix) { + return `script_${suffix}.rpa`; + }, + getHelpMenu() { + return []; + }, + getNewFileMenu() { + return [ { + label: 'RPA script', + group: 'Camunda 8', + action: 'create-diagram', + options: { + type: 'rpa' + } + } ]; + }, + getLinter() { + return null; + } } }; @@ -518,9 +559,12 @@ export default class TabsProvider { this.providersByFileType.bpmn = this.providersByFileType.bpmn.filter(p => p !== this.providers['cloud-bpmn']); this.providersByFileType.dmn = this.providersByFileType.dmn.filter(p => p !== this.providers['cloud-dmn']); this.providersByFileType.form = this.providersByFileType.form.filter(p => p !== this.providers['cloud-form']); + this.providersByFileType.rpa = []; + delete this.providers['cloud-bpmn']; delete this.providers['cloud-dmn']; delete this.providers['cloud-form']; + delete this.providers['rpa']; } if (Flags.get(DISABLE_PLATFORM)) { @@ -552,6 +596,11 @@ export default class TabsProvider { delete this.providers['cloud-form']; delete this.providersByFileType.form; } + + if (Flags.get(DISABLE_RPA)) { + delete this.providers.rpa; + delete this.providersByFileType.rpa; + } } getProviderNames() { diff --git a/client/src/app/__tests__/EmptyTabSpec.js b/client/src/app/__tests__/EmptyTabSpec.js index 91b7070376..8a035bca8c 100644 --- a/client/src/app/__tests__/EmptyTabSpec.js +++ b/client/src/app/__tests__/EmptyTabSpec.js @@ -39,11 +39,12 @@ describe('', function() { buttons.forEach(wrapper => wrapper.simulate('click')); // then - expect(onAction).to.have.callCount(6); + expect(onAction).to.have.callCount(7); expect(onAction.args).to.eql([ [ 'create-cloud-bpmn-diagram', undefined ], [ 'create-cloud-dmn-diagram', undefined ], [ 'create-cloud-form', undefined ], + [ 'create-diagram', { type: 'rpa' } ], [ 'create-bpmn-diagram', undefined ], [ 'create-dmn-diagram', undefined ], [ 'create-form', undefined ] diff --git a/client/src/app/__tests__/TabsProviderSpec.js b/client/src/app/__tests__/TabsProviderSpec.js index 3ac6ee691a..0277b1e376 100644 --- a/client/src/app/__tests__/TabsProviderSpec.js +++ b/client/src/app/__tests__/TabsProviderSpec.js @@ -18,7 +18,8 @@ import Flags, { CLOUD_ENGINE_VERSION, PLATFORM_ENGINE_VERSION, DISABLE_HTTL_HINT, - DEFAULT_HTTL + DEFAULT_HTTL, + DISABLE_RPA } from '../../util/Flags'; import { @@ -473,6 +474,21 @@ describe('TabsProvider', function() { // then expect(contents).to.include(`"executionPlatformVersion": "${ expectedPlatformVersion }"`); }); + + + it('should replace version placeholder with actual latest version (RPA)', function() { + + // given + const tabsProvider = new TabsProvider(); + + const expectedPlatformVersion = getLatestStablePlatformVersion(ENGINES.CLOUD); + + // when + const { file: { contents } } = tabsProvider.createTab('rpa'); + + // then + expect(contents).to.include(`"executionPlatformVersion": "${ expectedPlatformVersion }"`); + }); }); }); @@ -556,6 +572,7 @@ describe('TabsProvider', function() { expect(tabsProvider.createTab('dmn')).to.exist; expect(tabsProvider.createTab('cloud-dmn')).to.exist; expect(tabsProvider.createTab('form')).to.exist; + expect(tabsProvider.createTab('rpa')).to.exist; }); @@ -870,6 +887,7 @@ describe('TabsProvider', function() { expect(providers['cmmn']).to.exist; expect(providers['dmn']).to.exist; expect(providers['cloud-dmn']).to.exist; + expect(providers['rpa']).to.exist; expect(providers['empty']).to.exist; }); @@ -886,7 +904,7 @@ describe('TabsProvider', function() { const providerNames = tabsProvider.getProviderNames(); // then - expect(providerNames).to.eql([ 'BPMN', 'DMN', 'FORM' ]); + expect(providerNames).to.eql([ 'BPMN', 'DMN', 'FORM', 'RPA' ]); }); @@ -903,7 +921,7 @@ describe('TabsProvider', function() { const providerNames = tabsProvider.getProviderNames(); // then - expect(providerNames).to.eql([ 'BPMN', 'CMMN', 'DMN', 'FORM' ]); + expect(providerNames).to.eql([ 'BPMN', 'CMMN', 'DMN', 'FORM', 'RPA' ]); }); @@ -920,7 +938,7 @@ describe('TabsProvider', function() { const providerNames = tabsProvider.getProviderNames(); // then - expect(providerNames).to.eql([ 'BPMN', 'DMN', 'FORM' ]); + expect(providerNames).to.eql([ 'BPMN', 'DMN', 'FORM', 'RPA' ]); }); @@ -1165,6 +1183,21 @@ describe('TabsProvider', function() { }); + it('should disable RPA', function() { + + // given + Flags.init({ + [DISABLE_RPA]: true + }); + + // when + const tabsProvider = new TabsProvider(); + + // then + expect(tabsProvider.hasProvider('rpa')).to.be.false; + }); + + it('should disable HTTL hint', async function() { // given @@ -1242,7 +1275,8 @@ describe('TabsProvider', function() { 'dmn', 'cloud-dmn', 'form', - 'cloud-form' + 'cloud-form', + 'rpa' ].forEach((type) => { it(`should have icon <${type}>`, function() { diff --git a/client/src/app/tabs/rpa/RPAEditor.js b/client/src/app/tabs/rpa/RPAEditor.js new file mode 100644 index 0000000000..3aee1d889a --- /dev/null +++ b/client/src/app/tabs/rpa/RPAEditor.js @@ -0,0 +1,380 @@ +/** + * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH + * under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. + * + * Camunda licenses this file to you under the MIT; you may not use this file + * except in compliance with the MIT License. + */ + +import React from 'react'; + +import { + WithCache, + WithCachedState, + CachedComponent +} from '../../cached'; + +import * as css from './RPAEditor.less'; + +import { + isString +} from 'min-dash'; + +import { RPAEditor as RPACodeEditor, DebugInfo } from '@camunda/rpa-integration'; +import PropertiesPanelContainer from '../../resizable-container/PropertiesPanelContainer'; +import { getRPAEditMenu } from './getRobotEditMenu'; +import { Fill } from '../../slot-fill'; +import RunButton from './RunButton'; +import StatusButton from './StatusButton'; +import { Loader } from '../../primitives'; + +export class RPAEditor extends CachedComponent { + + constructor(props) { + super(props); + + this.state = { + loading: true + }; + + this.modelerRef = React.createRef(); + this.propertiesPanelRef = React.createRef(); + + this.handleLayoutChange = this.handleLayoutChange.bind(this); + } + + + handleListeners(editor, deregister) { + const method = deregister ? 'off' : 'on'; + + editor.eventBus[method]('config.changed', this.saveConfig); + + editor.eventBus[method]('model.changed', this.handleChanged); + } + + + saveConfig = (config) => { + this.props.setConfig('rpa', { workerConfig: config }); + this.setCached({ + lastWorkerConfig: config + }); + }; + + + componentDidMount() { + + const { + editorContainer, propertiesContainer, editor + } = this.getCached(); + + this.modelerRef.current.appendChild(editorContainer); + this.propertiesPanelRef.current.appendChild(propertiesContainer); + + if (!editor) { + + // Create editor if not present + this.createEditor().then(() => { + this.handleChanged(); + }); + } else { + + // or reimport if config changed + this.checkImport(); + this.handleListeners(editor); + this.setState({ + loading: false + }); + } + } + + async createEditor() { + this.setState({ + loading: true + }); + + const rpaConfig = await this.props.getConfig('rpa', {}); + + const { + editorContainer, + propertiesContainer, + editor: cachedEditor + } = this.getCached(); + + if (cachedEditor) { + cachedEditor.destroy(); + } + + const editor = RPACodeEditor({ + container: editorContainer, + propertiesPanel: { + container: propertiesContainer + }, + workerConfig: rpaConfig.workerConfig, + value: this.props.xml + }); + + // Prevent scrolling + editor.editor.updateOptions({ + scrollBeyondLastLine: false + }); + + this.setCached({ + editor, + lastXML: this.props.xml, + lastWorkerConfig: rpaConfig.workerConfig + }); + + this.setState({ + loading: false + }); + + this.handleListeners(editor); + return editor; + } + + componentWillUnmount() { + const { + editorContainer, + propertiesContainer, + editor + } = this.getCached(); + + editorContainer.remove(); + propertiesContainer.remove(); + + this.handleListeners(editor, true); + } + + componentDidUpdate(prevProps) { + if (isXMLChange(prevProps.xml, this.props.xml)) { + this.checkImport(); + } + + if (isChachedStateChange(prevProps, this.props)) { + this.handleChanged(); + } + } + + triggerAction(action) { + const { + editor + } = this.getCached(); + + const { + editor: monaco + } = editor; + + if (action === 'undo') { + monaco.trigger('menu', 'undo'); + } + + if (action === 'redo') { + monaco.trigger('menu', 'redo'); + } + + if (action === 'find') { + monaco.getAction('actions.find').run(); + } + + if (action === 'findNext') { + monaco.getAction('editor.action.nextMatchFindAction').run(); + } + + if (action === 'findPrev') { + monaco.getAction('editor.action.previousMatchFindAction').run(); + } + + if (action === 'replace') { + monaco.getAction('editor.action.startFindReplaceAction').run(); + } + } + + async checkImport() { + const { xml } = this.props; + + const { + lastXML, + lastWorkerConfig, + editor + } = this.getCached(); + + if (isXMLChange(lastXML, xml)) { + this.createEditor(); + return; + } + + const rpaConfig = await this.props.getConfig('rpa', {}); + if (isWorkerConfigChange(rpaConfig.workerConfig, lastWorkerConfig)) { + editor.workerConfig = rpaConfig.workerConfig; + editor.eventBus.fire('config.changed', rpaConfig.workerConfig); + return; + } + } + + isDirty() { + + const { + editor, + lastXML + } = this.getCached(); + + return isXMLChange(editor.getValue(), lastXML); + } + + handleChanged = () => { + const { + onChanged + } = this.props; + + const { + editor: monaco + } = this.getCached(); + + const undoState = { + redo: monaco.editor.getModel().canRedo(), + undo: monaco.editor.getModel().canUndo() + }; + + const editMenu = getRPAEditMenu(undoState); + + const dirty = this.isDirty(); + + const newState = { + canExport: false, + dirty, + save: true, + ...undoState + }; + + // ensure backwards compatibility + // https://github.com/camunda/camunda-modeler/commit/78357e3ed9e6e0255ac8225fbdf451a90457e8bf#diff-bd5be70c4e5eadf1a316c16085a72f0fL17 + newState.editable = true; + newState.searchable = true; + + const windowMenu = []; + + if (typeof onChanged === 'function') { + onChanged({ + ...newState, + editMenu, + windowMenu + }); + } + + this.setState({ + ...newState + }); + }; + + handleLayoutChange(newLayout) { + const { + onLayoutChanged + } = this.props; + + if (onLayoutChanged) { + onLayoutChanged(newLayout); + } + } + + getXML() { + const { editor } = this.getCached(); + + const xml = editor.getValue(); + + this.setCached({ + lastXML: xml + }); + + return xml; + } + + render() { + + const { editor } = this.getCached(); + + const loading = this.state.loading; + + return ( + <> +