* fix(plugin-workflow): fix variable type check * fix(plugin-workflow): fix node job button style
494 lines
15 KiB
TypeScript
494 lines
15 KiB
TypeScript
import { CloseOutlined, DeleteOutlined } from '@ant-design/icons';
|
|
import { ISchema, useForm } from '@formily/react';
|
|
import {
|
|
ActionContextProvider,
|
|
SchemaComponent,
|
|
SchemaInitializerItemOptions,
|
|
css,
|
|
cx,
|
|
useAPIClient,
|
|
useActionContext,
|
|
useCompile,
|
|
useRequest,
|
|
useResourceActionContext,
|
|
} from '@nocobase/client';
|
|
import { Registry, parse, str2moment } from '@nocobase/utils/client';
|
|
import { Alert, App, Button, Dropdown, Input, Tag, message } from 'antd';
|
|
import React, { useContext, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { AddButton } from '../AddButton';
|
|
import { useFlowContext } from '../FlowContext';
|
|
import { DrawerDescription } from '../components/DrawerDescription';
|
|
import { JobStatusOptionsMap } from '../constants';
|
|
import { NAMESPACE, lang } from '../locale';
|
|
import useStyles from '../style';
|
|
import { VariableOption, VariableOptions } from '../variable';
|
|
import aggregate from './aggregate';
|
|
import calculation from './calculation';
|
|
import condition from './condition';
|
|
import create from './create';
|
|
import delay from './delay';
|
|
import destroy from './destroy';
|
|
import loop from './loop';
|
|
import manual from './manual';
|
|
import parallel from './parallel';
|
|
import query from './query';
|
|
import request from './request';
|
|
import sql from './sql';
|
|
import update from './update';
|
|
import { StatusIcon } from '../components/StatusIcon';
|
|
|
|
export interface Instruction {
|
|
title: string;
|
|
type: string;
|
|
group: string;
|
|
description?: string;
|
|
options?: { label: string; value: any; key: string }[];
|
|
fieldset: { [key: string]: ISchema };
|
|
view?: ISchema;
|
|
scope?: { [key: string]: any };
|
|
components?: { [key: string]: any };
|
|
component?(props): JSX.Element;
|
|
endding?: boolean;
|
|
useVariables?(node, options?): VariableOption;
|
|
useScopeVariables?(node, options?): VariableOptions;
|
|
useInitializers?(node): SchemaInitializerItemOptions | null;
|
|
initializers?: { [key: string]: any };
|
|
}
|
|
|
|
export const instructions = new Registry<Instruction>();
|
|
|
|
instructions.register('calculation', calculation);
|
|
instructions.register('condition', condition);
|
|
instructions.register('parallel', parallel);
|
|
instructions.register('loop', loop);
|
|
instructions.register('delay', delay);
|
|
|
|
instructions.register('manual', manual);
|
|
|
|
instructions.register('query', query);
|
|
instructions.register('create', create);
|
|
instructions.register('update', update);
|
|
instructions.register('destroy', destroy);
|
|
instructions.register('aggregate', aggregate);
|
|
|
|
instructions.register('request', request);
|
|
instructions.register('sql', sql);
|
|
|
|
function useUpdateAction() {
|
|
const form = useForm();
|
|
const api = useAPIClient();
|
|
const ctx = useActionContext();
|
|
const { refresh } = useResourceActionContext();
|
|
const data = useNodeContext();
|
|
const { workflow } = useFlowContext();
|
|
return {
|
|
async run() {
|
|
if (workflow.executed) {
|
|
message.error(lang('Node in executed workflow cannot be modified'));
|
|
return;
|
|
}
|
|
await form.submit();
|
|
await api.resource('flow_nodes', data.id).update?.({
|
|
filterByTk: data.id,
|
|
values: {
|
|
config: form.values,
|
|
},
|
|
});
|
|
ctx.setVisible(false);
|
|
refresh();
|
|
},
|
|
};
|
|
}
|
|
|
|
export const NodeContext = React.createContext<any>({});
|
|
|
|
export function useNodeContext() {
|
|
return useContext(NodeContext);
|
|
}
|
|
|
|
export function useAvailableUpstreams(node) {
|
|
const stack: any[] = [];
|
|
if (!node) {
|
|
return [];
|
|
}
|
|
for (let current = node.upstream; current; current = current.upstream) {
|
|
stack.push(current);
|
|
}
|
|
|
|
return stack;
|
|
}
|
|
|
|
export function useUpstreamScopes(node) {
|
|
const stack: any[] = [];
|
|
if (!node) {
|
|
return [];
|
|
}
|
|
|
|
for (let current = node; current; current = current.upstream) {
|
|
if (current.upstream && current.branchIndex != null) {
|
|
stack.push(current.upstream);
|
|
}
|
|
}
|
|
|
|
return stack;
|
|
}
|
|
|
|
export function Node({ data }) {
|
|
const { styles } = useStyles();
|
|
const { component: Component = NodeDefaultView, endding } = instructions.get(data.type);
|
|
|
|
return (
|
|
<NodeContext.Provider value={data}>
|
|
<div className={cx(styles.nodeBlockClass)}>
|
|
<Component data={data} />
|
|
{!endding ? (
|
|
<AddButton upstream={data} />
|
|
) : (
|
|
<div
|
|
className={css`
|
|
flex-grow: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 1px;
|
|
height: 6em;
|
|
padding: 2em 0;
|
|
background-color: var(--nb-box-bg);
|
|
|
|
.anticon {
|
|
font-size: 1.5em;
|
|
line-height: 100%;
|
|
}
|
|
`}
|
|
>
|
|
<CloseOutlined />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</NodeContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function RemoveButton() {
|
|
const { t } = useTranslation();
|
|
const api = useAPIClient();
|
|
const { workflow, nodes, refresh } = useFlowContext() ?? {};
|
|
const current = useNodeContext();
|
|
const { modal } = App.useApp();
|
|
|
|
if (!workflow) {
|
|
return null;
|
|
}
|
|
const resource = api.resource('workflows.nodes', workflow.id);
|
|
|
|
async function onRemove() {
|
|
async function onOk() {
|
|
await resource.destroy?.({
|
|
filterByTk: current.id,
|
|
});
|
|
refresh();
|
|
}
|
|
|
|
const usingNodes = nodes.filter((node) => {
|
|
if (node === current) {
|
|
return false;
|
|
}
|
|
|
|
const template = parse(node.config);
|
|
const refs = template.parameters.filter(
|
|
({ key }) => key.startsWith(`$jobsMapByNodeId.${current.id}.`) || key === `$jobsMapByNodeId.${current.id}`,
|
|
);
|
|
return refs.length;
|
|
});
|
|
|
|
if (usingNodes.length) {
|
|
modal.error({
|
|
title: lang('Can not delete'),
|
|
content: lang(
|
|
'The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.',
|
|
{ nodes: usingNodes.map((item) => `#${item.id}`).join(', ') },
|
|
),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const hasBranches = !nodes.find((item) => item.upstream === current && item.branchIndex != null);
|
|
const message = hasBranches
|
|
? t('Are you sure you want to delete it?')
|
|
: lang('This node contains branches, deleting will also be preformed to them, are you sure?');
|
|
|
|
modal.confirm({
|
|
title: t('Delete'),
|
|
content: message,
|
|
onOk,
|
|
});
|
|
}
|
|
|
|
return workflow.executed ? null : (
|
|
<Button
|
|
type="text"
|
|
shape="circle"
|
|
icon={<DeleteOutlined />}
|
|
onClick={onRemove}
|
|
className="workflow-node-remove-button"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function InnerJobButton({ job, ...props }) {
|
|
const { styles } = useStyles();
|
|
|
|
return (
|
|
<Button {...props} shape="circle" size="small" className={cx(styles.nodeJobButtonClass, props.className)}>
|
|
<StatusIcon status={job.status} />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
export function JobButton() {
|
|
const { execution, setViewJob } = useFlowContext();
|
|
const { jobs } = useNodeContext() ?? {};
|
|
const { styles } = useStyles();
|
|
|
|
if (!execution) {
|
|
return null;
|
|
}
|
|
|
|
if (!jobs.length) {
|
|
return (
|
|
<span
|
|
className={cx(
|
|
styles.nodeJobButtonClass,
|
|
css`
|
|
border: 2px solid #d9d9d9;
|
|
border-radius: 50%;
|
|
cursor: not-allowed;
|
|
width: 24px;
|
|
height: 24px;
|
|
`,
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function onOpenJob({ key }) {
|
|
const job = jobs.find((item) => item.id == key);
|
|
setViewJob(job);
|
|
}
|
|
|
|
return jobs.length > 1 ? (
|
|
<Dropdown
|
|
menu={{
|
|
items: jobs.map((job) => {
|
|
return {
|
|
key: job.id,
|
|
label: (
|
|
<div
|
|
className={css`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5em;
|
|
|
|
time {
|
|
color: #999;
|
|
font-size: 0.8em;
|
|
}
|
|
`}
|
|
>
|
|
<StatusIcon status={job.status} />
|
|
<time>{str2moment(job.updatedAt).format('YYYY-MM-DD HH:mm:ss')}</time>
|
|
</div>
|
|
),
|
|
};
|
|
}),
|
|
onClick: onOpenJob,
|
|
}}
|
|
>
|
|
<InnerJobButton job={jobs[jobs.length - 1]} />
|
|
</Dropdown>
|
|
) : (
|
|
<InnerJobButton job={jobs[0]} onClick={() => setViewJob(jobs[0])} />
|
|
);
|
|
}
|
|
|
|
export function NodeDefaultView(props) {
|
|
const { data, children } = props;
|
|
const compile = useCompile();
|
|
const api = useAPIClient();
|
|
const { workflow, refresh } = useFlowContext() ?? {};
|
|
const { styles } = useStyles();
|
|
|
|
const instruction = instructions.get(data.type);
|
|
const detailText = workflow.executed ? '{{t("View")}}' : '{{t("Configure")}}';
|
|
const typeTitle = compile(instruction.title);
|
|
|
|
const [editingTitle, setEditingTitle] = useState<string>(data.title ?? typeTitle);
|
|
const [editingConfig, setEditingConfig] = useState(false);
|
|
|
|
async function onChangeTitle(next) {
|
|
const title = next || typeTitle;
|
|
setEditingTitle(title);
|
|
if (title === data.title) {
|
|
return;
|
|
}
|
|
await api.resource('flow_nodes').update?.({
|
|
filterByTk: data.id,
|
|
values: {
|
|
title,
|
|
},
|
|
});
|
|
refresh();
|
|
}
|
|
|
|
function onOpenDrawer(ev) {
|
|
if (ev.target === ev.currentTarget) {
|
|
setEditingConfig(true);
|
|
return;
|
|
}
|
|
const whiteSet = new Set(['workflow-node-meta', 'workflow-node-config-button', 'ant-input-disabled']);
|
|
for (let el = ev.target; el && el !== ev.currentTarget && el !== document.documentElement; el = el.parentNode) {
|
|
if ((Array.from(el.classList) as string[]).some((name: string) => whiteSet.has(name))) {
|
|
setEditingConfig(true);
|
|
ev.stopPropagation();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={cx(styles.nodeClass, `workflow-node-type-${data.type}`)}>
|
|
<div className={cx(styles.nodeCardClass, { configuring: editingConfig })} onClick={onOpenDrawer}>
|
|
<div className={cx(styles.nodeMetaClass, 'workflow-node-meta')}>
|
|
<Tag>{typeTitle}</Tag>
|
|
<span className="workflow-node-id">{data.id}</span>
|
|
</div>
|
|
<div>
|
|
<Input.TextArea
|
|
disabled={workflow.executed}
|
|
value={editingTitle}
|
|
onChange={(ev) => setEditingTitle(ev.target.value)}
|
|
onBlur={(ev) => onChangeTitle(ev.target.value)}
|
|
autoSize
|
|
/>
|
|
</div>
|
|
<RemoveButton />
|
|
<JobButton />
|
|
<ActionContextProvider value={{ visible: editingConfig, setVisible: setEditingConfig }}>
|
|
<SchemaComponent
|
|
scope={instruction.scope}
|
|
components={instruction.components}
|
|
schema={{
|
|
type: 'void',
|
|
properties: {
|
|
...(instruction.view ? { view: instruction.view } : {}),
|
|
button: {
|
|
type: 'void',
|
|
'x-content': detailText,
|
|
'x-component': Button,
|
|
'x-component-props': {
|
|
type: 'link',
|
|
className: 'workflow-node-config-button',
|
|
},
|
|
},
|
|
[`${instruction.type}_${data.id}`]: {
|
|
type: 'void',
|
|
title: data.title,
|
|
'x-component': 'Action.Drawer',
|
|
'x-decorator': 'Form',
|
|
'x-decorator-props': {
|
|
disabled: workflow.executed,
|
|
useValues(options) {
|
|
const { config } = useNodeContext();
|
|
return useRequest(() => {
|
|
return Promise.resolve({ data: config });
|
|
}, options);
|
|
},
|
|
},
|
|
properties: {
|
|
...(workflow.executed
|
|
? {
|
|
alert: {
|
|
type: 'void',
|
|
'x-component': Alert,
|
|
'x-component-props': {
|
|
type: 'warning',
|
|
showIcon: true,
|
|
message: `{{t("Node in executed workflow cannot be modified", { ns: "${NAMESPACE}" })}}`,
|
|
className: css`
|
|
width: 100%;
|
|
font-size: 85%;
|
|
margin-bottom: 2em;
|
|
`,
|
|
},
|
|
},
|
|
}
|
|
: instruction.description
|
|
? {
|
|
description: {
|
|
type: 'void',
|
|
'x-component': DrawerDescription,
|
|
'x-component-props': {
|
|
label: lang('Node type'),
|
|
title: instruction.title,
|
|
description: instruction.description,
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
fieldset: {
|
|
type: 'void',
|
|
'x-component': 'fieldset',
|
|
'x-component-props': {
|
|
className: css`
|
|
.ant-input,
|
|
.ant-select,
|
|
.ant-cascader-picker,
|
|
.ant-picker,
|
|
.ant-input-number,
|
|
.ant-input-affix-wrapper {
|
|
&.auto-width {
|
|
width: auto;
|
|
min-width: 6em;
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
properties: instruction.fieldset,
|
|
},
|
|
actions: workflow.executed
|
|
? null
|
|
: {
|
|
type: 'void',
|
|
'x-component': 'Action.Drawer.Footer',
|
|
properties: {
|
|
cancel: {
|
|
title: '{{t("Cancel")}}',
|
|
'x-component': 'Action',
|
|
'x-component-props': {
|
|
useAction: '{{ cm.useCancelAction }}',
|
|
},
|
|
},
|
|
submit: {
|
|
title: '{{t("Submit")}}',
|
|
'x-component': 'Action',
|
|
'x-component-props': {
|
|
type: 'primary',
|
|
useAction: useUpdateAction,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as ISchema,
|
|
},
|
|
}}
|
|
/>
|
|
</ActionContextProvider>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|