mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 04:39:34 +00:00
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:
parent
ceea649276
commit
86e672e9bb
@ -50,8 +50,7 @@
|
||||
"prettier --write"
|
||||
],
|
||||
"*.ts?(x)": [
|
||||
"eslint --fix",
|
||||
"prettier --parser=typescript --write"
|
||||
"eslint --fix"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -56,6 +56,8 @@ const useResource = (props: UseResourceProps) => {
|
||||
const association = useAssociation(props);
|
||||
const sourceId = useSourceId?.();
|
||||
const field = useField<Field>();
|
||||
const withoutTableFieldResource = useContext(WithoutTableFieldResource);
|
||||
const __parent = useContext(BlockRequestContext);
|
||||
if (block === 'TableField') {
|
||||
const options = {
|
||||
field,
|
||||
@ -68,8 +70,6 @@ const useResource = (props: UseResourceProps) => {
|
||||
return new TableFieldResource(options);
|
||||
}
|
||||
|
||||
const withoutTableFieldResource = useContext(WithoutTableFieldResource);
|
||||
const __parent = useContext(BlockRequestContext);
|
||||
if (
|
||||
!withoutTableFieldResource &&
|
||||
__parent?.block === 'TableField' &&
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { cloneDeep } from 'lodash';
|
||||
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) => {
|
||||
if (typeof value !== 'object') {
|
||||
return value;
|
||||
@ -84,7 +72,7 @@ function getFormValues(filterByTk, field, form, fieldNames, getField, resource)
|
||||
return omit({ ...form.values }, keys);
|
||||
}
|
||||
}
|
||||
console.log('form.values', form.values);
|
||||
|
||||
return form.values;
|
||||
const values = {};
|
||||
for (const key in form.values) {
|
||||
@ -151,8 +139,13 @@ export const useCreateActionProps = () => {
|
||||
return {
|
||||
async onClick() {
|
||||
const fieldNames = fields.map((field) => field.name);
|
||||
const { assignedValues: originalAssignedValues = {}, onSuccess, overwriteValues, skipValidator } =
|
||||
actionSchema?.['x-action-settings'] ?? {};
|
||||
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) {
|
||||
@ -175,6 +168,10 @@ export const useCreateActionProps = () => {
|
||||
...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;
|
||||
@ -718,8 +715,13 @@ export const useUpdateActionProps = () => {
|
||||
|
||||
return {
|
||||
async onClick() {
|
||||
const { assignedValues: originalAssignedValues = {}, onSuccess, overwriteValues, skipValidator } =
|
||||
actionSchema?.['x-action-settings'] ?? {};
|
||||
const {
|
||||
assignedValues: originalAssignedValues = {},
|
||||
onSuccess,
|
||||
overwriteValues,
|
||||
skipValidator,
|
||||
triggerWorkflows,
|
||||
} = actionSchema?.['x-action-settings'] ?? {};
|
||||
const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser });
|
||||
if (!skipValidator) {
|
||||
await form.submit();
|
||||
@ -738,6 +740,10 @@ export const useUpdateActionProps = () => {
|
||||
},
|
||||
...data,
|
||||
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;
|
||||
__parent?.service?.refresh?.();
|
||||
|
@ -797,6 +797,7 @@ export default {
|
||||
"Date display format":"日期显示格式",
|
||||
"Assign data scope for the template":"为模板指定数据范围",
|
||||
"Table selected records":"表格中选中的记录",
|
||||
|
||||
"Tag":"标签",
|
||||
"Tag color field":"标签颜色字段",
|
||||
"Sync successfully":"同步成功",
|
||||
|
@ -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 { Tree as AntdTree } from 'antd';
|
||||
import { Alert, Tree as AntdTree } from 'antd';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDesignable } from '../..';
|
||||
import { RemoteSelect, useCompile, useDesignable } from '../..';
|
||||
import { useCollection, useCollectionManager } from '../../../collection-manager';
|
||||
import { OpenModeSchemaItems } from '../../../schema-items';
|
||||
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
|
||||
@ -13,6 +14,7 @@ import { useLinkageAction } from './hooks';
|
||||
import { requestSettingsSchema } from './utils';
|
||||
import { useRecord } from '../../../record-provider';
|
||||
import { useSyncFromForm } from '../../../schema-settings/DataTemplates/utils';
|
||||
import { onFieldValueChange } from '@formily/core';
|
||||
|
||||
const Tree = connect(
|
||||
AntdTree,
|
||||
@ -32,30 +34,15 @@ const Tree = connect(
|
||||
);
|
||||
const MenuGroup = (props) => {
|
||||
const fieldSchema = useFieldSchema();
|
||||
const actionType = fieldSchema['x-action'] || '';
|
||||
const { t } = useTranslation();
|
||||
const actionTitles = {
|
||||
'customize:popup': t('Popup'),
|
||||
'customize:update': t('Update record'),
|
||||
'customize:save': t('Save record'),
|
||||
'customize:table:request': t('Custom request'),
|
||||
'customize:form:request': t('Custom request'),
|
||||
};
|
||||
if (
|
||||
![
|
||||
'customize:popup',
|
||||
'customize:update',
|
||||
'customize:save',
|
||||
'customize:table:request',
|
||||
'customize:form:request',
|
||||
].includes(actionType)
|
||||
) {
|
||||
return <>{props.children}</>;
|
||||
const compile = useCompile();
|
||||
const actionTitle = fieldSchema.title ? compile(fieldSchema.title) : '';
|
||||
const actionType = fieldSchema['x-action'] ?? '';
|
||||
if (!actionType.startsWith('customize:') || !actionTitle) {
|
||||
return props.children;
|
||||
}
|
||||
return (
|
||||
<SchemaSettings.ItemGroup title={`${t('Customize')} > ${actionTitles[actionType]}`}>
|
||||
{props.children}
|
||||
</SchemaSettings.ItemGroup>
|
||||
<SchemaSettings.ItemGroup title={`${t('Customize')} > ${actionTitle}`}>{props.children}</SchemaSettings.ItemGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@ -568,30 +555,15 @@ function AfterSuccess() {
|
||||
const { dn } = useDesignable();
|
||||
const { t } = useTranslation();
|
||||
const fieldSchema = useFieldSchema();
|
||||
const actionType = fieldSchema['x-action'] ?? '';
|
||||
|
||||
return (
|
||||
<SchemaSettings.ModalItem
|
||||
title={
|
||||
{
|
||||
'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]
|
||||
}
|
||||
title={t('After successful submission')}
|
||||
initialValues={fieldSchema?.['x-action-settings']?.['onSuccess']}
|
||||
schema={
|
||||
{
|
||||
type: 'object',
|
||||
title: {
|
||||
'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],
|
||||
title: t('After successful submission'),
|
||||
properties: {
|
||||
successMessage: {
|
||||
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) => {
|
||||
const { modalTip, linkageAction, ...restProps } = props;
|
||||
const fieldSchema = useFieldSchema();
|
||||
@ -702,6 +852,7 @@ export const ActionDesigner = (props) => {
|
||||
{isValid(fieldSchema?.['x-action-settings']?.requestSettings) && <RequestSettings />}
|
||||
{isValid(fieldSchema?.['x-action-settings']?.skipValidator) && <SkipValidation />}
|
||||
{isValid(fieldSchema?.['x-action-settings']?.['onSuccess']) && <AfterSuccess />}
|
||||
{isValid(fieldSchema?.['x-action-settings']?.triggerWorkflows) && <WorkflowConfig />}
|
||||
|
||||
{isChildCollectionAction && <SchemaSettings.EnableChildCollections collectionName={name} />}
|
||||
|
||||
|
@ -35,11 +35,11 @@ export const Action: ComposedAction = observer(
|
||||
className,
|
||||
icon,
|
||||
title,
|
||||
onClick,
|
||||
...others
|
||||
} = props;
|
||||
} = useProps(props);
|
||||
const { wrapSSR, componentCls, hashId } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
const { onClick } = useProps(props);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [formValueChanged, setFormValueChanged] = useState(false);
|
||||
const Designer = useDesigner();
|
||||
|
@ -1,14 +1,20 @@
|
||||
import { CollectionFieldOptions, useCollectionManager, useCompile } from '../../..';
|
||||
import { Tag, TreeSelect } from 'antd';
|
||||
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 { CollectionFieldOptions, useCollectionManager, useCompile } from '../../..';
|
||||
|
||||
export type AppendsTreeSelectProps = {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
value: string[] | string;
|
||||
onChange: (value: string[] | string) => void;
|
||||
multiple?: boolean;
|
||||
filter?(field): boolean;
|
||||
collection?: string;
|
||||
useCollection?(props: Pick<AppendsTreeSelectProps, 'collection'>): string;
|
||||
rootOption?: {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
|
||||
type TreeOptionType = Omit<DefaultOptionType, 'value'> & { value: string };
|
||||
@ -20,7 +26,8 @@ function usePropsCollection({ collection }) {
|
||||
type CallScope = {
|
||||
compile?(value: string): string;
|
||||
getCollectionFields?(name: any): CollectionFieldOptions[];
|
||||
}
|
||||
filter(field): boolean;
|
||||
};
|
||||
|
||||
function loadChildren(this, option) {
|
||||
const result = getCollectionFieldOptions.call(this, option.field.target, option);
|
||||
@ -38,13 +45,17 @@ function isAssociation(field) {
|
||||
return field.target && field.interface;
|
||||
}
|
||||
|
||||
function trueFilter(field) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function getCollectionFieldOptions(this: CallScope, collection, parentNode?): TreeOptionType[] {
|
||||
const fields = this.getCollectionFields(collection).filter(isAssociation);
|
||||
const boundLoadChildren = loadChildren.bind(this);
|
||||
return fields.map((field) => {
|
||||
const key = parentNode ? `${parentNode.value}.${field.name}` : field.name;
|
||||
return fields.filter(this.filter).map((field) => {
|
||||
const key = parentNode ? `${parentNode.value ? `${parentNode.value}.` : ''}${field.name}` : 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 {
|
||||
pId: parentNode?.key ?? null,
|
||||
id: key,
|
||||
@ -60,35 +71,71 @@ function getCollectionFieldOptions(this: CallScope, collection, parentNode?): Tr
|
||||
}
|
||||
|
||||
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 compile = useCompile();
|
||||
const { t } = useTranslation();
|
||||
const [optionsMap, setOptionsMap] = useState({});
|
||||
const baseCollection = useCollection({ collection });
|
||||
const treeData = Object.values(optionsMap);
|
||||
const value: string | DefaultOptionType[] = useMemo(() => {
|
||||
if (props.multiple) {
|
||||
return ((propsValue as string[]) || []).map((v) => optionsMap[v]).filter(Boolean);
|
||||
}
|
||||
return propsValue;
|
||||
}, [propsValue, props.multiple, optionsMap]);
|
||||
|
||||
const loadData = useCallback(async (option) => {
|
||||
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]);
|
||||
},
|
||||
[setOptionsMap],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const treeData = getCollectionFieldOptions.call({ compile, getCollectionFields }, baseCollection);
|
||||
setOptionsMap(treeData.reduce((result, item) => Object.assign(result, { [item.value]: item }), {}));
|
||||
}, [collection, baseCollection]);
|
||||
const parentNode = rootOption
|
||||
? {
|
||||
...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(() => {
|
||||
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;
|
||||
}
|
||||
const loaded = [];
|
||||
|
||||
value.forEach((v) => {
|
||||
arr.forEach((v) => {
|
||||
const paths = v.split('.');
|
||||
let option = optionsMap[paths[0]];
|
||||
for (let i = 1; i < paths.length; i++) {
|
||||
@ -104,7 +151,7 @@ export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
|
||||
const children = option.loadChildren(option);
|
||||
if (children?.length) {
|
||||
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,18 +159,22 @@ export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
|
||||
setOptionsMap((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 newValue = newNodes.map((i) => i.value).filter(Boolean) as string[];
|
||||
const handleChange = useCallback(
|
||||
(next: DefaultOptionType[] | string) => {
|
||||
if (!props.multiple) {
|
||||
onChange(next as string);
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = (next as DefaultOptionType[]).map((i) => i.value).filter(Boolean) as string[];
|
||||
const valueSet = new Set(newValue);
|
||||
const delValue = value.find((i) => !newValue.includes(i));
|
||||
const delValue = (value as DefaultOptionType[]).find((i) => !valueSet.has(i.value as string));
|
||||
|
||||
if (delValue) {
|
||||
const delNode = optionsMap[delValue];
|
||||
const prefix = `${delNode.value}.`;
|
||||
Object.keys(optionsMap)
|
||||
.forEach((key) => {
|
||||
const prefix = `${delValue.value}.`;
|
||||
Object.keys(optionsMap).forEach((key) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
valueSet.delete(key);
|
||||
}
|
||||
@ -132,37 +183,51 @@ export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
|
||||
newValue.forEach((v) => {
|
||||
const paths = v.split('.');
|
||||
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('.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
onChange(Array.from(valueSet));
|
||||
}, [value, optionsMap]);
|
||||
|
||||
const TreeTag = useCallback((props) => {
|
||||
const { value, onClose, disabled, closable } = props;
|
||||
const { fullTitle } = optionsMap[value];
|
||||
return (
|
||||
<Tag closable={closable && !disabled} onClose={onClose}>{fullTitle.join(' / ')}</Tag>
|
||||
},
|
||||
[props.multiple, value, onChange, optionsMap],
|
||||
);
|
||||
}, [optionsMap]);
|
||||
|
||||
const filterdValue = Array.isArray(value) ? value.filter((i) => i in optionsMap) : value;
|
||||
const TreeTag = useCallback(
|
||||
(props) => {
|
||||
const { value, onClose, disabled, closable } = props;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const { fullTitle } = optionsMap[value] ?? {};
|
||||
return (
|
||||
<Tag closable={closable && !disabled} onClose={onClose}>
|
||||
{fullTitle?.join(' / ')}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
[optionsMap],
|
||||
);
|
||||
|
||||
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 (
|
||||
<TreeSelect
|
||||
value={filterdValue}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||
value={filteredValue}
|
||||
placeholder={t('Select field')}
|
||||
showCheckedStrategy="SHOW_ALL"
|
||||
showCheckedStrategy={TreeSelect.SHOW_ALL}
|
||||
treeDefaultExpandedKeys={valueKeys}
|
||||
allowClear
|
||||
multiple
|
||||
treeCheckStrictly
|
||||
treeCheckable
|
||||
treeCheckStrictly={props.multiple}
|
||||
treeCheckable={props.multiple}
|
||||
tagRender={TreeTag}
|
||||
onChange={handleChange as unknown as () => void}
|
||||
onChange={handleChange as (next) => void}
|
||||
treeDataSimpleMode
|
||||
treeData={treeData}
|
||||
loadData={loadData}
|
||||
|
@ -55,7 +55,7 @@ export const getTabFormatValue = (labelUiSchema: ISchema, value: any, tagColor):
|
||||
if (Array.isArray(options) && value) {
|
||||
const values = toArr(value).map((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;
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ ReadPretty.ColorPicker = function ColorPicker(props: any) {
|
||||
|
||||
return (
|
||||
<div className={cls(prefixCls, props.className)}>
|
||||
<ColorPicker showText disabled value={props.value} size='small'/>
|
||||
<ColorPicker showText disabled value={props.value} size="small" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -5,7 +5,9 @@ const toStringByPicker = (value, picker, timezone: 'gmt' | 'local') => {
|
||||
if (!dayjs.isDayjs(value)) return value;
|
||||
if (timezone === 'local') {
|
||||
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') {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { DeleteOutlined, MenuOutlined } from '@ant-design/icons';
|
||||
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 { ArrayField, Field } from '@formily/core';
|
||||
import { spliceArrayState } from '@formily/core/esm/shared/internals';
|
||||
@ -454,13 +454,14 @@ export const Table: any = observer(
|
||||
const SortableWrapper = useCallback<React.FC>(
|
||||
({ children }) => {
|
||||
return dragSort
|
||||
? React.createElement(SortableContext, {
|
||||
? React.createElement<Omit<SortableContextProps, 'children'>>(
|
||||
SortableContext,
|
||||
{
|
||||
items: field.value?.map?.(getRowKey) || [],
|
||||
},
|
||||
children,
|
||||
})
|
||||
: React.createElement(React.Fragment, {
|
||||
children,
|
||||
});
|
||||
)
|
||||
: React.createElement(React.Fragment, {}, children);
|
||||
},
|
||||
[field, dragSort],
|
||||
);
|
||||
@ -530,7 +531,7 @@ export const Table: any = observer(
|
||||
{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">
|
||||
{field.errors.map((error) => {
|
||||
return error.messages.map((message) => <div>{message}</div>);
|
||||
return error.messages.map((message) => <div key={message}>{message}</div>);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
@ -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 = {
|
||||
title: '{{t("Configure actions")}}',
|
||||
@ -94,14 +120,16 @@ export const FormActionInitializers = {
|
||||
onSuccess: {
|
||||
manualClose: true,
|
||||
redirecting: false,
|
||||
successMessage: '{{t("Saved successfully")}}',
|
||||
successMessage: '{{t("Submitted successfully")}}',
|
||||
},
|
||||
triggerWorkflows: [],
|
||||
},
|
||||
'x-component-props': {
|
||||
useProps: '{{ useCreateActionProps }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
FormTriggerWorkflowActionInitializer,
|
||||
{
|
||||
type: 'item',
|
||||
title: '{{t("Custom request")}}',
|
||||
@ -225,14 +253,16 @@ export const CreateFormActionInitializers = {
|
||||
onSuccess: {
|
||||
manualClose: true,
|
||||
redirecting: false,
|
||||
successMessage: '{{t("Saved successfully")}}',
|
||||
successMessage: '{{t("Submitted successfully")}}',
|
||||
},
|
||||
triggerWorkflows: [],
|
||||
},
|
||||
'x-component-props': {
|
||||
useProps: '{{ useCreateActionProps }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
FormTriggerWorkflowActionInitializer,
|
||||
{
|
||||
type: 'item',
|
||||
title: '{{t("Custom request")}}',
|
||||
@ -355,14 +385,16 @@ export const UpdateFormActionInitializers = {
|
||||
onSuccess: {
|
||||
manualClose: true,
|
||||
redirecting: false,
|
||||
successMessage: '{{t("Saved successfully")}}',
|
||||
successMessage: '{{t("Submitted successfully")}}',
|
||||
},
|
||||
triggerWorkflows: [],
|
||||
},
|
||||
'x-component-props': {
|
||||
useProps: '{{ useUpdateActionProps }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
FormTriggerWorkflowActionInitializer,
|
||||
{
|
||||
type: 'item',
|
||||
title: '{{t("Custom request")}}',
|
||||
@ -378,7 +410,7 @@ export const UpdateFormActionInitializers = {
|
||||
onSuccess: {
|
||||
manualClose: false,
|
||||
redirecting: false,
|
||||
successMessage: '{{t("Request success")}}',
|
||||
successMessage: '{{t("Submitted successfully")}}',
|
||||
},
|
||||
},
|
||||
'x-component-props': {
|
||||
@ -485,7 +517,7 @@ export const BulkEditFormActionInitializers = {
|
||||
onSuccess: {
|
||||
manualClose: true,
|
||||
redirecting: false,
|
||||
successMessage: '{{t("Saved successfully")}}',
|
||||
successMessage: '{{t("Submitted successfully")}}',
|
||||
},
|
||||
},
|
||||
'x-component-props': {
|
||||
|
@ -13,6 +13,9 @@ export const CreateSubmitActionInitializer = (props) => {
|
||||
htmlType: 'submit',
|
||||
useProps: '{{ useCreateActionProps }}',
|
||||
},
|
||||
'x-action-settings': {
|
||||
triggerWorkflows: [],
|
||||
},
|
||||
};
|
||||
return <ActionInitializer {...props} schema={schema} />;
|
||||
};
|
||||
|
@ -13,6 +13,9 @@ export const UpdateSubmitActionInitializer = (props) => {
|
||||
htmlType: 'submit',
|
||||
useProps: '{{ useUpdateActionProps }}',
|
||||
},
|
||||
'x-action-settings': {
|
||||
triggerWorkflows: [],
|
||||
},
|
||||
};
|
||||
return <ActionInitializer {...props} schema={schema} />;
|
||||
};
|
||||
|
@ -100,9 +100,7 @@ export const useSyncFromForm = (fieldSchema, collection?, callBack?) => {
|
||||
cache.set(cacheKey, result);
|
||||
return result;
|
||||
};
|
||||
})(
|
||||
new LRUCache<string, any>({ max: 100 }),
|
||||
);
|
||||
})(new LRUCache<string, any>({ max: 100 }));
|
||||
|
||||
const traverseAssociations = ((cache) => {
|
||||
return (collectionName, { prefix, maxDepth, depth = 0, exclude = [] }) => {
|
||||
@ -149,9 +147,7 @@ export const useSyncFromForm = (fieldSchema, collection?, callBack?) => {
|
||||
cache.set(cacheKey, result);
|
||||
return result;
|
||||
};
|
||||
})(
|
||||
new LRUCache<string, any>({ max: 100 }),
|
||||
);
|
||||
})(new LRUCache<string, any>({ max: 100 }));
|
||||
const getEnableFieldTree = useCallback((collectionName: string, formData) => {
|
||||
if (!collectionName) {
|
||||
return [];
|
||||
|
@ -16,3 +16,4 @@ export * from './registry';
|
||||
// export * from './toposort';
|
||||
export * from './uid';
|
||||
export { dayjs, lodash };
|
||||
export * from './url';
|
||||
|
11
packages/core/utils/src/url.ts
Normal file
11
packages/core/utils/src/url.ts
Normal 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:';
|
||||
}
|
@ -382,11 +382,11 @@ export const GraphDrawPage = React.memo(() => {
|
||||
const categoryCtx = useContext(CollectionCategroriesContext);
|
||||
const scope = { ...options?.scope };
|
||||
const components = { ...options?.components };
|
||||
const useSaveGraphPositionAction = async (data) => {
|
||||
const saveGraphPositionAction = async (data) => {
|
||||
await api.resource('graphPositions').create({ values: data });
|
||||
await refreshPositions();
|
||||
};
|
||||
const useUpdatePositionAction = async (data, isbatch = false) => {
|
||||
const updatePositionAction = async (data, isbatch = false) => {
|
||||
if (isbatch) {
|
||||
await api.resource('graphPositions').update({
|
||||
values: data,
|
||||
@ -413,7 +413,7 @@ export const GraphDrawPage = React.memo(() => {
|
||||
const refreshGM = async () => {
|
||||
const data = await refreshCM();
|
||||
targetGraph.collections = data;
|
||||
targetGraph.updatePositionAction = useUpdatePositionAction;
|
||||
targetGraph.updatePositionAction = updatePositionAction;
|
||||
const currentNodes = targetGraph.getNodes();
|
||||
setCollectionData(data);
|
||||
setCollectionList(data);
|
||||
@ -568,12 +568,12 @@ export const GraphDrawPage = React.memo(() => {
|
||||
const oldPosition = targetGraph.positions.find((v) => v.collectionName === node.store.data.name);
|
||||
if (oldPosition) {
|
||||
(oldPosition.x !== currentPosition.x || oldPosition.y !== currentPosition.y) &&
|
||||
useUpdatePositionAction({
|
||||
updatePositionAction({
|
||||
collectionName: node.store.data.name,
|
||||
...currentPosition,
|
||||
});
|
||||
} else {
|
||||
useSaveGraphPositionAction({
|
||||
saveGraphPositionAction({
|
||||
collectionName: node.store.data.name,
|
||||
...currentPosition,
|
||||
});
|
||||
@ -609,7 +609,7 @@ export const GraphDrawPage = React.memo(() => {
|
||||
|
||||
const handleEdgeUnActive = (targetEdge) => {
|
||||
targetGraph.activeEdge = null;
|
||||
const { m2m, connectionType } = targetEdge.store?.data;
|
||||
const { m2m, connectionType } = targetEdge.store?.data ?? {};
|
||||
const m2mLineId = m2m?.find((v) => v !== targetEdge.id);
|
||||
const m2mEdge = targetGraph.getCellById(m2mLineId);
|
||||
const lightsOut = (edge) => {
|
||||
@ -646,7 +646,7 @@ export const GraphDrawPage = React.memo(() => {
|
||||
};
|
||||
const handleEdgeActive = (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 m2mEdge = targetGraph.getCellById(m2mLineId);
|
||||
const lightUp = (edge) => {
|
||||
@ -702,7 +702,7 @@ export const GraphDrawPage = React.memo(() => {
|
||||
getNodes(nodesData);
|
||||
getEdges(edgesData);
|
||||
getEdges(inheritEdges);
|
||||
layout(useSaveGraphPositionAction);
|
||||
layout(saveGraphPositionAction);
|
||||
};
|
||||
|
||||
// 增量渲染
|
||||
@ -721,18 +721,19 @@ export const GraphDrawPage = React.memo(() => {
|
||||
const diffNodes = getDiffNode(nodesData, currentNodes);
|
||||
const diffEdges = getDiffEdge(edgesData, currentEdgesGroup.currentRelateEdges || []);
|
||||
const diffInheritEdge = getDiffEdge(inheritEdges, currentEdgesGroup.currentInheritEdges || []);
|
||||
diffNodes.forEach(({ status, node, port }) => {
|
||||
const updateNode = targetGraph.getCellById(node.id);
|
||||
switch (status) {
|
||||
case 'add':
|
||||
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 }) => {
|
||||
const updateNode = targetGraph.getCellById(node.id);
|
||||
switch (status) {
|
||||
case 'add':
|
||||
if (referenceNode.x > 4500) {
|
||||
const minX = minBy(positions, 'x').x;
|
||||
referenceNode = minBy(yNodes, 'x');
|
||||
position = { x: minX, y: referenceNode.y + 400 };
|
||||
} else {
|
||||
@ -742,7 +743,7 @@ export const GraphDrawPage = React.memo(() => {
|
||||
...node,
|
||||
position,
|
||||
});
|
||||
useSaveGraphPositionAction({
|
||||
saveGraphPositionAction({
|
||||
collectionName: node.name,
|
||||
...position,
|
||||
});
|
||||
@ -759,17 +760,18 @@ export const GraphDrawPage = React.memo(() => {
|
||||
break;
|
||||
case 'delete':
|
||||
targetGraph.removeCell(node.id);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const renderDiffEdges = (data) => {
|
||||
data.forEach(({ status, edge }) => {
|
||||
switch (status) {
|
||||
case 'add':
|
||||
const newEdge = targetGraph.addEdge({
|
||||
...edge,
|
||||
});
|
||||
switch (status) {
|
||||
case 'add':
|
||||
optimizeEdge(newEdge);
|
||||
break;
|
||||
case 'delete':
|
||||
@ -1010,8 +1012,12 @@ export const GraphDrawPage = React.memo(() => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshPositions().then(() => {
|
||||
refreshPositions()
|
||||
.then(() => {
|
||||
refreshGM();
|
||||
})
|
||||
.catch((err) => {
|
||||
throw err;
|
||||
});
|
||||
}, []);
|
||||
const loadCollections = async () => {
|
||||
|
@ -169,6 +169,7 @@ export const snapshot: IField = {
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'AppendsTreeSelect',
|
||||
'x-component-props': {
|
||||
multiple: true,
|
||||
useCollection: useRecordCollection,
|
||||
},
|
||||
'x-reactions': [
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { createStyles, cx } from '@nocobase/client';
|
||||
import { Tag } from 'antd';
|
||||
import React from 'react';
|
||||
import { lang } from '../locale';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => {
|
||||
return {
|
||||
@ -16,6 +15,7 @@ const useStyles = createStyles(({ css, token }) => {
|
||||
|
||||
dl {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
dt {
|
||||
color: ${token.colorText};
|
||||
@ -33,19 +33,19 @@ const useStyles = createStyles(({ css, token }) => {
|
||||
};
|
||||
});
|
||||
|
||||
export function NodeDescription(props) {
|
||||
const { instruction } = props;
|
||||
export function DrawerDescription(props) {
|
||||
const { label, title, description } = props;
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, props.className)}>
|
||||
<dl>
|
||||
<dt>{lang('Node type')}</dt>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
<Tag style={{ background: 'none' }}>{instruction.title}</Tag>
|
||||
<Tag style={{ background: 'none' }}>{title}</Tag>
|
||||
</dd>
|
||||
</dl>
|
||||
{instruction.description ? <p>{instruction.description}</p> : null}
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -4,7 +4,7 @@ export * from './nodes';
|
||||
export { triggers } from './triggers';
|
||||
export { useWorkflowVariableOptions } from './variable';
|
||||
|
||||
import { Plugin, useCollectionDataSource } from '@nocobase/client';
|
||||
import { Plugin } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
import { ExecutionPage } from './ExecutionPage';
|
||||
import { WorkflowPage } from './WorkflowPage';
|
||||
@ -12,17 +12,19 @@ import { WorkflowProvider } from './WorkflowProvider';
|
||||
import { DynamicExpression } from './components/DynamicExpression';
|
||||
import { WorkflowTodo } from './nodes/manual/WorkflowTodo';
|
||||
import { WorkflowTodoBlockInitializer } from './nodes/manual/WorkflowTodoBlockInitializer';
|
||||
import { useTriggerWorkflowsActionProps } from './triggers/form';
|
||||
|
||||
export class WorkflowPlugin extends Plugin {
|
||||
async load() {
|
||||
this.addRoutes();
|
||||
this.addScopes();
|
||||
this.addComponents();
|
||||
this.app.use(WorkflowProvider);
|
||||
}
|
||||
|
||||
addScopes() {
|
||||
this.app.addScopes({
|
||||
useCollectionDataSource,
|
||||
useTriggerWorkflowsActionProps,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -18,7 +18,7 @@ import React, { useContext, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AddButton } from '../AddButton';
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
import { NodeDescription } from '../components/NodeDescription';
|
||||
import { DrawerDescription } from '../components/DrawerDescription';
|
||||
import { JobStatusOptionsMap } from '../constants';
|
||||
import { NAMESPACE, lang } from '../locale';
|
||||
import useStyles from '../style';
|
||||
@ -426,9 +426,11 @@ export function NodeDefaultView(props) {
|
||||
? {
|
||||
description: {
|
||||
type: 'void',
|
||||
'x-component': NodeDescription,
|
||||
'x-component': DrawerDescription,
|
||||
'x-component-props': {
|
||||
instruction,
|
||||
label: lang('Node type'),
|
||||
title: instruction.title,
|
||||
description: instruction.description,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -56,6 +56,7 @@ export const appends = {
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'AppendsTreeSelect',
|
||||
'x-component-props': {
|
||||
multiple: true,
|
||||
useCollection() {
|
||||
const { values } = useForm();
|
||||
return values?.collection;
|
||||
|
@ -243,7 +243,9 @@ const useStyles = createStyles(({ css, token }) => {
|
||||
font-weight: bold;
|
||||
|
||||
&: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};
|
||||
background-color: ${token.colorBgContainerDisabled};
|
||||
|
||||
|
@ -27,6 +27,7 @@ const collectionModeOptions = [
|
||||
export default {
|
||||
title: `{{t("Collection event", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'collection',
|
||||
description: `{{t("Event will be triggered on collection data row created, updated or deleted.", { ns: "${NAMESPACE}" })}}`,
|
||||
fieldset: {
|
||||
collection: {
|
||||
...collection,
|
||||
|
195
packages/plugins/workflow/src/client/triggers/form.tsx
Normal file
195
packages/plugins/workflow/src/client/triggers/form.tsx
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
@ -16,10 +16,12 @@ import { Registry } from '@nocobase/utils/client';
|
||||
import { Alert, Button, Input, Tag, message } from 'antd';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
import { DrawerDescription } from '../components/DrawerDescription';
|
||||
import { NAMESPACE, lang } from '../locale';
|
||||
import useStyles from '../style';
|
||||
import { VariableOptions } from '../variable';
|
||||
import collection from './collection';
|
||||
import formTrigger from './form';
|
||||
import schedule from './schedule/';
|
||||
|
||||
function useUpdateConfigAction() {
|
||||
@ -50,6 +52,7 @@ function useUpdateConfigAction() {
|
||||
export interface Trigger {
|
||||
title: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
// group: string;
|
||||
useVariables?(config: any, options?): VariableOptions;
|
||||
fieldset: { [key: string]: ISchema };
|
||||
@ -62,6 +65,7 @@ export interface Trigger {
|
||||
|
||||
export const triggers = new Registry<Trigger>();
|
||||
|
||||
triggers.register(formTrigger.type, formTrigger);
|
||||
triggers.register(collection.type, collection);
|
||||
triggers.register(schedule.type, schedule);
|
||||
|
||||
@ -168,7 +172,7 @@ export const TriggerConfig = () => {
|
||||
const { fieldset, scope, components } = trigger;
|
||||
typeTitle = trigger.title;
|
||||
const detailText = executed ? '{{t("View")}}' : '{{t("Configure")}}';
|
||||
const titleText = `${lang('Trigger')}: ${compile(typeTitle)}`;
|
||||
const titleText = lang('Trigger');
|
||||
|
||||
async function onChangeTitle(next) {
|
||||
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: {
|
||||
type: 'void',
|
||||
|
@ -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 { 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 {
|
||||
title: `{{t("Schedule event", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'schedule',
|
||||
description: `{{t("Event will be scheduled and triggered base on time conditions.", { ns: "${NAMESPACE}" })}}`,
|
||||
fieldset: {
|
||||
config: {
|
||||
type: 'void',
|
||||
@ -26,7 +31,6 @@ export default {
|
||||
useVariables(config, opts) {
|
||||
const compile = useCompile();
|
||||
const { getCollectionFields } = useCollectionManager();
|
||||
const { fieldNames = defaultFieldNames } = opts;
|
||||
const options: any[] = [];
|
||||
if (!opts?.types || opts.types.includes('date')) {
|
||||
options.push({ key: 'date', value: 'date', label: lang('Trigger time') });
|
||||
@ -49,7 +53,7 @@ export default {
|
||||
uiSchema: {
|
||||
title: lang('Trigger data'),
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
|
||||
compile,
|
||||
|
@ -23,7 +23,25 @@ export default {
|
||||
'Trigger data': '触发数据',
|
||||
'Trigger time': '触发时间',
|
||||
'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': '数据表事件',
|
||||
'Event will be triggered on collection data row created, updated or deleted.':
|
||||
'当数据表中的数据被新增、更新或删除时触发。',
|
||||
'Trigger on': '触发时机',
|
||||
'After record added': '新增数据后',
|
||||
'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.':
|
||||
'请选中需要在后续节点中被访问的关系字段。超过两层的对多关联可能会导致性能问题,请谨慎使用。',
|
||||
'Schedule event': '定时任务',
|
||||
'Scheduled job base on time conditions.': '基于时间条件的计划任务',
|
||||
'Trigger mode': '触发模式',
|
||||
'Based on certain date': '自定义时间',
|
||||
'Based on date field of collection': '根据数据表时间字段',
|
||||
|
@ -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('workflows', ['trigger'], 'loggedIn');
|
||||
|
||||
await db.import({
|
||||
directory: path.resolve(__dirname, 'collections'),
|
||||
|
@ -2,6 +2,8 @@ import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
name: 'posts',
|
||||
createdBy: true,
|
||||
updatedBy: true,
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
99
packages/plugins/workflow/src/server/triggers/form.ts
Normal file
99
packages/plugins/workflow/src/server/triggers/form.ts
Normal 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) {}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { requireModule } from '@nocobase/utils';
|
||||
import path from 'path';
|
||||
|
||||
import Plugin from '..';
|
||||
import type { WorkflowModel } from '../types';
|
||||
@ -14,6 +14,7 @@ export default function <T extends Trigger>(plugin, more: { [key: string]: { new
|
||||
const { triggers } = 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));
|
||||
|
||||
for (const [name, TClass] of Object.entries(more)) {
|
||||
|
Loading…
Reference in New Issue
Block a user