From 8e7feb6e85b9fe31fcb88a0a439327721cc00656 Mon Sep 17 00:00:00 2001 From: Mick McGrath Date: Thu, 28 May 2020 12:01:23 -0500 Subject: [PATCH 01/56] initial attempt at integrating st2flow --- .eslintrc.yml | 12 +- .flowconfig | 13 + .gitignore | 4 + Dockerfile | 18 + apps/st2-workflows/index.js | 25 + apps/st2-workflows/package.json | 108 ++ apps/st2-workflows/store.js | 527 ++++++++++ apps/st2-workflows/style.css | 53 + apps/st2-workflows/workflows.component.js | 456 ++++++++ babel.config.js | 1 + docker-compose.yml | 12 + main.js | 2 + modules/st2-router/history.js | 2 +- modules/st2-router/package.json | 2 +- modules/st2-router/route.component.js | 2 +- modules/st2-router/router.component.js | 14 + modules/st2-test-utils/test-utils.js | 5 + modules/st2-time/package.json | 2 +- modules/st2flow-canvas/astar.js | 483 +++++++++ modules/st2flow-canvas/collapse-button.js | 58 ++ modules/st2flow-canvas/const.js | 12 + modules/st2flow-canvas/index.js | 831 +++++++++++++++ modules/st2flow-canvas/package.json | 62 ++ modules/st2flow-canvas/path/index.js | 93 ++ modules/st2flow-canvas/path/line.js | 80 ++ modules/st2flow-canvas/poisson-rect.js | 153 +++ modules/st2flow-canvas/routing-graph.js | 226 ++++ modules/st2flow-canvas/style.css | 406 ++++++++ modules/st2flow-canvas/task.js | 286 ++++++ modules/st2flow-canvas/toolbar.js | 193 ++++ modules/st2flow-canvas/transition.js | 220 ++++ modules/st2flow-canvas/vector.js | 240 +++++ modules/st2flow-details/index.js | 141 +++ modules/st2flow-details/layout.js | 82 ++ modules/st2flow-details/meta-panel.js | 200 ++++ modules/st2flow-details/mistral-properties.js | 194 ++++ modules/st2flow-details/mistral-transition.js | 147 +++ .../st2flow-details/orquesta-properties.js | 106 ++ .../st2flow-details/orquesta-transition.js | 228 ++++ modules/st2flow-details/package.json | 61 ++ modules/st2flow-details/parameter-editor.js | 83 ++ modules/st2flow-details/parameter.js | 83 ++ modules/st2flow-details/parameters-panel.js | 126 +++ modules/st2flow-details/property.js | 48 + modules/st2flow-details/string-properties.js | 106 ++ modules/st2flow-details/style.css | 379 +++++++ modules/st2flow-details/task-details.js | 245 +++++ modules/st2flow-details/task-list.js | 61 ++ modules/st2flow-details/task.js | 71 ++ modules/st2flow-editor/index.js | 217 ++++ modules/st2flow-editor/package.json | 60 ++ modules/st2flow-editor/style.css | 40 + modules/st2flow-header/index.js | 73 ++ modules/st2flow-header/package.json | 59 ++ modules/st2flow-header/style.css | 90 ++ modules/st2flow-model/base-class.js | 225 ++++ modules/st2flow-model/base-model.js | 36 + modules/st2flow-model/event-emitter.js | 53 + modules/st2flow-model/index.js | 23 + modules/st2flow-model/interfaces.js | 122 +++ modules/st2flow-model/layout.js | 16 + modules/st2flow-model/model-meta.js | 48 + modules/st2flow-model/model-mistral.js | 648 ++++++++++++ modules/st2flow-model/model-orquesta.js | 664 ++++++++++++ modules/st2flow-model/package.json | 30 + modules/st2flow-model/schemas/metadata.json | 62 ++ modules/st2flow-model/schemas/mistral.json | 971 ++++++++++++++++++ modules/st2flow-model/schemas/orquesta.json | 330 ++++++ .../tests/data/actionchain-basic.yaml | 28 + .../st2flow-model/tests/data/common-data.js | 114 ++ .../st2flow-model/tests/data/meta-basic.json | 63 ++ .../tests/data/mistral-basic.yaml | 40 + .../tests/data/orquesta-basic.yaml | 36 + modules/st2flow-model/tests/test-common.js | 203 ++++ .../st2flow-model/tests/test-model-meta.js | 49 + .../st2flow-model/tests/test-model-mistral.js | 48 + .../tests/test-model-orquesta.js | 85 ++ modules/st2flow-notifications/README.md | 79 ++ modules/st2flow-notifications/index.js | 94 ++ modules/st2flow-notifications/package.json | 44 + modules/st2flow-notifications/style.css | 85 ++ modules/st2flow-palette/action.js | 68 ++ modules/st2flow-palette/index.js | 94 ++ modules/st2flow-palette/pack.js | 77 ++ modules/st2flow-palette/package.json | 58 ++ modules/st2flow-palette/style.css | 87 ++ modules/st2flow-perf/index.js | 36 + modules/st2flow-perf/package.json | 21 + modules/st2flow-yaml/README.md | 232 +++++ modules/st2flow-yaml/crawler.js | 654 ++++++++++++ modules/st2flow-yaml/index.js | 14 + modules/st2flow-yaml/objectifier.js | 128 +++ modules/st2flow-yaml/package.json | 25 + modules/st2flow-yaml/stringifier.js | 56 + .../st2flow-yaml/tests/data/basic-json.yaml | 45 + modules/st2flow-yaml/tests/data/basic.yaml | 67 ++ .../st2flow-yaml/tests/data/complex-json.yaml | 142 +++ modules/st2flow-yaml/tests/data/complex.yaml | 213 ++++ .../st2flow-yaml/tests/data/long-json.yaml | 412 ++++++++ modules/st2flow-yaml/tests/data/long.yaml | 390 +++++++ .../st2flow-yaml/tests/data/simple-json.yaml | 15 + modules/st2flow-yaml/tests/data/simple.yaml | 20 + .../st2flow-yaml/tests/refinery/js-in-yaml.js | 69 ++ .../tests/refinery/obj-to-yaml.js | 87 ++ modules/st2flow-yaml/tests/test-crawler.js | 433 ++++++++ modules/st2flow-yaml/tests/test-refinery.js | 34 + modules/st2flow-yaml/tests/test-token-set.js | 69 ++ modules/st2flow-yaml/token-factory.js | 166 +++ modules/st2flow-yaml/token-refinery.js | 470 +++++++++ modules/st2flow-yaml/token-set.js | 392 +++++++ modules/st2flow-yaml/types.js | 101 ++ modules/st2flow-yaml/util.js | 64 ++ package.json | 5 + 113 files changed, 16436 insertions(+), 5 deletions(-) create mode 100644 .flowconfig create mode 100644 Dockerfile create mode 100644 apps/st2-workflows/index.js create mode 100644 apps/st2-workflows/package.json create mode 100644 apps/st2-workflows/store.js create mode 100644 apps/st2-workflows/style.css create mode 100644 apps/st2-workflows/workflows.component.js create mode 100644 docker-compose.yml create mode 100644 modules/st2flow-canvas/astar.js create mode 100644 modules/st2flow-canvas/collapse-button.js create mode 100644 modules/st2flow-canvas/const.js create mode 100644 modules/st2flow-canvas/index.js create mode 100644 modules/st2flow-canvas/package.json create mode 100644 modules/st2flow-canvas/path/index.js create mode 100644 modules/st2flow-canvas/path/line.js create mode 100644 modules/st2flow-canvas/poisson-rect.js create mode 100644 modules/st2flow-canvas/routing-graph.js create mode 100644 modules/st2flow-canvas/style.css create mode 100644 modules/st2flow-canvas/task.js create mode 100644 modules/st2flow-canvas/toolbar.js create mode 100644 modules/st2flow-canvas/transition.js create mode 100644 modules/st2flow-canvas/vector.js create mode 100644 modules/st2flow-details/index.js create mode 100644 modules/st2flow-details/layout.js create mode 100644 modules/st2flow-details/meta-panel.js create mode 100644 modules/st2flow-details/mistral-properties.js create mode 100644 modules/st2flow-details/mistral-transition.js create mode 100644 modules/st2flow-details/orquesta-properties.js create mode 100644 modules/st2flow-details/orquesta-transition.js create mode 100644 modules/st2flow-details/package.json create mode 100644 modules/st2flow-details/parameter-editor.js create mode 100644 modules/st2flow-details/parameter.js create mode 100644 modules/st2flow-details/parameters-panel.js create mode 100644 modules/st2flow-details/property.js create mode 100644 modules/st2flow-details/string-properties.js create mode 100644 modules/st2flow-details/style.css create mode 100644 modules/st2flow-details/task-details.js create mode 100644 modules/st2flow-details/task-list.js create mode 100644 modules/st2flow-details/task.js create mode 100644 modules/st2flow-editor/index.js create mode 100644 modules/st2flow-editor/package.json create mode 100644 modules/st2flow-editor/style.css create mode 100644 modules/st2flow-header/index.js create mode 100644 modules/st2flow-header/package.json create mode 100644 modules/st2flow-header/style.css create mode 100644 modules/st2flow-model/base-class.js create mode 100644 modules/st2flow-model/base-model.js create mode 100644 modules/st2flow-model/event-emitter.js create mode 100644 modules/st2flow-model/index.js create mode 100644 modules/st2flow-model/interfaces.js create mode 100644 modules/st2flow-model/layout.js create mode 100644 modules/st2flow-model/model-meta.js create mode 100644 modules/st2flow-model/model-mistral.js create mode 100644 modules/st2flow-model/model-orquesta.js create mode 100644 modules/st2flow-model/package.json create mode 100644 modules/st2flow-model/schemas/metadata.json create mode 100644 modules/st2flow-model/schemas/mistral.json create mode 100644 modules/st2flow-model/schemas/orquesta.json create mode 100644 modules/st2flow-model/tests/data/actionchain-basic.yaml create mode 100644 modules/st2flow-model/tests/data/common-data.js create mode 100644 modules/st2flow-model/tests/data/meta-basic.json create mode 100644 modules/st2flow-model/tests/data/mistral-basic.yaml create mode 100644 modules/st2flow-model/tests/data/orquesta-basic.yaml create mode 100644 modules/st2flow-model/tests/test-common.js create mode 100644 modules/st2flow-model/tests/test-model-meta.js create mode 100644 modules/st2flow-model/tests/test-model-mistral.js create mode 100644 modules/st2flow-model/tests/test-model-orquesta.js create mode 100644 modules/st2flow-notifications/README.md create mode 100644 modules/st2flow-notifications/index.js create mode 100644 modules/st2flow-notifications/package.json create mode 100644 modules/st2flow-notifications/style.css create mode 100644 modules/st2flow-palette/action.js create mode 100644 modules/st2flow-palette/index.js create mode 100644 modules/st2flow-palette/pack.js create mode 100644 modules/st2flow-palette/package.json create mode 100644 modules/st2flow-palette/style.css create mode 100644 modules/st2flow-perf/index.js create mode 100644 modules/st2flow-perf/package.json create mode 100644 modules/st2flow-yaml/README.md create mode 100644 modules/st2flow-yaml/crawler.js create mode 100644 modules/st2flow-yaml/index.js create mode 100644 modules/st2flow-yaml/objectifier.js create mode 100644 modules/st2flow-yaml/package.json create mode 100644 modules/st2flow-yaml/stringifier.js create mode 100644 modules/st2flow-yaml/tests/data/basic-json.yaml create mode 100644 modules/st2flow-yaml/tests/data/basic.yaml create mode 100644 modules/st2flow-yaml/tests/data/complex-json.yaml create mode 100644 modules/st2flow-yaml/tests/data/complex.yaml create mode 100644 modules/st2flow-yaml/tests/data/long-json.yaml create mode 100644 modules/st2flow-yaml/tests/data/long.yaml create mode 100644 modules/st2flow-yaml/tests/data/simple-json.yaml create mode 100644 modules/st2flow-yaml/tests/data/simple.yaml create mode 100644 modules/st2flow-yaml/tests/refinery/js-in-yaml.js create mode 100644 modules/st2flow-yaml/tests/refinery/obj-to-yaml.js create mode 100644 modules/st2flow-yaml/tests/test-crawler.js create mode 100644 modules/st2flow-yaml/tests/test-refinery.js create mode 100644 modules/st2flow-yaml/tests/test-token-set.js create mode 100644 modules/st2flow-yaml/token-factory.js create mode 100644 modules/st2flow-yaml/token-refinery.js create mode 100644 modules/st2flow-yaml/token-set.js create mode 100644 modules/st2flow-yaml/types.js create mode 100644 modules/st2flow-yaml/util.js diff --git a/.eslintrc.yml b/.eslintrc.yml index 19107ad34..dec63b974 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1,6 +1,9 @@ --- root: true +globals: + $Shape: false plugins: + - flowtype - react - notice extends: @@ -135,6 +138,13 @@ rules: logical: parens-new-line prop: parens-new-line + # TODO: fix the notice notice/notice: - - error + # - error + - warn + + # this is the mustMatch from st2web - mustMatch: "(// Copyright \\d{4} [a-zA-Z0-9,\\.\\s]+\\n)+//\\n// Licensed under the Apache License, Version 2\\.0 \\(the \"License\"\\);\\n// you may not use this file except in compliance with the License\\.\\n// You may obtain a copy of the License at\\n//\\n//\\s+http://www\\.apache\\.org/licenses/LICENSE-2\\.0\\n//\\n// Unless required by applicable law or agreed to in writing, software\\n// distributed under the License is distributed on an \"AS IS\" BASIS,\\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\\n// See the License for the specific language governing permissions and\\n// limitations under the License\\.\\n" + + # this is the mustMatch from st2flow + # - mustMatch: "(// Copyright \\d{4} [a-zA-Z0-9,\\.\\s]+\\n)+//\\n// Unauthorized copying of this file, via any medium is strictly\\n// prohibited. Proprietary and confidential. See the LICENSE file\\n// included with this work for details\\.\\n" diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 000000000..e266f9e33 --- /dev/null +++ b/.flowconfig @@ -0,0 +1,13 @@ +[ignore] +.*/module-deps/test/invalid_pkg/package.json + +[include] + +[libs] + +[lints] + +[options] +esproposal.decorators=ignore + +[strict] diff --git a/.gitignore b/.gitignore index d9452c7e5..0c065f60e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ debian/files # VIM Swap .*.swp + + +# local stuff +config.local.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..c5e7b946c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:10.15.3 + +# Create app directory +WORKDIR /opt/stackstorm/static/webui/st2web + +# get files +COPY . /opt/stackstorm/static/webui/st2web +RUN rm /opt/stackstorm/static/webui/st2web/yarn.lock + +# install dependencies +RUN npm install -g gulp-cli lerna yarn +RUN lerna bootstrap + +# expose your ports +EXPOSE 3000 + +# start it up +CMD [ "gulp" ] diff --git a/apps/st2-workflows/index.js b/apps/st2-workflows/index.js new file mode 100644 index 000000000..74a976c27 --- /dev/null +++ b/apps/st2-workflows/index.js @@ -0,0 +1,25 @@ +// Copyright 2019 Extreme Networks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Workflows from './workflows.component'; + +const route = { + title: 'Workflows', + url: '/workflows', + icon: 'icon-history', + Component: Workflows, + position: 7, +}; + +export default route; diff --git a/apps/st2-workflows/package.json b/apps/st2-workflows/package.json new file mode 100644 index 000000000..4e4114f05 --- /dev/null +++ b/apps/st2-workflows/package.json @@ -0,0 +1,108 @@ +{ + "name": "@stackstorm/app-workflows", + "version": "0.3.0", + "description": "StackStorm Workflow Editor", + "main": "index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/stackstorm/st2web.git" + }, + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/stackstorm/st2web/issues" + }, + "homepage": "https://github.com/stackstorm/st2web#readme", + "browserify": { + "transform": [ + "babelify", + [ + "@stackstorm/browserify-postcss", + { + "extensions": [ + ".css" + ], + "inject": "insert-css", + "modularize": false, + "plugin": [ + "postcss-import", + "postcss-nested", + [ + "postcss-preset-env", + { + "features": { + "custom-properties": { + "preserve": false + } + } + } + ] + ] + } + ] + ] + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@babel/plugin-transform-async-to-generator": "^7.4.0", + "@babel/plugin-transform-flow-strip-types": "^7.4.0", + "@stackstorm/module-api": "^2.4.3", + "@stackstorm/module-router": "^2.4.3", + "@stackstorm/module-store": "^2.4.3", + "@stackstorm/st2-style": "^2.4.3", + "@stackstorm/st2flow-canvas": "1.0.0", + "@stackstorm/st2flow-details": "1.0.0", + "@stackstorm/st2flow-header": "1.0.0", + "@stackstorm/st2flow-model": "1.0.0-pre.5", + "@stackstorm/st2flow-palette": "1.0.0", + "babel-eslint": "^10.0.1", + "classnames": "^2.2.5", + "eslint-plugin-flowtype": "^3.5.1", + "eventemitter3": "^3.1.0", + "gulp-uglify-es": "^1.0.4", + "insert-css": "^2.0.0", + "lodash": "^4.17.4", + "moment": "^2.18.1", + "prop-types": "^15.6.0", + "react": "^16.8.4", + "react-dom": "^16.8.4", + "react-hotkeys": "^1.1.4", + "react-redux": "^6.0.1", + "react-router-dom": "^5.0.0", + "redux": "^4.0.1", + "urijs": "^1.19.1" + }, + "devDependencies": { + "@babel/core": "7.4.3", + "@babel/plugin-proposal-class-properties": "7.4.0", + "@babel/plugin-proposal-decorators": "7.4.0", + "@babel/plugin-proposal-object-rest-spread": "7.4.3", + "@babel/plugin-transform-runtime": "7.4.3", + "@babel/polyfill": "7.4.3", + "@babel/preset-env": "7.4.3", + "@babel/preset-react": "7.0.0", + "@babel/register": "7.4.0", + "@stackstorm/browserify-postcss": "0.3.4-patch.5", + "@stackstorm/module-test-utils": "^2.4.3", + "@stackstorm/st2-build": "^2.4.2", + "babelify": "^10.0.0", + "browserify": "^16.2.3", + "chai": "^4.1.2", + "eslint": "^5.16.0", + "eslint-plugin-notice": "0.7.8", + "eslint-plugin-react": "^7.12.4", + "flow-bin": "^0.96.0", + "ignore-styles": "^5.0.1", + "lerna": "^3.4.3", + "less": "^3.9.0", + "postcss": "^7.0.14", + "postcss-import": "12.0.1", + "postcss-nested": "4.1.2", + "postcss-preset-env": "6.6.0", + "request": "^2.69.0", + "sinon": "^7.3.1", + "sinon-chai": "^3.3.0", + "zombie": "^6.1.4" + } +} diff --git a/apps/st2-workflows/store.js b/apps/st2-workflows/store.js new file mode 100644 index 000000000..f0171f03e --- /dev/null +++ b/apps/st2-workflows/store.js @@ -0,0 +1,527 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +import { createScopedStore } from '@stackstorm/module-store'; + +import { models, OrquestaModel } from '@stackstorm/st2flow-model'; +import { layout } from '@stackstorm/st2flow-model/layout'; +import MetaModel from '@stackstorm/st2flow-model/model-meta'; +import { debounce, difference, get, uniqueId } from 'lodash'; + +let workflowModel = new OrquestaModel(); +const metaModel = new MetaModel(); + +metaModel.fromYAML(metaModel.constructor.minimum); +metaModel.set('runner_type', 'orquesta'); + +function workflowModelGetter(model) { + const { tasks, transitions, errors, input, vars } = model; + + const lastIndex = tasks + .map(task => (task.name.match(/task(\d+)/) || [])[1]) + .reduce((acc, item) => Math.max(acc, item || 0), 0); + + model.on( + 'error', + (err) => errors.push(...err) + ); + + if (model.checkWorkbook) { + model.checkWorkbook(); + } + + model.validate(); + + return { + workflowSource: model.toYAML(), + ranges: getRanges(model), + tasks, + input, + vars, + nextTask: `task${lastIndex + 1}`, + transitions, + notifications: errors.map(e => ({ + type: 'error', + source: 'workflow', + message: e.message, + id: uniqueId(), + })), + }; +} + +function metaModelGetter(model) { + const metaSource = model.toYAML(); + const { errors } = model; + + model.on( + 'error', + (err) => errors.push(err[0].message) + ); + + return { + metaSource, + notifications: errors.map(e => ({ type: 'error', message: e.message, source: 'meta-yaml-error' })), + meta: model.tokenSet.toObject(), + }; +} + +function getRanges(model) { + const ranges = {}; + + model.tasks.forEach(task => { + ranges[task.name] = workflowModel.getRangeForTask(task); + }); + + return ranges; +} + +function extendedValidation(meta, model) { + const errors = []; + if(model.input) { + const params = meta.parameters || {}; + const paramNames = Object.keys(params); + const inputNames = model.input.map(input => { + const key = typeof input === 'string' ? input : Object.keys(input)[0]; + return key; + }); + paramNames.forEach(paramName => { + if(!inputNames.includes(paramName)) { + errors.push(`Parameter "${paramName}" must be in input`); + } + }); + model.input.forEach(input => { + if(typeof input === 'string' && !params[input]) { + errors.push(`Extra input "${input}" must have a value`); + } + }); + } + + return errors.length ? errors : null; +} + + +const flowReducer = (state = {}, input) => { + const { + workflowSource = workflowModel.constructor.minimum, + metaSource = '', + pack = 'default', + meta = metaModel, + tasks = [], + transitions = [], + input: stateInput = [], + vars = [], + ranges = {}, + nextTask = 'task1', + + panels = {}, + actions = [], + notifications = [], + + navigation = {}, + + dirty = false, + } = state; + + state = { + ...state, + workflowSource, + metaSource, + pack, + meta, + tasks, + transitions, + input: stateInput, + vars, + ranges, + nextTask, + + panels, + actions, + notifications, + + navigation, + + dirty, + }; + + switch (input.type) { + // Workflow Model + case 'MODEL_ISSUE_COMMAND': { + const { command, args } = input; + + if (!workflowModel[command]) { + return state; + } + + if (!workflowModel.tokenSet) { + workflowModel.fromYAML(workflowModel.constructor.minimum); + } + + workflowModel[command](...args); + + const extendedNotifications = []; + if(command === 'applyDelta') { + // Editor changes mean extended validating the work. + const extendedErrors = extendedValidation(meta, workflowModel); + state.notifications = state.notifications.filter(e => e.source !== 'input'); + if(extendedErrors) { + extendedNotifications.push( + ...extendedErrors.map(message => ({ type: 'error', source: 'input', message, id: uniqueId() })) + ); + } + } + + const modelState = workflowModelGetter(workflowModel); + + return { + ...state, + ...modelState, + notifications: modelState.notifications.concat(extendedNotifications), + dirty: true, + }; + } + + case 'MODEL_LAYOUT': { + layout(workflowModel); + + return { + ...state, + ...workflowModelGetter(workflowModel), + dirty: true, + }; + } + + // Metadata Model + case 'META_ISSUE_COMMAND': { + const { command, args } = input; + + if (!metaModel[command]) { + return state; + } + + if (!metaModel.tokenSet) { + metaModel.fromYAML(metaModel.constructor.minimum); + } + + const oldParamNames = metaModel.parameters ? Object.keys(metaModel.parameters) : []; + oldParamNames.sort((a, b) => { + const aPos = get(metaModel, [ 'parameters', a, 'position' ]); + const bPos = get(metaModel, [ 'parameters', b, 'position' ]); + return aPos < bPos ? -1 : 1; + }); + + metaModel[command](...args); + + const runner_type = metaModel.get('runner_type'); + if (runner_type && runner_type !== meta.runner_type) { + if (state.tasks.length > 0) { + throw 'Cannot change runner_type of workflow that has existing tasks'; + } + const Model = models[runner_type]; + + workflowModel = new Model(); + workflowModel.fromYAML(workflowModel.constructor.minimum); + + state = { + ...state, + ...workflowModelGetter(workflowModel), + }; + } + + if(command === 'set' && args[0] === 'parameters') { + const [ , params ] = args; + if(!workflowModel.tokenSet) { + workflowModel.fromYAML(state.workflowSource); + } + + const paramNames = Object.keys(params); + const deletions = difference(oldParamNames, paramNames); + + workflowModel.setInputs(paramNames, deletions); + state.workflowSource = workflowModel.toYAML(); + state.input = workflowModel.input; + } + + const metaModelState = metaModelGetter(metaModel); + + return { + ...state, + ...metaModelState, + notifications: notifications.filter(n => n.source !== 'meta-yaml-error').concat(metaModelState.notifications), + dirty: true, + }; + } + + case 'PUSH_ERROR': { + const { error, link, source } = input; + + return { + ...state, + notifications: [ + ...notifications.filter(n => !source || n.source !== source), + { type: 'error', message: error, source, link, id: uniqueId() }, + ], + }; + } + + case 'PUSH_SUCCESS': { + const { message, link, source } = input; + + return { + ...state, + notifications: [ + ...notifications.filter(n => !source || n.source !== source), + { type: 'success', message, source, link, id: uniqueId() }, + ], + }; + } + + // CollapseModel + case 'PANEL_TOGGLE_COLLAPSE': { + const { name } = input; + + return { + ...state, + panels: { + ...panels, + [name]: !panels[name], + }, + }; + } + + //ActionsModel + case 'FETCH_ACTIONS': { + const { status, payload } = input; + + if (status === 'success') { + return { + ...state, + actions: payload, + }; + } + + return state; + } + + //NavigationModel + case 'CHANGE_NAVIGATION': { + const { navigation } = input; + + return { + ...state, + navigation: { + ...state.navigation, + ...navigation, + }, + }; + } + + case 'LOAD_WORKFLOW': { + const { currentWorkflow, status, payload } = input; + + const [ pack ] = currentWorkflow.split('.'); + + const newState = { + ...state, + pack, + currentWorkflow, + }; + + if (status === 'success') { + const { workflowSource, metaSource } = payload; + + metaModel.applyDelta(null, metaSource); + + const runner_type = metaModel.get('runner_type'); + const Model = models[runner_type]; + + if (workflowModel instanceof Model) { + workflowModel.applyDelta(null, workflowSource); + } + else { + workflowModel = new Model(workflowSource); + } + + if (workflowModel.tasks.every(({ coords }) => !coords.x && !coords.y)) { + layout(workflowModel); + } + + return { + ...newState, + metaSource, + workflowSource, + ...metaModelGetter(metaModel), + ...workflowModelGetter(workflowModel), + dirty: false, + }; + } + + return newState; + } + + case 'SAVE_WORKFLOW': { + const { status } = input; + + if (status === 'success') { + return { + ...state, + dirty: false, + }; + } + + return state; + } + + case '@@st2/INIT': { + const initMeta = new MetaModel(); + initMeta.applyDelta(null, metaModel.toYAML()); + return { + ...state, + metaSource: metaModel.toYAML(), + meta: initMeta, + }; + } + + default: + return state; + } +}; + +const prevRecords = []; +const nextRecords = []; + +const handleModelCommand = debounce((prevState = {}, state = {}, input) => { + const historyRecord = {}; + + if (prevState.workflowSource !== state.workflowSource) { + historyRecord.workflowSource = prevState.workflowSource; + } + + if (prevState.metaSource !== state.metaSource) { + historyRecord.metaSource = prevState.metaSource; + } + + if (Object.keys(historyRecord).length !== 0) { + prevRecords.push(historyRecord); + } + + return state; +}, 250, { leading: true, trailing: false }); + +const undoReducer = (prevState = {}, state = {}, input) => { + switch (input.type) { + case 'META_ISSUE_COMMAND': + case 'MODEL_LAYOUT': + case 'MODEL_ISSUE_COMMAND': { + handleModelCommand(prevState, state, input); + return state; + } + + case 'FLOW_UNDO': { + handleModelCommand.flush(); + + const historyRecord = prevRecords.pop(); + + if (!historyRecord) { + return state; + } + + const { workflowSource, metaSource } = historyRecord; + const futureRecord = {}; + + if (workflowSource !== undefined) { + futureRecord.workflowSource = state.workflowSource; + + workflowModel.applyDelta(null, workflowSource); + const parsedWorkflow = workflowModelGetter(workflowModel); + + state = { + ...state, + ...parsedWorkflow, + notifications: [ ...(state.notifications || []), ...(parsedWorkflow.notifications || []) ], + }; + } + + if (metaSource !== undefined) { + futureRecord.metaSource = state.metaSource; + + metaModel.applyDelta(null, metaSource); + + state = { + ...state, + ...metaModelGetter(metaModel), + }; + } + + nextRecords.push(futureRecord); + + return state; + } + + case 'FLOW_REDO': { + handleModelCommand.flush(); + + const historyRecord = nextRecords.pop(); + + if (!historyRecord) { + return state; + } + + const { workflowSource, metaSource } = historyRecord; + const pastRecord = {}; + + if (workflowSource !== undefined) { + pastRecord.workflowSource = state.workflowSource; + + workflowModel.applyDelta(null, workflowSource); + const parsedWorkflow = workflowModelGetter(workflowModel); + + state = { + ...state, + ...parsedWorkflow, + notifications: [ ...(state.notifications || []), ...(parsedWorkflow.notifications || []) ], + }; + } + + if (metaSource !== undefined) { + pastRecord.metaSource = state.metaSource; + + metaModel.applyDelta(null, metaSource); + + state = { + ...state, + ...metaModelGetter(metaModel), + }; + } + + prevRecords.push(pastRecord); + + return state; + } + + case 'SET_PACK': { + const { pack } = input; + return { + ...state, + pack, + }; + } + + default: + return state; + } +}; + +const reducer = (state = {}, action) => { + const nextState = flowReducer(state, action); + state = undoReducer(state, nextState, action); + + return state; +}; + +const store = createScopedStore('flow', reducer); + +export default store; diff --git a/apps/st2-workflows/style.css b/apps/st2-workflows/style.css new file mode 100644 index 000000000..fc9034a09 --- /dev/null +++ b/apps/st2-workflows/style.css @@ -0,0 +1,53 @@ +/* +Copyright 2020 Extreme Networks, Inc. + +Unauthorized copying of this file, via any medium is strictly +prohibited. Proprietary and confidential. See the LICENSE file +included with this work for details. +*/ + +@import '@stackstorm/st2-style/style.css'; + + +.component { + display: flex; + + flex-direction: column; + + height: 100%; + + &-row { + &-content { + flex: 1; + + display: flex; + + overflow: hidden; + } + } +} + +.header { + width: 100%; + height: 50px; +} + +.palette { + width: 250px; + + overflow: hidden; +} + +.canvas { + flex: 1; + height: 100%; +} + +.details { + width: 360px; + height: 100%; + + &.code { + width: 500px; + } +} diff --git a/apps/st2-workflows/workflows.component.js b/apps/st2-workflows/workflows.component.js new file mode 100644 index 000000000..b798e9ce2 --- /dev/null +++ b/apps/st2-workflows/workflows.component.js @@ -0,0 +1,456 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +import React, { Component } from 'react'; +// import ReactDOM from 'react-dom'; +// import { Provider, connect } from 'react-redux'; +import { connect } from 'react-redux'; +import { PropTypes } from 'prop-types'; +import { HotKeys } from 'react-hotkeys'; +import { pick, mapValues, get } from 'lodash'; +import cx from 'classnames'; +import url from 'url'; + +import Header from '@stackstorm/st2flow-header'; +import Palette from '@stackstorm/st2flow-palette'; +import Canvas from '@stackstorm/st2flow-canvas'; +import Details from '@stackstorm/st2flow-details'; + +import api from '@stackstorm/module-api'; + +import CollapseButton from '@stackstorm/st2flow-canvas/collapse-button'; +import { Toolbar, ToolbarButton, ToolbarDropdown } from '@stackstorm/st2flow-canvas/toolbar'; +import AutoForm from '@stackstorm/module-auto-form'; +import Button from '@stackstorm/module-forms/button.component'; + +// import { Router } from '@stackstorm/module-router'; +import globalStore from '@stackstorm/module-store'; + +import store from './store'; +import style from './style.css'; + +function guardKeyHandlers(obj, names) { + const filteredObj = pick(obj, names); + return mapValues(filteredObj, fn => { + return e => { + if(e.target === document.body) { + e.preventDefault(); + fn.call(obj); + } + }; + }); +} + +const POLL_INTERVAL = 5000; + +@connect( + ({ flow: { + panels, actions, meta, metaSource, workflowSource, pack, input, dirty, + } }) => ({ isCollapsed: panels, actions, meta, metaSource, workflowSource, pack, input, dirty }), + (dispatch) => ({ + toggleCollapse: name => dispatch({ + type: 'PANEL_TOGGLE_COLLAPSE', + name, + }), + fetchActions: () => dispatch({ + type: 'FETCH_ACTIONS', + promise: api.request({ path: '/actions/views/overview' }) + .catch(() => fetch('/actions.json').then(res => res.json())), + }), + sendError: (message, link) => dispatch({ type: 'PUSH_ERROR', error: message, link }), + sendSuccess: (message, link) => dispatch({ type: 'PUSH_SUCCESS', message, link }), + undo: () => dispatch({ type: 'FLOW_UNDO' }), + redo: () => dispatch({ type: 'FLOW_REDO' }), + layout: () => dispatch({ type: 'MODEL_LAYOUT' }), + setMeta: (attribute, value) => dispatch({ type: 'META_ISSUE_COMMAND', command: 'set', args: [ attribute, value ] }), + }) +) +// class Window extends Component<{ +export default class Workflows extends Component<{ + pack: string, + + meta: Object, + metaSource: string, + input: Array, + workflowSource: string, + + isCollapsed: Object, + toggleCollapse: Function, + + actions: Array, + fetchActions: Function, + sendSuccess: Function, + sendError: Function, + + undo: Function, + redo: Function, + layout: Function, +}, { + runningWorkflow: boolean, + showForm: boolean, + runFormData: Object +}> { + static propTypes = { + pack: PropTypes.string, + + meta: PropTypes.object, + metaSource: PropTypes.string, + setMeta: PropTypes.func, + input: PropTypes.array, + workflowSource: PropTypes.string, + dirty: PropTypes.bool, + + isCollapsed: PropTypes.object, + toggleCollapse: PropTypes.func, + + actions: PropTypes.array, + fetchActions: PropTypes.func, + + undo: PropTypes.func, + redo: PropTypes.func, + layout: PropTypes.func, + sendSuccess: PropTypes.func, + sendError: PropTypes.func, + } + + state = { + runningWorkflow: false, + showForm: false, + runFormData: {}, + }; + + async componentDidMount() { + this.props.fetchActions(); + } + + handleFormChange(data) { + if(!data) { + data = {}; + } + this.setState({ + runFormData: { + ...this.state.runFormData, + ...data, + }, + }); + } + + openForm() { + this.setState({ + showForm: true, + }); + } + + closeForm() { + this.setState({ + showForm: false, + }); + } + + get formIsValid() { + const { meta: { parameters = {} } } = this.props; + const { runFormData } = this.state; + const paramNames = Object.keys(parameters); + let valid = true; + + paramNames.forEach(name => { + const { required } = parameters[name] || {}; + if(required && runFormData[name] == null) { + valid = false; + } + }); + Object.keys(runFormData).forEach(formKey => { + if(!paramNames.includes(formKey) && runFormData[formKey] == null) { + valid = false; + } + }); + + return valid; + } + + run() { + const { meta, input, sendSuccess, sendError } = this.props; + const { runningWorkflow, runFormData } = this.state; + + if(runningWorkflow) { + return Promise.reject('Workflow already started'); + } + this.setState({ runningWorkflow: true }); + + const inputValues = input.reduce((acc, maybeInputValue) => { + if(typeof maybeInputValue === 'string') { + return acc; + } + else { + const key = Object.keys(maybeInputValue)[0]; + return { + ...acc, + [key]: maybeInputValue[key], + }; + } + }, {}); + + let parameters = mapValues(meta.parameters || {}, (param, paramName) => { + if(inputValues.hasOwnProperty(paramName)) { + return inputValues[paramName]; + } + else { + return param.default; + } + }, {}); + parameters = { + ...parameters, + ...runFormData, + }; + + + return api.request({ + method: 'post', + path: '/executions', + }, { + action: `${meta.pack}.${meta.name}`, + action_is_workflow: true, + parameters, + }).then(resp => { + const executionUrl = url.parse(resp.web_url); + + sendSuccess( + `Workflow ${resp.liveaction.action} submitted for execution. Details at `, + `${executionUrl.path}${executionUrl.hash}` + ); + setTimeout(this.poll.bind(this), POLL_INTERVAL, resp.id); + this.closeForm(); + }, err => { + this.setState({ runningWorkflow: false }); + sendError(`Submitting workflow ${meta.name} failed: ${get(err, 'response.data.faultstring') || err.message}`); + throw err; + }); + } + + poll(workflowId) { + const { sendSuccess, sendError } = this.props; + return api.request({ + method: 'get', + path: `/executions?id=${workflowId}`, + }).then(([ execution ]) => { + const executionUrl = url.parse(execution.web_url); + switch(execution.status) { + case 'failed': { + sendError( + `Workflow ${execution.liveaction.action} failed. Details at `, + `${executionUrl.path}${executionUrl.hash}` + ); + this.setState({ runningWorkflow: false }); + break; + } + case 'succeeded': { + sendSuccess( + `Workflow ${execution.liveaction.action} succeeded in ${execution.elapsed_seconds}s. Details at `, + `${executionUrl.path}${executionUrl.hash}` + ); + this.setState({ runningWorkflow: false }); + break; + } + // requesting, scheduled, or running + default: { + setTimeout(this.poll.bind(this), POLL_INTERVAL, workflowId); + break; + } + } + }); + } + + save() { + const { pack, meta, actions, workflowSource, metaSource, setMeta } = this.props; + + const existingAction = actions.find(e => e.name === meta.name && e.pack === pack); + + if (!meta.name) { + throw { response: { data: { faultstring: 'You must provide a name of the workflow.'}}}; + } + + if (typeof meta.entry_point === 'undefined' && typeof meta.name !== 'undefined') { + const entryPoint = `workflows/${meta.name}.yaml`; + meta.entry_point = entryPoint; + setMeta('entry_point', entryPoint); + } + + + meta.pack = pack; + meta.metadata_file = existingAction && existingAction.metadata_file && existingAction.metadata_file || `actions/${meta.name}.meta.yaml`; + meta.data_files = [{ + file_path: meta.entry_point, + content: workflowSource, + }, { + file_path: existingAction && existingAction.metadata_file && existingAction.metadata_file.replace(/^actions\//, '') || `${meta.name}.meta.yaml`, + content: metaSource, + }]; + + const promise = (async () => { + if (existingAction) { + await api.request({ method: 'put', path: `/actions/${pack}.${meta.name}` }, meta); + } + else { + await api.request({ method: 'post', path: '/actions' }, meta); + this.props.fetchActions(); + } + // don't need to return anything to the store. the handler will change dirty. + return {}; + })(); + + store.dispatch({ + type: 'SAVE_WORKFLOW', + promise, + }); + + return promise; + } + + style = style + + keyMap = { + undo: [ 'ctrl+z', 'meta+z' ], + redo: [ 'ctrl+shift+z', 'meta+shift+z' ], + handleTaskDelete: [ 'del', 'backspace' ], + } + + render() { + const { isCollapsed = {}, toggleCollapse, actions, undo, redo, layout, meta, input, dirty } = this.props; + const { runningWorkflow, showForm } = this.state; + + const autoFormData = input && input.reduce((acc, value) => { + if(typeof value === 'object') { + acc = { ...acc, ...value }; + } + return acc; + }, {}); + + return ( +
+
+ { !isCollapsed.header &&
} + toggleCollapse('header')} /> +
+
+ { !isCollapsed.palette && } + + + + undo()} /> + redo()} /> + layout()} + /> + this.save()} + /> + this.openForm()} + /> + this.closeForm()}> + { + meta.parameters && Object.keys(meta.parameters).length + ?

Run workflow with inputs

+ :

Run workflow

+ } + this.handleFormChange(runValue)} + onError={(error, runValue) => this.handleFormChange(runValue)} + /> +
+
+
+
+
+
+ { !isCollapsed.details &&
} +
+
+ ); + } +} + +globalStore.subscribe(() => { + const { location } = globalStore.getState(); + + let match; + + match = location.pathname.match('^/import/(.+)/(.+)'); + if (match) { + const [ ,, action ] = match; + + globalStore.dispatch({ + type: 'CHANGE_LOCATION', + location: { + ...location, + pathname: `/action/${action}`, + }, + }); + return; + } + + match = location.pathname.match('^/action/(.+)'); + if (match) { + const [ , ref ] = match; + + const { currentWorkflow } = store.getState(); + + if (currentWorkflow !== ref) { + store.dispatch({ + type: 'LOAD_WORKFLOW', + currentWorkflow: ref, + promise: (async () => { + const action = await api.request({ path: `/actions/${ref}` }); + const [ pack ] = ref.split('.'); + const [ metaSource, workflowSource ] = await Promise.all([ + api.request({ path: `/packs/views/file/${pack}/${action.metadata_file}`}), + api.request({ path: `/packs/views/file/${pack}/actions/${action.entry_point}`}), + ]); + return { + metaSource, + workflowSource, + }; + })(), + }); + } + return; + } +}); + +// const routes = [{ +// url: '/', +// Component: Window, +// }]; + +// ReactDOM.render(, document.querySelector('#container')); diff --git a/babel.config.js b/babel.config.js index fbe12717a..4c72a1dd6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -20,6 +20,7 @@ module.exports = function (api) { '@babel/preset-env', ]; const plugins = [ + '@babel/plugin-transform-flow-strip-types', [ '@babel/plugin-proposal-decorators', { 'legacy': true }], '@babel/plugin-proposal-class-properties', [ '@babel/plugin-proposal-object-rest-spread', { 'legacy': true }], diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..d7ce53a60 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.2' +services: + app: + build: . + ports: + - "3000:3000" + expose: + - 3000 + volumes: + - .:/opt/stackstorm/static/webui/st2web + - ./config.local.js:/opt/stackstorm/static/webui/st2web/config.js + - /opt/stackstorm/static/webui/st2web/node_modules diff --git a/main.js b/main.js index af563b8c9..5c2def8d0 100644 --- a/main.js +++ b/main.js @@ -29,6 +29,7 @@ import History from '@stackstorm/app-history'; import Packs from '@stackstorm/app-packs'; import Rules from '@stackstorm/app-rules'; import Inquiry from '@stackstorm/app-inquiry'; +import Workflows from '@stackstorm/app-workflows'; const routes = [ Actions, @@ -38,6 +39,7 @@ const routes = [ Packs, Rules, Inquiry, + Workflows, ]; window.fp = require('lodash/fp'); diff --git a/modules/st2-router/history.js b/modules/st2-router/history.js index 32429d0a2..4a624b82a 100644 --- a/modules/st2-router/history.js +++ b/modules/st2-router/history.js @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import createHashHistory from 'history/createHashHistory'; +import { createHashHistory } from 'history'; const history = createHashHistory({}); diff --git a/modules/st2-router/package.json b/modules/st2-router/package.json index a94118d85..f72e4c13c 100644 --- a/modules/st2-router/package.json +++ b/modules/st2-router/package.json @@ -48,7 +48,7 @@ "react": "^16.0.0", "react-dom": "^16.0.0", "react-redux": "7.0.2", - "react-router-dom": "5.0.0", + "react-router-dom": "^5.0.0", "request": "2.88.0", "sinon": "7.3.2", "sinon-chai": "3.3.0", diff --git a/modules/st2-router/route.component.js b/modules/st2-router/route.component.js index ec0b1bdac..4a75706fc 100644 --- a/modules/st2-router/route.component.js +++ b/modules/st2-router/route.component.js @@ -16,7 +16,7 @@ import React from 'react'; import { PropTypes } from 'prop-types'; import { connect } from 'react-redux'; -import matchPath from 'react-router/matchPath'; +import { matchPath } from 'react-router-dom'; @connect( ({ location }) => ({ location }) diff --git a/modules/st2-router/router.component.js b/modules/st2-router/router.component.js index a9430e0ae..fc350b43b 100644 --- a/modules/st2-router/router.component.js +++ b/modules/st2-router/router.component.js @@ -82,6 +82,20 @@ export default class Router extends React.Component { } for (const { url, Component } of routes) { + // TODO: use this ? + // from st2flow/modules/st2-router/router.component.js + // const regex = url instanceof RegExp ? regex : new RegExp(`^${url}`); + // const match = location.pathname.match(regex); + // if (match) { + // const [ , ...args ] = match; + // return ( + // + // ); + // } + if (location.pathname.startsWith(url)) { return ( { + return node.f; + }); +} + + +/* +Notes from B. Momberger on 2018-11-21: the following is an exceprt from +https://pdfs.semanticscholar.org/9f60/6a3b611e6ab7c2bcd4c1f79fb8585dda8be2.pdf +and + + +[E]ntries in the priority queue have form (v, D, lv, bv, p, cv) where: + v is the node in the orthogonal visibility graph; + D is the “direction of entry” to the node; + lv is the length of the partial path from s (start node) to v; + bv the number of *bends* in the partial path; + p a pointer to the parent entry (so that the final path can be reconstructed); + and cv the cost of the partial path. + +There is at most one entry +popped from the queue for each (v, D) pair. When an entry (v, D, lv, bv, p, cv) +is scheduled for addition to the priority queue, it is only added if no entry with +the same (v, D) pair has been removed from the queue, i.e. is on the closed list. +And only the entry with lowest cost for each (v, D) pair is kept on the priority +queue. +When we remove entry (v, D, lv, bv, p, cv) from the priority queue we +1. add the neighbour (v0, D) in the same direction with priority + f(lv + ||(v, v0)||1 + ||(v0, d)||1, sv + sd); + another way of saying that first arg is the sum of how far we've come, how far we're going, and how far we'll have left to go. + and for the second... sv isn't actually specified in the paper. but i think it's the cost of the node up to know +2. add the neighbours (v0, right(D)) and (v0, left(D)) at right angles to the entry + with priority f(lv + ||(v, v0)||1 + ||(v0, d)||1, sv + 1 + sd); + So turning adds one cost to the path (1 + sd); + +Let dirns((x1, y1), (x2, y2)) be the set of cardinal directions of the line (x1, y1) to (x2, y2) +Range[dirns] = { {N}, {S}, {E}, {W}, {N,E}, {N,W}, {S,E}, {S,W} } +Let left() and right() be bijective functions over the range of dirns(), inverse of each other + (the mapping of left() and right() should be intuitive) +Let reverse() be a bijective function over the range of dirns() + +sd is the estimation of the remaining segments required for the route from +(v0, D0) to (d, Dd). The estimation of the remaining segments required is: sd =... + +0: if D0 = Dd and dirns(v0, d) = {D0}; (if we are moving straight towards the destination in the final direction) +1: if left(Dd) = D0 ∨ right(Dd) = D0 + and D0 ∈ dirns(v0, d); (i.e. if you can turn 90 degrees or less to the final direction) +2: if D0 = Dd and dirns(v0, d) != {D0} + but D0 ∈ dirns(v0, d), + or D0 = reverse(Dd) and dirns(v0, d) != {Dd}; (two turns to chicane into alignment with Dd or turn around) +3: if left(Dd) = D0 ∨ right(Dd) = D0 and D0 !∈ dirns(v0, d); (we're going away from d in a perpendicular direction to final) +4: if D0 = reverse(Dd) and dirns(v0, d) = {Dd}, + or D0 = Dd and D0 !∈ dirns(v0, d). (going directly away from d in the opposite direction, or at d in the wrong direction) +*/ +function left(from) { + return { N: 'W', W: 'S', S: 'E', E: 'N'}[from]; +} +function right(from) { + return { N: 'E', W: 'N', S: 'W', E: 'S'}[from]; +} +function reverse(from) { + return { N: 'S', W: 'E', S: 'N', E: 'W'}[from]; +} + +export const astar = { + /** + * Perform an A* Search on a graph given a start and end node. + * @param {Array} graph + * @param {GridNode} start + * @param {GridNode} end + * @param {Object} [options] + * @param {bool} [options.closest] Specifies whether to return the + path to the closest node if the target is unreachable. + * @param {Function} [options.heuristic] Heuristic function (see + * astar.heuristics). + */ + search: function( + graph: Graph, + _start: {x: number, y: number}, + _end: {x: number, y: number}) { + const start = graph.nodes[`${_start.x}|${_start.y}|S`]; + let end = graph.nodes[`${_end.x}|${_end.y}|S`]; + if(!start || !end) { + return []; + } + + // We have to do a little setup here. First, we also create a node for the + // arrow point at the end, which is what actually ends at the task box. + // This is 10px below the supplied "end" for this graph; if we did the full + // orbit of 20px, then the arrow point would be under the task bubble. + const endTag = `${end.x}|${end.y + ORBIT_DISTANCE / 2}|S`; + let postEnd; + if(!graph.nodes[endTag]) { + postEnd = new GridNode(end.x, end.y + ORBIT_DISTANCE / 2, 'S', 1); + graph.nodes[endTag] = postEnd; + graph.grid[endTag] = []; + [ 'S', 'E', 'W' ].forEach(dir => { + if(graph.grid[`${end.toString()}|${dir}`].indexOf(endTag) < 0) { + graph.grid[`${end.toString()}|${dir}`].push(endTag); + } + }); + } + else { + postEnd = graph.nodes[endTag]; + } + // Then make that the new end point + const preEnd = end; + end = postEnd; + // and make sure the pre-end node can be accessed. If it's within the orbit + // of another task box, it might be disconnected from the graph. + graph.neighbors(preEnd).forEach(neighbor => { + const revDir = reverse(neighbor.dir); + [ 'N', 'S', 'E', 'W' ].forEach(dir => { + if(graph.grid[`${neighbor.toString()}|${dir}`] && + graph.grid[`${neighbor.toString()}|${dir}`].indexOf(`${preEnd.toString()}|${revDir}`) < 0 + ) { + graph.grid[`${neighbor.toString()}|${dir}`].push(`${preEnd.toString()}|${revDir}`); + } + }); + }); + + const heuristic = astar.heuristic; + + const openHeap = getHeap(); + + // make sure graph is clean + graph.init(); + + start.h = heuristic(start, end); + + openHeap.push(start); + + while (openHeap.size() > 0) { + + // Grab the lowest f(x) to process next. Heap keeps this sorted for us. + const currentNode = openHeap.pop(); + + // End case -- result has been found, return the traced path. + if (currentNode === end) { + return pathTo(currentNode); + } + + // Normal case -- move currentNode from open to closed, process each of its neighbors. + currentNode.closed = true; + + // Find all neighbors for the current node. + const neighbors = graph.neighbors(currentNode); + + for (let i = 0, il = neighbors.length; i < il; ++i) { // eslint-disable-line no-plusplus + const neighbor = neighbors[i]; + + if (neighbor.closed || graph.neighbors(neighbor).length < 2 && neighbor !== end) { + // Not a valid node to process, skip to next neighbor. + continue; + } + + // The g score is the shortest distance from start to current node. + // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet. + const gScore = currentNode.g + neighbor.getCost(currentNode); + const beenVisited = neighbor.visited; + + if (!beenVisited || gScore < neighbor.g) { + + // Found an optimal (so far) path to this node. Take score for node to see how good it is. + neighbor.visited = true; + neighbor.parent = currentNode; + neighbor.h = neighbor.h || heuristic(neighbor, end); + neighbor.g = gScore; + neighbor.f = neighbor.g + neighbor.h; + graph.markDirty(neighbor); + + if (!beenVisited) { + // Pushing to heap will put it in proper place based on the 'f' value. + openHeap.push(neighbor); + } + else { + // Already seen the node, but since it has been rescored we need to reorder it in the heap + openHeap.rescoreElement(neighbor); + } + } + } + } + + // No result was found, else would have returned in line 79 + // - empty array signifies failure to find path. + return []; + }, + // See list of heuristics: http://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html + heuristic: function(pos0, pos1) { + const d1 = pos1.x - pos0.x; + const d2 = pos1.y - pos0.y; + let h = Math.abs(d1) + Math.abs(d2); + + let dirV = ''; + if(d2 < 0) { + dirV = 'N'; + } + else if(d2 > 0) { + dirV = 'S'; + } + if(d1 > 0) { + dirV += 'E'; + } + else if(d1 < 0) { + dirV += 'W'; + } + + // 0: if D0 = Dd and dirns(v0, d) = {D0}; (if we are moving straight towards the destination in the final direction) + if(pos0.dir === pos1.dir && dirV === pos0.dir) { + // don't add anything to the weight. We're moving toward the destination. + } + // 1: if left(Dd) = D0 ∨ right(Dd) = D0 + // and D0 ∈ dirns(v0, d); (i.e. if you can turn 90 degrees or less to the final direction) + else if(dirV.indexOf(pos0.dir) > -1 && (left(pos0.dir) === pos1.dir || right(pos0.dir) === pos1.dir)) { + h += 1; + } + // 2: if D0 = Dd and dirns(v0, d) != {D0} + // but D0 ∈ dirns(v0, d), + // or D0 = reverse(Dd) and dirns(v0, d) != {Dd}; (two turns to chicane into alignment with Dd or turn around) + else if(pos0.dir === pos1.dir && (dirV.indexOf(pos0.dir) > -1 || pos0.dir === reverse(pos1.dir) && dirV !== pos1.dir)) { + h += 2; + } + // 3: if left(Dd) = D0 ∨ right(Dd) = D0 and D0 !∈ dirns(v0, d); (we're going away from d in a perpendicular direction to final) + else if((left(pos1.dir) === pos0.dir || right(pos1.dir) === pos0.dir) && dirV.indexOf(pos0.dir) < 0) { + h += 3; + } + // 4: if D0 = reverse(Dd) and dirns(v0, d) = {Dd}, + // or D0 = Dd and D0 !∈ dirns(v0, d). (going directly away from d in the opposite direction, or at d in the wrong direction) + else if(reverse(pos1.dir) === pos0.dir && dirV === pos1.dir || pos1.dir === pos0.dir && dirV.indexOf(pos0.dir) < 0) { + h += 4; + } + + return h; + }, + cleanNode: function(node: GridNode) { + node.f = 0; + node.g = 0; + node.h = 0; + node.visited = false; + node.closed = false; + node.parent = null; + }, +}; + + +export class Graph { + nodes: {[string]: GridNode}; // map x|y coords to GridNodes + grid: {[string]: Array} = {}; // map x|y coords to nearest-neighbor x|y coords + dirtyNodes: Array = []; // hold nodes which are dirty + + constructor(vertices: Array<{x: number, y: number}>, edges: { [string]: Array<{x: number, y: number}> }) { + this.nodes = vertices.reduce((nodes, v) => { + [ 'N', 'S', 'E', 'W' ].forEach(dir => { + const gn = new GridNode(v.x, v.y, dir, 1); + this.grid[`${gn.toString()}|${gn.dir}`] = edges[gn.toString()].map(({x, y}) => { + switch(true) { + case (x > v.x): return `${x}|${y}|E`; + case (x < v.x): return `${x}|${y}|W`; + case (y > v.y): return `${x}|${y}|S`; + case (y < v.y): return `${x}|${y}|N`; + default: return `${x}|${y}`; + } + }); + nodes[`${gn.toString()}|${gn.dir}`] = gn; + }); + return nodes; + }, {}); + this.init(); + } + init() { + this.dirtyNodes = []; + const keys = Object.keys(this.nodes); + for (let i = 0; i < keys.length; i=i+1) { + astar.cleanNode(this.nodes[keys[i]]); + } + } + + cleanDirty() { + for (let i = 0; i < this.dirtyNodes.length; i=i+1) { + astar.cleanNode(this.dirtyNodes[i]); + } + this.dirtyNodes = []; + } + + markDirty(node: GridNode) { + this.dirtyNodes.push(node); + } + + neighbors(node: GridNode) { + return this.grid[`${node.toString()}|${node.dir}`].map(vStr => this.nodes[vStr]).filter(node => node); + } +} + + +export class GridNode { + // grid coordinates + x: number; + y: number; + // inbound direction of node. + dir: string; + // weight of node itself + weight: number; + // calculated forms + f: number = 0; + g: number = 0; // cost of node plus cost of parent? + h: number = 0; + // traversal state + visited: boolean = false; + closed: boolean = false; + parent: GridNode = null; + // direction: Direction = null; + + constructor(x, y, dir, weight) { + this.x = x; + this.y = y; + this.dir = dir; + this.weight = weight; + } + + toString(): string { + return `${this.x}|${this.y}`; + } + + getCost(fromNeighbor: GridNode): number { + let weight = this.weight; + const length = (Math.abs(fromNeighbor.x - this.x) + Math.abs(fromNeighbor.y - this.y)); + + // This path *bends* if the dirs don't match up, so increase the cost + if(fromNeighbor.dir !== this.dir) { + weight += 1; + } + + return weight + length; + } +} + +class BinaryHeap { + scoreFunction: Function; + content: Array; + + constructor(scoreFunction: Function) { + this.content = []; + this.scoreFunction = scoreFunction; + } + + push(element: GridNode) { + // Add the new element to the end of the array. + this.content.push(element); + + // Allow it to sink down. + this.sinkDown(this.content.length - 1); + } + pop(): GridNode { + // Store the first element so we can return it later. + const result = this.content[0]; + // Get the element at the end of the array. + const end = this.content.pop(); + // If there are any elements left, put the end element at the + // start, and let it bubble up. + if (this.content.length > 0) { + this.content[0] = end; + this.bubbleUp(0); + } + return result; + } + remove(node: GridNode) { + const i = this.content.indexOf(node); + + // When it is found, the process seen in 'pop' is repeated + // to fill up the hole. + const end = this.content.pop(); + + if (i !== this.content.length - 1) { + this.content[i] = end; + + if (this.scoreFunction(end) < this.scoreFunction(node)) { + this.sinkDown(i); + } + else { + this.bubbleUp(i); + } + } + } + size(): number { + return this.content.length; + } + rescoreElement(node: GridNode) { + this.sinkDown(this.content.indexOf(node)); + } + sinkDown(n: number) { + // Fetch the element that has to be sunk. + const element = this.content[n]; + + // When at 0, an element can not sink any further. + while (n > 0) { + + // Compute the parent element's index, and fetch it. + const parentN = ((n + 1) >> 1) - 1; //eslint-disable-line no-bitwise + const parent = this.content[parentN]; + // Swap the elements if the parent is greater. + if (this.scoreFunction(element) < this.scoreFunction(parent)) { + this.content[parentN] = element; + this.content[n] = parent; + // Update 'n' to continue at the new position. + n = parentN; + } + // Found a parent that is less, no need to sink any further. + else { + break; + } + } + } + bubbleUp(n: number) { + // Look up the target element and its score. + const length = this.content.length; + const element = this.content[n]; + const elemScore = this.scoreFunction(element); + + // This is used to store the new position of the element, if any. + let swap = null; + do { + swap = null; + // Compute the indices of the child elements. + const child2N = (n + 1) << 1; //eslint-disable-line no-bitwise + const child1N = child2N - 1; + // If the first child exists (is inside the array)... + let child1Score; + if (child1N < length) { + // Look it up and compute its score. + const child1 = this.content[child1N]; + child1Score = this.scoreFunction(child1); + + // If the score is less than our element's, we need to swap. + if (child1Score < elemScore) { + swap = child1N; + } + } + + // Do the same checks for the other child. + if (child2N < length) { + const child2 = this.content[child2N]; + const child2Score = this.scoreFunction(child2); + if (child2Score < (swap === null ? elemScore : child1Score)) { + swap = child2N; + } + } + + // If the element needs to be moved, swap it, and continue. + if (swap !== null) { + this.content[n] = this.content[swap]; + this.content[swap] = element; + n = swap; + } + } while(swap !== null); + } +} + +export default astar; diff --git a/modules/st2flow-canvas/collapse-button.js b/modules/st2flow-canvas/collapse-button.js new file mode 100644 index 000000000..f6631ea7e --- /dev/null +++ b/modules/st2flow-canvas/collapse-button.js @@ -0,0 +1,58 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +//@flow + +import React, { Component } from 'react'; +import { PropTypes } from 'prop-types'; +import cx from 'classnames'; + +import style from './style.css'; + +export default class CollapseButton extends Component<{ + state: bool, + position: string, + onClick: any, +}> { + static propTypes = { + state: PropTypes.bool, + position: PropTypes.string, + onClick: PropTypes.func.isRequired, + } + + style = style + + handleClick(e: Event) { + e.stopPropagation(); + + this.props.onClick(); + } + + render() { + const { position, state } = this.props; + + const { className, icon } = { + left: { + className: this.style.left, + icon: state ? 'icon-chevron_right' : 'icon-chevron_left', + }, + right: { + className: this.style.right, + icon: state ? 'icon-chevron_left' : 'icon-chevron_right', + }, + top: { + className: this.style.top, + icon: state ? 'icon-chevron_down' : 'icon-chevron_up', + }, + }[position] || {}; + + return ( +
this.handleClick(e)}> + +
+ ); + } +} diff --git a/modules/st2flow-canvas/const.js b/modules/st2flow-canvas/const.js new file mode 100644 index 000000000..96b1c9a70 --- /dev/null +++ b/modules/st2flow-canvas/const.js @@ -0,0 +1,12 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +import Vector from './vector'; + +const origin = new Vector(20, 40); +const ORBIT_DISTANCE = 20; + +export { origin, ORBIT_DISTANCE }; diff --git a/modules/st2flow-canvas/index.js b/modules/st2flow-canvas/index.js new file mode 100644 index 000000000..4388bd853 --- /dev/null +++ b/modules/st2flow-canvas/index.js @@ -0,0 +1,831 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +//@flow + +import type { + CanvasPoint, + TaskInterface, + TaskRefInterface, + TransitionInterface, +} from '@stackstorm/st2flow-model/interfaces'; +import type { NotificationInterface } from '@stackstorm/st2flow-notifications'; +import type { Node } from 'react'; + +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { PropTypes } from 'prop-types'; +import cx from 'classnames'; +import fp from 'lodash/fp'; +import { uniqueId, uniq } from 'lodash'; + +import Notifications from '@stackstorm/st2flow-notifications'; +import {HotKeys} from 'react-hotkeys'; + +import type { BoundingBox } from './routing-graph'; +import Task from './task'; +import TransitionGroup from './transition'; +import Vector from './vector'; +import CollapseButton from './collapse-button'; +import { Graph } from './astar'; +import { ORBIT_DISTANCE } from './const'; +import { Toolbar, ToolbarButton } from './toolbar'; +import makeRoutingGraph from './routing-graph'; +import PoissonRectangleSampler from './poisson-rect'; + +import { origin } from './const'; + +import style from './style.css'; + +type DOMMatrix = { + m11: number, + m22: number +}; + +type Wheel = WheelEvent & { + wheelDelta: number +} + +// Order tasks before placement based on "weight" (bucket size) +// and "order" (longest non-looping transition path to task) +function weightedOrderedTaskSort(a, b) { + if (a.weight > b.weight) { + return -1; + } + else if (a.weight < b.weight) { + return 1; + } + else if (a.priority < b.priority) { + return -1; + } + else if (a.priority > b.priority) { + return 1; + } + else if (a.order < b.order) { + return -1; + } + else if (a.order > b.order) { + return 1; + } + else { + return 0; + } +} + +// given tasks and their undirected connections to other tasks, +// sort the tasks into buckets of connected tasks. +// This helps layout because it ensures that connected tasks can be drawn +// near each other. +function constructTaskBuckets( + tasks: Array, + transitionsByTaskBidi: {[string]: Array} +): Array> { + // start by creating buckets of connected tasks. + const taskBuckets = tasks.map(task => [ task.name ]); + let foundChange = true; + const doBucketPass = taskBucket => { + const bucketsByTask = {}; + taskBuckets.forEach(bucket => { + bucket.forEach(taskName => { + bucketsByTask[taskName] = bucket; + }); + }); + taskBucket.forEach(taskName => { + if (transitionsByTaskBidi[taskName].length) { + transitionsByTaskBidi[taskName].forEach(newTask => { + const connectedTaskBucket = bucketsByTask[newTask]; + if(connectedTaskBucket && connectedTaskBucket !== taskBucket) { + taskBucket.push(...connectedTaskBucket); + taskBuckets.splice(taskBuckets.indexOf(connectedTaskBucket), 1); + connectedTaskBucket.forEach(connectedTask => { + bucketsByTask[connectedTask] = taskBucket; + }); + foundChange = true; + } + }); + } + }); + }; + while(foundChange) { + foundChange = false; + taskBuckets.forEach(doBucketPass); + } + return taskBuckets; +} + +// given tasks, their buckets from constructTaskBuckets(), and outward transitions for each task, +// determine the order that items should be placed in, expecting that if there is a transition +// a->b, a should be placed before b so b can be placed south of a. also size the buckets for +// each task so the larget bucket gets placed first. +function constructPathOrdering( + tasks: Array, + taskBuckets: Array>, + transitionsByTask: { [string]: Array } +): { + taskInDegree: {[string]: number}, + taskBucketSize: {[string]: number}, + taskBucketPriority: {[string]: number}, +} { + const taskBucketSize: {[string]: number} = tasks.reduce((tbs, task: TaskInterface) => { + tbs[task.name] = 0; + return tbs; + }, {}); + const taskInDegree = { ...taskBucketSize }; + const taskBucketPriority = { ...taskBucketSize }; + + const followPaths = (task, nextTasks, inDegree, nonVisitedTransitions) => { + const recurseNext = nextTasks.map(nextTask => { + const recurseThis = nextTask in nonVisitedTransitions ? nonVisitedTransitions[nextTask] : null; + taskInDegree[nextTask] = Math.max(taskInDegree[nextTask], inDegree); + delete nonVisitedTransitions[nextTask]; + return recurseThis; + }); + nextTasks.forEach((nextTask, idx) => { + if(recurseNext[idx]) { + followPaths(nextTask, recurseNext[idx], inDegree + 1, nonVisitedTransitions); + } + }); + }; + // sort each bucket by perceived in-degree; + taskBuckets.forEach(keyBucket => { + // start by ordering the bucket in original task order + let firstIndex; + const bucket = tasks.filter((task, idx) => { + if(keyBucket.includes(task.name)) { + if (typeof firstIndex === 'undefined') { + firstIndex = idx; + } + return true; + } + else { + return false; + } + }).map(task => task.name); + bucket.forEach(taskName => { + taskBucketSize[taskName] = bucket.length; + taskBucketPriority[taskName] = firstIndex; + }); + bucket.forEach(nextTask => { + followPaths( + nextTask, + transitionsByTask[nextTask], + 1, + Object.assign({}, transitionsByTask) + ); + }); + }); + return { + taskInDegree, + taskBucketSize, + taskBucketPriority, + }; +} + +@connect( + ({ flow: { tasks, transitions, notifications, nextTask, panels, navigation }}) => ({ tasks, transitions, notifications, nextTask, isCollapsed: panels, navigation }), + (dispatch) => ({ + issueModelCommand: (command, ...args) => { + dispatch({ + type: 'MODEL_ISSUE_COMMAND', + command, + args, + }); + }, + toggleCollapse: name => dispatch({ + type: 'PANEL_TOGGLE_COLLAPSE', + name, + }), + navigate: (navigation) => dispatch({ + type: 'CHANGE_NAVIGATION', + navigation, + }), + }) +) +export default class Canvas extends Component<{ + children: Node, + className?: string, + + navigation: Object, + navigate: Function, + + tasks: Array, + transitions: Array, + notifications: Array, + issueModelCommand: Function, + nextTask: string, + + isCollapsed: Object, + toggleCollapse: Function, +}, { + scale: number, +}> { + static propTypes = { + children: PropTypes.node, + className: PropTypes.string, + + navigation: PropTypes.object, + navigate: PropTypes.func, + + tasks: PropTypes.array, + transitions: PropTypes.array, + notifications: PropTypes.array, + issueModelCommand: PropTypes.func, + nextTask: PropTypes.string, + + isCollapsed: PropTypes.object, + toggleCollapse: PropTypes.func, + } + + state = { + scale: 0, + } + + componentDidMount() { + const el = this.canvasRef.current; + + if (!el) { + return; + } + + el.addEventListener('wheel', this.handleMouseWheel); + el.addEventListener('mousedown', this.handleMouseDown); + window.addEventListener('mousemove', this.handleMouseMove); + window.addEventListener('mouseup', this.handleMouseUp); + window.addEventListener('resize', this.handleUpdate); + el.addEventListener('dragover', this.handleDragOver); + el.addEventListener('drop', this.handleDrop); + + this.handleUpdate(); + } + + componentDidUpdate() { + this.handleUpdate(); + } + + componentWillUnmount() { + const el = this.canvasRef.current; + + if (!el) { + return; + } + + el.removeEventListener('wheel', this.handleMouseWheel); + el.removeEventListener('mousedown', this.handleMouseDown); + window.removeEventListener('mousemove', this.handleMouseMove); + window.removeEventListener('mouseup', this.handleMouseUp); + window.removeEventListener('resize', this.handleUpdate); + el.removeEventListener('dragover', this.handleDragOver); + el.removeEventListener('drop', this.handleDrop); + } + + size: CanvasPoint + drag: boolean + startx: number + starty: number + + handleUpdate = () => { + const canvasEl = this.canvasRef.current; + const surfaceEl = this.surfaceRef.current; + + if (!canvasEl || !surfaceEl) { + return; + } + + const { transitions } = this.props; + let tasks = this.props.tasks.slice(0); + const { width, height } = canvasEl.getBoundingClientRect(); + + const scale = Math.E ** this.state.scale; + + this.size = tasks.reduce((acc, item) => { + const coords = new Vector(item.coords); + const size = new Vector(item.size); + const { x, y } = coords.add(size).add(50); + + return { + x: Math.max(x, acc.x), + y: Math.max(y, acc.y), + }; + }, { + x: width / scale, + y: height / scale, + }); + + surfaceEl.style.width = `${(this.size.x).toFixed()}px`; + surfaceEl.style.height = `${(this.size.y).toFixed()}px`; + + // take the log base sqrt(2) of the number of + const logTaskCount = Math.log(tasks.length) / Math.log(Math.sqrt(2)); + + if(surfaceEl.style.width) { + const needsCoords = []; + const sampler = new PoissonRectangleSampler( + Math.max(parseInt(surfaceEl.style.width) - 211, logTaskCount * (150 + ORBIT_DISTANCE / 2)), + Math.max(parseInt(surfaceEl.style.height) - 55, logTaskCount * (76 + ORBIT_DISTANCE)), + 211 + ORBIT_DISTANCE * 2, + 55 + ORBIT_DISTANCE * 3, + 50 + ); + // start by indexing the transitions by task, both in the directed form for determining ordering + // and in the bidirectional (undirected) form for determining connected subgraphs. + const transitionsByTask = tasks.reduce((tbt, task) => { + tbt[task.name] = uniq([].concat( + ...transitions + .filter(t => t.from.name === task.name) + .map(t => t.to), + ).map(t => t.name)); + return tbt; + }, {}); + + const transitionsByTaskBidi = tasks.reduce((tbt, task) => { + tbt[task.name] = uniq(transitionsByTask[task.name].concat( + transitions + .filter(t => t.to.map(tt => tt.name).includes(task.name)) + .map(t => t.from.name) + )); + return tbt; + }, {}); + // Get the "buckets" (subgraphs) of connected tasks in the graph. + const taskBuckets = constructTaskBuckets(tasks, transitionsByTaskBidi); + // For each task, determine its bucket size (biggest bucket gets rendered first) + // and the longest non-looping path of transitions to it + // (misnamed in-degree here for want of a better word). + // Where there is a loop in transitions, the ordering may be arbitrary + // but it makes an attempt to place downward then loop to + // the top from the bottom. + const { + taskInDegree, + taskBucketSize, + taskBucketPriority, + } = constructPathOrdering(tasks, taskBuckets, transitionsByTask); + + tasks = tasks.map((task) => ({ + task, + weight: taskBucketSize[task.name], + order: taskInDegree[task.name], + priority: taskBucketPriority[task.name], + })) + .sort(weightedOrderedTaskSort) + .map(t => t.task); + // Now take each task and the transitions starting from that task, and prefill them + // into the sampler if placed (i.e. if has coordinates). If not placed, queue for + // placement. Placement has to happen after prefill because the sampler has to + // know where all the items with fixed placement are before placing new ones. + tasks.forEach(task => { + const transitionsTo = [].concat( + ...transitions + .filter(t => t.from.name === task.name) + .map(t => t.to) + ).map(t => t.name); + + if(task.coords.x < 0 || task.coords.y < 0) { + needsCoords.push({task, transitionsTo}); + } + else { + const { x, y } = task.coords; + sampler.prefillPoint(x, y, transitionsTo); + } + }); + // finally, place the unplaced tasks. using handleTaskMove will also ensure + // that the placement gets set on the model and the YAML. + needsCoords.forEach(({task, transitionsTo}) => { + this.handleTaskMove(task, sampler.getNext(task.name, transitionsTo)); + }); + } + } + + handleMouseWheel = (e: Wheel): ?false => { + // considerations on scale factor (BM, 2019-02-07) + // on Chrome Mac and Safari Mac: + // For Mac trackpads with continuous scroll, wheelDelta is reported in multiples of 3, + // but for a fast scoll, the delta value may be >1000. + // deltaY is always wheelDelta / -3. + // For traditional mouse wheels with clicky scroll, wheelDelta is reported in multiples of 120. + // deltaY is non-integer and does not neatly gazinta wheelDelta. + // + // Firefox Mac: wheelDelta is undefined. deltaY increments by 1 for trackpad or mouse wheel. + // + // On Windows w/Edge, I see a ratio of -20:7 between wheelDelta and deltaY. I'm using a VM, but the Mac + // trackpad and the mouse report the same ratio. (increments of 120:-42) + // On Windows w/Chrome, the ratio is -6:5. The numbers don't seem to go above 360 for wheelDelta on a mousewheel + // or 600 for the trackpad + // + // Firefox Linux: wheelDelta is undefined, wheelY is always 3 or -3 + // Chromium Linus: wheelY is always in multiples of 53. Fifty-three! (wheelDelta is in multiples of 120) + // There's very little variation. I can sometimes get the trackpad to do -212:480, but not a real mouse wheel + const SCALE_FACTOR_MAC_TRACKPAD = .05; + const SCROLL_FACTOR_MAC_TRACKPAD = 15; + const SCALE_FACTOR_DEFAULT = .1; + const SCROLL_FACTOR_DEFAULT = 30; + + const getModifierState = (e.getModifierState || function(mod) { + mod = mod === 'Control' ? 'ctrl' : mod; + return this[`${mod.toLowerCase()}Key`]; + }).bind(e); + + if(getModifierState('Control')) { + e.preventDefault(); + const canvasEl = this.canvasRef.current; + if(canvasEl instanceof HTMLElement) { + const scrollFactor = e.wheelDelta && Math.abs(e.wheelDelta) < 120 + ? SCROLL_FACTOR_MAC_TRACKPAD + : Math.abs(e.wheelDelta) < 3 ? SCROLL_FACTOR_DEFAULT / 2 : SCROLL_FACTOR_DEFAULT; + canvasEl.scrollLeft += (e.deltaY < 0) ? -scrollFactor : scrollFactor; + } + + return undefined; + } + + if(getModifierState('Alt')) { + e.preventDefault(); + e.stopPropagation(); + + const { scale }: { scale: number } = this.state; + const delta = Math.max(-1, Math.min(1, e.wheelDelta || -e.deltaY)); + + // Zoom around the mouse pointer, by finding it's position normalized to the + // canvas and surface elements' coordinates, and moving the scroll on the + // canvas element to match the same proportions as before the scale. + const canvasEl = this.canvasRef.current; + const surfaceEl = this.surfaceRef.current; + if(canvasEl instanceof HTMLElement && surfaceEl instanceof HTMLElement) { + let canvasParentEl = canvasEl; + let canvasOffsetLeft = 0; + let canvasOffsetTop = 0; + do { + if(getComputedStyle(canvasParentEl).position !== 'static') { + canvasOffsetLeft += canvasParentEl.offsetLeft || 0; + canvasOffsetTop += canvasParentEl.offsetTop || 0; + } + canvasParentEl = canvasParentEl.parentNode; + } while (canvasParentEl && canvasParentEl !== document); + const surfaceScaleBefore: DOMMatrix = new window.DOMMatrix(getComputedStyle(surfaceEl).transform); + const mousePosCanvasX = (e.clientX - canvasOffsetLeft) / canvasEl.clientWidth; + const mousePosCanvasY = (e.clientY - canvasOffsetTop) / canvasEl.clientHeight; + const mousePosSurfaceX = (e.clientX - canvasOffsetLeft + canvasEl.scrollLeft) / + (surfaceEl.clientWidth * surfaceScaleBefore.m11); + const mousePosSurfaceY = (e.clientY - canvasOffsetTop + canvasEl.scrollTop) / + (surfaceEl.clientHeight * surfaceScaleBefore.m22); + this.setState({ + scale: scale + delta * (e.wheelDelta && Math.abs(e.wheelDelta) < 120 ? SCALE_FACTOR_MAC_TRACKPAD: SCALE_FACTOR_DEFAULT), + }); + + const surfaceScaleAfter: DOMMatrix = new window.DOMMatrix(getComputedStyle(surfaceEl).transform); + canvasEl.scrollLeft = surfaceEl.clientWidth * surfaceScaleAfter.m11 * mousePosSurfaceX - + canvasEl.clientWidth * mousePosCanvasX; + canvasEl.scrollTop = surfaceEl.clientHeight * surfaceScaleAfter.m22 * mousePosSurfaceY - + canvasEl.clientHeight * mousePosCanvasY; + } + + this.handleUpdate(); + + return false; + } + else { + return undefined; + } + } + + handleMouseDown = (e: MouseEvent) => { + if (e.target !== this.surfaceRef.current) { + return true; + } + + e.preventDefault(); + e.stopPropagation(); + + this.drag = true; + + const el = this.canvasRef.current; + + if (!el) { + return true; + } + + this.startx = e.clientX + el.scrollLeft; + this.starty = e.clientY + el.scrollTop; + + return false; + } + + handleMouseUp = (e: MouseEvent) => { + if (!this.drag) { + return true; + } + + e.preventDefault(); + e.stopPropagation(); + + this.drag = false; + + return false; + } + + handleMouseMove = (e: MouseEvent) => { + if (!this.drag) { + return true; + } + + e.preventDefault(); + e.stopPropagation(); + + const el = this.canvasRef.current; + + if (!el) { + return true; + } + + el.scrollLeft += (this.startx - (e.clientX + el.scrollLeft)); + el.scrollTop += (this.starty - (e.clientY + el.scrollTop)); + + return false; + } + + handleDragOver = (e: DragEvent) => { + if (e.target !== this.surfaceRef.current) { + return true; + } + + if (e.preventDefault) { + e.preventDefault(); + } + + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + + return false; + } + + handleDrop = (e: DragEvent) => { + if (e.stopPropagation) { + e.stopPropagation(); + } + + if (!e.dataTransfer) { + return true; + } + + const { action, handle } = JSON.parse(e.dataTransfer.getData('application/json')); + + const coords = new Vector(e.offsetX, e.offsetY).subtract(new Vector(handle)).subtract(new Vector(origin)); + + this.props.issueModelCommand('addTask', { + name: this.props.nextTask, + action: action.ref, + coords: Vector.max(coords, new Vector(0, 0)), + }); + + return false; + } + + handleTaskMove = (task: TaskRefInterface, coords: CanvasPoint) => { + this.props.issueModelCommand('updateTask', task, { coords }); + } + + handleTaskSelect = (task: TaskRefInterface) => { + this.props.navigate({ task: task.name, toTasks: undefined, type: 'execution', section: 'input' }); + } + + handleTransitionSelect = (e: MouseEvent, transition: TransitionInterface) => { + e.stopPropagation(); + this.props.navigate({ task: transition.from.name, toTasks: transition.to.map(t => t.name), type: 'execution', section: 'transitions' }); + } + + handleCanvasClick = (e: MouseEvent) => { + e.stopPropagation(); + this.props.navigate({ task: undefined, toTasks: undefined, section: undefined, type: 'metadata' }); + } + + handleTaskEdit = (task: TaskRefInterface) => { + this.props.navigate({ toTasks: undefined, task: task.name }); + } + + handleTaskDelete = (task: TaskRefInterface) => { + this.props.issueModelCommand('deleteTask', task); + } + + handleTaskConnect = (to: TaskRefInterface, from: TaskRefInterface) => { + this.props.issueModelCommand('addTransition', { from, to: [ to ] }); + } + + handleTransitionDelete = (transition: TransitionInterface) => { + this.props.issueModelCommand('deleteTransition', transition); + } + + get notifications() : Array { + return this.props.notifications; + } + get errors() : Array { + return this.props.notifications.filter(n => n.type === 'error'); + } + + style = style + canvasRef = React.createRef(); + surfaceRef = React.createRef(); + taskRefs = {}; + + get transitionRoutingGraph(): Graph { + const { taskRefs } = this; + + const boundingBoxes: Array = Object.keys(taskRefs).map((key: string): BoundingBox => { + + if(taskRefs[key].current) { + const task: TaskInterface = taskRefs[key].current.props.task; + + const coords = new Vector(task.coords).add(origin); + const size = new Vector(task.size); + + return { + left: coords.x - ORBIT_DISTANCE, + top: coords.y - ORBIT_DISTANCE, + bottom: coords.y + size.y + ORBIT_DISTANCE, + right: coords.x + size.x + ORBIT_DISTANCE, + midpointY: coords.y + size.y / 2, + midpointX: coords.x + size.x / 2, + }; + } + else { + return { + left: NaN, + top: NaN, + bottom: NaN, + right: NaN, + midpointY: NaN, + midpointX: NaN, + }; + } + }); + + return makeRoutingGraph(boundingBoxes); + } + + render() { + const { notifications, children, navigation, tasks=[], transitions=[], isCollapsed, toggleCollapse } = this.props; + const { scale } = this.state; + const { transitionRoutingGraph } = this; + + const surfaceStyle = { + transform: `scale(${Math.E ** scale})`, + }; + + const transitionGroups = transitions + .map(transition => { + const from = { + task: tasks.find(({ name }) => name === transition.from.name), + anchor: 'bottom', + }; + + const group = transition.to.map(tto => { + const to = { + task: tasks.find(({ name }) => name === tto.name) || {}, + anchor: 'top', + }; + + return { + from, + to, + }; + }); + + return { + id: uniqueId(`${transition.from.name}-`), + transition, + group, + color: transition.color, + }; + }); + + const selectedTask = tasks.filter(task => task.name === navigation.task)[0]; + + const selectedTransitionGroups = transitionGroups + .filter(({ transition }) => { + const { task, toTasks = [] } = navigation; + return transition.from.name === task && transition.to.length && fp.isEqual(toTasks, transition.to.map(t => t.name)); + }); + + // Currently this component is registering global key handlers (attach = document.body) + // At some point it may be desirable to pull the global keyMap up to main.js (handlers + // can stay here), but for now since all key commands affect the canvas, this is fine. + return ( + { + // This will break if canvas elements (tasks/transitions) become focus targets with + // tabindex or automatically focusing elements. But in that case, the Task already + // has a handler for delete waiting. + if(e.target === document.body) { + e.preventDefault(); + if(selectedTask) { + this.handleTaskDelete(selectedTask); + } + } + }}} + > +
this.handleCanvasClick(e)} + > + { children } + + this.setState({ scale: this.state.scale + .1 })} /> + this.setState({ scale: 0 })} /> + this.setState({ scale: this.state.scale - .1 })} /> + + toggleCollapse('palette')} /> + toggleCollapse('details')} /> +
+
+ { + tasks.map((task) => { + this.taskRefs[task.name] = this.taskRefs[task.name] || React.createRef(); + return ( + this.handleTaskMove(task, ...a)} + onConnect={(...a) => this.handleTaskConnect(task, ...a)} + onClick={() => this.handleTaskSelect(task)} + onDelete={() => this.handleTaskDelete(task)} + ref={this.taskRefs[task.name]} + /> + ); + }) + } + { + transitionGroups + .filter(({ transition }) => { + const { task, toTasks = [] } = navigation; + return transition.from.name === task && fp.isEqual(toTasks, transition.to.map(t => t.name)); + }) + .map(({ transition }) => { + const toPoint = transition.to + .map(task => tasks.find(({ name }) => name === task.name)) + .map(task => new Vector(task.size).multiply(new Vector(.5, 0)).add(new Vector(0, -10)).add(new Vector(task.coords))) + ; + + const fromPoint = [ transition.from ] + .map((task: TaskRefInterface): any => tasks.find(({ name }) => name === task.name)) + .map((task: TaskInterface) => new Vector(task.size).multiply(new Vector(.5, 1)).add(new Vector(task.coords))) + ; + + const point = fromPoint.concat(toPoint) + .reduce((acc, point) => (acc || point).add(point).divide(2)) + ; + + const { x, y } = point.add(origin); + return ( +
this.handleTransitionDelete(transition)} + /> + ); + }) + } + + { + transitionGroups + .map(({ id, transition, group, color }, i) => ( + this.handleTransitionSelect(e, transition)} + /> + )) + } + { + selectedTransitionGroups + .map(({ id, transition, group, color }, i) => ( + this.handleTransitionSelect(e, transition)} + /> + )) + } + +
+
+ +
+ + ); + } +} diff --git a/modules/st2flow-canvas/package.json b/modules/st2flow-canvas/package.json new file mode 100644 index 000000000..209848c0c --- /dev/null +++ b/modules/st2flow-canvas/package.json @@ -0,0 +1,62 @@ +{ + "name": "@stackstorm/st2flow-canvas", + "version": "1.0.0", + "description": "", + "main": "index.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/stackstorm/st2web.git" + }, + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/stackstorm/st2web/issues" + }, + "homepage": "https://github.com/stackstorm/st2web#readme", + "browserify": { + "transform": [ + "babelify", + [ + "@stackstorm/browserify-postcss", + { + "extensions": [ + ".css" + ], + "inject": "insert-css", + "modularize": { + "camelCase": true + }, + "plugin": [ + "postcss-import", + "postcss-nested", + "postcss-color-mod-function", + "postcss-preset-env" + ] + } + ] + ] + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@stackstorm/st2flow-notifications": "^1.0.0", + "insert-css": "2.0.0", + "react-hotkeys": "^1.1.4" + }, + "devDependencies": { + "@stackstorm/browserify-postcss": "0.3.4-patch.5", + "babelify": "10.0.0", + "classnames": "^2.2.6", + "postcss": "7.0.14", + "postcss-color-mod-function": "^3.0.3", + "postcss-import": "12.0.1", + "postcss-nested": "4.1.2", + "postcss-preset-env": "6.6.0" + } +} diff --git a/modules/st2flow-canvas/path/index.js b/modules/st2flow-canvas/path/index.js new file mode 100644 index 000000000..955a5ed16 --- /dev/null +++ b/modules/st2flow-canvas/path/index.js @@ -0,0 +1,93 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +import Vector from '../vector'; +import type { Direction } from './line'; +import Line from './line'; + +export class Path { + origin: Vector + elements: Array = []; + initialDir: Direction + constructor(origin: Vector, dir: Direction) { + Object.assign(this, { origin, initialDir: dir }); + } + + moveTo(newPosition: Vector) { + const pos = this.currentPosition; + const dir = this.currentDir; + + const yMove = newPosition.y !== pos.y; + const xMove = newPosition.x !== pos.x; + + let xLine: Line; + let yLine: Line; + if(xMove) { + if(this.elements.length && (dir === 'left' || dir === 'right')) { + xLine = this.elements.pop(); + xLine = new Line(xLine.px + (newPosition.x - pos.x) * (dir === 'left' ? -1 : 1), xLine.direction); + } + else { + xLine = new Line( + Math.abs(newPosition.x - pos.x), + newPosition.x > pos.x ? 'right' : 'left' + ); + } + } + if(yMove) { + if(this.elements.length && (dir === 'up' || dir === 'down')) { + yLine = this.elements.pop(); + yLine = new Line(yLine.px + (newPosition.y - pos.y) * (dir === 'up' ? -1 : 1), yLine.direction); + } + else { + yLine = new Line( + Math.abs(newPosition.y - pos.y), + newPosition.y > pos.y ? 'down' : 'up' + ); + } + } + if(dir === 'left' || dir === 'right') { + xLine && this.addLine(xLine); + yLine && this.addLine(yLine); + } + else { + yLine && this.addLine(yLine); + xLine && this.addLine(xLine); + } + } + + addLine(line: Line) { + this.elements.push(line); + } + + get currentDir() { + return this.elements.length > 0 + ? this.elements[this.elements.length - 1].direction + : this.initialDir; + } + + get currentPosition() { + let currentPoint = this.origin; + this.elements.forEach(el => { + currentPoint = el.calcNewPosition(currentPoint); + }); + return currentPoint; + } + + toString(): string { + let origin: Vector = this.origin; + const path = this.elements.map((el, idx) => { + const next = this.elements[idx + 1]; + const str = el.toPathString(origin, next); + origin = el.calcNewPosition(origin); + + return str; + }).join(' '); + return `M ${this.origin.x} ${this.origin.y} ${path}`; + } +} + +export default Path; diff --git a/modules/st2flow-canvas/path/line.js b/modules/st2flow-canvas/path/line.js new file mode 100644 index 000000000..39d8b7840 --- /dev/null +++ b/modules/st2flow-canvas/path/line.js @@ -0,0 +1,80 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +import Vector from '../vector'; +import { ORBIT_DISTANCE } from '../const'; + +export type Direction = 'up' | 'down' | 'left' | 'right'; +export class Line { + px: number; + direction: Direction; + constructor(px: number, dir: Direction) { + Object.defineProperties(this, { + direction: { + value: dir, + }, + px: { + value: px, + }, + }); + } + calcNewPosition(origin: Vector): Vector { + const point = new Vector(origin.x, origin.y); + switch(this.direction) { + case 'up': + point.y -= this.px; + break; + case 'down': + point.y += this.px; + break; + case 'left': + point.x -= this.px; + break; + case 'right': + point.x += this.px; + break; + } + return point; + } + toPathString(origin: Vector, next: Line): string { + const newPoint = this.calcNewPosition(origin); + + // does the next line segment curve out? + const adjustmentNext = next && next.direction !== this.direction ? ORBIT_DISTANCE : 0; + // does this line go up and down? or left and right? + const isYDimension = this.direction === 'up' || this.direction === 'down'; + // Which direction in pixels from 0,0? + const dimensionScale = this.direction === 'up' || this.direction === 'left' ? -1 : 1; + + let curvePath = ''; + + if(adjustmentNext) { + const adjustmentMax = Math.min(adjustmentNext, next.px / 2, this.px / 2); + const nextIsYDimension = next.direction === 'up' || next.direction === 'down'; + const nextDimensionScale = next.direction === 'up' || next.direction === 'left' ? -1 : 1; + + if(isYDimension && !nextIsYDimension) { + const oldPointY = newPoint.y; + newPoint.y -= adjustmentMax * dimensionScale; + const controlPointX = newPoint.x + adjustmentMax * nextDimensionScale; + curvePath = ` Q ${newPoint.x} ${oldPointY}, ${controlPointX} ${oldPointY}`; + } + else if(nextIsYDimension) { + const oldPointX = newPoint.x; + const controlPointY = newPoint.y + adjustmentMax * nextDimensionScale; + newPoint.x -= adjustmentMax * dimensionScale; + curvePath = ` Q ${oldPointX} ${newPoint.y}, ${oldPointX} ${controlPointY}`; + } + } + + return `L ${newPoint.x} ${newPoint.y}${curvePath}`; + } + toString(): string { + return `${this.px} ${this.direction}`; + } +} + +export default Line; diff --git a/modules/st2flow-canvas/poisson-rect.js b/modules/st2flow-canvas/poisson-rect.js new file mode 100644 index 000000000..0bdd515b4 --- /dev/null +++ b/modules/st2flow-canvas/poisson-rect.js @@ -0,0 +1,153 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + + +export interface Sampler { + getNext(taskName: string, transitionsTo: Array): {| x: number, y: number |}; + prefillPoint(x: number, y: number, transitionsTo: Array): {| x: number, y: number |}; +} + +export class PoissonRectangleSampler implements Sampler { + k = 30; // maximum number of samples before rejection + width: number; + height: number; + radiusX: number; + radiusY: number; + gridWidth: number; + gridHeight: number; + grid: Array<{ x: number, y: number, transitionsTo: Array}>; + queue: Array<{ x: number, y: number, transitionsTo: Array }> = []; + queueSize = 0; + sampleSize = 0; + prandSeed: number; + snapDistance: number; + + constructor(width: number, height: number, radiusX: number, radiusY: number, snapDistance: number = 20) { + Object.assign(this, { width, height, radiusX, radiusY, snapDistance}); + this.gridWidth = Math.ceil(width / radiusX); + this.gridHeight = Math.ceil(height / radiusY); + this.grid = new Array(this.gridWidth * this.gridHeight); + } + + getNext(taskName: string, transitionsTo: Array): {| x: number, y:number |} { + this.prandSeed = parseInt(taskName.replace(/[^A-Z0-9]/ig, ''), 36) % 2147483647; + if (!this.sampleSize) { + // for placing the first item, put it near the upper left. + return this.sample(this.prand() * this.radiusX, this.prand() * this.radiusY, transitionsTo); + } + + // Pick a random existing sample and remove it from the queue. + // Favor any task that's connected to the one we're trying to place via a transition + while (this.queueSize) { + const connectedTasks = this.queue.filter(s => s.transitionsTo.includes(taskName)); + const i = connectedTasks.length + ? this.queue.indexOf(connectedTasks[Math.floor(this.prand() * connectedTasks.length)]) + : Math.floor(this.prand() * this.queueSize); + const s = this.queue[i]; + + // Make a new candidate between [radius, 2 * radius] from the existing sample. + for (let j = 0; j < this.k; ++j) { // eslint-disable-line no-plusplus + // If there is a transition from the selected task to the new on, throw out most of the pseudo-random + // placement and place preferentially directly below. + if(s.transitionsTo.includes(taskName)) { + for(let adjustmentY of [ 1, 2 ]) { + for(let adjustmentX of [ -1, 0, -2, 1 ]) { + adjustmentX *= this.radiusX; + adjustmentY = this.radiusY; // y adjustment is fixed at +1 no matter which x is chosen + const x = s.x + Math.round((adjustmentX + this.prand() * this.radiusX) / this.snapDistance) * this.snapDistance; + const y = s.y + Math.round((adjustmentY + this.prand() * this.radiusY) / this.snapDistance) * this.snapDistance; + if (0 <= x && x < this.width && 0 <= y && y < this.height && this.far(x, y)) { + return this.sample(x, y, transitionsTo); + } + } + } + } + // otherwise place up to 1 height/width away in any orthogonal or diagonal dir. + else { + //since we're looking in a rectangle, we'll first pick one of the 12 cells around the + // 2w * 2h rectangle which are of size (w, h) + const cell = Math.floor(this.prand() * 12); + const adjustmentX = [ -2, -1, 0, 1, -2, 1, -2, 1, -2, -1, 0, 1 ][cell] * this.radiusX; + const adjustmentY = [ -2, -2, -2, -2, -1, -1, 0, 0, 1, 1, 1, 1 ][cell] * this.radiusY; + const x = s.x + Math.round((adjustmentX + this.prand() * this.radiusX) / this.snapDistance) * this.snapDistance; + const y = s.y + Math.round((adjustmentY + this.prand() * this.radiusY) / this.snapDistance) * this.snapDistance; + + // Reject candidates that are outside the allowed extent, + // or closer than 2 * radius to any existing sample. + if (0 <= x && x < this.width && 0 <= y && y < this.height && this.far(x, y)) { + return this.sample(x, y, transitionsTo); + } + } + } + + this.queue[i] = this.queue[--this.queueSize]; // eslint-disable-line no-plusplus + this.queue.length = this.queueSize; + } + return { x: 0, y: 0 }; + } + + prand() { + this.prandSeed = this.prandSeed * 16807 % 2147483647; + return (this.prandSeed - 1) / 2147483646; + } + + far(x, y) { + let i = Math.floor(x / this.radiusX); + let j = Math.floor(y / this.radiusY); + const i0 = Math.max(i - 2, 0); + const j0 = Math.max(j - 2, 0); + const i1 = Math.min(i + 3, this.gridWidth); + const j1 = Math.min(j + 3, this.gridHeight); + + for (j = j0; j < j1; ++j) { // eslint-disable-line no-plusplus + const o = j * this.gridWidth; + for (i = i0; i < i1; ++i) { // eslint-disable-line no-plusplus + const s = this.grid[o + i]; + if (s) { + const dx = Math.abs(s.x - x); + const dy = Math.abs(s.y - y); + if (dx < this.radiusX && dy < this.radiusY) { + return false; + } + } + } + } + + return true; + } + + sample(x: number, y: number, transitionsTo: Array): {| x: number, y: number |} { + const s = { x, y }; + const t = { x, y, transitionsTo }; + this.queue.push(t); + this.grid[this.gridWidth * Math.floor(y / this.radiusY) + Math.floor(x / this.radiusX)] = t; + ++this.sampleSize; // eslint-disable-line no-plusplus + ++this.queueSize; // eslint-disable-line no-plusplus + return s; + } + + prefillPoint(x: number, y: number, transitionsTo: Array): {| x: number, y: number |} { + if(x > this.width || y > this.height) { + // resize the grid to handle the larger size. + const oldGrid = this.grid; + this.width = x + 2 * this.radiusX; + this.height = y + 2 * this.radiusY; + this.gridWidth = Math.ceil(this.width / this.radiusX); + this.gridHeight = Math.ceil(this.height / this.radiusY); + this.grid = new Array(this.gridWidth * this.gridHeight); + this.sampleSize = 0; + this.queueSize = 0; + oldGrid.forEach(cell => { + if(cell) { + this.sample(cell.x, cell.y, cell.transitionsTo); + } + }); + } + return this.sample(x, y, transitionsTo); + } +} + +export default PoissonRectangleSampler; diff --git a/modules/st2flow-canvas/routing-graph.js b/modules/st2flow-canvas/routing-graph.js new file mode 100644 index 000000000..a55e0b058 --- /dev/null +++ b/modules/st2flow-canvas/routing-graph.js @@ -0,0 +1,226 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +// @flow +import { Graph } from './astar'; +import { ORBIT_DISTANCE } from './const'; + +export type BoundingBox = {| + left: number, + right: number, + top: number, + bottom: number, + midpointX: number, + midpointY: number, +|}; + +export function makeRoutingGraph(boundingBoxes: Array): Graph { + if (boundingBoxes.length < 1) { + return new Graph([], {}); + } + + /* Let I be the set of interesting points (x, y) in the diagram, i.e. the connector + points and corners of the bounding box of each object. Let XI be the set of x + coordinates in I and YI the set of y coordinates in I. The orthogonal visibility + graph V G = (V, E) is made up of nodes V ⊆ XI × YI s.t. (x, y) ∈ V iff there + exists y0 s.t. (x, y0) ∈ I and there is no intervening object between (x, y) and + (x, y0) and there exists x0 s.t. (x0, y) ∈ I and there is no intervening object + between (x, y) and (x0, y). There is an edge e ∈ E between each point in V to its + nearest neighbour to the north, south, east and west iff there is no intervening + object in the original diagram */ + const border = { + left: Infinity, + right: -Infinity, + top: Infinity, + bottom: -Infinity, + }; + const I = [].concat(...boundingBoxes.map(box => { + if(box.left < border.left) { + border.left = box.left; + } + if(box.right > border.right) { + border.right = box.right; + } + if(box.top < border.top) { + border.top = box.top; + } + if(box.bottom > border.bottom) { + border.bottom = box.bottom; + } + + return [ + { x: box.left, y: box.top }, + { x: box.left, y: box.bottom }, + { x: box.right, y: box.top }, + { x: box.right, y: box.bottom }, + // our connectors are currently at the midpoints of each edge. + // That can be changed here. + { x: box.left, y: box.midpointY }, + { x: box.midpointX, y: box.top }, + { x: box.midpointX, y: box.bottom }, + { x: box.right, y: box.midpointY }, + ]; + })).concat([ + { x: border.left - ORBIT_DISTANCE, y: border.top - ORBIT_DISTANCE }, + { x: border.left - ORBIT_DISTANCE, y: border.bottom + ORBIT_DISTANCE }, + { x: border.right + ORBIT_DISTANCE, y: border.top - ORBIT_DISTANCE }, + { x: border.right + ORBIT_DISTANCE, y: border.bottom + ORBIT_DISTANCE }, + ]); + const XI = I.reduce((a, i) => { + a[i.x] = a[i.x] || []; + a[i.x].push(i.y); + return a; + }, {}); + const YI = I.reduce((a, i) => { + a[i.y] = a[i.y] || []; + a[i.y].push(i.x); + return a; + }, {}); + const E = {}; + const V = [].concat(...Object.keys(XI).map(interestingX => { + const x = +interestingX; + return Object.keys(YI).map(interestingY => { + const y = +interestingY; + // optimization: find nearest neighbor first. + // if nearest neighbors are blocked then all are. + let nearestNeighborUp = -Infinity; + let nearestNeighborDown = Infinity; + let nearestNeighborLeft = -Infinity; + let nearestNeighborRight = Infinity; + YI[y].forEach(_x => { + // x > _x means _x is to the left + if(x !== _x) { + if(x > _x && _x > nearestNeighborLeft) { + nearestNeighborLeft = _x; + } + if(x < _x && _x < nearestNeighborRight) { + nearestNeighborRight = _x; + } + } + }); + XI[x].forEach(_y => { + // y > _y means _y is above + if(y !== _y) { + if(y > _y && _y > nearestNeighborUp) { + nearestNeighborUp = _y; + } + if(y < _y && _y < nearestNeighborDown) { + nearestNeighborDown = _y; + } + } + }); + + boundingBoxes.forEach(box => { + // Make visibility checks. If a box is beween (x, y) and the nearest "interesting" neighbor, + // (interesting neighbors are the points in I which share either an X or Y coordinate) + // remove that nearest neighbor. + if (x > box.left && x < box.right && y === box.bottom) { + // in this case y is the interesting point. Mark it as not having nearest neighbor + nearestNeighborUp = NaN; + } + else if(nearestNeighborUp > -Infinity && x > box.left && x < box.right && y > box.top && nearestNeighborUp < box.bottom) { + nearestNeighborUp = -Infinity; + } + if (x > box.left && x < box.right && y === box.top) { + // in this case y is the interesting point. Mark it as not having nearest neighbor + nearestNeighborDown = NaN; + } + else if(nearestNeighborDown < Infinity && x > box.left && x < box.right && y < box.bottom && nearestNeighborDown > box.top) { + nearestNeighborDown = Infinity; + } + if (y > box.top && y < box.bottom && x === box.right) { + // in this case y is the interesting point. Mark it as not having nearest neighbor + nearestNeighborLeft = NaN; + } + else if(nearestNeighborLeft > -Infinity && y > box.top && y < box.bottom && x > box.left && nearestNeighborLeft < box.right) { + nearestNeighborLeft = -Infinity; + } + if (y > box.top && y < box.bottom && x === box.left) { + // in this case y is the interesting point. Mark it as not having nearest neighbor + nearestNeighborRight = NaN; + } + else if(nearestNeighborRight < Infinity && y > box.top && y < box.bottom && x < box.right && nearestNeighborRight > box.left) { + nearestNeighborRight = Infinity; + } + }); + + if (XI[x].indexOf(y) > -1 || + ((nearestNeighborUp > -Infinity || + nearestNeighborDown < Infinity) && + (nearestNeighborLeft > -Infinity || + nearestNeighborRight < Infinity)) + ) { + E[`${x}|${y}`] = E[`${x}|${y}`] || []; + return { + x, + y, + nearestNeighborUp, + nearestNeighborDown, + nearestNeighborRight, + nearestNeighborLeft, + }; + } + else { + return {x, y: -Infinity, nearestNeighborLeft, nearestNeighborRight, nearestNeighborDown, nearestNeighborUp}; + } + }).filter(({y}) => y > -Infinity && `${x}|${y}` in E); + })); + + V.forEach(v => { + const { + x, + y, + } = v; + let { + nearestNeighborUp, + nearestNeighborDown, + nearestNeighborLeft, + nearestNeighborRight, + } = v; + // for what to put in the graph edges, now we want to look + // at any point in V, not just interesting ones. + // If there exists a point of interest (x, yi) such that there + // is no bounding box intervening, then all points + // (x, yj), y < yj < yi or y > yj > yi, will also not have a bounding + // box intervening, so we don't have to check again. Above, a bounding + // box being to the immediate left/top/right/bottom of a point caused + // that nearest neighbot to be set to NaN. + if((nearestNeighborUp = Object.keys(YI).reduce((bestY, _yStr) => { + const _y = +_yStr; + // 1. ensure nearest neighbor is a point in V (`x|y` in E means that a set of edges was set up for a point, + // and that's easier than iterating through V and doing deep comparisons) + // 2. Make sure it's not the same point and actually upward (_y < y) (NaN fails here, as NaN fails all < and > comparisons) + // 3. Check if it's closer than the previous candidate (_y > bestY) + // 4. if all are true, choose it instead of the previous candidate. + // 4a. if any are false, use the previous candidate instead. + return `${x}|${_y}` in E && _y < y && _y > bestY ? _y : bestY; + }, nearestNeighborUp)) !== -Infinity && nearestNeighborUp === nearestNeighborUp) { + E[`${x}|${y}`].push({x, y: nearestNeighborUp}); + } + if((nearestNeighborDown = Object.keys(YI).reduce((bestY, _yStr) => { + const _y = +_yStr; + return `${x}|${_y}` in E && _y > y && _y < bestY ? _y : bestY; + }, nearestNeighborDown)) !== Infinity && nearestNeighborDown === nearestNeighborDown) { + E[`${x}|${y}`].push({x, y: nearestNeighborDown}); + } + if((nearestNeighborLeft = Object.keys(XI).reduce((bestX, _xStr) => { + const _x = +_xStr; + return `${_x}|${y}` in E && _x < x && _x > bestX ? _x : bestX; + }, nearestNeighborLeft)) !== -Infinity && nearestNeighborLeft === nearestNeighborLeft) { + E[`${x}|${y}`].push({x: nearestNeighborLeft, y}); + } + if((nearestNeighborRight = Object.keys(XI).reduce((bestX, _xStr) => { + const _x = +_xStr; + return `${_x}|${y}` in E && _x > x && _x < bestX ? _x : bestX; + }, nearestNeighborRight)) !== Infinity && nearestNeighborRight === nearestNeighborRight) { + E[`${x}|${y}`].push({x: nearestNeighborRight, y}); + } + }); + + return new Graph(V, E); +} + +export default makeRoutingGraph; diff --git a/modules/st2flow-canvas/style.css b/modules/st2flow-canvas/style.css new file mode 100644 index 000000000..62cc1151f --- /dev/null +++ b/modules/st2flow-canvas/style.css @@ -0,0 +1,406 @@ +/* +Copyright 2020 Extreme Networks, Inc. + +Unauthorized copying of this file, via any medium is strictly +prohibited. Proprietary and confidential. See the LICENSE file +included with this work for details. +*/ + +:root { + --transition-color: orange; + --selected-bg: #fff3e6; + --selected-border: #ffa256; +} + +.component { + background-color: white; + + position: relative; +} + +.canvas { + overflow: auto; + + width: 100%; + height: 100%; + + position: relative; +} + +.task { + display: block; + + position: absolute; + + user-select: none; + + &-body { + padding: 10px; + background-color: white; + + border-radius: 3px; + box-shadow: 3px 3px 0 rgba(0, 0, 0, .3); + box-sizing: border-box; + + img { + float: left; + } + > div { + margin-left: 40px; + } + } + + &.selected &-body { + border: 1px solid var(--selected-border); + background-color: var(--selected-bg); + } + + &.selected &-button { + display: flex; + } + + &-button { + position: absolute; + + width: 24px; + height: 24px; + + right: -36px; + + border-radius: 50%; + border: 1px solid #ffa256; + background-color: #fff3e6; + + display: none; + align-items: center; + justify-content: center; + + &:after { + content: ' '; + } + + &.delete { + top: 12px; + } + } + + &-name { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + + pointer-events: none; + } + + &-action { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + pointer-events: none; + } + + &-badges { + right: 0; + bottom: 1px; + position: absolute; + } + + &-badge { + border: 1px solid black; + margin: 1px; + padding: 1px; + padding-right: 2px; + border-radius: 2px; + + &-with-items { + composes: icon-repeat from global; + &:before { + margin: 0; + font-weight: bold !important; + } + } + + &-join { + composes: icon-merge_type from global; + display: inline-block; + transform: rotateZ(180deg) translateY(1px); + &:before { + margin: 0; + font-weight: bold !important; + } + } + } + + &-handle { + position: absolute; + + width: 10px; + height: 10px; + + border-radius: 50%; + border: 1px solid #ffa256; + background-color: white; + + transform-origin: center center; + transform: translate(-50%, -50%); + + display: none; + + -webkit-user-drag: element; + + &:hover { + background-color: #ffa256; + } + } + + &.selected &-handle { + display: block; + } +} + +.transition { + stroke: var(--transition-color); + stroke-width: 5; + fill: transparent; + pointer-events: none; + + &-button { + position: absolute; + top: -13px; + left: -13px; + + width: 24px; + height: 24px; + + border-radius: 50%; + border: 1px solid #ffa256; + background-color: #fff3e6; + + display: flex; + + align-items: center; + justify-content: center; + + &:after { + content: ' '; + } + } +} + +.transition-active { + stroke-width: 12; + fill: transparent; + stroke: transparent; + visibility: hidden; + + &.selected { + visibility: visible; + stroke: var(--selected-bg); + pointer-events: none; + } +} + +.transition-active-border { + stroke-width: 13; + fill: transparent; + stroke: transparent; + pointer-events: stroke; + visibility: hidden; + + &.selected { + visibility: visible; + stroke: var(--transition-color); + pointer-events: none; + } +} + +.transition-arrow { + fill: var(--transition-color); +} + +.transition-arrow-active { + fill: var(--selected-bg); +} + +.surface { + background-color: #ebebeb; + background-image: linear-gradient(#ddd 2px, transparent 2px), + linear-gradient(90deg, #ddd 2px, transparent 2px), + linear-gradient(color-mod(#ddd a(30%)) 1px, transparent 1px), + linear-gradient(90deg, color-mod(#ddd a(30%)) 1px, transparent 1px); + background-size:100px 100px, 100px 100px, 20px 20px, 20px 20px; + background-position:-2px -2px, -2px -2px, -1px -1px, -1px -1px; + + box-shadow: inset 0px 0px 8px 0px rgba(0,0,0,0.32); + position: absolute; + + transform-origin: 0 0; +} + +.svg { + width: 100%; + height: 100%; + + /* Chrome shows vertical scroll when svg canvas matches container exactly so we're subtracting 3 pixels at the bottom */ + margin-bottom: -3px; + + pointer-events: none; +} + +.toolbar { + position: absolute; + top: 8px; + left: 0; + right: 0; + + height: 40px; + + display: flex; + justify-content: center; + + &-right { + right: 16px; + flex-flow: column; + width: 50px; + height: 140px; + left: auto; + top: 55px; + } +} + +.toolbar-button { + width: 32px; + height: 32px; + + border-radius: 50%; + background-color: white; + + z-index: 1; + margin: 8px; + + box-shadow: 0 0 3px rgba(0, 0, 0, 0.5); + + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + color: var(--gray); + font-size: 20px; + + &:after { + content: ' '; + } + + &.success { + animation: ok .2s linear 3s forwards; + background-color: var(--green-base); + color: white; + } + + &.error { + animation: fail .2s linear 3s forwards; + background-color: var(--red-base); + color: white; + } + + &.disabled { + background-color: #dddddd; + cursor: not-allowed; + } + + &-glow { + box-shadow: 0px 0px 5px red; + } +} + +.dropdown { + width: 100%; + position: absolute; + top: 57px; + overflow-y: visible; + z-index: 1000; + + &-pointer { + position: absolute; + top: -8px; + width: 20px; + height: 20px; + transform: rotateZ(45deg); + background-color: var(--gray-white); + } + + &-body { + margin-left: auto; + margin-right: auto; + background-color: var(--gray-white); + width: 250px; + padding: 15px; + padding-top: 1px; + box-shadow: 5px 5px rgba(0, 0, 0, 0.3); + } + + h2 { + text-align: center; + } +} + +.collapse-button { + position: absolute; + width: 30px; + height: 40px; + padding: 5px 0; + + background-color: #444; + color: #eee; + font-size: 23px; + + z-index: 1; + + display: flex; + align-items: center; + + + &.left { + left: 0; + border-bottom-right-radius: 4px; + box-shadow: 2px 0px 7px 0px rgba(0,0,0,0.32); + } + &.right { + right: 0; + border-bottom-left-radius: 4px; + box-shadow: -2px 0px 7px 0px rgba(0,0,0,0.32); + } + &.top { + top: 0; + right: 0; + background-color: rgba(30, 29, 31, 0.22); + width: initial; + padding: 5px 0; + } +} + +@keyframes ok { + 0% { + background-color: var(--green-base); + color: white; + } + 100% { + background-color: white; + color: var(--gray); + } +} + +@keyframes fail { + 0% { + background-color: var(--red-base); + color: white; + } + 100% { + background-color: white; + color: var(--gray); + } +} diff --git a/modules/st2flow-canvas/task.js b/modules/st2flow-canvas/task.js new file mode 100644 index 000000000..11855d1e5 --- /dev/null +++ b/modules/st2flow-canvas/task.js @@ -0,0 +1,286 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +//@flow + +import type { CanvasPoint, TaskInterface } from '@stackstorm/st2flow-model/interfaces'; + +import React, { Component } from 'react'; +import { PropTypes } from 'prop-types'; +import cx from 'classnames'; +import {HotKeys} from 'react-hotkeys'; + +import Vector from './vector'; +import { origin } from './const'; + +import style from './style.css'; +import api from '@stackstorm/module-api'; + +export class Task extends Component<{ + task: TaskInterface, + scale: number, + selected: bool, + onMove: Function, + onConnect: Function, + onClick: Function, + onDelete: Function, +}, { + delta: CanvasPoint +}> { + static propTypes = { + task: PropTypes.object.isRequired, + scale: PropTypes.number.isRequired, + selected: PropTypes.bool, + onMove: PropTypes.func, + onConnect: PropTypes.func, + onClick: PropTypes.func, + onDelete: PropTypes.func, + } + + state = { + delta: { + x: 0, + y: 0, + }, + } + + componentDidMount() { + const task = this.taskRef.current; + const handle = this.handleRef.current; + + if (!task || !handle) { + return; + } + + task.addEventListener('mousedown', this.handleMouseDown); + window.addEventListener('mousemove', this.handleMouseMove); + window.addEventListener('mouseup', this.handleMouseUp); + + handle.addEventListener('dragstart', this.handleDragStartHandle); + task.addEventListener('dragover', this.handleDragOver); + task.addEventListener('drop', this.handleDrop); + } + + componentWillUnmount() { + const task = this.taskRef.current; + const handle = this.handleRef.current; + + if (!task || !handle) { + return; + } + + task.removeEventListener('mousedown', this.handleMouseDown); + window.removeEventListener('mousemove', this.handleMouseMove); + window.removeEventListener('mouseup', this.handleMouseUp); + + handle.removeEventListener('dragstart', this.handleDragStartHandle); + task.removeEventListener('dragover', this.handleDragOver); + task.removeEventListener('drop', this.handleDrop); + } + + drag: bool + start: CanvasPoint + + handleMouseDown = (e: MouseEvent) => { + // Drag should only work on left button press + if (e.button !== 0) { + return true; + } + + e.preventDefault(); + e.stopPropagation(); + + this.drag = true; + + this.start = { + x: e.clientX, + y: e.clientY, + }; + + return false; + } + + handleMouseUp = (e: MouseEvent) => { + if (!this.drag) { + return true; + } + + e.preventDefault(); + e.stopPropagation(); + + this.drag = false; + + const scale = Math.E ** this.props.scale; + + if (this.props.onMove) { + const { coords } = this.props.task; + const { x: dx, y: dy } = this.state.delta; + if ( dx === 0 && dy === 0) { + return false; + } + const x = coords.x + dx / scale; + const y = coords.y + dy / scale; + this.props.onMove(Vector.max(new Vector(x, y), new Vector(0, 0))); + } + + this.setState({ + delta: { + x: 0, + y: 0, + }, + }); + + return false; + } + + handleMouseMove = (e: MouseEvent) => { + if (!this.drag) { + return true; + } + + e.preventDefault(); + e.stopPropagation(); + + const x = e.clientX - this.start.x; + const y = e.clientY - this.start.y; + + this.setState({ + delta: { x, y }, + }); + + return false; + } + + handleClick = (e: MouseEvent) => { + e.stopPropagation(); + + if (this.props.onClick) { + this.props.onClick(); + } + } + + handleDragStartHandle = (e: DragEvent) => { + e.stopPropagation(); + + this.style.opacity = '0.4'; + + const { task } = this.props; + + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('application/json', JSON.stringify({ + task, + })); + } + } + + handleDragOver = (e: DragEvent) => { + if (e.preventDefault) { + e.preventDefault(); + } + + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'move'; + } + } + + handleDrop = (e: DragEvent) => { + if (e.preventDefault) { + e.preventDefault(); + } + if (e.stopPropagation) { + e.stopPropagation(); + } + + if (e.dataTransfer) { + const { task } = JSON.parse(e.dataTransfer.getData('application/json')); + + if (this.props.onConnect) { + this.props.onConnect(task); + } + } + + return false; + } + + handleImageError() { + if (this.imgRef.current) { + this.imgRef.current.src = 'static/icon.png'; + } + } + + style = style + taskRef = React.createRef(); + handleRef = React.createRef(); + imgRef = React.createRef(); + + render() { + const { task, selected, onDelete } = this.props; + const { delta } = this.state; + + const packName = task.action.replace(/\..*$/, ''); + + const scale = Math.E ** this.props.scale; + + const coords = new Vector(delta).divide(scale).add(new Vector(task.coords)).add(origin); + + return ( + +
this.handleClick(e)} + > +
+ this.handleImageError()} + width="32" + height="32" + /> +
+
{task.name}
+
{task.action}
+
+ { + !!task.with && ( + + + { task.with && +task.with.concurrency ? task.with.concurrency : ''} + + ) + } + { + !!task.join && ( + + + {typeof task.join === 'number' ? task.join : '​\u200B'} + + ) + } +
+
+
+
onDelete()} /> +
+
+ + ); + } +} + +export default Task; diff --git a/modules/st2flow-canvas/toolbar.js b/modules/st2flow-canvas/toolbar.js new file mode 100644 index 000000000..e5d0eae50 --- /dev/null +++ b/modules/st2flow-canvas/toolbar.js @@ -0,0 +1,193 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +//@flow + +import React, { Component } from 'react'; +import { PropTypes } from 'prop-types'; +import { connect } from 'react-redux'; +import cx from 'classnames'; +import _ from 'lodash'; + +import style from './style.css'; + +@connect( + null, + dispatch => ({ + pushError: (error, source) => + dispatch({ + type: 'PUSH_ERROR', + source, + error, + }), + pushSuccess: (message, source) => + dispatch({ + type: 'PUSH_SUCCESS', + source, + message, + }), + }) +) +export class ToolbarButton extends Component< + { + icon: string, + errorMessage?: string, + successMessage?: string, + onClick: Function, + pushError: Function, + pushSuccess: Function, + disabled?: boolean, + title: string, + className: string, + }, + { + status: "initial" | "pending" | "success" | "error" + } +> { + static propTypes = { + icon: PropTypes.string, + errorMessage: PropTypes.string, + successMessage: PropTypes.string, + onClick: PropTypes.func, + pushError: PropTypes.func, + pushSuccess: PropTypes.func, + disabled: PropTypes.bool, + title: PropTypes.string, + className: PropTypes.string, + }; + + static defaultProps = { + errorMessage: '', + }; + + state = { + status: 'initial', + }; + + async handleClick(e: Event) { + e.stopPropagation(); + + const { onClick, errorMessage, successMessage, pushError, pushSuccess, disabled, icon } = this.props; + + if(disabled) { + return; + } + + if (onClick) { + this.setState({ status: 'pending' }); + try { + await onClick(); + this.setState({ status: 'success' }); + + setTimeout(() => { + this.setState({ status: 'initial' }); + }, 3200); + + if (successMessage) { + pushSuccess(successMessage, icon); + } + } + catch (e) { + this.setState({ status: 'error' }); + + setTimeout(() => { + this.setState({ status: 'initial' }); + }, 3200); + + const faultString = _.get(e, 'response.data.faultstring'); + + if (errorMessage && faultString) { + pushError(`${errorMessage}: ${faultString}`, icon); + } + else if (errorMessage || faultString) { + pushError(`${errorMessage || ''}${faultString || ''}`, icon); + } + } + } + } + + style = style; + + render() { + const { icon, disabled, title } = this.props; + const { status } = this.state; + return ( +
`${this.style.toolbarButton}-${c}`) : []) + )} + onClick={e => this.handleClick(e)} + title={title} + /> + ); + } +} + +export class ToolbarDropdown extends Component<{ + children: any, + shown: boolean, + pointerPosition: string, + onClose?: Function, +}> { + static propTypes = { + children: PropTypes.node, + shown: PropTypes.bool, + pointerPosition: PropTypes.string, + onClose: PropTypes.func, + }; + + componentDidMount() { + this.boundClickListener = (function(ev: Event) { + if(ev.target instanceof HTMLElement + && !ev.target.matches(`.${this.style.dropdown} *`) + && this.props.onClose + ) { + this.props.onClose(); + } + }).bind(this); + document.body && document.body.addEventListener('click', this.boundClickListener, false); + } + + componentWillUnmount() { + document.body && document.body.removeEventListener('click', this.boundClickListener, false); + } + + style = style; + + boundClickListener: (Event) => void; + + render() { + const { children, pointerPosition, shown } = this.props; + return shown && ( +
+
 
+
+ { children } +
+
+ ); + } +} + +export class Toolbar extends Component<{ + children: any, + position?: string +}> { + static propTypes = { + children: PropTypes.node, + position: PropTypes.string, + }; + + style = style; + + render() { + return
{this.props.children}
; + } +} diff --git a/modules/st2flow-canvas/transition.js b/modules/st2flow-canvas/transition.js new file mode 100644 index 000000000..a0aec56c8 --- /dev/null +++ b/modules/st2flow-canvas/transition.js @@ -0,0 +1,220 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +//@flow + +import type { TaskInterface } from '@stackstorm/st2flow-model/interfaces'; +import type { Node } from 'react'; + +import React, { Component } from 'react'; +import { PropTypes } from 'prop-types'; +import cx from 'classnames'; +import { get } from 'lodash'; + +import Vector from './vector'; +import { origin, ORBIT_DISTANCE } from './const'; +import Path from './path/'; +import type { Task } from './task'; +import astar, { Graph } from './astar'; + +import style from './style.css'; + +const ANCHORS = { + top: new Vector(.5, 0), + left: new Vector(0, .5), + bottom: new Vector(.5, 1), + right: new Vector(1, .5), +}; + +const HORISONTAL_MASK = new Vector(1, 0); +const VERTICAL_MASK = new Vector(0, 1); + +const CONTROLS = { + top: new Vector(0, -1), + left: new Vector(-1, 0), + bottom: new Vector(0, 1), + right: new Vector(1, 0), +}; + +type Target = { + task: TaskInterface, + anchor: string +} + +class SVGPath extends Component<{ + onClick: Function +}> { + componentDidMount() { + // React fail: onClick isn't supported for SVG elements, so + // manually set up the click handler here. + if(this.pathElement.current && this.pathElement.current instanceof Element) { + this.pathElement.current.addEventListener('click', () => this.props.onClick); + } + } + + componentWillUnmount() { + if(this.pathElement.current && this.pathElement.current instanceof Element) { + this.pathElement.current.removeEventListener('click', this.props.onClick); + } + } + + pathElement = React.createRef() + + render() { + return ; + } +} + +export default class TransitionGroup extends Component<{ + transitions: Array<{ + from: Target, + to: Target, + }>, + color: string, + selected: boolean, + onClick: Function, + taskRefs: {| [taskname: string]: { current: Task } |}, + graph: Graph +}> { + static propTypes = { + transitions: PropTypes.arrayOf( + PropTypes.shape({ + from: PropTypes.object.isRequired, + to: PropTypes.object.isRequired, + }) + ), + taskRefs: PropTypes.object.isRequired, + graph: PropTypes.object.isRequired, + selected: PropTypes.bool, + onClick: PropTypes.func, + } + + uniqId = 'some' + + style = style + + makePath(from: Target, to: Target) { + const { taskRefs, graph } = this.props; + if (!from.task || !to.task) { + return ''; + } + if (!get(taskRefs, [ from.task.name, 'current' ]) || !get(taskRefs, [ to.task.name, 'current' ])) { + return ''; + } + + const fromAnchor = ANCHORS[from.anchor]; + const fromControl = CONTROLS[from.anchor]; + const fromCoords = new Vector(from.task.coords).add(origin); + const fromSize = new Vector(from.task && from.task.size); + + const fromPoint = fromSize.multiply(fromAnchor).add(fromCoords); + const fromOrbit = fromControl.multiply(ORBIT_DISTANCE).add(fromPoint); + const path = new Path(fromPoint, 'down'); + + const toAnchor = ANCHORS[to.anchor]; + const toControl = CONTROLS[to.anchor]; + const toCoords = new Vector((to.task || {}).coords).add(origin); + const toSize = new Vector((to.task || {}).size); + + const arrowCompensation = toControl.multiply(10); + const toPoint = toSize.multiply(toAnchor).add(toCoords).add(arrowCompensation); + const toOrbit = toControl.multiply(ORBIT_DISTANCE).add(toPoint); + + const lagrangePoint = toOrbit.subtract(fromOrbit).divide(2); + const fromLagrange = lagrangePoint.multiply(lagrangePoint.y > 0 ? VERTICAL_MASK : HORISONTAL_MASK).add(fromOrbit); + const toLagrange = lagrangePoint.multiply(lagrangePoint.y > 0 ? VERTICAL_MASK : HORISONTAL_MASK).multiply(-1).add(toOrbit); + + + + // now for the A* algorithm + if(graph) { + const pathElements = astar.search( + graph, + fromPoint.add(new Vector(0, ORBIT_DISTANCE)), + toPoint.add(new Vector(0, -ORBIT_DISTANCE/2)) + ); + + if(pathElements.length > 0) { + pathElements.forEach(nextPoint => { + path.moveTo(new Vector(nextPoint.x, nextPoint.y)); + }); + //path.moveTo(toPoint); <-- this is now handled by astar.search + } + else { + // If the pathfinder can't find a path, use this as a fallback + [ fromOrbit, fromLagrange, toLagrange, toOrbit, toPoint ].forEach(path.moveTo.bind(path)); + } + } + + return path.toString(); + } + + render(): Array { + const { color, transitions, selected, taskRefs, ...props } = this.props; //eslint-disable-line no-unused-vars + + const transitionPaths = transitions + .map(({ from, to }) => ({ + from: from.task.name, + to: to.task.name, + path: this.makePath(from, to), + })); + + const markers = ( + + {selected && [ + + + , + + + , + ]} + + + + + ); + + const activeBorders = transitionPaths.map(({ from, to, path }) => ( + + )); + + const actives = transitionPaths.map(({ from, to, path }) => ( + + )); + + const paths = transitionPaths.map(({ from, to, path }) => ( + + )); + + return [ markers ] + .concat(activeBorders) + .concat(actives) + .concat(paths); + + } +} diff --git a/modules/st2flow-canvas/vector.js b/modules/st2flow-canvas/vector.js new file mode 100644 index 000000000..357da2f69 --- /dev/null +++ b/modules/st2flow-canvas/vector.js @@ -0,0 +1,240 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +//@flow + +// Copyright (C) 2011 by Evan Wallace +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), 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 to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// 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. + +// Provides a simple 3D vector class. Vector operations can be done using member +// functions, which return new vectors, or static functions, which reuse +// existing vectors to avoid generating garbage. +export default class Vector { + x: number + y: number + z: number + + constructor(px: any, py?: number, pz?: number) { + const { x = px, y = py, z = pz } = px; + + this.x = x || 0; + this.y = y || 0; + this.z = z || 0; + } + + transform(fn: Function, v?: Vector | number) { + if (v instanceof Vector) { + return new Vector(fn(this.x, v.x), fn(this.y, v.y), fn(this.z, v.z)); + } + return new Vector(fn(this.x, v), fn(this.y, v), fn(this.z, v)); + } + + // ### Instance Methods + // The methods `add()`, `subtract()`, `multiply()`, and `divide()` can all + // take either a vector or a number as an argument. + negative() { + return this.transform(i => -i); + } + + add(v: Vector | number) { + return this.transform((i, v) => i + v, v); + } + + subtract(v: Vector | number) { + return this.transform((i, v) => i - v, v); + } + + multiply(v: Vector | number) { + return this.transform((i, v) => i * v, v); + } + + divide(v: Vector | number) { + return this.transform((i, v) => i / v, v); + } + + equals(v: Vector) { + return this.x === v.x && this.y === v.y && this.z === v.z; + } + + dot(v: Vector) { + return this.x * v.x + this.y * v.y + this.z * v.z; + } + + cross(v: Vector) { + return new Vector( + this.y * v.z - this.z * v.y, + this.z * v.x - this.x * v.z, + this.x * v.y - this.y * v.x + ); + } + + length() { + return Math.sqrt(this.dot(this)); + } + + unit() { + return this.divide(this.length()); + } + + min() { + return Math.min(Math.min(this.x, this.y), this.z); + } + + max() { + return Math.max(Math.max(this.x, this.y), this.z); + } + + toAngles() { + return { + theta: Math.atan2(this.z, this.x), + phi: Math.asin(this.y / this.length()), + }; + } + + angleTo(a: Vector) { + return Math.acos(this.dot(a) / (this.length() * a.length())); + } + + toArray(n: number): Array { + return [ this.x, this.y, this.z ].slice(0, n || 3); + } + + clone() { + return new Vector(this.x, this.y, this.z); + } + + init(x: number, y: number, z: number) { + this.x = x; + this.y = y; + this.z = z; + return this; + } + + // ### Static Methods + // `Vector.randomDirection()` returns a vector with a length of 1 and a + // statistically uniform direction. `Vector.lerp()` performs linear + // interpolation between two vectors. + static negative(a: Vector, b: Vector) { + b.x = -a.x; b.y = -a.y; b.z = -a.z; + return b; + } + + static add(a: Vector, b: Vector | number, c: Object = {}) { + if (b instanceof Vector) { + c.x = a.x + b.x; + c.y = a.y + b.y; + c.z = a.z + b.z; + } + else { + c.x = a.x + b; + c.y = a.y + b; + c.z = a.z + b; + } + return c; + } + + static subtract(a: Vector, b: Vector | number, c: Object = {}) { + if (b instanceof Vector) { + c.x = a.x - b.x; + c.y = a.y - b.y; + c.z = a.z - b.z; + } + else { + c.x = a.x - b; + c.y = a.y - b; + c.z = a.z - b; + } + return c; + } + + static multiply(a: Vector, b: Vector | number, c: Object = {}) { + if (b instanceof Vector) { + c.x = a.x * b.x; + c.y = a.y * b.y; + c.z = a.z * b.z; + } + else { + c.x = a.x * b; + c.y = a.y * b; + c.z = a.z * b; + } + return c; + } + + static divide(a: Vector, b: Vector | number, c: Object = {}) { + if (b instanceof Vector) { + c.x = a.x / b.x; + c.y = a.y / b.y; + c.z = a.z / b.z; + } + else { + c.x = a.x / b; + c.y = a.y / b; + c.z = a.z / b; + } + return c; + } + + static cross(a: Vector, b: Vector, c: Object = {}) { + c.x = a.y * b.z - a.z * b.y; + c.y = a.z * b.x - a.x * b.z; + c.z = a.x * b.y - a.y * b.x; + return c; + } + + static unit(a: Vector, b: Object = {}) { + const length = a.length(); + b.x = a.x / length; + b.y = a.y / length; + b.z = a.z / length; + return b; + } + + static fromAngles(theta: number, phi: number) { + return new Vector(Math.cos(theta) * Math.cos(phi), Math.sin(phi), Math.sin(theta) * Math.cos(phi)); + } + + static randomDirection() { + return Vector.fromAngles(Math.random() * Math.PI * 2, Math.asin(Math.random() * 2 - 1)); + } + + static min(a: Vector, b: Vector) { + return new Vector(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.min(a.z, b.z)); + } + + static max(a: Vector, b: Vector) { + return new Vector(Math.max(a.x, b.x), Math.max(a.y, b.y), Math.max(a.z, b.z)); + } + + static lerp(a: Vector, b: Vector, fraction: number) { + return b.subtract(a).multiply(fraction).add(a); + } + + static fromArray(a: Array) { + return new Vector(a[0], a[1], a[2]); + } + + static angleBetween(a: Vector, b: Vector) { + return a.angleTo(b); + } +} diff --git a/modules/st2flow-details/index.js b/modules/st2flow-details/index.js new file mode 100644 index 000000000..65c9552de --- /dev/null +++ b/modules/st2flow-details/index.js @@ -0,0 +1,141 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +//@flow + +import type { TaskInterface } from '@stackstorm/st2flow-model/interfaces'; + +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { PropTypes } from 'prop-types'; +import cx from 'classnames'; + +import Editor from '@stackstorm/st2flow-editor'; +import { Toolbar, ToolbarButton } from './layout'; + +import Meta from './meta-panel'; +import TaskDetails from './task-details'; +import TaskList from './task-list'; + +import style from './style.css'; + +@connect( + ({ flow: { metaSource }}) => ({ + source: metaSource, + }), + (dispatch, source) => ({ + onEditorChange: source => dispatch({ + type: 'META_ISSUE_COMMAND', + command: 'applyDelta', + args: [ null, source ], + }), + }) +) +class MetaEditor extends Editor {} + +@connect( + ({ flow: { workflowSource }}) => ({ + source: workflowSource, + }), + (dispatch, source) => ({ + onEditorChange: source => dispatch({ + type: 'MODEL_ISSUE_COMMAND', + command: 'applyDelta', + args: [ null, source ], + }), + }) +) +class WorkflowEditor extends Editor {} + +@connect( + ({ flow: { actions, navigation }}) => ({ actions, navigation }), + (dispatch) => ({ + navigate: (navigation) => dispatch({ + type: 'CHANGE_NAVIGATION', + navigation, + }), + }) +) +export default class Details extends Component<{ + className?: string, + + navigation: Object, + navigate: Function, + + actions: Array, +}> { + static propTypes = { + className: PropTypes.string, + + navigation: PropTypes.object, + navigate: PropTypes.func, + + actions: PropTypes.array, + } + + sections = [{ + title: 'metadata', + className: 'icon-gear', + }, { + title: 'execution', + className: 'icon-lan', + }] + + style = style + + handleTaskSelect = (task: TaskInterface) => { + this.props.navigate({ toTasks: undefined, task: task.name }); + } + + handleBack = () => { + this.props.navigate({ toTasks: undefined, task: undefined }); + } + + render() { + const { actions, navigation, navigate } = this.props; + + const { type = 'metadata', asCode } = navigation; + + return ( +
+ + { + this.sections.map(section => { + return ( + navigate({ type: section.title, section: undefined })} + /> + ); + }) + } + navigate({ asCode: !asCode })} /> + + { + type === 'metadata' && ( + asCode + && + // $FlowFixMe Model is populated via decorator + || + ) + } + { + type === 'execution' && ( + asCode + && + || navigation.task + // $FlowFixMe ^^ + && + // $FlowFixMe ^^ + || + ) + } +
+ ); + } +} diff --git a/modules/st2flow-details/layout.js b/modules/st2flow-details/layout.js new file mode 100644 index 000000000..7dff6e46c --- /dev/null +++ b/modules/st2flow-details/layout.js @@ -0,0 +1,82 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +//@flow + +import type { Node } from 'react'; + +import React, { Component } from 'react'; +import { PropTypes } from 'prop-types'; +import cx from 'classnames'; + +import style from './style.css'; + +export class Toolbar extends Component<{ + secondary?: bool, + children?: Node, +}> { + static propTypes = { + secondary: PropTypes.bool, + children: PropTypes.node, + } + + style = style + + render() { + const { secondary } = this.props; + return ( +
+ { this.props.children } +
+ ); + } +} + +export class ToolbarButton extends Component<{ + className?: string, + children?: Node, + stretch?: bool, + selected?: bool, +}> { + static propTypes = { + className: PropTypes.string, + children: PropTypes.node, + stretch: PropTypes.bool, + selected: PropTypes.bool, + } + + style = style + + render() { + const { className, stretch, selected, ...props } = this.props; + return ( +
+ { this.props.children } +
+ ); + } +} + +export class Panel extends Component<{ + className?: string, + children?: Node, +}> { + static propTypes = { + className: PropTypes.string, + children: PropTypes.node, + } + + style = style + + render() { + const { className } = this.props; + return ( +
+ { this.props.children } +
+ ); + } +} diff --git a/modules/st2flow-details/meta-panel.js b/modules/st2flow-details/meta-panel.js new file mode 100644 index 000000000..47b0c2446 --- /dev/null +++ b/modules/st2flow-details/meta-panel.js @@ -0,0 +1,200 @@ +// Copyright 2020 Extreme Networks, Inc. +// +// Unauthorized copying of this file, via any medium is strictly +// prohibited. Proprietary and confidential. See the LICENSE file +// included with this work for details. + +//@flow + +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { PropTypes } from 'prop-types'; + +import BooleanField from '@stackstorm/module-auto-form/fields/boolean'; +import StringField from '@stackstorm/module-auto-form/fields/string'; +import EnumField from '@stackstorm/module-auto-form/fields/enum'; +import Button from '@stackstorm/module-forms/button.component'; +import { isJinja } from '@stackstorm/module-auto-form/fields/base'; + +import { Panel, Toolbar, ToolbarButton } from './layout'; +import Parameters from './parameters-panel'; +import { StringPropertiesPanel } from './string-properties'; + +const default_runner_type = 'orquesta'; + +@connect( + ({ flow: { pack, actions, navigation, meta, input, vars }}) => ({ pack, actions, navigation, meta, input, vars }), + (dispatch) => ({ + navigate: (navigation) => dispatch({ + type: 'CHANGE_NAVIGATION', + navigation, + }), + setMeta: (field, value) => { + try{ + dispatch({ + type: 'META_ISSUE_COMMAND', + command: 'set', + args: [ field, value ], + }); + } + catch(error) { + dispatch({ + type: 'PUSH_ERROR', + error, + }); + } + }, + setVars: (value) => { + dispatch({ + type: 'MODEL_ISSUE_COMMAND', + command: 'setVars', + args: [ value ], + }); + }, + setPack: (pack) => { + dispatch({ + type: 'SET_PACK', + pack, + }); + try{ + dispatch({ + type: 'META_ISSUE_COMMAND', + command: 'set', + args: [ 'pack', pack ], + }); + } + catch(error) { + dispatch({ + type: 'PUSH_ERROR', + error, + }); + } + }, + }) +) +export default class Meta extends Component<{ + pack: string, + setPack: Function, + + meta: Object, + setMeta: Function, + + navigation: Object, + navigate: Function, + + actions: Array, + vars: Array, + setVars: Function, +}> { + static propTypes = { + pack: PropTypes.string, + setPack: PropTypes.func, + + meta: PropTypes.object, + setMeta: PropTypes.func, + + navigation: PropTypes.object, + navigate: PropTypes.func, + + actions: PropTypes.array, + vars: PropTypes.array, + setVars: PropTypes.func, + } + + componentDidUpdate() { + const { meta, setMeta } = this.props; + + if (!meta.runner_type) { + setMeta('runner_type', default_runner_type); + } + } + + handleSectionSwitch(section: string) { + this.props.navigate({ section }); + } + + handleVarsChange(publish: Array<{}>) { + const { setVars } = this.props; + const val = (publish ? publish.slice(0) : []).map(kv => { + const key = Object.keys(kv)[0]; + const val = kv[key]; + if (val === '') { + return { [key]: null }; + } + else if (isJinja(val)) { + return kv; + } + else { + try { + const parsedVal = JSON.parse(val); + return { [key]: typeof parsedVal === 'object' ? parsedVal : val }; + } + catch(e) { + return kv; + } + } + }); + + // Make sure to mutate the copy + setVars(val); + } + + addVar() { + const { setVars, vars } = this.props; + const newVal = { key: '' }; + setVars((vars || []).concat([ newVal ])); + } + + render() { + const { pack, setPack, meta, setMeta, navigation, actions, vars } = this.props; + const { section = 'meta' } = navigation; + + const stringVars = vars && vars.map(kv => { + const key = Object.keys(kv)[0]; + const val = kv[key]; + if(typeof val === 'object') { + // nulls and objects both here. + return { [key]: val ? JSON.stringify(val, null, 2) : '' }; + } + else { + return { [key]: val.toString() }; + } + }); + + const packs = [ ...new Set(actions.map(a => a.pack)).add(pack) ]; + + return ([ + + this.handleSectionSwitch('meta')} selected={section === 'meta'}>Meta + this.handleSectionSwitch('parameters')} selected={section === 'parameters'}>Parameters + this.handleSectionSwitch('vars')} selected={section === 'vars'}>Vars + , + section === 'meta' && ( + + setMeta('runner_type', v)} /> + setPack(v)} /> + setMeta('name', v || '')} /> + setMeta('description', v)} /> + setMeta('enabled', v)} /> + setMeta('entry_point', v || '')} /> + + ), + section === 'parameters' && ( + //$FlowFixMe + + ), + section === 'vars' && ( + + this.handleVarsChange(val)} + /> + { vars && vars.length > 0 &&
} +