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:
parent
45d03d3ca5
commit
c018e5b913
@ -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': '拖拽中',
|
||||||
|
@ -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="workflow-toolbar">
|
||||||
|
<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}>
|
<div className={branchBlockClass}>
|
||||||
<Branch entry={entry} />
|
<Branch entry={entry} />
|
||||||
</div>
|
</div>
|
||||||
<div className={cx(nodeCardClass)}>{t('End')}</div>
|
<div className={cx(nodeCardClass)}>
|
||||||
|
<div className={cx(nodeMetaClass)}>
|
||||||
|
<Tag color="#333">{t('End')}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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,7 +220,9 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx(addButtonClass)}>
|
<div className={cx(addButtonClass)}>
|
||||||
<Dropdown trigger={['click']} overlay={
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
overlay={
|
||||||
<Menu onClick={ev => onCreate(ev)}>
|
<Menu onClick={ev => onCreate(ev)}>
|
||||||
{groups.map(group => (
|
{groups.map(group => (
|
||||||
<Menu.ItemGroup key={group.value} title={compile(group.name)}>
|
<Menu.ItemGroup key={group.value} title={compile(group.name)}>
|
||||||
@ -156,7 +240,9 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
|||||||
</Menu.ItemGroup>
|
</Menu.ItemGroup>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
}>
|
}
|
||||||
|
disabled={workflow.executed}
|
||||||
|
>
|
||||||
<Button shape="circle" icon={<PlusOutlined />} />
|
<Button shape="circle" icon={<PlusOutlined />} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,35 +1,17 @@
|
|||||||
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',
|
||||||
@ -38,68 +20,28 @@ export const WorkflowPage = () => {
|
|||||||
type: 'void',
|
type: 'void',
|
||||||
'x-decorator': 'ResourceActionProvider',
|
'x-decorator': 'ResourceActionProvider',
|
||||||
'x-decorator-props': {
|
'x-decorator-props': {
|
||||||
collection: workflowCollection,
|
collection: {
|
||||||
|
name: 'workflows',
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
resourceName: 'workflows',
|
resourceName: 'workflows',
|
||||||
request: {
|
request: {
|
||||||
resource: 'workflows',
|
resource: 'workflows',
|
||||||
action: 'get',
|
action: 'get',
|
||||||
params: {
|
params: {
|
||||||
filter: params,
|
filter: params,
|
||||||
appends: ['nodes'],
|
appends: ['nodes', 'revisions.id', 'revisions.createdAt', 'revisions.current', 'revisions.executed'],
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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: '{{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={{
|
components={{
|
||||||
TriggerConfig,
|
|
||||||
WorkflowCanvas,
|
WorkflowCanvas,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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{
|
||||||
|
@ -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';
|
|
||||||
|
|
||||||
|
@ -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{
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:not(:last-child){
|
||||||
margin-right: 1em;
|
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>
|
||||||
);
|
);
|
||||||
|
@ -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: {
|
||||||
|
|
||||||
|
@ -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,9 +78,11 @@ 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 (
|
||||||
|
<NodeContext.Provider value={data}>
|
||||||
<div className={cx(nodeBlockClass)}>
|
<div className={cx(nodeBlockClass)}>
|
||||||
{instruction.render
|
{instruction.render
|
||||||
? instruction.render(data)
|
? instruction.render(data)
|
||||||
@ -106,12 +114,15 @@ export function Node({ data }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</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,12 +160,13 @@ 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)}>
|
||||||
@ -174,7 +188,7 @@ export function NodeDefaultView(props) {
|
|||||||
view: instruction.view,
|
view: instruction.view,
|
||||||
config: {
|
config: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
title: '{{t("Configure")}}',
|
title: detailText,
|
||||||
'x-component': 'Action.Link',
|
'x-component': 'Action.Link',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
@ -182,7 +196,7 @@ export function NodeDefaultView(props) {
|
|||||||
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': {
|
||||||
@ -205,13 +219,25 @@ export function NodeDefaultView(props) {
|
|||||||
type: 'void',
|
type: 'void',
|
||||||
name: 'config',
|
name: 'config',
|
||||||
'x-component': 'fieldset',
|
'x-component': 'fieldset',
|
||||||
'x-component-props': {},
|
'x-component-props': {
|
||||||
|
disabled: workflow.executed
|
||||||
|
},
|
||||||
properties: instruction.fieldset
|
properties: instruction.fieldset
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-component': 'Action.Drawer.Footer',
|
'x-component': 'Action.Drawer.Footer',
|
||||||
properties: {
|
properties: workflow.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',
|
||||||
@ -239,6 +265,5 @@ export function NodeDefaultView(props) {
|
|||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</NodeContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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',
|
|
||||||
name: 'config.params',
|
|
||||||
title: '',
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
properties: {
|
|
||||||
filter: {
|
|
||||||
...filter,
|
...filter,
|
||||||
title: '{{t("Only update records matching conditions")}}',
|
title: '{{t("Only update records matching conditions")}}',
|
||||||
},
|
},
|
||||||
values
|
'config.params.values': values
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -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';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
}
|
145
packages/plugins/workflow/src/actions/workflows.ts
Normal file
145
packages/plugins/workflow/src/actions/workflows.ts
Normal 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();
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user