Feat(plugin workflow): refactor calculation and add filter for query (#264)

* feat(plugin-workflow): group nodes in add button dropdown menu and adjust some style

* fix(client): add missing key in component

* feat(plugin-workflow): add job type variable getter structure for calculation

* feat(plugin-workflow): add calculation config for query filter
This commit is contained in:
Junyi 2022-04-06 15:25:56 +08:00 committed by GitHub
parent cdfc418f39
commit 1dc8a21cfe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 399 additions and 103 deletions

View File

@ -127,7 +127,7 @@ export const AddFieldAction = () => {
{options.map((option) => { {options.map((option) => {
return ( return (
option.children.length > 0 && ( option.children.length > 0 && (
<Menu.ItemGroup title={compile(option.label)}> <Menu.ItemGroup key={option.label} title={compile(option.label)}>
{option.children.map((child) => { {option.children.map((child) => {
return <Menu.Item key={child.name}>{compile(child.title)}</Menu.Item>; return <Menu.Item key={child.name}>{compile(child.title)}</Menu.Item>;
})} })}

View File

@ -77,14 +77,7 @@ export function Branch({
{controller} {controller}
<AddButton upstream={from} branchIndex={branchIndex} /> <AddButton upstream={from} branchIndex={branchIndex} />
<div className="workflow-node-list"> <div className="workflow-node-list">
{list.map(item => { {list.map(item => <Node data={item} key={item.id} />)}
return (
<div key={item.id} className={cx(nodeBlockClass)}>
<Node data={item} />
<AddButton upstream={item} />
</div>
);
})}
</div> </div>
</div> </div>
); );
@ -98,7 +91,7 @@ interface AddButtonProps {
branchIndex?: number; branchIndex?: number;
}; };
function AddButton({ upstream, branchIndex = null }: AddButtonProps) { export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
const { resource } = useCollection(); const { resource } = useCollection();
const { data } = useResourceActionContext(); const { data } = useResourceActionContext();
const { onNodeAdded } = useFlowContext(); const { onNodeAdded } = useFlowContext();
@ -125,11 +118,19 @@ function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
onNodeAdded(node); onNodeAdded(node);
} }
const groups = [
{ value: 'control', name: '流程控制' },
{ value: 'model', name: '数据表操作' },
];
const instructionList = (Array.from(instructions.getValues()) as Instruction[]);
return ( return (
<div className={cx(addButtonClass)}> <div className={cx(addButtonClass)}>
<Dropdown trigger={['click']} overlay={ <Dropdown trigger={['click']} overlay={
<Menu onClick={ev => onCreate(ev)}> <Menu onClick={ev => onCreate(ev)}>
{(Array.from(instructions.getValues()) as Instruction[]).map(item => item.options {groups.map(group => (
<Menu.ItemGroup key={group.value} title={group.name}>
{instructionList.filter(item => item.group === group.value).map(item => item.options
? ( ? (
<Menu.SubMenu key={item.type} title={item.title}> <Menu.SubMenu key={item.type} title={item.title}>
{item.options.map(option => ( {item.options.map(option => (
@ -140,6 +141,8 @@ function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
: ( : (
<Menu.Item key={item.type}>{item.title}</Menu.Item> <Menu.Item key={item.type}>{item.title}</Menu.Item>
))} ))}
</Menu.ItemGroup>
))}
</Menu> </Menu>
}> }>
<Button shape="circle" icon={<PlusOutlined />} /> <Button shape="circle" icon={<PlusOutlined />} />

View File

@ -1,10 +1,13 @@
import React from "react"; import React from "react";
import { Input, Select } from "antd"; import { Cascader, DatePicker, Input, InputNumber, Select } from "antd";
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { useNodeContext } from "./nodes"; import { instructions, useNodeContext } from "./nodes";
import { useFlowContext } from "./WorkflowCanvas";
function NullRender() {
return null;
}
export const calculators = [ export const calculators = [
{ value: 'equal', name: '等于' }, { value: 'equal', name: '等于' },
@ -13,67 +16,219 @@ export const calculators = [
const JT_VALUE_RE = /^\s*\{\{([\s\S]*)\}\}\s*$/; const JT_VALUE_RE = /^\s*\{\{([\s\S]*)\}\}\s*$/;
function JobSelect({ value, onChange }) { export function parseStringValue(value: string, Types) {
const matcher = value.match(JT_VALUE_RE);
if (!matcher) {
return { type: 'constant', value, options: { type: 'string' } };
}
const [type, ...paths] = matcher[1].split('.');
return {
type,
options: paths.length ? (Types || VariableTypes)[type].parse(paths) : {}
};
}
const ConstantTypes = {
string: {
title: '字符串',
value: 'string',
component({ onChange, type, options, ...props }) {
return <Input {...props} onChange={ev => onChange(ev.target.value)} />;
},
default: ''
},
number: {
title: '数字',
value: 'number',
component({ type, options, ...props }) {
return <InputNumber {...props} />;
},
default: 0
},
boolean: {
title: '逻辑值',
value: 'boolean',
component({ type, options, ...props }) {
return (
<Select {...props}>
<Select.Option value={true}></Select.Option>
<Select.Option value={false}></Select.Option>
</Select>
);
},
default: false
},
// date: {
// title: '日期',
// value: 'date',
// component({ type, options, ...props }) {
// return <DatePicker {...props} />;
// },
// default: new Date()
// }
};
export const VariableTypes = {
constant: {
title: '常量',
value: 'constant',
options: Object.values(ConstantTypes).map(item => ({
value: item.value,
label: item.title
})),
component({ options: { type } = { type: 'string' } }) {
return type ? ConstantTypes[type].component : NullRender;
},
appendTypeValue({ options = { type: 'string' } }) {
return options?.type ? [options.type] : [];
},
onTypeChange(props, [type, optionsType], onChange) {
const { default: value } = ConstantTypes[optionsType];
onChange({
value,
type,
options: { ...props.options, type: optionsType }
});
},
parse(path) {
return { path };
}
},
job: {
title: '节点数据',
value: 'job',
options() {
const node = useNodeContext(); const node = useNodeContext();
const stack = []; const stack = [];
for (let current = node.upstream; current; current = current.upstream) { for (let current = node.upstream; current; current = current.upstream) {
stack.push(current); const { getter } = instructions.get(current.type);
// consider `getter` as the key of a value available node
if (getter) {
stack.push({
value: current.id,
label: current.title ?? `#${current.id}`
});
}
} }
return (
<Select value={value} onChange={onChange}>
{stack.map(item => (
<Select.Option key={item.id} value={item.id}>{item.title ?? `#${item.id}`}</Select.Option>
))}
</Select>
);
}
function ContextSelect({ value, onChange }) { return stack;
return (
<Select></Select>
);
}
const VariableTypeComponent = {
constant({ onChange, ...props }) {
return <Input {...props} onChange={ev => onChange(ev.target.value)} />
}, },
job: JobSelect, component({ options }) {
context: ContextSelect, const { nodes } = useFlowContext();
if (!options?.nodeId) {
return NullRender;
}
const node = nodes.find(n => n.id == options.nodeId);
if (!node) {
return NullRender;
}
const instruction = instructions.get(node.type);
return instruction?.getter ?? NullRender;
},
appendTypeValue({ options = {} }: { type: string, options: any }) {
return options.nodeId ? [Number.parseInt(options.nodeId, 10)] : [];
},
onTypeChange(props, [type, nodeId], onChange) {
onChange({
...props,
type,
options: { ...props.options, nodeId }
});
},
parse([nodeId, ...path]) {
return { nodeId, path: path.join('.') };
},
stringify({ options }) {
const stack = ['job'];
if (options.nodeId) {
stack.push(options.nodeId);
if (options.path) {
stack.push(options.path);
}
}
return `{{${stack.join('.')}}}`;
}
},
// context: ContextSelect,
// calculation: Calculation // calculation: Calculation
}; };
function OperandTypeSelect({ value, onChange }) { interface OperandProps {
return ( value: {
<Select value={value} onChange={onChange} placeholder="变量来源"> type: string;
<Select.Option value="constant"></Select.Option> value?: any;
<Select.Option value="job"></Select.Option> options?: any;
<Select.Option value="context" disabled></Select.Option> };
<Select.Option value="calculation" disabled></Select.Option> onChange(v: any): void
</Select>
);
} }
export function Operand({ value: operand = { type: 'constant', value: '' }, onChange }) { export function Operand({ onChange, value: operand = { type: 'constant', value: '', options: { type: 'string' } } }: OperandProps) {
const { value } = operand; const { type } = operand;
let { type = 'constant' } = operand;
if (typeof value === 'string') {
const matcher = value.match(JT_VALUE_RE);
if (matcher) { const { component, appendTypeValue } = VariableTypes[type];
console.log(matcher); const VariableComponent = typeof component === 'function' ? component(operand) : component;
}
}
const VariableComponent = VariableTypeComponent[type];
return ( return (
<div className={css` <div className={css`
display: flex; display: flex;
gap: .5em; gap: .5em;
align-items: center;
`}> `}>
<OperandTypeSelect value={type} onChange={v => onChange({ ...operand, type: v })} /> <Cascader
<VariableComponent value={value} onChange={v => onChange({ ...operand, value: v })} /> allowClear={false}
value={[type, ...(appendTypeValue ? appendTypeValue(operand) : [])]}
options={Object.values(VariableTypes).map(item => {
const children = typeof item.options === 'function' ? item.options() : item.options;
return {
label: item.title,
value: item.value,
children,
disabled: children && !children.length
};
})}
onChange={(t: Array<string | number>) => {
const { onTypeChange } = VariableTypes[t[0]];
if (typeof onTypeChange === 'function') {
onTypeChange(operand, t, onChange);
} else {
if (t[0] !== type) {
onChange({ type: t[0], value: null });
}
}
}}
/>
<VariableComponent {...operand} onChange={v => onChange({ ...operand, value: v })} />
</div>
);
}
export function Calculation({ calculator, operands, onChange }) {
return (
<div className={css`
display: flex;
gap: .5em;
align-items: center;
.ant-select{
width: auto;
}
`}>
<Operand value={operands[0]} onChange={(v => onChange({ calculator, operands: [v, operands[1]] }))} />
{operands[0]
? (
<>
<Select value={calculator} onChange={v => onChange({ operands, calculator: v })}>
{calculators.map(item => (
<Select.Option key={item.value} value={item.value}>{item.name}</Select.Option>
))}
</Select>
<Operand value={operands[1]} onChange={(v => onChange({ calculator, operands: [operands[0], v] }))} />
</>
)
: null
}
</div> </div>
); );
} }

View File

@ -7,7 +7,7 @@ import { Trans } from "react-i18next";
import { NodeDefaultView } from "."; import { NodeDefaultView } from ".";
import { Branch, useFlowContext } from "../WorkflowCanvas"; import { Branch, useFlowContext } from "../WorkflowCanvas";
import { branchBlockClass, nodeSubtreeClass } from "../style"; import { branchBlockClass, nodeSubtreeClass } from "../style";
import { calculators, Operand } from "../calculators"; import { Calculation } from "../calculators";
// import { SchemaComponent } from "../../schema-component"; // import { SchemaComponent } from "../../schema-component";
function CalculationItem({ value, onChange, onRemove }) { function CalculationItem({ value, onChange, onRemove }) {
@ -30,24 +30,7 @@ function CalculationItem({ value, onChange, onRemove }) {
onChange={group => onChange({ ...value, group })} onChange={group => onChange({ ...value, group })}
/> />
) )
: ( : <Calculation operands={operands} calculator={calculator} onChange={onChange} />
<div className={css`
display: flex;
gap: .5em;
.ant-select{
width: auto;
}
`}>
<Operand value={operands[0]} onChange={v => onChange({ ...value, operands: [v, operands[1]] })} />
<Select value={calculator} onChange={v => onChange({ ...value, calculator: v })}>
{calculators.map(item => (
<Select.Option key={item.value} value={item.value}>{item.name}</Select.Option>
))}
</Select>
<Operand value={operands[1]} onChange={v => onChange({ ...value, operands: [operands[0], v] })} />
</div>
)
} }
<Button onClick={onRemove} type="text" icon={<CloseCircleOutlined />} /> <Button onClick={onRemove} type="text" icon={<CloseCircleOutlined />} />
</div> </div>
@ -146,6 +129,7 @@ function CalculationConfig({ value, onChange }) {
export default { export default {
title: '条件判断', title: '条件判断',
type: 'condition', type: 'condition',
group: 'control',
fieldset: { fieldset: {
rejectOnFalse: { rejectOnFalse: {
type: 'boolean', type: 'boolean',

View File

@ -1,15 +1,15 @@
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { Button, Modal, Tag } from 'antd'; import { Button, Modal, Tag } from 'antd';
import { DeleteOutlined } from '@ant-design/icons'; import { DeleteOutlined, CloseOutlined } from '@ant-design/icons';
import { ISchema, useForm } from '@formily/react'; import { ISchema, useForm } from '@formily/react';
import { Registry } from '@nocobase/utils'; import { Registry } from '@nocobase/utils';
import { SchemaComponent, useActionContext, useAPIClient, useCollection, useRequest, useResourceActionContext } from '../..'; import { SchemaComponent, useActionContext, useAPIClient, useCollection, useRequest, useResourceActionContext } from '../..';
import { useFlowContext } from '../WorkflowCanvas'; import { AddButton, useFlowContext } from '../WorkflowCanvas';
import { nodeClass, nodeCardClass, nodeHeaderClass, nodeTitleClass } from '../style'; import { nodeClass, nodeCardClass, nodeHeaderClass, nodeTitleClass, nodeBlockClass } from '../style';
import query from './query'; import query from './query';
import condition from './condition'; import condition from './condition';
@ -44,12 +44,15 @@ function useUpdateConfigAction() {
export interface Instruction { export interface Instruction {
title: string; title: string;
type: string; type: string;
group: string;
options?: { label: string; value: any; key: string }[]; options?: { label: string; value: any; key: string }[];
fieldset: { [key: string]: ISchema }; fieldset: { [key: string]: ISchema };
view: ISchema; view?: ISchema;
scope?: { [key: string]: any }; scope?: { [key: string]: any };
components?: { [key: string]: any } components?: { [key: string]: any };
render?(props): React.ReactElement render?(props): React.ReactElement;
endding?: boolean;
getter?(node: any): React.ReactElement;
}; };
export const instructions = new Registry<Instruction>(); export const instructions = new Registry<Instruction>();
@ -67,12 +70,38 @@ export function useNodeContext() {
export function Node({ data }) { export function Node({ data }) {
const instruction = instructions.get(data.type); const instruction = instructions.get(data.type);
if (instruction.render) {
return instruction.render(data);
}
return ( return (
<NodeDefaultView data={data} /> <div className={cx(nodeBlockClass)}>
{instruction.render
? instruction.render(data)
: <NodeDefaultView data={data} />
}
{!instruction.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: #f0f2f5;
.anticon{
font-size: 1.5em;
line-height: 100%;
}
`}
>
<CloseOutlined />
</div>
)
}
</div>
); );
} }
@ -95,7 +124,7 @@ export function RemoveButton() {
Modal.confirm({ Modal.confirm({
title: '删除分支', title: '删除分支',
content: '节点包含分支,将删除其所有分支下的子节点,确定继续?', content: '节点包含分支,将同时删除其所有分支下的子节点,确定继续?',
onOk onOk
}); });
} }
@ -123,7 +152,7 @@ export function NodeDefaultView(props) {
</div> </div>
<SchemaComponent <SchemaComponent
scope={instruction.scope} scope={instruction.scope}
components={{...instruction.components}} components={instruction.components}
schema={{ schema={{
type: 'void', type: 'void',
properties: { properties: {

View File

@ -11,6 +11,7 @@ import { Button, Tooltip } from "antd";
export default { export default {
title: '并行', title: '并行',
type: 'parallel', type: 'parallel',
group: 'control',
fieldset: { fieldset: {
mode: { mode: {
type: 'string', type: 'string',
@ -39,7 +40,7 @@ export default {
return result.concat(node); return result.concat(node);
} }
return result; return result;
}, []); }, []).sort((a, b) => a.branchIndex - b.branchIndex);
const [branchCount, setBranchCount] = useState(Math.max(2, branches.length)); const [branchCount, setBranchCount] = useState(Math.max(2, branches.length));
const tempBranches = Array(Math.max(0, branchCount - branches.length)).fill(null); const tempBranches = Array(Math.max(0, branchCount - branches.length)).fill(null);

View File

@ -1,10 +1,21 @@
import React, { useState } from 'react';
import { useForm } from '@formily/react';
import { action } from '@formily/reactive'; import { action } from '@formily/reactive';
import { Cascader, Select } from 'antd';
import { t } from 'i18next'; import { t } from 'i18next';
import { useCollectionManager } from '../../collection-manager'; import { css } from '@emotion/css';
import { useRequest, useCollectionManager } from '../..';
import { useCollectionFilterOptions } from '../../collection-manager/action-hooks';
import { useFlowContext } from '../WorkflowCanvas';
import { parseStringValue, VariableTypes } from '../calculators';
const BaseTypeSet = new Set(['boolean', 'number', 'string', 'date']);
export default { export default {
title: '数据查询', title: '数据查询',
type: 'query', type: 'query',
group: 'model',
fieldset: { fieldset: {
collection: { collection: {
type: 'string', type: 'string',
@ -20,7 +31,42 @@ export default {
title: '多条数据', title: '多条数据',
name: 'multiple', name: 'multiple',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Checkbox' 'x-component': 'Checkbox',
'x-component-props': {
disabled: true
}
},
params: {
type: 'object',
name: 'params',
title: '查询参数',
'x-decorator': 'FormItem',
properties: {
filter: {
type: 'object',
title: '筛选条件',
name: 'filter',
'x-decorator': 'div',
'x-decorator-props': {
className: css`
.ant-select{
width: auto;
}
`
},
'x-component': 'Filter',
'x-component-props': {
useDataSource(options) {
const { values } = useForm();
const data = useCollectionFilterOptions(values.collection);
return useRequest(() => Promise.resolve({
data
}), options)
},
dynamicComponent: 'VariableComponent'
}
}
}
} }
}, },
view: { view: {
@ -38,5 +84,80 @@ export default {
})(collections); })(collections);
} }
} }
},
components: {
VariableComponent({ value, onChange, renderSchemaComponent }) {
const VTypes = { ...VariableTypes,
constant: {
title: '常量',
value: 'constant',
options: undefined
}
};
const operand = typeof value === 'string'
? parseStringValue(value, VTypes)
: { type: 'constant', value };
const { component, appendTypeValue } = VTypes[operand.type];
const [types, setTypes] = useState([operand.type, ...(appendTypeValue ? appendTypeValue(operand) : [])]);
const [type] = types;
const VariableComponent = typeof component === 'function' ? component(operand) : component;
return (
<div className={css`
display: flex;
gap: .5em;
align-items: center;
`}>
<Cascader
allowClear={false}
value={types}
options={Object.values(VTypes).map(item => ({
label: item.title,
value: item.value,
children: typeof item.options === 'function' ? item.options() : item.options
}))}
onChange={(next: Array<any>) => {
const { onTypeChange, stringify } = VTypes[next[0]];
setTypes(next);
if (typeof onTypeChange === 'function') {
onTypeChange(operand, next, (op) => {
onChange(stringify(op));
});
} else {
if (next[0] !== type) {
onChange(null);
}
}
}}
/>
{type === 'constant'
? renderSchemaComponent()
: <VariableComponent {...operand} onChange={(v) => {
const { stringify } = VTypes[type];
onChange(stringify(v));
}} />
}
</div>
);
}
},
getter({ options, onChange }) {
const { collections = [] } = useCollectionManager();
const { nodes } = useFlowContext();
const { config } = nodes.find(n => n.id == options.nodeId);
const collection = collections.find(item => item.name === config.collection) ?? { fields: [] };
return (
<Select value={options.path} placeholder="选择字段" onChange={path => onChange({ options: { ...options, path } })}>
{collection.fields
.filter(field => BaseTypeSet.has(field.uiSchema.type))
.map(field => (
<Select.Option key={field.name} value={field.name}>{t(field.uiSchema.title)}</Select.Option>
))}
</Select>
);
} }
}; };

View File

@ -88,6 +88,8 @@ export const branchClass = css`
`; `;
export const nodeBlockClass = css` export const nodeBlockClass = css`
flex-grow: 1;
flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -95,6 +97,7 @@ export const nodeBlockClass = css`
`; `;
export const nodeClass = css` export const nodeClass = css`
flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;