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"
|
"prettier --write"
|
||||||
],
|
],
|
||||||
"*.ts?(x)": [
|
"*.ts?(x)": [
|
||||||
"eslint --fix",
|
"eslint --fix"
|
||||||
"prettier --parser=typescript --write"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -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' &&
|
||||||
|
@ -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?.();
|
||||||
|
@ -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":"同步成功",
|
||||||
|
@ -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} />}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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') {
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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': {
|
||||||
|
@ -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} />;
|
||||||
};
|
};
|
||||||
|
@ -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} />;
|
||||||
};
|
};
|
||||||
|
@ -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 [];
|
||||||
|
@ -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';
|
||||||
|
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 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) => ({
|
||||||
|
@ -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': [
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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};
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
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 { 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',
|
||||||
|
@ -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,
|
||||||
|
@ -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': '根据数据表时间字段',
|
||||||
|
@ -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'),
|
||||||
|
@ -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',
|
||||||
|
@ -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 { 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)) {
|
||||||
|
Loading…
Reference in New Issue
Block a user