mirror of
https://gitee.com/nocobase/nocobase.git
synced 2024-12-01 19:58:15 +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 { Context } from '..';
|
||||||
import { getRepositoryFromParams } from './utils';
|
import { getRepositoryFromParams } from '../utils';
|
||||||
import { BelongsToManyRepository, MultipleRelationRepository, HasManyRepository } from '@nocobase/database';
|
import { BelongsToManyRepository, MultipleRelationRepository, HasManyRepository } from '@nocobase/database';
|
||||||
|
|
||||||
export async function add(ctx: Context, next) {
|
export async function add(ctx: Context, next) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { getRepositoryFromParams } from './utils';
|
import { getRepositoryFromParams } from '../utils';
|
||||||
|
|
||||||
export async function create(ctx: Context, next) {
|
export async function create(ctx: Context, next) {
|
||||||
const repository = getRepositoryFromParams(ctx);
|
const repository = getRepositoryFromParams(ctx);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { getRepositoryFromParams } from './utils';
|
import { getRepositoryFromParams } from '../utils';
|
||||||
|
|
||||||
export async function destroy(ctx: Context, next) {
|
export async function destroy(ctx: Context, next) {
|
||||||
const repository = getRepositoryFromParams(ctx);
|
const repository = getRepositoryFromParams(ctx);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { getRepositoryFromParams } from './utils';
|
import { getRepositoryFromParams } from '../utils';
|
||||||
|
|
||||||
export async function get(ctx: Context, next) {
|
export async function get(ctx: Context, next) {
|
||||||
const repository = getRepositoryFromParams(ctx);
|
const repository = getRepositoryFromParams(ctx);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ActionParams } from '@nocobase/resourcer';
|
import { ActionParams } from '@nocobase/resourcer';
|
||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { getRepositoryFromParams } from './utils';
|
import { getRepositoryFromParams } from '../utils';
|
||||||
|
|
||||||
export const DEFAULT_PAGE = 1;
|
export const DEFAULT_PAGE = 1;
|
||||||
export const DEFAULT_PER_PAGE = 20;
|
export const DEFAULT_PER_PAGE = 20;
|
||||||
|
@ -2,7 +2,7 @@ import { Op, Model } from 'sequelize';
|
|||||||
|
|
||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { Collection, TargetKey, Repository, SortField } from '@nocobase/database';
|
import { Collection, TargetKey, Repository, SortField } from '@nocobase/database';
|
||||||
import { getRepositoryFromParams } from './utils';
|
import { getRepositoryFromParams } from '../utils';
|
||||||
|
|
||||||
export async function move(ctx: Context, next) {
|
export async function move(ctx: Context, next) {
|
||||||
const repository = getRepositoryFromParams(ctx);
|
const repository = getRepositoryFromParams(ctx);
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
import { RelationRepositoryActionBuilder } from './utils';
|
import { RelationRepositoryActionBuilder } from '../utils';
|
||||||
export const remove = RelationRepositoryActionBuilder('remove');
|
export const remove = RelationRepositoryActionBuilder('remove');
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
import { RelationRepositoryActionBuilder } from './utils';
|
import { RelationRepositoryActionBuilder } from '../utils';
|
||||||
export const set = RelationRepositoryActionBuilder('set');
|
export const set = RelationRepositoryActionBuilder('set');
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { getRepositoryFromParams } from './utils';
|
import { getRepositoryFromParams } from '../utils';
|
||||||
import { BelongsToManyRepository } from '@nocobase/database';
|
import { BelongsToManyRepository } from '@nocobase/database';
|
||||||
|
|
||||||
export async function toggle(ctx: Context, next) {
|
export async function toggle(ctx: Context, next) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { getRepositoryFromParams } from './utils';
|
import { getRepositoryFromParams } from '../utils';
|
||||||
|
|
||||||
export async function update(ctx: Context, next) {
|
export async function update(ctx: Context, next) {
|
||||||
const repository = getRepositoryFromParams(ctx);
|
const repository = getRepositoryFromParams(ctx);
|
||||||
|
@ -4,6 +4,8 @@ import { Action } from '@nocobase/resourcer';
|
|||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import * as actions from './actions';
|
import * as actions from './actions';
|
||||||
|
|
||||||
|
export * as utils from './utils';
|
||||||
|
|
||||||
export type Next = () => Promise<any>;
|
export type Next = () => Promise<any>;
|
||||||
|
|
||||||
export interface Context extends Koa.Context {
|
export interface Context extends Koa.Context {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { MultipleRelationRepository, Repository } from '@nocobase/database';
|
import { MultipleRelationRepository, Repository } from '@nocobase/database';
|
||||||
import { Context } from '..';
|
import { Context } from '.';
|
||||||
|
|
||||||
export function getRepositoryFromParams(ctx: Context) {
|
export function getRepositoryFromParams(ctx: Context) {
|
||||||
const { resourceName, resourceOf } = ctx.action;
|
const { resourceName, resourceOf } = ctx.action;
|
@ -48,6 +48,7 @@ const plugins = [
|
|||||||
'@nocobase/plugin-users',
|
'@nocobase/plugin-users',
|
||||||
'@nocobase/plugin-acl',
|
'@nocobase/plugin-acl',
|
||||||
'@nocobase/plugin-china-region',
|
'@nocobase/plugin-china-region',
|
||||||
|
'@nocobase/plugin-workflow',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
|
@ -26,6 +26,9 @@ import {
|
|||||||
SignupPage,
|
SignupPage,
|
||||||
SystemSettingsProvider,
|
SystemSettingsProvider,
|
||||||
SystemSettingsShortcut,
|
SystemSettingsShortcut,
|
||||||
|
useRequest,
|
||||||
|
WorkflowPage,
|
||||||
|
WorkflowShortcut,
|
||||||
useRoutes
|
useRoutes
|
||||||
} from '@nocobase/client';
|
} from '@nocobase/client';
|
||||||
import { notification } from 'antd';
|
import { notification } from 'antd';
|
||||||
@ -62,6 +65,7 @@ const providers = [
|
|||||||
RouteSchemaComponent,
|
RouteSchemaComponent,
|
||||||
SigninPage,
|
SigninPage,
|
||||||
SignupPage,
|
SignupPage,
|
||||||
|
WorkflowPage,
|
||||||
BlockTemplatePage,
|
BlockTemplatePage,
|
||||||
BlockTemplateDetails,
|
BlockTemplateDetails,
|
||||||
},
|
},
|
||||||
@ -75,6 +79,7 @@ const providers = [
|
|||||||
ACLShortcut,
|
ACLShortcut,
|
||||||
DesignableSwitch,
|
DesignableSwitch,
|
||||||
CollectionManagerShortcut,
|
CollectionManagerShortcut,
|
||||||
|
WorkflowShortcut,
|
||||||
SystemSettingsShortcut,
|
SystemSettingsShortcut,
|
||||||
SchemaTemplateShortcut,
|
SchemaTemplateShortcut,
|
||||||
},
|
},
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
"@formily/antd": "^2.0.15",
|
"@formily/antd": "^2.0.15",
|
||||||
"@formily/core": "^2.0.15",
|
"@formily/core": "^2.0.15",
|
||||||
"@formily/react": "^2.0.15",
|
"@formily/react": "^2.0.15",
|
||||||
|
"@nocobase/utils": "0.6.0-alpha.0",
|
||||||
"ahooks": "^3.0.5",
|
"ahooks": "^3.0.5",
|
||||||
"antd": "^4.18.9",
|
"antd": "^4.18.9",
|
||||||
"axios": "^0.24.0",
|
"axios": "^0.24.0",
|
||||||
|
@ -21,4 +21,4 @@ export * from './schema-templates';
|
|||||||
export * from './settings-form';
|
export * from './settings-form';
|
||||||
export * from './system-settings';
|
export * from './system-settings';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
export * from './workflow';
|
||||||
|
@ -95,6 +95,7 @@ const InternalAdminLayout = (props: any) => {
|
|||||||
{ component: 'DesignableSwitch', pin: true },
|
{ component: 'DesignableSwitch', pin: true },
|
||||||
{ component: 'CollectionManagerShortcut', pin: true },
|
{ component: 'CollectionManagerShortcut', pin: true },
|
||||||
{ component: 'ACLShortcut', pin: true },
|
{ component: 'ACLShortcut', pin: true },
|
||||||
|
{ component: 'WorkflowShortcut', pin: true },
|
||||||
{ component: 'SchemaTemplateShortcut', pin: true },
|
{ component: 'SchemaTemplateShortcut', pin: true },
|
||||||
{ component: 'SystemSettingsShortcut' },
|
{ 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...
|
// test...
|
||||||
{
|
{
|
||||||
type: 'route',
|
type: 'route',
|
||||||
path: '/admin/workflow',
|
path: '/admin/workflows/:id',
|
||||||
component: 'WorkflowPage',
|
component: 'WorkflowPage',
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
"build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm"
|
"build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nocobase/actions": "^0.6.0-alpha.0",
|
||||||
"@nocobase/database": "^0.6.0-alpha.0",
|
"@nocobase/database": "^0.6.0-alpha.0",
|
||||||
"@nocobase/server": "^0.6.0-alpha.0",
|
"@nocobase/server": "^0.6.0-alpha.0",
|
||||||
"@nocobase/utils": "^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 post = await PostModel.create({ title: 't1' });
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
const [execution] = await workflow.getExecutions({ include: ['jobs'] });
|
||||||
const [job] = await execution.getJobs();
|
const [job] = execution.jobs;
|
||||||
expect(job.result).toBe(true);
|
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;
|
return a === b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notEqual(a, b) {
|
||||||
|
return a !== b;
|
||||||
|
}
|
||||||
|
|
||||||
function gt(a, b) {
|
function gt(a, b) {
|
||||||
return a > b;
|
return a > b;
|
||||||
}
|
}
|
||||||
@ -121,12 +125,14 @@ function lte(a, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
calculators.register('equal', equal);
|
calculators.register('equal', equal);
|
||||||
|
calculators.register('notEqual', notEqual);
|
||||||
calculators.register('gt', gt);
|
calculators.register('gt', gt);
|
||||||
calculators.register('gte', gte);
|
calculators.register('gte', gte);
|
||||||
calculators.register('lt', lt);
|
calculators.register('lt', lt);
|
||||||
calculators.register('lte', lte);
|
calculators.register('lte', lte);
|
||||||
|
|
||||||
calculators.register('===', equal);
|
calculators.register('===', equal);
|
||||||
|
calculators.register('!==', notEqual);
|
||||||
calculators.register('>', gt);
|
calculators.register('>', gt);
|
||||||
calculators.register('>=', gte);
|
calculators.register('>=', gte);
|
||||||
calculators.register('<', lt);
|
calculators.register('<', lt);
|
||||||
|
@ -9,12 +9,7 @@ export default {
|
|||||||
interface: 'string',
|
interface: 'string',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
name: 'title',
|
name: 'title',
|
||||||
title: '名称',
|
title: '名称'
|
||||||
component: {
|
|
||||||
showInTable: true,
|
|
||||||
showInDetail: true,
|
|
||||||
showInForm: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// which workflow belongs to
|
// which workflow belongs to
|
||||||
{
|
{
|
||||||
@ -34,7 +29,7 @@ export default {
|
|||||||
type: 'hasMany',
|
type: 'hasMany',
|
||||||
target: 'flow_nodes',
|
target: 'flow_nodes',
|
||||||
sourceKey: 'id',
|
sourceKey: 'id',
|
||||||
foreignKey: 'upstream_id',
|
foreignKey: 'upstreamId',
|
||||||
},
|
},
|
||||||
// only works when upstream node is branching type, such as condition and parallel.
|
// 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.
|
// put here because the design of flow-links model is not really necessary for now.
|
||||||
|
@ -36,7 +36,8 @@ export default {
|
|||||||
type: 'jsonb',
|
type: 'jsonb',
|
||||||
title: '触发配置',
|
title: '触发配置',
|
||||||
name: 'config',
|
name: 'config',
|
||||||
required: true
|
required: true,
|
||||||
|
defaultValue: {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interface: 'linkTo',
|
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, { calculate, Operand } from "../calculators";
|
||||||
import calculators from "../calculators";
|
|
||||||
import { JOB_STATUS } from "../constants";
|
import { JOB_STATUS } from "../constants";
|
||||||
|
|
||||||
type BaseCalculation = {
|
type BaseCalculation = {
|
||||||
|
@ -4,10 +4,7 @@ import { Plugin } from '@nocobase/server';
|
|||||||
|
|
||||||
import WorkflowModel from './models/Workflow';
|
import WorkflowModel from './models/Workflow';
|
||||||
import ExecutionModel from './models/Execution';
|
import ExecutionModel from './models/Execution';
|
||||||
|
import actions from './actions';
|
||||||
export * from './calculators';
|
|
||||||
export * from './triggers';
|
|
||||||
export * from './instructions';
|
|
||||||
|
|
||||||
export default class WorkflowPlugin extends Plugin {
|
export default class WorkflowPlugin extends Plugin {
|
||||||
async load(options = {}) {
|
async load(options = {}) {
|
||||||
@ -22,6 +19,8 @@ export default class WorkflowPlugin extends Plugin {
|
|||||||
directory: path.resolve(__dirname, 'collections'),
|
directory: path.resolve(__dirname, 'collections'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
actions(this.app);
|
||||||
|
|
||||||
// [Life Cycle]:
|
// [Life Cycle]:
|
||||||
// * load all workflows in db
|
// * load all workflows in db
|
||||||
// * add all hooks for enabled workflows
|
// * add all hooks for enabled workflows
|
||||||
|
@ -23,22 +23,28 @@ export default {
|
|||||||
on(this: WorkflowModel, callback: Function) {
|
on(this: WorkflowModel, callback: Function) {
|
||||||
const { database } = <typeof WorkflowModel>this.constructor;
|
const { database } = <typeof WorkflowModel>this.constructor;
|
||||||
const { collection, mode } = this.config;
|
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);
|
const handler = (data: any, options) => callback({ data: data.get() }, options);
|
||||||
// TODO: duplication when mode change should be considered
|
// TODO: duplication when mode change should be considered
|
||||||
for (let [key, event] of MODE_BITMAP_EVENTS.entries()) {
|
for (let [key, event] of MODE_BITMAP_EVENTS.entries()) {
|
||||||
if (mode & key) {
|
if (mode & key) {
|
||||||
model.addHook(event, this.getHookId(), handler);
|
Collection.model.addHook(event, this.getHookId(), handler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
off(this: WorkflowModel) {
|
off(this: WorkflowModel) {
|
||||||
const { database } = <typeof WorkflowModel>this.constructor;
|
const { database } = <typeof WorkflowModel>this.constructor;
|
||||||
const { collection, mode } = this.config;
|
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()) {
|
for (let [key, event] of MODE_BITMAP_EVENTS.entries()) {
|
||||||
if (mode & key) {
|
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);
|
return this.map.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getKeys(): Iterable<string> {
|
||||||
|
return this.map.keys();
|
||||||
|
}
|
||||||
|
|
||||||
getValues(): Iterable<T> {
|
getValues(): Iterable<T> {
|
||||||
return this.map.values();
|
return this.map.values();
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user