Feat(plugin workflow): revisions (#379)

* feat(plugin-workflow): avoid nodes to be added/removed/modified in executed workflow

* feat(plugin-workflow): add current field to workflow stand for current version

* feat(plugin-workflow): add duplicate action to workflow for revisions

* fix(plugin-workflow): fix relation field of workflow
This commit is contained in:
Junyi 2022-05-12 12:19:25 +08:00 committed by GitHub
parent 45d03d3ca5
commit c018e5b913
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 646 additions and 339 deletions

View File

@ -46,6 +46,7 @@ export default {
"Sign out": "注销", "Sign out": "注销",
"Cancel": "取消", "Cancel": "取消",
"Submit": "提交", "Submit": "提交",
"Close": "关闭",
"Set the data scope": "设置数据范围", "Set the data scope": "设置数据范围",
"Data blocks": "数据区块", "Data blocks": "数据区块",
"Table": "表格", "Table": "表格",
@ -412,8 +413,11 @@ export default {
'Trigger type': '触发方式', 'Trigger type': '触发方式',
'Description': '描述', 'Description': '描述',
'Status': '状态', 'Status': '状态',
'Enabled': '启用', 'Started': '启用',
'Disabled': '禁用', 'Stopped': '停用',
'Version': '版本',
'Copy to new version': '复制到新版本',
'Load failed': '加载失败', 'Load failed': '加载失败',
'Trigger': '触发器', 'Trigger': '触发器',
@ -477,6 +481,9 @@ export default {
'Please select collection first': '请先选择数据表', 'Please select collection first': '请先选择数据表',
'Only update records matching conditions': '只更新满足条件的数据', 'Only update records matching conditions': '只更新满足条件的数据',
'Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.': '未被赋值的字段将被设置为默认值,没有默认值的设置为空值。', 'Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.': '未被赋值的字段将被设置为默认值,没有默认值的设置为空值。',
'Trigger in executed workflow cannot be modified': '已经执行过工作流的触发器不能被修改',
'Node in executed workflow cannot be modified': '已经执行过工作流中的节点不能被修改',
'Unsaved changes': '未保存修改', 'Unsaved changes': '未保存修改',
'Are you sure you don\'t want to save?': '你确定不保存修改吗?', 'Are you sure you don\'t want to save?': '你确定不保存修改吗?',
'Dragging': '拖拽中', 'Dragging': '拖拽中',

View File

@ -1,17 +1,21 @@
import React, { useContext, useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import { Dropdown, Menu, Button } from 'antd'; import { Dropdown, Menu, Button, Tag, Switch } from 'antd';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined, DownOutlined, RightOutlined } from '@ant-design/icons';
import { cx } from '@emotion/css'; import { cx } from '@emotion/css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
useCollection, useAPIClient,
useCompile, useCompile,
useDocumentTitle, useDocumentTitle,
useResourceActionContext useRecord,
useResourceActionContext,
useResourceContext
} from '..'; } from '..';
import { Instruction, instructions, Node } from './nodes'; import { Instruction, instructions, Node } from './nodes';
import { addButtonClass, branchBlockClass, branchClass, nodeBlockClass, nodeCardClass, nodeHeaderClass, nodeTitleClass } from './style'; import { addButtonClass, branchBlockClass, branchClass, nodeCardClass, nodeMetaClass, workflowVersionDropdownClass } from './style';
import { TriggerConfig } from './triggers';
import { useHistory } from 'react-router-dom';
@ -38,8 +42,9 @@ export function useFlowContext() {
export function WorkflowCanvas() { export function WorkflowCanvas() {
const { t } = useTranslation(); const { t } = useTranslation();
const history = useHistory();
const { data, refresh, loading } = useResourceActionContext(); const { data, refresh, loading } = useResourceActionContext();
const { resource, targetKey } = useResourceContext();
const { setTitle } = useDocumentTitle(); const { setTitle } = useDocumentTitle();
useEffect(() => { useEffect(() => {
const { title } = data?.data ?? {}; const { title } = data?.data ?? {};
@ -50,12 +55,38 @@ export function WorkflowCanvas() {
return <div>{t('Load failed')}</div>; return <div>{t('Load failed')}</div>;
} }
const { nodes = [], ...workflow } = data?.data ?? {}; const { nodes = [], revisions = [], ...workflow } = data?.data ?? {};
makeNodes(nodes); makeNodes(nodes);
const entry = nodes.find(item => !item.upstream); const entry = nodes.find(item => !item.upstream);
function onSwitchVersion({ key }) {
if (key != workflow.id) {
history.push(key);
}
}
async function onToggle(value) {
await resource.update({
filterByTk: workflow[targetKey],
values: {
enabled: value,
// NOTE: keep `key` field to adapter for backend
key: workflow.key
}
});
refresh();
}
async function onDuplicate() {
const { data: { data: duplicated } } = await resource.duplicate({
filterByTk: workflow[targetKey]
});
history.push(duplicated.id);
}
return ( return (
<FlowContext.Provider value={{ <FlowContext.Provider value={{
workflow, workflow,
@ -63,10 +94,62 @@ export function WorkflowCanvas() {
onNodeAdded: refresh, onNodeAdded: refresh,
onNodeRemoved: refresh onNodeRemoved: refresh
}}> }}>
<div className={branchBlockClass}> <div className="workflow-toolbar">
<Branch entry={entry} /> <header>
<strong>{workflow.title}</strong>
</header>
<aside>
<div className="workflow-versions">
<label>{t('Version')}</label>
<Dropdown
trigger={['click']}
overlay={
<Menu
onClick={onSwitchVersion}
defaultSelectedKeys={[workflow.id]}
className={cx(workflowVersionDropdownClass)}
>
{revisions.sort((a, b) => b.id - a.id).map(item => (
<Menu.Item
key={item.id}
icon={item.current ? <RightOutlined /> : null}
className={item.executed ? 'executed' : 'unexecuted'}
>
<strong>{`#${item.id}`}</strong>
<time>{(new Date(item.createdAt)).toLocaleString()}</time>
</Menu.Item>
))}
</Menu>
}
>
<Button type="link">{workflow?.id ? `#${workflow.id}` : null}<DownOutlined /></Button>
</Dropdown>
</div>
<Switch
checked={workflow.enabled}
onChange={onToggle}
checkedChildren={t('Started')}
unCheckedChildren={t('Stopped')}
/>
{workflow.executed && !revisions.find(item => !item.executed && new Date(item.createdAt) > new Date(workflow.createdAt))
? (
<Button onClick={onDuplicate}>{t('Copy to new version')}</Button>
)
: null
}
</aside>
</div>
<div className="workflow-canvas">
<TriggerConfig />
<div className={branchBlockClass}>
<Branch entry={entry} />
</div>
<div className={cx(nodeCardClass)}>
<div className={cx(nodeMetaClass)}>
<Tag color="#333">{t('End')}</Tag>
</div>
</div>
</div> </div>
<div className={cx(nodeCardClass)}>{t('End')}</div>
</FlowContext.Provider> </FlowContext.Provider>
); );
} }
@ -104,9 +187,9 @@ interface AddButtonProps {
export function AddButton({ upstream, branchIndex = null }: AddButtonProps) { export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
const compile = useCompile(); const compile = useCompile();
const { resource } = useCollection(); const api = useAPIClient();
const { data } = useResourceActionContext(); const { workflow, onNodeAdded } = useFlowContext();
const { onNodeAdded } = useFlowContext(); const resource = api.resource('workflows.nodes', workflow.id);
async function onCreate({ keyPath }) { async function onCreate({ keyPath }) {
const type = keyPath.pop(); const type = keyPath.pop();
@ -120,7 +203,6 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
const { data: { data: node } } = await resource.create({ const { data: { data: node } } = await resource.create({
values: { values: {
type, type,
workflowId: data.data.id,
upstreamId: upstream?.id ?? null, upstreamId: upstream?.id ?? null,
branchIndex, branchIndex,
config config
@ -138,25 +220,29 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
return ( return (
<div className={cx(addButtonClass)}> <div className={cx(addButtonClass)}>
<Dropdown trigger={['click']} overlay={ <Dropdown
<Menu onClick={ev => onCreate(ev)}> trigger={['click']}
{groups.map(group => ( overlay={
<Menu.ItemGroup key={group.value} title={compile(group.name)}> <Menu onClick={ev => onCreate(ev)}>
{instructionList.filter(item => item.group === group.value).map(item => item.options {groups.map(group => (
? ( <Menu.ItemGroup key={group.value} title={compile(group.name)}>
<Menu.SubMenu key={item.type} title={compile(item.title)}> {instructionList.filter(item => item.group === group.value).map(item => item.options
{item.options.map(option => ( ? (
<Menu.Item key={option.key}>{compile(option.label)}</Menu.Item> <Menu.SubMenu key={item.type} title={compile(item.title)}>
))} {item.options.map(option => (
</Menu.SubMenu> <Menu.Item key={option.key}>{compile(option.label)}</Menu.Item>
) ))}
: ( </Menu.SubMenu>
<Menu.Item key={item.type}>{compile(item.title)}</Menu.Item> )
))} : (
</Menu.ItemGroup> <Menu.Item key={item.type}>{compile(item.title)}</Menu.Item>
))} ))}
</Menu> </Menu.ItemGroup>
}> ))}
</Menu>
}
disabled={workflow.executed}
>
<Button shape="circle" icon={<PlusOutlined />} /> <Button shape="circle" icon={<PlusOutlined />} />
</Dropdown> </Dropdown>
</div> </div>

View File

@ -9,6 +9,6 @@ export const WorkflowLink = () => {
const { id } = useRecord(); const { id } = useRecord();
const { setVisible } = useActionContext(); const { setVisible } = useActionContext();
return ( return (
<Link to={`/admin/plugins/workflows/${id}`} onClick={() => setVisible(false)}>{t('Configure')}</Link> <Link to={`/admin/plugins/workflows/${id}`} onClick={() => setVisible(false)}>{t('View')}</Link>
); );
} }

View File

@ -1,105 +1,47 @@
import { cx } from '@emotion/css'; import { cx } from '@emotion/css';
import { ISchema } from '@formily/react';
import React from 'react'; import React from 'react';
import { useRouteMatch } from 'react-router-dom'; import { useRouteMatch } from 'react-router-dom';
import { SchemaComponent } from '..'; import { SchemaComponent } from '..';
import { workflowPageClass } from './style'; import { workflowPageClass } from './style';
import { TriggerConfig } from './triggers';
import { WorkflowCanvas } from './WorkflowCanvas'; import { WorkflowCanvas } from './WorkflowCanvas';
const workflowCollection = {
name: 'workflow',
fields: [
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
title: '{{t("Name")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
],
};
export const WorkflowPage = () => { export const WorkflowPage = () => {
const { params } = useRouteMatch<any>(); const { params } = useRouteMatch<any>();
return ( return (
<div className={cx(workflowPageClass)}> <div className={cx(workflowPageClass)}>
<div className="workflow-canvas"> <SchemaComponent
<SchemaComponent schema={{
schema={{ type: 'void',
type: 'void', properties: {
properties: { [`provider_${params.id}`]: {
[`provider_${params.id}`]: { type: 'void',
type: 'void', 'x-decorator': 'ResourceActionProvider',
'x-decorator': 'ResourceActionProvider', 'x-decorator-props': {
'x-decorator-props': { collection: {
collection: workflowCollection, name: 'workflows',
resourceName: 'workflows', fields: [],
request: {
resource: 'workflows',
action: 'get',
params: {
filter: params,
appends: ['nodes'],
},
},
}, },
properties: { resourceName: 'workflows',
trigger: { request: {
type: 'void', resource: 'workflows',
'x-component': 'TriggerConfig', action: 'get',
}, params: {
nodes: { filter: params,
type: 'void', appends: ['nodes', 'revisions.id', 'revisions.createdAt', 'revisions.current', 'revisions.executed'],
'x-decorator': 'CollectionProvider',
'x-decorator-props': {
collection: {
name: 'flow_nodes',
fields: [
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
title: '{{t("Name")}}',
type: 'string',
'x-component': 'Input',
},
},
{
type: 'string',
name: 'type',
interface: 'select',
uiSchema: {
title: '{{t("Node type")}}',
type: 'string',
'x-component': 'Select',
required: true,
},
},
],
},
},
'x-component': 'WorkflowCanvas',
'x-component-props': {
// nodes
},
}, },
}, },
}, },
'x-component': 'WorkflowCanvas'
}, },
}} },
components={{ }}
TriggerConfig, components={{
WorkflowCanvas, WorkflowCanvas,
}} }}
/> />
</div>
</div> </div>
); );
}; };

View File

@ -1,24 +1,16 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { PartitionOutlined } from '@ant-design/icons'; import { PartitionOutlined } from '@ant-design/icons';
import { ISchema, useForm } from '@formily/react'; import { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { PluginManager } from '../plugin-manager';
import { ActionContext, SchemaComponent, useActionContext } from '../schema-component';
import { WorkflowTable } from './WorkflowTable';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PluginManager } from '../plugin-manager';
import { ActionContext, SchemaComponent } from '../schema-component';
import { workflowSchema } from './schemas/workflows';
import { WorkflowLink } from './WorkflowLink';
import { ExecutionResourceProvider } from './ExecutionResourceProvider';
const useCloseAction = () => {
const { setVisible } = useActionContext();
const form = useForm();
return {
async run() {
setVisible(false);
form.submit((values) => {
console.log(values);
});
},
};
};
const schema: ISchema = { const schema: ISchema = {
type: 'object', type: 'object',
@ -28,10 +20,7 @@ const schema: ISchema = {
type: 'void', type: 'void',
title: '{{t("Workflow")}}', title: '{{t("Workflow")}}',
properties: { properties: {
main: { table: workflowSchema,
type: 'void',
'x-component': 'WorkflowTable',
},
}, },
}, },
}, },
@ -52,9 +41,9 @@ export const WorkflowShortcut = () => {
<SchemaComponent <SchemaComponent
schema={schema} schema={schema}
components={{ components={{
WorkflowTable WorkflowLink,
ExecutionResourceProvider
}} }}
scope={{ useCloseAction }}
/> />
</ActionContext.Provider> </ActionContext.Provider>
); );

View File

@ -1,17 +0,0 @@
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

@ -261,7 +261,7 @@ export function Operand({
const { component, appendTypeValue } = Types[type] || {}; const { component, appendTypeValue } = Types[type] || {};
const VariableComponent = typeof component === 'function' ? component(operand) : NullRender; const VariableComponent = typeof component === 'function' ? component(operand) : NullRender;
console.log(Types);
return ( return (
<div className={css` <div className={css`
display: flex; display: flex;
@ -417,6 +417,7 @@ export const CollectionFieldset = observer(({ value, onChange }: any) => {
? parseStringValue(value[field.name], VTypes) ? parseStringValue(value[field.name], VTypes)
: { type: 'constant', value: value[field.name] }; : { type: 'constant', value: value[field.name] };
// TODO: try to use <ObjectField> to replace this map
return ( return (
<Form.Item key={field.name} label={compile(field.uiSchema?.title ?? field.name)} labelAlign="left" className={css` <Form.Item key={field.name} label={compile(field.uiSchema?.title ?? field.name)} labelAlign="left" className={css`
.ant-form-item-control-input-content{ .ant-form-item-control-input-content{

View File

@ -3,5 +3,3 @@ export * from './WorkflowLink';
export * from './WorkflowPage'; export * from './WorkflowPage';
export * from './WorkflowRouteProvider'; export * from './WorkflowRouteProvider';
export * from './WorkflowShortcut'; export * from './WorkflowShortcut';
export * from './WorkflowTable';

View File

@ -33,7 +33,7 @@ function CalculationItem({ value, onChange, onRemove }) {
) )
: <Calculation operands={operands} calculator={calculator} onChange={onChange} /> : <Calculation operands={operands} calculator={calculator} onChange={onChange} />
} }
<Button onClick={onRemove} type="text" icon={<CloseCircleOutlined />} /> <Button onClick={onRemove} type="link" icon={<CloseCircleOutlined />} />
</div> </div>
); );
} }
@ -111,12 +111,16 @@ function CalculationGroup({ value, onChange }) {
))} ))}
</div> </div>
<div className={css` <div className={css`
a:not(:last-child){ button{
margin-right: 1em; padding: 0;
&:not(:last-child){
margin-right: 1em;
}
} }
`} > `} >
<a onClick={onAddSingle}>{t('Add condition')}</a> <Button type="link" onClick={onAddSingle}>{t('Add condition')}</Button>
<a onClick={onAddGroup}>{t('Add condition group')}</a> <Button type="link" onClick={onAddGroup}>{t('Add condition group')}</Button>
</div> </div>
</div> </div>
); );

View File

@ -26,15 +26,7 @@ export default {
// disabled: true // disabled: true
// } // }
// }, // },
'config.params': { 'config.params.values': values
type: 'object',
name: 'config.params',
title: '',
'x-decorator': 'FormItem',
properties: {
values
}
}
}, },
view: { view: {

View File

@ -2,10 +2,10 @@ import { CloseOutlined, DeleteOutlined } from '@ant-design/icons';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { ISchema, useForm } from '@formily/react'; import { ISchema, useForm } from '@formily/react';
import { Registry } from '@nocobase/utils'; import { Registry } from '@nocobase/utils';
import { Button, Modal, Tag } from 'antd'; import { Button, message, Modal, Tag } from 'antd';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SchemaComponent, useActionContext, useAPIClient, useCollection, useCompile, useRequest, useResourceActionContext } from '../..'; import { SchemaComponent, useActionContext, useAPIClient, useCollection, useCompile, useRecord, useRequest, useResourceActionContext } from '../..';
import { nodeBlockClass, nodeCardClass, nodeClass, nodeHeaderClass, nodeMetaClass, nodeTitleClass } from '../style'; import { nodeBlockClass, nodeCardClass, nodeClass, nodeHeaderClass, nodeMetaClass, nodeTitleClass } from '../style';
import { AddButton, useFlowContext } from '../WorkflowCanvas'; import { AddButton, useFlowContext } from '../WorkflowCanvas';
@ -44,13 +44,19 @@ instructions.register('parallel', parallel);
instructions.register('calculation', calculation); instructions.register('calculation', calculation);
function useUpdateAction() { function useUpdateAction() {
const { t } = useTranslation();
const form = useForm(); const form = useForm();
const api = useAPIClient(); const api = useAPIClient();
const ctx = useActionContext(); const ctx = useActionContext();
const { refresh } = useResourceActionContext(); const { refresh } = useResourceActionContext();
const data = useNodeContext(); const data = useNodeContext();
const { workflow } = useFlowContext();
return { return {
async run() { async run() {
if (workflow.executed) {
message.error(t('Node in executed workflow cannot be modified'));
return;
}
await form.submit(); await form.submit();
await api.resource('flow_nodes', data.id).update({ await api.resource('flow_nodes', data.id).update({
filterByTk: data.id, filterByTk: data.id,
@ -72,46 +78,51 @@ export function useNodeContext() {
} }
export function Node({ data }) { export function Node({ data }) {
const instruction = instructions.get(data.type); const instruction = instructions.get(data.type);
return ( return (
<div className={cx(nodeBlockClass)}> <NodeContext.Provider value={data}>
{instruction.render <div className={cx(nodeBlockClass)}>
? instruction.render(data) {instruction.render
: <NodeDefaultView data={data} /> ? instruction.render(data)
} : <NodeDefaultView data={data} />
{!instruction.endding }
? <AddButton upstream={data} /> {!instruction.endding
: ( ? <AddButton upstream={data} />
<div : (
className={css` <div
flex-grow: 1; className={css`
display: flex; flex-grow: 1;
flex-direction: column; display: flex;
align-items: center; flex-direction: column;
justify-content: center; align-items: center;
width: 1px; justify-content: center;
height: 6em; width: 1px;
padding: 2em 0; height: 6em;
background-color: #f0f2f5; padding: 2em 0;
background-color: #f0f2f5;
.anticon{ .anticon{
font-size: 1.5em; font-size: 1.5em;
line-height: 100%; line-height: 100%;
} }
`} `}
> >
<CloseOutlined /> <CloseOutlined />
</div> </div>
) )
} }
</div> </div>
</NodeContext.Provider>
); );
} }
export function RemoveButton() { export function RemoveButton() {
const { t } = useTranslation(); const { t } = useTranslation();
const { resource } = useCollection(); const api = useAPIClient();
const { workflow } = useFlowContext();
const resource = api.resource('workflows.nodes', workflow.id);
const current = useNodeContext(); const current = useNodeContext();
const { nodes, onNodeRemoved } = useFlowContext(); const { nodes, onNodeRemoved } = useFlowContext();
@ -135,7 +146,9 @@ export function RemoveButton() {
}); });
} }
return ( return workflow.executed
? null
: (
<Button <Button
type="text" type="text"
shape="circle" shape="circle"
@ -147,98 +160,110 @@ export function RemoveButton() {
} }
export function NodeDefaultView(props) { export function NodeDefaultView(props) {
const { data, children } = props;
const compile = useCompile(); const compile = useCompile();
const { workflow } = useFlowContext();
const { data, children } = props;
const instruction = instructions.get(data.type); const instruction = instructions.get(data.type);
const detailText = workflow.executed ? '{{t("View")}}' : '{{t("Configure")}}';
return ( return (
<NodeContext.Provider value={data}> <div className={cx(nodeClass, `workflow-node-type-${data.type}`)}>
<div className={cx(nodeClass, `workflow-node-type-${data.type}`)}> <div className={cx(nodeCardClass)}>
<div className={cx(nodeCardClass)}> <div className={cx(nodeHeaderClass)}>
<div className={cx(nodeHeaderClass)}> <div className={cx(nodeMetaClass)}>
<div className={cx(nodeMetaClass)}> <Tag>{compile(instruction.title)}</Tag>
<Tag>{compile(instruction.title)}</Tag>
</div>
<h4 className={cx(nodeTitleClass)}>
<strong>{data.title}</strong>
<span className="workflow-node-id">#{data.id}</span>
</h4>
<RemoveButton />
</div> </div>
<SchemaComponent <h4 className={cx(nodeTitleClass)}>
scope={instruction.scope} <strong>{data.title}</strong>
components={instruction.components} <span className="workflow-node-id">#{data.id}</span>
schema={{ </h4>
type: 'void', <RemoveButton />
properties: { </div>
view: instruction.view, <SchemaComponent
config: { scope={instruction.scope}
type: 'void', components={instruction.components}
title: '{{t("Configure")}}', schema={{
'x-component': 'Action.Link', type: 'void',
'x-component-props': { properties: {
type: 'primary', view: instruction.view,
}, config: {
properties: { type: 'void',
drawer: { title: detailText,
type: 'void', 'x-component': 'Action.Link',
title: '{{t("Configure")}}', 'x-component-props': {
'x-component': 'Action.Drawer', type: 'primary',
'x-decorator': 'Form', },
'x-decorator-props': { properties: {
useValues(options) { drawer: {
const d = useNodeContext(); type: 'void',
return useRequest(() => { title: detailText,
return Promise.resolve({ data: d }); 'x-component': 'Action.Drawer',
}, options); 'x-decorator': 'Form',
} 'x-decorator-props': {
useValues(options) {
const d = useNodeContext();
return useRequest(() => {
return Promise.resolve({ data: d });
}, options);
}
},
properties: {
title: {
type: 'string',
name: 'title',
title: '{{t("Name")}}',
'x-decorator': 'FormItem',
'x-component': 'Input',
}, },
properties: { config: {
title: { type: 'void',
type: 'string', name: 'config',
name: 'title', 'x-component': 'fieldset',
title: '{{t("Name")}}', 'x-component-props': {
'x-decorator': 'FormItem', disabled: workflow.executed
'x-component': 'Input',
}, },
config: { properties: instruction.fieldset
type: 'void', },
name: 'config', actions: {
'x-component': 'fieldset', type: 'void',
'x-component-props': {}, 'x-component': 'Action.Drawer.Footer',
properties: instruction.fieldset properties: workflow.executed
}, ? {
actions: { close: {
type: 'void', title: '{{t("Close")}}',
'x-component': 'Action.Drawer.Footer', 'x-component': 'Action',
properties: { 'x-component-props': {
cancel: { useAction: '{{ cm.useCancelAction }}',
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
}, },
submit: { }
title: '{{t("Submit")}}', }
'x-component': 'Action', : {
'x-component-props': { cancel: {
type: 'primary', title: '{{t("Cancel")}}',
useAction: useUpdateAction, 'x-component': 'Action',
}, 'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
}, },
}, },
} as ISchema submit: {
} title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: useUpdateAction,
},
},
},
} as ISchema
} }
} }
} }
} }
}} }
/> }}
</div> />
{children}
</div> </div>
</NodeContext.Provider> {children}
</div>
); );
} }

View File

@ -8,19 +8,11 @@ export default {
group: 'collection', group: 'collection',
fieldset: { fieldset: {
'config.collection': collection, 'config.collection': collection,
'config.params': { 'config.params.filter': {
type: 'object', ...filter,
name: 'config.params', title: '{{t("Only update records matching conditions")}}',
title: '', },
'x-decorator': 'FormItem', 'config.params.values': values
properties: {
filter: {
...filter,
title: '{{t("Only update records matching conditions")}}',
},
values
}
}
}, },
view: { view: {

View File

@ -2,6 +2,8 @@ import { ISchema } from '@formily/react';
import { triggers } from '../triggers'; import { triggers } from '../triggers';
import { executionSchema } from './executions'; import { executionSchema } from './executions';
const collection = { const collection = {
name: 'workflows', name: 'workflows',
fields: [ fields: [
@ -50,8 +52,8 @@ const collection = {
title: '{{t("Status")}}', title: '{{t("Status")}}',
type: 'string', type: 'string',
enum: [ enum: [
{ label: '{{t("Enabled")}}', value: true }, { label: '{{t("Started")}}', value: true },
{ label: '{{t("Disabled")}}', value: false }, { label: '{{t("Stopped")}}', value: false },
], ],
'x-component': 'Radio.Group', 'x-component': 'Radio.Group',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
@ -75,7 +77,9 @@ export const workflowSchema: ISchema = {
action: 'list', action: 'list',
params: { params: {
pageSize: 50, pageSize: 50,
filter: {}, filter: {
current: true
},
sort: ['createdAt'], sort: ['createdAt'],
except: ['config'], except: ['config'],
}, },
@ -119,6 +123,11 @@ export const workflowSchema: ISchema = {
type: 'void', type: 'void',
'x-component': 'Action.Drawer', 'x-component': 'Action.Drawer',
'x-decorator': 'Form', 'x-decorator': 'Form',
'x-decorator-props': {
initialValue: {
current: true
}
},
title: '{{t("Add new")}}', title: '{{t("Add new")}}',
properties: { properties: {
title: { title: {
@ -138,14 +147,14 @@ export const workflowSchema: ISchema = {
'x-component': 'Action.Drawer.Footer', 'x-component': 'Action.Drawer.Footer',
properties: { properties: {
cancel: { cancel: {
title: 'Cancel', title: '{{ t("Cancel") }}',
'x-component': 'Action', 'x-component': 'Action',
'x-component-props': { 'x-component-props': {
useAction: '{{ cm.useCancelAction }}', useAction: '{{ cm.useCancelAction }}',
}, },
}, },
submit: { submit: {
title: 'Submit', title: '{{ t("Submit") }}',
'x-component': 'Action', 'x-component': 'Action',
'x-component-props': { 'x-component-props': {
type: 'primary', type: 'primary',

View File

@ -5,6 +5,26 @@ export const workflowPageClass = css`
width: 100%; width: 100%;
overflow: auto; overflow: auto;
.workflow-toolbar{
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: #fff;
header{
display: flex;
align-items: center;
gap: .5em;
}
aside{
display: flex;
align-items: center;
gap: .5em;
}
}
.workflow-canvas{ .workflow-canvas{
width: min-content; width: min-content;
min-width: 100%; min-width: 100%;
@ -15,6 +35,25 @@ export const workflowPageClass = css`
} }
`; `;
export const workflowVersionDropdownClass = css`
.ant-dropdown-menu-item{
&.unexecuted{
font-style: italic;
}
.ant-dropdown-menu-title-content{
text-align: right;
time{
margin-left: 0.5rem;
color: #999;
font-size: 80%;
}
}
}
`;
export const branchBlockClass = css` export const branchBlockClass = css`
display: flex; display: flex;
position: relative; position: relative;
@ -117,6 +156,10 @@ export const nodeCardClass = css`
opacity: 0; opacity: 0;
transition: opacity .3s ease; transition: opacity .3s ease;
&[disabled]{
display: none;
}
&:hover { &:hover {
color: red; color: red;
} }

View File

@ -2,15 +2,16 @@ import React from "react";
import { ISchema, useForm } from "@formily/react"; import { ISchema, useForm } from "@formily/react";
import { cx } from "@emotion/css"; import { cx } from "@emotion/css";
import { Registry } from "@nocobase/utils"; import { Registry } from "@nocobase/utils";
import { useTranslation } from "react-i18next";
import { message, Tag } from "antd";
import { SchemaComponent, useActionContext, useAPIClient, useCompile, useRecord, useRequest, useResourceActionContext } from '../../'; import { SchemaComponent, useActionContext, useAPIClient, useCompile, useRecord, useRequest, useResourceActionContext } from '../../';
import collection from './collection'; import collection from './collection';
import { nodeCardClass, nodeMetaClass } from "../style"; import { nodeCardClass, nodeMetaClass } from "../style";
import { useTranslation } from "react-i18next";
import { Tag } from "antd";
function useUpdateConfigAction() { function useUpdateConfigAction() {
const { t } = useTranslation();
const form = useForm(); const form = useForm();
const api = useAPIClient(); const api = useAPIClient();
const record = useRecord(); const record = useRecord();
@ -18,6 +19,10 @@ function useUpdateConfigAction() {
const { refresh } = useResourceActionContext(); const { refresh } = useResourceActionContext();
return { return {
async run() { async run() {
if (record.executed) {
message.error(t('Trigger in executed workflow cannot be modified'));
return;
}
await form.submit(); await form.submit();
await api.resource('workflows', record.id).update({ await api.resource('workflows', record.id).update({
filterByTk: record.id, filterByTk: record.id,
@ -53,8 +58,10 @@ export const TriggerConfig = () => {
if (!data) { if (!data) {
return null; return null;
} }
const { type, config } = data.data; const { type, config, executed } = data.data;
const { title, fieldset, scope, components } = triggers.get(type); const { title, fieldset, scope, components } = triggers.get(type);
const detailText = executed ? '{{t("View")}}' : '{{t("Configure")}}';
return ( return (
<div className={cx(nodeCardClass)}> <div className={cx(nodeCardClass)}>
<div className={cx(nodeMetaClass)}> <div className={cx(nodeMetaClass)}>
@ -64,13 +71,13 @@ export const TriggerConfig = () => {
<SchemaComponent <SchemaComponent
schema={{ schema={{
type: 'void', type: 'void',
title: '{{t("Configure")}}', title: detailText,
'x-component': 'Action.Link', 'x-component': 'Action.Link',
name: 'drawer', name: 'drawer',
properties: { properties: {
drawer: { drawer: {
type: 'void', type: 'void',
title: '{{t("Configure")}}', title: detailText,
'x-component': 'Action.Drawer', 'x-component': 'Action.Drawer',
'x-decorator': 'Form', 'x-decorator': 'Form',
'x-decorator-props': { 'x-decorator-props': {
@ -87,7 +94,17 @@ export const TriggerConfig = () => {
actions: { actions: {
type: 'void', type: 'void',
'x-component': 'Action.Drawer.Footer', 'x-component': 'Action.Drawer.Footer',
properties: { properties: executed
? {
close: {
title: '{{t("Close")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
}
}
: {
cancel: { cancel: {
title: '{{t("Cancel")}}', title: '{{t("Cancel")}}',
'x-component': 'Action', 'x-component': 'Action',

View File

@ -1,7 +1,6 @@
import { Application } from '@nocobase/server'; import { Application } from '@nocobase/server';
import Database from '@nocobase/database'; import Database from '@nocobase/database';
import { getApp } from '.'; import { getApp } from '.';
import { EXECUTION_STATUS } from '../constants';

View File

@ -1,4 +1,5 @@
import * as flow_nodes from './flow_nodes'; import * as workflows from './workflows';
import * as nodes from './nodes';
function make(name, mod) { function make(name, mod) {
return Object.keys(mod).reduce((result, key) => ({ return Object.keys(mod).reduce((result, key) => ({
@ -9,6 +10,13 @@ function make(name, mod) {
export default function(app) { export default function(app) {
app.actions({ app.actions({
...make('flow_nodes', flow_nodes) ...make('workflows', workflows),
...make('workflows.nodes', {
create: nodes.create,
destroy: nodes.destroy
}),
...make('flow_nodes', {
update: nodes.update
})
}); });
} }

View File

@ -1,10 +1,27 @@
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import actions, { Context, utils } from '@nocobase/actions'; import { Context, utils } from '@nocobase/actions';
import { MultipleRelationRepository } from '@nocobase/database';
import WorkflowModel from '../models/Workflow';
export async function create(context: Context, next) { export async function create(context: Context, next) {
return actions.create(context, async () => { const { db } = context;
const { body: instance, db } = context; const repository = utils.getRepositoryFromParams(context) as MultipleRelationRepository;
const repository = utils.getRepositoryFromParams(context); const { whitelist, blacklist, updateAssociationValues, values, associatedIndex: workflowId } = context.action.params;
context.body = await db.sequelize.transaction(async transaction => {
const workflow = await repository.getSourceModel(transaction) as WorkflowModel;
if (workflow.executed) {
context.throw(400, 'Node could not be created in executed workflow');
}
const instance = await repository.create({
values,
whitelist,
blacklist,
updateAssociationValues,
context,
transaction
});
if (!instance.upstreamId) { if (!instance.upstreamId) {
const previousHead = await repository.findOne({ const previousHead = await repository.findOne({
@ -13,30 +30,31 @@ export async function create(context: Context, next) {
$ne: instance.id $ne: instance.id
}, },
upstreamId: null upstreamId: null
} },
transaction
}); });
if (previousHead) { if (previousHead) {
await previousHead.setUpstream(instance); await previousHead.setUpstream(instance, { transaction });
await instance.setDownstream(previousHead); await instance.setDownstream(previousHead, { transaction });
instance.set('downstream', previousHead); instance.set('downstream', previousHead);
} }
return next(); return instance;
} }
const upstream = await instance.getUpstream(); const upstream = await instance.getUpstream({ transaction });
if (instance.branchIndex == null) { if (instance.branchIndex == null) {
const downstream = await upstream.getDownstream(); const downstream = await upstream.getDownstream({ transaction });
if (downstream) { if (downstream) {
await downstream.setUpstream(instance); await downstream.setUpstream(instance, { transaction });
await instance.setDownstream(downstream); await instance.setDownstream(downstream, { transaction });
instance.set('downstream', downstream); instance.set('downstream', downstream);
} }
await upstream.update({ await upstream.update({
downstreamId: instance.id downstreamId: instance.id
}); }, { transaction });
upstream.set('downstream', instance); upstream.set('downstream', instance);
} else { } else {
@ -46,23 +64,24 @@ export async function create(context: Context, next) {
[Op.ne]: instance.id [Op.ne]: instance.id
}, },
branchIndex: instance.branchIndex branchIndex: instance.branchIndex
} },
transaction
}); });
if (downstream) { if (downstream) {
await downstream.update({ await downstream.update({
upstreamId: instance.id, upstreamId: instance.id,
branchIndex: null branchIndex: null
}); }, { transaction });
await instance.setDownstream(downstream); await instance.setDownstream(downstream, { transaction });
instance.set('downstream', downstream); instance.set('downstream', downstream);
} }
} }
instance.set('upstream', upstream); instance.set('upstream', upstream);
await next();
}); });
await next();
} }
function searchBranchNodes(nodes, from): any[] { function searchBranchNodes(nodes, from): any[] {
@ -80,11 +99,16 @@ function searchBranchDownstreams(nodes, from) {
} }
export async function destroy(context: Context, next) { export async function destroy(context: Context, next) {
const repository = utils.getRepositoryFromParams(context);
const { db } = context; const { db } = context;
const repository = utils.getRepositoryFromParams(context) as MultipleRelationRepository;
const { filterByTk } = context.action.params; const { filterByTk } = context.action.params;
context.body = await db.sequelize.transaction(async transaction => { context.body = await db.sequelize.transaction(async transaction => {
const workflow = await repository.getSourceModel(transaction) as WorkflowModel;
if (workflow.executed) {
context.throw(400, 'Nodes in executed workflow could not be deleted');
}
const fields = ['id', 'upstreamId', 'downstreamId', 'branchIndex']; const fields = ['id', 'upstreamId', 'downstreamId', 'branchIndex'];
const instance = await repository.findOne({ const instance = await repository.findOne({
filterByTk, filterByTk,
@ -143,3 +167,33 @@ export async function destroy(context: Context, next) {
await next(); await next();
} }
export async function update(context: Context, next) {
const { db } = context;
const repository = utils.getRepositoryFromParams(context);
const { filterByTk, values, whitelist, blacklist, filter, updateAssociationValues } = context.action.params;
context.body = await db.sequelize.transaction(async transaction => {
// TODO(optimize): duplicated instance query
const { workflow } = await repository.findOne({
filterByTk,
appends: ['workflow.executed'],
transaction
});
if (workflow.executed) {
context.throw(400, 'Nodes in executed workflow could not be reconfigured');
}
return repository.update({
filterByTk,
values,
whitelist,
blacklist,
filter,
updateAssociationValues,
context,
transaction
});
});
await next();
}

View File

@ -0,0 +1,145 @@
import parse from 'json-templates';
import { Context, utils } from '@nocobase/actions';
import { Op } from '@nocobase/database';
export async function update(context: Context, next) {
const { db } = context;
const repository = utils.getRepositoryFromParams(context);
const { filterByTk, values, whitelist, blacklist, filter, updateAssociationValues } = context.action.params;
context.body = await db.sequelize.transaction(async transaction => {
const others: { enabled?: boolean, current?: boolean } = {};
if (values.enabled) {
values.current = true;
others.enabled = false;
}
if (values.current) {
others.current = false;
await repository.update({
filter: {
key: values.key,
id: {
[Op.ne]: filterByTk
}
},
values: others,
context,
transaction
});
}
const instance = await repository.update({
filterByTk,
values,
whitelist,
blacklist,
filter,
updateAssociationValues,
context,
transaction
});
return instance;
});
await next();
}
function typeOf(value) {
if (Array.isArray(value)) {
return 'array';
} else if (value instanceof Date) {
return 'date';
} else if (value === null) {
return 'null';
}
return typeof value;
}
function migrateConfig(config, oldToNew) {
function migrate(value) {
switch (typeOf(value)) {
case 'object':
return Object.keys(value).reduce((result, key) => ({ ...result, [key]: migrate(value[key]) }), {});
case 'array':
return value.map(item => migrate(item));
case 'string':
return value
.replace(
/(\{\{\$jobsMapByNodeId\.)(\d+)/,
(_, prefix, id) => `${prefix}${oldToNew.get(Number.parseInt(id, 10)).id}`
);
default:
return value;
}
}
return migrate(config);
}
export async function duplicate(context: Context, next) {
const { db } = context;
const repository = utils.getRepositoryFromParams(context);
const { filterByTk } = context.action.params;
context.body = await db.sequelize.transaction(async transaction => {
const origin = await repository.findOne({
filterByTk,
appends: ['nodes'],
context,
transaction
});
const instance = await repository.create({
values: {
key: origin.key,
title: origin.title,
description: origin.description,
type: origin.type,
config: origin.config
},
transaction
});
const originalNodesMap = new Map();
origin.nodes.forEach((node) => {
originalNodesMap.set(node.id, node);
});
const oldToNew = new Map();
const newToOld = new Map();
for await (const node of origin.nodes) {
const newNode = await instance.createNode({
type: node.type,
config: node.config,
title: node.title,
branchIndex: node.branchIndex
}, { transaction });
// NOTE: keep original node references for later replacement
oldToNew.set(node.id, newNode);
newToOld.set(newNode.id, node);
}
for await (const [oldId, newNode] of oldToNew.entries()) {
const oldNode = originalNodesMap.get(oldId);
const newUpstream = oldNode.upstreamId ? oldToNew.get(oldNode.upstreamId) : null;
const newDownstream = oldNode.downstreamId ? oldToNew.get(oldNode.downstreamId) : null;
await newNode.update({
upstreamId: newUpstream?.id ?? null,
downstreamId: newDownstream?.id ?? null,
config: migrateConfig(oldNode.config, oldToNew)
}, { transaction });
}
return instance;
});
await next();
}

View File

@ -5,6 +5,10 @@ export default {
model: 'WorkflowModel', model: 'WorkflowModel',
title: '自动化', title: '自动化',
fields: [ fields: [
{
name: 'key',
type: 'uid'
},
{ {
interface: 'string', interface: 'string',
type: 'string', type: 'string',
@ -66,15 +70,19 @@ export default {
name: 'executed', name: 'executed',
defaultValue: false defaultValue: false
}, },
{
type: 'boolean',
name: 'current',
defaultValue: false
},
{ {
type: 'hasMany', type: 'hasMany',
name: 'revisions', name: 'revisions',
target: 'workflows', target: 'workflows',
}, foreignKey: 'key',
{ sourceKey: 'key',
type: 'belongsTo', // NOTE: no constraints needed here because tricky self-referencing
name: 'current', constraints: false
target: 'workflows'
} }
] ]
} as CollectionOptions; } as CollectionOptions;

View File

@ -16,6 +16,7 @@ export default class WorkflowModel extends Model {
declare type: string; declare type: string;
declare config: any; declare config: any;
declare useTransaction: boolean; declare useTransaction: boolean;
declare executed: boolean;
declare createdAt: Date; declare createdAt: Date;
declare updatedAt: Date; declare updatedAt: Date;
@ -101,6 +102,10 @@ export default class WorkflowModel extends Model {
await execution.start({ transaction }); await execution.start({ transaction });
if (!this.executed) {
await this.update({ executed: true }, { transaction });
}
if (transaction && (!options.transaction || options.transaction.finished)) { if (transaction && (!options.transaction || options.transaction.finished)) {
await transaction.commit(); await transaction.commit();
} }