mirror of
https://gitee.com/nocobase/nocobase.git
synced 2024-11-29 10:48:30 +08:00
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 <chenlinxh@gmail.com>
This commit is contained in:
parent
e2616aa927
commit
b59a239a82
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -1,2 +1,2 @@
|
||||
import { RelationRepositoryActionBuilder } from './utils';
|
||||
import { RelationRepositoryActionBuilder } from '../utils';
|
||||
export const remove = RelationRepositoryActionBuilder('remove');
|
||||
|
@ -1,2 +1,2 @@
|
||||
import { RelationRepositoryActionBuilder } from './utils';
|
||||
import { RelationRepositoryActionBuilder } from '../utils';
|
||||
export const set = RelationRepositoryActionBuilder('set');
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -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<any>;
|
||||
|
||||
export interface Context extends Koa.Context {
|
||||
|
@ -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;
|
@ -48,6 +48,7 @@ const plugins = [
|
||||
'@nocobase/plugin-users',
|
||||
'@nocobase/plugin-acl',
|
||||
'@nocobase/plugin-china-region',
|
||||
'@nocobase/plugin-workflow',
|
||||
];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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",
|
||||
|
@ -21,4 +21,4 @@ export * from './schema-templates';
|
||||
export * from './settings-form';
|
||||
export * from './system-settings';
|
||||
export * from './user';
|
||||
|
||||
export * from './workflow';
|
||||
|
@ -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' },
|
||||
]}
|
||||
|
24
packages/client/src/workflow/ExecutionResourceProvider.tsx
Normal file
24
packages/client/src/workflow/ExecutionResourceProvider.tsx
Normal file
@ -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 (
|
||||
<ResourceActionProvider {...props} />
|
||||
);
|
||||
}
|
149
packages/client/src/workflow/WorkflowCanvas.tsx
Normal file
149
packages/client/src/workflow/WorkflowCanvas.tsx
Normal file
@ -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 <div>加载失败</div>;
|
||||
}
|
||||
|
||||
const { nodes = [] } = data?.data ?? {};
|
||||
|
||||
makeNodes(nodes);
|
||||
|
||||
const entry = nodes.find(item => !item.upstream);
|
||||
|
||||
return (
|
||||
<FlowContext.Provider value={{
|
||||
nodes,
|
||||
onNodeAdded: refresh,
|
||||
onNodeRemoved: refresh
|
||||
}}>
|
||||
<div className={branchBlockClass}>
|
||||
<Branch entry={entry} />
|
||||
</div>
|
||||
<div className={cx(nodeCardClass)}>结束</div>
|
||||
</FlowContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cx(branchClass)}>
|
||||
<div className="workflow-branch-lines" />
|
||||
{controller}
|
||||
<AddButton upstream={from} branchIndex={branchIndex} />
|
||||
<div className="workflow-node-list">
|
||||
{list.map(item => {
|
||||
return (
|
||||
<div key={item.id} className={cx(nodeBlockClass)}>
|
||||
<Node data={item} />
|
||||
<AddButton upstream={item} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className={cx(addButtonClass)}>
|
||||
<Dropdown trigger={['click']} overlay={
|
||||
<Menu onClick={ev => onCreate(ev)}>
|
||||
{(Array.from(instructions.getValues()) as Instruction[]).map(item => item.options
|
||||
? (
|
||||
<Menu.SubMenu key={item.type} title={item.title}>
|
||||
{item.options.map(option => (
|
||||
<Menu.Item key={option.key}>{option.label}</Menu.Item>
|
||||
))}
|
||||
</Menu.SubMenu>
|
||||
)
|
||||
: (
|
||||
<Menu.Item key={item.type}>{item.title}</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}>
|
||||
<Button shape="circle" icon={<PlusOutlined />} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
12
packages/client/src/workflow/WorkflowLink.tsx
Normal file
12
packages/client/src/workflow/WorkflowLink.tsx
Normal file
@ -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 (
|
||||
<Link to={`/admin/workflows/${id}`} onClick={() => setVisible(false)}>流程配置</Link>
|
||||
);
|
||||
}
|
107
packages/client/src/workflow/WorkflowPage.tsx
Normal file
107
packages/client/src/workflow/WorkflowPage.tsx
Normal file
@ -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 (
|
||||
<div className={cx(workflowPageClass)}>
|
||||
<div className="workflow-canvas">
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
provider: {
|
||||
type: 'void',
|
||||
'x-decorator': 'ResourceActionProvider',
|
||||
'x-decorator-props': {
|
||||
collection: workflowCollection,
|
||||
resourceName: 'workflows',
|
||||
request: {
|
||||
resource: 'workflows',
|
||||
action: 'get',
|
||||
params: {
|
||||
filter: params,
|
||||
appends: ['nodes']
|
||||
}
|
||||
}
|
||||
},
|
||||
properties: {
|
||||
trigger: {
|
||||
type: 'void',
|
||||
'x-component': 'TriggerConfig'
|
||||
},
|
||||
nodes: {
|
||||
type: 'void',
|
||||
'x-decorator': 'CollectionProvider',
|
||||
'x-decorator-props': {
|
||||
collection: {
|
||||
name: 'flow_nodes',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
interface: 'input',
|
||||
uiSchema: {
|
||||
title: '节点名称',
|
||||
type: 'string',
|
||||
'x-component': 'Input'
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'type',
|
||||
interface: 'select',
|
||||
uiSchema: {
|
||||
title: '节点类型',
|
||||
type: 'string',
|
||||
'x-component': 'Select',
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
'x-component': 'WorkflowCanvas',
|
||||
'x-component-props': {
|
||||
// nodes
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}}
|
||||
components={{
|
||||
TriggerConfig,
|
||||
WorkflowCanvas
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
59
packages/client/src/workflow/WorkflowShortcut.tsx
Normal file
59
packages/client/src/workflow/WorkflowShortcut.tsx
Normal file
@ -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 (
|
||||
<ActionContext.Provider value={{ visible, setVisible }}>
|
||||
<PluginManager.Toolbar.Item
|
||||
icon={<PartitionOutlined />}
|
||||
title={'工作流'}
|
||||
onClick={() => {
|
||||
setVisible(true);
|
||||
}}
|
||||
/>
|
||||
<SchemaComponent
|
||||
schema={schema}
|
||||
components={{
|
||||
WorkflowTable
|
||||
}}
|
||||
scope={{ useCloseAction }}
|
||||
/>
|
||||
</ActionContext.Provider>
|
||||
);
|
||||
};
|
17
packages/client/src/workflow/WorkflowTable.tsx
Normal file
17
packages/client/src/workflow/WorkflowTable.tsx
Normal file
@ -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 (
|
||||
<SchemaComponent
|
||||
schema={workflowSchema}
|
||||
components={{
|
||||
WorkflowLink,
|
||||
WorkflowPage,
|
||||
ExecutionResourceProvider
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
79
packages/client/src/workflow/calculators.tsx
Normal file
79
packages/client/src/workflow/calculators.tsx
Normal file
@ -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 (
|
||||
<Select value={value} onChange={onChange}>
|
||||
{stack.map(item => (
|
||||
<Select.Option key={item.id} value={item.id}>{item.title ?? `#${item.id}`}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextSelect({ value, onChange }) {
|
||||
return (
|
||||
<Select></Select>
|
||||
);
|
||||
}
|
||||
|
||||
const VariableTypeComponent = {
|
||||
constant({ onChange, ...props }) {
|
||||
return <Input {...props} onChange={ev => onChange(ev.target.value)} />
|
||||
},
|
||||
job: JobSelect,
|
||||
context: ContextSelect,
|
||||
// calculation: Calculation
|
||||
};
|
||||
|
||||
function OperandTypeSelect({ value, onChange }) {
|
||||
return (
|
||||
<Select value={value} onChange={onChange} placeholder="变量来源">
|
||||
<Select.Option value="constant">常量</Select.Option>
|
||||
<Select.Option value="job">节点数据</Select.Option>
|
||||
<Select.Option value="context" disabled>触发数据</Select.Option>
|
||||
<Select.Option value="calculation" disabled>计算</Select.Option>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={css`
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
`}>
|
||||
<OperandTypeSelect value={type} onChange={v => onChange({ ...operand, type: v })} />
|
||||
<VariableComponent value={value} onChange={v => onChange({ ...operand, value: v })} />
|
||||
</div>
|
||||
);
|
||||
}
|
5
packages/client/src/workflow/index.tsx
Normal file
5
packages/client/src/workflow/index.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './WorkflowPage';
|
||||
export * from './WorkflowLink';
|
||||
export * from './WorkflowTable';
|
||||
export * from './WorkflowShortcut';
|
||||
export * from './ExecutionResourceProvider';
|
234
packages/client/src/workflow/nodes/condition.tsx
Normal file
234
packages/client/src/workflow/nodes/condition.tsx
Normal file
@ -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 (
|
||||
<div className={css`
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin: .5em 0;
|
||||
`}>
|
||||
{value.group
|
||||
? (
|
||||
<CalculationGroup
|
||||
value={value.group}
|
||||
onChange={group => onChange({ ...value, group })}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={css`
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
|
||||
.ant-select{
|
||||
width: auto;
|
||||
}
|
||||
`}>
|
||||
<Operand value={operands[0]} onChange={v => onChange({ ...value, operands: [v, operands[1]] })} />
|
||||
<Select value={calculator} onChange={v => onChange({ ...value, calculator: v })}>
|
||||
{calculators.map(item => (
|
||||
<Select.Option key={item.value} value={item.value}>{item.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Operand value={operands[1]} onChange={v => onChange({ ...value, operands: [operands[0], v] })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Button onClick={onRemove} type="text" icon={<CloseCircleOutlined />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={css`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: .5em 1em;
|
||||
border: 1px dashed #ddd;
|
||||
|
||||
+ button{
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
`}>
|
||||
<div className={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5em;
|
||||
|
||||
.ant-select{
|
||||
width: auto;
|
||||
}
|
||||
`}>
|
||||
<Trans>
|
||||
{'Meet '}
|
||||
<Select value={type} onChange={t => onChange({ ...value, type: t })}>
|
||||
<Select.Option value="and">All</Select.Option>
|
||||
<Select.Option value="or">Any</Select.Option>
|
||||
</Select>
|
||||
{' conditions in the group'}
|
||||
</Trans>
|
||||
</div>
|
||||
<div className="calculation-items">
|
||||
{calculations.map((calculation, i) => (
|
||||
<CalculationItem
|
||||
key={`${calculation.calculator}_${i}`}
|
||||
value={calculation}
|
||||
onChange={onItemChange.bind(this, i)}
|
||||
onRemove={() => onRemove(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={css`
|
||||
a:not(:last-child){
|
||||
margin-right: 1em;
|
||||
}
|
||||
`} >
|
||||
<a onClick={onAddSingle}>添加条件</a>
|
||||
<a onClick={onAddGroup}>添加条件组</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalculationConfig({ value, onChange }) {
|
||||
const rule = value && Object.keys(value).length
|
||||
? value
|
||||
: { group: { type: 'and', calculations: [] } };
|
||||
return (
|
||||
<CalculationGroup value={rule.group} onChange={group => 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 (
|
||||
<NodeDefaultView data={data}>
|
||||
{rejectOnFalse ? null : (
|
||||
<div className={cx(nodeSubtreeClass)}>
|
||||
<div
|
||||
className={cx(branchBlockClass, css`
|
||||
> * > .workflow-branch-lines{
|
||||
> button{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`)}
|
||||
>
|
||||
<Branch from={data} entry={falseEntry} branchIndex={0}/>
|
||||
<Branch from={data} entry={trueEntry} branchIndex={1} />
|
||||
</div>
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
height: 2em;
|
||||
overflow: visible;
|
||||
|
||||
:before,:after{
|
||||
position: absolute;
|
||||
top: calc(1.5em - 1px);
|
||||
line-height: 1em;
|
||||
color: #999;
|
||||
background-color: #f0f2f5;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
:before{
|
||||
content: "否";
|
||||
right: 4em;
|
||||
}
|
||||
|
||||
:after{
|
||||
content: "是";
|
||||
left: 4em;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</NodeDefaultView>
|
||||
)
|
||||
},
|
||||
components: {
|
||||
CalculationConfig
|
||||
}
|
||||
};
|
187
packages/client/src/workflow/nodes/index.tsx
Normal file
187
packages/client/src/workflow/nodes/index.tsx
Normal file
@ -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<Instruction>();
|
||||
|
||||
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 (
|
||||
<NodeDefaultView data={data} />
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button type="text" shape="circle" icon={<DeleteOutlined />} onClick={onRemove} />
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeDefaultView(props) {
|
||||
const { data, children } = props;
|
||||
const instruction = instructions.get(data.type);
|
||||
|
||||
return (
|
||||
<NodeContext.Provider value={data}>
|
||||
<div className={cx(nodeClass, `workflow-node-type-${data.type}`)}>
|
||||
<div className={cx(nodeCardClass)}>
|
||||
<div className={cx(nodeHeaderClass)}>
|
||||
<h4 className={cx(nodeTitleClass)}>
|
||||
<Tag>{instruction.title}</Tag>
|
||||
<strong>{data.title}</strong>
|
||||
<span className="workflow-node-id">#{data.id}</span>
|
||||
</h4>
|
||||
<RemoveButton />
|
||||
</div>
|
||||
<SchemaComponent
|
||||
scope={instruction.scope}
|
||||
components={{...instruction.components}}
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
view: instruction.view,
|
||||
config: {
|
||||
type: 'void',
|
||||
title: '配置节点',
|
||||
'x-component': 'Action.Link',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
},
|
||||
properties: {
|
||||
drawer: {
|
||||
type: 'void',
|
||||
title: '配置节点',
|
||||
'x-component': 'Action.Drawer',
|
||||
'x-decorator': 'Form',
|
||||
'x-decorator-props': {
|
||||
useValues(options) {
|
||||
const node = useNodeContext();
|
||||
return useRequest(() => {
|
||||
return Promise.resolve({ data: node.config });
|
||||
}, options);
|
||||
}
|
||||
},
|
||||
properties: {
|
||||
...instruction.fieldset,
|
||||
actions: {
|
||||
type: 'void',
|
||||
'x-component': 'Action.Drawer.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: useUpdateConfigAction,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ISchema
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</NodeContext.Provider>
|
||||
);
|
||||
}
|
109
packages/client/src/workflow/nodes/parallel.tsx
Normal file
109
packages/client/src/workflow/nodes/parallel.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React, { useState } from "react";
|
||||
import { css, cx } from "@emotion/css";
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
import { NodeDefaultView } from ".";
|
||||
import { Branch, useFlowContext } from "../WorkflowCanvas";
|
||||
import { branchBlockClass, nodeSubtreeClass } from "../style";
|
||||
import { Button, Tooltip } from "antd";
|
||||
// import { SchemaComponent } from "../../schema-component";
|
||||
|
||||
export default {
|
||||
title: '并行',
|
||||
type: 'parallel',
|
||||
fieldset: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
name: 'mode',
|
||||
title: '模式',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Radio.Group',
|
||||
'x-component-props': {
|
||||
},
|
||||
enum: [
|
||||
{ value: 'all', label: '全部成功' },
|
||||
{ value: 'any', label: '任意成功' },
|
||||
// { value: 'race', label: '任意退出' },
|
||||
],
|
||||
default: 'all'
|
||||
}
|
||||
},
|
||||
view: {
|
||||
|
||||
},
|
||||
render(data) {
|
||||
const { id, config: { mode } } = data;
|
||||
const { nodes } = useFlowContext();
|
||||
const branches = nodes.reduce((result, node) => {
|
||||
if (node.upstreamId === id && node.branchIndex != null) {
|
||||
return result.concat(node);
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
const [branchCount, setBranchCount] = useState(Math.max(2, branches.length));
|
||||
|
||||
const tempBranches = Array(Math.max(0, branchCount - branches.length)).fill(null);
|
||||
|
||||
return (
|
||||
<NodeDefaultView data={data}>
|
||||
<div className={cx(nodeSubtreeClass)}>
|
||||
<div className={cx(branchBlockClass)}>
|
||||
{branches.map((branch) => (
|
||||
<Branch key={branch.id} from={data} entry={branch} branchIndex={branch.branchIndex} />
|
||||
))}
|
||||
{tempBranches.map((branch, i) => (
|
||||
<Branch
|
||||
key={`temp_${branches.length + i}`}
|
||||
from={data}
|
||||
branchIndex={branches.length + i}
|
||||
controller={
|
||||
branches.length + i > 1
|
||||
? (
|
||||
<div className={css`
|
||||
padding-top: 2em;
|
||||
|
||||
> button{
|
||||
.anticon{
|
||||
transform: rotate(45deg)
|
||||
}
|
||||
}
|
||||
`}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setBranchCount(branchCount - 1)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
height: 2em;
|
||||
`}
|
||||
>
|
||||
<Tooltip title="添加分支">
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
className={css`
|
||||
position: absolute;
|
||||
top: calc(50% - 1px);
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
|
||||
.anticon{
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
`}
|
||||
onClick={() => setBranchCount(branchCount + 1)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</NodeDefaultView>
|
||||
)
|
||||
}
|
||||
};
|
42
packages/client/src/workflow/nodes/query.tsx
Normal file
42
packages/client/src/workflow/nodes/query.tsx
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
128
packages/client/src/workflow/schemas/executions.ts
Normal file
128
packages/client/src/workflow/schemas/executions.ts
Normal file
@ -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'
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
303
packages/client/src/workflow/schemas/workflows.ts
Normal file
303
packages/client/src/workflow/schemas/workflows.ts
Normal file
@ -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 }}',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
133
packages/client/src/workflow/style.tsx
Normal file
133
packages/client/src/workflow/style.tsx
Normal file
@ -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;
|
||||
`;
|
94
packages/client/src/workflow/triggers/index.tsx
Normal file
94
packages/client/src/workflow/triggers/index.tsx
Normal file
@ -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 (
|
||||
<div className={cx(nodeCardClass)}>
|
||||
<h4>{title}</h4>
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'void',
|
||||
title: '触发器配置',
|
||||
'x-component': 'Action.Link',
|
||||
name: 'drawer',
|
||||
properties: {
|
||||
drawer: {
|
||||
type: 'void',
|
||||
title: '触发器配置',
|
||||
'x-component': 'Action.Drawer',
|
||||
'x-decorator': 'Form',
|
||||
'x-decorator-props': {
|
||||
initialValue: config
|
||||
},
|
||||
properties: {
|
||||
...fieldset,
|
||||
actions: {
|
||||
type: 'void',
|
||||
'x-component': 'Action.Drawer.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: useUpdateConfigAction
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
scope={scope}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
36
packages/client/src/workflow/triggers/model.tsx
Normal file
36
packages/client/src/workflow/triggers/model.tsx
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
@ -37,7 +37,7 @@ export class UiRoutesStoragePlugin extends Plugin {
|
||||
// test...
|
||||
{
|
||||
type: 'route',
|
||||
path: '/admin/workflow',
|
||||
path: '/admin/workflows/:id',
|
||||
component: 'WorkflowPage',
|
||||
},
|
||||
// {
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
145
packages/plugin-workflow/src/actions/flow_nodes.ts
Normal file
145
packages/plugin-workflow/src/actions/flow_nodes.ts
Normal file
@ -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();
|
||||
}
|
14
packages/plugin-workflow/src/actions/index.ts
Normal file
14
packages/plugin-workflow/src/actions/index.ts
Normal file
@ -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)
|
||||
});
|
||||
}
|
@ -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);
|
||||
|
@ -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.
|
||||
|
@ -36,7 +36,8 @@ export default {
|
||||
type: 'jsonb',
|
||||
title: '触发配置',
|
||||
name: 'config',
|
||||
required: true
|
||||
required: true,
|
||||
defaultValue: {}
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
|
6
packages/plugin-workflow/src/index.ts
Normal file
6
packages/plugin-workflow/src/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './constants';
|
||||
export * from './calculators';
|
||||
export * from './triggers';
|
||||
export * from './instructions';
|
||||
|
||||
export { default } from './server';
|
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -23,22 +23,28 @@ export default {
|
||||
on(this: WorkflowModel, callback: Function) {
|
||||
const { database } = <typeof WorkflowModel>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 } = <typeof WorkflowModel>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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -30,6 +30,10 @@ export class Registry<T> {
|
||||
return this.map.get(key);
|
||||
}
|
||||
|
||||
getKeys(): Iterable<string> {
|
||||
return this.map.keys();
|
||||
}
|
||||
|
||||
getValues(): Iterable<T> {
|
||||
return this.map.values();
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user