diff --git a/client/package-lock.json b/client/package-lock.json index 745284c5fd..47df1f947d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1612,7 +1612,9 @@ "asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=", + "dev": true, + "optional": true }, "asn1": { "version": "0.2.4", @@ -3280,15 +3282,6 @@ "sha.js": "^2.4.8" } }, - "create-react-context": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.3.tgz", - "integrity": "sha512-CQBmD0+QGgTaxDL3OX1IDXYqjkp2It4RIbcb99jS6AEg27Ga+a9G3JtK6SIu0HBwPLZlmwt9F7UwWA4Bn92Rag==", - "requires": { - "fbjs": "^0.8.0", - "gud": "^1.0.0" - } - }, "cross-env": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-5.2.0.tgz", @@ -4948,27 +4941,6 @@ "websocket-driver": ">=0.5.1" } }, - "fbjs": { - "version": "0.8.17", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz", - "integrity": "sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=", - "requires": { - "core-js": "^1.0.0", - "isomorphic-fetch": "^2.1.1", - "loose-envify": "^1.0.0", - "object-assign": "^4.1.0", - "promise": "^7.1.1", - "setimmediate": "^1.0.5", - "ua-parser-js": "^0.7.18" - }, - "dependencies": { - "core-js": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", - "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" - } - } - }, "fd-slicer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", @@ -5170,19 +5142,34 @@ } }, "formik": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/formik/-/formik-1.5.3.tgz", - "integrity": "sha512-SbNbAPbCD/aR35nJkTdu+JdTHw3sILYCC/ArJJoeHWlkDT0sY82ACFRx2VYH15odQ7zW3CCYN1adoZ+tb6QsOA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.0.4.tgz", + "integrity": "sha512-Y0mR8PGRtq/U072U4tkX1wnS/geDYs0n7uPlvmKtMTIJS4g8xCpaccAerQFWxEfClMK/JGpmEyG93zItAdASJA==", "requires": { - "create-react-context": "^0.2.2", "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^2.5.5", - "lodash": "^4.17.11", - "lodash-es": "^4.17.11", - "prop-types": "^15.6.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.14", + "lodash-es": "^4.17.14", "react-fast-compare": "^2.0.1", + "scheduler": "^0.17.0", "tiny-warning": "^1.0.2", - "tslib": "^1.9.3" + "tslib": "^1.10.0" + }, + "dependencies": { + "scheduler": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.17.0.tgz", + "integrity": "sha512-7rro8Io3tnCPuY4la/NuI5F2yfESpnfZyT6TtkXnSWVkcu0BCDJ+8gk5ozUaFaxpIyNuWAPXrH0yFcSi28fnDA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "tslib": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", + "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" + } } }, "forwarded": { @@ -5978,11 +5965,6 @@ "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true }, - "gud": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", - "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==" - }, "hammerjs": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", @@ -6159,9 +6141,12 @@ } }, "hoist-non-react-statics": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", - "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-wbg3bpgA/ZqWrZuMOeJi8+SKMhr7X9TesL/rXMjTzh0p0JUBo3II8DHboYbuIXWRlttrUFxwcu/5kygrCw8fJw==", + "requires": { + "react-is": "^16.7.0" + } }, "homedir-polyfill": { "version": "1.0.3", @@ -6877,15 +6862,6 @@ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", "dev": true }, - "isomorphic-fetch": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", - "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", - "requires": { - "node-fetch": "^1.0.1", - "whatwg-fetch": ">=0.10.0" - } - }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -7375,9 +7351,9 @@ "integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==" }, "lodash-es": { - "version": "4.17.11", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.11.tgz", - "integrity": "sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q==" + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz", + "integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==" }, "lodash.clonedeep": { "version": "4.5.0", @@ -9138,6 +9114,8 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dev": true, + "optional": true, "requires": { "asap": "~2.0.3" } @@ -9421,8 +9399,7 @@ "react-is": { "version": "16.8.6", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", - "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", - "dev": true + "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" }, "react-svg-core": { "version": "3.0.3", @@ -10292,7 +10269,8 @@ "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "dev": true }, "setprototypeof": { "version": "1.1.1", @@ -11411,9 +11389,9 @@ "integrity": "sha512-6XhXIOnwlM6dpuMogF6/C1u3EDUbRbjovbdVbnIGgDxG8HOW79B51MHwBQHQnVL4Pkad4phzQd5WDeBefuWDPg==" }, "tiny-warning": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.2.tgz", - "integrity": "sha512-rru86D9CpQRLvsFG5XFdy0KdLAvjdQDyZCsRcuu60WtzFylDM3eAWSxEVz5kzL2Gp544XiUvPbVKtOA/txLi9Q==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" }, "tmp": { "version": "0.0.33", @@ -11579,7 +11557,8 @@ "tslib": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", - "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==" + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", + "dev": true }, "tty-browserify": { "version": "0.0.0", @@ -11652,11 +11631,6 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", "dev": true }, - "ua-parser-js": { - "version": "0.7.19", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.19.tgz", - "integrity": "sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ==" - }, "uglify-js": { "version": "3.5.8", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.5.8.tgz", @@ -12327,11 +12301,6 @@ "integrity": "sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==", "dev": true }, - "whatwg-fetch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", - "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==" - }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/client/package.json b/client/package.json index 082adacc70..f7a2de18c9 100644 --- a/client/package.json +++ b/client/package.json @@ -30,7 +30,7 @@ "dmn-js-properties-panel": "^0.3.1", "drag-tabs": "^2.2.0", "events": "^3.0.0", - "formik": "^1.5.3", + "formik": "^2.0.4", "ids": "^0.2.2", "min-dash": "^3.4.0", "p-defer": "^3.0.0", diff --git a/client/src/plugins/deployment-tool/CamundaAPI.js b/client/src/plugins/deployment-tool/CamundaAPI.js index 3d838057cf..c2980e9468 100644 --- a/client/src/plugins/deployment-tool/CamundaAPI.js +++ b/client/src/plugins/deployment-tool/CamundaAPI.js @@ -8,29 +8,33 @@ * except in compliance with the MIT License. */ -import { - ConnectionError, - DeploymentError -} from './errors'; +import AuthTypes from './AuthTypes'; + +import debug from 'debug'; const FETCH_TIMEOUT = 5000; +const log = debug('CamundaAPI'); + export default class CamundaAPI { - constructor(baseUrl) { - this.baseUrl = baseUrl; + + constructor(endpoint) { + + this.baseUrl = normalizeBaseURL(endpoint.url); + + this.authentication = this.getAuthentication(endpoint); } - async deployDiagram(diagram, details) { + async deployDiagram(diagram, deployment) { const { - auth, - deploymentName, + name, tenantId - } = details; + } = deployment; const form = new FormData(); - form.append('deployment-name', deploymentName); + form.append('deployment-name', name); form.append('deployment-source', 'Camunda Modeler'); form.append('deploy-changed-only', 'true'); @@ -44,12 +48,9 @@ export default class CamundaAPI { form.append(diagramName, blob, diagramName); - const headers = this.getHeaders(auth); - - const response = await this.safelyFetch(`${this.baseUrl}/deployment/create`, { + const response = await this.fetch('/deployment/create', { method: 'POST', - body: form, - headers + body: form }); if (response.ok) { @@ -66,17 +67,14 @@ export default class CamundaAPI { }; } - const body = await this.safelyParse(response); + const body = await this.parse(response); throw new DeploymentError(response, body); } - async checkConnection(details = {}) { - const { auth } = details; - - const headers = this.getHeaders(auth); + async checkConnection() { - const response = await this.safelyFetch(`${this.baseUrl}/deployment?maxResults=0`, { headers }); + const response = await this.fetch('/deployment?maxResults=0'); if (response.ok) { return; @@ -85,21 +83,50 @@ export default class CamundaAPI { throw new ConnectionError(response); } - getHeaders(auth) { + 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 (auth) { - headers.authorization = this.getAuthHeader(auth); + if (this.authentication) { + headers.authorization = this.getAuthHeader(this.authentication); } return headers; } - getAuthHeader({ bearer, username, password }) { - if (bearer) { - return `Bearer ${bearer}`; + getAuthHeader(endpoint) { + + const { + token, + username, + password + } = endpoint; + + if (token) { + return `Bearer ${token}`; } if (username && password) { @@ -111,35 +138,39 @@ export default class CamundaAPI { throw new Error('Unknown auth options.'); } - async safelyFetch(url, options = {}) { - let response; + async fetch(path, options = {}) { + const url = `${this.baseUrl}${path}`; + const headers = this.getHeaders(); try { - options.signal = options.signal || this.setupTimeoutSignal(); - response = await fetch(url, options); + const signal = options.signal || this.setupTimeoutSignal(); + + return await fetch(url, { + ...options, + headers, + signal + }); } catch (error) { - response = { + log('failed to fetch', error); + + return { url, json: () => { return {}; } }; } - - return response; } setupTimeoutSignal(timeout = FETCH_TIMEOUT) { const controller = new AbortController(); - const { signal } = controller; - setTimeout(() => controller.abort(), timeout); - return signal; + return controller.signal; } - async safelyParse(response) { + async parse(response) { try { const json = await response.json(); @@ -149,3 +180,113 @@ export default class CamundaAPI { } } } + +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. Most likely your machine is not online right now', + [ CONNECTION_FAILED ]: 'Could not connect to the server. Did you run the engine?', + [ DIAGRAM_PARSE_ERROR ]: 'Server could not parse the diagram. Please check log for errors.', + [ UNAUTHORIZED ]: 'Authentication failed. Please check your credentials.', + [ FORBIDDEN ]: 'This user is not permitted to deploy. Please use different credentials or get this user enabled to deploy.', + [ NOT_FOUND ]: 'Could not find the Camunda endpoint. Please check the URL and make sure Camunda is running.', + [ 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; + } +} + + +// 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/client/src/plugins/deployment-tool/DeploymentConfigValidator.js b/client/src/plugins/deployment-tool/DeploymentConfigValidator.js new file mode 100644 index 0000000000..3db1038759 --- /dev/null +++ b/client/src/plugins/deployment-tool/DeploymentConfigValidator.js @@ -0,0 +1,325 @@ +/** + * 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 pDefer from 'p-defer'; + +import AuthTypes from './AuthTypes'; + +import CamundaAPI from './CamundaAPI'; + + +export default class DeploymentConfigValidator { + + validateEndpointURL = (value) => { + return ( + this.validateNonEmpty(value, 'Endpoint URL must not be empty.') || + this.validatePattern(value, /^https?:\/\//, 'Endpoint URL must start with "http://" or "https://".') || + null + ); + } + + 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) => { + return this.validateNonEmpty(value, 'Deployment name must not be empty.'); + } + + validateToken = (value) => { + return this.validateNonEmpty(value, 'Token must not be empty.'); + } + + validatePassword = (value) => { + return this.validateNonEmpty(value, 'Password must not be empty.'); + } + + validateUsername = (value) => { + return this.validateNonEmpty(value, 'Username must not be empty.'); + } + + validateDeployment(deployment = {}) { + return this.validate(deployment, { + name: this.validateDeploymentName + }); + } + + 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 + }); + } + + 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; + } + + validateBasic(configuration) { + + const { + deployment, + endpoint + } = configuration; + + const deploymentErrors = this.validateDeployment(deployment); + const endpointErrors = this.validateEndpoint(endpoint); + + return filterErrors({ + deployment: deploymentErrors, + endpoint: endpointErrors + }); + } + + isConfigurationValid(configuration) { + + const errors = this.validateBasic(configuration); + + return !hasKeys(errors); + } + + createConnectionChecker() { + return new ConnectionChecker(this); + } + +} + + +class ConnectionChecker { + + constructor(validator) { + this.validator = validator; + } + + subscribe(hooks) { + this.hooks = hooks; + } + + unsubscribe() { + + if (this.checkTimer) { + clearTimeout(this.checkTimer); + + this.checkTimer = null; + } + + this.endpoint = { __non_existing_endpoint: true }; + + this.lastCheck = null; + + this.hooks = null; + } + + check(endpoint) { + this.setEndpoint(endpoint); + + const { + lastCheck + } = this; + + // return cached result if endpoint did not change + // we'll periodically re-check in background anyway + if (lastCheck && shallowEquals(endpoint, lastCheck.endpoint)) { + return Promise.resolve(lastCheck.result); + } + + const deferred = this.scheduleCheck(); + + return deferred.promise; + } + + setEndpoint(endpoint) { + this.endpoint = endpoint; + } + + checkCompleted(endpoint, result) { + + const { + endpoint: currentEndpoint, + deferred, + hooks + } = this; + + if (!shallowEquals(endpoint, currentEndpoint)) { + return; + } + + const { + connectionError, + endpointErrors + } = result; + + this.lastCheck = { + endpoint, + unauthorized: connectionError && connectionError.code === 'UNAUTHORIZED', + result + }; + + this.deferred = null; + + deferred.resolve(result); + + hooks && hooks.onComplete && hooks.onComplete(result); + + if (!hasKeys(endpointErrors)) { + this.scheduleCheck(); + } + } + + checkStart() { + + const { + hooks + } = this; + + hooks.onStart && hooks.onStart(); + } + + scheduleCheck() { + + const { + endpoint, + lastCheck, + checkTimer, + validator + } = this; + + const deferred = this.deferred = this.deferred || pDefer(); + + // stop scheduled check + if (checkTimer) { + clearTimeout(checkTimer); + } + + const endpointErrors = validator.validateEndpoint(endpoint); + + if (hasKeys(endpointErrors)) { + this.checkCompleted(endpoint, { + endpointErrors + }); + } else { + + const delay = this.getCheckDelay(endpoint, lastCheck); + + this.checkTimer = setTimeout(() => { + this.triggerCheck(); + }, delay); + } + + return deferred; + } + + triggerCheck() { + const { + endpoint, + validator + } = this; + + this.checkStart(); + + validator.validateConnection(endpoint).then(connectionError => { + + this.checkCompleted(endpoint, { + connectionError + }); + + }).catch(error => { + console.error('connection check failed', error); + }); + } + + getCheckDelay(endpoint, lastCheck) { + + if (!lastCheck) { + return 1000; + } + + const { + endpoint: lastEndpoint, + unauthorized + } = lastCheck; + + const endpointChanged = !shallowEquals(endpoint, lastEndpoint); + + if (endpointChanged) { + return 1000; + } + + // back-off if last check was unauthorized. + // we do not want the user to be blocked by the engine + return unauthorized ? 15000 : 5000; + } + +} + +// 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; + }, {}); +} + + +function hash(el) { + return JSON.stringify(el); +} + +function shallowEquals(a, b) { + return hash(a) === hash(b); +} diff --git a/client/src/plugins/deployment-tool/DeploymentDetailsModal.js b/client/src/plugins/deployment-tool/DeploymentDetailsModal.js index c3f9e9e0a5..2c8e8f6e84 100644 --- a/client/src/plugins/deployment-tool/DeploymentDetailsModal.js +++ b/client/src/plugins/deployment-tool/DeploymentDetailsModal.js @@ -10,208 +10,191 @@ import React from 'react'; -import { debounce } from 'min-dash'; - import { Modal } from '../../app/primitives'; +import { + omit +} from 'min-dash'; + import css from './DeploymentDetailsModal.less'; import AuthTypes from './AuthTypes'; import { - AuthBasic, - AuthBearer, - FormControl + CheckBox, + Select, + TextInput } from './components'; import { - Field, - Form, - Formik + Formik, + Field } from 'formik'; -import { ConnectionErrorMessages } from './errors'; +export default class DeploymentDetailsModal extends React.PureComponent { -const initialFormValues = { - endpointUrl: 'http://localhost:8080/engine-rest', - tenantId: '', - deploymentName: 'diagram', - authType: AuthTypes.none, - username: '', - password: '', - bearer: '' -}; + constructor(props) { + super(props); -export default class DeploymentDetailsModal extends React.PureComponent { + const { + validator, + configuration + } = props; + + this.state = { + connectionState: {}, + deploymentDetailsShown: configuration.deployment.tenantId + }; - state = { - detailsOpen: false, - checkingConnection: null, - connectionError: null, - connectionHint: null, - lastPassword: null, - lastUsername: null, - lastAuthType: null + this.connectionChecker = validator.createConnectionChecker(); } - componentDidMount() { - this.mounted = true; + handleConnectionCheckStart = () => { + this.setConnectionState({ + isValidating: true, + isValidated: false + }); + } - // check connection with pre-validated initial form values - const initialValues = this.getInitialValues(); - const errors = this.props.validate(initialValues); + handleConnectionChecked = (result) => { - this.checkConnectionIfNeeded(initialValues, errors, true); + const { + endpointErrors, + connectionError + } = result; + + this.setConnectionState({ + isValidating: false, + isValidated: true, + isValid: !hasKeys(endpointErrors) && !connectionError, + endpointErrors, + connectionError + }); } - componentWillUnmount() { - this.mounted = false; + componentDidMount() { + this.connectionChecker.subscribe({ + onStart: this.handleConnectionCheckStart, + onComplete: this.handleConnectionChecked + }); } - getInitialValues() { - return { ...initialFormValues, ...this.props.details }; + componentWillUnmount() { + this.connectionChecker.unsubscribe(); } - checkConnection = async values => { - if (!this.mounted || this.state.checkingConnection) { - return; - } - + setConnectionState(connectionState) { this.setState({ - checkingConnection: true, - connectionHint: null, - lastUsername: values.username, - lastPassword: values.password, - lastAuthType: values.authType + connectionState: { + ...this.state.connectionState, + ...connectionState + } }); - - const connectionError = await this.props.checkConnection(values); - - this.mounted && this.setState({ connectionError, checkingConnection: false }); } - lazilyCheckConnection = debounce(this.checkConnection, 1000); + validate = (values, form) => { - moreLazilyCheckConnection = debounce(this.checkConnection, 2000); + this.connectionChecker.check(values.endpoint).then(() => { + return null; + }); + }; - validate = values => { - const errors = this.props.validate(values); + onClose = (action = 'cancel', data) => this.props.onClose(action, data); - this.checkConnectionIfNeeded(values, errors); + onCancel = () => this.onClose('cancel'); - return errors; + onSubmit = (values) => { + this.onClose('deploy', values); } - checkConnectionIfNeeded(values, errors, immediately = false) { - - const { - authType, - username, - password - } = values; - - const missingConfigHint = this.getEndpointConfigHint(values, errors); + fieldError = (meta) => { + return (this.state.connectionState.isValidated || meta.touched) && meta.error; + } - // skip connection check in case of invalid input - if (missingConfigHint) { - this.setState({ - connectionHint: missingConfigHint - }); + setAuthType = (form) => { - return; - } + return event => { - if (immediately) { - return this.checkConnection(values); - } + const authType = event.target.value; - // check connection if authentication type was changed or not using HTTP Basic - if (authType !== AuthTypes.basic || authType !== this.state.lastAuthType) { - return this.lazilyCheckConnection(values); - } + const { + values, + setValues + } = form; - const usernameOrPasswordChanged = !( - username === this.state.lastUsername && password === this.state.lastPassword - ); + let { + endpoint + } = values; - const previouslyUnauthorized = this.state.connectionError === ConnectionErrorMessages.unauthorized; + if (authType !== AuthTypes.basic) { + endpoint = omit(endpoint, [ 'username', 'password' ]); + } - // skip connection check if already failed for provided credentials - if (!usernameOrPasswordChanged && previouslyUnauthorized) { - return; - } + if (authType !== AuthTypes.bearer) { + endpoint = omit(endpoint, [ 'token' ]); + } - // apply longer delay if unauthorized during last check - if (previouslyUnauthorized) { - return this.moreLazilyCheckConnection(values); - } + setValues({ + ...values, + endpoint: { + ...endpoint, + authType + } + }); + }; - return this.lazilyCheckConnection(values); } - getEndpointConfigHint(values, errors) { - const areCredentialsMissing = this.getCredentialsConfigFields(values.authType) - .some(field => errors[field]); - - if (errors.endpointUrl && areCredentialsMissing) { - return 'Please finish the endpoint configuration to test the server connection.'; - } else if (errors.endpointUrl) { - return 'Please provide a valid REST endpoint to test the server connection.'; - } else if (areCredentialsMissing) { - return 'Please add the credentials to test the server connection.'; - } - } + toggleDetails = (form) => { - getCredentialsConfigFields(authType) { - switch (authType) { - case AuthTypes.none: - return []; - case AuthTypes.bearer: - return [ 'bearer' ]; - case AuthTypes.basic: - return [ 'username', 'password' ]; - } - } + return (event) => { - onClose = () => this.props.onClose(); + const { + deployment + } = form.values; - onSubmit = (values, { setSubmitting }) => { - if (this.state.connectionError) { - return setSubmitting(false); - } + if (deployment.tenantId) { + return; + } - this.props.onClose(values); + this.setState({ + deploymentDetailsShown: !this.state.deploymentDetailsShown + }); + }; } - toggleDetails = () => this.setState(state => ({ ...state, detailsOpen: !state.detailsOpen })); - render() { + const { - onFocusChange - } = this.props; + fieldError, + onSubmit, + validate, + onClose + } = this; - const initialValues = this.getInitialValues(); + const { + configuration: values, + validator + } = this.props; const { - checkingConnection, - connectionError, - connectionHint, - detailsOpen + connectionState, + deploymentDetailsShown } = this.state; - const onClose = this.onClose; - return ( - {({ isSubmitting, values }) => ( -
+ { form => ( + + Deploy Diagram @@ -220,137 +203,206 @@ export default class DeploymentDetailsModal extends React.PureComponent {

- Deployment Details
+ - { (detailsOpen || values['tenantId']) && }
-
+ + Endpoint Configuration + - Endpoint Configuration - - +
+ -
- -
- -
- - - - - -
- - { values.authType === AuthTypes.basic && ( - ) } - - { values.authType === AuthTypes.bearer && ( - ) } - + + + + + + + { form.values.endpoint.authType === AuthTypes.basic && ( + + + + + + )} + + { form.values.endpoint.authType === AuthTypes.bearer && ( + + )} + + { form.values.endpoint.authType !== AuthTypes.none && ( + + )}
+ +
- -
+ )} -
-
); } } -function ConnectionCheckResult({ checkingConnection, error, hint }) { - if (error) { +function ConnectionFeedback(props) { + + const { + connectionState + } = props; + + const { + isValid, + isValidating, + isValidated, + endpointErrors, + connectionError + } = connectionState; + + if (!isValidating && !isValidated) { + return null; + } + + if (isValidating) { return ( -
- { error } +
+ Validating connection.
); } - if (hint) { + if (isValid) { + return ( +
+ Connected successfully. +
+ ); + } + + if (hasKeys(endpointErrors)) { + + const message = + endpointErrors.url + ? 'Please provide a valid REST endpoint to test the server connection.' + : (endpointErrors.token || endpointErrors.username || endpointErrors.password) + ? 'Please add the credentials to test the server connection' + : 'Please correct validation errors'; + return (
- { hint } + { message }
); } - if (checkingConnection === false) { + if (connectionError) { return ( -
- Connected successfully. +
+ { connectionError.details }
); } - return ( -
- Testing the connection. -
- ); + throw new Error('unexpected connection state'); +} + +function hasKeys(obj) { + return obj && Object.keys(obj).length > 0; } diff --git a/client/src/plugins/deployment-tool/DeploymentDetailsModal.less b/client/src/plugins/deployment-tool/DeploymentDetailsModal.less index 986c680e88..55f229c01a 100644 --- a/client/src/plugins/deployment-tool/DeploymentDetailsModal.less +++ b/client/src/plugins/deployment-tool/DeploymentDetailsModal.less @@ -3,6 +3,10 @@ font-weight: normal; } + .icon { + vertical-align: text-top; + } + .intro { margin-bottom: 30px; } @@ -47,13 +51,13 @@ width: 10px; } - label { - text-align: right; + label:only-child { width: 100%; display: inline-block; + text-align: right; } - input, + input:not([type="checkbox"]), select { width: 100%; padding: 6px; diff --git a/client/src/plugins/deployment-tool/DeploymentTool.js b/client/src/plugins/deployment-tool/DeploymentTool.js index 25a15273e2..e876d69f2f 100644 --- a/client/src/plugins/deployment-tool/DeploymentTool.js +++ b/client/src/plugins/deployment-tool/DeploymentTool.js @@ -10,13 +10,15 @@ import React, { PureComponent } from 'react'; -import { omit } from 'min-dash'; - import AuthTypes from './AuthTypes'; import CamundaAPI from './CamundaAPI'; + import DeploymentDetailsModal from './DeploymentDetailsModal'; -import getEditMenu from './getEditMenu'; -import validators from './validators'; +import DeploymentConfigValidator from './DeploymentConfigValidator'; + +import { + generateId +} from '../../util'; import { Fill } from '../../app/slot-fill'; @@ -25,12 +27,14 @@ import { Icon } from '../../app/primitives'; -const VALIDATED_FIELDS = [ - 'deploymentName', - 'endpointUrl' -]; +const DEPLOYMENT_DETAILS_CONFIG_KEY = 'deployment-tool'; +const ENGINE_ENDPOINTS_CONFIG_KEY = 'camundaEngineEndpoints'; -const CONFIG_KEY = 'deployment-config'; +const DEFAULT_ENDPOINT = { + url: 'http://localhost:8080/engine-rest', + authType: AuthTypes.none, + rememberCredentials: false +}; export default class DeploymentTool extends PureComponent { @@ -40,6 +44,8 @@ export default class DeploymentTool extends PureComponent { activeTab: null } + validator = new DeploymentConfigValidator(); + componentDidMount() { this.props.subscribe('app.activeTabChanged', ({ activeTab }) => { this.setState({ activeTab }); @@ -54,33 +60,19 @@ export default class DeploymentTool extends PureComponent { return triggerAction('save-tab', { tab }); } - deploy = () => { + deploy = (options = {}) => { const { activeTab } = this.state; - this.deployTab(activeTab); + return this.deployTab(activeTab, options); } - async saveDetails(tab, details) { - const { - config - } = this.props; - - const savedDetails = this.getDetailsToSave(details); - - return config.setForFile(tab.file, CONFIG_KEY, savedDetails); - } + async deployTab(tab, options={}) { - async getSavedDetails(tab) { const { - config - } = this.props; - - return config.getForFile(tab.file, CONFIG_KEY); - } - - async deployTab(tab) { + configure + } = options; // (1) Open save file dialog if dirty tab = await this.saveTab(tab); @@ -90,199 +82,258 @@ export default class DeploymentTool extends PureComponent { return; } - // (2) Get deployment details - // (2.1) Try to get existing deployment details - let details = await this.getSavedDetails(tab); + // (2) Get deployment configuration + // (2.1) Try to get existing deployment configuration + let configuration = await this.getSavedConfiguration(tab); - // (2.2) Check if details are complete - const canDeploy = this.canDeployWithDetails(details); + // (2.2) Check if configuration are complete + const showConfiguration = configure || !this.canDeployWithConfiguration(configuration); - if (!canDeploy) { + if (showConfiguration) { - // (2.3) Open modal to enter deployment details - details = await this.getDetailsFromUserInput(tab, details); + // (2.3) Open modal to enter deployment configuration + const { + action, + configuration: userConfiguration + } = await this.getConfigurationFromUserInput(tab, configuration); // (2.3.1) Handle user cancelation - if (!details) { + if (action === 'cancel') { return; } - await this.saveDetails(tab, details); + configuration = await this.saveConfiguration(tab, userConfiguration); + + if (action === 'save') { + return; + } } // (3) Trigger deployment // (3.1) Show deployment result (success or error) + + try { + const deployment = await this.deployWithConfiguration(tab, configuration); + + await this.handleDeploymentSuccess(tab, deployment); + } catch (error) { + await this.handleDeploymentError(tab, error); + } + } + + handleDeploymentSuccess(tab, deployment) { + const { + displayNotification + } = this.props; + + displayNotification({ + type: 'success', + title: 'Deployment succeeded', + duration: 4000 + }); + } + + handleDeploymentError(tab, error) { const { log, displayNotification } = this.props; - try { - await this.deployWithDetails(tab, details); + displayNotification({ + type: 'error', + title: 'Deployment failed', + content: 'See the log for further details.', + duration: 10000 + }); - displayNotification({ - type: 'success', - title: 'Deployment succeeded', - duration: 4000 - }); - } catch (error) { - displayNotification({ - type: 'error', - title: 'Deployment failed', - content: 'See the log for further details.', - duration: 10000 - }); - log({ category: 'deploy-error', message: error.problems || error.message }); + log({ + category: 'deploy-error', + message: error.problems || error.details || error.message + }); + } + + async saveConfiguration(tab, configuration) { + + const { + endpoint, + deployment + } = configuration; + + await this.saveEndpoint(endpoint); + + const tabConfiguration = { + deployment, + endpointId: endpoint.id + }; + + await this.setTabConfiguration(tab, tabConfiguration); + + return configuration; + } + + async saveEndpoint(endpoint) { + + const { + id, + url, + authType, + rememberCredentials, + username, + password, + token + } = endpoint; + + const authConfiguration = + authType === AuthTypes.none + ? {} + : authType === AuthTypes.basic + ? { + username, + password: rememberCredentials ? password : '' + } + : { + token: rememberCredentials ? token : '' + }; + + const endpointConfiguration = { + id, + url, + authType, + rememberCredentials, + ...authConfiguration + }; + + const existingEndpoints = await this.getEndpoints(); + + const updatedEndpoints = addOrUpdateById(existingEndpoints, endpointConfiguration); + + await this.setEndpoints(updatedEndpoints); + + return endpointConfiguration; + } + + async getSavedConfiguration(tab) { + + const tabConfig = await this.getTabConfiguration(tab); + + if (!tabConfig) { + return undefined; } + + const { + deployment, + endpointId + } = tabConfig; + + const endpoints = await this.getEndpoints(); + + return { + deployment, + endpoint: endpoints.find(endpoint => endpoint.id === endpointId) + }; } - deployWithDetails(tab, details) { - const api = new CamundaAPI(details.endpointUrl); + deployWithConfiguration(tab, configuration) { + + const { + endpoint, + deployment + } = configuration; + + const api = new CamundaAPI(endpoint); - return api.deployDiagram(tab.file, details); + return api.deployDiagram(tab.file, deployment); } - canDeployWithDetails(details) { + canDeployWithConfiguration(configuration) { - // TODO(barmac): implement for instant deployment + // TODO(nikku): we'll re-enable this, once we make re-deploy + // the primary button action: https://github.com/camunda/camunda-modeler/issues/1440 return false; + + // return this.validator.isConfigurationValid(configuration); } - getDetailsFromUserInput(tab, details) { - const initialDetails = this.getInitialDetails(tab, details); + async getConfigurationFromUserInput(tab, providedConfiguration) { + const configuration = await this.getDefaultConfiguration(tab, providedConfiguration); return new Promise(resolve => { - const handleClose = result => { + const handleClose = (action, configuration) => { this.setState({ modalState: null }); - this.updateMenu(); - - // contract: if details provided, user closed with O.K. + // contract: if configuration provided, user closed with O.K. // otherwise they canceled it - if (result) { - return resolve(this.getDetailsFromForm(result)); - } - - resolve(); + return resolve({ action, configuration }); }; this.setState({ modalState: { tab, - details: initialDetails, + configuration, handleClose } }); }); } - getDetailsToSave(rawDetails) { - return omit(rawDetails, 'auth'); + getEndpoints() { + return this.props.config.get(ENGINE_ENDPOINTS_CONFIG_KEY, []); } - validateDetails = values => { - const validatedFields = this.getValidatedFields(values); - - const errors = validatedFields.reduce((currentErrors, field) => { - const error = validators[field] && validators[field](values[field]); - - return error ? { ...currentErrors, [field]: error } : currentErrors; - }, {}); - - return errors; + setEndpoints(endpoints) { + return this.props.config.set(ENGINE_ENDPOINTS_CONFIG_KEY, endpoints); } - checkConnection = async values => { - const baseUrl = this.getBaseUrl(values.endpointUrl); - const auth = this.getAuth(values); - - const api = new CamundaAPI(baseUrl); - - let connectionError = null; - - try { - await api.checkConnection({ auth }); - } catch (error) { - connectionError = error.message; - } - - return connectionError; + getTabConfiguration(tab) { + return this.props.config.getForFile(tab.file, DEPLOYMENT_DETAILS_CONFIG_KEY); } - getInitialDetails(tab, providedDetails) { - const details = { ...providedDetails }; - - if (!details.deploymentName) { - details.deploymentName = withoutExtension(tab.name); - } - - return details; + setTabConfiguration(tab, configuration) { + return this.props.config.setForFile(tab.file, DEPLOYMENT_DETAILS_CONFIG_KEY, configuration); } - getValidatedFields(values) { - switch (values.authType) { - case AuthTypes.none: - return VALIDATED_FIELDS; - case AuthTypes.bearer: - return VALIDATED_FIELDS.concat('bearer'); - case AuthTypes.basic: - return VALIDATED_FIELDS.concat('username', 'password'); - } - } + /** + * Get endpoint to be used by the current tab. + * + * @return {EndpointConfig} + */ + async getDefaultEndpoint(tab, providedEndpoint) { - getDetailsFromForm(values) { - const endpointUrl = this.getBaseUrl(values.endpointUrl); + let endpoint = {}; - const payload = { - endpointUrl, - deploymentName: values.deploymentName, - tenantId: values.tenantId, - authType: values.authType - }; + if (providedEndpoint) { + endpoint = providedEndpoint; + } else { - const auth = this.getAuth(values); + const existingEndpoints = await this.getEndpoints(); - if (auth) { - payload.auth = auth; + if (existingEndpoints.length) { + endpoint = existingEndpoints[0]; + } } - return payload; - } - - /** - * Extract base url in case `/deployment/create` was added at the end. - * @param {string} url - */ - getBaseUrl(url) { - return url.replace(/\/deployment\/create\/?/, ''); - } - - getAuth({ authType, username, password, bearer }) { - switch (authType) { - case AuthTypes.basic: - return { - username, - password - }; - case AuthTypes.bearer: { - return { - bearer - }; - } - } + return { + ...DEFAULT_ENDPOINT, + ...endpoint, + id: endpoint.id || generateId() + }; } - handleFocusChange = event => { - const editMenu = getEditMenu(isFocusedOnInput(event)); + async getDefaultConfiguration(tab, providedConfiguration = {}) { + const endpoint = await this.getDefaultEndpoint(tab, providedConfiguration.endpoint); - this.updateMenu({ editMenu }); - } + const deployment = providedConfiguration.deployment || {}; - updateMenu(menu) { - this.props.triggerAction('update-menu', menu); + return { + endpoint, + deployment: { + name: withoutExtension(tab.name), + ...deployment + } + }; } render() { @@ -294,7 +345,7 @@ export default class DeploymentTool extends PureComponent { @@ -302,12 +353,10 @@ export default class DeploymentTool extends PureComponent { { modalState && } ; } @@ -317,10 +366,25 @@ export default class DeploymentTool extends PureComponent { // helpers ////////// -function isFocusedOnInput(event) { - return event.type === 'focus' && ['INPUT', 'TEXTAREA'].includes(event.target.tagName); -} function withoutExtension(name) { return name.replace(/\.[^.]+$/, ''); } + +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 + ]; +} diff --git a/client/src/plugins/deployment-tool/__tests__/CamundaAPISpec.js b/client/src/plugins/deployment-tool/__tests__/CamundaAPISpec.js index c107ec5e11..ddda8278bc 100644 --- a/client/src/plugins/deployment-tool/__tests__/CamundaAPISpec.js +++ b/client/src/plugins/deployment-tool/__tests__/CamundaAPISpec.js @@ -30,7 +30,7 @@ describe('', () => { beforeEach(() => { fetchStub = sinon.stub(window, 'fetch'); - api = new CamundaAPI(baseUrl); + api = new CamundaAPI({ url: baseUrl }); }); afterEach(() => { diff --git a/client/src/plugins/deployment-tool/__tests__/DeploymentConfigValidatorSpec.js b/client/src/plugins/deployment-tool/__tests__/DeploymentConfigValidatorSpec.js new file mode 100644 index 0000000000..6ca04fb939 --- /dev/null +++ b/client/src/plugins/deployment-tool/__tests__/DeploymentConfigValidatorSpec.js @@ -0,0 +1,108 @@ +/** + * 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 DeploymentConfigValidator from '../DeploymentConfigValidator'; +import AuthTypes from '../AuthTypes'; + +const EMPTY_ENDPOINT_ERROR = 'Endpoint URL must not be empty.'; +const EMPTY_DEPLOYMENT_NAME_ERROR = 'Deployment name must not be empty.'; +const EMPTY_USERNAME_ERROR = 'Username must not be empty.'; +const EMPTY_PASSWORD_ERROR = 'Password must not be empty.'; +const EMPTY_TOKEN_ERROR = 'Token must not be empty.'; +const INVALID_URL_ERROR = 'Endpoint URL must start with "http://" or "https://".'; + + +describe('', () => { + + /** + * @type {DeploymentConfigValidator} + */ + let validator; + + beforeEach(() => { + validator = new DeploymentConfigValidator(); + }); + + + it('should validate deployment name', () => { + + // given + const validate = name => validator.validateDeployment({ + name + }); + + // then + expect(validate().name).to.eql(EMPTY_DEPLOYMENT_NAME_ERROR); + expect(validate('').name).to.eql(EMPTY_DEPLOYMENT_NAME_ERROR); + expect(validate('deployment name').name).to.not.exist; + }); + + + it('should validate endpoint url', () => { + + // given + const validate = url => validator.validateEndpoint({ + authType: AuthTypes.none, + url + }); + + // then + expect(validate().url).to.eql(EMPTY_ENDPOINT_ERROR); + expect(validate('').url).to.eql(EMPTY_ENDPOINT_ERROR); + expect(validate('url').url).to.eql(INVALID_URL_ERROR); + expect(validate('http://localhost:8080').url).to.not.exist; + expect(validate('https://localhost:8080').url).to.not.exist; + }); + + + it('should validate username', () => { + + // given + const validate = username => validator.validateEndpoint({ + authType: AuthTypes.basic, + username + }); + + // then + expect(validate().username).to.eql(EMPTY_USERNAME_ERROR); + expect(validate('').username).to.eql(EMPTY_USERNAME_ERROR); + expect(validate('username').username).to.not.exist; + }); + + + it('should validate password', () => { + + // given + const validate = password => validator.validateEndpoint({ + authType: AuthTypes.basic, + password + }); + + // then + expect(validate().password).to.eql(EMPTY_PASSWORD_ERROR); + expect(validate('').password).to.eql(EMPTY_PASSWORD_ERROR); + expect(validate('password').password).to.not.exist; + }); + + + it('should validate token', () => { + + // given + const validate = token => validator.validateEndpoint({ + authType: AuthTypes.bearer, + token + }); + + // then + expect(validate().token).to.eql(EMPTY_TOKEN_ERROR); + expect(validate('').token).to.eql(EMPTY_TOKEN_ERROR); + expect(validate('token').token).to.not.exist; + }); +}); diff --git a/client/src/plugins/deployment-tool/__tests__/DeploymentDetailsModalSpec.js b/client/src/plugins/deployment-tool/__tests__/DeploymentDetailsModalSpec.js index 1d77328235..6f39f53c33 100644 --- a/client/src/plugins/deployment-tool/__tests__/DeploymentDetailsModalSpec.js +++ b/client/src/plugins/deployment-tool/__tests__/DeploymentDetailsModalSpec.js @@ -12,6 +12,8 @@ import React from 'react'; +import pDefer from 'p-defer'; + import { mount, shallow @@ -19,7 +21,10 @@ import { import AuthTypes from '../AuthTypes'; import DeploymentDetailsModal from '../DeploymentDetailsModal'; +import DeploymentConfigValidator from '../DeploymentConfigValidator'; + +let mounted; describe('', () => { @@ -30,8 +35,6 @@ describe('', () => { describe('connection check', () => { - let mounted = null; - afterEach(() => { if (mounted && mounted.exists()) { mounted.unmount(); @@ -43,89 +46,96 @@ describe('', () => { it('should run connection check on mount with provided defaults', () => { // given - const checkConnectionStub = sinon.stub().resolves(); - - const initialFormValues = { - endpointUrl: 'http://localhost:8088/engine-rest', - tenantId: '', - deploymentName: 'diagram', - authType: AuthTypes.basic, - username: 'demo', - password: 'demo', - bearer: '' + const configuration = { + deployment: { + name: 'diagram', + tenantId: '' + }, + endpoint: { + url: 'http://localhost:8088/engine-rest', + authType: AuthTypes.basic, + username: 'demo', + password: 'demo' + } }; + const connectionChecker = new MockConnectionChecker(); + // when createModal({ - checkConnection: checkConnectionStub, - details: initialFormValues - }); + connectionChecker, + configuration + }, mount); // then - expect(checkConnectionStub).to.have.been.calledOnce; - expect(checkConnectionStub.args[0][0]).to.eql(initialFormValues); + expect(connectionChecker.check).to.have.been.calledOnce; + expect(connectionChecker.check).to.have.been.calledWith(configuration.endpoint); }); - it('should display hint if the username and password are missing', () => { + it('should display hint if the username and password are missing', async () => { // given - const checkConnectionStub = sinon.stub().resolves(); - - const initialFormValues = { - endpointUrl: 'http://localhost:8088/engine-rest', - tenantId: '', - deploymentName: 'diagram', - authType: AuthTypes.basic + const configuration = { + deployment: { + tenantId: '', + name: 'diagram' + }, + endpoint: { + url: 'http://localhost:8088/engine-rest', + authType: AuthTypes.basic + } }; - // when - const { wrapper } = createModal({ - checkConnection: checkConnectionStub, - details: initialFormValues, - validate: () => ({ username: 'username is missing', password: 'password is missing' }) + const connectionChecker = new MockConnectionChecker(); + + const { + wrapper + } = createModal({ + connectionChecker, + configuration }, mount); - mounted = wrapper; + // when + await connectionChecker.triggerComplete({}); - // then - const connectionCheckResult = wrapper.find('ConnectionCheckResult').first(); - const hint = connectionCheckResult.prop('hint'); + wrapper.update(); - expect(checkConnectionStub).to.not.have.been.called; - expect(hint).to.exist; - expect(connectionCheckResult.contains(hint), 'Does not display the hint').to.be.true; + // then + expect(wrapper.find('.hint.error')).to.have.length(2); }); - it('should display hint if token is missing', () => { + it('should display hint if token is missing', async () => { // given - const checkConnectionStub = sinon.stub().resolves(); - - const initialFormValues = { - endpointUrl: 'http://localhost:8088/engine-rest', - tenantId: '', - deploymentName: 'diagram', - authType: AuthTypes.bearer + const configuration = { + deployment: { + tenantId: '', + name: 'diagram' + }, + endpoint: { + url: 'http://localhost:8088/engine-rest', + authType: AuthTypes.bearer + } }; - // when - const { wrapper } = createModal({ - checkConnection: checkConnectionStub, - details: initialFormValues, - validate: () => ({ bearer: 'token is missing' }) + const connectionChecker = new MockConnectionChecker(); + + const { + wrapper + } = createModal({ + connectionChecker, + configuration }, mount); - mounted = wrapper; + // when + await connectionChecker.triggerComplete({}); + + wrapper.update(); // then - const connectionCheckResult = wrapper.find('ConnectionCheckResult').first(); - const hint = connectionCheckResult.prop('hint'); - - expect(checkConnectionStub).to.not.have.been.called; - expect(hint).to.exist; - expect(connectionCheckResult.contains(hint), 'Does not display the hint').to.be.true; + expect(wrapper.find('.hint.error')).to.have.length(1); }); }); @@ -135,14 +145,29 @@ describe('', () => { // helpers ////////// -function createModal(props, renderFn = shallow) { - props = { - checkConnection: noop, - validate: () => ({}), - ...props - }; - const wrapper = renderFn(); +function createModal(props={}, renderFn = shallow) { + + const { + configuration, + onClose, + connectionChecker, + ...apiOverrides + } = props; + + const validator = new MockValidator( + connectionChecker || new MockConnectionChecker(), apiOverrides + ); + + const wrapper = renderFn( + + ); + + mounted = wrapper; return { wrapper, @@ -151,3 +176,78 @@ function createModal(props, renderFn = shallow) { } function noop() {} + +function getDefaultConfiguration() { + return { + deployment: { + name: 'diagram', + tenantId: '' + }, + endpoint: { + url: 'http://localhost:8080/engine-rest', + authType: AuthTypes.none + } + }; +} + +class MockConnectionChecker { + + constructor() { + sinon.spy(this, 'check'); + } + + subscribe(hooks) { + this.hooks = hooks; + } + + unsubscribe() { + this.hooks = null; + } + + check(endpoint) { + + this.deferred = pDefer(); + + return this.deferred.promise.then(result => { + + this.hooks && this.hooks.onComplete(result); + + return result; + }); + } + + triggerStart() { + this.hooks && this.hooks.onStart(); + + return new Promise(resolve => { + setTimeout(resolve, 5); + }); + } + + triggerComplete(result) { + + this.deferred && this.deferred.resolve(result); + + return new Promise(resolve => { + setTimeout(resolve, 5); + }); + } + +} + +class MockValidator extends DeploymentConfigValidator { + + constructor(connectionChecker, apiStubs) { + super(); + + Object.assign(this, { + connectionChecker, + ...apiStubs + }); + } + + createConnectionChecker() { + return this.connectionChecker; + } + +} diff --git a/client/src/plugins/deployment-tool/__tests__/DeploymentToolSpec.js b/client/src/plugins/deployment-tool/__tests__/DeploymentToolSpec.js index 524439ea3c..803976df15 100644 --- a/client/src/plugins/deployment-tool/__tests__/DeploymentToolSpec.js +++ b/client/src/plugins/deployment-tool/__tests__/DeploymentToolSpec.js @@ -12,15 +12,19 @@ import React from 'react'; +import { shallow } from 'enzyme'; import { - mount, - shallow -} from 'enzyme'; -import { omit } from 'min-dash'; + omit +} from 'min-dash'; + +import { Config } from './../../../app/__tests__/mocks'; -import AuthTypes from '../AuthTypes'; -import DeploymentDetailsModal from '../DeploymentDetailsModal'; import DeploymentTool from '../DeploymentTool'; +import AuthTypes from '../AuthTypes'; + + +const CONFIG_KEY = 'deployment-tool'; +const ENGINE_ENDPOINTS_CONFIG_KEY = 'camundaEngineEndpoints'; describe('', () => { @@ -30,118 +34,307 @@ describe('', () => { }); - describe('#deploy', () => { + describe('deploy', () => { - let fetchStub, - mounted; + it('should derive deployment name from filename', async () => { - beforeEach(() => { - fetchStub = sinon.stub(window, 'fetch').resolves({ ok: true, json: () => ({}) }); - }); + // given + const deploySpy = sinon.spy(); + const activeTab = createTab({ name: 'foo.bpmn' }); + const { + instance + } = createDeploymentTool({ activeTab, deploySpy }); - afterEach(() => { - fetchStub.restore(); + // when + await instance.deploy(); - if (mounted && mounted.exists()) { - mounted.unmount(); - } + // then + expect(deploySpy).to.have.been.calledOnce; + expect(deploySpy.args[0][1].deployment).to.have.property('name', 'foo'); }); - it('should derive deployment name from filename', async () => { + it('should use saved config for deployed file', async () => { // given + const savedEndpoint = { + id: 'endpointId', + authType: AuthTypes.basic, + username: 'demo', + password: 'demo', + url: 'http://localhost:8088/engine-rest', + rememberCredentials: true + }; + + const savedConfiguration = { + deployment: { + name: 'diagram', + tenantId: '', + }, + endpointId: savedEndpoint.id + }; + + const config = { + get: (key, defaultValue) => { + if (key === ENGINE_ENDPOINTS_CONFIG_KEY) { + return [ + { id: 'OTHER_ENDPOINT' }, + savedEndpoint + ]; + } + + return defaultValue; + }, + getForFile: sinon.stub().returns(savedConfiguration) + }; + + const deploySpy = sinon.spy(); + const activeTab = createTab({ name: 'foo.bpmn' }); const { - instance, - wrapper - } = createDeploymentTool({ activeTab }, mount); - - mounted = wrapper; + instance + } = createDeploymentTool({ activeTab, config, deploySpy }); // when - instance.deploy(); + await instance.deployTab(activeTab); - await nextTick(); + // then + expect(deploySpy).to.have.been.calledOnce; + expect(deploySpy.args[0]).to.eql([ + activeTab, + { + deployment: savedConfiguration.deployment, + endpoint: savedEndpoint + } + ]); + }); - wrapper.update(); - // then - const modal = wrapper.find(DeploymentDetailsModal).first(); + it('should read and save config for deployed file', async () => { - const onClose = modal.prop('onClose'); + // given + const config = { + getForFile: sinon.spy(), + setForFile: sinon.spy() + }; - const deploymentName = modal.find('input[name="deploymentName"]').first().getDOMNode().value; + const configuration = createConfiguration(); - expect(deploymentName).to.eql('foo'); + const activeTab = createTab({ name: 'foo.bpmn' }); + + const { + instance + } = createDeploymentTool({ activeTab, config, ...configuration }); + + // when + await instance.deploy(); + + // then + expect(config.getForFile).to.have.been.calledOnce; + expect(config.getForFile.args[0]).to.eql([ + activeTab.file, + CONFIG_KEY + ]); - onClose(); + expect(config.setForFile).to.have.been.calledOnce; + expect(config.setForFile.args[0]).to.eql([ + activeTab.file, + CONFIG_KEY, + { ...omit(configuration, [ 'endpoint' ]), endpointId: configuration.endpoint.id } + ]); }); - it('should read and save config for deployed file', async () => { + it('should save credentials', async () => { // given - const config = sinon.stub({ - getForFile() { - return {}; - }, - setForFile() {} - }); - - const details = { - endpointUrl: 'http://localhost:8088/engine-rest', - tenantId: '', - deploymentName: 'diagram', - authType: AuthTypes.basic, - username: 'demo', - password: 'demo' + const config = { + set: sinon.spy() }; + const configuration = createConfiguration(); + const activeTab = createTab({ name: 'foo.bpmn' }); const { - instance, - wrapper - } = createDeploymentTool({ activeTab, config }, mount); + instance + } = createDeploymentTool({ activeTab, config, ...configuration }); + + // when + await instance.deploy(); + + // then + expect(config.set).to.have.been.calledOnce; + expect(config.set.args[0]).to.eql([ + ENGINE_ENDPOINTS_CONFIG_KEY, + [ configuration.endpoint ] + ]); + }); + + + it('should not save credentials if `rememberCredentials` was set to false', async () => { - mounted = wrapper; + // given + const config = { + set: sinon.spy() + }; + + const configuration = createConfiguration(null, { + rememberCredentials: false + }); + + const activeTab = createTab({ name: 'foo.bpmn' }); + + const { + instance + } = createDeploymentTool({ activeTab, config, ...configuration }); // when - instance.deploy(); + await instance.deploy(); + + // then + expect(config.set).to.have.been.calledOnce; + expect(config.set.args[0]).to.eql([ + ENGINE_ENDPOINTS_CONFIG_KEY, + [ { ...configuration.endpoint, password: '' } ] + ]); + }); + }); + - await nextTick(); + describe('save', () => { - wrapper.update(); + it('should save configuration when user decided to only save it', async () => { + + // given + const configuration = createConfiguration(); + const activeTab = createTab({ name: 'foo.bpmn' }); - const { handleClose } = wrapper.state('modalState'); + const config = { + set: sinon.spy(), + setForFile: sinon.spy() + }; - handleClose(details); + const { + instance + } = createDeploymentTool({ config, userAction: 'save', ...configuration }); - await nextTick(); + // when + await instance.deploy(); // then - expect(config.getForFile).to.have.been.calledOnce; - expect(config.getForFile.getCall(0).args).to.eql([ - activeTab.file, - 'deployment-config' + expect(config.set).to.have.been.calledOnce; + expect(config.set.args[0]).to.eql([ + ENGINE_ENDPOINTS_CONFIG_KEY, + [ configuration.endpoint ] ]); expect(config.setForFile).to.have.been.calledOnce; - expect(config.setForFile.getCall(0).args).to.eql([ + expect(config.setForFile.args[0]).to.eql([ activeTab.file, - 'deployment-config', - omit(details, [ 'username', 'password' ]) + CONFIG_KEY, + { ...omit(configuration, [ 'endpoint' ]), endpointId: configuration.endpoint.id } ]); }); + + it('should not deploy when user decided to only save configuration', async () => { + + // given + const deploySpy = sinon.spy(); + const { + instance + } = createDeploymentTool({ userAction: 'save' }); + + // when + await instance.deploy(); + + // then + expect(deploySpy).to.have.not.been.called; + }); + }); + + + describe('cancel', () => { + + it('should not save config if user cancelled the deployment', async () => { + + // given + const config = { + set: sinon.spy(), + setForFile: sinon.spy() + }; + + const activeTab = createTab({ name: 'foo.bpmn' }); + const { + instance + } = createDeploymentTool({ activeTab, config, userAction: 'cancel' }); + + // when + await instance.deploy(); + + // then + expect(config.setForFile).to.not.have.been.called; + expect(config.set).to.not.have.been.called; + }); }); }); -// helpers ////////// +// helper //// +class TestDeploymentTool extends DeploymentTool { + + /** + * @param {object} props + * @param {'cancel'|'save'|'deploy'} [props.userAction='deploy'] user action in configuration modal + * @param {object} [props.endpoint] overrides for endpoint configuration + * @param {object} [props.deployment] overrides for deployment configuration + */ + constructor(props) { + super(props); + } + + // removes CamundaAPI dependency + deployWithConfiguration(...args) { + this.props.deploySpy && this.props.deploySpy(...args); + } + + checkConnection = (...args) => { + this.props.checkConnectionSpy && this.props.checkConnectionSpy(...args); + } + + // closes automatically when modal is opened + componentDidUpdate(...args) { + super.componentDidUpdate && super.componentDidUpdate(...args); + + const { modalState } = this.state; + const { + userAction, + endpoint, + deployment + } = this.props; + + if (modalState) { + const action = userAction || 'deploy'; + + const configuration = action !== 'cancel' && { + endpoint: { + ...modalState.configuration.endpoint, + ...endpoint + }, + deployment: { + ...modalState.configuration.deployment, + ...deployment + } + }; + + modalState.handleClose(action, configuration); + } + } +} + function createDeploymentTool({ activeTab = createTab(), ...props @@ -157,20 +350,18 @@ function createDeploymentTool({ } }; - const config = { - getForFile() { - return {}; - }, - setForFile() {} - }; + const config = new Config({ + get: (_, defaultValue) => defaultValue, + ...props.config + }); - const wrapper = render(); return { @@ -194,8 +385,23 @@ function createTab(overrides = {}) { }; } -function nextTick() { - return new Promise(resolve => process.nextTick(() => resolve())); +function createConfiguration(deployment, endpoint) { + return { + deployment: { + name: 'diagram', + tenantId: '', + ...deployment + }, + endpoint: { + id: 'endpointId', + url: 'http://localhost:8088/engine-rest', + authType: AuthTypes.basic, + username: 'demo', + password: 'demo', + rememberCredentials: true, + ...endpoint + } + }; } function noop() {} diff --git a/client/src/plugins/deployment-tool/__tests__/validatorsSpec.js b/client/src/plugins/deployment-tool/__tests__/validatorsSpec.js deleted file mode 100644 index 3061919e83..0000000000 --- a/client/src/plugins/deployment-tool/__tests__/validatorsSpec.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * 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 validators from '../validators'; - - -describe('', () => { - - it('should validate endpoint url', () => { - - // given - const validate = validators.endpointUrl; - - // then - expect(validate()).to.exist; - expect(validate('')).to.exist; - expect(validate('ftp://url')).to.exist; - expect(validate('http://localhost:8080')).to.not.exist; - expect(validate('https://localhost:8080')).to.not.exist; - }); - - - it('should validate deployment name', () => { - - // given - const validate = validators.deploymentName; - - // then - expect(validate()).to.exist; - expect(validate('')).to.exist; - expect(validate('deployment name')).to.not.exist; - }); - - - it('should validate username', () => { - - // given - const validate = validators.username; - - // then - expect(validate()).to.exist; - expect(validate('')).to.exist; - expect(validate('username')).to.not.exist; - }); - - - it('should validate password', () => { - - // given - const validate = validators.password; - - // then - expect(validate()).to.exist; - expect(validate('')).to.exist; - expect(validate('password')).to.not.exist; - }); - - - it('should validate bearer token', () => { - - // given - const validate = validators.bearer; - - // then - expect(validate()).to.exist; - expect(validate('')).to.exist; - expect(validate('token')).to.not.exist; - }); -}); diff --git a/client/src/plugins/deployment-tool/components/AuthBasic.js b/client/src/plugins/deployment-tool/components/AuthBasic.js deleted file mode 100644 index 0df968e417..0000000000 --- a/client/src/plugins/deployment-tool/components/AuthBasic.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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 { Field } from 'formik'; - -import FormControl from './FormControl'; - - -export default function AuthBasic({ onFocusChange, ...props }) { - return ( - - - - - - ); -} \ No newline at end of file diff --git a/client/src/plugins/deployment-tool/components/CheckBox.js b/client/src/plugins/deployment-tool/components/CheckBox.js new file mode 100644 index 0000000000..885f23456c --- /dev/null +++ b/client/src/plugins/deployment-tool/components/CheckBox.js @@ -0,0 +1,64 @@ +/** + * 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 CheckBox(props) { + + const { + hint, + label, + field, + form, + fieldError, + ...restProps + } = props; + + const { + name: fieldName + } = field; + + const meta = form.getFieldMeta(fieldName); + + const error = (fieldError || defaultFieldError)(meta); + + return ( + +
+
+ + + + + + +
+ + ); +} diff --git a/client/src/plugins/deployment-tool/components/FormControl.js b/client/src/plugins/deployment-tool/components/FormControl.js deleted file mode 100644 index 4e110afa21..0000000000 --- a/client/src/plugins/deployment-tool/components/FormControl.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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 FormControl({ - field, - hint, - label, - onFocusChange, - validated, - form: { touched, errors, isSubmitting }, - ...props -}) { - const { name } = field; - - const invalid = errors[name] && touched[name]; - - return ( - -
- -
- -
- - - { invalid ? ( -
{errors[name]}
- ) : null} - - { hint ? ( -
{ hint }
- ) : null } -
-
- ); -} - - - -// helpers ////// -function compose(...handlers) { - return function(...args) { - handlers.forEach(handler => handler(...args)); - }; -} diff --git a/client/src/plugins/deployment-tool/components/AuthBearer.js b/client/src/plugins/deployment-tool/components/FormFeedback.js similarity index 58% rename from client/src/plugins/deployment-tool/components/AuthBearer.js rename to client/src/plugins/deployment-tool/components/FormFeedback.js index 7fad395956..e555c34b18 100644 --- a/client/src/plugins/deployment-tool/components/AuthBearer.js +++ b/client/src/plugins/deployment-tool/components/FormFeedback.js @@ -9,20 +9,20 @@ */ import React from 'react'; -import { Field } from 'formik'; -import FormControl from './FormControl'; +export default function FormFeedback(props) { + + const { + error, + hint + } = props; -export default function AuthBearer({ onFocusChange, ...props }) { return ( - + + { error &&
{ error }
} + + { hint &&
{ hint }
} +
); } diff --git a/client/src/plugins/deployment-tool/components/Select.js b/client/src/plugins/deployment-tool/components/Select.js new file mode 100644 index 0000000000..08096ebaac --- /dev/null +++ b/client/src/plugins/deployment-tool/components/Select.js @@ -0,0 +1,68 @@ +/** + * 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 Select(props) { + + const { + hint, + label, + field, + form, + fieldError, + children, + ...restProps + } = props; + + const { + name: fieldName + } = field; + + const meta = form.getFieldMeta(fieldName); + + const error = (fieldError || defaultFieldError)(meta); + + return ( + +
+ +
+ +
+ + + +
+
+ ); +} diff --git a/client/src/plugins/deployment-tool/components/TextInput.js b/client/src/plugins/deployment-tool/components/TextInput.js new file mode 100644 index 0000000000..e1f12a7bfe --- /dev/null +++ b/client/src/plugins/deployment-tool/components/TextInput.js @@ -0,0 +1,72 @@ +/** + * 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, + ...restProps + } = props; + + const { + name: fieldName, + value: fieldValue + } = field; + + const meta = form.getFieldMeta(fieldName); + + const error = (fieldError || defaultFieldError)(meta); + + const invalid = error; + const valid = !error && meta.touched; + + return ( + +
+ +
+ +
+ + + +
+
+ ); +} diff --git a/client/src/plugins/deployment-tool/errors/index.js b/client/src/plugins/deployment-tool/components/Util.js similarity index 64% rename from client/src/plugins/deployment-tool/errors/index.js rename to client/src/plugins/deployment-tool/components/Util.js index d1ba7d0a0f..9fcb1b1d51 100644 --- a/client/src/plugins/deployment-tool/errors/index.js +++ b/client/src/plugins/deployment-tool/components/Util.js @@ -8,11 +8,6 @@ * except in compliance with the MIT License. */ -import ConnectionError, { ConnectionErrorMessages } from './ConnectionError'; -import DeploymentError from './DeploymentError'; - -export { - ConnectionError, - ConnectionErrorMessages, - DeploymentError -}; +export function fieldError(meta) { + return meta.touched && meta.error; +} diff --git a/client/src/plugins/deployment-tool/components/index.js b/client/src/plugins/deployment-tool/components/index.js index 4b840d4b47..33c3db3b62 100644 --- a/client/src/plugins/deployment-tool/components/index.js +++ b/client/src/plugins/deployment-tool/components/index.js @@ -8,6 +8,6 @@ * except in compliance with the MIT License. */ -export { default as FormControl } from './FormControl'; -export { default as AuthBasic } from './AuthBasic'; -export { default as AuthBearer } from './AuthBearer'; +export { default as TextInput } from './TextInput'; +export { default as CheckBox } from './CheckBox'; +export { default as Select } from './Select'; diff --git a/client/src/plugins/deployment-tool/errors/ConnectionError.js b/client/src/plugins/deployment-tool/errors/ConnectionError.js deleted file mode 100644 index 623e581d41..0000000000 --- a/client/src/plugins/deployment-tool/errors/ConnectionError.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * 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 const ConnectionErrorMessages = { - noInternetConnection: 'Could not establish a network connection. Most likely your machine is not online right now.', - unableToConnect: 'Could not connect to the server. Did you run the engine?', - unauthorized: 'Authentication failed. Please check your credentials.', - forbidden: 'This user is not permitted to deploy. Please use different credentials or get this user enabled to deploy.', - notFound: 'Could not find the Camunda endpoint. Please check the URL and make sure Camunda is running.', - internalServerError: 'Camunda is reporting an error. Please check the server status.', - unreachable: 'Camunda is reporting an error. Please check the server status.' -}; - - -export default class ConnectionError extends Error { - constructor(response) { - super(); - - this.message = ( - this.getStatusCodeErrorMessage(response) || - this.getNetworkErrorMessage(response) - ); - } - - getStatusCodeErrorMessage(response) { - switch (response.status) { - case 401: - return ConnectionErrorMessages.unauthorized; - case 403: - return ConnectionErrorMessages.forbidden; - case 404: - return ConnectionErrorMessages.notFound; - case 500: - return ConnectionErrorMessages.internalServerError; - case 503: - return ConnectionErrorMessages.unavailable; - } - } - - getNetworkErrorMessage(response) { - if (!/^https?:\/\/localhost/.test(response.url) && !window.navigator.onLine) { - return ConnectionErrorMessages.noInternetConnection; - } - - return ConnectionErrorMessages.unableToConnect; - } -} diff --git a/client/src/plugins/deployment-tool/errors/DeploymentError.js b/client/src/plugins/deployment-tool/errors/DeploymentError.js deleted file mode 100644 index a4d22ed9c1..0000000000 --- a/client/src/plugins/deployment-tool/errors/DeploymentError.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * 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 DeploymentErrorMessages = { - noInternetConnection: 'Could not establish a network connection. Most likely your machine is not online right now.', - unableToConnect: 'Could not connect to the server. Did you run the engine?', - bpmnParsingError: 'Server could not parse the diagram. Please check log for errors.', - unauthorized: 'The deployment was unauthorized. Please use valid credentials.', - forbidden: 'The deployment was not permitted for your credentials. Please check your credentials.', - notFound: 'Could not connect to Camunda. Please check the endpoint URL.', - internalServerError: 'Camunda reported an unknown error. Please check the server status.', - serverUnavailable: 'Camunda is currently unavailable. Please try again later.' -}; - -const PARSE_ERROR = 'ENGINE-09005 Could not parse BPMN process. Errors:'; - - -export default class DeploymentError extends Error { - constructor(response, body) { - super(); - - this.message = ( - this.getCamundaBpmErrorMessage(body) || - this.getStatusCodeErrorMessage(response) || - this.getNetworkErrorMessage(response) - ); - - this.problems = this.getProblems(body); - } - - getCamundaBpmErrorMessage(body) { - if (body && body.message && body.message.startsWith(PARSE_ERROR)) { - return DeploymentErrorMessages.bpmnParsingError; - } - } - - getStatusCodeErrorMessage(response) { - switch (response.status) { - case 401: - return DeploymentErrorMessages.unauthorized; - case 403: - return DeploymentErrorMessages.forbidden; - case 404: - return DeploymentErrorMessages.notFound; - case 500: - return DeploymentErrorMessages.internalServerError; - case 503: - return DeploymentErrorMessages.serverUnavailable; - } - } - - getNetworkErrorMessage(response) { - if (!/^https?:\/\/localhost/.test(response.url) && !window.navigator.onLine) { - return DeploymentErrorMessages.noInternetConnection; - } - - return DeploymentErrorMessages.unableToConnect; - } - - getProblems(body) { - return body.message; - } -} diff --git a/client/src/plugins/deployment-tool/getEditMenu.js b/client/src/plugins/deployment-tool/getEditMenu.js deleted file mode 100644 index 2a20173be1..0000000000 --- a/client/src/plugins/deployment-tool/getEditMenu.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 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 function getEditMenu(enabled) { - return [ - [ - { - role: 'undo', - enabled - }, - { - role: 'redo', - enabled - }, - ], - [ - { - role: 'copy', - enabled - }, - { - role: 'cut', - enabled - }, - { - role: 'paste', - enabled - }, - { - role: 'selectAll', - enabled - } - ] - ]; -} diff --git a/client/src/plugins/deployment-tool/validators.js b/client/src/plugins/deployment-tool/validators.js deleted file mode 100644 index 92cd87abd3..0000000000 --- a/client/src/plugins/deployment-tool/validators.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * 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 { - bearer: validateBearer, - deploymentName: validateDeploymentName, - endpointUrl: validateEndpointUrl, - password: validatePassword, - username: validateUsername -}; - -function validateBearer(bearer) { - if (!bearer) { - return 'Token must not be empty.'; - } -} - -function validateDeploymentName(name) { - if (!name) { - return 'Deployment name must not be empty.'; - } -} - -function validateEndpointUrl(url) { - if (!url) { - return 'Endpoint URL must not be empty.'; - } - - if (!/^https?:\/\/.+/.test(url)) { - return 'Endpoint URL must start with "http://" or "https://".'; - } -} - -function validatePassword(password) { - if (!password) { - return 'Password must not be empty.'; - } -} - -function validateUsername(username) { - if (!username) { - return 'Username must not be empty.'; - } -} \ No newline at end of file