feat(plugin-workflow): aggregate (#1852)
* feat(plugin-workflow): add aggregate instruction * test(plugin-workflow): add test cases * fix(plugin-workflow): fix types * fix(plugin-workflow): fix double result type * test(plugin-workflow): fix test cases in mysql * refactor(plugin-workflow): consolidate variables api * fix(plugin-workflow): fix create node variable * fix(plugin-workflow): fix aggregate association name * fix(plugin-workflow): fix test cases * fix(plugin-workflow): fix aggregate node config for duplication * fix(plugin-workflow): fix variable api * fix(plugin-workflow): fix variable api caller * fix(plugin-workflow): fix job button style
This commit is contained in:
		
							parent
							
								
									770f53ec4e
								
							
						
					
					
						commit
						f4064767c6
					
				| @ -0,0 +1,70 @@ | ||||
| import React from 'react'; | ||||
| import { useFieldSchema } from '@formily/react'; | ||||
| import { css } from '@emotion/css'; | ||||
| 
 | ||||
| import { parse } from '@nocobase/utils/client'; | ||||
| import { SchemaInitializer } from '@nocobase/client'; | ||||
| 
 | ||||
| import { useFlowContext } from '../FlowContext'; | ||||
| 
 | ||||
| export const ValueBlock: (() => JSX.Element) & { | ||||
|   Initializer: (props) => JSX.Element; | ||||
|   Result: (props) => JSX.Element; | ||||
| } = () => { | ||||
|   return null; | ||||
| }; | ||||
| 
 | ||||
| function Initializer({ node, resultTitle, insert, ...props }) { | ||||
|   return ( | ||||
|     <SchemaInitializer.Item | ||||
|       {...props} | ||||
|       onClick={() => { | ||||
|         insert({ | ||||
|           type: 'void', | ||||
|           name: node.id, | ||||
|           title: node.title, | ||||
|           'x-component': 'CardItem', | ||||
|           'x-component-props': { | ||||
|             title: node.title ?? `#${node.id}`, | ||||
|           }, | ||||
|           'x-designer': 'SimpleDesigner', | ||||
|           properties: { | ||||
|             result: { | ||||
|               type: 'void', | ||||
|               title: resultTitle, | ||||
|               'x-component': 'ValueBlock.Result', | ||||
|               'x-component-props': { | ||||
|                 // NOTE: as same format as other reference for migration of revision
 | ||||
|                 dataSource: `{{$jobsMapByNodeId.${node.id}}}`, | ||||
|               }, | ||||
|             }, | ||||
|           }, | ||||
|         }); | ||||
|       }} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function Result({ dataSource }) { | ||||
|   const field = useFieldSchema(); | ||||
|   const { execution } = useFlowContext(); | ||||
|   if (!execution) { | ||||
|     return field.title; | ||||
|   } | ||||
|   const result = parse(dataSource)({ | ||||
|     $jobsMapByNodeId: (execution.jobs ?? []).reduce((map, job) => Object.assign(map, { [job.nodeId]: job.result }), {}), | ||||
|   }); | ||||
| 
 | ||||
|   return ( | ||||
|     <pre | ||||
|       className={css` | ||||
|         margin: 0; | ||||
|       `}
 | ||||
|     > | ||||
|       {JSON.stringify(result, null, 2)} | ||||
|     </pre> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| ValueBlock.Initializer = Initializer; | ||||
| ValueBlock.Result = Result; | ||||
| @ -160,6 +160,16 @@ export default { | ||||
|   'Please add at least one condition': '请添加至少一个条件', | ||||
|   'Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.': | ||||
|     '未被赋值的字段将被设置为默认值,没有默认值的设置为空值。', | ||||
| 
 | ||||
|   Aggregate: '聚合查询', | ||||
|   'Aggregator function': '聚合函数', | ||||
|   'Target type': '目标类型', | ||||
|   'Data of collection': '数据表数据', | ||||
|   'Data of associated collection': '关联数据表数据', | ||||
|   'Field to aggregate': '聚合字段', | ||||
|   Distinct: '去重', | ||||
|   'Query result': '查询结果', | ||||
| 
 | ||||
|   'Trigger in executed workflow cannot be modified': '已经执行过工作流的触发器不能被修改', | ||||
|   'Node in executed workflow cannot be modified': '已经执行过工作流中的节点不能被修改', | ||||
|   'Can not delete': '无法删除', | ||||
|  | ||||
							
								
								
									
										322
									
								
								packages/plugins/workflow/src/client/nodes/aggregate.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										322
									
								
								packages/plugins/workflow/src/client/nodes/aggregate.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,322 @@ | ||||
| import React, { useCallback } from 'react'; | ||||
| import { Cascader } from 'antd'; | ||||
| import { useForm } from '@formily/react'; | ||||
| 
 | ||||
| import { | ||||
|   SchemaInitializerItemOptions, | ||||
|   useCollectionDataSource, | ||||
|   useCompile, | ||||
|   SchemaComponentContext, | ||||
|   useCollectionManager, | ||||
| } from '@nocobase/client'; | ||||
| 
 | ||||
| import { collection, filter } from '../schemas/collection'; | ||||
| import { NAMESPACE, lang } from '../locale'; | ||||
| import { FilterDynamicComponent } from '../components/FilterDynamicComponent'; | ||||
| import { BaseTypeSets, nodesOptions, triggerOptions, useWorkflowVariableOptions } from '../variable'; | ||||
| import { FieldsSelect } from '../components/FieldsSelect'; | ||||
| import { ValueBlock } from '../components/ValueBlock'; | ||||
| import { useNodeContext } from '.'; | ||||
| 
 | ||||
| function matchToManyField(field, depth): boolean { | ||||
|   return ['hasMany', 'belongsToMany'].includes(field.type) && depth; | ||||
| } | ||||
| 
 | ||||
| function AssociatedConfig({ value, onChange, ...props }): JSX.Element { | ||||
|   const { setValuesIn } = useForm(); | ||||
|   const compile = useCompile(); | ||||
|   const { getCollection } = useCollectionManager(); | ||||
|   const current = useNodeContext(); | ||||
|   const options = [nodesOptions, triggerOptions].map((item) => { | ||||
|     const children = item.useOptions(current, { types: [matchToManyField] })?.filter(Boolean); | ||||
|     return { | ||||
|       label: compile(item.label), | ||||
|       value: item.value, | ||||
|       key: item.value, | ||||
|       children: compile(children), | ||||
|       disabled: children && !children.length, | ||||
|     }; | ||||
|   }); | ||||
| 
 | ||||
|   const { associatedKey = '', name: fieldName } = value ?? {}; | ||||
|   let p = []; | ||||
|   const matched = associatedKey.match(/^{{(.*)}}$/); | ||||
|   if (matched) { | ||||
|     p = [...matched[1].trim().split('.').slice(0, -1), fieldName]; | ||||
|   } | ||||
| 
 | ||||
|   const onSelectChange = useCallback( | ||||
|     (path, option) => { | ||||
|       if (!path?.length) { | ||||
|         setValuesIn('collection', null); | ||||
|         onChange({}); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // const associationFieldName = path.pop();
 | ||||
|       const { field } = option.pop(); | ||||
|       // need to get:
 | ||||
|       // * source collection (from node.config)
 | ||||
|       // * target collection (from field name)
 | ||||
|       const { collectionName, target, name } = field; | ||||
| 
 | ||||
|       const collection = getCollection(collectionName); | ||||
|       const primaryKeyField = collection.fields.find((f) => f.primaryKey); | ||||
| 
 | ||||
|       setValuesIn('collection', target); | ||||
| 
 | ||||
|       onChange({ | ||||
|         name, | ||||
|         // primary key data path
 | ||||
|         associatedKey: `{{${path.slice(0, -1).join('.')}.${primaryKeyField.name}}}`, | ||||
|         // data associated collection name
 | ||||
|         associatedCollection: collectionName, | ||||
|       }); | ||||
|     }, | ||||
|     [onChange], | ||||
|   ); | ||||
| 
 | ||||
|   return <Cascader {...props} value={p} options={options} onChange={onSelectChange} />; | ||||
| } | ||||
| 
 | ||||
| // based on collection:
 | ||||
| // { collection, field }
 | ||||
| 
 | ||||
| // based on data associated collection
 | ||||
| // { key: '{{$context.data.id}}', collection: "collection.association", field }
 | ||||
| // select data based
 | ||||
| 
 | ||||
| export default { | ||||
|   title: `{{t("Aggregate", { ns: "${NAMESPACE}" })}}`, | ||||
|   type: 'aggregate', | ||||
|   group: 'collection', | ||||
|   fieldset: { | ||||
|     aggregator: { | ||||
|       type: 'string', | ||||
|       title: `{{t("Aggregator function", { ns: "${NAMESPACE}" })}}`, | ||||
|       'x-decorator': 'FormItem', | ||||
|       'x-component': 'Radio.Group', | ||||
|       enum: [ | ||||
|         { label: 'COUNT', value: 'count' }, | ||||
|         { label: 'SUM', value: 'sum' }, | ||||
|         { label: 'AVG', value: 'avg' }, | ||||
|         { label: 'MIN', value: 'min' }, | ||||
|         { label: 'MAX', value: 'max' }, | ||||
|       ], | ||||
|       required: true, | ||||
|       default: 'count', | ||||
|     }, | ||||
|     associated: { | ||||
|       type: 'boolean', | ||||
|       title: `{{t("Target type", { ns: "${NAMESPACE}" })}}`, | ||||
|       'x-decorator': 'FormItem', | ||||
|       'x-component': 'Radio.Group', | ||||
|       enum: [ | ||||
|         { label: `{{t("Data of collection", { ns: "${NAMESPACE}" })}}`, value: false }, | ||||
|         { label: `{{t("Data of associated collection", { ns: "${NAMESPACE}" })}}`, value: true }, | ||||
|       ], | ||||
|       required: true, | ||||
|       default: false, | ||||
|       'x-reactions': [ | ||||
|         { | ||||
|           target: 'collection', | ||||
|           effects: ['onFieldValueChange'], | ||||
|           fulfill: { | ||||
|             state: { | ||||
|               value: null, | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           target: 'association', | ||||
|           effects: ['onFieldValueChange'], | ||||
|           fulfill: { | ||||
|             state: { | ||||
|               value: null, | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|     collectionField: { | ||||
|       type: 'void', | ||||
|       'x-decorator': 'SchemaComponentContext.Provider', | ||||
|       'x-decorator-props': { | ||||
|         value: { designable: false }, | ||||
|       }, | ||||
|       'x-component': 'Grid', | ||||
|       properties: { | ||||
|         row: { | ||||
|           type: 'void', | ||||
|           'x-component': 'Grid.Row', | ||||
|           properties: { | ||||
|             target: { | ||||
|               type: 'void', | ||||
|               'x-component': 'Grid.Col', | ||||
|               properties: { | ||||
|                 collection: { | ||||
|                   ...collection, | ||||
|                   title: `{{t("Data of collection", { ns: "${NAMESPACE}" })}}`, | ||||
|                   'x-component-props': { | ||||
|                     ...collection['x-component-props'], | ||||
|                     className: 'full-width', | ||||
|                   }, | ||||
|                   'x-reactions': [ | ||||
|                     ...collection['x-reactions'], | ||||
|                     { | ||||
|                       dependencies: ['associated'], | ||||
|                       fulfill: { | ||||
|                         state: { | ||||
|                           display: '{{$deps[0] ? "hidden" : "visible"}}', | ||||
|                         }, | ||||
|                       }, | ||||
|                     }, | ||||
|                     { | ||||
|                       target: 'params.field', | ||||
|                       effects: ['onFieldValueChange'], | ||||
|                       fulfill: { | ||||
|                         state: { | ||||
|                           value: null, | ||||
|                         }, | ||||
|                       }, | ||||
|                     }, | ||||
|                     { | ||||
|                       target: 'params.filter', | ||||
|                       effects: ['onFieldValueChange'], | ||||
|                       fulfill: { | ||||
|                         state: { | ||||
|                           value: null, | ||||
|                         }, | ||||
|                       }, | ||||
|                     }, | ||||
|                   ], | ||||
|                 }, | ||||
|                 association: { | ||||
|                   type: 'object', | ||||
|                   title: `{{t("Data of associated collection", { ns: "${NAMESPACE}" })}}`, | ||||
|                   'x-decorator': 'FormItem', | ||||
|                   'x-component': 'AssociatedConfig', | ||||
|                   'x-component-props': { | ||||
|                     className: 'full-width', | ||||
|                   }, | ||||
|                   'x-reactions': [ | ||||
|                     { | ||||
|                       dependencies: ['associated'], | ||||
|                       fulfill: { | ||||
|                         state: { | ||||
|                           visible: '{{!!$deps[0]}}', | ||||
|                         }, | ||||
|                       }, | ||||
|                     }, | ||||
|                   ], | ||||
|                   required: true, | ||||
|                 }, | ||||
|               }, | ||||
|             }, | ||||
|             field: { | ||||
|               type: 'void', | ||||
|               'x-component': 'Grid.Col', | ||||
|               properties: { | ||||
|                 'params.field': { | ||||
|                   type: 'string', | ||||
|                   title: `{{t("Field to aggregate", { ns: "${NAMESPACE}" })}}`, | ||||
|                   'x-decorator': 'FormItem', | ||||
|                   'x-component': 'FieldsSelect', | ||||
|                   'x-component-props': { | ||||
|                     filter(field) { | ||||
|                       return ( | ||||
|                         !field.hidden && | ||||
|                         field.interface && | ||||
|                         !['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type) | ||||
|                       ); | ||||
|                     }, | ||||
|                   }, | ||||
|                   required: true, | ||||
|                   'x-reactions': [ | ||||
|                     { | ||||
|                       dependencies: ['collection'], | ||||
|                       fulfill: { | ||||
|                         state: { | ||||
|                           visible: '{{!!$deps[0]}}', | ||||
|                         }, | ||||
|                       }, | ||||
|                     }, | ||||
|                   ], | ||||
|                 }, | ||||
|               }, | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|     params: { | ||||
|       type: 'object', | ||||
|       properties: { | ||||
|         distinct: { | ||||
|           type: 'boolean', | ||||
|           title: `{{t("Distinct", { ns: "${NAMESPACE}" })}}`, | ||||
|           'x-decorator': 'FormItem', | ||||
|           'x-component': 'Checkbox', | ||||
|           'x-reactions': [ | ||||
|             { | ||||
|               dependencies: ['collection', 'aggregator'], | ||||
|               fulfill: { | ||||
|                 state: { | ||||
|                   visible: '{{!!$deps[0] && ["count"].includes($deps[1])}}', | ||||
|                 }, | ||||
|               }, | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|         filter: { | ||||
|           ...filter, | ||||
|           'x-reactions': [ | ||||
|             { | ||||
|               dependencies: ['collection'], | ||||
|               fulfill: { | ||||
|                 state: { | ||||
|                   visible: '{{!!$deps[0]}}', | ||||
|                 }, | ||||
|               }, | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|   view: {}, | ||||
|   scope: { | ||||
|     useCollectionDataSource, | ||||
|   }, | ||||
|   components: { | ||||
|     SchemaComponentContext, | ||||
|     FilterDynamicComponent, | ||||
|     FieldsSelect, | ||||
|     ValueBlock, | ||||
|     AssociatedConfig, | ||||
|   }, | ||||
|   useVariables(current, { types }) { | ||||
|     if ( | ||||
|       types && | ||||
|       !types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type))) | ||||
|     ) { | ||||
|       return null; | ||||
|     } | ||||
|     return [ | ||||
|       // { key: '', value: '', label: lang('Calculation result') }
 | ||||
|     ]; | ||||
|   }, | ||||
|   useInitializers(node): SchemaInitializerItemOptions | null { | ||||
|     if (!node.config.collection) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       type: 'item', | ||||
|       title: node.title ?? `#${node.id}`, | ||||
|       component: ValueBlock.Initializer, | ||||
|       node, | ||||
|       resultTitle: lang('Query result'), | ||||
|     }; | ||||
|   }, | ||||
| }; | ||||
| @ -11,9 +11,10 @@ import { RadioWithTooltip } from '../components/RadioWithTooltip'; | ||||
| import { renderEngineReference } from '../components/renderEngineReference'; | ||||
| import { NAMESPACE, lang } from '../locale'; | ||||
| import { BaseTypeSets, useWorkflowVariableOptions } from '../variable'; | ||||
| import { ValueBlock } from '../components/ValueBlock'; | ||||
| 
 | ||||
| function matchDynamicExpressionCollectionField(field): boolean { | ||||
|   const { getCollectionFields, getCollection } = useCollectionManager(); | ||||
| function useDynamicExpressionCollectionFieldMatcher(field): boolean { | ||||
|   const { getCollectionFields } = useCollectionManager(); | ||||
|   if (field.type !== 'belongsTo') { | ||||
|     return false; | ||||
|   } | ||||
| @ -24,7 +25,7 @@ function matchDynamicExpressionCollectionField(field): boolean { | ||||
| 
 | ||||
| const DynamicConfig = ({ value, onChange }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const scope = useWorkflowVariableOptions([matchDynamicExpressionCollectionField]); | ||||
|   const scope = useWorkflowVariableOptions({ types: [useDynamicExpressionCollectionFieldMatcher] }); | ||||
| 
 | ||||
|   return ( | ||||
|     <FormLayout layout="vertical"> | ||||
| @ -49,7 +50,7 @@ const DynamicConfig = ({ value, onChange }) => { | ||||
| }; | ||||
| 
 | ||||
| function useWorkflowVariableEntityOptions() { | ||||
|   return useWorkflowVariableOptions([{ type: 'reference', options: { collection: '*', entity: true } }]); | ||||
|   return useWorkflowVariableOptions({ types: [{ type: 'reference', options: { collection: '*', entity: true } }] }); | ||||
| } | ||||
| 
 | ||||
| export default { | ||||
| @ -170,7 +171,8 @@ export default { | ||||
|     RadioWithTooltip, | ||||
|     DynamicConfig, | ||||
|   }, | ||||
|   useVariables(current, types) { | ||||
|   useVariables(current, options) { | ||||
|     const { types } = options ?? {}; | ||||
|     if ( | ||||
|       types && | ||||
|       !types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type))) | ||||
| @ -185,38 +187,9 @@ export default { | ||||
|     return { | ||||
|       type: 'item', | ||||
|       title: node.title ?? `#${node.id}`, | ||||
|       component: CalculationInitializer, | ||||
|       component: ValueBlock.Initializer, | ||||
|       node, | ||||
|       resultTitle: lang('Calculation result'), | ||||
|     }; | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| function CalculationInitializer({ node, insert, ...props }) { | ||||
|   return ( | ||||
|     <SchemaInitializer.Item | ||||
|       {...props} | ||||
|       onClick={() => { | ||||
|         insert({ | ||||
|           type: 'void', | ||||
|           name: node.id, | ||||
|           title: node.title, | ||||
|           'x-component': 'CardItem', | ||||
|           'x-component-props': { | ||||
|             title: node.title ?? `#${node.id}`, | ||||
|           }, | ||||
|           'x-designer': 'SimpleDesigner', | ||||
|           properties: { | ||||
|             result: { | ||||
|               type: 'void', | ||||
|               'x-component': 'CalculationResult', | ||||
|               'x-component-props': { | ||||
|                 // NOTE: as same format as other reference for migration of revision
 | ||||
|                 dataSource: `{{$jobsMapByNodeId.${node.id}}}`, | ||||
|               }, | ||||
|             }, | ||||
|           }, | ||||
|         }); | ||||
|       }} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @ -139,7 +139,7 @@ function getGroupCalculators(group) { | ||||
|   return Array.from(calculators.getEntities()).filter(([key, value]) => value.group === group); | ||||
| } | ||||
| 
 | ||||
| export function Calculation({ calculator, operands = [], onChange }) { | ||||
| function Calculation({ calculator, operands = [], onChange }) { | ||||
|   const compile = useCompile(); | ||||
|   const options = useWorkflowVariableOptions(); | ||||
|   return ( | ||||
|  | ||||
| @ -40,12 +40,14 @@ export default { | ||||
|     CollectionFieldset, | ||||
|     FieldsSelect, | ||||
|   }, | ||||
|   useVariables({ config }, types) { | ||||
|     return useCollectionFieldOptions({ | ||||
|       collection: config.collection, | ||||
|       types, | ||||
|       depth: config.params?.appends?.length ? 1 : 0, | ||||
|   useVariables({ config }, options) { | ||||
|     const result = useCollectionFieldOptions({ | ||||
|       collection: config?.collection, | ||||
|       ...options, | ||||
|       depth: options?.depth ?? config?.params?.appends?.length ? 1 : 0, | ||||
|     }); | ||||
| 
 | ||||
|     return result?.length ? result : null; | ||||
|   }, | ||||
|   useInitializers(node): SchemaInitializerItemOptions | null { | ||||
|     if (!node.config.collection) { | ||||
|  | ||||
| @ -17,7 +17,7 @@ import { | ||||
|   useResourceActionContext, | ||||
| } from '@nocobase/client'; | ||||
| 
 | ||||
| import { nodeBlockClass, nodeCardClass, nodeClass, nodeJobButtonClass, nodeMetaClass, nodeTitleClass } from '../style'; | ||||
| import { nodeBlockClass, nodeCardClass, nodeClass, nodeJobButtonClass, nodeMetaClass } from '../style'; | ||||
| import { AddButton } from '../AddButton'; | ||||
| import { useFlowContext } from '../FlowContext'; | ||||
| 
 | ||||
| @ -33,6 +33,8 @@ import query from './query'; | ||||
| import create from './create'; | ||||
| import update from './update'; | ||||
| import destroy from './destroy'; | ||||
| import aggregate from './aggregate'; | ||||
| 
 | ||||
| import { JobStatusOptionsMap } from '../constants'; | ||||
| import { NAMESPACE, lang } from '../locale'; | ||||
| import request from './request'; | ||||
| @ -49,8 +51,8 @@ export interface Instruction { | ||||
|   components?: { [key: string]: any }; | ||||
|   render?(props): React.ReactNode; | ||||
|   endding?: boolean; | ||||
|   useVariables?(node, types?): VariableOptions; | ||||
|   useScopeVariables?(node, types?): VariableOptions; | ||||
|   useVariables?(node, options?): VariableOptions; | ||||
|   useScopeVariables?(node, options?): VariableOptions; | ||||
|   useInitializers?(node): SchemaInitializerItemOptions | null; | ||||
|   initializers?: { [key: string]: any }; | ||||
| } | ||||
| @ -69,6 +71,8 @@ instructions.register('query', query); | ||||
| instructions.register('create', create); | ||||
| instructions.register('update', update); | ||||
| instructions.register('destroy', destroy); | ||||
| instructions.register('aggregate', aggregate); | ||||
| 
 | ||||
| instructions.register('request', request); | ||||
| 
 | ||||
| function useUpdateAction() { | ||||
| @ -234,7 +238,7 @@ function InnerJobButton({ job, ...props }) { | ||||
|   const { icon, color } = JobStatusOptionsMap[job.status]; | ||||
| 
 | ||||
|   return ( | ||||
|     <Button {...props} shape="circle" className={cx(nodeJobButtonClass, 'workflow-node-job-button')}> | ||||
|     <Button {...props} shape="circle" className={nodeJobButtonClass}> | ||||
|       <Tag color={color}>{icon}</Tag> | ||||
|     </Button> | ||||
|   ); | ||||
| @ -252,7 +256,6 @@ export function JobButton() { | ||||
|       <span | ||||
|         className={cx( | ||||
|           nodeJobButtonClass, | ||||
|           'workflow-node-job-button', | ||||
|           css` | ||||
|             border: 2px solid #d9d9d9; | ||||
|             border-radius: 50%; | ||||
| @ -287,7 +290,7 @@ export function JobButton() { | ||||
|                   } | ||||
|                 `}
 | ||||
|               > | ||||
|                 <span className={cx(nodeJobButtonClass, 'workflow-node-job-button')}> | ||||
|                 <span className={nodeJobButtonClass}> | ||||
|                   <Tag color={color}>{icon}</Tag> | ||||
|                 </span> | ||||
|                 <time>{str2moment(job.updatedAt).format('YYYY-MM-DD HH:mm:ss')}</time> | ||||
|  | ||||
| @ -8,7 +8,7 @@ import { useCompile } from '@nocobase/client'; | ||||
| import { NodeDefaultView } from '.'; | ||||
| import { useFlowContext } from '../FlowContext'; | ||||
| import { lang, NAMESPACE } from '../locale'; | ||||
| import { useWorkflowVariableOptions, VariableOption, VariableTypes } from '../variable'; | ||||
| import { useWorkflowVariableOptions, VariableOption, nodesOptions, triggerOptions } from '../variable'; | ||||
| import { addButtonClass, branchBlockClass, branchClass, nodeSubtreeClass } from '../style'; | ||||
| import { Branch } from '../Branch'; | ||||
| 
 | ||||
| @ -137,18 +137,16 @@ export default { | ||||
|         .split('.') | ||||
|         .map((path) => path.trim()); | ||||
| 
 | ||||
|       const options = VariableTypes.filter((item) => ['$context', '$jobsMapByNodeId'].includes(item.value)).map( | ||||
|         (item: any) => { | ||||
|           const opts = typeof item.useOptions === 'function' ? item.useOptions(node, types).filter(Boolean) : null; | ||||
|           return { | ||||
|             label: compile(item.title), | ||||
|             value: item.value, | ||||
|             key: item.value, | ||||
|             children: compile(opts), | ||||
|             disabled: opts && !opts.length, | ||||
|           }; | ||||
|         }, | ||||
|       ); | ||||
|       const options = [nodesOptions, triggerOptions].map((item: any) => { | ||||
|         const opts = typeof item.useOptions === 'function' ? item.useOptions(node, { types }).filter(Boolean) : null; | ||||
|         return { | ||||
|           label: compile(item.title), | ||||
|           value: item.value, | ||||
|           key: item.value, | ||||
|           children: compile(opts), | ||||
|           disabled: opts && !opts.length, | ||||
|         }; | ||||
|       }); | ||||
| 
 | ||||
|       targetOption.children = findOption(options, paths); | ||||
|     } | ||||
|  | ||||
| @ -4,7 +4,7 @@ import { Variable } from '@nocobase/client'; | ||||
| import { useWorkflowVariableOptions } from '../../variable'; | ||||
| 
 | ||||
| export function AssigneesSelect({ multiple = false, value = [], onChange }) { | ||||
|   const scope = useWorkflowVariableOptions([{ type: 'reference', options: { collection: 'users' } }]); | ||||
|   const scope = useWorkflowVariableOptions({ types: [{ type: 'reference', options: { collection: 'users' } }] }); | ||||
| 
 | ||||
|   return ( | ||||
|     <Variable.Input | ||||
|  | ||||
| @ -86,7 +86,7 @@ export default { | ||||
|     ModeConfig, | ||||
|     AssigneesSelect, | ||||
|   }, | ||||
|   useVariables({ config }, types) { | ||||
|   useVariables({ config }, { types }) { | ||||
|     const formKeys = Object.keys(config.forms ?? {}); | ||||
|     if (!formKeys.length) { | ||||
|       return null; | ||||
|  | ||||
| @ -42,12 +42,14 @@ export default { | ||||
|     FilterDynamicComponent, | ||||
|     FieldsSelect, | ||||
|   }, | ||||
|   useVariables({ config }, types) { | ||||
|     return useCollectionFieldOptions({ | ||||
|       collection: config.collection, | ||||
|       types, | ||||
|       depth: config.params?.appends?.length ? 1 : 0, | ||||
|   useVariables({ config }, options) { | ||||
|     const result = useCollectionFieldOptions({ | ||||
|       collection: config?.collection, | ||||
|       ...options, | ||||
|       depth: options?.depth ?? config?.params?.appends?.length ? 1 : 0, | ||||
|     }); | ||||
| 
 | ||||
|     return result?.length ? result : null; | ||||
|   }, | ||||
|   useInitializers(node): SchemaInitializerItemOptions | null { | ||||
|     if (!node.config.collection) { | ||||
|  | ||||
| @ -206,12 +206,6 @@ export const nodeCardClass = css` | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   > .workflow-node-job-button { | ||||
|     position: absolute; | ||||
|     top: 1rem; | ||||
|     right: 1rem; | ||||
|   } | ||||
| 
 | ||||
|   .ant-input { | ||||
|     font-weight: bold; | ||||
| 
 | ||||
| @ -246,6 +240,9 @@ export const nodeCardClass = css` | ||||
| 
 | ||||
| export const nodeJobButtonClass = css` | ||||
|   display: flex; | ||||
|   position: absolute; | ||||
|   top: 1.25em; | ||||
|   right: 1.25em; | ||||
|   width: 1.25rem; | ||||
|   height: 1.25rem; | ||||
|   min-width: 1.25rem; | ||||
|  | ||||
| @ -134,7 +134,7 @@ export default { | ||||
|   components: { | ||||
|     FieldsSelect, | ||||
|   }, | ||||
|   getOptions(config, types) { | ||||
|   useVariables(config, options) { | ||||
|     const { t } = useWorkflowTranslation(); | ||||
|     const rootFields = [ | ||||
|       { | ||||
| @ -147,8 +147,12 @@ export default { | ||||
|         }, | ||||
|       }, | ||||
|     ]; | ||||
|     const options = useCollectionFieldOptions({ fields: rootFields, types, depth: config.appends?.length ? 2 : 1 }); | ||||
|     return options; | ||||
|     const result = useCollectionFieldOptions({ | ||||
|       ...options, | ||||
|       fields: rootFields, | ||||
|       depth: options?.depth ?? (config.appends?.length ? 2 : 1), | ||||
|     }); | ||||
|     return result; | ||||
|   }, | ||||
|   useInitializers(config): SchemaInitializerItemOptions | null { | ||||
|     if (!config.collection) { | ||||
|  | ||||
| @ -53,7 +53,7 @@ export interface Trigger { | ||||
|   title: string; | ||||
|   type: string; | ||||
|   // group: string;
 | ||||
|   getOptions?(config: any, types: any[]): VariableOptions; | ||||
|   useVariables?(config: any, options?): VariableOptions; | ||||
|   fieldset: { [key: string]: ISchema }; | ||||
|   view?: ISchema; | ||||
|   scope?: { [key: string]: any }; | ||||
|  | ||||
| @ -39,15 +39,15 @@ export default { | ||||
|     ScheduleConfig, | ||||
|     FieldsSelect, | ||||
|   }, | ||||
|   getOptions(config, types) { | ||||
|   useVariables(config, { types }) { | ||||
|     const { t } = useWorkflowTranslation(); | ||||
|     const options: any[] = []; | ||||
|     if (!types || types.includes('date')) { | ||||
|       options.push({ key: 'date', value: 'date', label: t('Trigger time') }); | ||||
|     } | ||||
|     if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) { | ||||
|       const fieldOptions = useCollectionFieldOptions({ collection: config.collection }); | ||||
| 
 | ||||
|     const fieldOptions = useCollectionFieldOptions({ collection: config.collection }); | ||||
|     if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) { | ||||
|       if (fieldOptions.length) { | ||||
|         options.push({ | ||||
|           key: 'data', | ||||
|  | ||||
| @ -13,76 +13,77 @@ export type VariableOption = { | ||||
| 
 | ||||
| export type VariableOptions = VariableOption[] | null; | ||||
| 
 | ||||
| export const VariableTypes = [ | ||||
|   { | ||||
|     title: `{{t("Scope variables", { ns: "${NAMESPACE}" })}}`, | ||||
|     value: '$scopes', | ||||
|     useOptions(current, types) { | ||||
|       const scopes = useUpstreamScopes(current); | ||||
|       const options: VariableOption[] = []; | ||||
|       scopes.forEach((node) => { | ||||
|         const instruction = instructions.get(node.type); | ||||
|         const subOptions = instruction.useScopeVariables?.(node, types); | ||||
|         if (subOptions) { | ||||
|           options.push({ | ||||
|             key: node.id.toString(), | ||||
|             value: node.id.toString(), | ||||
|             label: node.title ?? `#${node.id}`, | ||||
|             children: subOptions, | ||||
|           }); | ||||
|         } | ||||
|       }); | ||||
|       return options; | ||||
|     }, | ||||
| export const nodesOptions = { | ||||
|   label: `{{t("Node result", { ns: "${NAMESPACE}" })}}`, | ||||
|   value: '$jobsMapByNodeId', | ||||
|   useOptions(current, options) { | ||||
|     const upstreams = useAvailableUpstreams(current); | ||||
|     const result: VariableOption[] = []; | ||||
|     upstreams.forEach((node) => { | ||||
|       const instruction = instructions.get(node.type); | ||||
|       const subOptions = instruction.useVariables?.(node, options); | ||||
|       if (subOptions) { | ||||
|         result.push({ | ||||
|           key: node.id.toString(), | ||||
|           value: node.id.toString(), | ||||
|           label: node.title ?? `#${node.id}`, | ||||
|           children: subOptions, | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
|     return result; | ||||
|   }, | ||||
|   { | ||||
|     title: `{{t("Node result", { ns: "${NAMESPACE}" })}}`, | ||||
|     value: '$jobsMapByNodeId', | ||||
|     useOptions(current, types) { | ||||
|       const upstreams = useAvailableUpstreams(current); | ||||
|       const options: VariableOption[] = []; | ||||
|       upstreams.forEach((node) => { | ||||
|         const instruction = instructions.get(node.type); | ||||
|         const subOptions = instruction.useVariables?.(node, types); | ||||
|         if (subOptions) { | ||||
|           options.push({ | ||||
|             key: node.id.toString(), | ||||
|             value: node.id.toString(), | ||||
|             label: node.title ?? `#${node.id}`, | ||||
|             children: subOptions, | ||||
|           }); | ||||
|         } | ||||
|       }); | ||||
|       return options; | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export const triggerOptions = { | ||||
|   label: `{{t("Trigger variables", { ns: "${NAMESPACE}" })}}`, | ||||
|   value: '$context', | ||||
|   useOptions(current, options) { | ||||
|     const { workflow } = useFlowContext(); | ||||
|     const trigger = triggers.get(workflow.type); | ||||
|     return trigger?.useVariables?.(workflow.config, options) ?? null; | ||||
|   }, | ||||
|   { | ||||
|     title: `{{t("Trigger variables", { ns: "${NAMESPACE}" })}}`, | ||||
|     value: '$context', | ||||
|     useOptions(current, types) { | ||||
|       const { workflow } = useFlowContext(); | ||||
|       const trigger = triggers.get(workflow.type); | ||||
|       return trigger?.getOptions?.(workflow.config, types) ?? null; | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export const scopeOptions = { | ||||
|   label: `{{t("Scope variables", { ns: "${NAMESPACE}" })}}`, | ||||
|   value: '$scopes', | ||||
|   useOptions(current, options) { | ||||
|     const scopes = useUpstreamScopes(current); | ||||
|     const result: VariableOption[] = []; | ||||
|     scopes.forEach((node) => { | ||||
|       const instruction = instructions.get(node.type); | ||||
|       const subOptions = instruction.useScopeVariables?.(node, options); | ||||
|       if (subOptions) { | ||||
|         result.push({ | ||||
|           key: node.id.toString(), | ||||
|           value: node.id.toString(), | ||||
|           label: node.title ?? `#${node.id}`, | ||||
|           children: subOptions, | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
|     return result; | ||||
|   }, | ||||
|   { | ||||
|     title: `{{t("System variables", { ns: "${NAMESPACE}" })}}`, | ||||
|     value: '$system', | ||||
|     useOptions(current, types) { | ||||
|       return [ | ||||
|         ...(!types || types.includes('date') | ||||
|           ? [ | ||||
|               { | ||||
|                 key: 'now', | ||||
|                 value: 'now', | ||||
|                 label: `{{t("System time")}}`, | ||||
|               }, | ||||
|             ] | ||||
|           : []), | ||||
|       ]; | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export const systemOptions = { | ||||
|   label: `{{t("System variables", { ns: "${NAMESPACE}" })}}`, | ||||
|   value: '$system', | ||||
|   useOptions(current, { types }) { | ||||
|     return [ | ||||
|       ...(!types || types.includes('date') | ||||
|         ? [ | ||||
|             { | ||||
|               key: 'now', | ||||
|               value: 'now', | ||||
|               label: `{{t("System time")}}`, | ||||
|             }, | ||||
|           ] | ||||
|         : []), | ||||
|     ]; | ||||
|   }, | ||||
| ]; | ||||
| }; | ||||
| 
 | ||||
| export const BaseTypeSets = { | ||||
|   boolean: new Set(['checkbox']), | ||||
| @ -107,7 +108,7 @@ export const BaseTypeSets = { | ||||
| // { type: 'reference', options: { collection: 'attachments', multiple: false } }
 | ||||
| // { type: 'reference', options: { collection: 'myExpressions', entity: false } }
 | ||||
| 
 | ||||
| function matchFieldType(field, type): boolean { | ||||
| function matchFieldType(field, type, depth): boolean { | ||||
|   const inputType = typeof type; | ||||
|   if (inputType === 'string') { | ||||
|     return BaseTypeSets[type]?.has(field.interface); | ||||
| @ -129,7 +130,7 @@ function matchFieldType(field, type): boolean { | ||||
|   } | ||||
| 
 | ||||
|   if (inputType === 'function') { | ||||
|     return type(field); | ||||
|     return type(field, depth); | ||||
|   } | ||||
| 
 | ||||
|   return false; | ||||
| @ -151,34 +152,31 @@ export function filterTypedFields(fields, types, depth = 1) { | ||||
|     ) { | ||||
|       return true; | ||||
|     } | ||||
|     return types.some((type) => matchFieldType(field, type)); | ||||
|     return types.some((type) => matchFieldType(field, type, depth)); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function useWorkflowVariableOptions(types?) { | ||||
| export function useWorkflowVariableOptions(options = {}) { | ||||
|   const compile = useCompile(); | ||||
|   const current = useNodeContext(); | ||||
|   const options = VariableTypes.map((item: any) => { | ||||
|     const opts = typeof item.useOptions === 'function' ? item.useOptions(current, types).filter(Boolean) : null; | ||||
|   const result = [scopeOptions, nodesOptions, triggerOptions, systemOptions].map((item: any) => { | ||||
|     const opts = typeof item.useOptions === 'function' ? item.useOptions(current, options).filter(Boolean) : null; | ||||
|     return { | ||||
|       label: compile(item.title), | ||||
|       label: compile(item.label), | ||||
|       value: item.value, | ||||
|       key: item.value, | ||||
|       children: compile(opts), | ||||
|       disabled: opts && !opts.length, | ||||
|     }; | ||||
|   }); | ||||
|   return options; | ||||
| 
 | ||||
|   return result; | ||||
| } | ||||
| 
 | ||||
| function useNormalizedFields(collectionName) { | ||||
|   const compile = useCompile(); | ||||
|   const { getCollection } = useCollectionManager(); | ||||
|   const collection = getCollection(collectionName); | ||||
|   if (!collection) { | ||||
|     return []; | ||||
|   } | ||||
|   const { fields } = collection; | ||||
|   const { getCollectionFields } = useCollectionManager(); | ||||
|   const fields = getCollectionFields(collectionName); | ||||
|   const foreignKeyFields: any[] = []; | ||||
|   const otherFields: any[] = []; | ||||
|   fields.forEach((field) => { | ||||
| @ -249,6 +247,7 @@ export function useCollectionFieldOptions(options): VariableOption[] { | ||||
|           isAssociationField(field) && depth | ||||
|             ? useCollectionFieldOptions({ collection: field.target, types, depth: depth - 1 }) | ||||
|             : null, | ||||
|         field, | ||||
|       }; | ||||
|     }); | ||||
| 
 | ||||
|  | ||||
| @ -0,0 +1,294 @@ | ||||
| import { Application } from '@nocobase/server'; | ||||
| import Database from '@nocobase/database'; | ||||
| import { getApp, sleep } from '..'; | ||||
| 
 | ||||
| describe('workflow > instructions > aggregate', () => { | ||||
|   let app: Application; | ||||
|   let db: Database; | ||||
|   let PostRepo; | ||||
|   let CommentRepo; | ||||
|   let TagRepo; | ||||
|   let WorkflowModel; | ||||
|   let workflow; | ||||
| 
 | ||||
|   beforeEach(async () => { | ||||
|     app = await getApp(); | ||||
| 
 | ||||
|     db = app.db; | ||||
|     WorkflowModel = db.getCollection('workflows').model; | ||||
|     PostRepo = db.getCollection('posts').repository; | ||||
|     CommentRepo = db.getCollection('comments').repository; | ||||
|     TagRepo = db.getCollection('tags').repository; | ||||
| 
 | ||||
|     workflow = await WorkflowModel.create({ | ||||
|       title: 'test workflow', | ||||
|       enabled: true, | ||||
|       type: 'collection', | ||||
|       config: { | ||||
|         mode: 1, | ||||
|         collection: 'posts', | ||||
|       }, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => db.close()); | ||||
| 
 | ||||
|   describe('based on collection', () => { | ||||
|     it('count', async () => { | ||||
|       const n1 = await workflow.createNode({ | ||||
|         type: 'aggregate', | ||||
|         config: { | ||||
|           aggregator: 'count', | ||||
|           collection: 'posts', | ||||
|           params: { | ||||
|             field: 'id', | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       const post = await PostRepo.create({ values: { title: 't1' } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [execution] = await workflow.getExecutions(); | ||||
|       const [job] = await execution.getJobs(); | ||||
|       expect(job.result).toBe(1); | ||||
|     }); | ||||
| 
 | ||||
|     it('sum', async () => { | ||||
|       const n1 = await workflow.createNode({ | ||||
|         type: 'aggregate', | ||||
|         config: { | ||||
|           aggregator: 'sum', | ||||
|           collection: 'posts', | ||||
|           params: { | ||||
|             field: 'read', | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       const p1 = await PostRepo.create({ values: { title: 't1', read: 1 } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [e1] = await workflow.getExecutions(); | ||||
|       const [j1] = await e1.getJobs(); | ||||
|       expect(j1.result).toBe(1); | ||||
| 
 | ||||
|       const p2 = await PostRepo.create({ values: { title: 't2', read: 2 } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [e2] = await workflow.getExecutions({ order: [['id', 'desc']] }); | ||||
|       const [j2] = await e2.getJobs(); | ||||
|       expect(j2.result).toBe(3); | ||||
|     }); | ||||
| 
 | ||||
|     it('avg', async () => { | ||||
|       const n1 = await workflow.createNode({ | ||||
|         type: 'aggregate', | ||||
|         config: { | ||||
|           aggregator: 'avg', | ||||
|           collection: 'posts', | ||||
|           params: { | ||||
|             field: 'read', | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       const p1 = await PostRepo.create({ values: { title: 't1', read: 1 } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [e1] = await workflow.getExecutions(); | ||||
|       const [j1] = await e1.getJobs(); | ||||
|       expect(j1.result).toBe(1); | ||||
| 
 | ||||
|       const p2 = await PostRepo.create({ values: { title: 't2', read: 2 } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [e2] = await workflow.getExecutions({ order: [['id', 'desc']] }); | ||||
|       const [j2] = await e2.getJobs(); | ||||
|       expect(j2.result).toBe(1.5); | ||||
|     }); | ||||
| 
 | ||||
|     it('min', async () => { | ||||
|       const n1 = await workflow.createNode({ | ||||
|         type: 'aggregate', | ||||
|         config: { | ||||
|           aggregator: 'min', | ||||
|           collection: 'posts', | ||||
|           params: { | ||||
|             field: 'read', | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       const p1 = await PostRepo.create({ values: { title: 't1', read: 1 } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [e1] = await workflow.getExecutions(); | ||||
|       const [j1] = await e1.getJobs(); | ||||
|       expect(j1.result).toBe(1); | ||||
| 
 | ||||
|       const p2 = await PostRepo.create({ values: { title: 't2', read: 2 } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [e2] = await workflow.getExecutions({ order: [['id', 'desc']] }); | ||||
|       const [j2] = await e2.getJobs(); | ||||
|       expect(j2.result).toBe(1); | ||||
|     }); | ||||
| 
 | ||||
|     it('max', async () => { | ||||
|       const n1 = await workflow.createNode({ | ||||
|         type: 'aggregate', | ||||
|         config: { | ||||
|           aggregator: 'max', | ||||
|           collection: 'posts', | ||||
|           params: { | ||||
|             field: 'read', | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       const p1 = await PostRepo.create({ values: { title: 't1', read: 1 } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [e1] = await workflow.getExecutions(); | ||||
|       const [j1] = await e1.getJobs(); | ||||
|       expect(j1.result).toBe(1); | ||||
| 
 | ||||
|       const p2 = await PostRepo.create({ values: { title: 't2', read: 2 } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [e2] = await workflow.getExecutions({ order: [['id', 'desc']] }); | ||||
|       const [j2] = await e2.getJobs(); | ||||
|       expect(j2.result).toBe(2); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('based on data associated collection', () => { | ||||
|     it('count', async () => { | ||||
|       const n1 = await workflow.createNode({ | ||||
|         type: 'aggregate', | ||||
|         config: { | ||||
|           aggregator: 'count', | ||||
|           collection: 'comments', | ||||
|           associated: true, | ||||
|           association: { | ||||
|             name: 'comments', | ||||
|             associatedKey: '{{$context.data.id}}', | ||||
|             associatedCollection: 'posts', | ||||
|           }, | ||||
|           params: { | ||||
|             field: 'id', | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
|       const n2 = await workflow.createNode({ | ||||
|         upstreamId: n1.id, | ||||
|         type: 'aggregate', | ||||
|         config: { | ||||
|           aggregator: 'count', | ||||
|           collection: 'comments', | ||||
|           associated: true, | ||||
|           association: { | ||||
|             name: 'comments', | ||||
|             associatedKey: '{{$context.data.id}}', | ||||
|             associatedCollection: 'posts', | ||||
|           }, | ||||
|           params: { | ||||
|             field: 'id', | ||||
|             filter: { | ||||
|               $and: [{ status: 1 }], | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
|       await n1.setDownstream(n2); | ||||
| 
 | ||||
|       await CommentRepo.create({ values: [{}, {}] }); | ||||
| 
 | ||||
|       const p1 = await PostRepo.create({ values: { title: 't1', comments: [{}, { status: 1 }] } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [e1] = await workflow.getExecutions(); | ||||
|       const [j1, j2] = await e1.getJobs({ order: [['id', 'ASC']] }); | ||||
|       expect(j1.result).toBe(2); | ||||
|       expect(j2.result).toBe(1); | ||||
|     }); | ||||
| 
 | ||||
|     it('sum', async () => { | ||||
|       const PostModel = db.getCollection('posts').model; | ||||
|       const p1 = await PostModel.create({ title: 't1', read: 1 }); | ||||
| 
 | ||||
|       const n1 = await workflow.createNode({ | ||||
|         type: 'create', | ||||
|         config: { | ||||
|           collection: 'tags', | ||||
|           params: { | ||||
|             values: { | ||||
|               posts: [p1.id, '{{$context.data.id}}'], | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
|       const n2 = await workflow.createNode({ | ||||
|         upstreamId: n1.id, | ||||
|         type: 'aggregate', | ||||
|         config: { | ||||
|           aggregator: 'sum', | ||||
|           collection: 'posts', | ||||
|           associated: true, | ||||
|           association: { | ||||
|             name: 'posts', | ||||
|             associatedKey: `{{$jobsMapByNodeId.${n1.id}.id}}`, | ||||
|             associatedCollection: 'tags', | ||||
|           }, | ||||
|           params: { | ||||
|             field: 'read', | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
|       await n1.setDownstream(n2); | ||||
|       const n3 = await workflow.createNode({ | ||||
|         upstreamId: n2.id, | ||||
|         type: 'aggregate', | ||||
|         config: { | ||||
|           aggregator: 'sum', | ||||
|           collection: 'posts', | ||||
|           associated: true, | ||||
|           association: { | ||||
|             name: 'posts', | ||||
|             associatedKey: `{{$jobsMapByNodeId.${n1.id}.id}}`, | ||||
|             associatedCollection: 'tags', | ||||
|           }, | ||||
|           params: { | ||||
|             field: 'read', | ||||
|             filter: { | ||||
|               $and: [{ title: 't1' }], | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
|       await n2.setDownstream(n3); | ||||
| 
 | ||||
|       await TagRepo.create({ values: [{}, {}] }); | ||||
| 
 | ||||
|       const p2 = await PostRepo.create({ values: { title: 't2', read: 2 } }); | ||||
| 
 | ||||
|       await sleep(500); | ||||
| 
 | ||||
|       const [e1] = await workflow.getExecutions(); | ||||
|       const [j1, j2, j3] = await e1.getJobs({ order: [['id', 'ASC']] }); | ||||
|       expect(j2.result).toBe(3); | ||||
|       expect(j3.result).toBe(1); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @ -0,0 +1,43 @@ | ||||
| import { DataTypes } from 'sequelize'; | ||||
| 
 | ||||
| import FlowNodeModel from '../models/FlowNode'; | ||||
| import Processor from '../Processor'; | ||||
| import { JOB_STATUS } from '../constants'; | ||||
| import { BelongsToManyRepository, HasManyRepository } from '@nocobase/database'; | ||||
| 
 | ||||
| const aggregators = { | ||||
|   count: 'count', | ||||
|   sum: 'sum', | ||||
|   avg: 'avg', | ||||
|   min: 'min', | ||||
|   max: 'max', | ||||
| }; | ||||
| 
 | ||||
| export default { | ||||
|   async run(node: FlowNodeModel, input, processor: Processor) { | ||||
|     const { aggregator, associated, collection, association = {}, params = {} } = node.config; | ||||
|     const options = processor.getParsedValue(params); | ||||
|     const { database } = <typeof FlowNodeModel>node.constructor; | ||||
|     const repo = associated | ||||
|       ? database.getRepository<HasManyRepository | BelongsToManyRepository>( | ||||
|           `${association?.associatedCollection}.${association.name}`, | ||||
|           processor.getParsedValue(association?.associatedKey), | ||||
|         ) | ||||
|       : database.getRepository(collection); | ||||
| 
 | ||||
|     if (!options.dataType && aggregator === 'avg') { | ||||
|       options.dataType = DataTypes.DOUBLE; | ||||
|     } | ||||
| 
 | ||||
|     const result = await repo.aggregate({ | ||||
|       ...options, | ||||
|       method: aggregators[aggregator], | ||||
|       transaction: processor.transaction, | ||||
|     }); | ||||
| 
 | ||||
|     return { | ||||
|       result: options.dataType === DataTypes.DOUBLE ? Number(result) : result, | ||||
|       status: JOB_STATUS.RESOLVED, | ||||
|     }; | ||||
|   }, | ||||
| }; | ||||
| @ -44,6 +44,7 @@ export default function <T extends Instruction>(plugin, more: { [key: string]: T | ||||
|     'create', | ||||
|     'update', | ||||
|     'destroy', | ||||
|     'aggregate', | ||||
|     'request', | ||||
|   ].reduce( | ||||
|     (result, key) => | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user