feat: plugin workflow visualization (#987)
* feat(plugin-workfow): adjust some ui * feat(plugin-workflow): add execution visualization * fix(plugin-workflow): fix changed component
This commit is contained in:
parent
ed6f9a0867
commit
7cb5ff554e
@ -12,6 +12,7 @@ export * from './icon';
|
||||
export * from './id';
|
||||
export * from './input';
|
||||
export * from './integer';
|
||||
export * from './json';
|
||||
export * from './linkTo';
|
||||
export * from './m2m';
|
||||
export * from './m2o';
|
||||
|
@ -0,0 +1,48 @@
|
||||
import { defaultProps, operators, unique } from './properties';
|
||||
import { IField } from './types';
|
||||
import { registerValidateRules } from '@formily/core';
|
||||
|
||||
registerValidateRules({
|
||||
json(value) {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const json: IField = {
|
||||
name: 'json',
|
||||
type: 'object',
|
||||
group: 'advanced',
|
||||
order: 3,
|
||||
title: '{{t("JSON")}}',
|
||||
sortable: true,
|
||||
default: {
|
||||
type: 'json',
|
||||
// name,
|
||||
uiSchema: {
|
||||
type: 'object',
|
||||
// title,
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
autoSize: {
|
||||
minRows: 5,
|
||||
// maxRows: 20,
|
||||
},
|
||||
},
|
||||
default: null
|
||||
},
|
||||
},
|
||||
hasDefaultValue: true,
|
||||
properties: {
|
||||
...defaultProps,
|
||||
},
|
||||
filterable: {
|
||||
}
|
||||
};
|
@ -416,7 +416,7 @@ export default {
|
||||
"Tencent COS": "Tencent COS",
|
||||
"Amazon S3": "Amazon S3",
|
||||
"Workflow": "Workflow",
|
||||
"Execution History": "Execution History",
|
||||
"Execution history": "Execution history",
|
||||
"Trigger type": "Trigger type",
|
||||
"Description": "Description",
|
||||
"Status": "Status",
|
||||
|
@ -416,7 +416,7 @@ export default {
|
||||
"Tencent COS": "Tencent COS",
|
||||
"Amazon S3": "Amazon S3",
|
||||
"Workflow": "ワークフロー",
|
||||
"Execution History": "実行履歴",
|
||||
"Execution history": "実行履歴",
|
||||
"Trigger type": "トリガータイプ",
|
||||
"Description": "説明",
|
||||
"Status": "状態",
|
||||
|
@ -416,7 +416,7 @@ export default {
|
||||
"Tencent COS": "Tencent COS",
|
||||
"Amazon S3": "Amazon S3",
|
||||
"Workflow": "Workflow",
|
||||
"Execution History": "История запусков",
|
||||
"Execution history": "История запусков",
|
||||
"Trigger type": "Тип триггера",
|
||||
"Description": "Описание",
|
||||
"Status": "Статус",
|
||||
|
@ -415,7 +415,7 @@ export default {
|
||||
"Aliyun OSS": "Aliyun OSS",
|
||||
"Amazon S3": "Amazon S3",
|
||||
"Workflow": "İş Akışı",
|
||||
"Execution History": "Yürütme Geçmişi",
|
||||
"Execution history": "Yürütme Geçmişi",
|
||||
"Trigger type": "Tetikleme türü",
|
||||
"Description": "Açıklama",
|
||||
"Status": "Durum",
|
||||
|
@ -510,7 +510,7 @@ export default {
|
||||
|
||||
// plugins/workflow
|
||||
'Workflow': '工作流',
|
||||
'Execution History': '执行历史',
|
||||
'Execution history': '执行历史',
|
||||
'Trigger type': '触发方式',
|
||||
'Description': '描述',
|
||||
'Status': '状态',
|
||||
@ -519,9 +519,11 @@ export default {
|
||||
'Version': '版本',
|
||||
'Copy to new version': '复制到新版本',
|
||||
|
||||
'Loading': '加载中',
|
||||
'Load failed': '加载失败',
|
||||
|
||||
'Trigger': '触发器',
|
||||
'Triggered at': '触发时间',
|
||||
'Collection event': '数据表事件',
|
||||
'Trigger on': '触发时机',
|
||||
'After record added': '新增数据后',
|
||||
@ -578,9 +580,13 @@ export default {
|
||||
'Arithmetic calculation': '算术运算',
|
||||
'String operation': '字符串',
|
||||
|
||||
'Executed at': '执行于',
|
||||
|
||||
'Queueing': '队列中',
|
||||
'On going': '进行中',
|
||||
'Succeeded': '成功',
|
||||
'Failed': '失败',
|
||||
'Pending': '等待处理',
|
||||
'Canceled': '已取消',
|
||||
|
||||
'This node contains branches, deleting will also be preformed to them, are you sure?': '节点包含分支,将同时删除其所有分支下的子节点,确定继续?',
|
||||
|
@ -177,6 +177,7 @@ const InternalAdminLayout = (props: any) => {
|
||||
<Layout.Content
|
||||
className={css`
|
||||
min-height: calc(100vh - 46px);
|
||||
padding-bottom: 42px;
|
||||
position: relative;
|
||||
// padding-bottom: 70px;
|
||||
> div {
|
||||
|
@ -8,7 +8,7 @@ import { useActionContext } from '.';
|
||||
import { ComposedActionDrawer } from './types';
|
||||
|
||||
export const ActionModal: ComposedActionDrawer = observer((props) => {
|
||||
const { footerNodeName = 'Action.Modal.Footer', ...others } = props;
|
||||
const { footerNodeName = 'Action.Modal.Footer', width = '80%', ...others } = props;
|
||||
const { visible, setVisible } = useActionContext();
|
||||
const schema = useFieldSchema();
|
||||
const field = useField();
|
||||
@ -27,7 +27,7 @@ export const ActionModal: ComposedActionDrawer = observer((props) => {
|
||||
}}
|
||||
>
|
||||
<Modal
|
||||
width={'80%'}
|
||||
width={width}
|
||||
title={field.title}
|
||||
{...others}
|
||||
destroyOnClose
|
||||
|
@ -27,7 +27,7 @@ export const Checkbox: ComposedCheckbox = connect(
|
||||
),
|
||||
mapReadPretty((props) => {
|
||||
if (!isValid(props.value)) {
|
||||
return <div></div>;
|
||||
return null;
|
||||
}
|
||||
return props.value ? <CheckOutlined style={{ color: '#52c41a' }} /> : null;
|
||||
}),
|
||||
@ -42,7 +42,7 @@ Checkbox.Group = connect(
|
||||
}),
|
||||
mapReadPretty((props) => {
|
||||
if (!isValid(props.value)) {
|
||||
return <div></div>;
|
||||
return null;
|
||||
}
|
||||
const { options = [] } = props;
|
||||
const field = useField<any>();
|
||||
@ -53,7 +53,7 @@ Checkbox.Group = connect(
|
||||
{dataSource
|
||||
.filter((option) => value.includes(option.value))
|
||||
.map((option, key) => (
|
||||
<Tag key={key} color={option.color}>
|
||||
<Tag key={key} color={option.color} icon={option.icon}>
|
||||
{option.label}
|
||||
</Tag>
|
||||
))}
|
||||
|
@ -4,10 +4,12 @@ import { Input as AntdInput } from 'antd';
|
||||
import { InputProps, TextAreaProps } from 'antd/lib/input';
|
||||
import React from 'react';
|
||||
import { ReadPretty } from './ReadPretty';
|
||||
import { Json } from './Json';
|
||||
|
||||
type ComposedInput = React.FC<InputProps> & {
|
||||
TextArea?: React.FC<TextAreaProps>;
|
||||
URL?: React.FC<InputProps>;
|
||||
JSON?: React.FC<TextAreaProps>;
|
||||
};
|
||||
|
||||
export const Input: ComposedInput = connect(
|
||||
@ -34,6 +36,9 @@ Input.TextArea = connect(
|
||||
}),
|
||||
mapReadPretty(ReadPretty.TextArea),
|
||||
);
|
||||
|
||||
Input.URL = connect(AntdInput, mapReadPretty(ReadPretty.URL));
|
||||
|
||||
Input.JSON = connect(Json, mapReadPretty(ReadPretty.JSON));
|
||||
|
||||
export default Input;
|
||||
|
@ -0,0 +1,28 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Field } from '@formily/core';
|
||||
import { useField } from '@formily/react';
|
||||
import { Input } from 'antd';
|
||||
import { TextAreaProps } from 'antd/lib/input';
|
||||
|
||||
export function Json({ value, onChange, space = 2, ...props }: TextAreaProps & { value: any, space: number }) {
|
||||
const field = useField<Field>();
|
||||
return (
|
||||
<Input.TextArea
|
||||
{...props}
|
||||
defaultValue={value != null ? JSON.stringify(value, null, space) : ''}
|
||||
onChange={(ev) => {
|
||||
try {
|
||||
const v = ev.target.value.trim() !== '' ? JSON.parse(ev.target.value) : null;
|
||||
field.setFeedback({});
|
||||
onChange(v);
|
||||
} catch (err) {
|
||||
field.setFeedback({
|
||||
type: 'error',
|
||||
code: 'JSONSyntaxError',
|
||||
messages: [err.message],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -5,6 +5,7 @@ import React from 'react';
|
||||
import { useCompile } from '../..';
|
||||
import { EllipsisWithTooltip } from './EllipsisWithTooltip';
|
||||
import { HTMLEncode } from './shared';
|
||||
import { cx, css } from '@emotion/css';
|
||||
|
||||
type Composed = {
|
||||
Input: React.FC<InputProps & { ellipsis?: any }>;
|
||||
@ -13,6 +14,7 @@ type Composed = {
|
||||
TextAreaProps & { ellipsis?: any; text?: any; addonBefore?: any; suffix?: any; addonAfter?: any; autop?: boolean }
|
||||
>;
|
||||
Html?: any;
|
||||
JSON?: React.FC<TextAreaProps & { space: number }>;
|
||||
};
|
||||
|
||||
export const ReadPretty: Composed = () => null;
|
||||
@ -114,3 +116,17 @@ ReadPretty.URL = (props) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReadPretty.JSON = (props) => {
|
||||
const prefixCls = usePrefixCls('json', props);
|
||||
return (
|
||||
<pre
|
||||
className={cx(prefixCls, props.className, css`
|
||||
margin-bottom: 0;
|
||||
`)}
|
||||
style={props.style}
|
||||
>
|
||||
{props.value != null ? JSON.stringify(props.value, null, props.space ?? 2) : ''}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* title: URL
|
||||
*/
|
||||
import { FormItem } from '@formily/antd';
|
||||
import { Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: {
|
||||
type: 'object',
|
||||
title: `Editable`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.JSON',
|
||||
'x-reactions': {
|
||||
target: 'read',
|
||||
fulfill: {
|
||||
state: {
|
||||
value: '{{$self.value}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
read: {
|
||||
type: 'string',
|
||||
title: `Read pretty`,
|
||||
'x-read-pretty': true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.JSON',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<SchemaComponentProvider components={{ Input, FormItem }}>
|
||||
<SchemaComponent schema={schema} />
|
||||
</SchemaComponentProvider>
|
||||
);
|
||||
};
|
@ -26,3 +26,7 @@ group:
|
||||
### URL
|
||||
|
||||
<code src="./demos/demo3.tsx" />
|
||||
|
||||
### JSON
|
||||
|
||||
<code src="./demos/demo4.tsx" />
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from './Input';
|
||||
export * from './ReadPretty';
|
||||
export * from './Json';
|
||||
|
@ -35,7 +35,7 @@ Radio.Group = connect(
|
||||
{dataSource
|
||||
.filter((option) => option.value === value)
|
||||
.map((option, key) => (
|
||||
<Tag key={key} color={option.color}>
|
||||
<Tag key={key} color={option.color} icon={option.icon}>
|
||||
{option.label}
|
||||
</Tag>
|
||||
))}
|
||||
|
@ -24,7 +24,7 @@ export const ReadPretty = observer((props: any) => {
|
||||
return (
|
||||
<div>
|
||||
{options.map((option, key) => (
|
||||
<Tag key={key} color={option[fieldNames.color]}>
|
||||
<Tag key={key} color={option[fieldNames.color]} icon={option.icon}>
|
||||
{option[fieldNames.label]}
|
||||
</Tag>
|
||||
))}
|
||||
|
@ -15,6 +15,7 @@
|
||||
"@nocobase/database": "0.8.0-alpha.1",
|
||||
"@nocobase/server": "0.8.0-alpha.1",
|
||||
"@nocobase/utils": "0.8.0-alpha.1",
|
||||
"classnames": "^2.3.1",
|
||||
"cron-parser": "4.4.0",
|
||||
"json-templates": "^4.2.0",
|
||||
"react-js-cron": "^1.4.0"
|
||||
|
84
packages/plugins/workflow/src/client/AddButton.tsx
Normal file
84
packages/plugins/workflow/src/client/AddButton.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { cx } from '@emotion/css';
|
||||
import { Dropdown, Menu, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
useAPIClient,
|
||||
useCompile
|
||||
} from '@nocobase/client';
|
||||
import { useFlowContext } from './FlowContext';
|
||||
import { Instruction, instructions, Node } from './nodes';
|
||||
import { addButtonClass } from './style';
|
||||
|
||||
|
||||
interface AddButtonProps {
|
||||
upstream;
|
||||
branchIndex?: number;
|
||||
};
|
||||
|
||||
export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
||||
const compile = useCompile();
|
||||
const api = useAPIClient();
|
||||
const { workflow, onNodeAdded } = useFlowContext() ?? {};
|
||||
if (!workflow) {
|
||||
return null;
|
||||
}
|
||||
const resource = api.resource('workflows.nodes', workflow.id);
|
||||
|
||||
async function onCreate({ keyPath }) {
|
||||
const type = keyPath.pop();
|
||||
const config = {};
|
||||
const [optionKey] = keyPath;
|
||||
if (optionKey) {
|
||||
const { value } = instructions.get(type).options.find(item => item.key === optionKey);
|
||||
Object.assign(config, value);
|
||||
}
|
||||
|
||||
const { data: { data: node } } = await resource.create({
|
||||
values: {
|
||||
type,
|
||||
upstreamId: upstream?.id ?? null,
|
||||
branchIndex,
|
||||
config
|
||||
}
|
||||
});
|
||||
|
||||
onNodeAdded(node);
|
||||
}
|
||||
|
||||
const groups = [
|
||||
{ value: 'control', name: '{{t("Control")}}' },
|
||||
{ value: 'collection', name: '{{t("Collection operations")}}' },
|
||||
];
|
||||
const instructionList = (Array.from(instructions.getValues()) as Instruction[]);
|
||||
|
||||
return (
|
||||
<div className={cx(addButtonClass)}>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
overlay={
|
||||
<Menu onClick={ev => onCreate(ev)}>
|
||||
{groups.map(group => (
|
||||
<Menu.ItemGroup key={group.value} title={compile(group.name)}>
|
||||
{instructionList.filter(item => item.group === group.value).map(item => item.options
|
||||
? (
|
||||
<Menu.SubMenu key={item.type} title={compile(item.title)}>
|
||||
{item.options.map(option => (
|
||||
<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>
|
||||
}
|
||||
disabled={workflow.executed}
|
||||
>
|
||||
<Button shape="circle" icon={<PlusOutlined />} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
28
packages/plugins/workflow/src/client/Branch.tsx
Normal file
28
packages/plugins/workflow/src/client/Branch.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { cx } from '@emotion/css';
|
||||
import { branchClass } from "./style";
|
||||
import { AddButton } from "./AddButton";
|
||||
import { Node } from './nodes';
|
||||
|
||||
export function Branch({
|
||||
from = null,
|
||||
entry = null,
|
||||
branchIndex = null,
|
||||
controller = null
|
||||
}) {
|
||||
const list = [];
|
||||
for (let node = entry; node; node = node.downstream) {
|
||||
list.push(node);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(branchClass)}>
|
||||
<div className="workflow-branch-lines" />
|
||||
{controller}
|
||||
<AddButton upstream={from} branchIndex={branchIndex} />
|
||||
<div className="workflow-node-list">
|
||||
{list.map(item => <Node data={item} key={item.id} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
111
packages/plugins/workflow/src/client/ExecutionCanvas.tsx
Normal file
111
packages/plugins/workflow/src/client/ExecutionCanvas.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Tag } from 'antd';
|
||||
import { cx } from '@emotion/css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
useCompile,
|
||||
useDocumentTitle,
|
||||
useResourceActionContext,
|
||||
} from '@nocobase/client';
|
||||
import { str2moment } from '@nocobase/utils/client';
|
||||
|
||||
import { FlowContext } from './FlowContext';
|
||||
import { branchBlockClass, nodeCardClass, nodeMetaClass } from './style';
|
||||
import { TriggerConfig } from './triggers';
|
||||
import { Branch } from './Branch';
|
||||
import { ExecutionStatusOptionsMap } from './constants';
|
||||
|
||||
|
||||
|
||||
|
||||
function makeNodes(nodes, jobs = []): void {
|
||||
const nodesMap = new Map();
|
||||
nodes.forEach(item => nodesMap.set(item.id, item));
|
||||
const jobsMap = new Map();
|
||||
jobs.forEach(item => jobsMap.set(item.nodeId, item));
|
||||
for (let node of nodesMap.values()) {
|
||||
if (node.upstreamId) {
|
||||
node.upstream = nodesMap.get(node.upstreamId);
|
||||
}
|
||||
|
||||
if (node.downstreamId) {
|
||||
node.downstream = nodesMap.get(node.downstreamId);
|
||||
}
|
||||
|
||||
if (jobsMap.has(node.id)) {
|
||||
node.job = jobsMap.get(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ExecutionCanvas() {
|
||||
const { t } = useTranslation();
|
||||
const compile = useCompile();
|
||||
const { data, refresh, loading } = useResourceActionContext();
|
||||
const { setTitle } = useDocumentTitle();
|
||||
useEffect(() => {
|
||||
const { workflow } = data?.data ?? {};
|
||||
setTitle(`${workflow?.title ? `${workflow.title} - ` : ''}${t('Execution history')}`);
|
||||
}, [data?.data]);
|
||||
|
||||
if (!data?.data) {
|
||||
if (loading) {
|
||||
return <div>{t('Loading')}</div>
|
||||
} else {
|
||||
return <div>{t('Load failed')}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
jobs = [],
|
||||
workflow: { nodes = [], revisions = [], ...workflow } = {},
|
||||
...execution
|
||||
} = data?.data ?? {};
|
||||
|
||||
makeNodes(nodes, jobs);
|
||||
|
||||
const entry = nodes.find(item => !item.upstream);
|
||||
|
||||
const statusOption = ExecutionStatusOptionsMap[execution.status];
|
||||
|
||||
return (
|
||||
<FlowContext.Provider value={{
|
||||
workflow: workflow.type ? workflow : null,
|
||||
nodes,
|
||||
execution
|
||||
}}>
|
||||
<div className="workflow-toolbar">
|
||||
<header>
|
||||
<span>
|
||||
<Link to={`/admin/settings/workflow/workflows`}>
|
||||
{t('Workflow')}
|
||||
</Link>
|
||||
</span>
|
||||
<span>
|
||||
<Link to={`/admin/settings/workflow/workflows/${workflow.id}`}>
|
||||
{workflow.title}
|
||||
</Link>
|
||||
</span>
|
||||
<strong>{`#${execution.id}`}</strong>
|
||||
</header>
|
||||
<aside>
|
||||
<Tag color={statusOption.color}>{compile(statusOption.label)}</Tag>
|
||||
<time>{str2moment(execution.updatedAt).format('YYYY-MM-DD HH:mm:ss')}</time>
|
||||
</aside>
|
||||
</div>
|
||||
<div className="workflow-canvas">
|
||||
<TriggerConfig workflow={workflow} />
|
||||
<div className={branchBlockClass}>
|
||||
<Branch entry={entry} />
|
||||
</div>
|
||||
<div className={cx(nodeCardClass)}>
|
||||
<div className={cx(nodeMetaClass)}>
|
||||
<Tag color="#333">{t('End')}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FlowContext.Provider>
|
||||
);
|
||||
}
|
15
packages/plugins/workflow/src/client/ExecutionLink.tsx
Normal file
15
packages/plugins/workflow/src/client/ExecutionLink.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useActionContext, useRecord } from '@nocobase/client';
|
||||
|
||||
|
||||
export const ExecutionLink = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useRecord();
|
||||
const { setVisible } = useActionContext();
|
||||
return (
|
||||
<Link to={`/admin/settings/workflow/executions/${id}`} onClick={() => setVisible(false)}>{t('View')}</Link>
|
||||
);
|
||||
}
|
47
packages/plugins/workflow/src/client/ExecutionPage.tsx
Normal file
47
packages/plugins/workflow/src/client/ExecutionPage.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { cx } from '@emotion/css';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
import { workflowPageClass } from './style';
|
||||
import { ExecutionCanvas } from './ExecutionCanvas';
|
||||
|
||||
|
||||
|
||||
export const ExecutionPage = () => {
|
||||
const { params } = useRouteMatch<any>();
|
||||
|
||||
return (
|
||||
<div className={cx(workflowPageClass)}>
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
[`execution_${params.id}`]: {
|
||||
type: 'void',
|
||||
'x-decorator': 'ResourceActionProvider',
|
||||
'x-decorator-props': {
|
||||
collection: {
|
||||
name: 'executions',
|
||||
fields: [],
|
||||
},
|
||||
resourceName: 'executions',
|
||||
request: {
|
||||
resource: 'executions',
|
||||
action: 'get',
|
||||
params: {
|
||||
filter: params,
|
||||
appends: ['jobs', 'workflow', 'workflow.nodes'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'x-component': 'ExecutionCanvas'
|
||||
},
|
||||
},
|
||||
}}
|
||||
components={{
|
||||
ExecutionCanvas,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
8
packages/plugins/workflow/src/client/FlowContext.ts
Normal file
8
packages/plugins/workflow/src/client/FlowContext.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
|
||||
export const FlowContext = React.createContext(null);
|
||||
|
||||
export function useFlowContext() {
|
||||
return useContext(FlowContext);
|
||||
}
|
@ -1,21 +1,21 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { Dropdown, Menu, Button, Tag, Switch, message } from 'antd';
|
||||
import { PlusOutlined, DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { cx } from '@emotion/css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import {
|
||||
useAPIClient,
|
||||
useCompile,
|
||||
useDocumentTitle,
|
||||
useResourceActionContext,
|
||||
useResourceContext
|
||||
} from '@nocobase/client';
|
||||
|
||||
import { Instruction, instructions, Node } from './nodes';
|
||||
import { addButtonClass, branchBlockClass, branchClass, nodeCardClass, nodeMetaClass, workflowVersionDropdownClass } from './style';
|
||||
import { FlowContext } from './FlowContext';
|
||||
import { branchBlockClass, nodeCardClass, nodeMetaClass, workflowVersionDropdownClass } from './style';
|
||||
import { TriggerConfig } from './triggers';
|
||||
import { Branch } from './Branch';
|
||||
|
||||
|
||||
|
||||
@ -34,12 +34,6 @@ function makeNodes(nodes): void {
|
||||
}
|
||||
}
|
||||
|
||||
const FlowContext = React.createContext(null);
|
||||
|
||||
export function useFlowContext() {
|
||||
return useContext(FlowContext);
|
||||
}
|
||||
|
||||
export function WorkflowCanvas() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
@ -48,7 +42,7 @@ export function WorkflowCanvas() {
|
||||
const { setTitle } = useDocumentTitle();
|
||||
useEffect(() => {
|
||||
const { title } = data?.data ?? {};
|
||||
setTitle(`${title ? `${title} - ` : ''}${t('Workflow')}`);
|
||||
setTitle(`${t('Workflow')}${title ? `: ${title}` : ''}`);
|
||||
}, [data?.data]);
|
||||
|
||||
if (!data?.data && !loading) {
|
||||
@ -72,7 +66,7 @@ export function WorkflowCanvas() {
|
||||
filterByTk: workflow[targetKey],
|
||||
values: {
|
||||
enabled: value,
|
||||
// NOTE: keep `key` field to adapter for backend
|
||||
// NOTE: keep `key` field to adapt for backend
|
||||
key: workflow.key
|
||||
}
|
||||
});
|
||||
@ -97,6 +91,11 @@ export function WorkflowCanvas() {
|
||||
}}>
|
||||
<div className="workflow-toolbar">
|
||||
<header>
|
||||
<span>
|
||||
<Link to={`/admin/settings/workflow/workflows`}>
|
||||
{t('Workflow')}
|
||||
</Link>
|
||||
</span>
|
||||
<strong>{workflow.title}</strong>
|
||||
</header>
|
||||
<aside>
|
||||
@ -110,11 +109,15 @@ export function WorkflowCanvas() {
|
||||
defaultSelectedKeys={[workflow.id]}
|
||||
className={cx(workflowVersionDropdownClass)}
|
||||
>
|
||||
{revisions.sort((a, b) => b.id - a.id).map(item => (
|
||||
{revisions.sort((a, b) => b.id - a.id).map((item, index) => (
|
||||
<Menu.Item
|
||||
key={item.id}
|
||||
icon={item.current ? <RightOutlined /> : null}
|
||||
className={item.executed ? 'executed' : 'unexecuted'}
|
||||
className={classnames({
|
||||
executed: item.executed,
|
||||
unexecuted: !item.executed,
|
||||
enabled: item.enabled,
|
||||
})}
|
||||
>
|
||||
<strong>{`#${item.id}`}</strong>
|
||||
<time>{(new Date(item.createdAt)).toLocaleString()}</time>
|
||||
@ -141,7 +144,7 @@ export function WorkflowCanvas() {
|
||||
</aside>
|
||||
</div>
|
||||
<div className="workflow-canvas">
|
||||
<TriggerConfig />
|
||||
<TriggerConfig workflow={workflow} />
|
||||
<div className={branchBlockClass}>
|
||||
<Branch entry={entry} />
|
||||
</div>
|
||||
@ -154,98 +157,3 @@ export function WorkflowCanvas() {
|
||||
</FlowContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function Branch({
|
||||
from = null,
|
||||
entry = null,
|
||||
branchIndex = null,
|
||||
controller = null
|
||||
}) {
|
||||
const list = [];
|
||||
for (let node = entry; node; node = node.downstream) {
|
||||
list.push(node);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(branchClass)}>
|
||||
<div className="workflow-branch-lines" />
|
||||
{controller}
|
||||
<AddButton upstream={from} branchIndex={branchIndex} />
|
||||
<div className="workflow-node-list">
|
||||
{list.map(item => <Node data={item} key={item.id} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(bug): useless observable
|
||||
// const instructionsList = observable(Array.from(instructions.getValues()));
|
||||
|
||||
interface AddButtonProps {
|
||||
upstream;
|
||||
branchIndex?: number;
|
||||
};
|
||||
|
||||
export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
||||
const compile = useCompile();
|
||||
const api = useAPIClient();
|
||||
const { workflow, onNodeAdded } = useFlowContext();
|
||||
const resource = api.resource('workflows.nodes', workflow.id);
|
||||
|
||||
async function onCreate({ keyPath }) {
|
||||
const type = keyPath.pop();
|
||||
const config = {};
|
||||
const [optionKey] = keyPath;
|
||||
if (optionKey) {
|
||||
const { value } = instructions.get(type).options.find(item => item.key === optionKey);
|
||||
Object.assign(config, value);
|
||||
}
|
||||
|
||||
const { data: { data: node } } = await resource.create({
|
||||
values: {
|
||||
type,
|
||||
upstreamId: upstream?.id ?? null,
|
||||
branchIndex,
|
||||
config
|
||||
}
|
||||
});
|
||||
|
||||
onNodeAdded(node);
|
||||
}
|
||||
|
||||
const groups = [
|
||||
{ value: 'control', name: '{{t("Control")}}' },
|
||||
{ value: 'collection', name: '{{t("Collection operations")}}' },
|
||||
];
|
||||
const instructionList = (Array.from(instructions.getValues()) as Instruction[]);
|
||||
|
||||
return (
|
||||
<div className={cx(addButtonClass)}>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
overlay={
|
||||
<Menu onClick={ev => onCreate(ev)}>
|
||||
{groups.map(group => (
|
||||
<Menu.ItemGroup key={group.value} title={compile(group.name)}>
|
||||
{instructionList.filter(item => item.group === group.value).map(item => item.options
|
||||
? (
|
||||
<Menu.SubMenu key={item.type} title={compile(item.title)}>
|
||||
{item.options.map(option => (
|
||||
<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>
|
||||
}
|
||||
disabled={workflow.executed}
|
||||
>
|
||||
<Button shape="circle" icon={<PlusOutlined />} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -10,6 +10,6 @@ export const WorkflowLink = () => {
|
||||
const { id } = useRecord();
|
||||
const { setVisible } = useActionContext();
|
||||
return (
|
||||
<Link to={`/admin/plugins/workflows/${id}`} onClick={() => setVisible(false)}>{t('View')}</Link>
|
||||
<Link to={`/admin/settings/workflow/workflows/${id}`} onClick={() => setVisible(false)}>{t('View')}</Link>
|
||||
);
|
||||
}
|
||||
|
@ -30,7 +30,7 @@ export const WorkflowPage = () => {
|
||||
action: 'get',
|
||||
params: {
|
||||
filter: params,
|
||||
appends: ['nodes', 'revisions.id', 'revisions.createdAt', 'revisions.current', 'revisions.executed'],
|
||||
appends: ['nodes', 'revisions.id', 'revisions.createdAt', 'revisions.current', 'revisions.executed', 'revisions.enabled'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -2,14 +2,19 @@ import { PluginManagerContext, RouteSwitchContext, SettingsCenterProvider } from
|
||||
import React, { useContext } from 'react';
|
||||
import { WorkflowPage } from './WorkflowPage';
|
||||
import { WorkflowPane, WorkflowShortcut } from './WorkflowShortcut';
|
||||
import { ExecutionPage } from './ExecutionPage';
|
||||
|
||||
export const WorkflowProvider = (props) => {
|
||||
const ctx = useContext(PluginManagerContext);
|
||||
const { routes, components, ...others } = useContext(RouteSwitchContext);
|
||||
routes[1].routes.unshift({
|
||||
type: 'route',
|
||||
path: '/admin/plugins/workflows/:id',
|
||||
path: '/admin/settings/workflow/workflows/:id',
|
||||
component: 'WorkflowPage',
|
||||
}, {
|
||||
type: 'route',
|
||||
path: '/admin/settings/workflow/executions/:id',
|
||||
component: 'ExecutionPage',
|
||||
});
|
||||
return (
|
||||
<SettingsCenterProvider
|
||||
@ -34,7 +39,7 @@ export const WorkflowProvider = (props) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RouteSwitchContext.Provider value={{ components: { ...components, WorkflowPage }, ...others, routes }}>
|
||||
<RouteSwitchContext.Provider value={{ components: { ...components, WorkflowPage, ExecutionPage }, ...others, routes }}>
|
||||
{props.children}
|
||||
</RouteSwitchContext.Provider>
|
||||
</PluginManagerContext.Provider>
|
||||
|
@ -1,46 +1,27 @@
|
||||
import { PartitionOutlined } from '@ant-design/icons';
|
||||
import { ISchema } from '@formily/react';
|
||||
import { uid } from '@formily/shared';
|
||||
import { ActionContext, PluginManager, SchemaComponent } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
import { Card } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { PartitionOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { ExecutionResourceProvider } from './ExecutionResourceProvider';
|
||||
|
||||
import { PluginManager, SchemaComponent } from '@nocobase/client';
|
||||
|
||||
import { workflowSchema } from './schemas/workflows';
|
||||
import { WorkflowLink } from './WorkflowLink';
|
||||
import { ExecutionResourceProvider } from './ExecutionResourceProvider';
|
||||
import { ExecutionLink } from './ExecutionLink';
|
||||
|
||||
const schema: ISchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[uid()]: {
|
||||
'x-component': 'Action.Drawer',
|
||||
type: 'void',
|
||||
title: '{{t("Workflow")}}',
|
||||
properties: {
|
||||
table: workflowSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const schema2: ISchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
[uid()]: workflowSchema,
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkflowPane = () => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<Card bordered={false}>
|
||||
<SchemaComponent
|
||||
schema={schema2}
|
||||
schema={workflowSchema}
|
||||
components={{
|
||||
WorkflowLink,
|
||||
ExecutionResourceProvider,
|
||||
ExecutionLink
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
@ -60,26 +41,3 @@ export const WorkflowShortcut = () => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkflowShortcut2 = () => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<ActionContext.Provider value={{ visible, setVisible }}>
|
||||
<PluginManager.Toolbar.Item
|
||||
icon={<PartitionOutlined />}
|
||||
title={t('Workflow')}
|
||||
onClick={() => {
|
||||
setVisible(true);
|
||||
}}
|
||||
/>
|
||||
<SchemaComponent
|
||||
schema={schema}
|
||||
components={{
|
||||
WorkflowLink,
|
||||
ExecutionResourceProvider,
|
||||
}}
|
||||
/>
|
||||
</ActionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
@ -1,13 +1,11 @@
|
||||
import React from "react";
|
||||
import { observer, useForm } from "@formily/react";
|
||||
import { Button, Cascader, Dropdown, Input, InputNumber, Menu, Select, Form } from "antd";
|
||||
import { Cascader, Input, InputNumber, Select } from "antd";
|
||||
import { css } from "@emotion/css";
|
||||
import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
import { SchemaComponent, useCollectionManager, useCompile } from "@nocobase/client";
|
||||
import { useCompile } from "@nocobase/client";
|
||||
|
||||
import { instructions, useNodeContext } from "./nodes";
|
||||
import { useFlowContext } from "./WorkflowCanvas";
|
||||
import { useFlowContext } from "./FlowContext";
|
||||
import { triggers } from "./triggers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Registry } from "@nocobase/utils/client";
|
||||
@ -451,109 +449,3 @@ export function VariableComponent({ value, onChange, renderSchemaComponent }) {
|
||||
</VariableTypesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: observer for watching useProps
|
||||
export const CollectionFieldset = observer(({ value, onChange }: any) => {
|
||||
const { t } = useTranslation();
|
||||
const compile = useCompile();
|
||||
const { getCollectionFields } = useCollectionManager();
|
||||
const { values: data } = useForm();
|
||||
const fields = getCollectionFields(data?.config?.collection)
|
||||
.filter(field => (
|
||||
!field.hidden
|
||||
&& (field.uiSchema ? !field.uiSchema['x-read-pretty'] : false)
|
||||
));
|
||||
|
||||
const VTypes = {
|
||||
...VariableTypes,
|
||||
constant: {
|
||||
title: '{{t("Constant")}}',
|
||||
value: 'constant',
|
||||
options: undefined
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className={css`
|
||||
margin-top: .5em;
|
||||
|
||||
> .ant-formily-item{
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-formily-item-label{
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
`}>
|
||||
{fields.length
|
||||
? (
|
||||
<>
|
||||
{fields
|
||||
.filter(field => field.name in value)
|
||||
.map(field => {
|
||||
const operand = typeof value[field.name] === 'string'
|
||||
? parseStringValue(value[field.name], VTypes)
|
||||
: { type: 'constant', value: value[field.name] };
|
||||
|
||||
// TODO: try to use <ObjectField> to replace this map
|
||||
return (
|
||||
<Form.Item key={field.name} label={compile(field.uiSchema?.title ?? field.name)} labelAlign="left" className={css`
|
||||
.ant-form-item-control-input-content{
|
||||
display: flex;
|
||||
}
|
||||
`}>
|
||||
<VariableTypesContext.Provider value={VTypes}>
|
||||
<Operand
|
||||
value={operand}
|
||||
onChange={(next) => {
|
||||
if (next.type !== operand.type && next.type === 'constant') {
|
||||
onChange({ ...value, [field.name]: null });
|
||||
} else {
|
||||
const { stringify } = VTypes[next.type];
|
||||
onChange({ ...value, [field.name]: stringify(next) });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{operand.type === 'constant'
|
||||
? <SchemaComponent schema={{ ...field.uiSchema, name: field.name }} />
|
||||
: null
|
||||
}
|
||||
</Operand>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={() => {
|
||||
const { [field.name]: _, ...rest } = value;
|
||||
onChange(rest);
|
||||
}}
|
||||
/>
|
||||
</VariableTypesContext.Provider>
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
{Object.keys(value).length < fields.length
|
||||
? (
|
||||
<Dropdown overlay={
|
||||
<Menu onClick={({ key }) => onChange({ ...value, [key]: null })} className={css`
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
`}>
|
||||
{fields
|
||||
.filter(field => !(field.name in value))
|
||||
.map(field => (
|
||||
<Menu.Item key={field.name}>{compile(field.uiSchema?.title ?? field.name)}</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}>
|
||||
<Button icon={<PlusOutlined />}>{t('Add field')}</Button>
|
||||
</Dropdown>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</>
|
||||
)
|
||||
: <p>{t('Please select collection first')}</p>
|
||||
}
|
||||
</fieldset>
|
||||
);
|
||||
});
|
||||
|
41
packages/plugins/workflow/src/client/constants.tsx
Normal file
41
packages/plugins/workflow/src/client/constants.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CloseOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckOutlined,
|
||||
ExclamationOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
export const EXECUTION_STATUS = {
|
||||
QUEUEING: null,
|
||||
STARTED: 0,
|
||||
SUCCEEDED: 1,
|
||||
FAILED: -1,
|
||||
CANCELED: -2
|
||||
};
|
||||
|
||||
export const ExecutionStatusOptions = [
|
||||
{ value: EXECUTION_STATUS.QUEUEING, label: '{{t("Queueing")}}', color: 'blue' },
|
||||
{ value: EXECUTION_STATUS.STARTED, label: '{{t("On going")}}', color: 'gold' },
|
||||
{ value: EXECUTION_STATUS.SUCCEEDED, label: '{{t("Succeeded")}}', color: 'green' },
|
||||
{ value: EXECUTION_STATUS.FAILED, label: '{{t("Failed")}}', color: 'red' },
|
||||
{ value: EXECUTION_STATUS.CANCELED, label: '{{t("Canceled")}}' },
|
||||
];
|
||||
|
||||
export const ExecutionStatusOptionsMap = ExecutionStatusOptions.reduce((map, option) => Object.assign(map, { [option.value]: option }), {});
|
||||
|
||||
export const JOB_STATUS = {
|
||||
PENDING: 0,
|
||||
RESOLVED: 1,
|
||||
REJECTED: -1,
|
||||
CANCELED: -2
|
||||
};
|
||||
|
||||
export const JobStatusOptions = [
|
||||
{ value: JOB_STATUS.PENDING, label: '{{t("Pending")}}', color: '#d4c306', icon: <ClockCircleOutlined /> },
|
||||
{ value: JOB_STATUS.RESOLVED, label: '{{t("Succeeded")}}', color: '#67c068', icon: <CheckOutlined /> },
|
||||
{ value: JOB_STATUS.REJECTED, label: '{{t("Failed")}}', color: '#f40', icon: <ExclamationOutlined /> },
|
||||
{ value: JOB_STATUS.CANCELED, label: '{{t("Canceled")}}', color: '#f40', icon: <CloseOutlined /> }
|
||||
];
|
||||
|
||||
export const JobStatusOptionsMap = JobStatusOptions.reduce((map, option) => Object.assign(map, { [option.value]: option }), {});
|
@ -7,7 +7,8 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { i18n } from "@nocobase/client";
|
||||
|
||||
import { NodeDefaultView } from ".";
|
||||
import { Branch, useFlowContext } from "../WorkflowCanvas";
|
||||
import { Branch } from "../Branch";
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
import { branchBlockClass, nodeSubtreeClass } from "../style";
|
||||
import { Calculation } from "../calculators";
|
||||
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { Select } from 'antd';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCollectionDataSource, useCollectionManager, useCompile } from '@nocobase/client';
|
||||
import { BaseTypeSet, CollectionFieldset } from '../calculators';
|
||||
import { useCollectionDataSource } from '@nocobase/client';
|
||||
|
||||
import { collection, values } from '../schemas/collection';
|
||||
import { useFlowContext } from '../WorkflowCanvas';
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
import CollectionFieldSelect from '../components/CollectionFieldSelect';
|
||||
import CollectionFieldset from '../components/CollectionFieldset';
|
||||
|
||||
|
||||
|
||||
|
@ -1,14 +1,18 @@
|
||||
import { CloseOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import React, { useContext } from 'react';
|
||||
import {
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { ISchema, useForm } from '@formily/react';
|
||||
import { Registry } from '@nocobase/utils/client';
|
||||
import { Button, message, Modal, Tag } from 'antd';
|
||||
import React, { useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Registry } from '@nocobase/utils/client';
|
||||
import { SchemaComponent, useActionContext, useAPIClient, useCompile, useRequest, useResourceActionContext } from '@nocobase/client';
|
||||
|
||||
import { nodeBlockClass, nodeCardClass, nodeClass, nodeHeaderClass, nodeMetaClass, nodeTitleClass } from '../style';
|
||||
import { AddButton, useFlowContext } from '../WorkflowCanvas';
|
||||
import { AddButton } from '../AddButton';
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
|
||||
import calculation from './calculation';
|
||||
import condition from './condition';
|
||||
@ -19,6 +23,7 @@ import query from './query';
|
||||
import create from './create';
|
||||
import update from './update';
|
||||
import destroy from './destroy';
|
||||
import { JobStatusOptions, JobStatusOptionsMap } from '../constants';
|
||||
|
||||
export interface Instruction {
|
||||
title: string;
|
||||
@ -125,10 +130,12 @@ export function Node({ data }) {
|
||||
export function RemoveButton() {
|
||||
const { t } = useTranslation();
|
||||
const api = useAPIClient();
|
||||
const { workflow } = useFlowContext();
|
||||
const resource = api.resource('workflows.nodes', workflow.id);
|
||||
const { workflow, nodes, onNodeRemoved } = useFlowContext() ?? {};
|
||||
const current = useNodeContext();
|
||||
const { nodes, onNodeRemoved } = useFlowContext();
|
||||
if (!workflow) {
|
||||
return null;
|
||||
}
|
||||
const resource = api.resource('workflows.nodes', workflow.id);
|
||||
|
||||
async function onRemove() {
|
||||
async function onOk() {
|
||||
@ -163,9 +170,111 @@ export function RemoveButton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function JobButton() {
|
||||
const { t } = useTranslation();
|
||||
const compile = useCompile();
|
||||
const { execution } = useFlowContext();
|
||||
const { id, type, title, job } = useNodeContext() ?? {};
|
||||
if (!execution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<span
|
||||
className={cx('workflow-node-job-button', css`
|
||||
border: 2px solid #d9d9d9;
|
||||
border-radius: 50%;
|
||||
`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const instruction = instructions.get(type);
|
||||
const { value, icon, color } = JobStatusOptionsMap[job.status];
|
||||
|
||||
return (
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
[job.id]: {
|
||||
type: 'void',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
title: icon,
|
||||
shape: 'circle',
|
||||
className: ['workflow-node-job-button', css`
|
||||
background-color: ${color};
|
||||
&:hover,&:focus{
|
||||
background-color: ${color}
|
||||
}
|
||||
`]
|
||||
},
|
||||
properties: {
|
||||
[job.id]: {
|
||||
type: 'void',
|
||||
'x-decorator': 'Form',
|
||||
'x-decorator-props': {
|
||||
initialValue: job
|
||||
},
|
||||
'x-component': 'Action.Modal',
|
||||
title: (
|
||||
<div className={cx(nodeTitleClass)}>
|
||||
<Tag>{compile(instruction.title)}</Tag>
|
||||
<strong>{title}</strong>
|
||||
<span className="workflow-node-id">#{id}</span>
|
||||
</div>
|
||||
),
|
||||
properties: {
|
||||
status: {
|
||||
type: 'number',
|
||||
title: '{{t("Status")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
enum: JobStatusOptions,
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
title: '{{t("Executed at")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'DatePicker',
|
||||
'x-component-props': {
|
||||
showTime: true
|
||||
},
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
result: {
|
||||
type: 'object',
|
||||
title: '{{t("Node result")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
className: css`
|
||||
padding: 1em;
|
||||
background-color: #eee;
|
||||
`
|
||||
},
|
||||
'x-read-pretty': true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeDefaultView(props) {
|
||||
const compile = useCompile();
|
||||
const { workflow } = useFlowContext();
|
||||
const { workflow } = useFlowContext() ?? {};
|
||||
if (!workflow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, children } = props;
|
||||
const instruction = instructions.get(data.type);
|
||||
const detailText = workflow.executed ? '{{t("View")}}' : '{{t("Configure")}}';
|
||||
@ -182,6 +291,7 @@ export function NodeDefaultView(props) {
|
||||
<span className="workflow-node-id">#{data.id}</span>
|
||||
</h4>
|
||||
<RemoveButton />
|
||||
<JobButton />
|
||||
</div>
|
||||
<SchemaComponent
|
||||
scope={instruction.scope}
|
||||
|
@ -7,7 +7,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { i18n } from "@nocobase/client";
|
||||
|
||||
import { NodeDefaultView } from ".";
|
||||
import { Branch, useFlowContext } from "../WorkflowCanvas";
|
||||
import { Branch } from "../Branch";
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
import { branchBlockClass, nodeSubtreeClass } from "../style";
|
||||
|
||||
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { useCollectionDataSource, useCollectionManager, useCompile } from '@nocobase/client';
|
||||
|
||||
import { useFlowContext } from '../WorkflowCanvas';
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
import { VariableComponent } from '../calculators';
|
||||
import { collection, filter } from '../schemas/collection';
|
||||
import CollectionFieldSelect from '../components/CollectionFieldSelect';
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useCollectionDataSource } from '@nocobase/client';
|
||||
|
||||
import { CollectionFieldset, VariableComponent } from '../calculators';
|
||||
import { VariableComponent } from '../calculators';
|
||||
import CollectionFieldset from '../components/CollectionFieldset';
|
||||
import { collection, filter, values } from '../schemas/collection';
|
||||
|
||||
|
||||
|
@ -1,4 +1,8 @@
|
||||
import React from 'react';
|
||||
import { ISchema } from '@formily/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useActionContext } from '@nocobase/client';
|
||||
import { ExecutionStatusOptions } from '../constants';
|
||||
|
||||
const collection = {
|
||||
name: 'executions',
|
||||
@ -10,21 +14,23 @@ const collection = {
|
||||
name: 'createdAt',
|
||||
uiSchema: {
|
||||
type: 'datetime',
|
||||
title: '{{t("Created at")}}',
|
||||
title: '{{t("Triggered at")}}',
|
||||
'x-component': 'DatePicker',
|
||||
'x-component-props': {},
|
||||
'x-read-pretty': true,
|
||||
} as ISchema,
|
||||
},
|
||||
{
|
||||
interface: 'number',
|
||||
type: 'number',
|
||||
interface: 'object',
|
||||
type: 'belongsTo',
|
||||
name: 'workflowId',
|
||||
uiSchema: {
|
||||
type: 'number',
|
||||
title: '{{t("Version")}}',
|
||||
'x-component': 'InputNumber',
|
||||
'x-read-pretty': true,
|
||||
['x-component']({ value }) {
|
||||
const { setVisible } = useActionContext();
|
||||
return <Link to={`/admin/settings/workflow/workflows/${value}`} onClick={() => setVisible(false)}>{`#${value}`}</Link>;
|
||||
}
|
||||
} as ISchema,
|
||||
},
|
||||
{
|
||||
@ -36,12 +42,7 @@ const collection = {
|
||||
type: 'string',
|
||||
'x-component': 'Select',
|
||||
'x-decorator': 'FormItem',
|
||||
enum: [
|
||||
{ value: 0, label: '{{t("On going")}}' },
|
||||
{ value: 1, label: '{{t("Succeeded")}}' },
|
||||
{ value: -1, label: '{{t("Failed")}}' },
|
||||
{ value: -2, label: '{{t("Canceled")}}' },
|
||||
],
|
||||
enum: ExecutionStatusOptions,
|
||||
} as ISchema,
|
||||
}
|
||||
],
|
||||
@ -58,6 +59,7 @@ export const executionSchema = {
|
||||
resource: 'executions',
|
||||
action: 'list',
|
||||
params: {
|
||||
appends: ['workflow.id', 'workflow.title'],
|
||||
pageSize: 50,
|
||||
sort: ['-createdAt'],
|
||||
},
|
||||
@ -130,27 +132,27 @@ export const executionSchema = {
|
||||
},
|
||||
}
|
||||
},
|
||||
// actions: {
|
||||
// type: 'void',
|
||||
// title: '{{ t("Actions") }}',
|
||||
// 'x-component': 'Table.Column',
|
||||
// properties: {
|
||||
// actions: {
|
||||
// type: 'void',
|
||||
// 'x-component': 'Space',
|
||||
// 'x-component-props': {
|
||||
// split: '|',
|
||||
// },
|
||||
// properties: {
|
||||
// config: {
|
||||
// type: 'void',
|
||||
// title: '查看',
|
||||
// 'x-component': 'ExecutionLink'
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
actions: {
|
||||
type: 'void',
|
||||
title: '{{ t("Actions") }}',
|
||||
'x-component': 'Table.Column',
|
||||
properties: {
|
||||
actions: {
|
||||
type: 'void',
|
||||
'x-component': 'Space',
|
||||
'x-component-props': {
|
||||
split: '|',
|
||||
},
|
||||
properties: {
|
||||
config: {
|
||||
type: 'void',
|
||||
title: '{{t("Details")}}',
|
||||
'x-component': 'ExecutionLink'
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -30,6 +30,7 @@ const collection = {
|
||||
enum: Array.from(triggers.getEntities()).map(([value, { title }]) => ({
|
||||
value,
|
||||
label: title,
|
||||
color: 'gold'
|
||||
})),
|
||||
required: true,
|
||||
} as ISchema,
|
||||
@ -52,7 +53,7 @@ const collection = {
|
||||
title: '{{t("Status")}}',
|
||||
type: 'string',
|
||||
enum: [
|
||||
{ label: '{{t("On")}}', value: true },
|
||||
{ label: '{{t("On")}}', value: true, color: '#52c41a' },
|
||||
{ label: '{{t("Off")}}', value: false },
|
||||
],
|
||||
'x-component': 'Radio.Group',
|
||||
@ -64,7 +65,7 @@ const collection = {
|
||||
};
|
||||
|
||||
export const workflowSchema: ISchema = {
|
||||
type: 'object',
|
||||
type: 'void',
|
||||
properties: {
|
||||
provider: {
|
||||
type: 'void',
|
||||
@ -235,7 +236,7 @@ export const workflowSchema: ISchema = {
|
||||
},
|
||||
executions: {
|
||||
type: 'void',
|
||||
title: '{{t("Execution History")}}',
|
||||
title: '{{t("Execution history")}}',
|
||||
'x-component': 'Action.Link',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
@ -243,7 +244,7 @@ export const workflowSchema: ISchema = {
|
||||
properties: {
|
||||
drawer: {
|
||||
type: 'void',
|
||||
title: '{{t("Execution History")}}',
|
||||
title: '{{t("Execution history")}}',
|
||||
'x-component': 'Action.Drawer',
|
||||
properties: executionSchema
|
||||
}
|
||||
|
@ -15,7 +15,13 @@ export const workflowPageClass = css`
|
||||
header{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5em;
|
||||
|
||||
> *:not(:last-child) {
|
||||
&:after{
|
||||
content: ">";
|
||||
margin: 0 .5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aside{
|
||||
@ -38,8 +44,20 @@ export const workflowPageClass = css`
|
||||
export const workflowVersionDropdownClass = css`
|
||||
.ant-dropdown-menu-item{
|
||||
|
||||
strong{
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&.enabled{
|
||||
strong{
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&.unexecuted{
|
||||
font-style: italic;
|
||||
strong{
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-title-content{
|
||||
@ -148,10 +166,14 @@ export const nodeCardClass = css`
|
||||
padding: 1em;
|
||||
box-shadow: 0 .25em .5em rgba(0, 0, 0, .1);
|
||||
|
||||
.workflow-node-remove-button{
|
||||
.workflow-node-remove-button,
|
||||
.workflow-node-job-button{
|
||||
position: absolute;
|
||||
right: -.5em;
|
||||
top: -.5em;
|
||||
}
|
||||
|
||||
.workflow-node-remove-button{
|
||||
color: #999;
|
||||
opacity: 0;
|
||||
transition: opacity .3s ease;
|
||||
@ -165,6 +187,23 @@ export const nodeCardClass = css`
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-node-job-button{
|
||||
display: flex;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
min-width: 1.25rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 0.8em;
|
||||
color: #fff;
|
||||
|
||||
&[type="button"]{
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover{
|
||||
.workflow-node-remove-button{
|
||||
opacity: 1;
|
||||
@ -181,6 +220,8 @@ export const nodeMetaClass = css`
|
||||
`;
|
||||
|
||||
export const nodeTitleClass = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
|
||||
.workflow-node-id{
|
||||
|
@ -4,7 +4,7 @@ import { observer, useForm, useFormEffects } from '@formily/react';
|
||||
|
||||
import { useCollectionDataSource, useCollectionManager, useCompile } from '@nocobase/client';
|
||||
|
||||
import { useFlowContext } from '../WorkflowCanvas';
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
import { collection, filter } from '../schemas/collection';
|
||||
import { css } from '@emotion/css';
|
||||
import { onFieldValueChange } from '@formily/core';
|
||||
|
@ -4,11 +4,12 @@ import { Registry } from "@nocobase/utils/client";
|
||||
import { message, Tag } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InfoOutlined } from '@ant-design/icons';
|
||||
|
||||
import { SchemaComponent, useActionContext, useAPIClient, useCompile, useResourceActionContext } from '@nocobase/client';
|
||||
|
||||
import { nodeCardClass, nodeMetaClass } from "../style";
|
||||
import { useFlowContext } from "../WorkflowCanvas";
|
||||
import { nodeCardClass, nodeHeaderClass, nodeMetaClass, nodeTitleClass } from "../style";
|
||||
import { useFlowContext } from "../FlowContext";
|
||||
import collection from './collection';
|
||||
import schedule from "./schedule/";
|
||||
|
||||
@ -17,7 +18,7 @@ function useUpdateConfigAction() {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm();
|
||||
const api = useAPIClient();
|
||||
const { workflow } = useFlowContext();
|
||||
const { workflow } = useFlowContext() ?? {};
|
||||
const ctx = useActionContext();
|
||||
const { refresh } = useResourceActionContext();
|
||||
return {
|
||||
@ -55,24 +56,98 @@ export const triggers = new Registry<Trigger>();
|
||||
triggers.register(collection.type, collection);
|
||||
triggers.register(schedule.type, schedule);
|
||||
|
||||
export const TriggerConfig = () => {
|
||||
const { t } = useTranslation();
|
||||
function TriggerExecution() {
|
||||
const compile = useCompile();
|
||||
const { data } = useResourceActionContext();
|
||||
if (!data) {
|
||||
const { workflow, execution } = useFlowContext();
|
||||
if (!execution) {
|
||||
return null;
|
||||
}
|
||||
const { type, config, executed } = data.data;
|
||||
|
||||
const trigger = triggers.get(workflow.type);
|
||||
|
||||
return (
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
trigger: {
|
||||
type: 'void',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
title: <InfoOutlined />,
|
||||
shape: 'circle',
|
||||
className: 'workflow-node-job-button',
|
||||
type: 'primary'
|
||||
},
|
||||
properties: {
|
||||
[execution.id]: {
|
||||
type: 'void',
|
||||
'x-decorator': 'Form',
|
||||
'x-decorator-props': {
|
||||
initialValue: execution
|
||||
},
|
||||
'x-component': 'Action.Modal',
|
||||
title: (
|
||||
<div className={cx(nodeTitleClass)}>
|
||||
<Tag>{compile(trigger.title)}</Tag>
|
||||
<strong>{workflow.title}</strong>
|
||||
<span className="workflow-node-id">#{execution.id}</span>
|
||||
</div>
|
||||
),
|
||||
properties: {
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
title: '{{t("Triggered at")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'DatePicker',
|
||||
'x-component-props': {
|
||||
showTime: true
|
||||
},
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
context: {
|
||||
type: 'object',
|
||||
title: '{{t("Trigger context")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
className: css`
|
||||
padding: 1em;
|
||||
background-color: #eee;
|
||||
`
|
||||
},
|
||||
'x-read-pretty': true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const TriggerConfig = ({ workflow }) => {
|
||||
const { t } = useTranslation();
|
||||
const compile = useCompile();
|
||||
if (!workflow || !workflow.type) {
|
||||
return null;
|
||||
}
|
||||
const { type, config, executed } = workflow;
|
||||
const { title, fieldset, scope, components } = triggers.get(type);
|
||||
const detailText = executed ? '{{t("View")}}' : '{{t("Configure")}}';
|
||||
const titleText = `${t('Trigger')}: ${compile(title)}`;
|
||||
|
||||
return (
|
||||
<div className={cx(nodeCardClass)}>
|
||||
<div className={cx(nodeMetaClass)}>
|
||||
<Tag color="gold">{t('Trigger')}</Tag>
|
||||
<div className={cx(nodeHeaderClass)}>
|
||||
<div className={cx(nodeMetaClass)}>
|
||||
<Tag color="gold">{t('Trigger')}</Tag>
|
||||
</div>
|
||||
<h4>{compile(title)}</h4>
|
||||
<TriggerExecution />
|
||||
</div>
|
||||
<h4>{compile(title)}</h4>
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'void',
|
||||
|
@ -5,7 +5,7 @@ import { Select } from 'antd';
|
||||
import { useCompile, useCollectionDataSource, useCollectionManager } from '@nocobase/client';
|
||||
|
||||
import { ScheduleConfig } from './ScheduleConfig';
|
||||
import { useFlowContext } from '../../WorkflowCanvas';
|
||||
import { useFlowContext } from '../../FlowContext';
|
||||
import { BaseTypeSet } from '../../calculators';
|
||||
|
||||
export default {
|
||||
|
Loading…
Reference in New Issue
Block a user