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:
Junyi 2022-03-27 15:51:48 +08:00 committed by GitHub
parent e2616aa927
commit b59a239a82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1937 additions and 33 deletions

View File

@ -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) {

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -1,2 +1,2 @@
import { RelationRepositoryActionBuilder } from './utils';
import { RelationRepositoryActionBuilder } from '../utils';
export const remove = RelationRepositoryActionBuilder('remove');

View File

@ -1,2 +1,2 @@
import { RelationRepositoryActionBuilder } from './utils';
import { RelationRepositoryActionBuilder } from '../utils';
export const set = RelationRepositoryActionBuilder('set');

View File

@ -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) {

View File

@ -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);

View File

@ -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 {

View File

@ -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;

View File

@ -48,6 +48,7 @@ const plugins = [
'@nocobase/plugin-users',
'@nocobase/plugin-acl',
'@nocobase/plugin-china-region',
'@nocobase/plugin-workflow',
];
for (const plugin of plugins) {

View File

@ -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,
},

View File

@ -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",

View File

@ -21,4 +21,4 @@ export * from './schema-templates';
export * from './settings-form';
export * from './system-settings';
export * from './user';
export * from './workflow';

View File

@ -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' },
]}

View 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} />
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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
}}
/>
);
};

View 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>
);
}

View File

@ -0,0 +1,5 @@
export * from './WorkflowPage';
export * from './WorkflowLink';
export * from './WorkflowTable';
export * from './WorkflowShortcut';
export * from './ExecutionResourceProvider';

View 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
}
};

View 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>
);
}

View 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>
)
}
};

View 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);
}
}
}
};

View 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'
},
}
}
}
}
}
}
}
}
};

View 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 }}',
},
},
}
}
}
}
}
}
}
}
}
};

View 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;
`;

View 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>
);
}

View 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);
}
}
}
};

View File

@ -37,7 +37,7 @@ export class UiRoutesStoragePlugin extends Plugin {
// test...
{
type: 'route',
path: '/admin/workflow',
path: '/admin/workflows/:id',
component: 'WorkflowPage',
},
// {

View File

@ -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",

View File

@ -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);
});

View 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();
}

View 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)
});
}

View File

@ -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);

View File

@ -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.

View File

@ -36,7 +36,8 @@ export default {
type: 'jsonb',
title: '触发配置',
name: 'config',
required: true
required: true,
defaultValue: {}
},
{
interface: 'linkTo',

View File

@ -0,0 +1,6 @@
export * from './constants';
export * from './calculators';
export * from './triggers';
export * from './instructions';
export { default } from './server';

View File

@ -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 = {

View File

@ -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

View File

@ -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());
}
}
}

View File

@ -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();
}