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': '请添加至少一个条件',
|
'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.':
|
'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': '已经执行过工作流的触发器不能被修改',
|
'Trigger in executed workflow cannot be modified': '已经执行过工作流的触发器不能被修改',
|
||||||
'Node in executed workflow cannot be modified': '已经执行过工作流中的节点不能被修改',
|
'Node in executed workflow cannot be modified': '已经执行过工作流中的节点不能被修改',
|
||||||
'Can not delete': '无法删除',
|
'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 { renderEngineReference } from '../components/renderEngineReference';
|
||||||
import { NAMESPACE, lang } from '../locale';
|
import { NAMESPACE, lang } from '../locale';
|
||||||
import { BaseTypeSets, useWorkflowVariableOptions } from '../variable';
|
import { BaseTypeSets, useWorkflowVariableOptions } from '../variable';
|
||||||
|
import { ValueBlock } from '../components/ValueBlock';
|
||||||
|
|
||||||
function matchDynamicExpressionCollectionField(field): boolean {
|
function useDynamicExpressionCollectionFieldMatcher(field): boolean {
|
||||||
const { getCollectionFields, getCollection } = useCollectionManager();
|
const { getCollectionFields } = useCollectionManager();
|
||||||
if (field.type !== 'belongsTo') {
|
if (field.type !== 'belongsTo') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -24,7 +25,7 @@ function matchDynamicExpressionCollectionField(field): boolean {
|
|||||||
|
|
||||||
const DynamicConfig = ({ value, onChange }) => {
|
const DynamicConfig = ({ value, onChange }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scope = useWorkflowVariableOptions([matchDynamicExpressionCollectionField]);
|
const scope = useWorkflowVariableOptions({ types: [useDynamicExpressionCollectionFieldMatcher] });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormLayout layout="vertical">
|
<FormLayout layout="vertical">
|
||||||
@ -49,7 +50,7 @@ const DynamicConfig = ({ value, onChange }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function useWorkflowVariableEntityOptions() {
|
function useWorkflowVariableEntityOptions() {
|
||||||
return useWorkflowVariableOptions([{ type: 'reference', options: { collection: '*', entity: true } }]);
|
return useWorkflowVariableOptions({ types: [{ type: 'reference', options: { collection: '*', entity: true } }] });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -170,7 +171,8 @@ export default {
|
|||||||
RadioWithTooltip,
|
RadioWithTooltip,
|
||||||
DynamicConfig,
|
DynamicConfig,
|
||||||
},
|
},
|
||||||
useVariables(current, types) {
|
useVariables(current, options) {
|
||||||
|
const { types } = options ?? {};
|
||||||
if (
|
if (
|
||||||
types &&
|
types &&
|
||||||
!types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type)))
|
!types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type)))
|
||||||
@ -185,38 +187,9 @@ export default {
|
|||||||
return {
|
return {
|
||||||
type: 'item',
|
type: 'item',
|
||||||
title: node.title ?? `#${node.id}`,
|
title: node.title ?? `#${node.id}`,
|
||||||
component: CalculationInitializer,
|
component: ValueBlock.Initializer,
|
||||||
node,
|
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);
|
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 compile = useCompile();
|
||||||
const options = useWorkflowVariableOptions();
|
const options = useWorkflowVariableOptions();
|
||||||
return (
|
return (
|
||||||
|
@ -40,12 +40,14 @@ export default {
|
|||||||
CollectionFieldset,
|
CollectionFieldset,
|
||||||
FieldsSelect,
|
FieldsSelect,
|
||||||
},
|
},
|
||||||
useVariables({ config }, types) {
|
useVariables({ config }, options) {
|
||||||
return useCollectionFieldOptions({
|
const result = useCollectionFieldOptions({
|
||||||
collection: config.collection,
|
collection: config?.collection,
|
||||||
types,
|
...options,
|
||||||
depth: config.params?.appends?.length ? 1 : 0,
|
depth: options?.depth ?? config?.params?.appends?.length ? 1 : 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return result?.length ? result : null;
|
||||||
},
|
},
|
||||||
useInitializers(node): SchemaInitializerItemOptions | null {
|
useInitializers(node): SchemaInitializerItemOptions | null {
|
||||||
if (!node.config.collection) {
|
if (!node.config.collection) {
|
||||||
|
@ -17,7 +17,7 @@ import {
|
|||||||
useResourceActionContext,
|
useResourceActionContext,
|
||||||
} from '@nocobase/client';
|
} 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 { AddButton } from '../AddButton';
|
||||||
import { useFlowContext } from '../FlowContext';
|
import { useFlowContext } from '../FlowContext';
|
||||||
|
|
||||||
@ -33,6 +33,8 @@ import query from './query';
|
|||||||
import create from './create';
|
import create from './create';
|
||||||
import update from './update';
|
import update from './update';
|
||||||
import destroy from './destroy';
|
import destroy from './destroy';
|
||||||
|
import aggregate from './aggregate';
|
||||||
|
|
||||||
import { JobStatusOptionsMap } from '../constants';
|
import { JobStatusOptionsMap } from '../constants';
|
||||||
import { NAMESPACE, lang } from '../locale';
|
import { NAMESPACE, lang } from '../locale';
|
||||||
import request from './request';
|
import request from './request';
|
||||||
@ -49,8 +51,8 @@ export interface Instruction {
|
|||||||
components?: { [key: string]: any };
|
components?: { [key: string]: any };
|
||||||
render?(props): React.ReactNode;
|
render?(props): React.ReactNode;
|
||||||
endding?: boolean;
|
endding?: boolean;
|
||||||
useVariables?(node, types?): VariableOptions;
|
useVariables?(node, options?): VariableOptions;
|
||||||
useScopeVariables?(node, types?): VariableOptions;
|
useScopeVariables?(node, options?): VariableOptions;
|
||||||
useInitializers?(node): SchemaInitializerItemOptions | null;
|
useInitializers?(node): SchemaInitializerItemOptions | null;
|
||||||
initializers?: { [key: string]: any };
|
initializers?: { [key: string]: any };
|
||||||
}
|
}
|
||||||
@ -69,6 +71,8 @@ instructions.register('query', query);
|
|||||||
instructions.register('create', create);
|
instructions.register('create', create);
|
||||||
instructions.register('update', update);
|
instructions.register('update', update);
|
||||||
instructions.register('destroy', destroy);
|
instructions.register('destroy', destroy);
|
||||||
|
instructions.register('aggregate', aggregate);
|
||||||
|
|
||||||
instructions.register('request', request);
|
instructions.register('request', request);
|
||||||
|
|
||||||
function useUpdateAction() {
|
function useUpdateAction() {
|
||||||
@ -234,7 +238,7 @@ function InnerJobButton({ job, ...props }) {
|
|||||||
const { icon, color } = JobStatusOptionsMap[job.status];
|
const { icon, color } = JobStatusOptionsMap[job.status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button {...props} shape="circle" className={cx(nodeJobButtonClass, 'workflow-node-job-button')}>
|
<Button {...props} shape="circle" className={nodeJobButtonClass}>
|
||||||
<Tag color={color}>{icon}</Tag>
|
<Tag color={color}>{icon}</Tag>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@ -252,7 +256,6 @@ export function JobButton() {
|
|||||||
<span
|
<span
|
||||||
className={cx(
|
className={cx(
|
||||||
nodeJobButtonClass,
|
nodeJobButtonClass,
|
||||||
'workflow-node-job-button',
|
|
||||||
css`
|
css`
|
||||||
border: 2px solid #d9d9d9;
|
border: 2px solid #d9d9d9;
|
||||||
border-radius: 50%;
|
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>
|
<Tag color={color}>{icon}</Tag>
|
||||||
</span>
|
</span>
|
||||||
<time>{str2moment(job.updatedAt).format('YYYY-MM-DD HH:mm:ss')}</time>
|
<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 { NodeDefaultView } from '.';
|
||||||
import { useFlowContext } from '../FlowContext';
|
import { useFlowContext } from '../FlowContext';
|
||||||
import { lang, NAMESPACE } from '../locale';
|
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 { addButtonClass, branchBlockClass, branchClass, nodeSubtreeClass } from '../style';
|
||||||
import { Branch } from '../Branch';
|
import { Branch } from '../Branch';
|
||||||
|
|
||||||
@ -137,18 +137,16 @@ export default {
|
|||||||
.split('.')
|
.split('.')
|
||||||
.map((path) => path.trim());
|
.map((path) => path.trim());
|
||||||
|
|
||||||
const options = VariableTypes.filter((item) => ['$context', '$jobsMapByNodeId'].includes(item.value)).map(
|
const options = [nodesOptions, triggerOptions].map((item: any) => {
|
||||||
(item: any) => {
|
const opts = typeof item.useOptions === 'function' ? item.useOptions(node, { types }).filter(Boolean) : null;
|
||||||
const opts = typeof item.useOptions === 'function' ? item.useOptions(node, types).filter(Boolean) : null;
|
return {
|
||||||
return {
|
label: compile(item.title),
|
||||||
label: compile(item.title),
|
value: item.value,
|
||||||
value: item.value,
|
key: item.value,
|
||||||
key: item.value,
|
children: compile(opts),
|
||||||
children: compile(opts),
|
disabled: opts && !opts.length,
|
||||||
disabled: opts && !opts.length,
|
};
|
||||||
};
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
targetOption.children = findOption(options, paths);
|
targetOption.children = findOption(options, paths);
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import { Variable } from '@nocobase/client';
|
|||||||
import { useWorkflowVariableOptions } from '../../variable';
|
import { useWorkflowVariableOptions } from '../../variable';
|
||||||
|
|
||||||
export function AssigneesSelect({ multiple = false, value = [], onChange }) {
|
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 (
|
return (
|
||||||
<Variable.Input
|
<Variable.Input
|
||||||
|
@ -86,7 +86,7 @@ export default {
|
|||||||
ModeConfig,
|
ModeConfig,
|
||||||
AssigneesSelect,
|
AssigneesSelect,
|
||||||
},
|
},
|
||||||
useVariables({ config }, types) {
|
useVariables({ config }, { types }) {
|
||||||
const formKeys = Object.keys(config.forms ?? {});
|
const formKeys = Object.keys(config.forms ?? {});
|
||||||
if (!formKeys.length) {
|
if (!formKeys.length) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -42,12 +42,14 @@ export default {
|
|||||||
FilterDynamicComponent,
|
FilterDynamicComponent,
|
||||||
FieldsSelect,
|
FieldsSelect,
|
||||||
},
|
},
|
||||||
useVariables({ config }, types) {
|
useVariables({ config }, options) {
|
||||||
return useCollectionFieldOptions({
|
const result = useCollectionFieldOptions({
|
||||||
collection: config.collection,
|
collection: config?.collection,
|
||||||
types,
|
...options,
|
||||||
depth: config.params?.appends?.length ? 1 : 0,
|
depth: options?.depth ?? config?.params?.appends?.length ? 1 : 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return result?.length ? result : null;
|
||||||
},
|
},
|
||||||
useInitializers(node): SchemaInitializerItemOptions | null {
|
useInitializers(node): SchemaInitializerItemOptions | null {
|
||||||
if (!node.config.collection) {
|
if (!node.config.collection) {
|
||||||
|
@ -206,12 +206,6 @@ export const nodeCardClass = css`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> .workflow-node-job-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-input {
|
.ant-input {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|
||||||
@ -246,6 +240,9 @@ export const nodeCardClass = css`
|
|||||||
|
|
||||||
export const nodeJobButtonClass = css`
|
export const nodeJobButtonClass = css`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25em;
|
||||||
|
right: 1.25em;
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
min-width: 1.25rem;
|
min-width: 1.25rem;
|
||||||
|
@ -134,7 +134,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
FieldsSelect,
|
FieldsSelect,
|
||||||
},
|
},
|
||||||
getOptions(config, types) {
|
useVariables(config, options) {
|
||||||
const { t } = useWorkflowTranslation();
|
const { t } = useWorkflowTranslation();
|
||||||
const rootFields = [
|
const rootFields = [
|
||||||
{
|
{
|
||||||
@ -147,8 +147,12 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const options = useCollectionFieldOptions({ fields: rootFields, types, depth: config.appends?.length ? 2 : 1 });
|
const result = useCollectionFieldOptions({
|
||||||
return options;
|
...options,
|
||||||
|
fields: rootFields,
|
||||||
|
depth: options?.depth ?? (config.appends?.length ? 2 : 1),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
useInitializers(config): SchemaInitializerItemOptions | null {
|
useInitializers(config): SchemaInitializerItemOptions | null {
|
||||||
if (!config.collection) {
|
if (!config.collection) {
|
||||||
|
@ -53,7 +53,7 @@ export interface Trigger {
|
|||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
// group: string;
|
// group: string;
|
||||||
getOptions?(config: any, types: any[]): VariableOptions;
|
useVariables?(config: any, options?): VariableOptions;
|
||||||
fieldset: { [key: string]: ISchema };
|
fieldset: { [key: string]: ISchema };
|
||||||
view?: ISchema;
|
view?: ISchema;
|
||||||
scope?: { [key: string]: any };
|
scope?: { [key: string]: any };
|
||||||
|
@ -39,15 +39,15 @@ export default {
|
|||||||
ScheduleConfig,
|
ScheduleConfig,
|
||||||
FieldsSelect,
|
FieldsSelect,
|
||||||
},
|
},
|
||||||
getOptions(config, types) {
|
useVariables(config, { types }) {
|
||||||
const { t } = useWorkflowTranslation();
|
const { t } = useWorkflowTranslation();
|
||||||
const options: any[] = [];
|
const options: any[] = [];
|
||||||
if (!types || types.includes('date')) {
|
if (!types || types.includes('date')) {
|
||||||
options.push({ key: 'date', value: 'date', label: t('Trigger time') });
|
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) {
|
if (fieldOptions.length) {
|
||||||
options.push({
|
options.push({
|
||||||
key: 'data',
|
key: 'data',
|
||||||
|
@ -13,76 +13,77 @@ export type VariableOption = {
|
|||||||
|
|
||||||
export type VariableOptions = VariableOption[] | null;
|
export type VariableOptions = VariableOption[] | null;
|
||||||
|
|
||||||
export const VariableTypes = [
|
export const nodesOptions = {
|
||||||
{
|
label: `{{t("Node result", { ns: "${NAMESPACE}" })}}`,
|
||||||
title: `{{t("Scope variables", { ns: "${NAMESPACE}" })}}`,
|
value: '$jobsMapByNodeId',
|
||||||
value: '$scopes',
|
useOptions(current, options) {
|
||||||
useOptions(current, types) {
|
const upstreams = useAvailableUpstreams(current);
|
||||||
const scopes = useUpstreamScopes(current);
|
const result: VariableOption[] = [];
|
||||||
const options: VariableOption[] = [];
|
upstreams.forEach((node) => {
|
||||||
scopes.forEach((node) => {
|
const instruction = instructions.get(node.type);
|
||||||
const instruction = instructions.get(node.type);
|
const subOptions = instruction.useVariables?.(node, options);
|
||||||
const subOptions = instruction.useScopeVariables?.(node, types);
|
if (subOptions) {
|
||||||
if (subOptions) {
|
result.push({
|
||||||
options.push({
|
key: node.id.toString(),
|
||||||
key: node.id.toString(),
|
value: node.id.toString(),
|
||||||
value: node.id.toString(),
|
label: node.title ?? `#${node.id}`,
|
||||||
label: node.title ?? `#${node.id}`,
|
children: subOptions,
|
||||||
children: subOptions,
|
});
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
});
|
return result;
|
||||||
return options;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
title: `{{t("Node result", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
value: '$jobsMapByNodeId',
|
export const triggerOptions = {
|
||||||
useOptions(current, types) {
|
label: `{{t("Trigger variables", { ns: "${NAMESPACE}" })}}`,
|
||||||
const upstreams = useAvailableUpstreams(current);
|
value: '$context',
|
||||||
const options: VariableOption[] = [];
|
useOptions(current, options) {
|
||||||
upstreams.forEach((node) => {
|
const { workflow } = useFlowContext();
|
||||||
const instruction = instructions.get(node.type);
|
const trigger = triggers.get(workflow.type);
|
||||||
const subOptions = instruction.useVariables?.(node, types);
|
return trigger?.useVariables?.(workflow.config, options) ?? null;
|
||||||
if (subOptions) {
|
|
||||||
options.push({
|
|
||||||
key: node.id.toString(),
|
|
||||||
value: node.id.toString(),
|
|
||||||
label: node.title ?? `#${node.id}`,
|
|
||||||
children: subOptions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return options;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
title: `{{t("Trigger variables", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
value: '$context',
|
export const scopeOptions = {
|
||||||
useOptions(current, types) {
|
label: `{{t("Scope variables", { ns: "${NAMESPACE}" })}}`,
|
||||||
const { workflow } = useFlowContext();
|
value: '$scopes',
|
||||||
const trigger = triggers.get(workflow.type);
|
useOptions(current, options) {
|
||||||
return trigger?.getOptions?.(workflow.config, types) ?? null;
|
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',
|
export const systemOptions = {
|
||||||
useOptions(current, types) {
|
label: `{{t("System variables", { ns: "${NAMESPACE}" })}}`,
|
||||||
return [
|
value: '$system',
|
||||||
...(!types || types.includes('date')
|
useOptions(current, { types }) {
|
||||||
? [
|
return [
|
||||||
{
|
...(!types || types.includes('date')
|
||||||
key: 'now',
|
? [
|
||||||
value: 'now',
|
{
|
||||||
label: `{{t("System time")}}`,
|
key: 'now',
|
||||||
},
|
value: 'now',
|
||||||
]
|
label: `{{t("System time")}}`,
|
||||||
: []),
|
},
|
||||||
];
|
]
|
||||||
},
|
: []),
|
||||||
|
];
|
||||||
},
|
},
|
||||||
];
|
};
|
||||||
|
|
||||||
export const BaseTypeSets = {
|
export const BaseTypeSets = {
|
||||||
boolean: new Set(['checkbox']),
|
boolean: new Set(['checkbox']),
|
||||||
@ -107,7 +108,7 @@ export const BaseTypeSets = {
|
|||||||
// { type: 'reference', options: { collection: 'attachments', multiple: false } }
|
// { type: 'reference', options: { collection: 'attachments', multiple: false } }
|
||||||
// { type: 'reference', options: { collection: 'myExpressions', entity: false } }
|
// { type: 'reference', options: { collection: 'myExpressions', entity: false } }
|
||||||
|
|
||||||
function matchFieldType(field, type): boolean {
|
function matchFieldType(field, type, depth): boolean {
|
||||||
const inputType = typeof type;
|
const inputType = typeof type;
|
||||||
if (inputType === 'string') {
|
if (inputType === 'string') {
|
||||||
return BaseTypeSets[type]?.has(field.interface);
|
return BaseTypeSets[type]?.has(field.interface);
|
||||||
@ -129,7 +130,7 @@ function matchFieldType(field, type): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (inputType === 'function') {
|
if (inputType === 'function') {
|
||||||
return type(field);
|
return type(field, depth);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@ -151,34 +152,31 @@ export function filterTypedFields(fields, types, depth = 1) {
|
|||||||
) {
|
) {
|
||||||
return true;
|
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 compile = useCompile();
|
||||||
const current = useNodeContext();
|
const current = useNodeContext();
|
||||||
const options = VariableTypes.map((item: any) => {
|
const result = [scopeOptions, nodesOptions, triggerOptions, systemOptions].map((item: any) => {
|
||||||
const opts = typeof item.useOptions === 'function' ? item.useOptions(current, types).filter(Boolean) : null;
|
const opts = typeof item.useOptions === 'function' ? item.useOptions(current, options).filter(Boolean) : null;
|
||||||
return {
|
return {
|
||||||
label: compile(item.title),
|
label: compile(item.label),
|
||||||
value: item.value,
|
value: item.value,
|
||||||
key: item.value,
|
key: item.value,
|
||||||
children: compile(opts),
|
children: compile(opts),
|
||||||
disabled: opts && !opts.length,
|
disabled: opts && !opts.length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return options;
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useNormalizedFields(collectionName) {
|
function useNormalizedFields(collectionName) {
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
const { getCollection } = useCollectionManager();
|
const { getCollectionFields } = useCollectionManager();
|
||||||
const collection = getCollection(collectionName);
|
const fields = getCollectionFields(collectionName);
|
||||||
if (!collection) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const { fields } = collection;
|
|
||||||
const foreignKeyFields: any[] = [];
|
const foreignKeyFields: any[] = [];
|
||||||
const otherFields: any[] = [];
|
const otherFields: any[] = [];
|
||||||
fields.forEach((field) => {
|
fields.forEach((field) => {
|
||||||
@ -249,6 +247,7 @@ export function useCollectionFieldOptions(options): VariableOption[] {
|
|||||||
isAssociationField(field) && depth
|
isAssociationField(field) && depth
|
||||||
? useCollectionFieldOptions({ collection: field.target, types, depth: depth - 1 })
|
? useCollectionFieldOptions({ collection: field.target, types, depth: depth - 1 })
|
||||||
: null,
|
: 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',
|
'create',
|
||||||
'update',
|
'update',
|
||||||
'destroy',
|
'destroy',
|
||||||
|
'aggregate',
|
||||||
'request',
|
'request',
|
||||||
].reduce(
|
].reduce(
|
||||||
(result, key) =>
|
(result, key) =>
|
||||||
|
Loading…
Reference in New Issue
Block a user