From b59a239a82aa81c58c94884efccb1c497b702baa Mon Sep 17 00:00:00 2001 From: Junyi Date: Sun, 27 Mar 2022 15:51:48 +0800 Subject: [PATCH] Feat: client base entry of plugin workflow (#225) * feat(plugin-workflow): add base client entry for workflow * fix(plugin-workflow): workflow table * feat: custom ui route (#227) * feat(plugin-workflow): add execution table * refactor(actions): expose utils of actions * fix(repo): move ".editorconfig" to root * feat(plugin-workflow): base workflow management able to add node * fix(plugin-workflow): fix empty workflow * feat(plugin-workfow): add flow canvas and style * fix(plugin-workflow): fix type for building * feat(plugin-workflow): fix add node in branch and add branch ui * feat(plugin-workflow): add calculation structure to condition config * fix(plugin-workflow): fix branch line style * feat(plugin-workflow): remove node with sub-branch * feat(plugin-workflow): add parallel node type * fix(plugin-workflow): fix dependency in client Co-authored-by: chenos --- packages/app/.editorconfig => .editorconfig | 0 packages/actions/src/actions/add.ts | 2 +- packages/actions/src/actions/create.ts | 2 +- packages/actions/src/actions/destroy.ts | 2 +- packages/actions/src/actions/get.ts | 2 +- packages/actions/src/actions/list.ts | 2 +- packages/actions/src/actions/move.ts | 2 +- packages/actions/src/actions/remove.ts | 2 +- packages/actions/src/actions/set.ts | 2 +- packages/actions/src/actions/toggle.ts | 2 +- packages/actions/src/actions/update.ts | 2 +- packages/actions/src/index.ts | 2 + packages/actions/src/{actions => }/utils.ts | 2 +- packages/api/src/index.ts | 1 + packages/app/src/pages/index.tsx | 5 + packages/client/package.json | 1 + packages/client/src/index.tsx | 2 +- .../route-switch/antd/admin-layout/index.tsx | 1 + .../workflow/ExecutionResourceProvider.tsx | 24 ++ .../client/src/workflow/WorkflowCanvas.tsx | 149 +++++++++ packages/client/src/workflow/WorkflowLink.tsx | 12 + packages/client/src/workflow/WorkflowPage.tsx | 107 +++++++ .../client/src/workflow/WorkflowShortcut.tsx | 59 ++++ .../client/src/workflow/WorkflowTable.tsx | 17 + packages/client/src/workflow/calculators.tsx | 79 +++++ packages/client/src/workflow/index.tsx | 5 + .../client/src/workflow/nodes/condition.tsx | 234 ++++++++++++++ packages/client/src/workflow/nodes/index.tsx | 187 +++++++++++ .../client/src/workflow/nodes/parallel.tsx | 109 +++++++ packages/client/src/workflow/nodes/query.tsx | 42 +++ .../client/src/workflow/schemas/executions.ts | 128 ++++++++ .../client/src/workflow/schemas/workflows.ts | 303 ++++++++++++++++++ packages/client/src/workflow/style.tsx | 133 ++++++++ .../client/src/workflow/triggers/index.tsx | 94 ++++++ .../client/src/workflow/triggers/model.tsx | 36 +++ .../plugin-ui-routes-storage/src/index.ts | 2 +- packages/plugin-workflow/package.json | 1 + .../__tests__/instructions/condition.test.ts | 4 +- .../plugin-workflow/src/actions/flow_nodes.ts | 145 +++++++++ packages/plugin-workflow/src/actions/index.ts | 14 + .../plugin-workflow/src/calculators/index.ts | 6 + .../src/collections/flow_nodes.ts | 9 +- .../src/collections/workflows.ts | 3 +- packages/plugin-workflow/src/index.ts | 6 + .../src/instructions/condition.ts | 3 +- packages/plugin-workflow/src/server.ts | 7 +- .../plugin-workflow/src/triggers/model.ts | 14 +- packages/utils/src/registry.ts | 4 + 48 files changed, 1937 insertions(+), 33 deletions(-) rename packages/app/.editorconfig => .editorconfig (100%) rename packages/actions/src/{actions => }/utils.ts (95%) create mode 100644 packages/client/src/workflow/ExecutionResourceProvider.tsx create mode 100644 packages/client/src/workflow/WorkflowCanvas.tsx create mode 100644 packages/client/src/workflow/WorkflowLink.tsx create mode 100644 packages/client/src/workflow/WorkflowPage.tsx create mode 100644 packages/client/src/workflow/WorkflowShortcut.tsx create mode 100644 packages/client/src/workflow/WorkflowTable.tsx create mode 100644 packages/client/src/workflow/calculators.tsx create mode 100644 packages/client/src/workflow/index.tsx create mode 100644 packages/client/src/workflow/nodes/condition.tsx create mode 100644 packages/client/src/workflow/nodes/index.tsx create mode 100644 packages/client/src/workflow/nodes/parallel.tsx create mode 100644 packages/client/src/workflow/nodes/query.tsx create mode 100644 packages/client/src/workflow/schemas/executions.ts create mode 100644 packages/client/src/workflow/schemas/workflows.ts create mode 100644 packages/client/src/workflow/style.tsx create mode 100644 packages/client/src/workflow/triggers/index.tsx create mode 100644 packages/client/src/workflow/triggers/model.tsx create mode 100644 packages/plugin-workflow/src/actions/flow_nodes.ts create mode 100644 packages/plugin-workflow/src/actions/index.ts create mode 100644 packages/plugin-workflow/src/index.ts diff --git a/packages/app/.editorconfig b/.editorconfig similarity index 100% rename from packages/app/.editorconfig rename to .editorconfig diff --git a/packages/actions/src/actions/add.ts b/packages/actions/src/actions/add.ts index 2c381c89d..28adf3283 100644 --- a/packages/actions/src/actions/add.ts +++ b/packages/actions/src/actions/add.ts @@ -1,5 +1,5 @@ import { Context } from '..'; -import { getRepositoryFromParams } from './utils'; +import { getRepositoryFromParams } from '../utils'; import { BelongsToManyRepository, MultipleRelationRepository, HasManyRepository } from '@nocobase/database'; export async function add(ctx: Context, next) { diff --git a/packages/actions/src/actions/create.ts b/packages/actions/src/actions/create.ts index 95028c2bc..2a08776de 100644 --- a/packages/actions/src/actions/create.ts +++ b/packages/actions/src/actions/create.ts @@ -1,5 +1,5 @@ import { Context } from '..'; -import { getRepositoryFromParams } from './utils'; +import { getRepositoryFromParams } from '../utils'; export async function create(ctx: Context, next) { const repository = getRepositoryFromParams(ctx); diff --git a/packages/actions/src/actions/destroy.ts b/packages/actions/src/actions/destroy.ts index 09a9ac7f1..1abf86ced 100644 --- a/packages/actions/src/actions/destroy.ts +++ b/packages/actions/src/actions/destroy.ts @@ -1,5 +1,5 @@ import { Context } from '..'; -import { getRepositoryFromParams } from './utils'; +import { getRepositoryFromParams } from '../utils'; export async function destroy(ctx: Context, next) { const repository = getRepositoryFromParams(ctx); diff --git a/packages/actions/src/actions/get.ts b/packages/actions/src/actions/get.ts index 5fa807410..0f9f6559a 100644 --- a/packages/actions/src/actions/get.ts +++ b/packages/actions/src/actions/get.ts @@ -1,5 +1,5 @@ import { Context } from '..'; -import { getRepositoryFromParams } from './utils'; +import { getRepositoryFromParams } from '../utils'; export async function get(ctx: Context, next) { const repository = getRepositoryFromParams(ctx); diff --git a/packages/actions/src/actions/list.ts b/packages/actions/src/actions/list.ts index f16768d6a..409a90b68 100644 --- a/packages/actions/src/actions/list.ts +++ b/packages/actions/src/actions/list.ts @@ -1,6 +1,6 @@ import { ActionParams } from '@nocobase/resourcer'; import { Context } from '..'; -import { getRepositoryFromParams } from './utils'; +import { getRepositoryFromParams } from '../utils'; export const DEFAULT_PAGE = 1; export const DEFAULT_PER_PAGE = 20; diff --git a/packages/actions/src/actions/move.ts b/packages/actions/src/actions/move.ts index 6875787fc..28b131028 100644 --- a/packages/actions/src/actions/move.ts +++ b/packages/actions/src/actions/move.ts @@ -2,7 +2,7 @@ import { Op, Model } from 'sequelize'; import { Context } from '..'; import { Collection, TargetKey, Repository, SortField } from '@nocobase/database'; -import { getRepositoryFromParams } from './utils'; +import { getRepositoryFromParams } from '../utils'; export async function move(ctx: Context, next) { const repository = getRepositoryFromParams(ctx); diff --git a/packages/actions/src/actions/remove.ts b/packages/actions/src/actions/remove.ts index d52c4301c..8faadc843 100644 --- a/packages/actions/src/actions/remove.ts +++ b/packages/actions/src/actions/remove.ts @@ -1,2 +1,2 @@ -import { RelationRepositoryActionBuilder } from './utils'; +import { RelationRepositoryActionBuilder } from '../utils'; export const remove = RelationRepositoryActionBuilder('remove'); diff --git a/packages/actions/src/actions/set.ts b/packages/actions/src/actions/set.ts index 700c2b908..3fcab6813 100644 --- a/packages/actions/src/actions/set.ts +++ b/packages/actions/src/actions/set.ts @@ -1,2 +1,2 @@ -import { RelationRepositoryActionBuilder } from './utils'; +import { RelationRepositoryActionBuilder } from '../utils'; export const set = RelationRepositoryActionBuilder('set'); diff --git a/packages/actions/src/actions/toggle.ts b/packages/actions/src/actions/toggle.ts index 192e17c1f..5ee54bb74 100644 --- a/packages/actions/src/actions/toggle.ts +++ b/packages/actions/src/actions/toggle.ts @@ -1,5 +1,5 @@ import { Context } from '..'; -import { getRepositoryFromParams } from './utils'; +import { getRepositoryFromParams } from '../utils'; import { BelongsToManyRepository } from '@nocobase/database'; export async function toggle(ctx: Context, next) { diff --git a/packages/actions/src/actions/update.ts b/packages/actions/src/actions/update.ts index 52e011635..77ddae7e2 100644 --- a/packages/actions/src/actions/update.ts +++ b/packages/actions/src/actions/update.ts @@ -1,5 +1,5 @@ import { Context } from '..'; -import { getRepositoryFromParams } from './utils'; +import { getRepositoryFromParams } from '../utils'; export async function update(ctx: Context, next) { const repository = getRepositoryFromParams(ctx); diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index 22aa2c3d3..3af4600ae 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -4,6 +4,8 @@ import { Action } from '@nocobase/resourcer'; import lodash from 'lodash'; import * as actions from './actions'; +export * as utils from './utils'; + export type Next = () => Promise; export interface Context extends Koa.Context { diff --git a/packages/actions/src/actions/utils.ts b/packages/actions/src/utils.ts similarity index 95% rename from packages/actions/src/actions/utils.ts rename to packages/actions/src/utils.ts index 4f91547ba..a52470a4b 100644 --- a/packages/actions/src/actions/utils.ts +++ b/packages/actions/src/utils.ts @@ -1,5 +1,5 @@ import { MultipleRelationRepository, Repository } from '@nocobase/database'; -import { Context } from '..'; +import { Context } from '.'; export function getRepositoryFromParams(ctx: Context) { const { resourceName, resourceOf } = ctx.action; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a652e2a0e..fff87164d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -48,6 +48,7 @@ const plugins = [ '@nocobase/plugin-users', '@nocobase/plugin-acl', '@nocobase/plugin-china-region', + '@nocobase/plugin-workflow', ]; for (const plugin of plugins) { diff --git a/packages/app/src/pages/index.tsx b/packages/app/src/pages/index.tsx index 0332f8a56..050bd02b0 100644 --- a/packages/app/src/pages/index.tsx +++ b/packages/app/src/pages/index.tsx @@ -26,6 +26,9 @@ import { SignupPage, SystemSettingsProvider, SystemSettingsShortcut, + useRequest, + WorkflowPage, + WorkflowShortcut, useRoutes } from '@nocobase/client'; import { notification } from 'antd'; @@ -62,6 +65,7 @@ const providers = [ RouteSchemaComponent, SigninPage, SignupPage, + WorkflowPage, BlockTemplatePage, BlockTemplateDetails, }, @@ -75,6 +79,7 @@ const providers = [ ACLShortcut, DesignableSwitch, CollectionManagerShortcut, + WorkflowShortcut, SystemSettingsShortcut, SchemaTemplateShortcut, }, diff --git a/packages/client/package.json b/packages/client/package.json index df9cccaef..d8b3d6e43 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -23,6 +23,7 @@ "@formily/antd": "^2.0.15", "@formily/core": "^2.0.15", "@formily/react": "^2.0.15", + "@nocobase/utils": "0.6.0-alpha.0", "ahooks": "^3.0.5", "antd": "^4.18.9", "axios": "^0.24.0", diff --git a/packages/client/src/index.tsx b/packages/client/src/index.tsx index 51eb7f64b..acdfb4821 100644 --- a/packages/client/src/index.tsx +++ b/packages/client/src/index.tsx @@ -21,4 +21,4 @@ export * from './schema-templates'; export * from './settings-form'; export * from './system-settings'; export * from './user'; - +export * from './workflow'; diff --git a/packages/client/src/route-switch/antd/admin-layout/index.tsx b/packages/client/src/route-switch/antd/admin-layout/index.tsx index 0d0f07961..e6264570b 100644 --- a/packages/client/src/route-switch/antd/admin-layout/index.tsx +++ b/packages/client/src/route-switch/antd/admin-layout/index.tsx @@ -95,6 +95,7 @@ const InternalAdminLayout = (props: any) => { { component: 'DesignableSwitch', pin: true }, { component: 'CollectionManagerShortcut', pin: true }, { component: 'ACLShortcut', pin: true }, + { component: 'WorkflowShortcut', pin: true }, { component: 'SchemaTemplateShortcut', pin: true }, { component: 'SystemSettingsShortcut' }, ]} diff --git a/packages/client/src/workflow/ExecutionResourceProvider.tsx b/packages/client/src/workflow/ExecutionResourceProvider.tsx new file mode 100644 index 000000000..f2f08323a --- /dev/null +++ b/packages/client/src/workflow/ExecutionResourceProvider.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { ResourceActionProvider, useRecord } from ".."; + +export const ExecutionResourceProvider = ({ request, ...others }) => { + const workflow = useRecord(); + const props = { + ...others, + request: { + ...request, + params: { + ...request?.params, + filter: { + ...(request?.params?.filter), + workflowId: workflow.id + } + } + }, + workflow + }; + + return ( + + ); +} diff --git a/packages/client/src/workflow/WorkflowCanvas.tsx b/packages/client/src/workflow/WorkflowCanvas.tsx new file mode 100644 index 000000000..86c091910 --- /dev/null +++ b/packages/client/src/workflow/WorkflowCanvas.tsx @@ -0,0 +1,149 @@ +import React, { useContext } from 'react'; +import { Dropdown, Menu, Button } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { cx } from '@emotion/css'; +import { addButtonClass, branchBlockClass, branchClass, nodeBlockClass, nodeCardClass, nodeHeaderClass, nodeTitleClass } from './style'; + +import { + useCollection, + useResourceActionContext +} from '..'; +import { Instruction, instructions, Node } from './nodes'; + + + + +function makeNodes(nodes): void { + const nodesMap = new Map(); + nodes.forEach(item => nodesMap.set(item.id, item)); + for (let node of nodesMap.values()) { + if (node.upstreamId) { + node.upstream = nodesMap.get(node.upstreamId); + } + + if (node.downstreamId) { + node.downstream = nodesMap.get(node.downstreamId); + } + } +} + +const FlowContext = React.createContext(null); + +export function useFlowContext() { + return useContext(FlowContext); +} + +export function WorkflowCanvas() { + const { data, refresh, loading } = useResourceActionContext(); + + if (!data?.data && !loading) { + return
加载失败
; + } + + const { nodes = [] } = data?.data ?? {}; + + makeNodes(nodes); + + const entry = nodes.find(item => !item.upstream); + + return ( + +
+ +
+
结束
+
+ ); +} + +export function Branch({ + from = null, + entry = null, + branchIndex = null, + controller = null +}) { + const list = []; + for (let node = entry; node; node = node.downstream) { + list.push(node); + } + + return ( +
+
+ {controller} + +
+ {list.map(item => { + return ( +
+ + +
+ ); + })} +
+
+ ); +} + +// TODO(bug): useless observable +// const instructionsList = observable(Array.from(instructions.getValues())); + +interface AddButtonProps { + upstream; + branchIndex?: number; +}; + +function AddButton({ upstream, branchIndex = null }: AddButtonProps) { + const { resource } = useCollection(); + const { data } = useResourceActionContext(); + const { onNodeAdded } = useFlowContext(); + + async function onCreate({ keyPath }) { + const type = keyPath.pop(); + const config = {}; + const [optionKey] = keyPath; + if (optionKey) { + const { value } = instructions.get(type).options.find(item => item.key === optionKey); + Object.assign(config, value); + } + + const { data: { data: node } } = await resource.create({ + values: { + type, + workflowId: data.data.id, + upstreamId: upstream?.id ?? null, + branchIndex, + config + } + }); + + onNodeAdded(node); + } + + return ( +
+ onCreate(ev)}> + {(Array.from(instructions.getValues()) as Instruction[]).map(item => item.options + ? ( + + {item.options.map(option => ( + {option.label} + ))} + + ) + : ( + {item.title} + ))} + + }> +
+ ); +}; diff --git a/packages/client/src/workflow/WorkflowLink.tsx b/packages/client/src/workflow/WorkflowLink.tsx new file mode 100644 index 000000000..220594725 --- /dev/null +++ b/packages/client/src/workflow/WorkflowLink.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useActionContext, useRecord } from '..'; + + +export const WorkflowLink = () => { + const { id } = useRecord(); + const { setVisible } = useActionContext(); + return ( + setVisible(false)}>流程配置 + ); +} diff --git a/packages/client/src/workflow/WorkflowPage.tsx b/packages/client/src/workflow/WorkflowPage.tsx new file mode 100644 index 000000000..6c15977ec --- /dev/null +++ b/packages/client/src/workflow/WorkflowPage.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { useRouteMatch } from 'react-router-dom'; +import { ISchema } from '@formily/react'; +import { cx } from '@emotion/css'; +import { SchemaComponent } from '..'; +import { TriggerConfig } from './triggers'; +import { WorkflowCanvas } from './WorkflowCanvas' +import { workflowPageClass } from './style'; + + + +const workflowCollection = { + name: 'workflow', + fields: [ + { + type: 'string', + name: 'title', + interface: 'input', + uiSchema: { + title: '流程名称', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + } + ] +}; + +export const WorkflowPage = () => { + const { params } = useRouteMatch(); + + return ( +
+
+ +
+
+ ); +}; diff --git a/packages/client/src/workflow/WorkflowShortcut.tsx b/packages/client/src/workflow/WorkflowShortcut.tsx new file mode 100644 index 000000000..0dd64a986 --- /dev/null +++ b/packages/client/src/workflow/WorkflowShortcut.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; +import { PartitionOutlined } from '@ant-design/icons'; +import { ISchema, useForm } from '@formily/react'; +import { uid } from '@formily/shared'; +import { PluginManager } from '../plugin-manager'; +import { ActionContext, SchemaComponent, useActionContext } from '../schema-component'; +import { WorkflowTable } from './WorkflowTable'; + +const useCloseAction = () => { + const { setVisible } = useActionContext(); + const form = useForm(); + return { + async run() { + setVisible(false); + form.submit((values) => { + console.log(values); + }); + }, + }; +}; + +const schema: ISchema = { + type: 'object', + properties: { + [uid()]: { + 'x-component': 'Action.Drawer', + type: 'void', + title: '工作流', + properties: { + main: { + type: 'void', + 'x-component': 'WorkflowTable', + }, + }, + }, + }, +}; + +export const WorkflowShortcut = () => { + const [visible, setVisible] = useState(false); + return ( + + } + title={'工作流'} + onClick={() => { + setVisible(true); + }} + /> + + + ); +}; diff --git a/packages/client/src/workflow/WorkflowTable.tsx b/packages/client/src/workflow/WorkflowTable.tsx new file mode 100644 index 000000000..c86661267 --- /dev/null +++ b/packages/client/src/workflow/WorkflowTable.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { SchemaComponent } from '../schema-component'; +import { WorkflowLink, WorkflowPage, ExecutionResourceProvider } from '.'; +import { workflowSchema } from './schemas/workflows'; + +export const WorkflowTable = () => { + return ( + + ); +}; diff --git a/packages/client/src/workflow/calculators.tsx b/packages/client/src/workflow/calculators.tsx new file mode 100644 index 000000000..a4096948d --- /dev/null +++ b/packages/client/src/workflow/calculators.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { Input, Select } from "antd"; +import { css } from "@emotion/css"; + +import { useNodeContext } from "./nodes"; + + + +export const calculators = [ + { value: 'equal', name: '等于' }, + { value: 'notEqual', name: '不等于' } +]; + +const JT_VALUE_RE = /^\s*\{\{([\s\S]*)\}\}\s*$/; + +function JobSelect({ value, onChange }) { + const node = useNodeContext(); + const stack = []; + for (let current = node.upstream; current; current = current.upstream) { + stack.push(current); + } + return ( + + ); +} + +function ContextSelect({ value, onChange }) { + return ( + + ); +} + +const VariableTypeComponent = { + constant({ onChange, ...props }) { + return onChange(ev.target.value)} /> + }, + job: JobSelect, + context: ContextSelect, + // calculation: Calculation +}; + +function OperandTypeSelect({ value, onChange }) { + return ( + + ); +} + +export function Operand({ value: operand = { type: 'constant', value: '' }, onChange }) { + const { value } = operand; + let { type = 'constant' } = operand; + if (typeof value === 'string') { + const matcher = value.match(JT_VALUE_RE); + + if (matcher) { + console.log(matcher); + } + } + + const VariableComponent = VariableTypeComponent[type]; + + return ( +
+ onChange({ ...operand, type: v })} /> + onChange({ ...operand, value: v })} /> +
+ ); +} diff --git a/packages/client/src/workflow/index.tsx b/packages/client/src/workflow/index.tsx new file mode 100644 index 000000000..b85a387af --- /dev/null +++ b/packages/client/src/workflow/index.tsx @@ -0,0 +1,5 @@ +export * from './WorkflowPage'; +export * from './WorkflowLink'; +export * from './WorkflowTable'; +export * from './WorkflowShortcut'; +export * from './ExecutionResourceProvider'; diff --git a/packages/client/src/workflow/nodes/condition.tsx b/packages/client/src/workflow/nodes/condition.tsx new file mode 100644 index 000000000..f540a0058 --- /dev/null +++ b/packages/client/src/workflow/nodes/condition.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import { css, cx } from "@emotion/css"; +import { Button, Select } from "antd"; +import { CloseCircleOutlined } from '@ant-design/icons'; +import { Trans } from "react-i18next"; + +import { NodeDefaultView } from "."; +import { Branch, useFlowContext } from "../WorkflowCanvas"; +import { branchBlockClass, nodeSubtreeClass } from "../style"; +import { calculators, Operand } from "../calculators"; +// import { SchemaComponent } from "../../schema-component"; + +function CalculationItem({ value, onChange, onRemove }) { + if (!value) { + return null; + } + + const { calculator, operands = [] } = value; + + return ( +
+ {value.group + ? ( + onChange({ ...value, group })} + /> + ) + : ( +
+ onChange({ ...value, operands: [v, operands[1]] })} /> + + onChange({ ...value, operands: [operands[0], v] })} /> +
+ ) + } +
+ ); +} + +function CalculationGroup({ value, onChange }) { + const { type = 'and', calculations = [] } = value; + + function onAddSingle() { + onChange({ + ...value, + calculations: [...calculations, { not: false, calculator: 'equal' }] + }); + } + + function onAddGroup() { + onChange({ + ...value, + calculations: [...calculations, { not: false, group: { type: 'and', calculations: [] } }] + }); + } + + function onRemove(i: number) { + calculations.splice(i, 1); + onChange({ ...value, calculations: [...calculations] }); + } + + function onItemChange(i: number, v) { + calculations.splice(i, 1, v); + + onChange({ ...value, calculations: [...calculations] }); + } + + return ( +
+
+ + {'Meet '} + + {' conditions in the group'} + +
+
+ {calculations.map((calculation, i) => ( + onRemove(i)} + /> + ))} +
+ +
+ ); +} + +function CalculationConfig({ value, onChange }) { + const rule = value && Object.keys(value).length + ? value + : { group: { type: 'and', calculations: [] } }; + return ( + onChange({ ...rule, group })} /> + ); +} + +export default { + title: '条件判断', + type: 'condition', + fieldset: { + rejectOnFalse: { + type: 'boolean', + name: 'rejectOnFalse', + title: '模式', + 'x-decorator': 'FormItem', + 'x-component': 'Radio.Group', + 'x-component-props': { + disabled: true, + }, + enum: [ + { value: true, label: '通行模式' }, + { value: false, label: '分支模式' }, + ], + }, + calculation: { + type: 'string', + name: 'calculation', + title: '条件配置', + 'x-decorator': 'FormItem', + 'x-component': 'CalculationConfig', + } + }, + view: { + + }, + options: [ + { label: '通行模式', key: 'rejectOnFalse', value: { rejectOnFalse: true } }, + { label: '分支模式', key: 'branch', value: { rejectOnFalse: false } } + ], + render(data) { + const { id, config: { calculation, rejectOnFalse } } = data; + const { nodes } = useFlowContext(); + const trueEntry = nodes.find(item => item.upstreamId === id && item.branchIndex === 1); + const falseEntry = nodes.find(item => item.upstreamId === id && item.branchIndex === 0); + return ( + + {rejectOnFalse ? null : ( +
+
* > .workflow-branch-lines{ + > button{ + display: none; + } + } + `)} + > + + +
+
+
+ )} + + ) + }, + components: { + CalculationConfig + } +}; diff --git a/packages/client/src/workflow/nodes/index.tsx b/packages/client/src/workflow/nodes/index.tsx new file mode 100644 index 000000000..0778150fa --- /dev/null +++ b/packages/client/src/workflow/nodes/index.tsx @@ -0,0 +1,187 @@ +import React, { useContext } from 'react'; +import { cx } from '@emotion/css'; +import { Button, Modal, Tag } from 'antd'; +import { DeleteOutlined } from '@ant-design/icons'; +import { ISchema, useForm } from '@formily/react'; + +import { Registry } from '@nocobase/utils'; + +import { SchemaComponent, useActionContext, useAPIClient, useCollection, useRequest, useResourceActionContext } from '../..'; +import { useFlowContext } from '../WorkflowCanvas'; + +import { nodeClass, nodeCardClass, nodeHeaderClass, nodeTitleClass } from '../style'; + +import query from './query'; +import condition from './condition'; +import parallel from './parallel'; + + +function useUpdateConfigAction() { + const form = useForm(); + const api = useAPIClient(); + const ctx = useActionContext(); + const { refresh } = useResourceActionContext(); + const data = useNodeContext(); + return { + async run() { + await api.resource('flow_nodes', data.id).update({ + filterByTk: data.id, + values: { + config: { + ...data.config, + ...form.values + } + }, + }); + ctx.setVisible(false); + refresh(); + }, + }; +}; + + + +export interface Instruction { + title: string; + type: string; + options?: { label: string; value: any; key: string }[]; + fieldset: { [key: string]: ISchema }; + view: ISchema; + scope?: { [key: string]: any }; + components?: { [key: string]: any } + render?(props): React.ReactElement +}; + +export const instructions = new Registry(); + +instructions.register('query', query); +instructions.register('condition', condition); +instructions.register('parallel', parallel); + +const NodeContext = React.createContext(null); + +export function useNodeContext() { + return useContext(NodeContext); +} + +export function Node({ data }) { + const instruction = instructions.get(data.type); + + if (instruction.render) { + return instruction.render(data); + } + + return ( + + ); +} + +export function RemoveButton() { + const { resource } = useCollection(); + const current = useNodeContext(); + const { nodes, onNodeRemoved } = useFlowContext(); + + async function onRemove() { + async function onOk() { + const { data: { data: node } } = await resource.destroy({ + filterByTk: current.id + }); + onNodeRemoved(node); + } + + if (!nodes.find(item => item.upstream === current && item.branchIndex != null)) { + return onOk(); + } + + Modal.confirm({ + title: '删除分支', + content: '节点包含分支,将删除其所有分支下的子节点,确定继续?', + onOk + }); + } + + return ( +
+ ) + : null + } + /> + ))} +
+
+ +
+ + + ) + } +}; diff --git a/packages/client/src/workflow/nodes/query.tsx b/packages/client/src/workflow/nodes/query.tsx new file mode 100644 index 000000000..ac122ec8d --- /dev/null +++ b/packages/client/src/workflow/nodes/query.tsx @@ -0,0 +1,42 @@ +import { action } from '@formily/reactive'; +import { t } from 'i18next'; +import { useCollectionManager } from '../../collection-manager'; + +export default { + title: '数据查询', + type: 'query', + fieldset: { + collection: { + type: 'string', + title: '数据表', + name: 'collection', + required: true, + 'x-reactions': ['{{useCollectionDataSource()}}'], + 'x-decorator': 'FormItem', + 'x-component': 'Select', + }, + multiple: { + type: 'boolean', + title: '多条数据', + name: 'multiple', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox' + } + }, + view: { + + }, + scope: { + useCollectionDataSource() { + return (field: any) => { + const { collections = [] } = useCollectionManager(); + action.bound((data: any) => { + field.dataSource = data.map(item => ({ + label: t(item.title), + value: item.name + })); + })(collections); + } + } + } +}; diff --git a/packages/client/src/workflow/schemas/executions.ts b/packages/client/src/workflow/schemas/executions.ts new file mode 100644 index 000000000..7b909f784 --- /dev/null +++ b/packages/client/src/workflow/schemas/executions.ts @@ -0,0 +1,128 @@ +import { ISchema } from '@formily/react'; + +const collection = { + name: 'executions', + fields: [ + { + type: 'number', + name: 'workflow', + interface: 'linkTo', + uiSchema: { + title: '所属工作流', + type: 'string', + } as ISchema, + }, + { + type: 'number', + name: 'status', + interface: 'select', + uiSchema: { + title: '执行状态', + type: 'string', + 'x-component': 'Select', + 'x-decorator': 'FormItem', + enum: [ + { value: 0, label: '进行中' }, + { value: 1, label: '已完成' }, + { value: -1, label: '已失败' }, + { value: -2, label: '已取消' }, + ], + } as ISchema, + } + ], +}; + +export const executionSchema = { + provider: { + type: 'void', + 'x-decorator': 'ExecutionResourceProvider', + 'x-decorator-props': { + collection, + resourceName: 'executions', + request: { + resource: 'executions', + action: 'list', + params: { + pageSize: 50, + sort: ['-createdAt'], + }, + }, + }, + 'x-component': 'CollectionProvider', + 'x-component-props': { + collection, + }, + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 16, + }, + }, + properties: { + // filter: { + // type: 'object', + // 'x-component': 'Filter', + // } + } + }, + table: { + type: 'void', + 'x-component': 'Table.Void', + 'x-component-props': { + rowKey: 'id', + useDataSource: '{{ cm.useDataSourceFromRAC }}', + }, + properties: { + workflow: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + workflow: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + } + }, + status: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + status: { + type: 'number', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + } + }, + actions: { + type: 'void', + title: '{{ t("Actions") }}', + 'x-component': 'Table.Column', + properties: { + actions: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + config: { + type: 'void', + title: '查看', + 'x-component': 'ExecutionLink' + }, + } + } + } + } + } + } + } + } +}; diff --git a/packages/client/src/workflow/schemas/workflows.ts b/packages/client/src/workflow/schemas/workflows.ts new file mode 100644 index 000000000..1adafc668 --- /dev/null +++ b/packages/client/src/workflow/schemas/workflows.ts @@ -0,0 +1,303 @@ +import { ISchema } from '@formily/react'; +import { executionSchema } from './executions'; + +const collection = { + name: 'workflows', + fields: [ + { + type: 'string', + name: 'title', + interface: 'input', + uiSchema: { + title: '流程名称', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + // { + // type: 'string', + // name: 'description', + // interface: 'textarea', + // uiSchema: { + // title: '描述', + // type: 'string', + // 'x-component': 'TextArea', + // } as ISchema, + // }, + { + type: 'string', + name: 'type', + interface: 'select', + uiSchema: { + title: '触发方式', + type: 'string', + 'x-component': 'Select', + 'x-decorator': 'FormItem', + enum: [ + { value: 'model', label: '数据变动' } + ], + } as ISchema, + }, + { + type: 'boolean', + name: 'enabled', + interface: 'radio', + uiSchema: { + title: '状态', + type: 'string', + enum: [ + { label: '启用', value: true }, + { label: '禁用', value: false }, + ], + 'x-component': 'Radio.Group', + 'x-decorator': 'FormItem', + } as ISchema + } + ], +}; + +export const workflowSchema: ISchema = { + type: 'object', + properties: { + provider: { + type: 'void', + 'x-decorator': 'ResourceActionProvider', + 'x-decorator-props': { + collection, + resourceName: 'workflows', + request: { + resource: 'workflows', + action: 'list', + params: { + pageSize: 50, + filter: {}, + sort: ['createdAt'], + except: ['config'], + }, + }, + }, + 'x-component': 'CollectionProvider', + 'x-component-props': { + collection, + }, + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 16, + }, + }, + properties: { + delete: { + type: 'void', + title: '删除', + 'x-component': 'Action', + }, + create: { + type: 'void', + title: '添加工作流', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + title: '添加工作流', + properties: { + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + description: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + type: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: 'Cancel', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: 'Submit', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useCreateAction }}', + }, + }, + }, + }, + }, + }, + }, + }, + } + }, + table: { + type: 'void', + 'x-component': 'Table.Void', + 'x-component-props': { + rowKey: 'id', + rowSelection: { + type: 'checkbox', + }, + useDataSource: '{{ cm.useDataSourceFromRAC }}', + }, + properties: { + title: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + title: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + } + }, + type: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + type: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + } + }, + enabled: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + enabled: { + type: 'boolean', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + } + }, + actions: { + type: 'void', + title: '{{ t("Actions") }}', + 'x-component': 'Table.Column', + properties: { + actions: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + config: { + type: 'void', + title: '配置流程', + 'x-component': 'WorkflowLink' + }, + executions: { + type: 'void', + title: '执行历史', + 'x-component': 'Action.Link', + 'x-component-props': { + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + title: '执行历史', + 'x-component': 'Action.Drawer', + properties: executionSchema + } + } + }, + update: { + type: 'void', + title: '{{ t("Edit") }}', + 'x-component': 'Action.Link', + 'x-component-props': { + type: 'primary', + }, + properties: { + modal: { + type: 'void', + 'x-component': 'Action.Modal', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues: '{{ cm.useValuesFromRecord }}', + }, + title: '编辑工作流', + properties: { + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + enabled: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + footer: { + type: 'void', + 'x-component': 'Action.Modal.Footer', + properties: { + cancel: { + title: '{{ t("Cancel") }}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{ t("Submit") }}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useUpdateAction }}', + }, + }, + }, + }, + }, + }, + }, + }, + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action.Link', + 'x-component-props': { + confirm: { + title: "{{t('Delete record')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + useAction: '{{ cm.useDestroyActionAndRefreshCM }}', + }, + }, + } + } + } + } + } + } + } + } + } +}; diff --git a/packages/client/src/workflow/style.tsx b/packages/client/src/workflow/style.tsx new file mode 100644 index 000000000..fcdb2fce1 --- /dev/null +++ b/packages/client/src/workflow/style.tsx @@ -0,0 +1,133 @@ +import { css } from '@emotion/css'; + +export const workflowPageClass = css` + height: 100%; + width: 100%; + overflow: auto; + + .workflow-canvas{ + width: min-content; + min-width: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding: 2em; + } +`; + +export const branchBlockClass = css` + display: flex; + position: relative; + + :before{ + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: calc(50% - .5px); + width: 1px; + background-color: #f0f2f5; + } +`; + +export const branchClass = css` + display: flex; + flex-direction: column; + align-items: center; + position: relative; + padding: 0 2em; + + .workflow-node-list{ + flex-grow: 1; + min-width: 20em; + } + + .workflow-branch-lines{ + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background-color: #ddd; + } + + :before,:after{ + content: ""; + position: absolute; + height: 1px; + background-color: #ddd; + } + + :before{ + top: 0; + } + + :after{ + bottom: 0; + } + + :not(:first-child):not(:last-child){ + :before,:after{ + left: 0; + width: 100%; + } + } + + :last-child:not(:first-child){ + :before,:after{ + right: 50%; + width: 50%; + } + } + + :first-child:not(:last-child){ + :before,:after{ + left: 50%; + width: 50%; + } + } +`; + +export const nodeBlockClass = css` + display: flex; + flex-direction: column; + align-items: center; + position: relative; +`; + +export const nodeClass = css` + display: flex; + flex-direction: column; + align-items: center; +`; + +export const nodeCardClass = css` + width: 20em; + background: #fff; + padding: 1em; + box-shadow: 0 .25em .5em rgba(0, 0, 0, .1); +`; + +export const nodeHeaderClass = css` + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const nodeTitleClass = css` + font-weight: normal; + + .workflow-node-id{ + color: #999; + } +`; + +export const nodeSubtreeClass = css` + display: flex; + flex-direction: column-reverse; + align-items: center; +`; + +export const addButtonClass = css` + flex-shrink: 0; + padding: 2em 0; +`; diff --git a/packages/client/src/workflow/triggers/index.tsx b/packages/client/src/workflow/triggers/index.tsx new file mode 100644 index 000000000..2991c53de --- /dev/null +++ b/packages/client/src/workflow/triggers/index.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { useForm } from "@formily/react"; +import { cx } from "@emotion/css"; + +import { SchemaComponent, useActionContext, useAPIClient, useRecord, useResourceActionContext } from '../../'; +import model from './model'; +import { nodeCardClass } from "../style"; + + +function useUpdateConfigAction() { + const form = useForm(); + const api = useAPIClient(); + const record = useRecord(); + const ctx = useActionContext(); + const { refresh } = useResourceActionContext(); + return { + async run() { + await api.resource('workflows', record.id).update({ + filterByTk: record.id, + values: { + config: { + ...record.config, + ...form.values + } + }, + }); + ctx.setVisible(false); + refresh(); + }, + }; +}; + + +const triggerTypes = { + model +}; + +export const TriggerConfig = () => { + const { data } = useResourceActionContext(); + if (!data) { + return null; + } + const { type, config } = data.data; + const { title, fieldset, scope } = triggerTypes[type]; + return ( +
+

{title}

+ +
+ ); +} diff --git a/packages/client/src/workflow/triggers/model.tsx b/packages/client/src/workflow/triggers/model.tsx new file mode 100644 index 000000000..a8905362a --- /dev/null +++ b/packages/client/src/workflow/triggers/model.tsx @@ -0,0 +1,36 @@ +import { action } from '@formily/reactive'; +import { t } from 'i18next'; +import { useCollectionManager } from '../../collection-manager'; + +export default { + title: '数据表事件', + fieldset: { + collection: { + type: 'string', + title: '数据表', + name: 'collection', + required: true, + 'x-reactions': ['{{useAsyncDataSource()}}'], + 'x-decorator': 'FormItem', + 'x-component': 'Select', + }, + // mode: { + // type: 'number', + // title: '触发时机', + // name: 'mode', + // } + }, + scope: { + useAsyncDataSource() { + return (field: any) => { + const { collections = [] } = useCollectionManager(); + action.bound((data: any) => { + field.dataSource = data.map(item => ({ + label: t(item.title), + value: item.name + })); + })(collections); + } + } + } +}; diff --git a/packages/plugin-ui-routes-storage/src/index.ts b/packages/plugin-ui-routes-storage/src/index.ts index 77bc6d3f6..714cc9103 100644 --- a/packages/plugin-ui-routes-storage/src/index.ts +++ b/packages/plugin-ui-routes-storage/src/index.ts @@ -37,7 +37,7 @@ export class UiRoutesStoragePlugin extends Plugin { // test... { type: 'route', - path: '/admin/workflow', + path: '/admin/workflows/:id', component: 'WorkflowPage', }, // { diff --git a/packages/plugin-workflow/package.json b/packages/plugin-workflow/package.json index 37a4fd6c6..3ec9ef352 100644 --- a/packages/plugin-workflow/package.json +++ b/packages/plugin-workflow/package.json @@ -10,6 +10,7 @@ "build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm" }, "dependencies": { + "@nocobase/actions": "^0.6.0-alpha.0", "@nocobase/database": "^0.6.0-alpha.0", "@nocobase/server": "^0.6.0-alpha.0", "@nocobase/utils": "^0.6.0-alpha.0", diff --git a/packages/plugin-workflow/src/__tests__/instructions/condition.test.ts b/packages/plugin-workflow/src/__tests__/instructions/condition.test.ts index 1e6594277..d0375a5b9 100644 --- a/packages/plugin-workflow/src/__tests__/instructions/condition.test.ts +++ b/packages/plugin-workflow/src/__tests__/instructions/condition.test.ts @@ -200,8 +200,8 @@ describe('workflow > instructions > condition', () => { const post = await PostModel.create({ title: 't1' }); - const [execution] = await workflow.getExecutions(); - const [job] = await execution.getJobs(); + const [execution] = await workflow.getExecutions({ include: ['jobs'] }); + const [job] = execution.jobs; expect(job.result).toBe(true); }); diff --git a/packages/plugin-workflow/src/actions/flow_nodes.ts b/packages/plugin-workflow/src/actions/flow_nodes.ts new file mode 100644 index 000000000..22d4cb461 --- /dev/null +++ b/packages/plugin-workflow/src/actions/flow_nodes.ts @@ -0,0 +1,145 @@ +import { Op } from 'sequelize'; +import actions, { Context, utils } from '@nocobase/actions'; + +export async function create(context: Context, next) { + return actions.create(context, async () => { + const { body: instance, db } = context; + const repository = utils.getRepositoryFromParams(context); + + if (!instance.upstreamId) { + const previousHead = await repository.findOne({ + filter: { + id: { + $ne: instance.id + }, + upstreamId: null + } + }); + if (previousHead) { + await previousHead.setUpstream(instance); + await instance.setDownstream(previousHead); + instance.set('downstream', previousHead); + } + return next(); + } + + const upstream = await instance.getUpstream(); + + if (instance.branchIndex == null) { + const downstream = await upstream.getDownstream(); + + if (downstream) { + await downstream.setUpstream(instance); + await instance.setDownstream(downstream); + instance.set('downstream', downstream); + } + + await upstream.update({ + downstreamId: instance.id + }); + + upstream.set('downstream', instance); + } else { + const [downstream] = await upstream.getBranches({ + where: { + id: { + [Op.ne]: instance.id + }, + branchIndex: instance.branchIndex + } + }); + + if (downstream) { + await downstream.update({ + upstreamId: instance.id, + branchIndex: null + }); + await instance.setDownstream(downstream); + instance.set('downstream', downstream); + } + } + + instance.set('upstream', upstream); + + await next(); + }); +} + +function searchBranchNodes(nodes, from): any[] { + const branchHeads = nodes + .filter((item: any) => item.upstreamId === from.id && item.branchIndex != null); + return branchHeads.reduce((flatten: any[], head) => flatten.concat(searchBranchDownstreams(nodes, head)), []) as any[]; +} + +function searchBranchDownstreams(nodes, from) { + let result = []; + for (let search = from; search; search = search.downstream) { + result = [...result, search, ...searchBranchNodes(nodes, search)]; + } + return result; +} + +export async function destroy(context: Context, next) { + const repository = utils.getRepositoryFromParams(context); + const { db } = context; + const { filterByTk } = context.action.params; + + context.body = await db.sequelize.transaction(async transaction => { + const fields = ['id', 'upstreamId', 'downstreamId', 'branchIndex']; + const instance = await repository.findOne({ + filterByTk, + fields: [...fields, 'workflowId'], + appends: ['upstream', 'downstream'], + transaction + }); + const { upstream, downstream } = instance.get(); + + if (upstream && upstream.downstreamId === instance.id) { + await upstream.update({ + downstreamId: instance.downstreamId + }, { transaction }); + } + + if (downstream) { + await downstream.update({ + upstreamId: instance.upstreamId, + branchIndex: instance.branchIndex + }, { transaction }); + } + + const nodes = await repository.find({ + filter: { + workflowId: instance.workflowId + }, + fields, + transaction + }); + const nodesMap = new Map(); + // make map + nodes.forEach(item => { + nodesMap.set(item.id, item); + }); + // overwrite + nodesMap.set(instance.id, instance); + // make linked list + nodes.forEach(item => { + if (item.upstreamId) { + item.upstream = nodesMap.get(item.upstreamId); + } + if (item.downstreamId) { + item.downstream = nodesMap.get(item.downstreamId); + } + }); + + const branchNodes = searchBranchNodes(nodes, instance); + + await repository.destroy({ + filterByTk: [instance.id, ...branchNodes.map(item => item.id)], + transaction + }); + + return instance; + }); + + await next(); +} diff --git a/packages/plugin-workflow/src/actions/index.ts b/packages/plugin-workflow/src/actions/index.ts new file mode 100644 index 000000000..607e6b69b --- /dev/null +++ b/packages/plugin-workflow/src/actions/index.ts @@ -0,0 +1,14 @@ +import * as flow_nodes from './flow_nodes'; + +function make(name, mod) { + return Object.keys(mod).reduce((result, key) => ({ + ...result, + [`${name}:${key}`]: mod[key] + }), {}) +} + +export default function(app) { + app.actions({ + ...make('flow_nodes', flow_nodes) + }); +} diff --git a/packages/plugin-workflow/src/calculators/index.ts b/packages/plugin-workflow/src/calculators/index.ts index 97b96da1d..0328f076a 100644 --- a/packages/plugin-workflow/src/calculators/index.ts +++ b/packages/plugin-workflow/src/calculators/index.ts @@ -104,6 +104,10 @@ function equal(a, b) { return a === b; } +function notEqual(a, b) { + return a !== b; +} + function gt(a, b) { return a > b; } @@ -121,12 +125,14 @@ function lte(a, b) { } calculators.register('equal', equal); +calculators.register('notEqual', notEqual); calculators.register('gt', gt); calculators.register('gte', gte); calculators.register('lt', lt); calculators.register('lte', lte); calculators.register('===', equal); +calculators.register('!==', notEqual); calculators.register('>', gt); calculators.register('>=', gte); calculators.register('<', lt); diff --git a/packages/plugin-workflow/src/collections/flow_nodes.ts b/packages/plugin-workflow/src/collections/flow_nodes.ts index 5cd2b9083..77422a215 100644 --- a/packages/plugin-workflow/src/collections/flow_nodes.ts +++ b/packages/plugin-workflow/src/collections/flow_nodes.ts @@ -9,12 +9,7 @@ export default { interface: 'string', type: 'string', name: 'title', - title: '名称', - component: { - showInTable: true, - showInDetail: true, - showInForm: true, - }, + title: '名称' }, // which workflow belongs to { @@ -34,7 +29,7 @@ export default { type: 'hasMany', target: 'flow_nodes', sourceKey: 'id', - foreignKey: 'upstream_id', + foreignKey: 'upstreamId', }, // only works when upstream node is branching type, such as condition and parallel. // put here because the design of flow-links model is not really necessary for now. diff --git a/packages/plugin-workflow/src/collections/workflows.ts b/packages/plugin-workflow/src/collections/workflows.ts index 02deea2cb..ede082b06 100644 --- a/packages/plugin-workflow/src/collections/workflows.ts +++ b/packages/plugin-workflow/src/collections/workflows.ts @@ -36,7 +36,8 @@ export default { type: 'jsonb', title: '触发配置', name: 'config', - required: true + required: true, + defaultValue: {} }, { interface: 'linkTo', diff --git a/packages/plugin-workflow/src/index.ts b/packages/plugin-workflow/src/index.ts new file mode 100644 index 000000000..6074afd19 --- /dev/null +++ b/packages/plugin-workflow/src/index.ts @@ -0,0 +1,6 @@ +export * from './constants'; +export * from './calculators'; +export * from './triggers'; +export * from './instructions'; + +export { default } from './server'; diff --git a/packages/plugin-workflow/src/instructions/condition.ts b/packages/plugin-workflow/src/instructions/condition.ts index 2f8bcbe4e..c95aca873 100644 --- a/packages/plugin-workflow/src/instructions/condition.ts +++ b/packages/plugin-workflow/src/instructions/condition.ts @@ -1,5 +1,4 @@ -import { calculate, Operand } from "../calculators"; -import calculators from "../calculators"; +import calculators, { calculate, Operand } from "../calculators"; import { JOB_STATUS } from "../constants"; type BaseCalculation = { diff --git a/packages/plugin-workflow/src/server.ts b/packages/plugin-workflow/src/server.ts index 8316c3eae..bfbe686e2 100644 --- a/packages/plugin-workflow/src/server.ts +++ b/packages/plugin-workflow/src/server.ts @@ -4,10 +4,7 @@ import { Plugin } from '@nocobase/server'; import WorkflowModel from './models/Workflow'; import ExecutionModel from './models/Execution'; - -export * from './calculators'; -export * from './triggers'; -export * from './instructions'; +import actions from './actions'; export default class WorkflowPlugin extends Plugin { async load(options = {}) { @@ -22,6 +19,8 @@ export default class WorkflowPlugin extends Plugin { directory: path.resolve(__dirname, 'collections'), }); + actions(this.app); + // [Life Cycle]: // * load all workflows in db // * add all hooks for enabled workflows diff --git a/packages/plugin-workflow/src/triggers/model.ts b/packages/plugin-workflow/src/triggers/model.ts index f9f5e034b..7003ee0df 100644 --- a/packages/plugin-workflow/src/triggers/model.ts +++ b/packages/plugin-workflow/src/triggers/model.ts @@ -23,22 +23,28 @@ export default { on(this: WorkflowModel, callback: Function) { const { database } = this.constructor; const { collection, mode } = this.config; - const { model } = database.getCollection(collection); + const Collection = database.getCollection(collection); + if (!Collection) { + return; + } const handler = (data: any, options) => callback({ data: data.get() }, options); // TODO: duplication when mode change should be considered for (let [key, event] of MODE_BITMAP_EVENTS.entries()) { if (mode & key) { - model.addHook(event, this.getHookId(), handler); + Collection.model.addHook(event, this.getHookId(), handler); } } }, off(this: WorkflowModel) { const { database } = this.constructor; const { collection, mode } = this.config; - const { model } = database.getCollection(collection); + const Collection = database.getCollection(collection); + if (!Collection) { + return; + } for (let [key, event] of MODE_BITMAP_EVENTS.entries()) { if (mode & key) { - model.removeHook(event, this.getHookId()); + Collection.model.removeHook(event, this.getHookId()); } } } diff --git a/packages/utils/src/registry.ts b/packages/utils/src/registry.ts index d506e1ee0..ae95b2121 100644 --- a/packages/utils/src/registry.ts +++ b/packages/utils/src/registry.ts @@ -30,6 +30,10 @@ export class Registry { return this.map.get(key); } + getKeys(): Iterable { + return this.map.keys(); + } + getValues(): Iterable { return this.map.values(); }