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:
Junyi 2022-10-30 11:54:14 +08:00 committed by GitHub
parent ed6f9a0867
commit 7cb5ff554e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 840 additions and 360 deletions

View File

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

View File

@ -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: {
}
};

View File

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

View File

@ -416,7 +416,7 @@ export default {
"Tencent COS": "Tencent COS",
"Amazon S3": "Amazon S3",
"Workflow": "ワークフロー",
"Execution History": "実行履歴",
"Execution history": "実行履歴",
"Trigger type": "トリガータイプ",
"Description": "説明",
"Status": "状態",

View File

@ -416,7 +416,7 @@ export default {
"Tencent COS": "Tencent COS",
"Amazon S3": "Amazon S3",
"Workflow": "Workflow",
"Execution History": "История запусков",
"Execution history": "История запусков",
"Trigger type": "Тип триггера",
"Description": "Описание",
"Status": "Статус",

View File

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

View File

@ -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?': '节点包含分支,将同时删除其所有分支下的子节点,确定继续?',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,3 +26,7 @@ group:
### URL
<code src="./demos/demo3.tsx" />
### JSON
<code src="./demos/demo4.tsx" />

View File

@ -1,2 +1,3 @@
export * from './Input';
export * from './ReadPretty';
export * from './Json';

View File

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

View File

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

View File

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

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

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

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

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

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

View File

@ -0,0 +1,8 @@
import React, { useContext } from "react";
export const FlowContext = React.createContext(null);
export function useFlowContext() {
return useContext(FlowContext);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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