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:
Junyi 2023-05-17 12:37:03 +07:00 committed by GitHub
parent 770f53ec4e
commit f4064767c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 879 additions and 161 deletions

View File

@ -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;

View File

@ -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': '无法删除',

View 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'),
};
},
};

View File

@ -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}}}`,
},
},
},
});
}}
/>
);
}

View File

@ -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 (

View File

@ -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) {

View File

@ -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>

View File

@ -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);
} }

View File

@ -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

View File

@ -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;

View File

@ -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) {

View File

@ -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;

View File

@ -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) {

View File

@ -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 };

View File

@ -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',

View File

@ -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,
}; };
}); });

View File

@ -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);
});
});
});

View File

@ -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,
};
},
};

View File

@ -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) =>