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) => {
return (
option.children.length > 0 && (
<Menu.ItemGroup title={compile(option.label)}>
<Menu.ItemGroup key={option.label} title={compile(option.label)}>
{option.children.map((child) => {
return <Menu.Item key={child.name}>{compile(child.title)}</Menu.Item>;
})}

View File

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

View File

@ -1,10 +1,13 @@
import React from "react";
import { Input, Select } from "antd";
import { Cascader, DatePicker, Input, InputNumber, Select } from "antd";
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 = [
{ value: 'equal', name: '等于' },
@ -13,67 +16,219 @@ export const calculators = [
const JT_VALUE_RE = /^\s*\{\{([\s\S]*)\}\}\s*$/;
function JobSelect({ value, onChange }) {
const node = useNodeContext();
const stack = [];
for (let current = node.upstream; current; current = current.upstream) {
stack.push(current);
export function parseStringValue(value: string, Types) {
const matcher = value.match(JT_VALUE_RE);
if (!matcher) {
return { type: 'constant', value, options: { type: 'string' } };
}
return (
<Select value={value} onChange={onChange}>
{stack.map(item => (
<Select.Option key={item.id} value={item.id}>{item.title ?? `#${item.id}`}</Select.Option>
))}
</Select>
);
const [type, ...paths] = matcher[1].split('.');
return {
type,
options: paths.length ? (Types || VariableTypes)[type].parse(paths) : {}
};
}
function ContextSelect({ value, onChange }) {
return (
<Select></Select>
);
}
const VariableTypeComponent = {
constant({ onChange, ...props }) {
return <Input {...props} onChange={ev => onChange(ev.target.value)} />
const ConstantTypes = {
string: {
title: '字符串',
value: 'string',
component({ onChange, type, options, ...props }) {
return <Input {...props} onChange={ev => onChange(ev.target.value)} />;
},
default: ''
},
job: JobSelect,
context: ContextSelect,
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 stack = [];
for (let current = node.upstream; current; current = current.upstream) {
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 stack;
},
component({ options }) {
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
};
function OperandTypeSelect({ value, onChange }) {
return (
<Select value={value} onChange={onChange} placeholder="变量来源">
<Select.Option value="constant"></Select.Option>
<Select.Option value="job"></Select.Option>
<Select.Option value="context" disabled></Select.Option>
<Select.Option value="calculation" disabled></Select.Option>
</Select>
);
interface OperandProps {
value: {
type: string;
value?: any;
options?: any;
};
onChange(v: any): void
}
export function Operand({ value: operand = { type: 'constant', value: '' }, onChange }) {
const { value } = operand;
let { type = 'constant' } = operand;
if (typeof value === 'string') {
const matcher = value.match(JT_VALUE_RE);
export function Operand({ onChange, value: operand = { type: 'constant', value: '', options: { type: 'string' } } }: OperandProps) {
const { type } = operand;
if (matcher) {
console.log(matcher);
}
}
const VariableComponent = VariableTypeComponent[type];
const { component, appendTypeValue } = VariableTypes[type];
const VariableComponent = typeof component === 'function' ? component(operand) : component;
return (
<div className={css`
display: flex;
gap: .5em;
align-items: center;
`}>
<OperandTypeSelect value={type} onChange={v => onChange({ ...operand, type: v })} />
<VariableComponent value={value} onChange={v => onChange({ ...operand, value: v })} />
<Cascader
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>
);
}

View File

@ -7,7 +7,7 @@ import { Trans } from "react-i18next";
import { NodeDefaultView } from ".";
import { Branch, useFlowContext } from "../WorkflowCanvas";
import { branchBlockClass, nodeSubtreeClass } from "../style";
import { calculators, Operand } from "../calculators";
import { Calculation } from "../calculators";
// import { SchemaComponent } from "../../schema-component";
function CalculationItem({ value, onChange, onRemove }) {
@ -30,24 +30,7 @@ function CalculationItem({ value, onChange, onRemove }) {
onChange={group => onChange({ ...value, group })}
/>
)
: (
<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>
)
: <Calculation operands={operands} calculator={calculator} onChange={onChange} />
}
<Button onClick={onRemove} type="text" icon={<CloseCircleOutlined />} />
</div>
@ -146,6 +129,7 @@ function CalculationConfig({ value, onChange }) {
export default {
title: '条件判断',
type: 'condition',
group: 'control',
fieldset: {
rejectOnFalse: {
type: 'boolean',

View File

@ -1,15 +1,15 @@
import React, { useContext } from 'react';
import { cx } from '@emotion/css';
import { css, cx } from '@emotion/css';
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 { Registry } from '@nocobase/utils';
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 condition from './condition';
@ -44,12 +44,15 @@ function useUpdateConfigAction() {
export interface Instruction {
title: string;
type: string;
group: string;
options?: { label: string; value: any; key: string }[];
fieldset: { [key: string]: ISchema };
view: ISchema;
view?: ISchema;
scope?: { [key: string]: any };
components?: { [key: string]: any }
render?(props): React.ReactElement
components?: { [key: string]: any };
render?(props): React.ReactElement;
endding?: boolean;
getter?(node: any): React.ReactElement;
};
export const instructions = new Registry<Instruction>();
@ -67,12 +70,38 @@ export function useNodeContext() {
export function Node({ data }) {
const instruction = instructions.get(data.type);
if (instruction.render) {
return instruction.render(data);
}
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({
title: '删除分支',
content: '节点包含分支,将删除其所有分支下的子节点,确定继续?',
content: '节点包含分支,将同时删除其所有分支下的子节点,确定继续?',
onOk
});
}
@ -123,7 +152,7 @@ export function NodeDefaultView(props) {
</div>
<SchemaComponent
scope={instruction.scope}
components={{...instruction.components}}
components={instruction.components}
schema={{
type: 'void',
properties: {

View File

@ -11,6 +11,7 @@ import { Button, Tooltip } from "antd";
export default {
title: '并行',
type: 'parallel',
group: 'control',
fieldset: {
mode: {
type: 'string',
@ -39,7 +40,7 @@ export default {
return result.concat(node);
}
return result;
}, []);
}, []).sort((a, b) => a.branchIndex - b.branchIndex);
const [branchCount, setBranchCount] = useState(Math.max(2, branches.length));
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 { Cascader, Select } from 'antd';
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 {
title: '数据查询',
type: 'query',
group: 'model',
fieldset: {
collection: {
type: 'string',
@ -20,7 +31,42 @@ export default {
title: '多条数据',
name: 'multiple',
'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: {
@ -38,5 +84,80 @@ export default {
})(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`
flex-grow: 1;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
@ -95,6 +97,7 @@ export const nodeBlockClass = css`
`;
export const nodeClass = css`
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;