diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 90ad4f6b..b7446e91 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,6 +14,24 @@ updates: - dependency-name: "react*" open-pull-requests-limit: 10 + - package-ecosystem: "npm" + directory: "/camunda-modeler-formio-plugin" + schedule: + interval: "weekly" + allow: + # Allow updates for React and any packages starting "react" + - dependency-name: "react*" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: "/camunda-modeler-deployment-plugin" + schedule: + interval: "weekly" + allow: + # Allow updates for React and any packages starting "react" + - dependency-name: "react*" + open-pull-requests-limit: 10 + - package-ecosystem: "github-actions" # Workflow files stored in the # default location of `.github/workflows` diff --git a/.github/workflows/build-test-and-publish.yaml b/.github/workflows/build-test-and-publish.yaml index 0020747c..f98877ab 100644 --- a/.github/workflows/build-test-and-publish.yaml +++ b/.github/workflows/build-test-and-publish.yaml @@ -123,28 +123,97 @@ jobs: zip -r -l ~/formio-plugin.zip ./ env: NODE_OPTIONS: "--openssl-legacy-provider" - - name: install modeller + - name: Upload build results + uses: actions/upload-artifact@v3 + with: + name: plugin + path: ~/formio-plugin.zip + retention-days: 1 + build-deployment-plugin: + name: Build Camunda Modeler Deployment Plugin + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: build plugin run: | - wget https://downloads.camunda.cloud/release/camunda-modeler/5.12.1/camunda-modeler-5.12.1-linux-x64.tar.gz - tar -xvf camunda-modeler*.tar.gz - mkdir -p ~/.config/camunda-modeler/plugins - cp -R ./camunda-modeler-formio-plugin/resources/plugins/formio-plugin ~/.config/camunda-modeler/plugins + cd ./camunda-modeler-deployment-plugin + chmod +x ./install.sh + export MODELER_DIR=$(pwd) + npm install -f + npm run build + cd ${MODELER_DIR}/resources/plugins/deploy-plugin + zip -r -l ~/deploy-plugin.zip ./ + env: + NODE_OPTIONS: "--openssl-legacy-provider" + - name: Upload build results + uses: actions/upload-artifact@v3 + with: + name: deploy-plugin + path: ~/deploy-plugin.zip + retention-days: 1 + run-modeler-tests: + name: Run modeler tests + needs: + - build-plugin + - build-deployment-plugin + - build-maven + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-java@v3 with: distribution: "temurin" java-version: "17" + - uses: actions/download-artifact@v3 + with: + name: plugin + path: ./ + - uses: actions/download-artifact@v3 + with: + name: deploy-plugin + path: ./ + - name: Cache local Maven repository + uses: actions/cache@v3 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GH_TOKEN }} + - name: run services + env: + CR_PAT: ${{ secrets.GH_TOKEN }} + run: | + sed -i -e "s/IMAGE_TAG=.*/IMAGE_TAG=${{ needs.build-maven.outputs.tag }}/" ./.env + docker compose pull + docker compose -f "docker-compose.yml" up -d --no-build + - name: install modeller + run: | + wget https://downloads.camunda.cloud/release/camunda-modeler/5.16.0/camunda-modeler-5.16.0-linux-x64.tar.gz + tar -xvf camunda-modeler*.tar.gz + mkdir -p ~/.config/camunda-modeler/plugins + unzip formio-plugin.zip -d ~/.config/camunda-modeler/plugins/formio-plugin + unzip deploy-plugin.zip -d ~/.config/camunda-modeler/plugins/deploy-plugin + cd ./cucumber-tests + chmod +x ./healthcheck.sh + ./healthcheck.sh - name: Run Camunda Modeler test uses: coactions/setup-xvfb@v1.0.1 with: run: mvn clean verify -ntp -Dcucumber.filter.tags="@modeler" working-directory: ./cucumber-tests - options: #optional - - name: Upload build results - uses: actions/upload-artifact@v3 - with: - name: plugin - path: ~/formio-plugin.zip - retention-days: 1 run-tests: name: Run Tests needs: [ build-maven ] @@ -201,6 +270,8 @@ jobs: needs: - build-maven - build-plugin + - build-deployment-plugin + - run-modeler-tests - run-tests runs-on: ubuntu-latest steps: @@ -222,6 +293,10 @@ jobs: with: name: plugin path: ./ + - uses: actions/download-artifact@v3 + with: + name: deploy-plugin + path: ./ - name: MVN set version run: | mvn versions:set -DnewVersion=$NEW_VERSION @@ -240,7 +315,7 @@ jobs: - name: Release it run: | export PREV_VERSION=$(gh release list | grep Latest | awk '{print $3}') - gh release create v$NEW_VERSION --title v$NEW_VERSION --notes-start-tag $PREV_VERSION --generate-notes --latest ./formio-plugin.zip + gh release create v$NEW_VERSION --title v$NEW_VERSION --notes-start-tag $PREV_VERSION --generate-notes --latest ./formio-plugin.zip ./deploy-plugin.zip env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} NEW_VERSION: ${{ needs.build-maven.outputs.version }} diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml index 20c05bf0..b67ecbf3 100644 --- a/.github/workflows/codacy.yml +++ b/.github/workflows/codacy.yml @@ -2,6 +2,9 @@ name: Codacy Security Scan on: push: + branches: + - main + - 0.0.x schedule: - cron: '0 0 * * 0' diff --git a/camunda-formio-bpmn/simple-task.bpmn b/camunda-formio-bpmn/simple-task.bpmn index c29c79c0..0a522318 100644 --- a/camunda-formio-bpmn/simple-task.bpmn +++ b/camunda-formio-bpmn/simple-task.bpmn @@ -121,4 +121,4 @@ print('Variable: ' + execution.getVariable("action")); - + \ No newline at end of file diff --git a/camunda-formio-plugin/src/main/java/org/softcannery/camunda/FormioContext.java b/camunda-formio-plugin/src/main/java/org/softcannery/camunda/FormioContext.java index 334d60d4..a34f6f2e 100644 --- a/camunda-formio-plugin/src/main/java/org/softcannery/camunda/FormioContext.java +++ b/camunda-formio-plugin/src/main/java/org/softcannery/camunda/FormioContext.java @@ -49,23 +49,19 @@ import jakarta.annotation.PostConstruct; import java.util.AbstractMap; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.function.Supplier; import java.util.stream.Collectors; -import lombok.SneakyThrows; import org.camunda.bpm.engine.ProcessEngine; import org.camunda.bpm.engine.RepositoryService; import org.camunda.bpm.engine.impl.context.Context; import org.camunda.bpm.engine.impl.persistence.entity.ExecutionEntity; import org.camunda.bpm.engine.impl.pvm.process.ActivityImpl; import org.camunda.bpm.engine.variable.value.IntegerValue; -import org.softcannery.formio.model.FormKey; import org.softcannery.formio.model.SubmissionEntity; import org.softcannery.formio.model.SubmissionValue; import org.softcannery.formio.repository.SubmissionHistoryRepository; diff --git a/camunda-formio-react-app/src/components/Footer.js b/camunda-formio-react-app/src/components/Footer.js index 98b283fe..812f0941 100644 --- a/camunda-formio-react-app/src/components/Footer.js +++ b/camunda-formio-react-app/src/components/Footer.js @@ -1,12 +1,12 @@ -import React, { Component } from "react"; -import { Menu } from "semantic-ui-react"; +import React, {Component} from "react"; +import {Menu} from "semantic-ui-react"; export default class Footer extends Component { render() { return ( ); diff --git a/camunda-modeler-deployment-plugin/.babelrc b/camunda-modeler-deployment-plugin/.babelrc new file mode 100644 index 00000000..17922e57 --- /dev/null +++ b/camunda-modeler-deployment-plugin/.babelrc @@ -0,0 +1,29 @@ +{ + "plugins": [ + "@babel/plugin-syntax-dynamic-import", + "@babel/plugin-proposal-class-properties" + ], + "presets": [ + [ + "@babel/preset-env", { + "modules": false, + "targets": { + "chrome": "78" + } + } + ], + "@babel/preset-react" + ], + "env": { + "coverage": { + "plugins": [ + [ "istanbul", { + "exclude": [ + "**/__tests__/**", + "**/test/**" + ] + } ] + ] + } + } +} diff --git a/camunda-modeler-deployment-plugin/.editorconfig b/camunda-modeler-deployment-plugin/.editorconfig new file mode 100644 index 00000000..bab9ddf7 --- /dev/null +++ b/camunda-modeler-deployment-plugin/.editorconfig @@ -0,0 +1,57 @@ +# +# Camunda Platform Accelerator for Form.io Community License v1.0 +# +# This Camunda Platform Accelerator for Form.io Community License v1.0 (“this Agreement”) sets +# forth the terms and conditions on which Soft Cannery LTD. (“the Licensor”) makes available +# this software (“the Software”). BY INSTALLING, DOWNLOADING, ACCESSING, USING OR DISTRIBUTING +# THE SOFTWARE YOU INDICATE YOUR ACCEPTANCE TO, AND ARE ENTERING INTO A CONTRACT WITH, +# THE LICENSOR ON THE TERMS SET OUT IN THIS AGREEMENT. IF YOU DO NOT AGREE TO THESE TERMS, +# YOU MUST NOT USE THE SOFTWARE. IF YOU ARE RECEIVING THE SOFTWARE ON BEHALF OF A LEGAL ENTITY, +# YOU REPRESENT AND WARRANT THAT YOU HAVE THE ACTUAL AUTHORITY TO AGREE TO THE TERMS AND +# CONDITIONS OF THIS AGREEMENT ON BEHALF OF SUCH ENTITY. “Licensee” means you, an individual, +# or the entity on whose behalf you are receiving the Software. +# +# Permission is hereby granted, free of charge, to the Licensee obtaining a copy of this +# Software and associated documentation files, to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, publish, distribute, +# sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +# is furnished to do so, subject in each case to the following conditions: +# +# Condition 1: If the Licensee distributes the Software or any derivative works of the Software, +# the Licensee must attach this Agreement. +# +# Condition 2: Without limiting other conditions in this Agreement, the grant of rights under +# this Agreement does not include the right to provide Commercial Product or Service. Written +# permission from the Licensor is required to provide Commercial Product or Service. +# +# A “Commercial Product or Service” is software or service intended for or directed towards +# commercial advantage or monetary compensation for the provider of the product or service +# enabling parties to deploy and/or execute Commercial Product or Service. +# +# If the Licensee is in breach of the Conditions, this Agreement, including the rights granted +# under it, will automatically terminate with immediate effect. +# +# SUBJECT AS SET OUT BELOW, THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +# NOTHING IN THIS AGREEMENT EXCLUDES OR RESTRICTS A PARTY’S LIABILITY FOR (A) DEATH OR PERSONAL +# INJURY CAUSED BY THAT PARTY’S NEGLIGENCE, (B) FRAUD, OR (C) ANY OTHER LIABILITY TO THE EXTENT +# THAT IT CANNOT BE LAWFULLY EXCLUDED OR RESTRICTED. +# + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/.eslintignore b/camunda-modeler-deployment-plugin/.eslintignore new file mode 100644 index 00000000..04c01ba7 --- /dev/null +++ b/camunda-modeler-deployment-plugin/.eslintignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/.eslintrc b/camunda-modeler-deployment-plugin/.eslintrc new file mode 100644 index 00000000..19828bf0 --- /dev/null +++ b/camunda-modeler-deployment-plugin/.eslintrc @@ -0,0 +1,10 @@ +{ + "env": { + "browser": true, + "es6": true + }, + "extends": [ + "plugin:bpmn-io/es6", + "plugin:bpmn-io/jsx" + ] + } \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/.gitignore b/camunda-modeler-deployment-plugin/.gitignore new file mode 100644 index 00000000..f0db27e9 --- /dev/null +++ b/camunda-modeler-deployment-plugin/.gitignore @@ -0,0 +1,66 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next + +.DS_Store +dist/ + +.idea/ diff --git a/camunda-modeler-deployment-plugin/.travis.yml b/camunda-modeler-deployment-plugin/.travis.yml new file mode 100644 index 00000000..998b0449 --- /dev/null +++ b/camunda-modeler-deployment-plugin/.travis.yml @@ -0,0 +1,3 @@ +language: node_js + +node_js: '10' \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/camunda/CamundaDeploymentModal.less b/camunda-modeler-deployment-plugin/client/camunda/CamundaDeploymentModal.less new file mode 100644 index 00000000..9eb12f00 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/camunda/CamundaDeploymentModal.less @@ -0,0 +1,22 @@ +:local(.DeploymentConfigModal) { + .icon { + vertical-align: text-top; + } + + .intro { + margin-bottom: 30px; + } + + fieldset:first-child { + margin-top: 0px; + } + + fieldset:last-child { + margin-bottom: 0px; + padding-bottom: 16px; + } + + section { + position: relative; + } +} \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/camunda/CamundaDeploymentOverlay.js b/camunda-modeler-deployment-plugin/client/camunda/CamundaDeploymentOverlay.js new file mode 100644 index 00000000..a903d2be --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/camunda/CamundaDeploymentOverlay.js @@ -0,0 +1,415 @@ +/** + * 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 {omit} from 'min-dash'; + +import css from './CamundaDeploymentModal.less'; + +import AuthTypes from '../shared/AuthTypes'; + +import {FileInput, Radio, TextInput} from '../shared/ui'; + +import {Overlay, Section} from 'camunda-modeler-plugin-helpers/components'; + +import {Field, Formik} from 'formik'; + + +export default class CamundaDeploymentOverlay extends React.PureComponent { + + constructor(props) { + super(props); + + this.state = { + isAuthNeeded: true + }; + + this.valuesCache = { ...props.configuration }; + } + + componentDidMount = () => { + const { + subscribeToFocusChange, + validator + } = this.props; + + const { + onAppFocusChange + } = this; + + subscribeToFocusChange(onAppFocusChange); + // validator.resetCancel(); + } + + componentWillUnmount = () => { + this.props.unsubscribeFromFocusChange(); + } + + isConnectionError(code) { + return code === 'NOT_FOUND' || code === 'CONNECTION_FAILED' || code === 'NO_INTERNET_CONNECTION'; + } + + checkEndpointURLConnectivity = async (skipError) => { + // const { + // valuesCache, + // setFieldErrorCache, + // externalErrorCodeCache, + // isConnectionError + // } = this; + + // if (isConnectionError(externalErrorCodeCache) || skipError) { + + // const validationResult = await this.props.validator.validateConnection(valuesCache.endpoint); + + // if (!hasKeys(validationResult)) { + + // this.externalErrorCodeCache = null; + // this.props.validator.clearEndpointURLError(setFieldErrorCache); + // } else { + + // const { code } = validationResult; + + // if (isConnectionError(code) && code !== this.externalErrorCodeCache) { + // this.props.validator.updateEndpointURLError(code, setFieldErrorCache); + // } + + // this.externalErrorCodeCache = code; + // } + // } + } + + onSetFieldValueReceived = () => { + + // Initial endpoint URL validation. Note that this is not a form validation + // and should affect only the Endpoint URL field. + return this.checkEndpointURLConnectivity(true); + } + + onAppFocusChange = () => { + + // User may fix connection related errors by focusing out from app (turn on wifi, start server etc.) + // In that case we want to check if errors are fixed when the users focuses back on to the app. + return this.checkEndpointURLConnectivity(); + } + + onClose = (action = 'cancel', data = null, shouldOverrideCredentials = false) => { + + if (shouldOverrideCredentials) { + + const { valuesCache } = this; + const { endpoint } = valuesCache; + const { + username, password, token, rememberCredentials + } = endpoint; + + if (rememberCredentials) { + this.props.saveCredentials({ username, password, token }); + } else { + this.props.removeCredentials(); + } + } + + this.props.onClose(action, data); + } + + onSubmit = async (values, { setFieldError }) => { + const { + endpoint + } = values; + + const connectionValidation = await this.props.validator.validateConnection(endpoint); + + if (!hasKeys(connectionValidation)) { + this.externalErrorCodeCache = null; + this.onClose('deploy', values); + } else { + + const { + details, + code + } = connectionValidation; + + if (code === 'UNAUTHORIZED') { + this.setState({ + isAuthNeeded: true + }); + } + + this.externalErrorCodeCache = code; + this.props.validator.onExternalError(values.endpoint.authType, details, code, setFieldError); + } + } + + fieldError = (meta) => { + return meta.error; + } + + setAuthType = (form) => { + + return event => { + + const authType = event.target.value; + + const { + values, + setValues + } = form; + + let { + endpoint + } = values; + + if (authType !== AuthTypes.basic) { + endpoint = omit(endpoint, ['username', 'password']); + } + + if (authType !== AuthTypes.bearer) { + endpoint = omit(endpoint, ['token']); + } + + setValues({ + ...values, + endpoint: { + ...endpoint, + authType + } + }); + }; + + } + + onAuthDetection = (isAuthNeeded) => { + this.setState({ + isAuthNeeded + }); + } + + render() { + + const { + fieldError, + onSubmit, + onClose, + onSetFieldValueReceived, + onAuthDetection + } = this; + + const { + configuration: values, + validator, + title, + intro, + primaryAction + } = this.props; + + return ( + onClose('cancel', null, false)} + anchor={this.props.anchor} + minWidth={500} + > + + {form => { + + this.valuesCache = { ...form.values }; + if (!this.setFieldErrorCache) { + this.setFieldErrorCache = form.setFieldError; + onSetFieldValueReceived(); + } + + return ( +
+ + + { + title || 'Deploy Diagram to Camunda Platform' + } + +
+ + + { + intro && ( +

+ {intro} +

+ ) + } +
+
+ + { + return validator.validateDeploymentName(value, this.isOnBeforeSubmit); + }} + autoFocus + /> + + +
+
+
+
+
+ + Endpoint Configuration + + +
+
+ + { + // this.externalErrorCodeCache = null; + // return validator.validateEndpointURL( + // value, + // form.setFieldError, + // this.isOnBeforeSubmit, + // onAuthDetection, + // (code) => { this.externalErrorCodeCache = code; } + // ); + // } } + /> + + { + form.handleChange(event); + this.setAuthType(form); + }} + values={ + [ + { value: AuthTypes.basic, label: 'HTTP Basic' }, + { value: AuthTypes.bearer, label: 'Bearer token' } + ] + } + /> + + {form.values.endpoint.authType === AuthTypes.basic && ( + { + return validator.validateUsername(value || '', this.isOnBeforeSubmit); + }} + label="Username" + /> + { + return validator.validatePassword(value || '', this.isOnBeforeSubmit); + }} + label="Password" + type="password" + /> + )} + + {form.values.endpoint.authType === AuthTypes.bearer && ( { + return validator.validateToken(value || '', this.isOnBeforeSubmit); + }} + label="Token" + />)} + + {/* { + isAuthNeeded && ( + + ) + } */} +
+
+ +
+
+
+ + Include additional files + + +
+ +
+ + + +
+ +
+
+ ); + }} +
+
+ ); + } +} + +function hasKeys(obj) { + return obj && Object.keys(obj).length > 0; +} diff --git a/camunda-modeler-deployment-plugin/client/camunda/CamundaDeploymentPlugin.js b/camunda-modeler-deployment-plugin/client/camunda/CamundaDeploymentPlugin.js new file mode 100644 index 00000000..4ad75019 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/camunda/CamundaDeploymentPlugin.js @@ -0,0 +1,528 @@ +import React, {PureComponent} from 'react'; +import {Fill} from 'camunda-modeler-plugin-helpers/components'; +import CamundaDeploymentOverlay from './CamundaDeploymentOverlay'; +import AuthTypes from '../shared/AuthTypes'; +import generateId from '../shared/generate-id'; +import {ConnectionError, default as CamundaAPI, DeploymentError} from '../shared/CamundaAPI'; +import {omit} from 'min-dash'; +import DeploymentConfigValidator from './validation/DeploymentConfigValidator'; + + +const DEPLOYMENT_DETAILS_CONFIG_KEY = 'camunda-deployment-tool'; +const ENGINE_ENDPOINTS_CONFIG_KEY = 'camundaEngineEndpoints'; +const PROCESS_DEFINITION_CONFIG_KEY = 'process-definition'; +const DEFAULT_ENDPOINT = { + url: 'http://localhost:8080/engine-rest', + authType: AuthTypes.basic, + rememberCredentials: true +}; +const ET_EXECUTION_PLATFORM_NAME = 'Camunda Platform'; +const FORMIO_TYPE = '.formio'; +const BPMN_TYPE = '.bpmn'; + +export default class CamundaDeploymentPlugin extends PureComponent { + constructor(props) { + super(props); + this.btnRef = React.createRef(); + } + + state = { + modalState: null, + activeTab: null + } + + validator = new DeploymentConfigValidator(); + + componentDidMount() { + this.props.subscribe('app.activeTabChanged', ({ activeTab }) => { + this.setState({ activeTab }); + }); + + this.props.subscribe('app.focus-changed', () => { + if (this.focusChangeCallback) { + this.focusChangeCallback(); + } + }); + } + + subscribeToFocusChange = (callback) => { + this.focusChangeCallback = callback; + } + + unsubscribeFromFocusChange = () => { + delete this.focusChangeCallback; + } + + saveTab = (tab) => { + const { + triggerAction + } = this.props; + + return triggerAction('save-tab', { tab }); + } + + async getSavedConfiguration(tab) { + + const tabConfig = await this.getTabConfiguration(tab); + + if (!tabConfig) { + return undefined; + } + + const { + deployment, + endpointId + } = tabConfig; + + const deploymentWithAttachments = await this.withAttachments(deployment); + + const endpoints = await this.getEndpoints(); + + return { + deployment: deploymentWithAttachments, + endpoint: endpoints.find(endpoint => endpoint.id === endpointId) + }; + } + + async saveConfiguration(tab, configuration) { + + const { + endpoint, + deployment + } = configuration; + + await this.saveEndpoint(endpoint); + + const tabConfiguration = { + deployment: withSerializedAttachments(deployment), + endpointId: endpoint.id + }; + + await this.setTabConfiguration(tab, tabConfiguration); + + return configuration; + } + + async getDefaultConfiguration(tab, providedConfiguration = {}) { + const endpoint = await this.getDefaultEndpoint(tab, providedConfiguration.endpoint); + + const deployment = providedConfiguration.deployment || {}; + const deploymentWithAttachments = await this.withAttachments({ + name: withoutExtension(tab.name), + attachments: [ + { + path: this.getTemplateFilePath(tab) + } + ], + ...deployment + }); + + return { + endpoint, + deployment: deploymentWithAttachments + }; + } + + async getDefaultEndpoint(tab, providedEndpoint) { + + let endpoint = {}, + defaultUrl = DEFAULT_ENDPOINT.url; + + if (providedEndpoint) { + endpoint = providedEndpoint; + } else { + + const existingEndpoints = await this.getEndpoints(); + + if (existingEndpoints.length) { + endpoint = existingEndpoints[0]; + } + } + + // since we have deprecated AuthTypes.none, we should correct existing + // configurations + if (endpoint.authType !== AuthTypes.basic && endpoint.authType !== AuthTypes.bearer) { + endpoint.authType = DEFAULT_ENDPOINT.authType; + } + + return { + ...DEFAULT_ENDPOINT, + url: defaultUrl, + ...endpoint, + id: endpoint.id || generateId() + }; + } + + async getConfigurationFromUserInput(tab, providedConfiguration, uiOptions) { + const configuration = await this.getDefaultConfiguration(tab, providedConfiguration); + + return new Promise(resolve => { + const handleClose = (action, configuration) => { + + this.setState({ + modalState: null + }); + + // contract: if configuration provided, user closed with O.K. + // otherwise they canceled it + return resolve({ action, configuration }); + }; + + this.setState({ + modalState: { + tab, + configuration, + handleClose, + ...uiOptions + } + }); + }); + } + + getEndpoints() { + return this.props.config.get(ENGINE_ENDPOINTS_CONFIG_KEY, []); + } + + setEndpoints(endpoints) { + return this.props.config.set(ENGINE_ENDPOINTS_CONFIG_KEY, endpoints); + } + + async withAttachments(deployment) { + const fileSystem = this.props._getGlobal('fileSystem'); + const { attachments = [] } = deployment; + + async function readFile(path) { + try { + + // (1) try to read file from file system + const file = await fileSystem.readFile(path, { encoding: false }); + + // (2a) store contents as a File object + // @barmac: This is required for the performance reasons. The contents retrieved from FS + // is a Uint8Array. During the form submission, Formik builds a map of touched fields + // and it traverses all nested objects for that. The outcome was that the form would freeze + // for a couple of seconds when one tried to re-deploy a file of size >1MB, because Formik + // tried to build a map with bits' indexes as keys with all values as `true`. Wrapping the + // contents in a File object prevents such behavior. + return { + ...file, + contents: new File([ file.contents ], file.name) + }; + } catch { + + // (2b) if read fails, return an empty file descriptor + return { + contents: null, + path, + name: basename(path) + }; + } + } + + const files = await Promise.all(attachments.map(({ path }) => readFile(path))); + + return { + ...deployment, + attachments: files.filter(f => f.contents) + }; + } + + replaceFileExt(path, srcType, dstType) { + return (path) ? path.substring(0, path.lastIndexOf(srcType)) + dstType : null; + } + + getTemplateFileName(tab) { + return (tab.file && tab.file.path) + ? this.replaceFileExt(tab.file.name, BPMN_TYPE, FORMIO_TYPE) + : null; + } + + getTemplateFilePath(tab) { + return (tab.file) + ? this.replaceFileExt(tab.file.path, BPMN_TYPE, FORMIO_TYPE) + : null; + } + + getTabConfiguration(tab) { + return this.props.config.getForFile(tab.file, DEPLOYMENT_DETAILS_CONFIG_KEY); + } + + setTabConfiguration(tab, configuration) { + return this.props.config.setForFile(tab.file, DEPLOYMENT_DETAILS_CONFIG_KEY, configuration); + } + + async saveEndpoint(endpoint) { + + const existingEndpoints = await this.getEndpoints(); + const updatedEndpoints = addOrUpdateById(existingEndpoints, endpoint); + + await this.setEndpoints(updatedEndpoints); + return endpoint; + } + + getVersion(configuration) { + const { endpoint } = configuration; + const api = new CamundaAPI(endpoint); + return api.getVersion(); + } + + deploy = (options) => { + const { + activeTab + } = this.state; + + return this.deployTab(activeTab, options); + }; + + async deployTab(tab, options={}) { + + const { + configure + } = options; + + // (1) Open save file dialog if dirty + tab = await this.saveTab(tab); + + // (1.1) Cancel deploy if file save cancelled + if (!tab) { + return; + } + + // (2) Get deployment configuration + // (2.1) Try to get existing deployment configuration + let configuration = await this.getSavedConfiguration(tab); + + // (2.3) Open modal to enter deployment configuration + const { + action, + configuration: userConfiguration + } = await this.getConfigurationFromUserInput(tab, configuration); + + // (2.3.1) Handle user cancelation + if (action === 'cancel') { + return; + } + + configuration = await this.saveConfiguration(tab, userConfiguration); + + if (action === 'save') { + return; + } + + // (3) Trigger deployment + let version; + + try { + + // (3.1) Retrieve version we deploy to via API + try { + version = (await this.getVersion(configuration)).version; + } catch (error) { + if (!(error instanceof ConnectionError)) { + throw error; + } + version = null; + } + + // (3.2) Deploy via API + const deployment = await this.deployWithConfiguration(tab, configuration); + + // (3.3) save deployed process definition + await this.saveProcessDefinition(tab, deployment); + + // (3.4) Handle deployment success or error + await this.handleDeploymentSuccess(tab, deployment, version); + } catch (error) { + + if (!(error instanceof DeploymentError)) { + throw error; + } + + await this.handleDeploymentError(tab, error, version); + } + } + + handleDeploymentError(tab, error, version) { + const { + log, + displayNotification, + triggerAction + } = this.props; + + displayNotification({ + type: 'error', + title: 'Deployment failed', + content: 'See the log for further details.', + duration: 10000 + }); + + log({ + category: 'deploy-error', + message: error.problems || error.details || error.message + }); + + // If we retrieved the executionPlatformVersion, include it in event + const deployedTo = (version && + { executionPlatformVersion: version, executionPlatform: ET_EXECUTION_PLATFORM_NAME }) || undefined; + + // notify interested parties + triggerAction('emit-event', { + type: 'deployment.error', + payload: { + error, + context: 'deploymentTool', + ...(deployedTo && { deployedTo: deployedTo }) + } + }); + } + + deployWithConfiguration(tab, configuration) { + + const { + endpoint, + deployment + } = configuration; + + const api = new CamundaAPI(endpoint); + + return api.deployDiagram(tab.file, deployment); + } + + async saveProcessDefinition(tab, deployment) { + + if (!deployment || !deployment.deployedProcessDefinition) { + return; + } + + const { + deployedProcessDefinition: processDefinition + } = deployment; + + const { + config + } = this.props; + + return await config.setForFile(tab.file, PROCESS_DEFINITION_CONFIG_KEY, processDefinition); + } + + handleDeploymentSuccess(tab, deployment, version) { + const { + displayNotification, + triggerAction + } = this.props; + + displayNotification({ + type: 'success', + title: 'Deployment succeeded', + duration: 4000 + }); + + // notify interested parties + triggerAction('emit-event', { + type: 'deployment.done', + payload: { + deployment, + deployedTo: { + executionPlatformVersion: version, + executionPlatform: ET_EXECUTION_PLATFORM_NAME + }, + context: 'deploymentTool' + } + }); + } + + render() { + + const { + activeTab, + modalState + } = this.state; + + const deploy = () => this.deploy({ configure: true }); + // const deployService = new DeployService(this.deployRef); + + const handleClick = () => { + if(modalState) { + modalState.handleClose('cancel', null, false); + return; + } + + deploy(); + } + + return + { isCamundaTab(activeTab) && ( + + )} + + {/**/} + + {/**/} + {modalState && + + } + ; + } +} + + +// helpers ////////// + +function withoutExtension(name) { + return name.replace(/\.[^.]+$/, ''); +} + +function withoutCredentials(endpoint) { + return omit(endpoint, ['username', 'password', 'token']); +} + +function addOrUpdateById(collection, element) { + + const index = collection.findIndex(el => el.id === element.id); + + if (index !== -1) { + return [ + ...collection.slice(0, index), + element, + ...collection.slice(index + 1) + ]; + } + + return [ + ...collection, + element + ]; +} + +function isCamundaTab(tab) { + return tab && tab.type === 'bpmn'; +} + +function withSerializedAttachments(deployment) { + const { attachments: fileList = [] } = deployment; + const attachments = fileList.map(file => ({ path: file.path })); + + return { ...deployment, attachments }; +} + +function basename(filePath) { + return filePath.split('\\').pop().split('/').pop(); +} diff --git a/camunda-modeler-deployment-plugin/client/camunda/validation/BaseInputValidator.js b/camunda-modeler-deployment-plugin/client/camunda/validation/BaseInputValidator.js new file mode 100644 index 00000000..27f176a8 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/camunda/validation/BaseInputValidator.js @@ -0,0 +1,54 @@ +/** + * 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. + */ + +export default class BaseInputValidator { + + constructor(fieldName) { + this.fieldName = fieldName; + + this._cachedValidatonResult = null; + } + + clearError = (setFieldError) => { + this.setCachedValidationResult(null); + setFieldError(this.fieldName, null); + } + + updateError = (setFieldError, errorMessage) => { + this.setCachedValidationResult(errorMessage); + setFieldError(this.fieldName, errorMessage); + } + + getCachedValue = () => { + return this._cachedValue; + } + + setCachedValue = (value) => { + this._cachedValue = value; + } + + onExternalError = (details, setFieldError) => { + setFieldError(this.fieldName, details); + + this.setCachedValidationResult(details); + } + + getCachedValidationResult = () => { + return this._cachedValidatonResult; + } + + setCachedValidationResult = (value) => { + this._cachedValidatonResult = value; + } + + invalidateCachedValidationResult = () => { + this._cachedValidatonResult = null; + } +} diff --git a/camunda-modeler-deployment-plugin/client/camunda/validation/DefaultInputValidator.js b/camunda-modeler-deployment-plugin/client/camunda/validation/DefaultInputValidator.js new file mode 100644 index 00000000..631c3e96 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/camunda/validation/DefaultInputValidator.js @@ -0,0 +1,77 @@ +/** + * 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 BaseInputValidator from './BaseInputValidator'; + +export default class DefaultInputValidator extends BaseInputValidator { + + // This validator validates input fields: + // Not initially + // Only when the form is submitted + // + // This validator is also "forgiving", it means that + // once the user types anything on a non-validated field + // the error dissapears and won't be shown until the form + // is submitted again. + + constructor(fieldName, validateNonEmpty, text) { + + super(fieldName); + + this.validateNonEmpty = validateNonEmpty; + this.text = text; + } + + _validate = (value, forceRecheck) => { + const { + text, + validateNonEmpty, + getCachedValidationResult, + setCachedValidationResult + } = this; + + if (forceRecheck) { + const result = validateNonEmpty(value, text); + setCachedValidationResult(result); + return result; + } + return getCachedValidationResult(); + } + + validate = (value, isOnBeforeSubmit) => { + + const { + getCachedValue, + setCachedValue, + invalidateCachedValidationResult, + _validate + } = this; + + // always force validation before submit + if (isOnBeforeSubmit) { + setCachedValue(value); + return _validate(value, true); + } + + // user is typing on the field + if (value !== getCachedValue()) { + setCachedValue(value); + invalidateCachedValidationResult(); + return null; + } + + // user is not typing on the field. + if (value === getCachedValue()) { + return _validate(value, false); + } + + setCachedValue(value); + } +} diff --git a/camunda-modeler-deployment-plugin/client/camunda/validation/DeploymentConfigValidator.js b/camunda-modeler-deployment-plugin/client/camunda/validation/DeploymentConfigValidator.js new file mode 100644 index 00000000..c0b03e58 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/camunda/validation/DeploymentConfigValidator.js @@ -0,0 +1,242 @@ +/** + * 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 AuthTypes from '../../shared/AuthTypes'; + +import { default as CamundaAPI, ApiErrorMessages } from '../../shared/CamundaAPI'; + +import EndpointURLValidator from './EndpointURLValidator'; + +import DefaultInputValidator from './DefaultInputValidator'; + +export default class DeploymentConfigValidator { + + constructor() { + this.endpointURLValidator = new EndpointURLValidator( + 'endpoint.url', + this.validateNonEmpty, + this.validatePattern, + this.validateConnectionWithoutCredentials + ); + + this.deploymentNameValidator = new DefaultInputValidator( + 'deployment.name', + this.validateNonEmpty, + 'Deployment name must not be empty.' + ); + + this.usernameValidator = new DefaultInputValidator( + 'endpoint.username', + this.validateNonEmpty, + 'Credentials are required to connect to the server.' + ); + + this.passwordValidator = new DefaultInputValidator( + 'endpoint.password', + this.validateNonEmpty, + 'Credentials are required to connect to the server.' + ); + + this.tokenValidator = new DefaultInputValidator( + 'endpoint.token', + this.validateNonEmpty, + 'Token must not be empty.' + ); + + this.lastConnectionCheckID = 0; + } + + resetCancel = () => { + this.endpointURLValidator.resetCancel(); + } + + cancel = () => { + this.endpointURLValidator.cancel(); + } + + onExternalError = (authType, details, code, setFieldError) => { + if (code === 'UNAUTHORIZED') { + if (authType === AuthTypes.basic) { + this.usernameValidator.onExternalError(details, setFieldError); + this.passwordValidator.onExternalError(details, setFieldError); + } else { + this.tokenValidator.onExternalError(details, setFieldError); + } + } else { + this.endpointURLValidator.onExternalError(details, setFieldError); + } + } + + validateEndpointURL = (value, setFieldError, isOnBeforeSubmit, onAuthDetection, onConnectionStatusUpdate) => { + return this.endpointURLValidator.validate( + value, setFieldError, isOnBeforeSubmit, onAuthDetection, onConnectionStatusUpdate + ); + } + + validatePattern = (value, pattern, message) => { + const matches = pattern.test(value); + + return matches ? null : message; + } + + validateNonEmpty = (value, message = 'Must provide a value.') => { + return value ? null : message; + } + + validateDeploymentName = (value, isOnBeforeSubmit) => { + return this.deploymentNameValidator.validate(value, isOnBeforeSubmit); + } + + validateToken = (value, isOnBeforeSubmit) => { + return this.tokenValidator.validate(value, isOnBeforeSubmit); + } + + validatePassword = (value, isOnBeforeSubmit) => { + return this.passwordValidator.validate(value, isOnBeforeSubmit); + } + + validateUsername = (value, isOnBeforeSubmit) => { + return this.usernameValidator.validate(value, isOnBeforeSubmit); + } + + validateDeployment(deployment = {}) { + return this.validate(deployment, { + name: this.validateDeploymentName, + attachments: this.validateAttachments + }); + } + + validateEndpoint(endpoint = {}) { + + return this.validate(endpoint, { + url: this.validateEndpointURL, + token: endpoint.authType === AuthTypes.bearer && this.validateToken, + password: endpoint.authType === AuthTypes.basic && this.validatePassword, + username: endpoint.authType === AuthTypes.basic && this.validateUsername + }); + } + + validateAttachments(attachments = []) { + const errors = attachments.map(({ contents }) => { + if (contents === null) { + return 'File cannot be found.'; + } + }); + + return errors.some(error => !!error) ? errors : undefined; + } + + validate(values, validators) { + + const errors = {}; + + for (const [ attr, validator ] of Object.entries(validators)) { + + if (!validator) { + continue; + } + + const error = validator(values[attr]); + + if (error) { + errors[attr] = error; + } + } + + return errors; + } + + validateConnection = async endpoint => { + + const api = new CamundaAPI(endpoint); + + try { + await api.checkConnection(); + } catch (error) { + return error; + } + + return null; + } + + validateConnectionWithoutCredentials = async (url) => { + this.lastConnectionCheckID ++; + const lastConnectionCheckID = this.lastConnectionCheckID; + const result = await this.validateConnection({ url }); + + if (this.lastConnectionCheckID != lastConnectionCheckID) { + + // URL has changed while we were waiting for the response of an older request + return { isExpired: true }; + } + return result; + } + + clearEndpointURLError = (setFieldError) => { + this.endpointURLValidator.clearError(setFieldError); + } + + updateEndpointURLError = (code, setFieldError) => { + + const errorMessage = ApiErrorMessages[code]; + this.endpointURLValidator.updateError(setFieldError, errorMessage); + } + + validateBasic(configuration) { + + const { + deployment, + endpoint + } = configuration; + + const deploymentErrors = this.validateDeployment(deployment); + const endpointErrors = this.validateEndpoint(endpoint); + + return filterErrors({ + deployment: deploymentErrors, + endpoint: endpointErrors + }); + } + + /** + * Check if configuration is valid. + * + * @param {*} configuration + * @returns {boolean} + */ + isConfigurationValid(configuration) { + if (!configuration) { + return false; + } + + const errors = this.validateBasic(configuration); + + return !hasKeys(errors); + } +} + + +// helpers ///////////////// + +function hasKeys(obj) { + return obj && Object.keys(obj).length > 0; +} + +function filterErrors(errors) { + + return Object.entries(errors).reduce((filtered, [ key, value ]) => { + + if (value && hasKeys(value)) { + filtered[key] = value; + } + + return filtered; + }, {}); +} diff --git a/camunda-modeler-deployment-plugin/client/camunda/validation/EndpointURLValidator.js b/camunda-modeler-deployment-plugin/client/camunda/validation/EndpointURLValidator.js new file mode 100644 index 00000000..eaa9b9c6 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/camunda/validation/EndpointURLValidator.js @@ -0,0 +1,131 @@ +/** + * 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 BaseInputValidator from './BaseInputValidator'; + +export default class EndpointURLValidator extends BaseInputValidator { + + constructor(fieldName, validateNonEmpty, validatePattern, validateConnectionWithoutCredentials) { + + super(fieldName); + + this.validateNonEmpty = validateNonEmpty; + this.validatePattern = validatePattern; + this.validateConnectionWithoutCredentials = validateConnectionWithoutCredentials; + + this.isDirty = false; + + this.timeoutID = null; + } + + resetCancel = () => { + this.isCanceled = false; + } + + cancel = () => { + this.isCanceled = true; + this.clearTimeout(); + } + + validateEndpointURLCompleteness(value) { + const trimmed = value.trim(); + + if (trimmed === 'http://' || trimmed === 'https://') { + return 'Should point to a running Camunda Platform REST API.'; + } + + return null; + } + + setFieldError = (value, setFieldErrorMethod) => { + this.setCachedValidationResult(value); + setFieldErrorMethod('endpoint.url', value); + } + + clearTimeout() { + if (this.timeoutID !== null) { + clearTimeout(this.timeoutID); + this.timeoutID = null; + } + } + + setTimeout(value, setFieldError, onAuthDetection, onConnectionStatusUpdate) { + this.timeoutID = setTimeout(async () => { + const completenessValidation = this.validateEndpointURLCompleteness(value); + + if (completenessValidation) { + return this.setFieldError(completenessValidation, setFieldError); + } + + const connectionValidation = await this.validateConnectionWithoutCredentials(value); + + if (connectionValidation) { + const { + code, + details, + isExpired + } = connectionValidation; + + if (isExpired || this.isCanceled) { + return; + } + + onConnectionStatusUpdate(code); + onAuthDetection(code === 'UNAUTHORIZED'); + + if (code !== 'UNAUTHORIZED') { + return this.setFieldError(details, setFieldError); + } + } else { + + // auth not needed + onAuthDetection(false); + onConnectionStatusUpdate(null); + } + }, this.isDirty ? 1000 : 0); + } + + validate(value = '', setFieldError, isOnBeforeSubmit, onAuthDetection, onConnectionStatusUpdate) { + + const { + getCachedValue, + setCachedValue, + getCachedValidationResult, + setCachedValidationResult + } = this; + + if (getCachedValue() === value && !isOnBeforeSubmit) { + return getCachedValidationResult(); + } + + setCachedValue(value); + + const nonEmptyValidation = this.validateNonEmpty(value, 'Endpoint URL must not be empty.'); + const patternValidation = this.validatePattern(value, /^https?:\/\//, 'Endpoint URL must start with "http://" or "https://".'); + const completenessValidation = this.validateEndpointURLCompleteness(value); + + this.clearTimeout(); + + if (!isOnBeforeSubmit) { + + setCachedValidationResult(nonEmptyValidation || patternValidation || null); + + if (!getCachedValidationResult()) { + this.setTimeout(value, setFieldError, onAuthDetection, onConnectionStatusUpdate); + } + } else { + + setCachedValidationResult(nonEmptyValidation || patternValidation || completenessValidation); + } + + this.isDirty = true; + return getCachedValidationResult(); + } +} diff --git a/camunda-modeler-deployment-plugin/client/camunda/validation/index.js b/camunda-modeler-deployment-plugin/client/camunda/validation/index.js new file mode 100644 index 00000000..350e4973 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/camunda/validation/index.js @@ -0,0 +1,11 @@ +/** + * 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. + */ + +export { default } from './DeploymentConfigValidator.js'; diff --git a/camunda-modeler-deployment-plugin/client/index.js b/camunda-modeler-deployment-plugin/client/index.js new file mode 100644 index 00000000..2e12b43a --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/index.js @@ -0,0 +1,4 @@ +import {registerClientExtension} from 'camunda-modeler-plugin-helpers'; +import CamundaDeploymentPlugin from './camunda/CamundaDeploymentPlugin'; + +registerClientExtension(CamundaDeploymentPlugin); diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/Arrow.svg b/camunda-modeler-deployment-plugin/client/resources/icons/Arrow.svg new file mode 100644 index 00000000..0fcb12cc --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/Arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/Close.svg b/camunda-modeler-deployment-plugin/client/resources/icons/Close.svg new file mode 100644 index 00000000..0f98072f --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/Close.svg @@ -0,0 +1,3 @@ + + + diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/Create.svg b/camunda-modeler-deployment-plugin/client/resources/icons/Create.svg new file mode 100644 index 00000000..057fad9e --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/Create.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/Delete.svg b/camunda-modeler-deployment-plugin/client/resources/icons/Delete.svg new file mode 100644 index 00000000..1977c4bc --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/Delete.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/Error.svg b/camunda-modeler-deployment-plugin/client/resources/icons/Error.svg new file mode 100644 index 00000000..41d0180b --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/Error.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/Feedback.svg b/camunda-modeler-deployment-plugin/client/resources/icons/Feedback.svg new file mode 100644 index 00000000..b41e87b2 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/Feedback.svg @@ -0,0 +1,3 @@ + + + diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/LinkArrow.svg b/camunda-modeler-deployment-plugin/client/resources/icons/LinkArrow.svg new file mode 100644 index 00000000..50385693 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/LinkArrow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/Play.svg b/camunda-modeler-deployment-plugin/client/resources/icons/Play.svg new file mode 100644 index 00000000..bcd81f7a --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/Play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/Warning.svg b/camunda-modeler-deployment-plugin/client/resources/icons/Warning.svg new file mode 100644 index 00000000..8abce590 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/Warning.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/file-types/BPMN.svg b/camunda-modeler-deployment-plugin/client/resources/icons/file-types/BPMN.svg new file mode 100644 index 00000000..6e193157 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/file-types/BPMN.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/file-types/DMN.svg b/camunda-modeler-deployment-plugin/client/resources/icons/file-types/DMN.svg new file mode 100644 index 00000000..9872a6d7 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/file-types/DMN.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/resources/icons/file-types/Form.svg b/camunda-modeler-deployment-plugin/client/resources/icons/file-types/Form.svg new file mode 100644 index 00000000..c74176ad --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/resources/icons/file-types/Form.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/shared/AuthTypes.js b/camunda-modeler-deployment-plugin/client/shared/AuthTypes.js new file mode 100644 index 00000000..57750e5c --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/shared/AuthTypes.js @@ -0,0 +1,16 @@ +/** + * 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. + */ + +const AuthTypes = { + basic: 'basic', + bearer: 'bearer' +}; + +export default AuthTypes; diff --git a/camunda-modeler-deployment-plugin/client/shared/CamundaAPI.js b/camunda-modeler-deployment-plugin/client/shared/CamundaAPI.js new file mode 100644 index 00000000..7fa9b8a3 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/shared/CamundaAPI.js @@ -0,0 +1,358 @@ +/** + * 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 AuthTypes from './AuthTypes'; + +import debug from 'debug'; + +const FETCH_TIMEOUT = 5000; + +const log = debug('CamundaAPI'); + + +export default class CamundaAPI { + + constructor(endpoint) { + + this.baseUrl = normalizeBaseURL(endpoint.url); + + this.authentication = this.getAuthentication(endpoint); + } + + async deployDiagram(diagram, deployment) { + const { + name, + tenantId, + attachments = [] + } = deployment; + + const form = new FormData(); + + form.append('deployment-name', name); + form.append('deployment-source', 'Camunda Modeler'); + + // make sure that we do not re-deploy already existing deployment + form.append('enable-duplicate-filtering', 'true'); + + if (tenantId) { + form.append('tenant-id', tenantId); + } + + const diagramName = diagram.name; + + const blob = new Blob([ diagram.contents ], { type: 'text/xml' }); + + form.append(diagramName, blob, diagramName); + + attachments.forEach(file => { + form.append(file.name, new Blob([ file.contents ]), file.name); + }); + + const response = await this.fetch('/deployment/create', { + method: 'POST', + body: form + }); + + if (response.ok) { + + const { + id, + deployedProcessDefinitions + } = await response.json(); + + return { + id, + deployedProcessDefinitions, + deployedProcessDefinition: Object.values(deployedProcessDefinitions || {})[0] + }; + } + + const body = await this.parse(response); + + throw new DeploymentError(response, body); + } + + async startInstance(processDefinition, options) { + + const { + businessKey, + variables + } = options; + + const response = await this.fetch(`/process-definition/${processDefinition.id}/start`, { + method: 'POST', + body: JSON.stringify({ + businessKey, + variables + }), + headers: { + 'content-type': 'application/json' + } + }); + + if (response.ok) { + return await response.json(); + } + + const body = await this.parse(response); + + throw new StartInstanceError(response, body); + } + + async checkConnection() { + + const response = await this.fetch('/deployment?maxResults=0'); + + if (response.ok) { + return; + } + + throw new ConnectionError(response); + } + + async getVersion() { + + const response = await this.fetch('/version'); + + if (response.ok) { + const { version } = await response.json(); + return { + version: version + }; + } + + throw new ConnectionError(response); + } + + getAuthentication(endpoint) { + + const { + authType, + username, + password, + token + } = endpoint; + + switch (authType) { + case AuthTypes.basic: + return { + username, + password + }; + case AuthTypes.bearer: + return { + token + }; + } + } + + getHeaders() { + const headers = { + accept: 'application/json' + }; + + if (this.authentication) { + headers.authorization = this.getAuthHeader(this.authentication); + } + + return headers; + } + + getAuthHeader(endpoint) { + + const { + token, + username, + password + } = endpoint; + + if (token) { + return `Bearer ${token}`; + } + + if (username && password) { + const credentials = window.btoa(`${username}:${password}`); + + return `Basic ${credentials}`; + } + } + + async fetch(path, options = {}) { + const url = `${this.baseUrl}${path}`; + const headers = { + ...options.headers, + ...this.getHeaders() + }; + + try { + const signal = options.signal || this.setupTimeoutSignal(); + + return await fetch(url, { + ...options, + headers, + signal + }); + } catch (error) { + log('failed to fetch', error); + + return { + url, + json: () => { + return {}; + } + }; + } + } + + setupTimeoutSignal(timeout = FETCH_TIMEOUT) { + const controller = new AbortController(); + + setTimeout(() => controller.abort(), timeout); + + return controller.signal; + } + + async parse(response) { + try { + const json = await response.json(); + + return json; + } catch (error) { + return {}; + } + } +} + +const NO_INTERNET_CONNECTION = 'NO_INTERNET_CONNECTION'; +const CONNECTION_FAILED = 'CONNECTION_FAILED'; +const DIAGRAM_PARSE_ERROR = 'DIAGRAM_PARSE_ERROR'; +const UNAUTHORIZED = 'UNAUTHORIZED'; +const FORBIDDEN = 'FORBIDDEN'; +const NOT_FOUND = 'NOT_FOUND'; +const INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR'; +const UNAVAILABLE_ERROR = 'UNAVAILABLE_ERROR'; + +export const ApiErrors = { + NO_INTERNET_CONNECTION, + CONNECTION_FAILED, + DIAGRAM_PARSE_ERROR, + UNAUTHORIZED, + FORBIDDEN, + NOT_FOUND, + INTERNAL_SERVER_ERROR, + UNAVAILABLE_ERROR +}; + +export const ApiErrorMessages = { + [ NO_INTERNET_CONNECTION ]: 'Could not establish a network connection.', + [ CONNECTION_FAILED ]: 'Should point to a running Camunda Platform REST API.', + [ DIAGRAM_PARSE_ERROR ]: 'Server could not parse the diagram. Please check log for errors.', + [ UNAUTHORIZED ]: 'Credentials do not match with the server.', + [ FORBIDDEN ]: 'This user is not permitted to deploy. Please use different credentials or get this user enabled to deploy.', + [ NOT_FOUND ]: 'Should point to a running Camunda Platform REST API.', + [ INTERNAL_SERVER_ERROR ]: 'Camunda is reporting an error. Please check the server status.', + [ UNAVAILABLE_ERROR ]: 'Camunda is reporting an error. Please check the server status.' +}; + +export class ConnectionError extends Error { + + constructor(response) { + super('Connection failed'); + + this.code = ( + getResponseErrorCode(response) || + getNetworkErrorCode(response) + ); + + this.details = ApiErrorMessages[this.code]; + } +} + + +export class DeploymentError extends Error { + + constructor(response, body) { + super('Deployment failed'); + + this.code = ( + getCamundaErrorCode(response, body) || + getResponseErrorCode(response) || + getNetworkErrorCode(response) + ); + + this.details = ApiErrorMessages[this.code]; + + this.problems = body && body.message; + } +} + +export class StartInstanceError extends Error { + + constructor(response, body) { + super('Starting instance failed'); + + this.code = ( + getCamundaErrorCode(response, body) || + getResponseErrorCode(response) || + getNetworkErrorCode(response) + ); + + this.details = ApiErrorMessages[this.code]; + + this.problems = body && body.message; + } +} + + +// helpers /////////////// + +function getNetworkErrorCode(response) { + if (isLocalhost(response.url) || isOnline()) { + return CONNECTION_FAILED; + } + + return NO_INTERNET_CONNECTION; +} + +function getResponseErrorCode(response) { + switch (response.status) { + case 401: + return UNAUTHORIZED; + case 403: + return FORBIDDEN; + case 404: + return NOT_FOUND; + case 500: + return INTERNAL_SERVER_ERROR; + case 503: + return UNAVAILABLE_ERROR; + } +} + +function getCamundaErrorCode(response, body) { + + const PARSE_ERROR_PREFIX = 'ENGINE-09005 Could not parse BPMN process.'; + + if (body && body.message && body.message.startsWith(PARSE_ERROR_PREFIX)) { + return DIAGRAM_PARSE_ERROR; + } +} + +function isLocalhost(url) { + return /^https?:\/\/(127\.0\.0\.1|localhost)/.test(url); +} + +function isOnline() { + return window.navigator.onLine; +} + +function normalizeBaseURL(url) { + return url.replace(/\/deployment\/create\/?/, ''); +} diff --git a/camunda-modeler-deployment-plugin/client/shared/generate-id.js b/camunda-modeler-deployment-plugin/client/shared/generate-id.js new file mode 100644 index 00000000..0b849481 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/shared/generate-id.js @@ -0,0 +1,17 @@ +/** + * 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 Ids from 'ids'; + +const ids = new Ids([ 32, 36, 1 ]); + +export default function generateId() { + return ids.next(); +} diff --git a/camunda-modeler-deployment-plugin/client/shared/ui/FileInput.js b/camunda-modeler-deployment-plugin/client/shared/ui/FileInput.js new file mode 100644 index 00000000..a3ec876c --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/shared/ui/FileInput.js @@ -0,0 +1,168 @@ +/** + * 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 { uniqueBy } from 'min-dash'; + + import { getIn } from 'formik'; + import classNames from 'classnames'; + + import DeteleIcon from '../../resources/icons/Delete.svg'; + import CreateIcon from '../../resources/icons/Create.svg'; + import BPMNIcon from '../../resources/icons/file-types/BPMN.svg'; + import DMNIcon from '../../resources/icons/file-types/DMN.svg'; + import FormIcon from '../../resources/icons/file-types/Form.svg'; + import ErrorIcon from '../../resources/icons/Error.svg'; + + /** + * @typedef FileDescriptor + * @property {Uint8Array|Blob|null} contents + * @property {string} name + * @property {string} path + */ + + export default function FileInput(props) { + const { + field, + form + } = props; + + const { + name, + value, + onBlur + } = field; + + const inputRef = React.useRef(null); + + function onChange() { + const { files } = inputRef.current; + const fileDescriptors = toFileDescriptors(files); + + form.setFieldValue(name, uniqueBy('path', value, fileDescriptors)); + } + + function removeFile(fileToRemove) { + form.setFieldValue(name, field.value.filter(file => file !== fileToRemove)); + } + + return ( +
+ + + + + +
+ ); + } + + function FileList(props) { + const { + errors: formErrors, + fieldName, + files, + onRemove + } = props; + + const invalid = !!getIn(formErrors, fieldName); + + return ( + + ); + } + + function ListItem(props) { + const { error, name, onRemove } = props; + + return ( +
  • + + {getIconFromFileType(name, error)} + + { name } + + + +
  • + ); + } + + + /** + * + * @param {FileList} fileList + * @returns {FileDescriptor} + */ + function toFileDescriptors(fileList) { + return Array.from(fileList) + .map(file => { + return { + name: file.name, + path: file.path, + lastModified: file.lastModified, + contents: file + }; + }); + } + + function getTypeFromFileExtension(name) { + return name.substring(name.lastIndexOf('.') + 1).toLowerCase(); + } + + function getFileLabel(name, error) { + return error ? + `${error.slice(0, -1)}: ${name}` + : name; + } + + function getIconFromFileType(name, error) { + const extension = getTypeFromFileExtension(name); + + if (error) return ; + + switch (extension) { + case 'bpmn': + return ; + case 'dmn': + return ; + case 'form': + return ; + default: + return
    ; + } + } \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/shared/ui/FormFeedback.js b/camunda-modeler-deployment-plugin/client/shared/ui/FormFeedback.js new file mode 100644 index 00000000..105abdd3 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/shared/ui/FormFeedback.js @@ -0,0 +1,28 @@ +/** + * 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'; + + export default function FormFeedback(props) { + + const { + error + } = props; + + return ( + + { error && ( +
    + { error } +
    + ) } +
    + ); + } \ No newline at end of file diff --git a/camunda-modeler-deployment-plugin/client/shared/ui/Radio.js b/camunda-modeler-deployment-plugin/client/shared/ui/Radio.js new file mode 100644 index 00000000..468c4232 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/shared/ui/Radio.js @@ -0,0 +1,92 @@ +/** + * 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 classNames from 'classnames'; + +export default function Radio(props) { + + const { + hint, + label, + field, + form, + children, + values, + ...restProps + } = props; + + const { + name: fieldName + } = field; + + const meta = form.getFieldMeta(fieldName); + + const isChecked = (childValue) => meta.value === childValue; + + return ( + +
    + +
    + { + values.map((child) => { + const id = 'radio-element-' + toKebabCase(child.label); + return ( + +
    + + +
    +
    + ); + }) + } +
    +
    +
    + ); +} + + + +// helper ///// +/** + * Converts text to kebab-case. + * + * @example + * const label = "HTTP Basic"; + * + * // http-basic + * const id = toKebabCase(label); + * + * @param {string} name + */ +function toKebabCase(name) { + return name.toLowerCase().replace(/\s/g, '-'); +} diff --git a/camunda-modeler-deployment-plugin/client/shared/ui/TextInput.js b/camunda-modeler-deployment-plugin/client/shared/ui/TextInput.js new file mode 100644 index 00000000..f8f090d3 --- /dev/null +++ b/camunda-modeler-deployment-plugin/client/shared/ui/TextInput.js @@ -0,0 +1,94 @@ +/** + * 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 classNames from 'classnames'; + + import FormFeedback from './FormFeedback'; + + import { + fieldError as defaultFieldError + } from './Util'; + + + export default function TextInput(props) { + + const { + hint, + label, + field, + form, + fieldError, + children, + multiline, + description, + ...restProps + } = props; + + const { + name: fieldName, + value: fieldValue + } = field; + + const meta = form.getFieldMeta(fieldName); + + const error = (fieldError || defaultFieldError)(meta, fieldName); + + function textElement() { + function getTextarea() { + return