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

* feat(plugin-workflow): add form trigger

* test(plugin-workflow): add test cases

* fix(client): fix component warning

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

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

* fix(client): fix tree component value

* fix(client): fix value render in component

* fix(plugin-workflow): fix context load

* fix(client): fix type

* fix(client): fix type

* fix(plugin-workflow): fix params

* fix(plugin-workflow): fix required

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

* fix(plugin-workflow): fix test cases

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

* fix(plugin-workflow): remove useless locale

* fix(client): adjust locale

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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