tachybase_todo/packages/plugins/workflow/src/client/nodes/index.tsx
Junyi a59ed4ee17
fix(plugin-workflow): fix variable type check (#2492)
* fix(plugin-workflow): fix variable type check

* fix(plugin-workflow): fix node job button style
2023-08-20 02:45:46 -07:00

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