From f4064767c6d8571e1a048110fac54e4fdc6639f3 Mon Sep 17 00:00:00 2001 From: Junyi Date: Wed, 17 May 2023 12:37:03 +0700 Subject: [PATCH] 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 --- .../src/client/components/ValueBlock.tsx | 70 ++++ .../workflow/src/client/locale/zh-CN.ts | 10 + .../workflow/src/client/nodes/aggregate.tsx | 322 ++++++++++++++++++ .../workflow/src/client/nodes/calculation.tsx | 45 +-- .../workflow/src/client/nodes/condition.tsx | 2 +- .../workflow/src/client/nodes/create.tsx | 12 +- .../workflow/src/client/nodes/index.tsx | 15 +- .../workflow/src/client/nodes/loop.tsx | 24 +- .../client/nodes/manual/AssigneesSelect.tsx | 2 +- .../src/client/nodes/manual/index.tsx | 2 +- .../workflow/src/client/nodes/query.tsx | 12 +- .../plugins/workflow/src/client/style.tsx | 9 +- .../src/client/triggers/collection.tsx | 10 +- .../workflow/src/client/triggers/index.tsx | 2 +- .../src/client/triggers/schedule/index.tsx | 6 +- .../plugins/workflow/src/client/variable.tsx | 159 +++++---- .../__tests__/instructions/aggregate.test.ts | 294 ++++++++++++++++ .../src/server/instructions/aggregate.ts | 43 +++ .../workflow/src/server/instructions/index.ts | 1 + 19 files changed, 879 insertions(+), 161 deletions(-) create mode 100644 packages/plugins/workflow/src/client/components/ValueBlock.tsx create mode 100644 packages/plugins/workflow/src/client/nodes/aggregate.tsx create mode 100644 packages/plugins/workflow/src/server/__tests__/instructions/aggregate.test.ts create mode 100644 packages/plugins/workflow/src/server/instructions/aggregate.ts diff --git a/packages/plugins/workflow/src/client/components/ValueBlock.tsx b/packages/plugins/workflow/src/client/components/ValueBlock.tsx new file mode 100644 index 000000000..7205e1361 --- /dev/null +++ b/packages/plugins/workflow/src/client/components/ValueBlock.tsx @@ -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 ( + { + 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 ( +
+      {JSON.stringify(result, null, 2)}
+    
+ ); +} + +ValueBlock.Initializer = Initializer; +ValueBlock.Result = Result; diff --git a/packages/plugins/workflow/src/client/locale/zh-CN.ts b/packages/plugins/workflow/src/client/locale/zh-CN.ts index f3cb6f0c4..91e3a1038 100644 --- a/packages/plugins/workflow/src/client/locale/zh-CN.ts +++ b/packages/plugins/workflow/src/client/locale/zh-CN.ts @@ -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': '无法删除', diff --git a/packages/plugins/workflow/src/client/nodes/aggregate.tsx b/packages/plugins/workflow/src/client/nodes/aggregate.tsx new file mode 100644 index 000000000..8e794ec3f --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/aggregate.tsx @@ -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 ; +} + +// 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'), + }; + }, +}; diff --git a/packages/plugins/workflow/src/client/nodes/calculation.tsx b/packages/plugins/workflow/src/client/nodes/calculation.tsx index 787d3ca18..dd54a18ec 100644 --- a/packages/plugins/workflow/src/client/nodes/calculation.tsx +++ b/packages/plugins/workflow/src/client/nodes/calculation.tsx @@ -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 ( @@ -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 ( - { - 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}}}`, - }, - }, - }, - }); - }} - /> - ); -} diff --git a/packages/plugins/workflow/src/client/nodes/condition.tsx b/packages/plugins/workflow/src/client/nodes/condition.tsx index 6e41b175c..5f5901e80 100644 --- a/packages/plugins/workflow/src/client/nodes/condition.tsx +++ b/packages/plugins/workflow/src/client/nodes/condition.tsx @@ -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 ( diff --git a/packages/plugins/workflow/src/client/nodes/create.tsx b/packages/plugins/workflow/src/client/nodes/create.tsx index b67694693..0cd84ee62 100644 --- a/packages/plugins/workflow/src/client/nodes/create.tsx +++ b/packages/plugins/workflow/src/client/nodes/create.tsx @@ -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) { diff --git a/packages/plugins/workflow/src/client/nodes/index.tsx b/packages/plugins/workflow/src/client/nodes/index.tsx index 185030202..0f91cefe4 100644 --- a/packages/plugins/workflow/src/client/nodes/index.tsx +++ b/packages/plugins/workflow/src/client/nodes/index.tsx @@ -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 ( - ); @@ -252,7 +256,6 @@ export function JobButton() { - + {icon} diff --git a/packages/plugins/workflow/src/client/nodes/loop.tsx b/packages/plugins/workflow/src/client/nodes/loop.tsx index e1890d811..bbda35339 100644 --- a/packages/plugins/workflow/src/client/nodes/loop.tsx +++ b/packages/plugins/workflow/src/client/nodes/loop.tsx @@ -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); } diff --git a/packages/plugins/workflow/src/client/nodes/manual/AssigneesSelect.tsx b/packages/plugins/workflow/src/client/nodes/manual/AssigneesSelect.tsx index 2a21a25bf..fd59c72bb 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/AssigneesSelect.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/AssigneesSelect.tsx @@ -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 ( .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; diff --git a/packages/plugins/workflow/src/client/triggers/collection.tsx b/packages/plugins/workflow/src/client/triggers/collection.tsx index 4d710f90e..15d2ad752 100644 --- a/packages/plugins/workflow/src/client/triggers/collection.tsx +++ b/packages/plugins/workflow/src/client/triggers/collection.tsx @@ -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) { diff --git a/packages/plugins/workflow/src/client/triggers/index.tsx b/packages/plugins/workflow/src/client/triggers/index.tsx index 0c2cea152..8f8c6dbfb 100644 --- a/packages/plugins/workflow/src/client/triggers/index.tsx +++ b/packages/plugins/workflow/src/client/triggers/index.tsx @@ -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 }; diff --git a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx index 167b65d05..7543ba1f2 100644 --- a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx +++ b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx @@ -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', diff --git a/packages/plugins/workflow/src/client/variable.tsx b/packages/plugins/workflow/src/client/variable.tsx index cae3e7a13..7b266c796 100644 --- a/packages/plugins/workflow/src/client/variable.tsx +++ b/packages/plugins/workflow/src/client/variable.tsx @@ -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, }; }); diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/aggregate.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/aggregate.test.ts new file mode 100644 index 000000000..aba57d6fa --- /dev/null +++ b/packages/plugins/workflow/src/server/__tests__/instructions/aggregate.test.ts @@ -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); + }); + }); +}); diff --git a/packages/plugins/workflow/src/server/instructions/aggregate.ts b/packages/plugins/workflow/src/server/instructions/aggregate.ts new file mode 100644 index 000000000..28e2b403b --- /dev/null +++ b/packages/plugins/workflow/src/server/instructions/aggregate.ts @@ -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 } = node.constructor; + const repo = associated + ? database.getRepository( + `${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, + }; + }, +}; diff --git a/packages/plugins/workflow/src/server/instructions/index.ts b/packages/plugins/workflow/src/server/instructions/index.ts index 6afa01323..bccda8685 100644 --- a/packages/plugins/workflow/src/server/instructions/index.ts +++ b/packages/plugins/workflow/src/server/instructions/index.ts @@ -44,6 +44,7 @@ export default function (plugin, more: { [key: string]: T 'create', 'update', 'destroy', + 'aggregate', 'request', ].reduce( (result, key) =>