feat(plugin-workflow): add preset and branching config before add node

This commit is contained in:
mytharcher 2024-11-02 13:41:33 +08:00
parent f019924129
commit 84c62a4ab7
10 changed files with 389 additions and 109 deletions

View File

@ -355,6 +355,7 @@ export default class extends Instruction {
default: 0,
},
};
branching = true;
scope = {
renderEngineReference,
};

View File

@ -58,6 +58,7 @@ export default class extends Instruction {
default: 'all',
},
};
branching = true;
components = {
RadioWithTooltip,
};
@ -118,6 +119,7 @@ export default class extends Instruction {
icon={<PlusOutlined />}
onClick={() => setBranchCount(branchCount - 1)}
disabled={workflow.executed}
size="small"
/>
</div>
) : null
@ -145,6 +147,7 @@ export default class extends Instruction {
transform: rotate(-45deg);
}
`}
size="small"
onClick={() => setBranchCount(branchCount + 1)}
disabled={workflow.executed}
/>

View File

@ -1,5 +1,5 @@
{
"Parallel branch": "分支",
"Parallel branch": "并行分支",
"Run multiple branch processes in parallel.": "并行运行多个分支流程。",
"Add branch": "增加分支",
"Mode": "执行模式",

View File

@ -8,16 +8,17 @@
*/
import React, { useCallback, useMemo, useState } from 'react';
import { Button, Dropdown, MenuProps } from 'antd';
import { Button, Dropdown, MenuProps, Modal } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { css, useAPIClient, useCompile, usePlugin } from '@nocobase/client';
import { css, SchemaComponent, useAPIClient, useCompile, usePlugin } from '@nocobase/client';
import WorkflowPlugin from '.';
import { useFlowContext } from './FlowContext';
import { NAMESPACE } from './locale';
import { Instruction } from './nodes';
import useStyles from './style';
import { useAddNodeContext } from './AddNodeContext';
interface AddButtonProps {
upstream;
@ -29,14 +30,13 @@ export function AddButton(props: AddButtonProps) {
const { upstream, branchIndex = null } = props;
const engine = usePlugin(WorkflowPlugin);
const compile = useCompile();
const api = useAPIClient();
const { workflow, refresh } = useFlowContext() ?? {};
const { workflow } = useFlowContext() ?? {};
const instructionList = Array.from(engine.instructions.getValues()) as Instruction[];
const { styles } = useStyles();
const [creating, setCreating] = useState(false);
const { onPreset, onCreate, creating } = useAddNodeContext();
const groups = useMemo(() => {
return [
const result = [
{ key: 'control', label: `{{t("Control", { ns: "${NAMESPACE}" })}}` },
{ key: 'calculation', label: `{{t("Calculation", { ns: "${NAMESPACE}" })}}` },
{ key: 'collection', label: `{{t("Collection operations", { ns: "${NAMESPACE}" })}}` },
@ -58,62 +58,36 @@ export function AddButton(props: AddButtonProps) {
'aria-label': item.type,
key: item.type,
label: item.title,
type: item.options ? 'subMenu' : null,
children: item.options
? item.options.map((option) => ({
role: 'button',
'aria-label': option.key,
key: option.key,
label: option.label,
}))
: null,
})),
};
})
.filter((group) => group.children.length);
}, [branchIndex, engine, instructionList, upstream, workflow]);
const onCreate = useCallback(
return compile(result);
}, [branchIndex, compile, engine, instructionList, upstream, workflow]);
const onClick = useCallback(
async ({ keyPath }) => {
const type = keyPath.pop();
const [optionKey] = keyPath;
const [type] = keyPath;
const instruction = engine.instructions.get(type);
const config = instruction.createDefaultConfig();
if (optionKey) {
const { value } = instruction.options?.find((item) => item.key === optionKey) ?? {};
Object.assign(config, typeof value === 'function' ? value() : value);
if (!instruction) {
return;
}
if (workflow) {
setCreating(true);
try {
await api.resource('workflows.nodes', workflow.id).create({
values: {
type,
upstreamId: upstream?.id ?? null,
branchIndex,
title: compile(instruction.title),
config,
},
});
refresh();
} catch (err) {
console.error(err);
} finally {
setCreating(false);
}
const title = compile(instruction.title);
const config = instruction.createDefaultConfig?.() ?? {};
const data = { instruction, type, title, config, upstream, branchIndex };
if (
instruction.presetFieldset ||
(typeof instruction.branching === 'function' ? instruction.branching(config) : instruction.branching)
) {
onPreset(data);
} else {
onCreate(data);
}
},
[api, branchIndex, engine.instructions, refresh, upstream?.id, workflow],
[branchIndex, engine.instructions, onCreate, upstream],
);
const menu = useMemo<MenuProps>(() => {
return {
onClick: onCreate,
items: compile(groups),
};
}, [groups, onCreate]);
if (!workflow) {
return null;
}
@ -121,8 +95,10 @@ export function AddButton(props: AddButtonProps) {
return (
<div className={styles.addButtonClass}>
<Dropdown
trigger={['click']}
menu={menu}
menu={{
items: groups,
onClick,
}}
disabled={workflow.executed}
overlayClassName={css`
.ant-dropdown-menu-root {
@ -135,7 +111,8 @@ export function AddButton(props: AddButtonProps) {
aria-label={props['aria-label'] || 'add-button'}
shape="circle"
icon={<PlusOutlined />}
loading={creating}
loading={creating?.upstream === upstream && creating?.branchIndex === branchIndex}
size="small"
/>
</Dropdown>
</div>

View File

@ -0,0 +1,249 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { createForm } from '@formily/core';
import { observer, useForm } from '@formily/react';
import {
ActionContextProvider,
FormItem,
SchemaComponent,
useActionContext,
useAPIClient,
useCancelAction,
} from '@nocobase/client';
import { useFlowContext } from './FlowContext';
import { lang, NAMESPACE } from './locale';
import { RadioWithTooltip } from './components';
function useAddNodeSubmitAction() {
const form = useForm();
return {
async run() {
console.log('=======', form.values);
},
};
}
const AddNodeContext = createContext(null);
export function useAddNodeContext() {
return useContext(AddNodeContext);
}
const defaultBranchingOptions = [
{
label: `{{t('After end of branches', { ns: "${NAMESPACE}" })}}`,
value: false,
},
{
label: `{{t('Inside of branch', { ns: "${NAMESPACE}" })}}`,
value: 0,
},
];
const DownstreamBranchIndex = observer((props) => {
const { presetting } = useAddNodeContext();
const { nodes } = useFlowContext();
const { values } = useForm();
const options = useMemo(() => {
if (!presetting?.instruction) {
return [];
}
const { instruction, upstream, branchIndex } = presetting || {};
const downstream = upstream
? nodes.find((item) => item.upstreamId === upstream.id && item.branchIndex === branchIndex)
: nodes.find((item) => item.upstreamId === null);
if (!downstream) {
return [];
}
const branching =
typeof instruction.branching === 'function' ? instruction.branching(values.config) : instruction.branching;
if (!branching) {
return [];
}
return branching === true ? defaultBranchingOptions : branching;
}, [presetting, nodes, values.config]);
if (!options.length) {
return null;
}
return (
<SchemaComponent
components={{
RadioWithTooltip,
}}
schema={{
name: `${presetting?.type ?? 'unknown'}-${presetting?.upstream?.id ?? 'root'}-${presetting?.branchIndex}`,
type: 'void',
properties: {
downstreamBranchIndex: {
type: 'number',
title: lang('Move all downstream nodes to', { ns: NAMESPACE }),
'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip',
'x-component-props': {
options,
direction: 'vertical',
},
default: false,
required: true,
},
},
}}
/>
);
// return (
// <FormItem label={lang('Move all downstream nodes to', { ns: NAMESPACE })}>
// <RadioWithTooltip {...props} options={options} defaultValue={-1} direction="vertical" />
// </FormItem>
// );
});
function PresetFieldset({ useSchema }) {
const schema = useSchema();
return <SchemaComponent schema={schema} />;
}
export function AddNodeContextProvider(props) {
const api = useAPIClient();
const [creating, setCreating] = useState(null);
const [presetting, setPresetting] = useState(null);
const [formValueChanged, setFormValueChanged] = useState(false);
const { workflow, refresh } = useFlowContext() ?? {};
const form = useMemo(() => {
return createForm({
initialValues: {},
values: {},
});
}, [presetting]);
const onModalCancel = useCallback(
(visible) => {
if (!visible) {
// form.setValues({});
// form.clearFormGraph('.config', true);
form.reset();
setTimeout(() => {
setPresetting(null);
});
}
},
[form],
);
const onCreate = useCallback(
async ({ type, title, config, upstream, branchIndex }) => {
setCreating({ upstream, branchIndex });
try {
await api.resource('workflows.nodes', workflow.id).create({
values: {
type,
upstreamId: upstream?.id ?? null,
branchIndex,
title,
config,
},
});
refresh();
} catch (err) {
console.error(err);
} finally {
setCreating(null);
}
},
[api, refresh, workflow.id],
);
const usePresetSchema = useCallback(() => presetting?.instruction.presetFieldset, [presetting]);
return (
<AddNodeContext.Provider value={{ onPreset: setPresetting, presetting, onCreate, creating }}>
{props.children}
<ActionContextProvider
value={{
visible: Boolean(presetting),
setVisible: onModalCancel,
formValueChanged,
setFormValueChanged,
openSize: 'small',
}}
>
<SchemaComponent
components={{
DownstreamBranchIndex,
}}
scope={{
useCancelAction,
useAddNodeSubmitAction,
usePresetSchema,
}}
schema={{
name: `modal`,
type: 'void',
'x-decorator': 'FormV2',
'x-decorator-props': {
form,
},
'x-component': 'Action.Modal',
title: `{{ t("Add node", { ns: "${NAMESPACE}" }) }}`,
properties: {
config: {
type: 'void',
'x-component': 'PresetFieldset',
'x-component-props': {
useSchema: '{{ usePresetSchema }}',
},
// properties: configSchema,
},
downstreamBranchIndex: {
type: 'void',
'x-component': 'DownstreamBranchIndex',
},
footer: {
'x-component': 'Action.Modal.Footer',
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
properties: {
cancel: {
type: 'void',
title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCancelAction }}',
},
},
submit: {
type: 'void',
title: `{{ t("Submit") }}`,
'x-component': 'Action',
'x-component-props': {
type: 'primary',
htmlType: 'submit',
useAction: '{{ useAddNodeSubmitAction }}',
},
},
},
},
},
},
},
}}
/>
</ActionContextProvider>
</AddNodeContext.Provider>
);
}

View File

@ -18,6 +18,7 @@ import { useFlowContext } from './FlowContext';
import { lang } from './locale';
import useStyles from './style';
import { TriggerConfig } from './triggers';
import { AddNodeContextProvider } from './AddNodeContext';
export function CanvasContent({ entry }) {
const { styles } = useStyles();
@ -27,41 +28,43 @@ export function CanvasContent({ entry }) {
return (
<div className="workflow-canvas-wrapper">
<ErrorBoundary FallbackComponent={ErrorFallback} onError={console.error}>
<div className="workflow-canvas" style={{ zoom: zoom / 100 }}>
<div
className={cx(
styles.branchBlockClass,
css`
margin-top: 0 !important;
`,
)}
>
<div className={styles.branchClass}>
{workflow?.executed ? (
<Alert
type="warning"
message={lang('Executed workflow cannot be modified. Could be copied to a new version to modify.')}
showIcon
className={css`
margin-bottom: 1em;
`}
/>
) : null}
<TriggerConfig />
<div
className={cx(
styles.branchBlockClass,
css`
margin-top: 0 !important;
`,
)}
>
<Branch entry={entry} />
<AddNodeContextProvider>
<div className="workflow-canvas" style={{ zoom: zoom / 100 }}>
<div
className={cx(
styles.branchBlockClass,
css`
margin-top: 0 !important;
`,
)}
>
<div className={styles.branchClass}>
{workflow?.executed ? (
<Alert
type="warning"
message={lang('Executed workflow cannot be modified. Could be copied to a new version to modify.')}
showIcon
className={css`
margin-bottom: 1em;
`}
/>
) : null}
<TriggerConfig />
<div
className={cx(
styles.branchBlockClass,
css`
margin-top: 0 !important;
`,
)}
>
<Branch entry={entry} />
</div>
<div className={styles.terminalClass}>{lang('End')}</div>
</div>
<div className={styles.terminalClass}>{lang('End')}</div>
</div>
</div>
</div>
</AddNodeContextProvider>
</ErrorBoundary>
<div className="workflow-canvas-zoomer">
<Slider vertical reverse defaultValue={100} step={10} min={10} value={zoom} onChange={setZoom} />

View File

@ -20,6 +20,12 @@ import useStyles from '../style';
import { useWorkflowVariableOptions, WorkflowVariableTextArea } from '../variable';
import { CalculationConfig } from '../components/Calculation';
const BRANCH_INDEX = {
DEFAULT: null,
ON_TRUE: 1,
ON_FALSE: 0,
} as const;
export default class extends Instruction {
title = `{{t("Condition", { ns: "${NAMESPACE}" })}}`;
type = 'condition';
@ -107,18 +113,44 @@ export default class extends Instruction {
required: true,
},
};
options = [
{
label: `{{t('Continue when "Yes"', { ns: "${NAMESPACE}" })}}`,
key: 'rejectOnFalse',
value: { rejectOnFalse: true },
presetFieldset = {
rejectOnFalse: {
type: 'boolean',
title: `{{t("Mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
enum: [
{
label: `{{t('Continue when "Yes"', { ns: "${NAMESPACE}" })}}`,
value: true,
},
{
label: `{{t('Branch into "Yes" and "No"', { ns: "${NAMESPACE}" })}}`,
value: false,
},
],
default: true,
},
{
label: `{{t('Branch into "Yes" and "No"', { ns: "${NAMESPACE}" })}}`,
key: 'branch',
value: { rejectOnFalse: false },
},
];
};
branching = ({ rejectOnFalse = true } = {}) => {
return rejectOnFalse
? false
: [
{
label: `{{t('After end of branches', { ns: "${NAMESPACE}" })}}`,
value: false,
},
{
label: `{{t('Inside of "Yes" branch', { ns: "${NAMESPACE}" })}}`,
value: BRANCH_INDEX.ON_TRUE,
},
{
label: `{{t('Inside of "No" branch', { ns: "${NAMESPACE}" })}}`,
value: BRANCH_INDEX.ON_FALSE,
},
];
};
scope = {
renderEngineReference,

View File

@ -51,27 +51,40 @@ export type NodeAvailableContext = {
branchIndex: number;
};
type Config = Record<string, any>;
type Options = { label: string; value: any }[];
export abstract class Instruction {
title: string;
type: string;
group: string;
description?: string;
/**
* @experimental
* @deprecated migrate to `presetFieldset` instead
*/
options?: { label: string; value: any; key: string }[];
fieldset: Record<string, ISchema>;
/**
* @experimental
*/
presetFieldset?: Record<string, ISchema>;
/**
* To presentation if the instruction is creating a branch
* @experimental
*/
branching?: boolean | Options | ((config: Config) => boolean | Options);
/**
* @experimental
*/
view?: ISchema;
scope?: { [key: string]: any };
components?: { [key: string]: any };
scope?: Record<string, any>;
components?: Record<string, any>;
Component?(props): JSX.Element;
/**
* @experimental
*/
createDefaultConfig?(): Record<string, any> {
createDefaultConfig?(): Config {
return {};
}
useVariables?(node, options?: UseVariableOptions): VariableOption;
@ -568,12 +581,7 @@ export function NodeDefaultView(props) {
'Node with unknown type will cause error. Please delete it or check plugin which provide this type.',
)}
>
<div
role="button"
aria-label={`_untyped-${editingTitle}`}
className={cx(styles.nodeCardClass, 'invalid')}
onClick={onOpenDrawer}
>
<div role="button" aria-label={`_untyped-${editingTitle}`} className={cx(styles.nodeCardClass, 'invalid')}>
<div className={cx(styles.nodeMetaClass, 'workflow-node-meta')}>
<Tag color="error">{lang('Unknown node')}</Tag>
<span className="workflow-node-id">{data.id}</span>

View File

@ -160,6 +160,8 @@
"Continue when \"Yes\"": "“是”则继续",
"Branch into \"Yes\" and \"No\"": "“是”和“否”分别继续",
"Condition expression": "条件表达式",
"Inside of \"Yes\" branch": "“是”分支内",
"Inside of \"No\" branch": "“否”分支内",
"Create record": "新增数据",
"Add new record to a collection. You can use variables from upstream nodes to assign values to fields.":
"向一个数据表中添加新的数据。可以使用上游节点里的变量为字段赋值。",
@ -206,5 +208,10 @@
"Succeeded": "成功",
"Test run": "测试执行",
"Test run will do the actual data manipulating or API calling, please use with caution.": "测试执行会进行实际的数据操作或 API 调用,请谨慎使用。",
"No variable": "无变量"
"No variable": "无变量",
"Add node": "添加节点",
"Move all downstream nodes to": "将所有下游节点移至",
"After end of branches": "分支结束后",
"Inside of branch": "分支内"
}

View File

@ -22,7 +22,7 @@ function make(name, mod) {
}
export default function ({ app }) {
app.actions({
app.resourceManager.registerActionHandlers({
...make('workflows', workflows),
...make('workflows.nodes', {
create: nodes.create,