feat(plugin-workflow): support multiple data source in workflow (#3739)

* feat(plugin-workflow): support multiple data source in workflow

* fix(plugin-workflow): fix test cases

* test(plugin-workflow-sql): debug test case

* fix(plugin-workflow): fix collection trigger creation without config

* test(plugin-workflow-sql): debug test case

* fix: workflow e2e test

* chore(ci): disable console intercept in vitest

* chore(ci): disable console intercept in vitest

* chore(ci): disable console intercept in vitest

* chore(ci): disable console intercept in vitest

* test(plugin-workflow-sql): debug test case

* test: approval e2e

* fix: remove pro-plugins from packages

* refactor(plugin-workflow): support pass collection from props to CollectionBlockInitializer

* test(plugin-workflow): add test case

* fix(plugin-workflow): disable modification of executed workflow

* fix: e2ePageObjectModel

* fix: load data source when data source load failed (#3793)

* chore: console.log

* fix(subTable): fix sorting rule setting (#3795)

* fix: through collection support search (#3800)

* fix(client): visible -> useVisible

* fix(client): fix action designer error occured in custom form (#3801)

* fix(client): fix action designer error occured in custom form

* fix(client): fix from the source

* chore(module): remove submodule

* fix(plugin-workflow): fix client cycling import

* fix(plugin-workflow): fix collection event name

* fix(plugin-workflow): fix undefined ref

---------

Co-authored-by: hongboji <j414562100@qq.com>
Co-authored-by: ChengLei Shao <chareice@live.com>
Co-authored-by: Zeke Zhang <958414905@qq.com>
Co-authored-by: katherinehhh <shunai.tang@hand-china.com>
Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2024-03-25 14:46:22 +08:00 committed by GitHub
parent f2828cd8b0
commit d691e4c7e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 2423 additions and 887 deletions

View File

@ -19,6 +19,7 @@ function addTestCommand(name, cli) {
.arguments('[paths...]')
.allowUnknownOption()
.action(async (paths, opts) => {
process.argv.push('--disable-console-intercept');
if (name === 'test:server') {
process.env.TEST_ENV = 'server-side';
} else if (name === 'test:client') {

View File

@ -11,7 +11,11 @@ import { usePlugin } from '../../../application/hooks';
import { SchemaSettingOptions, SchemaSettings } from '../../../application/schema-settings';
import { useSchemaToolbar } from '../../../application/schema-toolbar';
import { useFormBlockContext } from '../../../block-provider';
import { useCollectionManager_deprecated, useCollection_deprecated } from '../../../collection-manager';
import {
joinCollectionName,
useCollectionManager_deprecated,
useCollection_deprecated,
} from '../../../collection-manager';
import { FlagProvider } from '../../../flag-provider';
import { SaveMode } from '../../../modules/actions/submit/createSubmitActionSettings';
import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items';
@ -28,6 +32,7 @@ import {
import { DefaultValueProvider } from '../../../schema-settings/hooks/useIsAllowToSetDefaultValue';
import { useLinkageAction } from './hooks';
import { requestSettingsSchema } from './utils';
import { DataSourceProvider, useDataSourceKey } from '../../../data-source';
const MenuGroup = (props) => {
return props.children;
@ -327,7 +332,8 @@ function WorkflowSelect({ actionType, direct = false, ...props }) {
const { setValuesIn } = useForm();
const baseCollection = useCollection_deprecated();
const { getCollection } = useCollectionManager_deprecated();
const [workflowCollection, setWorkflowCollection] = useState(baseCollection.name);
const dataSourceKey = useDataSourceKey();
const [workflowCollection, setWorkflowCollection] = useState(joinCollectionName(dataSourceKey, baseCollection.name));
const compile = useCompile();
const workflowPlugin = usePlugin('workflow') as any;
@ -351,11 +357,11 @@ function WorkflowSelect({ actionType, direct = false, ...props }) {
const path = paths[i];
const associationField = collection.fields.find((f) => f.name === path);
if (associationField) {
collection = getCollection(associationField.target);
collection = getCollection(associationField.target, dataSourceKey);
}
}
}
setWorkflowCollection(collection.name);
setWorkflowCollection(joinCollectionName(dataSourceKey, collection.name));
setValuesIn(`group[${index}].workflowKey`, null);
});
});
@ -375,38 +381,40 @@ function WorkflowSelect({ actionType, direct = false, ...props }) {
);
return (
<RemoteSelect
manual={false}
placeholder={t('Select workflow', { ns: 'workflow' })}
fieldNames={{
label: 'title',
value: 'key',
}}
service={{
resource: 'workflows',
action: 'list',
params: {
filter: {
type: workflowTypes,
enabled: true,
'config.collection': workflowCollection,
<DataSourceProvider dataSource="main">
<RemoteSelect
manual={false}
placeholder={t('Select workflow', { ns: 'workflow' })}
fieldNames={{
label: 'title',
value: 'key',
}}
service={{
resource: 'workflows',
action: 'list',
params: {
filter: {
type: workflowTypes,
enabled: true,
'config.collection': workflowCollection,
},
},
},
}}
optionFilter={optionFilter}
optionRender={({ label, data }) => {
const typeOption = workflowPlugin.getTriggersOptions().find((item) => item.value === data.type);
return typeOption ? (
<Flex justify="space-between">
<span>{label}</span>
<Tag color={typeOption.color}>{compile(typeOption.label)}</Tag>
</Flex>
) : (
label
);
}}
{...props}
/>
}}
optionFilter={optionFilter}
optionRender={({ label, data }) => {
const typeOption = workflowPlugin.getTriggersOptions().find((item) => item.value === data.type);
return typeOption ? (
<Flex justify="space-between">
<span>{label}</span>
<Tag color={typeOption.color}>{compile(typeOption.label)}</Tag>
</Flex>
) : (
label
);
}}
{...props}
/>
</DataSourceProvider>
);
}
@ -414,7 +422,7 @@ export function WorkflowConfig() {
const { dn } = useDesignable();
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const { name: collection } = useCollection_deprecated();
const collection = useCollection_deprecated();
// TODO(refactor): should refactor for getting certain action type, better from 'x-action'.
const formBlock = useFormBlockContext();
const actionType = formBlock?.type || fieldSchema['x-action'];
@ -483,7 +491,9 @@ export function WorkflowConfig() {
'x-component-props': {
placeholder: t('Select context', { ns: 'workflow' }),
popupMatchSelectWidth: false,
collection,
collection: `${
collection.dataSource && collection.dataSource !== 'main' ? `${collection.dataSource}:` : ''
}${collection.name}`,
filter: '{{ fieldFilter }}',
rootOption: {
label: t('Full form data', { ns: 'workflow' }),

View File

@ -3,7 +3,12 @@ import { Tag, TreeSelect } from 'antd';
import type { DefaultOptionType, TreeSelectProps } from 'rc-tree-select/es/TreeSelect';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CollectionFieldOptions_deprecated, useCollectionManager_deprecated, useCompile } from '../../..';
import {
CollectionFieldOptions_deprecated,
parseCollectionName,
useCollectionManager_deprecated,
useCompile,
} from '../../..';
export type AppendsTreeSelectProps = {
value: string[] | string;
@ -27,7 +32,7 @@ function usePropsCollection({ collection }) {
type CallScope = {
compile?(value: string): string;
getCollectionFields?(name: any): CollectionFieldOptions_deprecated[];
getCollectionFields?(name: any, dataSource?: string): CollectionFieldOptions_deprecated[];
filter(field): boolean;
};
@ -52,7 +57,8 @@ function trueFilter(field) {
}
function getCollectionFieldOptions(this: CallScope, collection, parentNode?): TreeOptionType[] {
const fields = this.getCollectionFields(collection).filter(isAssociation);
const [dataSourceName, collectionName] = parseCollectionName(collection);
const fields = this.getCollectionFields(collectionName, dataSourceName).filter(isAssociation);
const boundLoadChildren = loadChildren.bind(this);
return fields.filter(this.filter).map((field) => {
const key = parentNode ? `${parentNode.value ? `${parentNode.value}.` : ''}${field.name}` : field.name;
@ -84,11 +90,12 @@ export const AppendsTreeSelect: React.FC<TreeSelectProps & AppendsTreeSelectProp
loadData: propsLoadData,
...restProps
} = props;
const { getCollectionFields } = useCollectionManager_deprecated();
const compile = useCompile();
const { t } = useTranslation();
const [optionsMap, setOptionsMap] = useState({});
const baseCollection = useCollection({ collection });
const collectionString = useCollection({ collection });
const [dataSourceName, collectionName] = parseCollectionName(collectionString);
const { getCollectionFields } = useCollectionManager_deprecated(dataSourceName);
const treeData = Object.values(optionsMap);
const value: string | DefaultOptionType[] = useMemo(() => {
if (props.multiple) {
@ -111,7 +118,7 @@ export const AppendsTreeSelect: React.FC<TreeSelectProps & AppendsTreeSelectProp
},
[propsLoadData],
);
// NOTE:
useEffect(() => {
const parentNode = rootOption
? {
@ -123,17 +130,19 @@ export const AppendsTreeSelect: React.FC<TreeSelectProps & AppendsTreeSelectProp
isLeaf: false,
}
: null;
const treeData =
const tData =
propsLoadData === null
? []
: getCollectionFieldOptions.call({ compile, getCollectionFields, filter }, baseCollection, parentNode);
const map = treeData.reduce((result, item) => Object.assign(result, { [item.value]: item }), {});
: getCollectionFieldOptions.call({ compile, getCollectionFields, filter }, collectionString, parentNode);
const map = tData.reduce((result, item) => Object.assign(result, { [item.value]: item }), {});
if (parentNode) {
map[parentNode.value] = parentNode;
}
setOptionsMap(map);
}, [collection, baseCollection, rootOption, filter, propsLoadData]);
}, [collectionString, rootOption, filter, propsLoadData]);
// NOTE: preload options in value
useEffect(() => {
const arr = (props.multiple ? propsValue : propsValue ? [propsValue] : []) as string[];
if (!arr?.length || arr.every((v) => Boolean(optionsMap[v]))) {

View File

@ -7,3 +7,10 @@ export function parseCollectionName(collection: string) {
const dataSourceName = dataSourceCollection[0] ?? 'main';
return [dataSourceName, collectionName];
}
export function joinCollectionName(dataSourceName: string, collectionName: string) {
if (!dataSourceName || dataSourceName === 'main') {
return collectionName;
}
return `${dataSourceName}:${collectionName}`;
}

View File

@ -56,7 +56,7 @@
"sqlite3": "^5.0.8",
"supertest": "^6.1.6",
"vite": "^5.0.0",
"vitest": "^1.0.0",
"vitest": "^1.4.0",
"vitest-dom": "^0.1.1",
"ws": "^8.13.0"
},

View File

@ -101,18 +101,13 @@ const parseString = (() => {
value = value();
}
if (typeof value === 'object' && value !== null) {
// Accommodate numbers as values.
if (str.startsWith('{{') && str.endsWith('}}')) {
return value;
}
// Accommodate numbers as values.
if (matches.length === 1 && str.startsWith('{{') && str.endsWith('}}')) {
return value;
}
// Accommodate numbers as values.
if (matches.length === 1 && str.startsWith('{{') && str.endsWith('}}')) {
return value;
if (value instanceof Date) {
value = value.toISOString();
}
return result.replace(match, value == null ? '' : value);

View File

@ -6,7 +6,12 @@ import {
useCollectionManager_deprecated,
useCompile,
} from '@nocobase/client';
import { Trigger, CollectionBlockInitializer, getCollectionFieldOptions } from '@nocobase/plugin-workflow/client';
import {
Trigger,
CollectionBlockInitializer,
getCollectionFieldOptions,
useWorkflowAnyExecuted,
} from '@nocobase/plugin-workflow/client';
import { NAMESPACE, useLang } from '../locale';
export default class extends Trigger {
@ -17,10 +22,8 @@ export default class extends Trigger {
type: 'string',
required: true,
'x-decorator': 'FormItem',
'x-component': 'CollectionSelect',
'x-component-props': {
className: 'auto-width',
},
'x-component': 'DataSourceCollectionCascader',
'x-disabled': '{{ useWorkflowAnyExecuted() }}',
title: `{{t("Collection", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Which collection record belongs to.", { ns: "${NAMESPACE}" })}}`,
'x-reactions': [
@ -63,6 +66,7 @@ export default class extends Trigger {
};
scope = {
useCollectionDataSource,
useWorkflowAnyExecuted,
};
isActionTriggerable = (config, context) => {
return ['create', 'update', 'customize:update', 'customize:triggerWorkflows'].includes(context.action);
@ -126,7 +130,7 @@ export default class extends Trigger {
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
Component: CollectionBlockInitializer,
collection: config.collection,
dataSource: '{{$context.data}}',
dataPath: '$context.data',
};
}
}

View File

@ -9,6 +9,7 @@ import {
apiUpdateWorkflowTrigger,
appendJsonCollectionName,
generalWithNoRelationalFields,
apiGetDataSourceCount,
} from '@nocobase/plugin-workflow-test/e2e';
import { expect, test } from '@nocobase/test/e2e';
import { dayjs } from '@nocobase/utils';
@ -51,7 +52,8 @@ test.describe('Configuration page to configure the Trigger node', () => {
const formEventTriggerNode = new FormEventTriggerNode(page, workFlowName, triggerNodeCollectionName);
await formEventTriggerNode.nodeConfigure.click();
await formEventTriggerNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await formEventTriggerNode.submitButton.click();
//配置录入数据区块
@ -60,6 +62,10 @@ test.describe('Configuration page to configure the Trigger node', () => {
await page.waitForLoadState('networkidle');
await page.getByLabel('schema-initializer-Grid-page:addBlock').hover();
await page.getByRole('menuitem', { name: 'table Table' }).hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: `${triggerNodeCollectionDisplayName}` }).click();
// 移开鼠标,关闭菜单
@ -143,7 +149,8 @@ test.describe('Configuration page to configure the Trigger node', () => {
const formEventTriggerNode = new FormEventTriggerNode(page, workFlowName, triggerNodeCollectionName);
await formEventTriggerNode.nodeConfigure.click();
await formEventTriggerNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await formEventTriggerNode.submitButton.click();
//配置录入数据区块
@ -152,6 +159,10 @@ test.describe('Configuration page to configure the Trigger node', () => {
await page.waitForLoadState('networkidle');
await page.getByLabel('schema-initializer-Grid-page:addBlock').hover();
await page.getByRole('menuitem', { name: 'table Table' }).hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: `${triggerNodeCollectionDisplayName}` }).click();
// 移开鼠标,关闭菜单
@ -649,8 +660,11 @@ test.describe('Configuration page copy to new version', () => {
// 3、预期结果新版本工作流配置内容同旧版本一样
const formEventTriggerNode = new FormEventTriggerNode(page, workFlowName, triggerNodeCollectionName);
await formEventTriggerNode.nodeConfigure.click();
await expect(page.getByRole('button', { name: triggerNodeCollectionDisplayName })).toBeVisible();
await expect(
page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection')
.getByText(`Main / ${triggerNodeCollectionDisplayName}`),
).toBeVisible();
// 4、后置处理删除工作流
await apiDeleteWorkflow(workflowId);
});

View File

@ -1,11 +1,11 @@
import { get } from 'lodash';
import { BelongsTo, HasOne } from 'sequelize';
import { Model, modelAssociationByKey } from '@nocobase/database';
import { DefaultContext } from '@nocobase/server';
import { ActionContext } from '@nocobase/resourcer';
import { Next } from '@nocobase/actions';
import Application, { DefaultContext } from '@nocobase/server';
import { Context as ActionContext, Next } from '@nocobase/actions';
import WorkflowPlugin, { Trigger, WorkflowModel, toJSON } from '@nocobase/plugin-workflow';
import { parseCollectionName } from '@nocobase/data-source-manager';
interface Context extends ActionContext, DefaultContext {}
@ -13,7 +13,7 @@ export default class extends Trigger {
constructor(workflow: WorkflowPlugin) {
super(workflow);
workflow.app.resourcer.use(this.middleware);
workflow.app.use(this.middleware, { after: 'dataSource' });
}
async triggerAction(context: Context, next: Next) {
@ -55,6 +55,7 @@ export default class extends Trigger {
private async trigger(context: Context) {
const { triggerWorkflows = '', values } = context.action.params;
const dataSourceHeader = context.get('x-data-source') || 'main';
const { currentUser, currentRole } = context.state;
const { model: UserModel } = this.workflow.db.getCollection('users');
@ -79,12 +80,16 @@ export default class extends Trigger {
const asyncGroup = [];
for (const workflow of workflows) {
const { collection, appends = [] } = workflow.config;
const [dataSourceName, collectionName] = parseCollectionName(collection);
const trigger = triggers.find((trigger) => trigger[0] == workflow.key);
const event = [workflow];
if (context.action.resourceName !== 'workflows') {
if (!context.body) {
continue;
}
if (dataSourceName !== dataSourceHeader) {
continue;
}
const { body: data } = context;
for (const row of Array.isArray(data) ? data : [data]) {
let payload = row;
@ -101,7 +106,7 @@ export default class extends Trigger {
}
const model = payload.constructor;
if (payload instanceof Model) {
if (collection !== model.collection.name) {
if (collectionName !== model.collection.name) {
continue;
}
if (appends.length) {
@ -115,7 +120,9 @@ export default class extends Trigger {
event.push({ data: toJSON(payload), ...userInfo });
}
} else {
const { model, repository } = context.db.getCollection(collection);
const { model, repository } = (<Application>context.app).dataSourceManager.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
let data = trigger[1] ? get(values, trigger[1]) : values;
const pk = get(data, model.primaryKeyAttribute);
if (appends.length && pk != null) {

View File

@ -12,7 +12,7 @@ describe('workflow > action-trigger', () => {
let PostRepo;
let CommentRepo;
let WorkflowModel;
let UserModel;
let UserRepo;
let users;
let userAgents;
@ -26,12 +26,14 @@ describe('workflow > action-trigger', () => {
WorkflowModel = db.getCollection('workflows').model;
PostRepo = db.getCollection('posts').repository;
CommentRepo = db.getCollection('comments').repository;
UserModel = db.getCollection('users').model;
UserRepo = db.getCollection('users').repository;
users = await UserModel.bulkCreate([
{ id: 2, nickname: 'a' },
{ id: 3, nickname: 'b' },
]);
users = await UserRepo.create({
values: [
{ id: 2, nickname: 'a', roles: [{ name: 'root' }] },
{ id: 3, nickname: 'b' },
],
});
userAgents = users.map((user) => app.agent().login(user));
});
@ -569,4 +571,49 @@ describe('workflow > action-trigger', () => {
expect(e3s[0].status).toBe(EXECUTION_STATUS.RESOLVED);
});
});
describe('multiple data source', () => {
it('trigger on different data source', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'action',
config: {
collection: 'another:posts',
},
});
const res1 = await userAgents[0].resource('posts').create({
values: { title: 't1' },
triggerWorkflows: `${workflow.key}`,
});
expect(res1.status).toBe(200);
await sleep(500);
const e1s = await workflow.getExecutions();
expect(e1s.length).toBe(0);
// const res2 = await userAgents[0]
// .set('x-data-source', 'another')
// .resource('posts')
// .create({
// values: { title: 't2' },
// triggerWorkflows: `${workflow.key}`,
// });
const res2 = await agent
.login(users[0])
.set('x-data-source', 'another')
.post('/api/posts:create')
.query({ triggerWorkflows: `${workflow.key}` })
.send({ title: 't2' });
expect(res2.status).toBe(200);
await sleep(500);
const e2s = await workflow.getExecutions();
expect(e2s.length).toBe(1);
expect(e2s[0].status).toBe(EXECUTION_STATUS.RESOLVED);
});
});
});

View File

@ -6,6 +6,8 @@ import {
SchemaComponentContext,
SchemaInitializerItemType,
css,
joinCollectionName,
parseCollectionName,
useCollectionDataSource,
useCollectionFilterOptions,
useCollectionManager_deprecated,
@ -116,25 +118,34 @@ function AssociatedConfig({ value, onChange, ...props }): JSX.Element {
// need to get:
// * source collection (from node.config)
// * target collection (from field name)
const { collectionName, target, name } = field;
const { collectionName, target, name, dataSourceKey } = field;
const collection = getCollection(collectionName);
const collection = getCollection(collectionName, dataSourceKey);
const primaryKeyField = collection.fields.find((f) => f.primaryKey);
setValuesIn('collection', target);
setValuesIn('collection', `${dataSourceKey}:${target}`);
onChange({
name,
// primary key data path
associatedKey: `{{${path.slice(0, -1).join('.')}.${primaryKeyField.name}}}`,
// data associated collection name
associatedCollection: collectionName,
associatedCollection: joinCollectionName(dataSourceKey, collectionName),
});
},
[onChange],
);
return <Cascader {...props} value={p} options={options} onChange={onSelectChange} loadData={loadData as any} />;
return (
<Cascader
{...props}
value={p}
options={options}
changeOnSelect
onChange={onSelectChange}
loadData={loadData as any}
/>
);
}
// based on collection:
@ -217,7 +228,7 @@ export default class extends Instruction {
type: 'string',
required: true,
'x-decorator': 'FormItem',
'x-component': 'CollectionSelect',
'x-component': 'DataSourceCollectionCascader',
title: `{{t("Data of collection", { ns: "${NAMESPACE}" })}}`,
'x-reactions': [
{
@ -333,7 +344,8 @@ export default class extends Instruction {
'x-component-props': {
useProps() {
const { values } = useForm();
const options = useCollectionFilterOptions(values?.collection);
const [dataSourceName, collectionName] = parseCollectionName(values?.collection);
const options = useCollectionFilterOptions(collectionName, dataSourceName);
return {
options,
className: css`

View File

@ -80,7 +80,8 @@ test.describe('no filter', () => {
const aggregateRecordNodeId = await aggregateRecordNode.node.locator('.workflow-node-id').innerText();
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.submitButton.click();
@ -179,7 +180,8 @@ test.describe('no filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.sumRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.submitButton.click();
@ -281,7 +283,8 @@ test.describe('no filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.avgRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.submitButton.click();
@ -385,7 +388,8 @@ test.describe('no filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.minRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.submitButton.click();
@ -488,7 +492,8 @@ test.describe('no filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.maxRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.submitButton.click();
@ -590,7 +595,8 @@ test.describe('no filter', () => {
const aggregateRecordNodeId = await aggregateRecordNode.node.locator('.workflow-node-id').innerText();
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.distinctCheckBox.click();
@ -690,7 +696,8 @@ test.describe('no filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.sumRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.distinctCheckBox.click();
@ -795,7 +802,8 @@ test.describe('no filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.avgRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.distinctCheckBox.click();
@ -902,7 +910,8 @@ test.describe('no filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.minRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.distinctCheckBox.click();
@ -1006,7 +1015,8 @@ test.describe('no filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.maxRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.distinctCheckBox.click();
@ -1111,7 +1121,8 @@ test.describe('filter', () => {
const aggregateRecordNodeId = await aggregateRecordNode.node.locator('.workflow-node-id').innerText();
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
// 过滤条件
@ -1217,7 +1228,8 @@ test.describe('filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.sumRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
// 过滤条件
@ -1327,7 +1339,8 @@ test.describe('filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.avgRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
// 过滤条件
@ -1442,7 +1455,8 @@ test.describe('filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.minRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
// 过滤条件
@ -1553,7 +1567,8 @@ test.describe('filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.maxRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
// 过滤条件
@ -1663,7 +1678,8 @@ test.describe('filter', () => {
const aggregateRecordNodeId = await aggregateRecordNode.node.locator('.workflow-node-id').innerText();
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.distinctCheckBox.click();
@ -1770,7 +1786,8 @@ test.describe('filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.sumRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.distinctCheckBox.click();
@ -1885,7 +1902,8 @@ test.describe('filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.avgRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.distinctCheckBox.click();
@ -2009,7 +2027,8 @@ test.describe('filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.minRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.distinctCheckBox.click();
@ -2121,7 +2140,8 @@ test.describe('filter', () => {
await aggregateRecordNode.nodeConfigure.click();
await aggregateRecordNode.maxRadio.click();
await aggregateRecordNode.collectionDropDown.click();
await page.getByText(aggregateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click();
await aggregateRecordNode.aggregatedFieldDropDown.click();
await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click();
await aggregateRecordNode.distinctCheckBox.click();

View File

@ -1,4 +1,5 @@
import { BelongsToManyRepository, DataTypes, HasManyRepository } from '@nocobase/database';
import { parseCollectionName } from '@nocobase/data-source-manager';
import { DataTypes } from '@nocobase/database';
import { Processor, Instruction, JOB_STATUS, FlowNodeModel } from '@nocobase/plugin-workflow';
const aggregators = {
@ -13,13 +14,14 @@ export default class extends Instruction {
async run(node: FlowNodeModel, input, processor: Processor) {
const { aggregator, associated, collection, association = {}, params = {} } = node.config;
const options = processor.getParsedValue(params, node.id);
const { database } = <typeof FlowNodeModel>node.constructor;
const [dataSourceName, collectionName] = parseCollectionName(collection);
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
const repo = associated
? database.getRepository<HasManyRepository | BelongsToManyRepository>(
? collectionManager.getRepository(
`${association?.associatedCollection}.${association.name}`,
processor.getParsedValue(association?.associatedKey, node.id),
)
: database.getRepository(collection);
: collectionManager.getRepository(collectionName);
if (!options.dataType && aggregator === 'avg') {
options.dataType = DataTypes.DOUBLE;

View File

@ -3,6 +3,7 @@ import { Application } from '@nocobase/server';
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
import Plugin from '..';
import { EXECUTION_STATUS } from '@nocobase/plugin-workflow';
describe('workflow > instructions > aggregate', () => {
let app: Application;
@ -295,4 +296,33 @@ describe('workflow > instructions > aggregate', () => {
expect(j3.result).toBe(1);
});
});
describe('multiple data source', () => {
it('query on another data source', async () => {
const AnotherPostRepo = app.dataSourceManager.dataSources.get('another').collectionManager.getRepository('posts');
const post = await AnotherPostRepo.create({ values: { title: 't1' } });
const p1s = await AnotherPostRepo.find();
expect(p1s.length).toBe(1);
const n1 = await workflow.createNode({
type: 'aggregate',
config: {
collection: 'another:posts',
aggregator: 'count',
params: {
field: 'id',
},
},
});
await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.result).toBe(1);
});
});
});

View File

@ -24,10 +24,10 @@ import WorkflowPlugin, {
linkNodes,
useAvailableUpstreams,
useFlowContext,
DetailsBlockProvider,
} from '@nocobase/plugin-workflow/client';
import { NAMESPACE, useLang } from '../locale';
import { DetailsBlockProvider } from './instruction/DetailsBlockProvider';
import { FormBlockProvider } from './instruction/FormBlockProvider';
import { ManualFormType, manualFormTypes } from './instruction/SchemaConfig';

View File

@ -10,6 +10,7 @@ import {
apiUpdateWorkflowTrigger,
appendJsonCollectionName,
generalWithNoRelationalFields,
apiGetDataSourceCount,
} from '@nocobase/plugin-workflow-test/e2e';
import { expect, test } from '@nocobase/test/e2e';
import { dayjs } from '@nocobase/utils';
@ -81,6 +82,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -216,6 +221,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -351,6 +360,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -486,6 +499,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -621,6 +638,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -756,6 +777,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -891,6 +916,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1027,6 +1056,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1162,6 +1195,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1297,6 +1334,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1440,6 +1481,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1574,6 +1619,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1715,6 +1764,10 @@ test.describe('field data entry', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1865,6 +1918,10 @@ test.describe('action button', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -2000,6 +2057,10 @@ test.describe('action button', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -2141,6 +2202,10 @@ test.describe('action button', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page

View File

@ -10,6 +10,7 @@ import {
apiUpdateWorkflowTrigger,
appendJsonCollectionName,
generalWithNoRelationalFields,
apiGetDataSourceCount,
} from '@nocobase/plugin-workflow-test/e2e';
import { expect, test } from '@nocobase/test/e2e';
import { dayjs } from '@nocobase/utils';
@ -97,6 +98,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -249,6 +254,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -401,6 +410,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -553,6 +566,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -705,6 +722,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -857,6 +878,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1025,6 +1050,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1193,6 +1222,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1361,6 +1394,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1530,6 +1567,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1707,6 +1748,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -1875,6 +1920,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -2049,6 +2098,10 @@ test.describe('field data update', () => {
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.updateRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page

View File

@ -8,6 +8,7 @@ import {
apiUpdateWorkflowTrigger,
appendJsonCollectionName,
generalWithNoRelationalFields,
apiGetDataSourceCount,
} from '@nocobase/plugin-workflow-test/e2e';
import { expect, test } from '@nocobase/test/e2e';
import { dayjs } from '@nocobase/utils';
@ -69,6 +70,10 @@ test('filter task node', async ({ page, mockPage, mockCollections, mockRecords }
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page
@ -179,6 +184,10 @@ test('filter workflow name', async ({ page, mockPage, mockCollections, mockRecor
await manualNode.configureUserInterfaceButton.click();
await manualNode.addBlockButton.hover();
await manualNode.createRecordFormMenu.hover();
const dataSourcesCount = await apiGetDataSourceCount();
if (dataSourcesCount > 1) {
await page.getByRole('menuitem', { name: 'Main right' }).hover();
}
await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page

View File

@ -35,6 +35,8 @@ import {
} from '@nocobase/client';
import WorkflowPlugin, {
JOB_STATUS,
DetailsBlockProvider,
SimpleDesigner,
useAvailableUpstreams,
useFlowContext,
useNodeContext,
@ -44,7 +46,6 @@ import WorkflowPlugin, {
import { Registry, lodash } from '@nocobase/utils/client';
import { NAMESPACE, useLang } from '../../locale';
import { DetailsBlockProvider } from './DetailsBlockProvider';
import { FormBlockProvider } from './FormBlockProvider';
import createRecordForm from './forms/create';
import customRecordForm from './forms/custom';
@ -104,24 +105,6 @@ const blockTypeNames = {
record: `{{t("Data record", { ns: "${NAMESPACE}" })}}`,
};
function SimpleDesigner() {
const schema = useFieldSchema();
const title = blockTypeNames[schema['x-designer-props']?.type] ?? '{{t("Block")}}';
const compile = useCompile();
return (
<GeneralSchemaDesigner title={compile(title)}>
<SchemaSettingsBlockTitleItem />
<SchemaSettingsDivider />
<SchemaSettingsRemove
removeParentsIfNoChildren
breakRemoveOn={{
'x-component': 'Grid',
}}
/>
</GeneralSchemaDesigner>
);
}
/**
* @deprecated
*/
@ -153,7 +136,7 @@ export const addBlockButton_deprecated = new CompatibleSchemaInitializer({
{
name: 'nodes',
type: 'subMenu',
title: `{{t("Node result", { ns: "${NAMESPACE}" })}}`,
title: `{{t("Node result", { ns: "workflow" })}}`,
children: nodeBlockInitializers,
},
]

View File

@ -3,7 +3,7 @@ import React, { useContext, useMemo, useState } from 'react';
import { ArrayTable } from '@formily/antd-v5';
import { Field, createForm } from '@formily/core';
import { useField, useFieldSchema, useForm } from '@formily/react';
import lodash from 'lodash';
import { cloneDeep, pick, set } from 'lodash';
import {
ActionContextProvider,
@ -161,7 +161,7 @@ function getOptions(interfaces) {
const schema = interfaces[type];
const { group = 'others' } = schema;
fields[group] = fields[group] || {};
lodash.set(fields, [group, type], schema);
set(fields, [group, type], schema);
});
return Object.keys(GroupLabels)
@ -208,33 +208,51 @@ const CustomItemsComponent = (props) => {
const items = useCommonInterfaceInitializers();
const collection = useCollection_deprecated();
const { setCollectionFields } = useContext(FormBlockContext);
const form = useMemo(() => createForm(), [interfaceOptions]);
return (
<AddCustomFormFieldButtonContext.Provider
value={{
onAddField(item) {
const fieldInterface: Record<string, any> = pick(item, [
'name',
'group',
'title',
'default',
'validateSchema',
]);
const {
properties: { unique, type, ...properties },
...options
} = lodash.cloneDeep(item);
delete properties.name['x-disabled'];
setInterface({
...options,
properties,
});
properties: { unique, type, layout, autoIncrement, ...properties },
} = item;
fieldInterface.properties = properties;
const result = cloneDeep(fieldInterface);
delete result.properties.name['x-disabled'];
setInterface(result);
},
setCallback,
}}
>
<SchemaInitializerItems {...props} items={items} />
<ActionContextProvider value={{ visible: Boolean(interfaceOptions) }}>
<ActionContextProvider
value={{
visible: Boolean(interfaceOptions),
setVisible(v) {
if (!v) {
setInterface(null);
}
},
}}
>
{interfaceOptions ? (
<SchemaComponent
schema={{
type: 'void',
name: 'drawer',
title: '{{t("Configure field")}}',
'x-decorator': 'Form',
'x-decorator': 'FormV2',
'x-decorator-props': {
form,
},
'x-component': 'Action.Drawer',
properties: {
...interfaceOptions.properties,
@ -266,7 +284,7 @@ const CustomItemsComponent = (props) => {
'x-component-props': {
type: 'primary',
useAction() {
const { values, query } = useForm();
const { values, query, reset } = useForm();
const messages = [useLang('Field name existed in form')];
return {
async run() {
@ -301,6 +319,7 @@ const CustomItemsComponent = (props) => {
'x-toolbar': 'FormItemSchemaToolbar',
'x-settings': 'fieldSettings:FormItem',
});
reset();
setCallback(null);
setInterface(null);
},

View File

@ -138,7 +138,7 @@ export default class extends Instruction {
title: form.title ?? formKey,
Component: CollectionBlockInitializer,
collection: form.collection,
dataSource: `{{$jobsMapByNodeKey.${node.key}.${formKey}}}`,
dataPath: `$jobsMapByNodeKey.${node.key}.${formKey}`,
} as SchemaInitializerItemType)
: null;
})

View File

@ -0,0 +1,223 @@
import Database from '@nocobase/database';
import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
import { MockServer } from '@nocobase/test';
// NOTE: skipped because time is not stable on github ci, but should work in local
describe('workflow > instructions > manual', () => {
let app: MockServer;
let agent;
let userAgents;
let db: Database;
let PostRepo;
let AnotherPostRepo;
let WorkflowModel;
let workflow;
let UserModel;
let users;
let UserJobModel;
beforeEach(async () => {
app = await getApp({
plugins: ['users', 'auth', 'workflow-manual'],
});
// await app.getPlugin('auth').install();
agent = app.agent();
db = app.db;
WorkflowModel = db.getCollection('workflows').model;
PostRepo = db.getCollection('posts').repository;
AnotherPostRepo = app.dataSourceManager.dataSources.get('another').collectionManager.getRepository('posts');
UserModel = db.getCollection('users').model;
UserJobModel = db.getModel('users_jobs');
users = await UserModel.bulkCreate([
{ id: 2, nickname: 'a' },
{ id: 3, nickname: 'b' },
]);
userAgents = users.map((user) => app.agent().login(user));
workflow = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {
mode: 1,
collection: 'posts',
},
});
});
afterEach(() => app.destroy());
describe('multiple data source', () => {
describe('create', () => {
it('create as configured', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
type: 'create',
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
collection: 'posts',
dataSource: 'another',
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(1);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { title: 't1' }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
await sleep(500);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.RESOLVED);
expect(j1.result).toMatchObject({ f1: { title: 't1' } });
const posts = await AnotherPostRepo.find();
expect(posts.length).toBe(1);
expect(posts[0]).toMatchObject({ title: 't1' });
});
it('save first and then commit', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
type: 'create',
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.PENDING, key: 'pending' },
],
collection: 'posts',
dataSource: 'another',
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(1);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { title: 't1' }, _: 'pending' },
},
});
expect(res1.status).toBe(202);
await sleep(500);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toMatchObject({ f1: { title: 't1' } });
const c1 = await AnotherPostRepo.find();
expect(c1.length).toBe(0);
const res2 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { title: 't2' }, _: 'resolve' },
},
});
await sleep(500);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
const [j2] = await e2.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toMatchObject({ f1: { title: 't2' } });
const c2 = await AnotherPostRepo.find();
expect(c2.length).toBe(1);
});
});
describe('update', () => {
it('update as configured', async () => {
const post = await AnotherPostRepo.create({ values: { title: 't1' } });
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
type: 'update',
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
collection: 'posts',
dataSource: 'another',
filter: {
id: post.id,
},
},
},
},
});
await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(1);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { title: 't2' }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
await sleep(500);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
const [j1] = await e2.getJobs();
expect(j1.status).toBe(JOB_STATUS.RESOLVED);
expect(j1.result).toMatchObject({ f1: { title: 't2' } });
const postsAfter = await AnotherPostRepo.find();
expect(postsAfter.length).toBe(1);
expect(postsAfter[0]).toMatchObject({ title: 't2' });
});
});
});
});

View File

@ -333,516 +333,6 @@ describe('workflow > instructions > manual', () => {
});
});
describe('mode: 0 (single record)', () => {
it('the only user assigned could submit', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [pending] = await workflow.getExecutions();
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await agent.resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: { result: { f1: {}, _: 'resolve' } },
});
expect(res1.status).toBe(401);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: {}, _: 'resolve' },
},
});
expect(res2.status).toBe(403);
const res3 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res3.status).toBe(202);
await sleep(1000);
const [j2] = await pending.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const usersJobsAfter = await UserJobModel.findAll();
expect(usersJobsAfter.length).toBe(1);
expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED);
expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const res4 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 2 }, _: 'resolve' },
},
});
expect(res4.status).toBe(400);
});
it('any user assigned could submit', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
forms: {
f1: {
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [pending] = await workflow.getExecutions();
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await j1.getUsersJobs();
const res1 = await userAgents[1].resource('users_jobs').submit({
filterByTk: usersJobs.find((item) => item.userId === users[1].id).id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [j2] = await pending.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const res2 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs.find((item) => item.userId === users[0].id).id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res2.status).toBe(400);
});
it('also could submit to users_jobs api', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].get('status')).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].get('userId')).toBe(users[0].id);
const res = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res.status).toBe(202);
await sleep(1000);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.status).toBe(JOB_STATUS.RESOLVED);
expect(job.result).toEqual({ f1: { a: 1 }, _: 'resolve' });
});
});
describe('mode: 1 (multiple record, all)', () => {
it('all resolved', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: 1,
forms: {
f1: {
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toBe(0.5);
const usersJobs1 = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(usersJobs1.length).toBe(2);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 2 }, _: 'resolve' },
},
});
expect(res2.status).toBe(202);
await sleep(1000);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
const [j2] = await e2.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toBe(1);
});
it('first rejected', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: 1,
forms: {
f1: {
actions: [{ status: JOB_STATUS.REJECTED, key: 'reject' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.REJECTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.REJECTED);
expect(j1.result).toBe(0.5);
const usersJobs1 = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(usersJobs1.length).toBe(2);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(400);
});
it('last rejected', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: 1,
forms: {
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toBe(0.5);
const usersJobs1 = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(usersJobs1.length).toBe(2);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(202);
await sleep(1000);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.REJECTED);
const [j2] = await e2.getJobs();
expect(j2.status).toBe(JOB_STATUS.REJECTED);
expect(j2.result).toBe(1);
});
});
describe('mode: -1 (multiple record, any)', () => {
it('first resolved', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: -1,
forms: {
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.RESOLVED);
expect(j1.result).toBe(0.5);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(400);
});
it('any resolved', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: -1,
forms: {
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toBe(0.5);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res2.status).toBe(202);
await sleep(1000);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
const [j2] = await e2.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toBe(1);
});
it('all rejected', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: -1,
forms: {
f1: {
actions: [{ status: JOB_STATUS.REJECTED, key: 'reject' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toBe(0.5);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(202);
await sleep(1000);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.REJECTED);
const [j2] = await e2.getJobs();
expect(j2.status).toBe(JOB_STATUS.REJECTED);
expect(j2.result).toBe(1);
});
});
describe('use result of submitted form in manual node', () => {
it('result should be available and correct', async () => {
const n1 = await workflow.createNode({

View File

@ -0,0 +1,561 @@
import Database from '@nocobase/database';
import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
import { MockServer } from '@nocobase/test';
// NOTE: skipped because time is not stable on github ci, but should work in local
describe('workflow > instructions > manual', () => {
let app: MockServer;
let agent;
let userAgents;
let db: Database;
let PostRepo;
let CommentRepo;
let WorkflowModel;
let workflow;
let UserModel;
let users;
let UserJobModel;
beforeEach(async () => {
app = await getApp({
plugins: ['users', 'auth', 'workflow-manual'],
});
// await app.getPlugin('auth').install();
agent = app.agent();
db = app.db;
WorkflowModel = db.getCollection('workflows').model;
PostRepo = db.getCollection('posts').repository;
CommentRepo = db.getCollection('comments').repository;
UserModel = db.getCollection('users').model;
UserJobModel = db.getModel('users_jobs');
users = await UserModel.bulkCreate([
{ id: 2, nickname: 'a' },
{ id: 3, nickname: 'b' },
]);
userAgents = users.map((user) => app.agent().login(user));
workflow = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {
mode: 1,
collection: 'posts',
},
});
});
afterEach(() => app.destroy());
describe('mode: 0 (single record)', () => {
it('the only user assigned could submit', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [pending] = await workflow.getExecutions();
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await agent.resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: { result: { f1: {}, _: 'resolve' } },
});
expect(res1.status).toBe(401);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: {}, _: 'resolve' },
},
});
expect(res2.status).toBe(403);
const res3 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res3.status).toBe(202);
await sleep(1000);
const [j2] = await pending.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const usersJobsAfter = await UserJobModel.findAll();
expect(usersJobsAfter.length).toBe(1);
expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED);
expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const res4 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 2 }, _: 'resolve' },
},
});
expect(res4.status).toBe(400);
});
it('any user assigned could submit', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
forms: {
f1: {
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [pending] = await workflow.getExecutions();
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await j1.getUsersJobs();
const res1 = await userAgents[1].resource('users_jobs').submit({
filterByTk: usersJobs.find((item) => item.userId === users[1].id).id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [j2] = await pending.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const res2 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs.find((item) => item.userId === users[0].id).id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res2.status).toBe(400);
});
it('also could submit to users_jobs api', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].get('status')).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].get('userId')).toBe(users[0].id);
const res = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res.status).toBe(202);
await sleep(1000);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.status).toBe(JOB_STATUS.RESOLVED);
expect(job.result).toEqual({ f1: { a: 1 }, _: 'resolve' });
});
});
describe('mode: 1 (multiple record, all)', () => {
it('all resolved', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: 1,
forms: {
f1: {
actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toBe(0.5);
const usersJobs1 = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(usersJobs1.length).toBe(2);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 2 }, _: 'resolve' },
},
});
expect(res2.status).toBe(202);
await sleep(1000);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
const [j2] = await e2.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toBe(1);
});
it('first rejected', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: 1,
forms: {
f1: {
actions: [{ status: JOB_STATUS.REJECTED, key: 'reject' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.REJECTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.REJECTED);
expect(j1.result).toBe(0.5);
const usersJobs1 = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(usersJobs1.length).toBe(2);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(400);
});
it('last rejected', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: 1,
forms: {
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toBe(0.5);
const usersJobs1 = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(usersJobs1.length).toBe(2);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(202);
await sleep(1000);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.REJECTED);
const [j2] = await e2.getJobs();
expect(j2.status).toBe(JOB_STATUS.REJECTED);
expect(j2.result).toBe(1);
});
});
describe('mode: -1 (multiple record, any)', () => {
it('first resolved', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: -1,
forms: {
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.RESOLVED);
expect(j1.result).toBe(0.5);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(400);
});
it('any resolved', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: -1,
forms: {
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toBe(0.5);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res2.status).toBe(202);
await sleep(1000);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
const [j2] = await e2.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toBe(1);
});
it('all rejected', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id, users[1].id],
mode: -1,
forms: {
f1: {
actions: [{ status: JOB_STATUS.REJECTED, key: 'reject' }],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res1.status).toBe(202);
await sleep(1000);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await e1.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toBe(0.5);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(202);
await sleep(1000);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.REJECTED);
const [j2] = await e2.getJobs();
expect(j2.status).toBe(JOB_STATUS.REJECTED);
expect(j2.result).toBe(1);
});
});
});

View File

@ -1,8 +1,15 @@
import { Processor } from '@nocobase/plugin-workflow';
import ManualInstruction from '../ManualInstruction';
export default async function (this: ManualInstruction, instance, { collection }, processor: Processor) {
const repo = this.workflow.db.getRepository(collection);
export default async function (
this: ManualInstruction,
instance,
{ dataSource = 'main', collection },
processor: Processor,
) {
const repo = this.workflow.app.dataSourceManager.dataSources
.get(dataSource)
.collectionManager.getRepository(collection);
if (!repo) {
throw new Error(`collection ${collection} for create data on manual node not found`);
}
@ -18,6 +25,6 @@ export default async function (this: ManualInstruction, instance, { collection }
context: {
executionId: processor.execution.id,
},
// transaction: processor.transaction,
transaction: processor.transaction,
});
}

View File

@ -1,8 +1,15 @@
import { Processor } from '@nocobase/plugin-workflow';
import ManualInstruction from '../ManualInstruction';
export default async function (this: ManualInstruction, instance, { collection, filter = {} }, processor: Processor) {
const repo = this.workflow.db.getRepository(collection);
export default async function (
this: ManualInstruction,
instance,
{ dataSource = 'main', collection, filter = {} },
processor: Processor,
) {
const repo = this.workflow.app.dataSourceManager.dataSources
.get(dataSource)
.collectionManager.getRepository(collection);
if (!repo) {
throw new Error(`collection ${collection} for update data on manual node not found`);
}
@ -18,6 +25,6 @@ export default async function (this: ManualInstruction, instance, { collection,
context: {
executionId: processor.execution.id,
},
// transaction: processor.transaction,
transaction: processor.transaction,
});
}

View File

@ -0,0 +1,76 @@
import { Migration } from '@nocobase/server';
function findSchema(root, filter, onlyLeaf = false) {
const result = [];
if (!root) {
return result;
}
if (filter(root) && (!onlyLeaf || !root.properties)) {
result.push(root);
return result;
}
if (root.properties) {
Object.keys(root.properties).forEach((key) => {
result.push(...findSchema(root.properties[key], filter));
});
}
return result;
}
function migrateSchema(schema) {
const root = { properties: schema };
const [node] = findSchema(root, (item) => {
return (
item['x-decorator'] === 'DetailsBlockProvider' &&
item['x-component'] === 'CardItem' &&
item['x-designer'] === 'SimpleDesigner'
);
});
if (node && node['x-decorator-props']?.dataSource) {
node['x-decorator-props'].dataPath = node['x-decorator-props'].dataSource.replace(/^{{|}}$/g, '');
delete node['x-decorator-props'].dataSource;
}
return schema;
}
export default class extends Migration {
async up() {
const { db } = this.context;
const NodeRepo = db.getRepository('flow_nodes');
await db.sequelize.transaction(async (transaction) => {
const nodes = await NodeRepo.find({
filter: {
type: 'manual',
},
transaction,
});
console.log('%d nodes need to be migrated.', nodes.length);
await nodes.reduce(
(promise, node) =>
promise.then(() => {
const { schema, ...config } = node.config;
return node.update(
{
config: {
...config,
...migrateSchema(schema),
},
},
{
silent: true,
transaction,
},
);
}),
Promise.resolve(),
);
});
}
}

View File

@ -12,6 +12,21 @@ export default class extends Instruction {
group = 'collection';
description = `{{t("Execute a SQL statement in database.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
dataSource: {
type: 'string',
required: true,
title: `{{t("Data source")}}`,
description: `{{t("Select a data source to execute SQL.", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'DataSourceSelect',
'x-component-props': {
className: 'auto-width',
filter(item) {
return item.options.isDBInstance;
},
},
default: 'main',
},
sql: {
type: 'string',
required: true,

View File

@ -1,5 +1,6 @@
{
"SQL action": "SQL 操作",
"Execute a SQL statement in database.": "在数据库中执行一个 SQL 语句",
"Select a data source to execute SQL.": "选择一个数据源来执行 SQL",
"SQL query result could be used through <1>JSON query node</1> (Commercial plugin).": "SQL 执行的结果可在 <1>JSON 解析节点</1> 中使用(商业插件)。"
}

View File

@ -2,15 +2,22 @@ import { Processor, Instruction, JOB_STATUS, FlowNodeModel } from '@nocobase/plu
export default class extends Instruction {
async run(node: FlowNodeModel, input, processor: Processor) {
const { sequelize } = (<typeof FlowNodeModel>node.constructor).database;
const sql = processor.getParsedValue(node.config.sql ?? '', node.id).trim();
// @ts-ignore
const { db } = this.workflow.app.dataSourceManager.dataSources.get(
node.config.dataSource || 'main',
).collectionManager;
if (!db) {
throw new Error(`type of data source "${node.config.dataSource}" is not database`);
}
const sql = processor.getParsedValue(node.config.sql || '', node.id).trim();
if (!sql) {
return {
status: JOB_STATUS.RESOLVED,
};
}
const result = await sequelize.query(sql, {
// @ts-ignore
const result = await db.sequelize.query(sql, {
transaction: processor.transaction,
// plain: true,
// model: db.getCollection(node.config.collection).model

View File

@ -92,6 +92,26 @@ describe('workflow > instructions > sql', () => {
});
describe('sql with variables', () => {
it('$system.now', async () => {
const queryInterface = db.sequelize.getQueryInterface();
const n1 = await workflow.createNode({
type: 'sql',
config: {
sql: `select '{{$system.now}}' as a`,
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
const [sqlJob] = await execution.getJobs({ order: [['id', 'ASC']] });
expect(sqlJob.status).toBe(JOB_STATUS.RESOLVED);
// expect(queryJob.status).toBe(JOB_STATUS.RESOLVED);
// expect(queryJob.result.read).toBe(post.id);
});
it('update', async () => {
const queryInterface = db.sequelize.getQueryInterface();
const n1 = await workflow.createNode({
@ -195,4 +215,35 @@ describe('workflow > instructions > sql', () => {
expect(job.status).toBe(JOB_STATUS.RESOLVED);
});
});
describe('multiple data source', () => {
it('query on another data source', async () => {
const anotherSource = app.dataSourceManager.dataSources.get('another');
const PostCollection = anotherSource.collectionManager.getCollection('posts');
const { repository: AnotherPostRepo } = PostCollection;
const post = await AnotherPostRepo.create({ values: { title: 't1' } });
const p1s = await AnotherPostRepo.find();
expect(p1s.length).toBe(1);
const n1 = await workflow.createNode({
type: 'sql',
config: {
dataSource: 'another',
sql: `select * from ${PostCollection.quotedTableName()}`,
},
});
await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.result.length).toBe(2);
expect(job.result[0].length).toBe(1);
// @ts-ignore
expect(job.result[0][0].id).toBe(post.id);
});
});
});

View File

@ -79,62 +79,102 @@ export class ApprovalTriggerNode {
nodeTitle: Locator;
nodeConfigure: Locator;
collectionDropDown: Locator;
checkWthdrawable: Locator;
configureUserInterfaceButton: Locator;
dataBlocksInitiationRadio: Locator;
dataBlocksAndGlobalApprovalBlocksInitiationRadio: Locator;
allowedToBeWithdrawnCheckbox: Locator;
goToconfigureButton: Locator;
addBlockButton: Locator;
addApplyFormMenu: Locator;
configureFieldsButton: Locator;
configureActionsButton: Locator;
saveDraftSwitch: Locator;
submitButton: Locator;
cancelButton: Locator;
addNodeButton: Locator;
constructor(page: Page, triggerName: string, collectionName: string) {
this.page = page;
this.node = page.getByText('TriggeraConfigure');
this.nodeTitle = page.locator('textarea').filter({ hasText: triggerName });
this.nodeConfigure = page.getByRole('button', { name: 'Configure' });
this.collectionDropDown = page.getByRole('button', { name: 'Select collection' });
this.checkWthdrawable = page.getByLabel('Withdrawable');
this.configureUserInterfaceButton = page.getByRole('button', { name: 'Configure user interface' });
this.addBlockButton = page.getByRole('button', { name: 'Add block' });
this.collectionDropDown = page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection')
.locator('.ant-select-selection-search-input');
this.dataBlocksInitiationRadio = page.getByLabel('Initiate and approve in data blocks only');
this.dataBlocksAndGlobalApprovalBlocksInitiationRadio = page.getByLabel(
'Initiate and approve in both data blocks and global approval blocks',
);
this.allowedToBeWithdrawnCheckbox = page.getByLabel('Allowed to be withdrawn');
this.goToconfigureButton = page.getByRole('button', { name: 'Go to configure' });
this.addBlockButton = page.getByLabel(`schema-initializer-Grid-ApprovalApplyAddBlockButton-${collectionName}`);
this.addApplyFormMenu = page.getByRole('menuitem', { name: 'Apply form' });
this.configureFieldsButton = page.getByTestId('configure-fields-button-of-form-item-' + collectionName);
this.configureActionsButton = page.getByTestId(
'approval-trigger-configure-form-actions-add-action-button-' + collectionName,
this.configureFieldsButton = page.getByLabel(`schema-initializer-Grid-form:configureFields-${collectionName}`);
this.configureActionsButton = page.getByLabel(
`schema-initializer-ActionBar-ApprovalApplyAddActionButton-${collectionName}`,
);
this.saveDraftSwitch = page.getByRole('menuitem', { name: 'Save draft' }).getByRole('switch');
this.addNodeButton = page.getByLabel('add-button', { exact: true });
this.submitButton = page.getByLabel('action-Action-Submit-workflows');
this.cancelButton = page.getByLabel('action-Action-Cancel-workflows');
this.addNodeButton = this.addNodeButton = page.getByLabel('add-button', { exact: true });
}
}
export class ApprovalNode {
export class ApprovalPassthroughModeNode {
readonly page: Page;
node: Locator;
nodeTitle: Locator;
nodeConfigure: Locator;
addAssigneesButton: Locator;
assigneesDropDown: Locator;
checkReturnable: Locator;
configureUserInterfaceButton: Locator;
OrRadio: Locator;
AndRadio: Locator;
votingRadio: Locator;
parallellyRadio: Locator;
sequentiallyRadio: Locator;
goToconfigureButton: Locator;
addBlockButton: Locator;
addApplyFormMenu: Locator;
configureFieldsButton: Locator;
configureActionsButton: Locator;
saveDraftSwitch: Locator;
addDetailsMenu: Locator;
detailsConfigureFieldsButton: Locator;
addActionsMenu: Locator;
actionsConfigureFieldsButton: Locator;
actionsConfigureActionsButton: Locator;
addApproveButton: Locator;
addRejectButton: Locator;
addReturnButton: Locator;
addNodeResult: Locator;
submitButton: Locator;
cancelButton: Locator;
addNodeButton: Locator;
constructor(page: Page, nodeName: string, collectionName: string) {
this.page = page;
this.node = page.getByText('TriggeraConfigure');
this.nodeTitle = page.locator('textarea').filter({ hasText: nodeName });
this.nodeConfigure = page.getByRole('button', { name: 'Configure' });
this.assigneesDropDown = page.getByLabel('Search');
this.checkReturnable = page.getByLabel('Returnable');
this.configureUserInterfaceButton = page.getByRole('button', { name: 'Configure user interface' });
this.addBlockButton = page.getByTestId('add-block-button-in-workflow-workflows');
this.addApplyFormMenu = page.getByRole('menuitem', { name: 'Apply form' });
this.configureFieldsButton = page.getByTestId('configure-fields-button-of-form-item-' + collectionName);
this.configureActionsButton = page.getByTestId(
'approval-trigger-configure-form-actions-add-action-button-' + collectionName,
this.node = page.getByLabel(`Approval-${nodeName}`, { exact: true });
this.nodeTitle = page.getByLabel(`Approval-${nodeName}`, { exact: true }).getByRole('textbox');
this.nodeConfigure = page
.getByLabel(`Approval-${nodeName}`, { exact: true })
.getByRole('button', { name: 'Configure' });
this.addAssigneesButton = page.getByRole('button', { name: 'plus Add assignee' });
this.assigneesDropDown = page.getByTestId('select-single');
this.OrRadio = page.getByLabel('Or', { exact: true });
this.AndRadio = page.getByLabel('And', { exact: true });
this.votingRadio = page.getByLabel('Voting', { exact: true });
this.parallellyRadio = page.getByLabel('Parallelly', { exact: true });
this.sequentiallyRadio = page.getByLabel('Sequentially', { exact: true });
this.goToconfigureButton = page.getByRole('button', { name: 'Go to configure' });
this.addBlockButton = page.getByLabel('schema-initializer-Grid-ApprovalProcessAddBlockButton-workflows');
this.addDetailsMenu = page.getByRole('menuitem', { name: 'Details' });
this.detailsConfigureFieldsButton = page.getByLabel(
`schema-initializer-Grid-ReadPrettyFormItemInitializers-${collectionName}`,
);
this.saveDraftSwitch = page.getByRole('menuitem', { name: 'Save draft' }).getByRole('switch');
this.addActionsMenu = page.getByRole('menuitem', { name: 'Actions' }).getByRole('switch');
this.actionsConfigureFieldsButton = page.getByLabel('schema-initializer-Grid-FormItemInitializers-approvalRecords');
this.actionsConfigureActionsButton = page.getByLabel(
'schema-initializer-ActionBar-ApprovalProcessAddActionButton-approvalRecords',
);
this.addApproveButton = page.getByRole('menuitem', { name: 'Approve' }).getByRole('switch');
this.addRejectButton = page.getByRole('menuitem', { name: 'Reject' }).getByRole('switch');
this.addReturnButton = page.getByRole('menuitem', { name: 'Return' }).getByRole('switch');
this.addNodeResult = page.getByRole('menuitem', { name: 'Node result right' });
this.submitButton = page.getByLabel('action-Action-Submit-workflows');
this.cancelButton = page.getByLabel('action-Action-Cancel-workflows');
this.addNodeButton = page.getByLabel(`add-button-calculation-${nodeName}`, { exact: true });
}
}
@ -164,7 +204,9 @@ export class ScheduleTriggerNode {
this.RrpeatModeDropdown = page.getByLabel('block-item-RepeatField-workflows-Repeat mode');
this.dataTableTimeFieldOptions = page.getByLabel('Based on date field of collection');
this.collectionDropDown = page.getByRole('button', { name: 'Select collection' });
this.collectionDropDown = page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection')
.locator('.ant-select-selection-search-input');
this.startTimeDropdown = page.getByLabel('block-item-OnField-workflows-Starts on');
this.submitButton = page.getByLabel('action-Action-Submit-workflows');
this.cancelButton = page.getByLabel('action-Action-Cancel-workflows');
@ -187,7 +229,10 @@ export class CollectionTriggerNode {
this.node = page.getByLabel(`Trigger-${triggerName}`);
this.nodeTitle = page.getByLabel(`Trigger-${triggerName}`).getByRole('textbox');
this.nodeConfigure = page.getByLabel(`Trigger-${triggerName}`).getByRole('button', { name: 'Configure' });
this.collectionDropDown = page.getByRole('button', { name: 'Select collection' });
// this.collectionDropDown = page.getByRole('button', { name: 'Select collection' });
this.collectionDropDown = page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection')
.locator('.ant-select-selection-search-input');
this.triggerOnDropdown = page
.getByLabel('block-item-Select-workflows-Trigger on')
.getByRole('button', { name: 'Trigger on' });
@ -212,7 +257,9 @@ export class FormEventTriggerNode {
this.node = page.getByLabel(`Trigger-${triggerName}`);
this.nodeTitle = page.getByLabel(`Trigger-${triggerName}`).getByRole('textbox');
this.nodeConfigure = page.getByLabel(`Trigger-${triggerName}`).getByRole('button', { name: 'Configure' });
this.collectionDropDown = page.getByRole('button', { name: 'Select collection' });
this.collectionDropDown = page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection')
.locator('.ant-select-selection-search-input');
this.relationalDataDropdown = page.getByTestId('select-field-Preload associations');
this.submitButton = page.getByLabel('action-Action-Submit-workflows');
this.cancelButton = page.getByLabel('action-Action-Cancel-workflows');
@ -269,7 +316,9 @@ export class QueryRecordNode {
this.nodeConfigure = page
.getByLabel(`Query record-${nodeName}`, { exact: true })
.getByRole('button', { name: 'Configure' });
this.collectionDropDown = page.getByRole('button', { name: 'Select collection' });
this.collectionDropDown = page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection')
.locator('.ant-select-selection-search-input');
this.allowMultipleDataBoxesForResults = page.getByLabel('Allow multiple records as');
this.addSortFieldsButton = page.getByRole('button', { name: 'plus Add sort field' });
this.pageNumberEditBox = page.getByLabel('variable-constant');
@ -299,7 +348,9 @@ export class CreateRecordNode {
this.nodeConfigure = page
.getByLabel(`Create record-${nodeName}`, { exact: true })
.getByRole('button', { name: 'Configure' });
this.collectionDropDown = page.getByRole('button', { name: 'Select collection' });
this.collectionDropDown = page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection')
.locator('.ant-select-selection-search-input');
this.addFieldsButton = page.getByRole('button', { name: 'plus Add field' });
this.submitButton = page.getByLabel('action-Action-Submit-workflows');
this.cancelButton = page.getByLabel('action-Action-Cancel-workflows');
@ -326,7 +377,9 @@ export class UpdateRecordNode {
this.nodeConfigure = page
.getByLabel(`Update record-${nodeName}`, { exact: true })
.getByRole('button', { name: 'Configure' });
this.collectionDropDown = page.getByRole('button', { name: 'Select collection' });
this.collectionDropDown = page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection')
.locator('.ant-select-selection-search-input');
this.batchUpdateModeRadio = page
.getByLabel('block-item-IndividualHooksRadioWithTooltip-workflows-Update mode')
.getByLabel('Update in a batch');
@ -356,7 +409,9 @@ export class DeleteRecordNode {
this.nodeConfigure = page
.getByLabel(`Delete record-${nodeName}`, { exact: true })
.getByRole('button', { name: 'Configure' });
this.collectionDropDown = page.getByRole('button', { name: 'Select collection' });
this.collectionDropDown = page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection')
.locator('.ant-select-selection-search-input');
this.submitButton = page.getByLabel('action-Action-Submit-workflows');
this.cancelButton = page.getByLabel('action-Action-Cancel-workflows');
this.addNodeButton = page.getByLabel(`add-button-delete-${nodeName}`, { exact: true });
@ -395,11 +450,12 @@ export class AggregateNode {
this.minRadio = page.getByLabel('MIN', { exact: true });
this.dataTableDataRadio = page.getByLabel('Data of collection');
this.linkedDataTableDataRadio = page.getByLabel('Data of associated collection');
this.collectionDropDown = page.getByRole('button', { name: 'Select collection' });
// this.aggregatedFieldDropDown = page.getByLabel('block-item-FieldsSelect-workflows-Field to aggregate').getByRole('textbox').getByRole('combobox');
this.aggregatedFieldDropDown = page.locator(
'input.ant-select-selection-search-input[role="combobox"][aria-haspopup="listbox"]',
);
this.collectionDropDown = page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Data of collection')
.locator('.ant-select-selection-search-input');
this.aggregatedFieldDropDown = page
.getByLabel('block-item-FieldsSelect-workflows-Field to aggregate')
.locator('.ant-select-selection-search-input');
this.distinctCheckBox = page
.getByLabel('block-item-Checkbox-workflows-Distinct')
.locator('input.ant-checkbox-input[type="checkbox"]');
@ -572,7 +628,7 @@ export default module.exports = {
WorkflowManagement,
WorkflowListRecords,
ApprovalTriggerNode,
ApprovalNode,
ApprovalPassthroughModeNode,
ScheduleTriggerNode,
CollectionTriggerNode,
FormEventTriggerNode,

View File

@ -1,4 +1,4 @@
import { request } from '@nocobase/test/e2e';
import { request, Page } from '@nocobase/test/e2e';
const PORT = process.env.APP_PORT || 20000;
const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${PORT}`;
@ -722,6 +722,156 @@ export const apiSubmitRecordTriggerFormEvent = async (triggerWorkflows: string,
return await result.json();
};
// 获取数据源个数
export const apiGetDataSourceCount = async () => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.get(`/api/dataSources:list?pageSize=50`, {
headers,
});
if (!result.ok()) {
throw new Error(await result.text());
}
/*
{
"data": 1
}
*/
return (await result.json()).meta.count;
};
// 添加业务表单条数据触发工作流表单事件,triggerWorkflows=key1!field,key2,key3!field.subfield
export const apiCreateRecordTriggerActionEvent = async (
collectionName: string,
triggerWorkflows: string,
data: any,
) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
/*
{
"title": "a11",
"enabled": true,
"description": null
}
*/
const result = await api.post(`/api/${collectionName}:create?triggerWorkflows=${triggerWorkflows}`, {
headers,
data,
});
if (!result.ok()) {
throw new Error(await result.text());
}
/*
{
"data": {
"id": 1,
"createdAt": "2023-12-12T02:43:53.793Z",
"updatedAt": "2023-12-12T05:41:33.300Z",
"key": "fzk3j2oj4el",
"title": "a11",
"enabled": true,
"description": null
},
"meta": {
"allowedActions": {
"view": [
1
],
"update": [
1
],
"destroy": [
1
]
}
}
}
*/
return (await result.json()).data;
};
// 审批中心发起审批
export const apiApplyApprovalEvent = async (data: any) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
/*
{
"title": "a11",
"enabled": true,
"description": null
}
*/
const result = await api.post('/api/approvals:create', {
headers,
data,
});
if (!result.ok()) {
throw new Error(await result.text());
}
/*
{
"data": {
"id": 35,
"collectionName": "tt_amt_orgREmwr",
"data": {
"id": 6,
"url": null,
"sort": 3,
"email": null,
"phone": null,
"address": null,
"orgcode": "区域编码000000006",
"orgname": "阿三大苏打实打实的",
"isenable": null,
"staffnum": null,
"createdAt": "2024-03-09T11:37:47.620Z",
"sharesnum": null,
"updatedAt": "2024-03-09T11:37:47.620Z",
"insurednum": null,
"range_json": null,
"regcapital": null,
"testdataid": null,
"createdById": 1,
"paidcapital": null,
"range_check": [],
"updatedById": 1,
"status_radio": null,
"establishdate": null,
"insuranceratio": null,
"range_markdown": null,
"range_richtext": null,
"status_singleselect": null,
"range_multipleselect": [],
"insuranceratio_formula": null
},
"status": 2,
"workflowId": 39,
"dataKey": "6",
"updatedAt": "2024-03-09T11:37:47.640Z",
"createdAt": "2024-03-09T11:37:47.640Z",
"createdById": 1,
"updatedById": 1,
"workflowKey": null,
"latestExecutionId": null
}
}
*/
return (await result.json()).data;
};
const getStorageItem = (key: string, storageState: any) => {
return storageState.origins
.find((item) => item.origin === APP_BASE_URL)
@ -767,6 +917,16 @@ function getHeaders(storageState: any) {
return headers;
}
// 用户登录新会话
export const userLogin = async (page: Page, approvalUserEmail: string, approvalUser: string) => {
await page.goto(`${process.env.APP_BASE_URL}/signin`);
await page.getByPlaceholder('Email').fill(approvalUserEmail);
await page.getByPlaceholder('Password').fill(approvalUser);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForLoadState('networkidle');
return page;
};
export default module.exports = {
apiCreateWorkflow,
apiUpdateWorkflow,
@ -783,4 +943,8 @@ export default module.exports = {
apiCreateRecordTriggerFormEvent,
apiSubmitRecordTriggerFormEvent,
apiFilterList,
apiGetDataSourceCount,
apiCreateRecordTriggerActionEvent,
apiApplyApprovalEvent,
userLogin,
};

View File

@ -1,11 +1,14 @@
import path from 'path';
import { ApplicationOptions, Plugin } from '@nocobase/server';
import { MockServer, createMockServer } from '@nocobase/test';
import { MockServer, createMockServer, mockDatabase } from '@nocobase/test';
import functions from './functions';
import triggers from './triggers';
import instructions from './instructions';
import { Resourcer } from '@nocobase/resourcer';
import { SequelizeDataSource } from '@nocobase/data-source-manager';
import { uid } from '@nocobase/utils';
export interface MockServerOptions extends ApplicationOptions {
collectionsPath?: string;
@ -33,7 +36,7 @@ export async function getApp(options: MockServerOptions = {}): Promise<MockServe
}
}
}
return createMockServer({
const app = await createMockServer({
...others,
plugins: [
[
@ -49,6 +52,30 @@ export async function getApp(options: MockServerOptions = {}): Promise<MockServe
...plugins,
],
});
await app.dataSourceManager.add(
new SequelizeDataSource({
name: 'another',
collectionManager: {
database: mockDatabase({
tablePrefix: `t${uid(5)}`,
}),
},
resourceManager: {},
}),
);
const another = app.dataSourceManager.dataSources.get('another');
// @ts-ignore
const anotherDB = another.collectionManager.db;
await anotherDB.import({
directory: path.resolve(__dirname, 'collections'),
});
await anotherDB.sync();
another.acl.allow('*', '*');
return app;
}
export default class WorkflowTestPlugin extends Plugin {

View File

@ -21,10 +21,9 @@ import { ExecutionStatusOptionsMap, JobStatusOptions } from './constants';
import { FlowContext, useFlowContext } from './FlowContext';
import { lang, NAMESPACE } from './locale';
import useStyles from './style';
import { linkNodes } from './utils';
import { linkNodes, getWorkflowDetailPath, getWorkflowExecutionsPath } from './utils';
import { DownOutlined, ExclamationCircleFilled, StopOutlined } from '@ant-design/icons';
import { StatusButton } from './components/StatusButton';
import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './constant';
import { useTranslation } from 'react-i18next';
function attachJobs(nodes, jobs: any[] = []): void {

View File

@ -0,0 +1,33 @@
import React from 'react';
import { SchemaComponentOptions, usePlugin } from '@nocobase/client';
import PluginWorkflowClient, { FlowContext } from '.';
export function ExecutionContextProvider({ children, workflow, execution, nodes }) {
const workflowPlugin = usePlugin(PluginWorkflowClient);
const triggerComponents = workflowPlugin.triggers.get(workflow.type).components;
const nodeComponents = nodes.reduce(
(components, { type }) => Object.assign(components, workflowPlugin.instructions.get(type).components),
{},
);
return (
<FlowContext.Provider
value={{
workflow,
nodes,
execution,
}}
>
<SchemaComponentOptions
components={{
...triggerComponents,
...nodeComponents,
}}
>
{children}
</SchemaComponentOptions>
</FlowContext.Provider>
);
}

View File

@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useActionContext, useRecord } from '@nocobase/client';
import { getWorkflowExecutionsPath } from './constant';
import { getWorkflowExecutionsPath } from './utils';
export const ExecutionLink = () => {
const { t } = useTranslation();

View File

@ -21,8 +21,7 @@ import { FlowContext, useFlowContext } from './FlowContext';
import { lang } from './locale';
import { executionSchema } from './schemas/executions';
import useStyles from './style';
import { linkNodes } from './utils';
import { getWorkflowDetailPath } from './constant';
import { linkNodes, getWorkflowDetailPath } from './utils';
import { ExecutionStatusColumn } from './components/ExecutionStatus';
function ExecutionResourceProvider({ request, filter = {}, ...others }) {

View File

@ -2,9 +2,10 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { getWorkflowDetailPath } from './constant';
import { useActionContext, useGetAriaLabelOfAction, useRecord } from '@nocobase/client';
import { getWorkflowDetailPath } from './utils';
export const WorkflowLink = () => {
const { t } = useTranslation();
const { id } = useRecord();

View File

@ -47,7 +47,9 @@ test.describe('Configuration page to configure the Trigger node', () => {
const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName);
await collectionTriggerNode.nodeConfigure.click();
await collectionTriggerNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
// await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await collectionTriggerNode.triggerOnDropdown.click();
await page.getByText('After record added', { exact: true }).click();
await collectionTriggerNode.submitButton.click();
@ -101,7 +103,8 @@ test.describe('Configuration page to configure the Trigger node', () => {
const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName);
await collectionTriggerNode.nodeConfigure.click();
await collectionTriggerNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await collectionTriggerNode.triggerOnDropdown.click();
await page.getByText('After record updated', { exact: true }).click();
await collectionTriggerNode.submitButton.click();
@ -157,7 +160,8 @@ test.describe('Configuration page to configure the Trigger node', () => {
const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName);
await collectionTriggerNode.nodeConfigure.click();
await collectionTriggerNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await collectionTriggerNode.triggerOnDropdown.click();
await page.getByText('After record added or updated', { exact: true }).click();
await collectionTriggerNode.submitButton.click();
@ -218,7 +222,8 @@ test.describe('Configuration page to configure the Trigger node', () => {
const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName);
await collectionTriggerNode.nodeConfigure.click();
await collectionTriggerNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await collectionTriggerNode.triggerOnDropdown.click();
await page.getByText('After record added or updated', { exact: true }).click();
// 设置触发器过滤条件
@ -285,7 +290,8 @@ test.describe('Configuration page to configure the Trigger node', () => {
const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName);
await collectionTriggerNode.nodeConfigure.click();
await collectionTriggerNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await collectionTriggerNode.triggerOnDropdown.click();
await page.getByText('After record updated', { exact: true }).click();
// 设置触发器过滤条件
@ -723,7 +729,12 @@ test.describe('Configuration page copy to new version', () => {
// 3、预期结果新版本工作流配置内容同旧版本一样
const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName);
await collectionTriggerNode.nodeConfigure.click();
await expect(page.getByRole('button', { name: triggerNodeCollectionDisplayName })).toBeVisible();
// await expect(page.getByRole('button', { name: `Main / ${triggerNodeCollectionDisplayName}` })).toBeVisible();
await expect(
page
.getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection')
.getByText(`Main / ${triggerNodeCollectionDisplayName}`),
).toBeVisible();
// 4、后置处理删除工作流
await apiDeleteWorkflow(workflowId);

View File

@ -73,7 +73,8 @@ test('Collection event add data trigger, single row text fields for common table
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -172,7 +173,8 @@ test('Collection event add data trigger, normal table single line text field, se
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -273,7 +275,8 @@ test('Collection event add data trigger, normal table integer field, set constan
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -372,7 +375,8 @@ test('Collection event add data trigger, normal table integer field, set trigger
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -473,7 +477,8 @@ test('Collection event add data trigger, normal table numeric field, set constan
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -572,7 +577,8 @@ test('Collection event add data trigger, normal table numeric field, set trigger
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -673,7 +679,8 @@ test('Collection event add data trigger, normal table dropdown radio field, set
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -769,7 +776,8 @@ test('Collection event add data trigger, normal table dropdown radio field, set
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -870,7 +878,8 @@ test('Collection event add data trigger, normal table dropdown radio fields, set
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -967,7 +976,8 @@ test('Collection event add data trigger, normal table dropdown radio fields, set
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -1068,7 +1078,8 @@ test('Collection event add data trigger, normal table date field, set constant d
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();
@ -1169,7 +1180,8 @@ test('Collection event add data trigger, normal table date field, set trigger no
const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText();
await createRecordNode.nodeConfigure.click();
await createRecordNode.collectionDropDown.click();
await page.getByText(createNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click();
// 设置字段
await createRecordNode.addFieldsButton.click();
await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click();

View File

@ -78,7 +78,8 @@ test('Collection event add data trigger, filter single line text field not null,
const deleteRecordNode = new DeleteRecordNode(page, deleteRecordNodeName);
await deleteRecordNode.nodeConfigure.click();
await deleteRecordNode.collectionDropDown.click();
await page.getByText(deleteNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: deleteNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page.getByLabel('block-item-Filter-workflows-Filter').getByRole('button', { name: 'Select field' }).click();
@ -172,7 +173,8 @@ test('Collection event add data trigger, filter single line text field is trigge
const deleteRecordNode = new DeleteRecordNode(page, deleteRecordNodeName);
await deleteRecordNode.nodeConfigure.click();
await deleteRecordNode.collectionDropDown.click();
await page.getByText(deleteNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: deleteNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page.getByLabel('block-item-Filter-workflows-Filter').getByRole('button', { name: 'Select field' }).click();

View File

@ -61,7 +61,8 @@ test('Collection event add data trigger, no filter no sort query common table 1
const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText();
await queryRecordNode.nodeConfigure.click();
await queryRecordNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await queryRecordNode.submitButton.click();
// 2、测试步骤添加数据触发工作流
@ -138,7 +139,8 @@ test('Collection event add data trigger, no filtering and no sorting, query comm
const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText();
await queryRecordNode.nodeConfigure.click();
await queryRecordNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await queryRecordNode.allowMultipleDataBoxesForResults.check();
await expect(queryRecordNode.pageNumberEditBox).toHaveValue('1');
await expect(queryRecordNode.pageSizeEditBox).toHaveValue('20');
@ -216,7 +218,8 @@ test('Collection event add data trigger, no filter ID ascending, query common ta
const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText();
await queryRecordNode.nodeConfigure.click();
await queryRecordNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await queryRecordNode.allowMultipleDataBoxesForResults.check();
// 设置排序条件
await queryRecordNode.addSortFieldsButton.click();
@ -324,7 +327,8 @@ test('Collection event add data trigger, no filter ID descending, query common t
const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText();
await queryRecordNode.nodeConfigure.click();
await queryRecordNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await queryRecordNode.allowMultipleDataBoxesForResults.check();
// 设置排序条件
await queryRecordNode.addSortFieldsButton.click();
@ -433,7 +437,8 @@ test('Collection event add data trigger, no filtering and no sorting, query mult
const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText();
await queryRecordNode.nodeConfigure.click();
await queryRecordNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await queryRecordNode.allowMultipleDataBoxesForResults.check();
await expect(queryRecordNode.pageNumberEditBox).toHaveValue('1');
await expect(queryRecordNode.pageSizeEditBox).toHaveValue('20');
@ -536,7 +541,8 @@ test('Collection event add data trigger, no filtering and no sorting, query the
const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText();
await queryRecordNode.nodeConfigure.click();
await queryRecordNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
// await queryRecordNode.allowMultipleDataBoxesForResults.check();
await expect(queryRecordNode.pageNumberEditBox).toHaveValue('1');
await expect(queryRecordNode.pageSizeEditBox).toHaveValue('20');
@ -641,7 +647,8 @@ test('Collection event add data trigger, no filtering and no sorting, query the
const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText();
await queryRecordNode.nodeConfigure.click();
await queryRecordNode.collectionDropDown.click();
await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click();
await queryRecordNode.allowMultipleDataBoxesForResults.check();
await expect(queryRecordNode.pageNumberEditBox).toHaveValue('1');
await expect(queryRecordNode.pageSizeEditBox).toHaveValue('20');
@ -769,7 +776,8 @@ test('Collection event add data trigger, filter to meet all conditions (status_s
const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText();
await queryRecordNode.nodeConfigure.click();
await queryRecordNode.collectionDropDown.click();
await page.getByText(queryNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: queryNodeCollectionDisplayName }).click();
// await queryRecordNode.allowMultipleDataBoxesForResults.check();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -884,7 +892,8 @@ test('Collection event add data trigger, filter to satisfy any condition (status
const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText();
await queryRecordNode.nodeConfigure.click();
await queryRecordNode.collectionDropDown.click();
await page.getByText(queryNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: queryNodeCollectionDisplayName }).click();
await queryRecordNode.allowMultipleDataBoxesForResults.check();
// 设置过滤条件
await page.getByTestId('filter-select-all-or-any').click();

View File

@ -86,7 +86,8 @@ test('Collection event add data trigger, filter single line text field not empty
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -240,7 +241,8 @@ test('Collection event add data trigger, filter single line text field not empty
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -401,7 +403,8 @@ test('Collection event add data trigger, filter multi-line text field not empty,
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -555,7 +558,8 @@ test('Collection event add data trigger, filter multiline text field not empty,
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -710,7 +714,8 @@ test('Collection event add data trigger, filter integer field not null, common t
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -860,7 +865,8 @@ test('Collection event add data trigger, filter integer field not empty, common
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -1014,7 +1020,8 @@ test('Collection event add data trigger, filter numeric field not null, common t
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -1163,7 +1170,8 @@ test('Collection event add data trigger, filter numeric field not empty, common
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -1315,7 +1323,8 @@ test('Collection event add data trigger, filter dropdown radio field not null, c
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -1464,7 +1473,8 @@ test('Collection event add data trigger, filter dropdown radio field not empty,
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -1619,7 +1629,8 @@ test('Collection event add data trigger, filter dropdown radio fields not null,
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -1772,7 +1783,8 @@ test('Collection event add data trigger, filter dropdown radio fields not empty,
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -1931,7 +1943,8 @@ test('Collection event add data trigger, filter date field not null, common tabl
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page
@ -2086,7 +2099,8 @@ test('Collection event add data trigger, filter date field not empty, common tab
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
await page

View File

@ -86,7 +86,8 @@ test('Collection event add data trigger, filter single line text field not empty
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -241,7 +242,8 @@ test('Collection event add data trigger, filter single line text field not empty
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -403,7 +405,8 @@ test('Collection event add data trigger, filter multi-line text field not empty,
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -558,7 +561,8 @@ test('Collection event add data trigger, filter multiline text field not empty,
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -714,7 +718,8 @@ test('Collection event add data trigger, filter integer field not null, common t
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -865,7 +870,8 @@ test('Collection event add data trigger, filter integer field not empty, common
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -1020,7 +1026,8 @@ test('Collection event add data trigger, filter numeric field not null, common t
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -1170,7 +1177,8 @@ test('Collection event add data trigger, filter numeric field not empty, common
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -1323,7 +1331,8 @@ test('Collection event add data trigger, filter dropdown radio field not null, c
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -1473,7 +1482,8 @@ test('Collection event add data trigger, filter dropdown radio field not empty,
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -1629,7 +1639,8 @@ test('Collection event add data trigger, filter dropdown radio fields not null,
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -1783,7 +1794,8 @@ test('Collection event add data trigger, filter dropdown radio fields not empty,
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -1943,7 +1955,8 @@ test('Collection event add data trigger, filter date field not null, common tabl
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();
@ -2099,7 +2112,8 @@ test('Collection event add data trigger, filter date field not empty, common tab
const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText();
await updateRecordNode.nodeConfigure.click();
await updateRecordNode.collectionDropDown.click();
await page.getByText(updateNodeCollectionDisplayName).click();
await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click();
await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click();
await updateRecordNode.articleByArticleUpdateModeRadio.click();
// 设置过滤条件
await page.getByText('Add condition', { exact: true }).click();

View File

@ -1,9 +1,11 @@
import React from 'react';
import { uid } from '@formily/shared';
import {
CollectionProvider_deprecated,
SchemaInitializerItem,
SchemaInitializerItemType,
parseCollectionName,
useCollectionManager_deprecated,
useRecordCollectionDataSourceItems,
useSchemaInitializer,
@ -13,23 +15,29 @@ import {
import { traverseSchema } from '../utils';
function InnerCollectionBlockInitializer({ collection, dataSource, ...props }) {
function InnerCollectionBlockInitializer({ collection, dataPath, ...props }) {
const { insert } = useSchemaInitializer();
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
const { getCollection } = useCollectionManager_deprecated();
const items = useRecordCollectionDataSourceItems('FormItem') as SchemaInitializerItemType[];
const resolvedCollection = getCollection(collection);
let resolvedCollection;
if (typeof collection === 'string') {
const [dataSourceName, collectionName] = parseCollectionName(collection);
resolvedCollection = getCollection(collectionName, dataSourceName);
} else {
resolvedCollection = collection;
}
async function onConfirm({ item }) {
const template = item.template ? await getTemplateSchemaByMode(item) : null;
const result = {
type: 'void',
name: resolvedCollection.name,
name: uid(),
title: resolvedCollection.title,
'x-decorator': 'DetailsBlockProvider',
'x-decorator-props': {
collection,
dataSource,
dataPath,
},
'x-component': 'CardItem',
'x-component-props': {
@ -66,11 +74,20 @@ function InnerCollectionBlockInitializer({ collection, dataSource, ...props }) {
return <SchemaInitializerItem {...props} onClick={onConfirm} items={items} />;
}
export function CollectionBlockInitializer() {
export function CollectionBlockInitializer(props) {
const itemConfig = useSchemaInitializerItem();
const sourceCollection = props?.collection ?? itemConfig.collection;
let dataSource, collection;
if (typeof sourceCollection === 'string') {
const parsed = parseCollectionName(sourceCollection);
dataSource = parsed[0];
collection = parsed[1];
} else {
collection = sourceCollection;
}
return (
<CollectionProvider_deprecated collection={itemConfig.collection}>
<InnerCollectionBlockInitializer {...itemConfig} />
<CollectionProvider_deprecated dataSource={dataSource} collection={collection}>
<InnerCollectionBlockInitializer {...itemConfig} {...props} />
</CollectionProvider_deprecated>
);
}

View File

@ -6,6 +6,7 @@ import {
SchemaComponent,
Variable,
css,
parseCollectionName,
useCollectionManager_deprecated,
useCompile,
useToken,
@ -21,7 +22,8 @@ function AssociationInput(props) {
const { path } = useField();
const fieldName = path.segments[path.segments.length - 1] as string;
const { values: config } = useForm();
const fields = getCollectionFields(config?.collection);
const [dataSourceName, collectionName] = parseCollectionName(config?.collection);
const fields = getCollectionFields(collectionName, dataSourceName);
const { type } = fields.find((item) => item.name === fieldName);
const value = Array.isArray(props.value) ? props.value.join(',') : props.value;
@ -39,11 +41,11 @@ const CollectionFieldSet = observer(
const { t } = useTranslation();
const compile = useCompile();
const form = useForm();
const { getCollection, getCollectionFields } = useCollectionManager_deprecated();
const { getCollectionFields } = useCollectionManager_deprecated();
const scope = useWorkflowVariableOptions();
const { values: config } = form;
const collectionName = config?.collection;
const collectionFields = getCollectionFields(collectionName).filter((field) => field.uiSchema);
const [dataSourceName, collectionName] = parseCollectionName(config?.collection);
const collectionFields = getCollectionFields(collectionName, dataSourceName).filter((field) => field.uiSchema);
const fields = filter ? collectionFields.filter(filter.bind(config)) : collectionFields;
const unassignedFields = useMemo(() => fields.filter((field) => !value || !(field.name in value)), [fields, value]);
@ -79,7 +81,7 @@ const CollectionFieldSet = observer(
`}
>
{fields.length ? (
<CollectionProvider_deprecated collection={getCollection(collectionName)}>
<CollectionProvider_deprecated name={collectionName} dataSource={dataSourceName}>
{fields
.filter((field) => value && field.name in value)
.map((field) => {

View File

@ -1,19 +1,21 @@
import React, { useMemo, useRef } from 'react';
import { createForm } from '@formily/core';
import { useField } from '@formily/react';
import { get } from 'lodash';
import {
BlockRequestContext_deprecated,
CollectionProvider_deprecated,
FormBlockContext,
RecordProvider,
parseCollectionName,
useAPIClient,
useAssociationNames,
useBlockRequestContext,
} from '@nocobase/client';
import { useFlowContext } from '@nocobase/plugin-workflow/client';
import { parse } from '@nocobase/utils/client';
import React, { useMemo, useRef } from 'react';
function useFlowContextData(dataSource) {
import { useFlowContext } from '../FlowContext';
function useFlowContextData(dataPath) {
const { execution, nodes } = useFlowContext();
const nodesKeyMap = useMemo(() => {
@ -31,16 +33,24 @@ function useFlowContextData(dataSource) {
[execution?.context, execution?.jobs, nodesKeyMap],
);
const result = useMemo(() => parse(dataSource)(data), [data, dataSource]);
const result = useMemo(() => get(data, dataPath), [data, dataPath]);
return result;
}
export function DetailsBlockProvider(props) {
export function DetailsBlockProvider({ collection, dataPath, children }) {
const field = useField();
const formBlockRef = useRef(null);
const { getAssociationAppends } = useAssociationNames();
const { appends, updateAssociationValues } = getAssociationAppends();
const values = useFlowContextData(props.dataSource);
const values = useFlowContextData(dataPath);
let dataSourceName, resolvedCollection;
if (typeof collection === 'string') {
const parsed = parseCollectionName(collection);
dataSourceName = parsed[0];
resolvedCollection = parsed[1];
} else {
resolvedCollection = collection;
}
const form = useMemo(
() =>
@ -61,11 +71,11 @@ export function DetailsBlockProvider(props) {
},
};
const api = useAPIClient();
const resource = api.resource(props.collection);
const resource = api.resource(resolvedCollection);
const __parent = useBlockRequestContext();
return (
<CollectionProvider_deprecated collection={props.collection}>
<CollectionProvider_deprecated dataSource={dataSourceName} collection={resolvedCollection}>
<RecordProvider record={values} parent={null}>
<BlockRequestContext_deprecated.Provider value={{ block: 'form', field, service, resource, __parent }}>
<FormBlockContext.Provider
@ -78,7 +88,7 @@ export function DetailsBlockProvider(props) {
formBlockRef,
}}
>
{props.children}
{children}
</FormBlockContext.Provider>
</BlockRequestContext_deprecated.Provider>
</RecordProvider>

View File

@ -2,7 +2,7 @@ import { observer, useForm } from '@formily/react';
import { Select } from 'antd';
import React from 'react';
import { useCollectionManager_deprecated, useCompile } from '@nocobase/client';
import { parseCollectionName, useCollectionManager_deprecated, useCompile } from '@nocobase/client';
function defaultFilter() {
return true;
@ -14,7 +14,8 @@ export const FieldsSelect = observer(
const compile = useCompile();
const { getCollectionFields } = useCollectionManager_deprecated();
const { values } = useForm();
const fields = getCollectionFields(values?.collection);
const [dataSourceName, collectionName] = parseCollectionName(values?.collection);
const fields = getCollectionFields(collectionName, dataSourceName);
return (
<Select

View File

@ -0,0 +1,27 @@
import React from 'react';
import { useFieldSchema } from '@formily/react';
import {
GeneralSchemaDesigner,
SchemaSettingsBlockTitleItem,
SchemaSettingsDivider,
SchemaSettingsRemove,
useCompile,
} from '@nocobase/client';
export function SimpleDesigner() {
const schema = useFieldSchema();
const compile = useCompile();
return (
<GeneralSchemaDesigner title={compile(schema.title)}>
<SchemaSettingsBlockTitleItem />
<SchemaSettingsDivider />
<SchemaSettingsRemove
removeParentsIfNoChildren
breakRemoveOn={{
'x-component': 'Grid',
}}
/>
</GeneralSchemaDesigner>
);
}

View File

@ -3,10 +3,12 @@ import { css, SchemaInitializerItem, useSchemaInitializer, useSchemaInitializerI
import { parse } from '@nocobase/utils/client';
import React from 'react';
import { useFlowContext } from '../FlowContext';
import { SimpleDesigner } from './SimpleDesigner';
export const ValueBlock: (() => JSX.Element) & {
Initializer: () => JSX.Element;
Result: (props) => JSX.Element;
Designer: () => JSX.Element;
} = () => {
return null;
};
@ -27,7 +29,7 @@ function Initializer() {
'x-component-props': {
title: node.title ?? `#${node.id}`,
},
'x-designer': 'SimpleDesigner',
'x-designer': 'ValueBlock.Designer',
properties: {
result: {
type: 'void',
@ -71,3 +73,4 @@ function Result({ dataSource }) {
ValueBlock.Initializer = Initializer;
ValueBlock.Result = Result;
ValueBlock.Designer = SimpleDesigner;

View File

@ -1,6 +1,8 @@
export * from './CollectionBlockInitializer';
export * from './DetailsBlockProvider';
export * from './FieldsSelect';
export * from './FilterDynamicComponent';
export * from './RadioWithTooltip';
export * from './CheckboxGroupWithTooltip';
export * from './ValueBlock';
export * from './SimpleDesigner';

View File

@ -1,2 +0,0 @@
export const getWorkflowDetailPath = (id: string | number) => `/admin/workflow/workflows/${id}`;
export const getWorkflowExecutionsPath = (id: string | number) => `/admin/workflow/executions/${id}`;

View File

@ -0,0 +1,3 @@
export * from './useGetAriaLabelOfAddButton';
export * from './useTriggerWorkflowActionProps';
export * from './useWorkflowExecuted';

View File

@ -0,0 +1,11 @@
import { useFlowContext } from '../FlowContext';
export function useWorkflowExecuted() {
const { workflow } = useFlowContext();
return Boolean(workflow?.executed);
}
export function useWorkflowAnyExecuted() {
const { workflow } = useFlowContext();
return Boolean(workflow?.allExecuted);
}

View File

@ -1,16 +1,3 @@
export * from './Branch';
export * from './FlowContext';
export * from './constants';
export * from './nodes';
export { Trigger, useTrigger } from './triggers';
export * from './variable';
export * from './components';
export * from './utils';
export * from './hooks/useGetAriaLabelOfAddButton';
export { default as useStyles } from './style';
export * from './variable';
export * from './hooks/useTriggerWorkflowActionProps';
import React from 'react';
import { Plugin } from '@nocobase/client';
@ -30,7 +17,7 @@ import QueryInstruction from './nodes/query';
import CreateInstruction from './nodes/create';
import UpdateInstruction from './nodes/update';
import DestroyInstruction from './nodes/destroy';
import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './constant';
import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './utils';
import { NAMESPACE } from './locale';
import { customizeSubmitToWorkflowActionSettings } from './settings/customizeSubmitToWorkflowActionSettings';
@ -114,3 +101,16 @@ export default class PluginWorkflowClient extends Plugin {
});
}
}
export * from './Branch';
export * from './FlowContext';
export * from './constants';
export * from './nodes';
export { Trigger, useTrigger } from './triggers';
export * from './variable';
export * from './components';
export * from './utils';
export * from './hooks';
export { default as useStyles } from './style';
export * from './variable';
export * from './ExecutionContextProvider';

View File

@ -18,7 +18,22 @@ export default class extends Instruction {
group = 'collection';
description = `{{t("Add new record to a collection. You can use variables from upstream nodes to assign values to fields.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
collection,
collection: {
...collection,
'x-reactions': [
...collection['x-reactions'],
{
target: 'params',
effects: ['onFieldValueChange'],
fulfill: {
state: {
visible: '{{!!$self.value}}',
value: '{{Object.create({})}}',
},
},
},
],
},
// multiple: {
// type: 'boolean',
// title: '多条数据',
@ -84,7 +99,7 @@ export default class extends Instruction {
title: node.title ?? `#${node.id}`,
Component: CollectionBlockInitializer,
collection: node.config.collection,
dataSource: `{{$jobsMapByNodeKey.${node.key}}}`,
dataPath: `$jobsMapByNodeKey.${node.key}`,
};
}
}

View File

@ -12,7 +12,29 @@ export default class extends Instruction {
group = 'collection';
description = `{{t("Delete records of a collection. Could use variables in workflow context as filter. All records match the filter will be deleted.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
collection,
collection: {
...collection,
'x-reactions': [
...collection['x-reactions'],
{
target: 'params',
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
},
},
{
target: 'params',
effects: ['onFieldValueChange'],
fulfill: {
state: {
value: '{{Object.create({})}}',
},
},
},
],
},
params: {
type: 'object',
properties: {

View File

@ -22,7 +22,22 @@ export default class extends Instruction {
group = 'collection';
description = `{{t("Query records from a collection. You can use variables from upstream nodes as query conditions.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
collection,
collection: {
...collection,
'x-reactions': [
...collection['x-reactions'],
{
target: 'params',
effects: ['onFieldValueChange'],
fulfill: {
state: {
visible: '{{!!$self.value}}',
value: '{{Object.create({})}}',
},
},
},
],
},
multiple: {
type: 'boolean',
'x-decorator': 'FormItem',
@ -130,7 +145,7 @@ export default class extends Instruction {
title: node.title ?? `#${node.id}`,
Component: CollectionBlockInitializer,
collection: node.config.collection,
dataSource: `{{$jobsMapByNodeKey.${node.key}}}`,
dataPath: `$jobsMapByNodeKey.${node.key}`,
};
}
}

View File

@ -46,7 +46,38 @@ export default class extends Instruction {
group = 'collection';
description = `{{t("Update records of a collection. You can use variables from upstream nodes as query conditions and field values.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
collection,
collection: {
...collection,
'x-reactions': [
...collection['x-reactions'],
{
target: 'params',
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
},
},
{
target: 'params.filter',
effects: ['onFieldValueChange'],
fulfill: {
state: {
value: '{{Object.create({})}}',
},
},
},
{
target: 'params.values',
effects: ['onFieldValueChange'],
fulfill: {
state: {
value: '{{Object.create({})}}',
},
},
},
],
},
params: {
type: 'object',
properties: {

View File

@ -1,5 +1,5 @@
import { useForm } from '@formily/react';
import { css, useCollectionFilterOptions } from '@nocobase/client';
import { css, parseCollectionName, useCollectionFilterOptions } from '@nocobase/client';
import { NAMESPACE } from '../locale';
export const collection = {
@ -8,15 +8,13 @@ export const collection = {
required: true,
'x-reactions': [],
'x-decorator': 'FormItem',
'x-component': 'CollectionSelect',
'x-component-props': {
className: 'auto-width',
},
'x-component': 'DataSourceCollectionCascader',
};
export const values = {
type: 'object',
title: '{{t("Fields values")}}',
description: `{{t("Unassigned fields will be set to default values, and those without default values will be set to null.", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-decorator-props': {
labelAlign: 'left',
@ -25,7 +23,6 @@ export const values = {
`,
},
'x-component': 'CollectionFieldset',
description: `{{t("Unassigned fields will be set to default values, and those without default values will be set to null.", { ns: "${NAMESPACE}" })}}`,
};
export const filter = {
@ -36,7 +33,8 @@ export const filter = {
'x-component-props': {
useProps() {
const { values } = useForm();
const options = useCollectionFilterOptions(values?.collection);
const [dataSourceName, collectionName] = parseCollectionName(values?.collection);
const options = useCollectionFilterOptions(collectionName, dataSourceName);
return {
options,
className: css`

View File

@ -1,12 +1,14 @@
import React from 'react';
import { ISchema } from '@formily/react';
import { Link } from 'react-router-dom';
import { useActionContext, useRecord, useResourceActionContext, useResourceContext } from '@nocobase/client';
import { ExecutionStatusOptions } from '../constants';
import { NAMESPACE } from '../locale';
import { useTranslation } from 'react-i18next';
import { message } from 'antd';
import { getWorkflowDetailPath } from '../constant';
import { useActionContext, useRecord, useResourceActionContext, useResourceContext } from '@nocobase/client';
import { ExecutionStatusOptions } from '../constants';
import { NAMESPACE } from '../locale';
import { getWorkflowDetailPath } from '../utils';
export const executionCollection = {
name: 'execution-executions',

View File

@ -9,6 +9,7 @@ import { FieldsSelect } from '../components/FieldsSelect';
import { NAMESPACE, lang } from '../locale';
import { appends, collection, filter } from '../schemas/collection';
import { getCollectionFieldOptions } from '../variable';
import { useWorkflowAnyExecuted } from '../hooks';
import { Trigger } from '.';
const COLLECTION_TRIGGER_MODE = {
@ -31,6 +32,12 @@ export default class extends Trigger {
fieldset = {
collection: {
...collection,
'x-disabled': '{{ useWorkflowAnyExecuted() }}',
'x-component-props': {
dataSourceFilter(item) {
return item.options.key === 'main' || item.options.isDBInstance;
},
},
['x-reactions']: [
...collection['x-reactions'],
{
@ -147,6 +154,7 @@ export default class extends Trigger {
};
scope = {
useCollectionDataSource,
useWorkflowAnyExecuted,
};
components = {
FieldsSelect,
@ -192,7 +200,7 @@ export default class extends Trigger {
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
Component: CollectionBlockInitializer,
collection: config.collection,
dataSource: '{{$context.data}}',
dataPath: '$context.data',
};
}
}

View File

@ -10,7 +10,8 @@ function dateFieldFilter(field) {
return !field.hidden && (field.uiSchema ? field.type === 'date' : false);
}
export function OnField({ value, onChange }) {
export function OnField({ value: propsValue, onChange }) {
const value = propsValue ?? {};
const { t } = useTranslation();
const [dir, setDir] = useState(value.offset ? value.offset / Math.abs(value.offset) : 0);

View File

@ -68,14 +68,21 @@ const ModeFieldsets = {
[SCHEDULE_MODE.DATE_FIELD]: {
collection: {
...collection,
'x-component-props': {
dataSourceFilter(item) {
return item.options.key === 'main' || item.options.isDBInstance;
},
},
'x-reactions': [
...collection['x-reactions'],
{
// only full path works
target: 'startsOn',
effects: ['onFieldValueChange'],
fulfill: {
state: {
visible: '{{!!$self.value}}',
value: '{{Object.create({})}}',
},
},
},

View File

@ -79,7 +79,7 @@ export default class extends Trigger {
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
Component: CollectionBlockInitializer,
collection: config.collection,
dataSource: '{{$context.data}}',
dataPath: '$context.data',
};
}
}

View File

@ -43,3 +43,11 @@ export function traverseSchema(schema, fn) {
});
}
}
export function getWorkflowDetailPath(id: string | number) {
return `/admin/workflow/workflows/${id}`;
}
export function getWorkflowExecutionsPath(id: string | number) {
return `/admin/workflow/executions/${id}`;
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Variable, useCompile, usePlugin } from '@nocobase/client';
import { Variable, parseCollectionName, useCompile, usePlugin } from '@nocobase/client';
import { useFlowContext } from './FlowContext';
import { NAMESPACE, lang } from './locale';
@ -250,22 +250,23 @@ export function useWorkflowVariableOptions(options: UseVariableOptions = {}) {
}
function getNormalizedFields(collectionName, { compile, getCollectionFields }) {
const fields = getCollectionFields(collectionName);
const [dataSourceName, collection] = parseCollectionName(collectionName);
const fields = getCollectionFields(collection, dataSourceName);
const foreignKeyFields: any[] = [];
const otherFields: any[] = [];
const result: any[] = [];
fields.forEach((field) => {
if (field.isForeignKey) {
foreignKeyFields.push(field);
} else {
otherFields.push(field);
result.push(field);
}
});
for (let i = otherFields.length - 1; i >= 0; i--) {
const field = otherFields[i];
for (let i = result.length - 1; i >= 0; i--) {
const field = result[i];
if (field.type === 'belongsTo') {
const foreignKeyField = foreignKeyFields.find((f) => f.name === field.foreignKey);
if (foreignKeyField) {
otherFields.splice(i, 0, {
result.splice(i, 0, {
...field,
...foreignKeyField,
uiSchema: {
@ -274,7 +275,7 @@ function getNormalizedFields(collectionName, { compile, getCollectionFields }) {
},
});
} else {
otherFields.splice(i, 0, {
result.splice(i, 0, {
...field,
name: field.foreignKey,
type: 'bigInt',
@ -288,8 +289,8 @@ function getNormalizedFields(collectionName, { compile, getCollectionFields }) {
}
} else if (field.type === 'context' && field.collectionName === 'users') {
const belongsToField =
otherFields.find((f) => f.type === 'belongsTo' && f.target === 'users' && f.foreignKey === field.name) ?? {};
otherFields.splice(i, 0, {
result.find((f) => f.type === 'belongsTo' && f.target === 'users' && f.foreignKey === field.name) ?? {};
result.splice(i, 0, {
...field,
type: field.dataType,
interface: belongsToField.interface,
@ -301,13 +302,15 @@ function getNormalizedFields(collectionName, { compile, getCollectionFields }) {
}
}
return otherFields.filter((field) => field.interface && !field.hidden);
return result.filter((field) => field.interface && !field.hidden);
}
function loadChildren(option) {
const appends = getNextAppends(option.field, option.appends);
const result = getCollectionFieldOptions({
collection: option.field.target,
collection: `${
option.field.dataSourceKey && option.field.dataSourceKey !== 'main' ? `${option.field.dataSourceKey}:` : ''
}${option.field.target}`,
types: option.types,
appends,
depth: option.depth - 1,
@ -336,8 +339,7 @@ export function getCollectionFieldOptions(options): VariableOption[] {
getCollectionFields,
fieldNames = defaultFieldNames,
} = options;
const normalizedFields = getNormalizedFields(collection, { compile, getCollectionFields });
const computedFields = fields ?? normalizedFields;
const computedFields = fields ?? getNormalizedFields(collection, { compile, getCollectionFields });
const boundLoadChildren = loadChildren.bind({ compile, getCollectionFields, fieldNames });
const result: VariableOption[] = filterTypedFields({

View File

@ -126,4 +126,35 @@ describe('workflow > instructions > create', () => {
expect(job.result.posts[0].id).toBe(post.id);
});
});
describe('multiple data source', () => {
it('create one', async () => {
const n1 = await workflow.createNode({
type: 'create',
config: {
collection: 'another:posts',
params: {
values: {
title: '{{$context.data.title}}',
published: true,
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs();
expect(job.result.title).toBe(post.title);
const AnotherPostRepo = app.dataSourceManager.dataSources.get('another').collectionManager.getRepository('posts');
const p2s = await AnotherPostRepo.find();
expect(p2s.length).toBe(1);
expect(p2s[0].title).toBe(post.title);
expect(p2s[0].published).toBe(true);
});
});
});

View File

@ -55,4 +55,37 @@ describe('workflow > instructions > destroy', () => {
expect(count).toBe(0);
});
});
describe('multiple data source', () => {
it('destroy one', async () => {
const AnotherPostRepo = app.dataSourceManager.dataSources.get('another').collectionManager.getRepository('posts');
const post = await AnotherPostRepo.create({ values: { title: 't1' } });
const p1s = await AnotherPostRepo.find();
expect(p1s.length).toBe(1);
const n1 = await workflow.createNode({
type: 'destroy',
config: {
collection: 'another:posts',
params: {
filter: {
// @ts-ignore
id: post.id,
},
},
},
});
await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs();
expect(job.result).toBe(1);
const p2s = await AnotherPostRepo.find();
expect(p2s.length).toBe(0);
});
});
});

View File

@ -381,4 +381,35 @@ describe('workflow > instructions > query', () => {
expect(job.result).toMatchObject([]);
});
});
describe('multiple data source', () => {
it('query on another data source', async () => {
const AnotherPostRepo = app.dataSourceManager.dataSources.get('another').collectionManager.getRepository('posts');
const post = await AnotherPostRepo.create({ values: { title: 't1' } });
const p1s = await AnotherPostRepo.find();
expect(p1s.length).toBe(1);
const n1 = await workflow.createNode({
type: 'query',
config: {
collection: 'another:posts',
params: {
filter: {
// @ts-ignore
id: post.id,
},
},
},
});
await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.result.title).toBe('t1');
});
});
});

View File

@ -1,11 +1,13 @@
import { Application } from '@nocobase/server';
import Database from '@nocobase/database';
import { MockDatabase } from '@nocobase/database';
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
import { MockServer } from '@nocobase/test';
import type { WorkflowModel as WorkflowModelType } from '../../types';
import { EXECUTION_STATUS } from '../../constants';
describe('workflow > instructions > update', () => {
let app: Application;
let db: Database;
let app: MockServer;
let db: MockDatabase;
let PostRepo;
let WorkflowModel;
let workflow: WorkflowModelType;
@ -60,6 +62,8 @@ describe('workflow > instructions > update', () => {
});
it('params: from job of node', async () => {
const p1 = await PostRepo.create({ values: { title: 't1' }, hooks: false });
const n1 = await workflow.createNode({
type: 'query',
config: {
@ -98,6 +102,9 @@ describe('workflow > instructions > update', () => {
// should get from db
const post = await PostRepo.findById(id);
expect(post.title).toBe('changed');
await p1.reload();
expect(p1.title).toBe('t1');
});
});
@ -186,4 +193,40 @@ describe('workflow > instructions > update', () => {
expect(w2Exes.length).toBe(1);
});
});
describe('multiple data source', () => {
it('update one', async () => {
const AnotherPostRepo = app.dataSourceManager.dataSources.get('another').collectionManager.getRepository('posts');
const post = await AnotherPostRepo.create({ values: { title: 't1' } });
const p1s = await AnotherPostRepo.find();
expect(p1s.length).toBe(1);
const n1 = await workflow.createNode({
type: 'update',
config: {
collection: 'another:posts',
params: {
filter: {
// @ts-ignore
id: post.id,
},
values: {
title: 't2',
},
},
},
});
await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const p2s = await AnotherPostRepo.find();
expect(p2s.length).toBe(1);
expect(p2s[0].title).toBe('t2');
});
});
});

View File

@ -1,11 +1,12 @@
import Database from '@nocobase/database';
import { MockDatabase } from '@nocobase/database';
import { MockServer } from '@nocobase/test';
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
import { Application } from '@nocobase/server';
import { EXECUTION_STATUS } from '../../constants';
describe('workflow > triggers > collection', () => {
let app: Application;
let db: Database;
let app: MockServer;
let db: MockDatabase;
let CategoryRepo;
let PostRepo;
let CommentRepo;
@ -14,7 +15,7 @@ describe('workflow > triggers > collection', () => {
beforeEach(async () => {
app = await getApp({
plugins: ['error-handler', 'collection-manager'],
plugins: ['error-handler', 'collection-manager', 'users', 'auth'],
});
db = app.db;
@ -28,6 +29,16 @@ describe('workflow > triggers > collection', () => {
afterEach(() => app.destroy());
describe('toggle', () => {
it('create without config should ok', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {},
});
expect(workflow).toBeDefined();
});
it('when collection change', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
@ -507,4 +518,97 @@ describe('workflow > triggers > collection', () => {
expect(executions[0].status).toBe(EXECUTION_STATUS.RESOLVED);
});
});
describe('multiple data source', () => {
let anotherDB: MockDatabase;
beforeEach(async () => {
// @ts-ignore
anotherDB = app.dataSourceManager.dataSources.get('another').collectionManager.db;
});
it('collection trigger on another', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {
mode: 1,
collection: 'another:posts',
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const e1s = await workflow.getExecutions();
expect(e1s.length).toBe(0);
const AnotherPostRepo = anotherDB.getRepository('posts');
const anotherPost = await AnotherPostRepo.create({ values: { title: 't2' } });
await sleep(500);
const e2s = await workflow.getExecutions();
expect(e2s.length).toBe(1);
expect(e2s[0].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e2s[0].context.data.title).toBe('t2');
const p1s = await PostRepo.find();
expect(p1s.length).toBe(1);
const p2s = await AnotherPostRepo.find();
expect(p2s.length).toBe(1);
});
it('revisiond workflow should only trigger on enabled version', async () => {
const w1 = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {
mode: 1,
collection: 'another:posts',
},
});
const AnotherPostRepo = anotherDB.getRepository('posts');
const p1 = await AnotherPostRepo.create({ values: { title: 't2' } });
await sleep(500);
const e1s = await w1.getExecutions();
expect(e1s.length).toBe(1);
const user = await app.db.getRepository('users').findOne();
const agent = app.agent().login(user);
const { body } = await agent.resource('workflows').revision({
filterByTk: w1.id,
filter: {
key: w1.key,
},
});
const w2 = await WorkflowModel.findByPk(body.data.id);
await w2.update({ enabled: true });
expect(w2.enabled).toBe(true);
console.log('w2', w2.toJSON());
await w1.reload();
expect(w1.enabled).toBe(false);
const p2 = await AnotherPostRepo.create({ values: { title: 't2' } });
await sleep(500);
const e2s = await w1.getExecutions({ order: [['createdAt', 'ASC']] });
expect(e2s.length).toBe(1);
const ExecutionRepo = app.db.getRepository('executions');
const e3s = await ExecutionRepo.find({
filter: {
workflowId: w2.id,
},
});
expect(e3s.length).toBe(1);
});
});
});

View File

@ -1,3 +1,5 @@
import { parseCollectionName } from '@nocobase/data-source-manager';
import { JOB_STATUS } from '../constants';
import { toJSON } from '../utils';
import type Processor from '../Processor';
@ -7,8 +9,11 @@ import { Instruction } from '.';
export class CreateInstruction extends Instruction {
async run(node: FlowNodeModel, input, processor: Processor) {
const { collection, params: { appends = [], ...params } = {} } = node.config;
const [dataSourceName, collectionName] = parseCollectionName(collection);
const { repository, model } = (<typeof FlowNodeModel>node.constructor).database.getCollection(collection);
const { repository, model } = this.workflow.app.dataSourceManager.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
const options = processor.getParsedValue(params, node.id);
const created = await repository.create({

View File

@ -1,3 +1,5 @@
import { parseCollectionName } from '@nocobase/data-source-manager';
import { Instruction } from '.';
import type Processor from '../Processor';
import { JOB_STATUS } from '../constants';
@ -7,9 +9,13 @@ export class DestroyInstruction extends Instruction {
async run(node: FlowNodeModel, input, processor: Processor) {
const { collection, params = {} } = node.config;
const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection);
const [dataSourceName, collectionName] = parseCollectionName(collection);
const { repository } = this.workflow.app.dataSourceManager.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
const options = processor.getParsedValue(params, node.id);
const result = await repo.destroy({
const result = await repository.destroy({
...options,
context: {
stack: Array.from(new Set((processor.execution.context.stack ?? []).concat(processor.execution.id))),

View File

@ -1,4 +1,5 @@
import { DEFAULT_PAGE, DEFAULT_PER_PAGE, utils } from '@nocobase/actions';
import { parseCollectionName } from '@nocobase/data-source-manager';
import type Processor from '../Processor';
import { JOB_STATUS } from '../constants';
@ -10,7 +11,11 @@ export class QueryInstruction extends Instruction {
async run(node: FlowNodeModel, input, processor: Processor) {
const { collection, multiple, params = {}, failOnEmpty = false } = node.config;
const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection);
const [dataSourceName, collectionName] = parseCollectionName(collection);
const { repository } = this.workflow.app.dataSourceManager.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
const {
page = DEFAULT_PAGE,
pageSize = DEFAULT_PER_PAGE,
@ -26,7 +31,7 @@ export class QueryInstruction extends Instruction {
}, new Set()),
)
: options.appends;
const result = await (multiple ? repo.find : repo.findOne).call(repo, {
const result = await (multiple ? repository.find : repository.findOne).call(repository, {
...options,
...utils.pageArgsToLimitArgs(page, pageSize),
sort: sort

View File

@ -1,3 +1,5 @@
import { parseCollectionName } from '@nocobase/data-source-manager';
import type Processor from '../Processor';
import { JOB_STATUS } from '../constants';
import type { FlowNodeModel } from '../types';
@ -7,9 +9,13 @@ export class UpdateInstruction extends Instruction {
async run(node: FlowNodeModel, input, processor: Processor) {
const { collection, params = {} } = node.config;
const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection);
const [dataSourceName, collectionName] = parseCollectionName(collection);
const { repository } = this.workflow.app.dataSourceManager.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
const options = processor.getParsedValue(params, node.id);
const result = await repo.update({
const result = await repository.update({
...options,
context: {
stack: Array.from(new Set((processor.execution.context.stack ?? []).concat(processor.execution.id))),

View File

@ -1,7 +1,8 @@
import { Collection, Model, Transactionable } from '@nocobase/database';
import { Model, Transactionable } from '@nocobase/database';
import Trigger from '.';
import { toJSON } from '../utils';
import type { WorkflowModel } from '../types';
import { parseCollectionName, ICollection } from '@nocobase/data-source-manager';
export interface CollectionChangeTriggerConfig {
collection: string;
@ -25,18 +26,21 @@ function getHookId(workflow, type) {
return `${type}#${workflow.id}`;
}
function getFieldRawName(collection: Collection, name: string) {
function getFieldRawName(collection: ICollection, name: string) {
const field = collection.getField(name);
if (field && field.type === 'belongsTo') {
return field.foreignKey;
if (field && field.options.type === 'belongsTo') {
return field.options.foreignKey;
}
return name;
}
// async function, should return promise
async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: Model, options) {
const { collection: collectionName, condition, changed, mode, appends } = workflow.config;
const collection = (<typeof Model>data.constructor).database.getCollection(collectionName);
const { condition, changed, mode, appends } = workflow.config;
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
const collection = this.workflow.app.dataSourceManager?.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
const { transaction, context } = options;
const { repository, model } = collection;
@ -45,7 +49,9 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
changed &&
changed.length &&
changed
.filter((name) => !['linkTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(collection.getField(name).type))
.filter(
(name) => !['linkTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(collection.getField(name).options.type),
)
.every((name) => !data.changedWithAssociations(getFieldRawName(collection, name)))
) {
return;
@ -102,16 +108,20 @@ export default class CollectionTrigger extends Trigger {
events = new Map();
on(workflow: WorkflowModel) {
const { db } = this.workflow.app;
const { collection, mode } = workflow.config;
const Collection = db.getCollection(collection);
if (!Collection) {
if (!collection) {
return;
}
const [dataSourceName, collectionName] = parseCollectionName(collection);
// @ts-ignore
const { db } = this.workflow.app.dataSourceManager?.dataSources.get(dataSourceName)?.collectionManager ?? {};
if (!db || !db.getCollection(collectionName)) {
return;
}
for (const [key, type] of MODE_BITMAP_EVENTS.entries()) {
const event = `${collection}.${type}`;
const name = getHookId(workflow, event);
const event = `${collectionName}.${type}`;
const name = getHookId(workflow, `${collection}.${type}`);
if (mode & key) {
if (!this.events.has(name)) {
const listener = handler.bind(this, workflow);
@ -129,19 +139,23 @@ export default class CollectionTrigger extends Trigger {
}
off(workflow: WorkflowModel) {
const { db } = this.workflow.app;
const { collection, mode } = workflow.config;
const Collection = db.getCollection(collection);
if (!Collection) {
if (!collection) {
return;
}
const [dataSourceName, collectionName] = parseCollectionName(collection);
// @ts-ignore
const { db } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName)?.collectionManager ?? {};
if (!db || !db.getCollection(collectionName)) {
return;
}
for (const [key, type] of MODE_BITMAP_EVENTS.entries()) {
const event = `${collection}.${type}`;
const name = getHookId(workflow, event);
const name = getHookId(workflow, `${collection}.${type}`);
if (mode & key) {
const listener = this.events.get(name);
if (listener) {
db.off(event, listener);
db.off(`${collectionName}.${type}`, listener);
this.events.delete(name);
}
}

View File

@ -3,6 +3,7 @@ import parser from 'cron-parser';
import type Plugin from '../../Plugin';
import type { WorkflowModel } from '../../types';
import { parseDateWithoutMs, SCHEDULE_MODE } from './utils';
import { parseCollectionName } from '@nocobase/data-source-manager';
export type ScheduleOnField = {
field: string;
@ -334,7 +335,9 @@ export default class ScheduleTrigger {
this.inspect([workflow]);
const { collection } = workflow.config;
const event = `${collection}.afterSaveWithAssociations`;
const [dataSourceName, collectionName] = parseCollectionName(collection);
const event = `${collectionName}.afterSaveWithAssociations`;
const eventKey = `${collection}.afterSaveWithAssociations`;
const name = getHookId(workflow, event);
if (this.events.has(name)) {
return;
@ -346,7 +349,8 @@ export default class ScheduleTrigger {
};
this.events.set(name, listener);
this.workflow.app.db.on(event, listener);
// @ts-ignore
this.workflow.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager.db.on(event, listener);
}
off(workflow: WorkflowModel) {
@ -358,12 +362,16 @@ export default class ScheduleTrigger {
}
const { collection } = workflow.config;
const event = `${collection}.afterSaveWithAssociations`;
const [dataSourceName, collectionName] = parseCollectionName(collection);
const event = `${collectionName}.afterSaveWithAssociations`;
const eventKey = `${collection}.afterSaveWithAssociations`;
const name = getHookId(workflow, event);
if (this.events.has(name)) {
if (this.events.has(eventKey)) {
const listener = this.events.get(name);
this.events.delete(name);
this.workflow.app.db.off(event, listener);
// @ts-ignore
const { db } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager;
db.off(event, listener);
this.events.delete(eventKey);
}
}
}