feat(plugin-workflow): add form trigger type (#2347)

* feat(plugin-workflow): add form trigger

* test(plugin-workflow): add test cases

* fix(client): fix component warning

* fix(plugin-workflow): fix context data for create and update

* fix(plugin-workflow): allow to select any form type workflow in configuration

* fix(client): fix tree component value

* fix(client): fix value render in component

* fix(plugin-workflow): fix context load

* fix(client): fix type

* fix(client): fix type

* fix(plugin-workflow): fix params

* fix(plugin-workflow): fix required

* fix(plugin): fix context not matching collection error

* fix(plugin-workflow): fix test cases

* refactor(plugin-workflow): change trigger workflow action config to cascaded

* fix(plugin-workflow): remove useless locale

* fix(client): adjust locale

* fix(client): remove useless locale
This commit is contained in:
Junyi 2023-08-09 11:12:57 +07:00 committed by GitHub
parent ceea649276
commit 86e672e9bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1171 additions and 189 deletions

View File

@ -50,8 +50,7 @@
"prettier --write" "prettier --write"
], ],
"*.ts?(x)": [ "*.ts?(x)": [
"eslint --fix", "eslint --fix"
"prettier --parser=typescript --write"
] ]
}, },
"devDependencies": { "devDependencies": {

View File

@ -56,6 +56,8 @@ const useResource = (props: UseResourceProps) => {
const association = useAssociation(props); const association = useAssociation(props);
const sourceId = useSourceId?.(); const sourceId = useSourceId?.();
const field = useField<Field>(); const field = useField<Field>();
const withoutTableFieldResource = useContext(WithoutTableFieldResource);
const __parent = useContext(BlockRequestContext);
if (block === 'TableField') { if (block === 'TableField') {
const options = { const options = {
field, field,
@ -68,8 +70,6 @@ const useResource = (props: UseResourceProps) => {
return new TableFieldResource(options); return new TableFieldResource(options);
} }
const withoutTableFieldResource = useContext(WithoutTableFieldResource);
const __parent = useContext(BlockRequestContext);
if ( if (
!withoutTableFieldResource && !withoutTableFieldResource &&
__parent?.block === 'TableField' && __parent?.block === 'TableField' &&

View File

@ -1,5 +1,5 @@
import { SchemaExpressionScopeContext, useField, useFieldSchema, useForm } from '@formily/react'; import { SchemaExpressionScopeContext, useField, useFieldSchema, useForm } from '@formily/react';
import { parse } from '@nocobase/utils/client'; import { isURL, parse } from '@nocobase/utils/client';
import { App, message } from 'antd'; import { App, message } from 'antd';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import get from 'lodash/get'; import get from 'lodash/get';
@ -38,18 +38,6 @@ function renderTemplate(str: string, data: any) {
}); });
} }
function isURL(string) {
let url;
try {
url = new URL(string);
} catch (e) {
return false;
}
return url.protocol === 'http:' || url.protocol === 'https:';
}
const filterValue = (value) => { const filterValue = (value) => {
if (typeof value !== 'object') { if (typeof value !== 'object') {
return value; return value;
@ -84,7 +72,7 @@ function getFormValues(filterByTk, field, form, fieldNames, getField, resource)
return omit({ ...form.values }, keys); return omit({ ...form.values }, keys);
} }
} }
console.log('form.values', form.values);
return form.values; return form.values;
const values = {}; const values = {};
for (const key in form.values) { for (const key in form.values) {
@ -151,8 +139,13 @@ export const useCreateActionProps = () => {
return { return {
async onClick() { async onClick() {
const fieldNames = fields.map((field) => field.name); const fieldNames = fields.map((field) => field.name);
const { assignedValues: originalAssignedValues = {}, onSuccess, overwriteValues, skipValidator } = const {
actionSchema?.['x-action-settings'] ?? {}; assignedValues: originalAssignedValues = {},
onSuccess,
overwriteValues,
skipValidator,
triggerWorkflows,
} = actionSchema?.['x-action-settings'] ?? {};
const addChild = fieldSchema?.['x-component-props']?.addChild; const addChild = fieldSchema?.['x-component-props']?.addChild;
const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser }); const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser });
if (!skipValidator) { if (!skipValidator) {
@ -175,6 +168,10 @@ export const useCreateActionProps = () => {
...assignedValues, ...assignedValues,
}, },
filterKeys: filterKeys, filterKeys: filterKeys,
// TODO(refactor): should change to inject by plugin
triggerWorkflows: triggerWorkflows?.length
? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',')
: undefined,
}); });
actionField.data.loading = false; actionField.data.loading = false;
actionField.data.data = data; actionField.data.data = data;
@ -718,8 +715,13 @@ export const useUpdateActionProps = () => {
return { return {
async onClick() { async onClick() {
const { assignedValues: originalAssignedValues = {}, onSuccess, overwriteValues, skipValidator } = const {
actionSchema?.['x-action-settings'] ?? {}; assignedValues: originalAssignedValues = {},
onSuccess,
overwriteValues,
skipValidator,
triggerWorkflows,
} = actionSchema?.['x-action-settings'] ?? {};
const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser }); const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser });
if (!skipValidator) { if (!skipValidator) {
await form.submit(); await form.submit();
@ -738,6 +740,10 @@ export const useUpdateActionProps = () => {
}, },
...data, ...data,
updateAssociationValues, updateAssociationValues,
// TODO(refactor): should change to inject by plugin
triggerWorkflows: triggerWorkflows?.length
? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',')
: undefined,
}); });
actionField.data.loading = false; actionField.data.loading = false;
__parent?.service?.refresh?.(); __parent?.service?.refresh?.();

View File

@ -797,6 +797,7 @@ export default {
"Date display format":"日期显示格式", "Date display format":"日期显示格式",
"Assign data scope for the template":"为模板指定数据范围", "Assign data scope for the template":"为模板指定数据范围",
"Table selected records":"表格中选中的记录", "Table selected records":"表格中选中的记录",
"Tag":"标签", "Tag":"标签",
"Tag color field":"标签颜色字段", "Tag color field":"标签颜色字段",
"Sync successfully":"同步成功", "Sync successfully":"同步成功",

View File

@ -1,10 +1,11 @@
import { connect, ISchema, mapProps, useField, useFieldSchema, useForm } from '@formily/react'; import { ArrayTable } from '@formily/antd-v5';
import { connect, ISchema, mapProps, useField, useFieldSchema, useForm, useFormEffects } from '@formily/react';
import { isValid, uid } from '@formily/shared'; import { isValid, uid } from '@formily/shared';
import { Tree as AntdTree } from 'antd'; import { Alert, Tree as AntdTree } from 'antd';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDesignable } from '../..'; import { RemoteSelect, useCompile, useDesignable } from '../..';
import { useCollection, useCollectionManager } from '../../../collection-manager'; import { useCollection, useCollectionManager } from '../../../collection-manager';
import { OpenModeSchemaItems } from '../../../schema-items'; import { OpenModeSchemaItems } from '../../../schema-items';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
@ -13,6 +14,7 @@ import { useLinkageAction } from './hooks';
import { requestSettingsSchema } from './utils'; import { requestSettingsSchema } from './utils';
import { useRecord } from '../../../record-provider'; import { useRecord } from '../../../record-provider';
import { useSyncFromForm } from '../../../schema-settings/DataTemplates/utils'; import { useSyncFromForm } from '../../../schema-settings/DataTemplates/utils';
import { onFieldValueChange } from '@formily/core';
const Tree = connect( const Tree = connect(
AntdTree, AntdTree,
@ -32,30 +34,15 @@ const Tree = connect(
); );
const MenuGroup = (props) => { const MenuGroup = (props) => {
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const actionType = fieldSchema['x-action'] || '';
const { t } = useTranslation(); const { t } = useTranslation();
const actionTitles = { const compile = useCompile();
'customize:popup': t('Popup'), const actionTitle = fieldSchema.title ? compile(fieldSchema.title) : '';
'customize:update': t('Update record'), const actionType = fieldSchema['x-action'] ?? '';
'customize:save': t('Save record'), if (!actionType.startsWith('customize:') || !actionTitle) {
'customize:table:request': t('Custom request'), return props.children;
'customize:form:request': t('Custom request'),
};
if (
![
'customize:popup',
'customize:update',
'customize:save',
'customize:table:request',
'customize:form:request',
].includes(actionType)
) {
return <>{props.children}</>;
} }
return ( return (
<SchemaSettings.ItemGroup title={`${t('Customize')} > ${actionTitles[actionType]}`}> <SchemaSettings.ItemGroup title={`${t('Customize')} > ${actionTitle}`}>{props.children}</SchemaSettings.ItemGroup>
{props.children}
</SchemaSettings.ItemGroup>
); );
}; };
@ -568,30 +555,15 @@ function AfterSuccess() {
const { dn } = useDesignable(); const { dn } = useDesignable();
const { t } = useTranslation(); const { t } = useTranslation();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const actionType = fieldSchema['x-action'] ?? '';
return ( return (
<SchemaSettings.ModalItem <SchemaSettings.ModalItem
title={ title={t('After successful submission')}
{
'customize:save': t('After successful save'),
'customize:update': t('After successful update'),
'customize:table:request': t('After successful request'),
'customize:form:request': t('After successful request'),
'customize:bulkUpdate': t('After successful bulk update'),
}[actionType]
}
initialValues={fieldSchema?.['x-action-settings']?.['onSuccess']} initialValues={fieldSchema?.['x-action-settings']?.['onSuccess']}
schema={ schema={
{ {
type: 'object', type: 'object',
title: { title: t('After successful submission'),
'customize:save': t('After successful save'),
'customize:update': t('After successful update'),
'customize:table:request': t('After successful request'),
'customize:form:request': t('After successful request'),
'customize:bulkUpdate': t('After successful bulk update'),
}[actionType],
properties: { properties: {
successMessage: { successMessage: {
title: t('Popup message'), title: t('Popup message'),
@ -673,6 +645,184 @@ function RemoveButton() {
); );
} }
function FormWorkflowSelect(props) {
const index = ArrayTable.useIndex();
const { setValuesIn } = useForm();
const baseCollection = useCollection();
const { getCollection } = useCollectionManager();
const [workflowCollection, setWorkflowCollection] = useState(baseCollection.name);
useFormEffects(() => {
onFieldValueChange(`group[${index}].context`, (field) => {
let collection = baseCollection;
if (field.value) {
const paths = field.value.split('.');
for (let i = 0; i < paths.length && collection; i++) {
const path = paths[i];
const associationField = collection.fields.find((f) => f.name === path);
if (associationField) {
collection = getCollection(associationField.target);
}
}
}
setWorkflowCollection(collection.name);
setValuesIn(`group[${index}].workflowKey`, null);
});
});
return (
<RemoteSelect
{...props}
service={{
resource: 'workflows',
action: 'list',
params: {
filter: {
type: 'form',
enabled: true,
'config.collection': workflowCollection,
},
},
}}
/>
);
}
function WorkflowConfig() {
const { dn } = useDesignable();
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const { name: collection } = useCollection();
const description = {
submit: t('Workflow will be triggered after submitting succeeded.', { ns: 'workflow' }),
'customize:save': t('Workflow will be triggered after saving succeeded.', { ns: 'workflow' }),
'customize:triggerWorkflows': t('Workflow will be triggered directly once the button clicked.', { ns: 'workflow' }),
}[fieldSchema?.['x-action']];
return (
<SchemaSettings.ModalItem
title={t('Bind workflows', { ns: 'workflow' })}
scope={{
fieldFilter(field) {
return ['belongsTo', 'hasOne'].includes(field.type);
},
}}
components={{
Alert,
ArrayTable,
FormWorkflowSelect,
}}
schema={
{
type: 'void',
title: t('Bind workflows', { ns: 'workflow' }),
properties: {
description: description && {
type: 'void',
'x-component': 'Alert',
'x-component-props': {
message: description,
style: {
marginBottom: '1em',
},
},
},
group: {
type: 'array',
'x-component': 'ArrayTable',
'x-decorator': 'FormItem',
items: {
type: 'object',
properties: {
context: {
type: 'void',
'x-component': 'ArrayTable.Column',
'x-component-props': {
title: t('Trigger data context', { ns: 'workflow' }),
width: 200,
},
properties: {
context: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'AppendsTreeSelect',
'x-component-props': {
placeholder: t('Select context', { ns: 'workflow' }),
popupMatchSelectWidth: false,
collection,
filter: '{{ fieldFilter }}',
rootOption: {
label: t('Full form data', { ns: 'workflow' }),
value: '',
},
allowClear: false,
},
default: '',
},
},
},
workflowKey: {
type: 'void',
'x-component': 'ArrayTable.Column',
'x-component-props': {
title: t('Workflow', { ns: 'workflow' }),
},
properties: {
workflowKey: {
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'FormWorkflowSelect',
'x-component-props': {
placeholder: t('Select workflow', { ns: 'workflow' }),
fieldNames: {
label: 'title',
value: 'key',
},
},
required: true,
},
},
},
operations: {
type: 'void',
'x-component': 'ArrayTable.Column',
'x-component-props': {
width: 32,
},
properties: {
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayTable.Remove',
},
},
},
},
},
properties: {
add: {
type: 'void',
title: t('Add workflow', { ns: 'workflow' }),
'x-component': 'ArrayTable.Addition',
},
},
},
},
} as ISchema
}
initialValues={{ group: fieldSchema?.['x-action-settings']?.triggerWorkflows }}
onSubmit={({ group }) => {
fieldSchema['x-action-settings']['triggerWorkflows'] = group;
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
'x-action-settings': fieldSchema['x-action-settings'],
},
});
}}
/>
);
}
export const ActionDesigner = (props) => { export const ActionDesigner = (props) => {
const { modalTip, linkageAction, ...restProps } = props; const { modalTip, linkageAction, ...restProps } = props;
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
@ -702,6 +852,7 @@ export const ActionDesigner = (props) => {
{isValid(fieldSchema?.['x-action-settings']?.requestSettings) && <RequestSettings />} {isValid(fieldSchema?.['x-action-settings']?.requestSettings) && <RequestSettings />}
{isValid(fieldSchema?.['x-action-settings']?.skipValidator) && <SkipValidation />} {isValid(fieldSchema?.['x-action-settings']?.skipValidator) && <SkipValidation />}
{isValid(fieldSchema?.['x-action-settings']?.['onSuccess']) && <AfterSuccess />} {isValid(fieldSchema?.['x-action-settings']?.['onSuccess']) && <AfterSuccess />}
{isValid(fieldSchema?.['x-action-settings']?.triggerWorkflows) && <WorkflowConfig />}
{isChildCollectionAction && <SchemaSettings.EnableChildCollections collectionName={name} />} {isChildCollectionAction && <SchemaSettings.EnableChildCollections collectionName={name} />}

View File

@ -35,11 +35,11 @@ export const Action: ComposedAction = observer(
className, className,
icon, icon,
title, title,
onClick,
...others ...others
} = props; } = useProps(props);
const { wrapSSR, componentCls, hashId } = useStyles(); const { wrapSSR, componentCls, hashId } = useStyles();
const { t } = useTranslation(); const { t } = useTranslation();
const { onClick } = useProps(props);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [formValueChanged, setFormValueChanged] = useState(false); const [formValueChanged, setFormValueChanged] = useState(false);
const Designer = useDesigner(); const Designer = useDesigner();

View File

@ -1,14 +1,20 @@
import { CollectionFieldOptions, useCollectionManager, useCompile } from '../../..';
import { Tag, TreeSelect } from 'antd'; import { Tag, TreeSelect } from 'antd';
import type { DefaultOptionType } from 'rc-tree-select/es/TreeSelect'; import type { DefaultOptionType } from 'rc-tree-select/es/TreeSelect';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CollectionFieldOptions, useCollectionManager, useCompile } from '../../..';
export type AppendsTreeSelectProps = { export type AppendsTreeSelectProps = {
value: string[]; value: string[] | string;
onChange: (value: string[]) => void; onChange: (value: string[] | string) => void;
multiple?: boolean;
filter?(field): boolean;
collection?: string; collection?: string;
useCollection?(props: Pick<AppendsTreeSelectProps, 'collection'>): string; useCollection?(props: Pick<AppendsTreeSelectProps, 'collection'>): string;
rootOption?: {
label: string;
value: string;
};
}; };
type TreeOptionType = Omit<DefaultOptionType, 'value'> & { value: string }; type TreeOptionType = Omit<DefaultOptionType, 'value'> & { value: string };
@ -20,7 +26,8 @@ function usePropsCollection({ collection }) {
type CallScope = { type CallScope = {
compile?(value: string): string; compile?(value: string): string;
getCollectionFields?(name: any): CollectionFieldOptions[]; getCollectionFields?(name: any): CollectionFieldOptions[];
} filter(field): boolean;
};
function loadChildren(this, option) { function loadChildren(this, option) {
const result = getCollectionFieldOptions.call(this, option.field.target, option); const result = getCollectionFieldOptions.call(this, option.field.target, option);
@ -38,13 +45,17 @@ function isAssociation(field) {
return field.target && field.interface; return field.target && field.interface;
} }
function trueFilter(field) {
return true;
}
function getCollectionFieldOptions(this: CallScope, collection, parentNode?): TreeOptionType[] { function getCollectionFieldOptions(this: CallScope, collection, parentNode?): TreeOptionType[] {
const fields = this.getCollectionFields(collection).filter(isAssociation); const fields = this.getCollectionFields(collection).filter(isAssociation);
const boundLoadChildren = loadChildren.bind(this); const boundLoadChildren = loadChildren.bind(this);
return fields.map((field) => { return fields.filter(this.filter).map((field) => {
const key = parentNode ? `${parentNode.value}.${field.name}` : field.name; const key = parentNode ? `${parentNode.value ? `${parentNode.value}.` : ''}${field.name}` : field.name;
const fieldTitle = this.compile(field.uiSchema?.title) ?? field.name; const fieldTitle = this.compile(field.uiSchema?.title) ?? field.name;
const isLeaf = !this.getCollectionFields(field.target).filter(isAssociation).length; const isLeaf = !this.getCollectionFields(field.target).filter(isAssociation).filter(this.filter).length;
return { return {
pId: parentNode?.key ?? null, pId: parentNode?.key ?? null,
id: key, id: key,
@ -60,35 +71,71 @@ function getCollectionFieldOptions(this: CallScope, collection, parentNode?): Tr
} }
export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => { export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
const { value = [], onChange, collection, useCollection = usePropsCollection, ...restProps } = props; const {
value: propsValue,
onChange,
collection,
useCollection = usePropsCollection,
filter = trueFilter,
rootOption,
...restProps
} = props;
const { getCollectionFields } = useCollectionManager(); const { getCollectionFields } = useCollectionManager();
const compile = useCompile(); const compile = useCompile();
const { t } = useTranslation(); const { t } = useTranslation();
const [optionsMap, setOptionsMap] = useState({}); const [optionsMap, setOptionsMap] = useState({});
const baseCollection = useCollection({ collection }); const baseCollection = useCollection({ collection });
const treeData = Object.values(optionsMap); const treeData = Object.values(optionsMap);
const value: string | DefaultOptionType[] = useMemo(() => {
const loadData = useCallback(async (option) => { if (props.multiple) {
if (!option.isLeaf && option.loadChildren) { return ((propsValue as string[]) || []).map((v) => optionsMap[v]).filter(Boolean);
const children = option.loadChildren(option);
setOptionsMap((prev) => {
return children.reduce((result, item) => Object.assign(result, { [item.value]: item }), { ...prev });
});
} }
}, [setOptionsMap]); return propsValue;
}, [propsValue, props.multiple, optionsMap]);
const loadData = useCallback(
async (option) => {
if (!option.isLeaf && option.loadChildren) {
const children = option.loadChildren(option);
setOptionsMap((prev) => {
return children.reduce((result, item) => Object.assign(result, { [item.value]: item }), { ...prev });
});
}
},
[setOptionsMap],
);
useEffect(() => { useEffect(() => {
const treeData = getCollectionFieldOptions.call({ compile, getCollectionFields }, baseCollection); const parentNode = rootOption
setOptionsMap(treeData.reduce((result, item) => Object.assign(result, { [item.value]: item }), {})); ? {
}, [collection, baseCollection]); ...rootOption,
id: rootOption.value,
key: rootOption.value,
title: rootOption.label,
fullTitle: rootOption.label,
isLeaf: false,
}
: null;
const treeData = getCollectionFieldOptions.call(
{ compile, getCollectionFields, filter },
baseCollection,
parentNode,
);
const map = treeData.reduce((result, item) => Object.assign(result, { [item.value]: item }), {});
if (parentNode) {
map[parentNode.value] = parentNode;
}
setOptionsMap(map);
}, [collection, baseCollection, rootOption, filter]);
useEffect(() => { useEffect(() => {
if (!value?.length || value.every(v => Boolean(optionsMap[v]))) { const arr = (props.multiple ? propsValue : propsValue ? [propsValue] : []) as string[];
if (!arr?.length || arr.every((v) => Boolean(optionsMap[v]))) {
return; return;
} }
const loaded = []; const loaded = [];
value.forEach((v) => { arr.forEach((v) => {
const paths = v.split('.'); const paths = v.split('.');
let option = optionsMap[paths[0]]; let option = optionsMap[paths[0]];
for (let i = 1; i < paths.length; i++) { for (let i = 1; i < paths.length; i++) {
@ -104,7 +151,7 @@ export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
const children = option.loadChildren(option); const children = option.loadChildren(option);
if (children?.length) { if (children?.length) {
loaded.push(...children); loaded.push(...children);
option = children.find(item => item.value === paths.slice(0, i + 1).join('.')); option = children.find((item) => item.value === paths.slice(0, i + 1).join('.'));
} }
} }
} }
@ -112,57 +159,75 @@ export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
setOptionsMap((prev) => { setOptionsMap((prev) => {
return loaded.reduce((result, item) => Object.assign(result, { [item.value]: item }), { ...prev }); return loaded.reduce((result, item) => Object.assign(result, { [item.value]: item }), { ...prev });
}); });
}, [value, treeData.length]); }, [propsValue, treeData.length, props.multiple]);
const handleChange = useCallback((newNodes: DefaultOptionType[]) => { const handleChange = useCallback(
const newValue = newNodes.map((i) => i.value).filter(Boolean) as string[]; (next: DefaultOptionType[] | string) => {
const valueSet = new Set(newValue); if (!props.multiple) {
const delValue = value.find((i) => !newValue.includes(i)); onChange(next as string);
return;
}
if (delValue) { const newValue = (next as DefaultOptionType[]).map((i) => i.value).filter(Boolean) as string[];
const delNode = optionsMap[delValue]; const valueSet = new Set(newValue);
const prefix = `${delNode.value}.`; const delValue = (value as DefaultOptionType[]).find((i) => !valueSet.has(i.value as string));
Object.keys(optionsMap)
.forEach((key) => { if (delValue) {
const prefix = `${delValue.value}.`;
Object.keys(optionsMap).forEach((key) => {
if (key.startsWith(prefix)) { if (key.startsWith(prefix)) {
valueSet.delete(key); valueSet.delete(key);
} }
}); });
} else { } else {
newValue.forEach((v) => { newValue.forEach((v) => {
const paths = v.split('.'); const paths = v.split('.');
if (paths.length) { if (paths.length) {
for (let i = 1; i < paths.length; i++) { for (let i = 1; i <= paths.length; i++) {
valueSet.add(paths.slice(0, i).join('.')); valueSet.add(paths.slice(0, i).join('.'));
}
} }
} });
}); }
} onChange(Array.from(valueSet));
onChange(Array.from(valueSet)); },
}, [value, optionsMap]); [props.multiple, value, onChange, optionsMap],
);
const TreeTag = useCallback((props) => { const TreeTag = useCallback(
const { value, onClose, disabled, closable } = props; (props) => {
const { fullTitle } = optionsMap[value]; const { value, onClose, disabled, closable } = props;
return ( if (!value) {
<Tag closable={closable && !disabled} onClose={onClose}>{fullTitle.join(' / ')}</Tag> return null;
); }
}, [optionsMap]); const { fullTitle } = optionsMap[value] ?? {};
return (
<Tag closable={closable && !disabled} onClose={onClose}>
{fullTitle?.join(' / ')}
</Tag>
);
},
[optionsMap],
);
const filterdValue = Array.isArray(value) ? value.filter((i) => i in optionsMap) : value; const filteredValue = Array.isArray(value) ? value.filter((i) => i.value in optionsMap) : value;
const valueKeys: string[] = props.multiple
? (propsValue as string[])
: propsValue != null
? [propsValue as string]
: [];
return ( return (
<TreeSelect <TreeSelect
value={filterdValue} value={filteredValue}
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
placeholder={t('Select field')} placeholder={t('Select field')}
showCheckedStrategy="SHOW_ALL" showCheckedStrategy={TreeSelect.SHOW_ALL}
treeDefaultExpandedKeys={valueKeys}
allowClear allowClear
multiple treeCheckStrictly={props.multiple}
treeCheckStrictly treeCheckable={props.multiple}
treeCheckable
tagRender={TreeTag} tagRender={TreeTag}
onChange={handleChange as unknown as () => void} onChange={handleChange as (next) => void}
treeDataSimpleMode treeDataSimpleMode
treeData={treeData} treeData={treeData}
loadData={loadData} loadData={loadData}

View File

@ -55,7 +55,7 @@ export const getTabFormatValue = (labelUiSchema: ISchema, value: any, tagColor):
if (Array.isArray(options) && value) { if (Array.isArray(options) && value) {
const values = toArr(value).map((val) => { const values = toArr(value).map((val) => {
const opt: any = options.find((option: any) => option.value === val); const opt: any = options.find((option: any) => option.value === val);
return React.createElement(Tag, { color: tagColor||opt?.color }, opt?.label); return React.createElement(Tag, { color: tagColor || opt?.color }, opt?.label);
}); });
return values; return values;
} }

View File

@ -18,7 +18,7 @@ ReadPretty.ColorPicker = function ColorPicker(props: any) {
return ( return (
<div className={cls(prefixCls, props.className)}> <div className={cls(prefixCls, props.className)}>
<ColorPicker showText disabled value={props.value} size='small'/> <ColorPicker showText disabled value={props.value} size="small" />
</div> </div>
); );
}; };

View File

@ -5,7 +5,9 @@ const toStringByPicker = (value, picker, timezone: 'gmt' | 'local') => {
if (!dayjs.isDayjs(value)) return value; if (!dayjs.isDayjs(value)) return value;
if (timezone === 'local') { if (timezone === 'local') {
const offset = new Date().getTimezoneOffset(); const offset = new Date().getTimezoneOffset();
return dayjs(toStringByPicker(value, picker, 'gmt')).add(offset, 'minutes').toISOString(); return dayjs(toStringByPicker(value, picker, 'gmt'))
.add(offset, 'minutes')
.toISOString();
} }
if (picker === 'year') { if (picker === 'year') {

View File

@ -1,6 +1,6 @@
import { DeleteOutlined, MenuOutlined } from '@ant-design/icons'; import { DeleteOutlined, MenuOutlined } from '@ant-design/icons';
import { TinyColor } from '@ctrl/tinycolor'; import { TinyColor } from '@ctrl/tinycolor';
import { SortableContext, useSortable } from '@dnd-kit/sortable'; import { SortableContext, SortableContextProps, useSortable } from '@dnd-kit/sortable';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { ArrayField, Field } from '@formily/core'; import { ArrayField, Field } from '@formily/core';
import { spliceArrayState } from '@formily/core/esm/shared/internals'; import { spliceArrayState } from '@formily/core/esm/shared/internals';
@ -454,13 +454,14 @@ export const Table: any = observer(
const SortableWrapper = useCallback<React.FC>( const SortableWrapper = useCallback<React.FC>(
({ children }) => { ({ children }) => {
return dragSort return dragSort
? React.createElement(SortableContext, { ? React.createElement<Omit<SortableContextProps, 'children'>>(
items: field.value?.map?.(getRowKey) || [], SortableContext,
{
items: field.value?.map?.(getRowKey) || [],
},
children, children,
}) )
: React.createElement(React.Fragment, { : React.createElement(React.Fragment, {}, children);
children,
});
}, },
[field, dragSort], [field, dragSort],
); );
@ -530,7 +531,7 @@ export const Table: any = observer(
{field.errors.length > 0 && ( {field.errors.length > 0 && (
<div className="ant-formily-item-error-help ant-formily-item-help ant-formily-item-help-enter ant-formily-item-help-enter-active"> <div className="ant-formily-item-error-help ant-formily-item-help ant-formily-item-help-enter ant-formily-item-help-enter-active">
{field.errors.map((error) => { {field.errors.map((error) => {
return error.messages.map((message) => <div>{message}</div>); return error.messages.map((message) => <div key={message}>{message}</div>);
})} })}
</div> </div>
)} )}

View File

@ -1,3 +1,29 @@
// TODO(refactor): should be moved to workflow plugin
const FormTriggerWorkflowActionInitializer = {
type: 'item',
title: '{{t("Submit to workflow", { ns: "workflow" })}}',
component: 'CustomizeActionInitializer',
schema: {
title: '{{t("Submit to workflow", { ns: "workflow" })}}',
'x-component': 'Action',
'x-component-props': {
useProps: '{{ useTriggerWorkflowsActionProps }}',
},
'x-designer': 'Action.Designer',
'x-action-settings': {
assignedValues: {},
skipValidator: false,
onSuccess: {
manualClose: true,
redirecting: false,
successMessage: '{{t("Submitted successfully")}}',
},
triggerWorkflows: [],
},
'x-action': 'customize:triggerWorkflows',
},
};
// 表单的操作配置 // 表单的操作配置
export const FormActionInitializers = { export const FormActionInitializers = {
title: '{{t("Configure actions")}}', title: '{{t("Configure actions")}}',
@ -94,14 +120,16 @@ export const FormActionInitializers = {
onSuccess: { onSuccess: {
manualClose: true, manualClose: true,
redirecting: false, redirecting: false,
successMessage: '{{t("Saved successfully")}}', successMessage: '{{t("Submitted successfully")}}',
}, },
triggerWorkflows: [],
}, },
'x-component-props': { 'x-component-props': {
useProps: '{{ useCreateActionProps }}', useProps: '{{ useCreateActionProps }}',
}, },
}, },
}, },
FormTriggerWorkflowActionInitializer,
{ {
type: 'item', type: 'item',
title: '{{t("Custom request")}}', title: '{{t("Custom request")}}',
@ -225,14 +253,16 @@ export const CreateFormActionInitializers = {
onSuccess: { onSuccess: {
manualClose: true, manualClose: true,
redirecting: false, redirecting: false,
successMessage: '{{t("Saved successfully")}}', successMessage: '{{t("Submitted successfully")}}',
}, },
triggerWorkflows: [],
}, },
'x-component-props': { 'x-component-props': {
useProps: '{{ useCreateActionProps }}', useProps: '{{ useCreateActionProps }}',
}, },
}, },
}, },
FormTriggerWorkflowActionInitializer,
{ {
type: 'item', type: 'item',
title: '{{t("Custom request")}}', title: '{{t("Custom request")}}',
@ -355,14 +385,16 @@ export const UpdateFormActionInitializers = {
onSuccess: { onSuccess: {
manualClose: true, manualClose: true,
redirecting: false, redirecting: false,
successMessage: '{{t("Saved successfully")}}', successMessage: '{{t("Submitted successfully")}}',
}, },
triggerWorkflows: [],
}, },
'x-component-props': { 'x-component-props': {
useProps: '{{ useUpdateActionProps }}', useProps: '{{ useUpdateActionProps }}',
}, },
}, },
}, },
FormTriggerWorkflowActionInitializer,
{ {
type: 'item', type: 'item',
title: '{{t("Custom request")}}', title: '{{t("Custom request")}}',
@ -378,7 +410,7 @@ export const UpdateFormActionInitializers = {
onSuccess: { onSuccess: {
manualClose: false, manualClose: false,
redirecting: false, redirecting: false,
successMessage: '{{t("Request success")}}', successMessage: '{{t("Submitted successfully")}}',
}, },
}, },
'x-component-props': { 'x-component-props': {
@ -485,7 +517,7 @@ export const BulkEditFormActionInitializers = {
onSuccess: { onSuccess: {
manualClose: true, manualClose: true,
redirecting: false, redirecting: false,
successMessage: '{{t("Saved successfully")}}', successMessage: '{{t("Submitted successfully")}}',
}, },
}, },
'x-component-props': { 'x-component-props': {

View File

@ -13,6 +13,9 @@ export const CreateSubmitActionInitializer = (props) => {
htmlType: 'submit', htmlType: 'submit',
useProps: '{{ useCreateActionProps }}', useProps: '{{ useCreateActionProps }}',
}, },
'x-action-settings': {
triggerWorkflows: [],
},
}; };
return <ActionInitializer {...props} schema={schema} />; return <ActionInitializer {...props} schema={schema} />;
}; };

View File

@ -13,6 +13,9 @@ export const UpdateSubmitActionInitializer = (props) => {
htmlType: 'submit', htmlType: 'submit',
useProps: '{{ useUpdateActionProps }}', useProps: '{{ useUpdateActionProps }}',
}, },
'x-action-settings': {
triggerWorkflows: [],
},
}; };
return <ActionInitializer {...props} schema={schema} />; return <ActionInitializer {...props} schema={schema} />;
}; };

View File

@ -100,9 +100,7 @@ export const useSyncFromForm = (fieldSchema, collection?, callBack?) => {
cache.set(cacheKey, result); cache.set(cacheKey, result);
return result; return result;
}; };
})( })(new LRUCache<string, any>({ max: 100 }));
new LRUCache<string, any>({ max: 100 }),
);
const traverseAssociations = ((cache) => { const traverseAssociations = ((cache) => {
return (collectionName, { prefix, maxDepth, depth = 0, exclude = [] }) => { return (collectionName, { prefix, maxDepth, depth = 0, exclude = [] }) => {
@ -149,9 +147,7 @@ export const useSyncFromForm = (fieldSchema, collection?, callBack?) => {
cache.set(cacheKey, result); cache.set(cacheKey, result);
return result; return result;
}; };
})( })(new LRUCache<string, any>({ max: 100 }));
new LRUCache<string, any>({ max: 100 }),
);
const getEnableFieldTree = useCallback((collectionName: string, formData) => { const getEnableFieldTree = useCallback((collectionName: string, formData) => {
if (!collectionName) { if (!collectionName) {
return []; return [];

View File

@ -16,3 +16,4 @@ export * from './registry';
// export * from './toposort'; // export * from './toposort';
export * from './uid'; export * from './uid';
export { dayjs, lodash }; export { dayjs, lodash };
export * from './url';

View File

@ -0,0 +1,11 @@
export function isURL(string) {
let url;
try {
url = new URL(string);
} catch (e) {
return false;
}
return url.protocol === 'http:' || url.protocol === 'https:';
}

View File

@ -382,11 +382,11 @@ export const GraphDrawPage = React.memo(() => {
const categoryCtx = useContext(CollectionCategroriesContext); const categoryCtx = useContext(CollectionCategroriesContext);
const scope = { ...options?.scope }; const scope = { ...options?.scope };
const components = { ...options?.components }; const components = { ...options?.components };
const useSaveGraphPositionAction = async (data) => { const saveGraphPositionAction = async (data) => {
await api.resource('graphPositions').create({ values: data }); await api.resource('graphPositions').create({ values: data });
await refreshPositions(); await refreshPositions();
}; };
const useUpdatePositionAction = async (data, isbatch = false) => { const updatePositionAction = async (data, isbatch = false) => {
if (isbatch) { if (isbatch) {
await api.resource('graphPositions').update({ await api.resource('graphPositions').update({
values: data, values: data,
@ -413,7 +413,7 @@ export const GraphDrawPage = React.memo(() => {
const refreshGM = async () => { const refreshGM = async () => {
const data = await refreshCM(); const data = await refreshCM();
targetGraph.collections = data; targetGraph.collections = data;
targetGraph.updatePositionAction = useUpdatePositionAction; targetGraph.updatePositionAction = updatePositionAction;
const currentNodes = targetGraph.getNodes(); const currentNodes = targetGraph.getNodes();
setCollectionData(data); setCollectionData(data);
setCollectionList(data); setCollectionList(data);
@ -568,12 +568,12 @@ export const GraphDrawPage = React.memo(() => {
const oldPosition = targetGraph.positions.find((v) => v.collectionName === node.store.data.name); const oldPosition = targetGraph.positions.find((v) => v.collectionName === node.store.data.name);
if (oldPosition) { if (oldPosition) {
(oldPosition.x !== currentPosition.x || oldPosition.y !== currentPosition.y) && (oldPosition.x !== currentPosition.x || oldPosition.y !== currentPosition.y) &&
useUpdatePositionAction({ updatePositionAction({
collectionName: node.store.data.name, collectionName: node.store.data.name,
...currentPosition, ...currentPosition,
}); });
} else { } else {
useSaveGraphPositionAction({ saveGraphPositionAction({
collectionName: node.store.data.name, collectionName: node.store.data.name,
...currentPosition, ...currentPosition,
}); });
@ -609,7 +609,7 @@ export const GraphDrawPage = React.memo(() => {
const handleEdgeUnActive = (targetEdge) => { const handleEdgeUnActive = (targetEdge) => {
targetGraph.activeEdge = null; targetGraph.activeEdge = null;
const { m2m, connectionType } = targetEdge.store?.data; const { m2m, connectionType } = targetEdge.store?.data ?? {};
const m2mLineId = m2m?.find((v) => v !== targetEdge.id); const m2mLineId = m2m?.find((v) => v !== targetEdge.id);
const m2mEdge = targetGraph.getCellById(m2mLineId); const m2mEdge = targetGraph.getCellById(m2mLineId);
const lightsOut = (edge) => { const lightsOut = (edge) => {
@ -646,7 +646,7 @@ export const GraphDrawPage = React.memo(() => {
}; };
const handleEdgeActive = (targetEdge) => { const handleEdgeActive = (targetEdge) => {
targetGraph.activeEdge = targetEdge; targetGraph.activeEdge = targetEdge;
const { associated, m2m, connectionType } = targetEdge.store?.data; const { associated, m2m, connectionType } = targetEdge.store?.data ?? {};
const m2mLineId = m2m?.find((v) => v !== targetEdge.id); const m2mLineId = m2m?.find((v) => v !== targetEdge.id);
const m2mEdge = targetGraph.getCellById(m2mLineId); const m2mEdge = targetGraph.getCellById(m2mLineId);
const lightUp = (edge) => { const lightUp = (edge) => {
@ -702,7 +702,7 @@ export const GraphDrawPage = React.memo(() => {
getNodes(nodesData); getNodes(nodesData);
getEdges(edgesData); getEdges(edgesData);
getEdges(inheritEdges); getEdges(inheritEdges);
layout(useSaveGraphPositionAction); layout(saveGraphPositionAction);
}; };
// 增量渲染 // 增量渲染
@ -721,18 +721,19 @@ export const GraphDrawPage = React.memo(() => {
const diffNodes = getDiffNode(nodesData, currentNodes); const diffNodes = getDiffNode(nodesData, currentNodes);
const diffEdges = getDiffEdge(edgesData, currentEdgesGroup.currentRelateEdges || []); const diffEdges = getDiffEdge(edgesData, currentEdgesGroup.currentRelateEdges || []);
const diffInheritEdge = getDiffEdge(inheritEdges, currentEdgesGroup.currentInheritEdges || []); const diffInheritEdge = getDiffEdge(inheritEdges, currentEdgesGroup.currentInheritEdges || []);
const maxY = maxBy(positions, 'y').y;
const minX = minBy(positions, 'x').x;
const yNodes = positions.filter((v) => {
return Math.abs(v.y - maxY) < 100;
});
let referenceNode: any = maxBy(yNodes, 'x');
let position;
diffNodes.forEach(({ status, node, port }) => { diffNodes.forEach(({ status, node, port }) => {
const updateNode = targetGraph.getCellById(node.id); const updateNode = targetGraph.getCellById(node.id);
switch (status) { switch (status) {
case 'add': case 'add':
const maxY = maxBy(positions, 'y').y;
const yNodes = positions.filter((v) => {
return Math.abs(v.y - maxY) < 100;
});
let referenceNode: any = maxBy(yNodes, 'x');
let position;
if (referenceNode.x > 4500) { if (referenceNode.x > 4500) {
const minX = minBy(positions, 'x').x;
referenceNode = minBy(yNodes, 'x'); referenceNode = minBy(yNodes, 'x');
position = { x: minX, y: referenceNode.y + 400 }; position = { x: minX, y: referenceNode.y + 400 };
} else { } else {
@ -742,7 +743,7 @@ export const GraphDrawPage = React.memo(() => {
...node, ...node,
position, position,
}); });
useSaveGraphPositionAction({ saveGraphPositionAction({
collectionName: node.name, collectionName: node.name,
...position, ...position,
}); });
@ -759,17 +760,18 @@ export const GraphDrawPage = React.memo(() => {
break; break;
case 'delete': case 'delete':
targetGraph.removeCell(node.id); targetGraph.removeCell(node.id);
break;
default: default:
return null; return null;
} }
}); });
const renderDiffEdges = (data) => { const renderDiffEdges = (data) => {
data.forEach(({ status, edge }) => { data.forEach(({ status, edge }) => {
const newEdge = targetGraph.addEdge({
...edge,
});
switch (status) { switch (status) {
case 'add': case 'add':
const newEdge = targetGraph.addEdge({
...edge,
});
optimizeEdge(newEdge); optimizeEdge(newEdge);
break; break;
case 'delete': case 'delete':
@ -1010,9 +1012,13 @@ export const GraphDrawPage = React.memo(() => {
}, []); }, []);
useEffect(() => { useEffect(() => {
refreshPositions().then(() => { refreshPositions()
refreshGM(); .then(() => {
}); refreshGM();
})
.catch((err) => {
throw err;
});
}, []); }, []);
const loadCollections = async () => { const loadCollections = async () => {
return targetGraph.collections?.map((collection: any) => ({ return targetGraph.collections?.map((collection: any) => ({

View File

@ -169,6 +169,7 @@ export const snapshot: IField = {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'AppendsTreeSelect', 'x-component': 'AppendsTreeSelect',
'x-component-props': { 'x-component-props': {
multiple: true,
useCollection: useRecordCollection, useCollection: useRecordCollection,
}, },
'x-reactions': [ 'x-reactions': [

View File

@ -1,7 +1,6 @@
import { createStyles, cx } from '@nocobase/client'; import { createStyles, cx } from '@nocobase/client';
import { Tag } from 'antd'; import { Tag } from 'antd';
import React from 'react'; import React from 'react';
import { lang } from '../locale';
const useStyles = createStyles(({ css, token }) => { const useStyles = createStyles(({ css, token }) => {
return { return {
@ -16,6 +15,7 @@ const useStyles = createStyles(({ css, token }) => {
dl { dl {
display: flex; display: flex;
align-items: baseline;
dt { dt {
color: ${token.colorText}; color: ${token.colorText};
@ -33,19 +33,19 @@ const useStyles = createStyles(({ css, token }) => {
}; };
}); });
export function NodeDescription(props) { export function DrawerDescription(props) {
const { instruction } = props; const { label, title, description } = props;
const { styles } = useStyles(); const { styles } = useStyles();
return ( return (
<div className={cx(styles.container, props.className)}> <div className={cx(styles.container, props.className)}>
<dl> <dl>
<dt>{lang('Node type')}</dt> <dt>{label}</dt>
<dd> <dd>
<Tag style={{ background: 'none' }}>{instruction.title}</Tag> <Tag style={{ background: 'none' }}>{title}</Tag>
</dd> </dd>
</dl> </dl>
{instruction.description ? <p>{instruction.description}</p> : null} {description ? <p>{description}</p> : null}
</div> </div>
); );
} }

View File

@ -4,7 +4,7 @@ export * from './nodes';
export { triggers } from './triggers'; export { triggers } from './triggers';
export { useWorkflowVariableOptions } from './variable'; export { useWorkflowVariableOptions } from './variable';
import { Plugin, useCollectionDataSource } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import React from 'react'; import React from 'react';
import { ExecutionPage } from './ExecutionPage'; import { ExecutionPage } from './ExecutionPage';
import { WorkflowPage } from './WorkflowPage'; import { WorkflowPage } from './WorkflowPage';
@ -12,17 +12,19 @@ import { WorkflowProvider } from './WorkflowProvider';
import { DynamicExpression } from './components/DynamicExpression'; import { DynamicExpression } from './components/DynamicExpression';
import { WorkflowTodo } from './nodes/manual/WorkflowTodo'; import { WorkflowTodo } from './nodes/manual/WorkflowTodo';
import { WorkflowTodoBlockInitializer } from './nodes/manual/WorkflowTodoBlockInitializer'; import { WorkflowTodoBlockInitializer } from './nodes/manual/WorkflowTodoBlockInitializer';
import { useTriggerWorkflowsActionProps } from './triggers/form';
export class WorkflowPlugin extends Plugin { export class WorkflowPlugin extends Plugin {
async load() { async load() {
this.addRoutes(); this.addRoutes();
this.addScopes();
this.addComponents(); this.addComponents();
this.app.use(WorkflowProvider); this.app.use(WorkflowProvider);
} }
addScopes() { addScopes() {
this.app.addScopes({ this.app.addScopes({
useCollectionDataSource, useTriggerWorkflowsActionProps,
}); });
} }

View File

@ -18,7 +18,7 @@ import React, { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AddButton } from '../AddButton'; import { AddButton } from '../AddButton';
import { useFlowContext } from '../FlowContext'; import { useFlowContext } from '../FlowContext';
import { NodeDescription } from '../components/NodeDescription'; import { DrawerDescription } from '../components/DrawerDescription';
import { JobStatusOptionsMap } from '../constants'; import { JobStatusOptionsMap } from '../constants';
import { NAMESPACE, lang } from '../locale'; import { NAMESPACE, lang } from '../locale';
import useStyles from '../style'; import useStyles from '../style';
@ -426,9 +426,11 @@ export function NodeDefaultView(props) {
? { ? {
description: { description: {
type: 'void', type: 'void',
'x-component': NodeDescription, 'x-component': DrawerDescription,
'x-component-props': { 'x-component-props': {
instruction, label: lang('Node type'),
title: instruction.title,
description: instruction.description,
}, },
}, },
} }

View File

@ -56,6 +56,7 @@ export const appends = {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'AppendsTreeSelect', 'x-component': 'AppendsTreeSelect',
'x-component-props': { 'x-component-props': {
multiple: true,
useCollection() { useCollection() {
const { values } = useForm(); const { values } = useForm();
return values?.collection; return values?.collection;

View File

@ -243,7 +243,9 @@ const useStyles = createStyles(({ css, token }) => {
font-weight: bold; font-weight: bold;
&:not(:focus) { &:not(:focus) {
transition: background-color 0.3s ease, border-color 0.3s ease; transition:
background-color 0.3s ease,
border-color 0.3s ease;
border-color: ${token.colorBorderBg}; border-color: ${token.colorBorderBg};
background-color: ${token.colorBgContainerDisabled}; background-color: ${token.colorBgContainerDisabled};

View File

@ -27,6 +27,7 @@ const collectionModeOptions = [
export default { export default {
title: `{{t("Collection event", { ns: "${NAMESPACE}" })}}`, title: `{{t("Collection event", { ns: "${NAMESPACE}" })}}`,
type: 'collection', type: 'collection',
description: `{{t("Event will be triggered on collection data row created, updated or deleted.", { ns: "${NAMESPACE}" })}}`,
fieldset: { fieldset: {
collection: { collection: {
...collection, ...collection,

View File

@ -0,0 +1,195 @@
import { useField, useFieldSchema, useForm } from '@formily/react';
import {
SchemaInitializerItemOptions,
useAPIClient,
useActionContext,
useBlockRequestContext,
useCollection,
useCollectionDataSource,
useCollectionManager,
useCompile,
useCurrentUserContext,
useFilterByTk,
useRecord,
} from '@nocobase/client';
import { isURL, parse } from '@nocobase/utils/client';
import { App, message } from 'antd';
import omit from 'lodash/omit';
import { useNavigate } from 'react-router-dom';
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
import { NAMESPACE, lang } from '../locale';
import { appends, collection } from '../schemas/collection';
import { getCollectionFieldOptions } from '../variable';
export default {
title: `{{t("Form event", { ns: "${NAMESPACE}" })}}`,
type: 'form',
description: `{{t("Event triggers when submitted a workflow bound form action.", { ns: "${NAMESPACE}" })}}`,
fieldset: {
collection: {
...collection,
title: `{{t("Form data model", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Use a collection to match form data.", { ns: "${NAMESPACE}" })}}`,
['x-reactions']: [
...collection['x-reactions'],
{
target: 'appends',
effects: ['onFieldValueChange'],
fulfill: {
state: {
value: [],
},
},
},
],
},
appends: {
...appends,
title: `{{t("Associations to use", { ns: "${NAMESPACE}" })}}`,
},
},
scope: {
useCollectionDataSource,
},
components: {},
useVariables(config, options) {
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const rootFields = [
{
collectionName: config.collection,
name: 'data',
type: 'hasOne',
target: config.collection,
uiSchema: {
title: lang('Trigger data'),
},
},
];
const result = getCollectionFieldOptions({
// depth,
...options,
fields: rootFields,
appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
compile,
getCollectionFields,
});
return result;
},
useInitializers(config): SchemaInitializerItemOptions | null {
if (!config.collection) {
return null;
}
return {
type: 'item',
key: 'triggerData',
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
component: CollectionBlockInitializer,
collection: config.collection,
dataSource: '{{$context.data}}',
};
},
initializers: {},
};
function getFormValues(filterByTk, field, form, fieldNames, getField, resource) {
if (filterByTk) {
const actionFields = field?.data?.activeFields as Set<string>;
if (actionFields) {
const keys = Object.keys(form.values).filter((key) => {
const f = getField(key);
return !actionFields.has(key) && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(f?.type);
});
return omit({ ...form.values }, keys);
}
}
return form.values;
}
export function useTriggerWorkflowsActionProps() {
const api = useAPIClient();
const form = useForm();
const { field, resource, __parent } = useBlockRequestContext();
const { setVisible, fieldSchema } = useActionContext();
const navigate = useNavigate();
const actionSchema = useFieldSchema();
const actionField = useField();
const { fields, getField, getTreeParentField } = useCollection();
const compile = useCompile();
const filterByTk = useFilterByTk();
const currentRecord = useRecord();
const currentUserContext = useCurrentUserContext();
const { modal } = App.useApp();
const currentUser = currentUserContext?.data?.data;
const filterKeys = actionField.componentProps.filterKeys || [];
return {
async onClick() {
const fieldNames = fields.map((field) => field.name);
const {
assignedValues: originalAssignedValues = {},
onSuccess,
overwriteValues,
skipValidator,
triggerWorkflows,
} = actionSchema?.['x-action-settings'] ?? {};
const addChild = fieldSchema?.['x-component-props']?.addChild;
const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser });
if (!skipValidator) {
await form.submit();
}
const values = getFormValues(filterByTk, field, form, fieldNames, getField, resource);
// const values = omitBy(formValues, (value) => isEqual(JSON.stringify(value), '[{}]'));
if (addChild) {
const treeParentField = getTreeParentField();
values[treeParentField?.name ?? 'parent'] = currentRecord;
values[treeParentField?.foreignKey ?? 'parentId'] = currentRecord.id;
}
actionField.data = field.data || {};
actionField.data.loading = true;
try {
const data = await api.resource('workflows').trigger({
values: {
...values,
...overwriteValues,
...assignedValues,
},
filterKeys: filterKeys,
// TODO(refactor): should change to inject by plugin
triggerWorkflows: triggerWorkflows?.length
? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',')
: undefined,
});
actionField.data.loading = false;
actionField.data.data = data;
__parent?.service?.refresh?.();
setVisible?.(false);
if (!onSuccess?.successMessage) {
return;
}
if (onSuccess?.manualClose) {
modal.success({
title: compile(onSuccess?.successMessage),
onOk: async () => {
await form.reset();
if (onSuccess?.redirecting && onSuccess?.redirectTo) {
if (isURL(onSuccess.redirectTo)) {
window.location.href = onSuccess.redirectTo;
} else {
navigate(onSuccess.redirectTo);
}
}
},
});
} else {
message.success(compile(onSuccess?.successMessage));
}
} catch (error) {
actionField.data.loading = false;
}
},
};
}

View File

@ -16,10 +16,12 @@ import { Registry } from '@nocobase/utils/client';
import { Alert, Button, Input, Tag, message } from 'antd'; import { Alert, Button, Input, Tag, message } from 'antd';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useFlowContext } from '../FlowContext'; import { useFlowContext } from '../FlowContext';
import { DrawerDescription } from '../components/DrawerDescription';
import { NAMESPACE, lang } from '../locale'; import { NAMESPACE, lang } from '../locale';
import useStyles from '../style'; import useStyles from '../style';
import { VariableOptions } from '../variable'; import { VariableOptions } from '../variable';
import collection from './collection'; import collection from './collection';
import formTrigger from './form';
import schedule from './schedule/'; import schedule from './schedule/';
function useUpdateConfigAction() { function useUpdateConfigAction() {
@ -50,6 +52,7 @@ function useUpdateConfigAction() {
export interface Trigger { export interface Trigger {
title: string; title: string;
type: string; type: string;
description?: string;
// group: string; // group: string;
useVariables?(config: any, options?): VariableOptions; useVariables?(config: any, options?): VariableOptions;
fieldset: { [key: string]: ISchema }; fieldset: { [key: string]: ISchema };
@ -62,6 +65,7 @@ export interface Trigger {
export const triggers = new Registry<Trigger>(); export const triggers = new Registry<Trigger>();
triggers.register(formTrigger.type, formTrigger);
triggers.register(collection.type, collection); triggers.register(collection.type, collection);
triggers.register(schedule.type, schedule); triggers.register(schedule.type, schedule);
@ -168,7 +172,7 @@ export const TriggerConfig = () => {
const { fieldset, scope, components } = trigger; const { fieldset, scope, components } = trigger;
typeTitle = trigger.title; typeTitle = trigger.title;
const detailText = executed ? '{{t("View")}}' : '{{t("Configure")}}'; const detailText = executed ? '{{t("View")}}' : '{{t("Configure")}}';
const titleText = `${lang('Trigger')}: ${compile(typeTitle)}`; const titleText = lang('Trigger');
async function onChangeTitle(next) { async function onChangeTitle(next) {
const t = next || typeTitle; const t = next || typeTitle;
@ -254,6 +258,18 @@ export const TriggerConfig = () => {
}, },
}, },
} }
: trigger.description
? {
description: {
type: 'void',
'x-component': DrawerDescription,
'x-component-props': {
label: lang('Trigger type'),
title: trigger.title,
description: trigger.description,
},
},
}
: {}), : {}),
fieldset: { fieldset: {
type: 'void', type: 'void',

View File

@ -1,15 +1,20 @@
import { useCollectionDataSource, SchemaInitializerItemOptions, useCompile, useCollectionManager } from '@nocobase/client'; import {
SchemaInitializerItemOptions,
useCollectionDataSource,
useCollectionManager,
useCompile,
} from '@nocobase/client';
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
import { NAMESPACE, lang } from '../../locale';
import { getCollectionFieldOptions } from '../../variable';
import { ScheduleConfig } from './ScheduleConfig'; import { ScheduleConfig } from './ScheduleConfig';
import { SCHEDULE_MODE } from './constants'; import { SCHEDULE_MODE } from './constants';
import { NAMESPACE, lang } from '../../locale';
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
import { defaultFieldNames, getCollectionFieldOptions } from '../../variable';
import { FieldsSelect } from '../../components/FieldsSelect';
export default { export default {
title: `{{t("Schedule event", { ns: "${NAMESPACE}" })}}`, title: `{{t("Schedule event", { ns: "${NAMESPACE}" })}}`,
type: 'schedule', type: 'schedule',
description: `{{t("Event will be scheduled and triggered base on time conditions.", { ns: "${NAMESPACE}" })}}`,
fieldset: { fieldset: {
config: { config: {
type: 'void', type: 'void',
@ -26,7 +31,6 @@ export default {
useVariables(config, opts) { useVariables(config, opts) {
const compile = useCompile(); const compile = useCompile();
const { getCollectionFields } = useCollectionManager(); const { getCollectionFields } = useCollectionManager();
const { fieldNames = defaultFieldNames } = opts;
const options: any[] = []; const options: any[] = [];
if (!opts?.types || opts.types.includes('date')) { if (!opts?.types || opts.types.includes('date')) {
options.push({ key: 'date', value: 'date', label: lang('Trigger time') }); options.push({ key: 'date', value: 'date', label: lang('Trigger time') });
@ -49,7 +53,7 @@ export default {
uiSchema: { uiSchema: {
title: lang('Trigger data'), title: lang('Trigger data'),
}, },
} },
], ],
appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])], appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
compile, compile,

View File

@ -23,7 +23,25 @@ export default {
'Trigger data': '触发数据', 'Trigger data': '触发数据',
'Trigger time': '触发时间', 'Trigger time': '触发时间',
'Triggered at': '触发时间', 'Triggered at': '触发时间',
'Form event': '表单事件',
'Event triggers when submitted a workflow bound form action.': '在提交绑定工作流的表单操作按钮后触发。',
'Form data model': '表单数据模型',
'Use a collection to match form data.': '使用一个数据表来匹配表单数据。',
'Associations to use': '待使用的关系数据',
'Bind workflows': '绑定工作流',
'Workflow will be triggered after submitting succeeded.': '提交成功后触发工作流。',
'Workflow will be triggered after saving succeeded.': '保存成功后触发工作流。',
'Workflow will be triggered directly once the button clicked.': '按钮点击后直接触发工作流。',
'Submit to workflow': '提交至工作流',
'Add workflow': '添加工作流',
'Select workflow': '选择工作流',
'Trigger data context': '触发数据上下文',
'Full form data': '完整表单数据',
'Select context': '选择上下文',
'Collection event': '数据表事件', 'Collection event': '数据表事件',
'Event will be triggered on collection data row created, updated or deleted.':
'当数据表中的数据被新增、更新或删除时触发。',
'Trigger on': '触发时机', 'Trigger on': '触发时机',
'After record added': '新增数据后', 'After record added': '新增数据后',
'After record updated': '更新数据后', 'After record updated': '更新数据后',
@ -37,6 +55,7 @@ export default {
'Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.': 'Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.':
'请选中需要在后续节点中被访问的关系字段。超过两层的对多关联可能会导致性能问题,请谨慎使用。', '请选中需要在后续节点中被访问的关系字段。超过两层的对多关联可能会导致性能问题,请谨慎使用。',
'Schedule event': '定时任务', 'Schedule event': '定时任务',
'Scheduled job base on time conditions.': '基于时间条件的计划任务',
'Trigger mode': '触发模式', 'Trigger mode': '触发模式',
'Based on certain date': '自定义时间', 'Based on certain date': '自定义时间',
'Based on date field of collection': '根据数据表时间字段', 'Based on date field of collection': '根据数据表时间字段',

View File

@ -129,7 +129,13 @@ export default class WorkflowPlugin extends Plugin {
], ],
}); });
this.app.acl.registerSnippet({
name: 'ui.*',
actions: ['workflows:list'],
});
this.app.acl.allow('users_jobs', ['list', 'get', 'submit'], 'loggedIn'); this.app.acl.allow('users_jobs', ['list', 'get', 'submit'], 'loggedIn');
this.app.acl.allow('workflows', ['trigger'], 'loggedIn');
await db.import({ await db.import({
directory: path.resolve(__dirname, 'collections'), directory: path.resolve(__dirname, 'collections'),

View File

@ -2,6 +2,8 @@ import { CollectionOptions } from '@nocobase/database';
export default { export default {
name: 'posts', name: 'posts',
createdBy: true,
updatedBy: true,
fields: [ fields: [
{ {
type: 'string', type: 'string',

View File

@ -0,0 +1,354 @@
import Database from '@nocobase/database';
import { MockServer } from '@nocobase/test';
import { getApp, sleep } from '..';
import { EXECUTION_STATUS } from '../../constants';
describe('workflow > triggers > form', () => {
let app: MockServer;
let db: Database;
let agent;
let PostRepo;
let CommentRepo;
let WorkflowModel;
let UserModel;
let users;
let userAgents;
beforeEach(async () => {
app = await getApp({
plugins: ['users', 'auth'],
});
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;
users = await UserModel.bulkCreate([
{ id: 1, nickname: 'a' },
{ id: 2, nickname: 'b' },
]);
userAgents = users.map((user) => app.agent().login(user));
});
afterEach(() => app.stop());
describe('create', () => {
it('enabled / disabled', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'form',
config: {
collection: 'posts',
},
});
const res1 = await userAgents[0].resource('posts').create({
values: { title: 't1' },
triggerWorkflows: `${workflow.key}`,
});
expect(res1.status).toBe(200);
await sleep(500);
const e1 = await workflow.getExecutions();
expect(e1.length).toBe(1);
expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1[0].context.data).toMatchObject({ title: 't1' });
await workflow.update({
enabled: false,
});
const res2 = await userAgents[0].resource('posts').create({
values: { title: 't2' },
triggerWorkflows: `${workflow.key}`,
});
expect(res2.status).toBe(200);
await sleep(500);
const e2 = await workflow.getExecutions({ order: [['id', 'ASC']] });
expect(e2.length).toBe(1);
await workflow.update({
enabled: true,
});
const res3 = await userAgents[0].resource('posts').create({
values: { title: 't3' },
triggerWorkflows: `${workflow.key}`,
});
expect(res2.status).toBe(200);
await sleep(500);
const e3 = await workflow.getExecutions({ order: [['id', 'ASC']] });
expect(e3.length).toBe(2);
expect(e3[1].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e3[1].context.data).toMatchObject({ title: 't3' });
});
it('only trigger if params provided matching collection config', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'form',
config: {
collection: 'posts',
},
});
const res1 = await userAgents[0].resource('posts').create({
values: { title: 't1' },
triggerWorkflows: `${workflow.key}`,
});
expect(res1.status).toBe(200);
await sleep(500);
const e1 = await workflow.getExecutions();
expect(e1.length).toBe(1);
expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1[0].context.data).toMatchObject({ title: 't1' });
await workflow.update({
config: {
collection: 'comments',
},
});
const res2 = await userAgents[0].resource('posts').create({
values: { title: 't2' },
triggerWorkflows: `${workflow.key}`,
});
expect(res2.status).toBe(200);
await sleep(500);
const e2 = await workflow.getExecutions({ order: [['id', 'ASC']] });
expect(e2.length).toBe(1);
// expect(e2[1].status).toBe(EXECUTION_STATUS.RESOLVED);
// expect(e2[1].context.data).toMatchObject({ title: 't2' });
});
it('system fields could be accessed', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'form',
config: {
collection: 'posts',
},
});
const res1 = await userAgents[0].resource('posts').create({
values: { title: 't1' },
triggerWorkflows: `${workflow.key}`,
});
expect(res1.status).toBe(200);
await sleep(500);
const e1 = await workflow.getExecutions();
expect(e1.length).toBe(1);
expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1[0].context.data).toHaveProperty('createdAt');
});
it('appends', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'form',
config: {
collection: 'posts',
appends: ['createdBy'],
},
});
const res1 = await userAgents[0].resource('posts').create({
values: { title: 't1' },
triggerWorkflows: `${workflow.key}`,
});
expect(res1.status).toBe(200);
await sleep(500);
const e1 = await workflow.getExecutions();
expect(e1.length).toBe(1);
expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1[0].context.data).toHaveProperty('createdBy');
});
});
describe('update', () => {
it('trigger after updated', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'form',
config: {
collection: 'posts',
},
});
const res1 = await userAgents[0].resource('posts').create({
values: { title: 't1' },
});
expect(res1.status).toBe(200);
await sleep(500);
const e1 = await workflow.getExecutions();
expect(e1.length).toBe(0);
const res2 = await userAgents[0].resource('posts').update({
filterByTk: res1.body.data.id,
values: { title: 't2' },
triggerWorkflows: `${workflow.key}`,
});
await sleep(500);
const [e2] = await workflow.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e2.context.data).toHaveProperty('title', 't2');
});
});
describe('directly trigger', () => {
it('trigger data', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'form',
});
const res1 = await userAgents[0].resource('workflows').trigger({
values: { title: 't1' },
triggerWorkflows: `${workflow.key}`,
});
expect(res1.status).toBe(202);
await sleep(500);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1.context.data).toMatchObject({ title: 't1' });
});
it('multi trigger', async () => {
const w1 = await WorkflowModel.create({
enabled: true,
type: 'form',
});
const w2 = await WorkflowModel.create({
enabled: true,
type: 'form',
});
const res1 = await userAgents[0].resource('workflows').trigger({
values: { title: 't1' },
triggerWorkflows: `${w1.key},${w2.key}`,
});
expect(res1.status).toBe(202);
await sleep(500);
const [e1] = await w1.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1.context.data).toMatchObject({ title: 't1' });
const [e2] = await w2.getExecutions();
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e2.context.data).toMatchObject({ title: 't1' });
});
});
describe('context data path', () => {
it('level: 1', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'form',
});
const res1 = await userAgents[0].resource('workflows').trigger({
values: { title: 't1', category: { title: 'c1' } },
triggerWorkflows: `${workflow.key}!category`,
});
expect(res1.status).toBe(202);
await sleep(500);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1.context.data).toMatchObject({ title: 'c1' });
});
it('level: 2', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'form',
});
const res1 = await userAgents[0].resource('workflows').trigger({
values: { content: 'comment1', post: { category: { title: 'c1' } } },
triggerWorkflows: `${workflow.key}!post.category`,
});
expect(res1.status).toBe(202);
await sleep(500);
const [e1] = await workflow.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1.context.data).toMatchObject({ title: 'c1' });
});
});
describe('workflow key', () => {
it('revision', async () => {
const w1 = await WorkflowModel.create({
enabled: true,
type: 'form',
});
const res1 = await userAgents[0].resource('workflows').trigger({
values: { title: 't1' },
triggerWorkflows: `${w1.key}`,
});
expect(res1.status).toBe(202);
await sleep(500);
const [e1] = await w1.getExecutions();
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1.context.data).toMatchObject({ title: 't1' });
const res2 = await userAgents[0].resource('workflows').revision({
filterByTk: w1.id,
filter: {
key: w1.key,
},
});
const w2 = await WorkflowModel.findByPk(res2.body.data.id);
await w2.update({
enabled: true,
});
const res3 = await userAgents[0].resource('workflows').trigger({
values: { title: 't2' },
triggerWorkflows: `${w1.key}`,
});
expect(res3.status).toBe(202);
await sleep(500);
const e2 = await w1.getExecutions();
expect(e2.length).toBe(1);
const e3 = await w2.getExecutions();
expect(e3.length).toBe(1);
expect(e3[0].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e3[0].context.data).toMatchObject({ title: 't2' });
});
});
});

View File

@ -0,0 +1,99 @@
import { get } from 'lodash';
import { Trigger } from '.';
import Plugin from '..';
import { WorkflowModel } from '../types';
import { Model, modelAssociationByKey } from '@nocobase/database';
import { BelongsTo, HasOne } from 'sequelize';
export default class FormTrigger extends Trigger {
constructor(plugin: Plugin) {
super(plugin);
plugin.app.resourcer.use(this.middleware);
plugin.app.actions({
['workflows:trigger']: this.triggerAction,
});
}
triggerAction = async (context, next) => {
const { triggerWorkflows } = context.action.params;
if (!triggerWorkflows) {
return context.throw(400);
}
context.status = 202;
await next();
this.trigger(context);
};
middleware = async (context, next) => {
await next();
const { resourceName, actionName } = context.action;
if ((resourceName === 'workflows' && actionName === 'trigger') || !['create', 'update'].includes(actionName)) {
return;
}
this.trigger(context);
};
async trigger(context) {
const { triggerWorkflows, values } = context.action.params;
if (!triggerWorkflows) {
return;
}
const triggers = triggerWorkflows.split(',').map((trigger) => trigger.split('!'));
const workflowRepo = this.plugin.db.getRepository('workflows');
const workflows = await workflowRepo.find({
filter: {
key: triggers.map((trigger) => trigger[0]),
current: true,
type: 'form',
enabled: true,
},
});
workflows.forEach((workflow) => {
const trigger = triggers.find((trigger) => trigger[0] == workflow.key);
if (context.body?.data) {
const { data } = context.body;
(Array.isArray(data) ? data : [data]).forEach(async (row: Model) => {
let payload = row;
if (trigger[1]) {
const paths = trigger[1].split('.');
for await (const field of paths) {
if (payload.get(field)) {
payload = payload.get(field);
} else {
const association = <HasOne | BelongsTo>modelAssociationByKey(payload, field);
payload = await payload[association.accessors.get]();
}
}
}
const { collection, appends = [] } = workflow.config;
const model = <typeof Model>payload.constructor;
if (collection !== model.collection.name) {
return;
}
if (appends.length) {
payload = await model.collection.repository.findOne({
filterByTk: payload.get(model.primaryKeyAttribute),
appends,
});
}
this.plugin.trigger(workflow, { data: payload });
});
} else {
this.plugin.trigger(workflow, { data: trigger[1] ? get(values, trigger[1]) : values });
}
});
}
on(workflow: WorkflowModel) {}
off(workflow: WorkflowModel) {}
}

View File

@ -1,5 +1,5 @@
import path from 'path';
import { requireModule } from '@nocobase/utils'; import { requireModule } from '@nocobase/utils';
import path from 'path';
import Plugin from '..'; import Plugin from '..';
import type { WorkflowModel } from '../types'; import type { WorkflowModel } from '../types';
@ -14,6 +14,7 @@ export default function <T extends Trigger>(plugin, more: { [key: string]: { new
const { triggers } = plugin; const { triggers } = plugin;
triggers.register('collection', new (requireModule(path.join(__dirname, 'collection')))(plugin)); triggers.register('collection', new (requireModule(path.join(__dirname, 'collection')))(plugin));
triggers.register('form', new (requireModule(path.join(__dirname, 'form')))(plugin));
triggers.register('schedule', new (requireModule(path.join(__dirname, 'schedule')))(plugin)); triggers.register('schedule', new (requireModule(path.join(__dirname, 'schedule')))(plugin));
for (const [name, TClass] of Object.entries(more)) { for (const [name, TClass] of Object.entries(more)) {