From 86e672e9bb4361612320e213dcdd381fd6d96838 Mon Sep 17 00:00:00 2001 From: Junyi Date: Wed, 9 Aug 2023 11:12:57 +0700 Subject: [PATCH] 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 --- package.json | 3 +- .../src/block-provider/BlockProvider.tsx | 4 +- .../client/src/block-provider/hooks/index.ts | 42 ++- packages/core/client/src/locale/zh_CN.ts | 1 + .../antd/action/Action.Designer.tsx | 233 ++++++++++-- .../schema-component/antd/action/Action.tsx | 4 +- .../appends-tree-select/AppendsTreeSelect.tsx | 183 ++++++--- .../antd/association-field/util.ts | 2 +- .../antd/color-picker/ReadPretty.tsx | 2 +- .../antd/color-picker/util.ts | 4 +- .../schema-component/antd/table-v2/Table.tsx | 17 +- .../buttons/FormActionInitializers.tsx | 42 ++- .../items/CreateSubmitActionInitializer.tsx | 3 + .../items/UpdateSubmitActionInitializer.tsx | 3 + .../schema-settings/DataTemplates/utils.tsx | 8 +- packages/core/utils/src/client.ts | 1 + packages/core/utils/src/url.ts | 11 + .../src/client/GraphDrawPage.tsx | 50 +-- .../snapshot-field/src/client/interface.ts | 1 + ...eDescription.tsx => DrawerDescription.tsx} | 12 +- .../plugins/workflow/src/client/index.tsx | 6 +- .../workflow/src/client/nodes/index.tsx | 8 +- .../workflow/src/client/schemas/collection.ts | 1 + .../plugins/workflow/src/client/style.tsx | 4 +- .../src/client/triggers/collection.tsx | 1 + .../workflow/src/client/triggers/form.tsx | 195 ++++++++++ .../workflow/src/client/triggers/index.tsx | 18 +- .../src/client/triggers/schedule/index.tsx | 18 +- packages/plugins/workflow/src/locale/zh-CN.ts | 19 + .../plugins/workflow/src/server/Plugin.ts | 6 + .../src/server/__tests__/collections/posts.ts | 2 + .../server/__tests__/triggers/form.test.ts | 354 ++++++++++++++++++ .../workflow/src/server/triggers/form.ts | 99 +++++ .../workflow/src/server/triggers/index.ts | 3 +- 34 files changed, 1171 insertions(+), 189 deletions(-) create mode 100644 packages/core/utils/src/url.ts rename packages/plugins/workflow/src/client/components/{NodeDescription.tsx => DrawerDescription.tsx} (73%) create mode 100644 packages/plugins/workflow/src/client/triggers/form.tsx create mode 100644 packages/plugins/workflow/src/server/__tests__/triggers/form.test.ts create mode 100644 packages/plugins/workflow/src/server/triggers/form.ts diff --git a/package.json b/package.json index da62e69fd..1a1c1c82a 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,7 @@ "prettier --write" ], "*.ts?(x)": [ - "eslint --fix", - "prettier --parser=typescript --write" + "eslint --fix" ] }, "devDependencies": { diff --git a/packages/core/client/src/block-provider/BlockProvider.tsx b/packages/core/client/src/block-provider/BlockProvider.tsx index 46d0c70dd..462df68f3 100644 --- a/packages/core/client/src/block-provider/BlockProvider.tsx +++ b/packages/core/client/src/block-provider/BlockProvider.tsx @@ -56,6 +56,8 @@ const useResource = (props: UseResourceProps) => { const association = useAssociation(props); const sourceId = useSourceId?.(); const field = useField(); + 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' && diff --git a/packages/core/client/src/block-provider/hooks/index.ts b/packages/core/client/src/block-provider/hooks/index.ts index 6082e0c71..bcd469720 100644 --- a/packages/core/client/src/block-provider/hooks/index.ts +++ b/packages/core/client/src/block-provider/hooks/index.ts @@ -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?.(); diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index b04397473..bae22a427 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -797,6 +797,7 @@ export default { "Date display format":"日期显示格式", "Assign data scope for the template":"为模板指定数据范围", "Table selected records":"表格中选中的记录", + "Tag":"标签", "Tag color field":"标签颜色字段", "Sync successfully":"同步成功", diff --git a/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx b/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx index bdc60bbb1..8f9e9ccd7 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx @@ -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 ( - ${actionTitles[actionType]}`}> - {props.children} - + ${actionTitle}`}>{props.children} ); }; @@ -568,30 +555,15 @@ function AfterSuccess() { const { dn } = useDesignable(); const { t } = useTranslation(); const fieldSchema = useFieldSchema(); - const actionType = fieldSchema['x-action'] ?? ''; return ( { + 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 ( + + ); +} + +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 ( + { + 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) && } {isValid(fieldSchema?.['x-action-settings']?.skipValidator) && } {isValid(fieldSchema?.['x-action-settings']?.['onSuccess']) && } + {isValid(fieldSchema?.['x-action-settings']?.triggerWorkflows) && } {isChildCollectionAction && } diff --git a/packages/core/client/src/schema-component/antd/action/Action.tsx b/packages/core/client/src/schema-component/antd/action/Action.tsx index 015db3a8a..1f75d11c6 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.tsx @@ -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(); diff --git a/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx b/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx index 8b0b77607..322c84ff7 100644 --- a/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx +++ b/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx @@ -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): string; + rootOption?: { + label: string; + value: string; + }; }; type TreeOptionType = Omit & { 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 = (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 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 }); - }); + const value: string | DefaultOptionType[] = useMemo(() => { + if (props.multiple) { + return ((propsValue as string[]) || []).map((v) => optionsMap[v]).filter(Boolean); } - }, [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(() => { - 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 = (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,57 +159,75 @@ export const AppendsTreeSelect: React.FC = (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 valueSet = new Set(newValue); - const delValue = value.find((i) => !newValue.includes(i)); + const handleChange = useCallback( + (next: DefaultOptionType[] | string) => { + if (!props.multiple) { + onChange(next as string); + return; + } - if (delValue) { - const delNode = optionsMap[delValue]; - const prefix = `${delNode.value}.`; - Object.keys(optionsMap) - .forEach((key) => { + const newValue = (next as DefaultOptionType[]).map((i) => i.value).filter(Boolean) as string[]; + const valueSet = new Set(newValue); + const delValue = (value as DefaultOptionType[]).find((i) => !valueSet.has(i.value as string)); + + if (delValue) { + const prefix = `${delValue.value}.`; + Object.keys(optionsMap).forEach((key) => { if (key.startsWith(prefix)) { valueSet.delete(key); } }); - } else { - newValue.forEach((v) => { - const paths = v.split('.'); - if (paths.length) { - for (let i = 1; i < paths.length; i++) { - valueSet.add(paths.slice(0, i).join('.')); + } else { + newValue.forEach((v) => { + const paths = v.split('.'); + if (paths.length) { + for (let i = 1; i <= paths.length; i++) { + valueSet.add(paths.slice(0, i).join('.')); + } } - } - }); - } - onChange(Array.from(valueSet)); - }, [value, optionsMap]); + }); + } + onChange(Array.from(valueSet)); + }, + [props.multiple, value, onChange, optionsMap], + ); - const TreeTag = useCallback((props) => { - const { value, onClose, disabled, closable } = props; - const { fullTitle } = optionsMap[value]; - return ( - {fullTitle.join(' / ')} - ); - }, [optionsMap]); + const TreeTag = useCallback( + (props) => { + const { value, onClose, disabled, closable } = props; + if (!value) { + return null; + } + const { fullTitle } = optionsMap[value] ?? {}; + return ( + + {fullTitle?.join(' / ')} + + ); + }, + [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 ( void} + onChange={handleChange as (next) => void} treeDataSimpleMode treeData={treeData} loadData={loadData} diff --git a/packages/core/client/src/schema-component/antd/association-field/util.ts b/packages/core/client/src/schema-component/antd/association-field/util.ts index 30f603b7a..467ee2443 100644 --- a/packages/core/client/src/schema-component/antd/association-field/util.ts +++ b/packages/core/client/src/schema-component/antd/association-field/util.ts @@ -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; } diff --git a/packages/core/client/src/schema-component/antd/color-picker/ReadPretty.tsx b/packages/core/client/src/schema-component/antd/color-picker/ReadPretty.tsx index 9b0357ec8..bd8e0ee36 100644 --- a/packages/core/client/src/schema-component/antd/color-picker/ReadPretty.tsx +++ b/packages/core/client/src/schema-component/antd/color-picker/ReadPretty.tsx @@ -18,7 +18,7 @@ ReadPretty.ColorPicker = function ColorPicker(props: any) { return (
- +
); }; diff --git a/packages/core/client/src/schema-component/antd/color-picker/util.ts b/packages/core/client/src/schema-component/antd/color-picker/util.ts index e037070bd..f091fd5fa 100644 --- a/packages/core/client/src/schema-component/antd/color-picker/util.ts +++ b/packages/core/client/src/schema-component/antd/color-picker/util.ts @@ -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') { diff --git a/packages/core/client/src/schema-component/antd/table-v2/Table.tsx b/packages/core/client/src/schema-component/antd/table-v2/Table.tsx index 4b3f77707..82a01b4a9 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/Table.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/Table.tsx @@ -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( ({ children }) => { return dragSort - ? React.createElement(SortableContext, { - items: field.value?.map?.(getRowKey) || [], + ? React.createElement>( + 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 && (
{field.errors.map((error) => { - return error.messages.map((message) =>
{message}
); + return error.messages.map((message) =>
{message}
); })}
)} diff --git a/packages/core/client/src/schema-initializer/buttons/FormActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/FormActionInitializers.tsx index 660445447..908928773 100644 --- a/packages/core/client/src/schema-initializer/buttons/FormActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/FormActionInitializers.tsx @@ -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': { diff --git a/packages/core/client/src/schema-initializer/items/CreateSubmitActionInitializer.tsx b/packages/core/client/src/schema-initializer/items/CreateSubmitActionInitializer.tsx index 740f3688f..c443766d7 100644 --- a/packages/core/client/src/schema-initializer/items/CreateSubmitActionInitializer.tsx +++ b/packages/core/client/src/schema-initializer/items/CreateSubmitActionInitializer.tsx @@ -13,6 +13,9 @@ export const CreateSubmitActionInitializer = (props) => { htmlType: 'submit', useProps: '{{ useCreateActionProps }}', }, + 'x-action-settings': { + triggerWorkflows: [], + }, }; return ; }; diff --git a/packages/core/client/src/schema-initializer/items/UpdateSubmitActionInitializer.tsx b/packages/core/client/src/schema-initializer/items/UpdateSubmitActionInitializer.tsx index b012a2630..11c4fe311 100644 --- a/packages/core/client/src/schema-initializer/items/UpdateSubmitActionInitializer.tsx +++ b/packages/core/client/src/schema-initializer/items/UpdateSubmitActionInitializer.tsx @@ -13,6 +13,9 @@ export const UpdateSubmitActionInitializer = (props) => { htmlType: 'submit', useProps: '{{ useUpdateActionProps }}', }, + 'x-action-settings': { + triggerWorkflows: [], + }, }; return ; }; diff --git a/packages/core/client/src/schema-settings/DataTemplates/utils.tsx b/packages/core/client/src/schema-settings/DataTemplates/utils.tsx index fa4e63ceb..0ade5cac2 100644 --- a/packages/core/client/src/schema-settings/DataTemplates/utils.tsx +++ b/packages/core/client/src/schema-settings/DataTemplates/utils.tsx @@ -100,9 +100,7 @@ export const useSyncFromForm = (fieldSchema, collection?, callBack?) => { cache.set(cacheKey, result); return result; }; - })( - new LRUCache({ max: 100 }), - ); + })(new LRUCache({ 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({ max: 100 }), - ); + })(new LRUCache({ max: 100 })); const getEnableFieldTree = useCallback((collectionName: string, formData) => { if (!collectionName) { return []; diff --git a/packages/core/utils/src/client.ts b/packages/core/utils/src/client.ts index b88101868..3c6b63d13 100644 --- a/packages/core/utils/src/client.ts +++ b/packages/core/utils/src/client.ts @@ -16,3 +16,4 @@ export * from './registry'; // export * from './toposort'; export * from './uid'; export { dayjs, lodash }; +export * from './url'; diff --git a/packages/core/utils/src/url.ts b/packages/core/utils/src/url.ts new file mode 100644 index 000000000..feeeb4355 --- /dev/null +++ b/packages/core/utils/src/url.ts @@ -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:'; +} diff --git a/packages/plugins/graph-collection-manager/src/client/GraphDrawPage.tsx b/packages/plugins/graph-collection-manager/src/client/GraphDrawPage.tsx index 25d2db241..2231a4e7c 100644 --- a/packages/plugins/graph-collection-manager/src/client/GraphDrawPage.tsx +++ b/packages/plugins/graph-collection-manager/src/client/GraphDrawPage.tsx @@ -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 || []); + 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': - 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) { - 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 }) => { + const newEdge = targetGraph.addEdge({ + ...edge, + }); switch (status) { case 'add': - const newEdge = targetGraph.addEdge({ - ...edge, - }); optimizeEdge(newEdge); break; case 'delete': @@ -1010,9 +1012,13 @@ export const GraphDrawPage = React.memo(() => { }, []); useEffect(() => { - refreshPositions().then(() => { - refreshGM(); - }); + refreshPositions() + .then(() => { + refreshGM(); + }) + .catch((err) => { + throw err; + }); }, []); const loadCollections = async () => { return targetGraph.collections?.map((collection: any) => ({ diff --git a/packages/plugins/snapshot-field/src/client/interface.ts b/packages/plugins/snapshot-field/src/client/interface.ts index c51d270bd..a7ee08e4c 100644 --- a/packages/plugins/snapshot-field/src/client/interface.ts +++ b/packages/plugins/snapshot-field/src/client/interface.ts @@ -169,6 +169,7 @@ export const snapshot: IField = { 'x-decorator': 'FormItem', 'x-component': 'AppendsTreeSelect', 'x-component-props': { + multiple: true, useCollection: useRecordCollection, }, 'x-reactions': [ diff --git a/packages/plugins/workflow/src/client/components/NodeDescription.tsx b/packages/plugins/workflow/src/client/components/DrawerDescription.tsx similarity index 73% rename from packages/plugins/workflow/src/client/components/NodeDescription.tsx rename to packages/plugins/workflow/src/client/components/DrawerDescription.tsx index 6ebe588a3..23aa425f6 100644 --- a/packages/plugins/workflow/src/client/components/NodeDescription.tsx +++ b/packages/plugins/workflow/src/client/components/DrawerDescription.tsx @@ -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 (
-
{lang('Node type')}
+
{label}
- {instruction.title} + {title}
- {instruction.description ?

{instruction.description}

: null} + {description ?

{description}

: null}
); } diff --git a/packages/plugins/workflow/src/client/index.tsx b/packages/plugins/workflow/src/client/index.tsx index cf358b26f..e31a1db92 100644 --- a/packages/plugins/workflow/src/client/index.tsx +++ b/packages/plugins/workflow/src/client/index.tsx @@ -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, }); } diff --git a/packages/plugins/workflow/src/client/nodes/index.tsx b/packages/plugins/workflow/src/client/nodes/index.tsx index b1eb35033..0a20c1dc7 100644 --- a/packages/plugins/workflow/src/client/nodes/index.tsx +++ b/packages/plugins/workflow/src/client/nodes/index.tsx @@ -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, }, }, } diff --git a/packages/plugins/workflow/src/client/schemas/collection.ts b/packages/plugins/workflow/src/client/schemas/collection.ts index 6a040f906..2d2810ae1 100644 --- a/packages/plugins/workflow/src/client/schemas/collection.ts +++ b/packages/plugins/workflow/src/client/schemas/collection.ts @@ -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; diff --git a/packages/plugins/workflow/src/client/style.tsx b/packages/plugins/workflow/src/client/style.tsx index 0f2a01964..7415ab148 100644 --- a/packages/plugins/workflow/src/client/style.tsx +++ b/packages/plugins/workflow/src/client/style.tsx @@ -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}; diff --git a/packages/plugins/workflow/src/client/triggers/collection.tsx b/packages/plugins/workflow/src/client/triggers/collection.tsx index 5685a529b..581c703b9 100644 --- a/packages/plugins/workflow/src/client/triggers/collection.tsx +++ b/packages/plugins/workflow/src/client/triggers/collection.tsx @@ -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, diff --git a/packages/plugins/workflow/src/client/triggers/form.tsx b/packages/plugins/workflow/src/client/triggers/form.tsx new file mode 100644 index 000000000..dd6ee3085 --- /dev/null +++ b/packages/plugins/workflow/src/client/triggers/form.tsx @@ -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; + 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; + } + }, + }; +} diff --git a/packages/plugins/workflow/src/client/triggers/index.tsx b/packages/plugins/workflow/src/client/triggers/index.tsx index 207f9282c..6cf5c17ed 100644 --- a/packages/plugins/workflow/src/client/triggers/index.tsx +++ b/packages/plugins/workflow/src/client/triggers/index.tsx @@ -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(); +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', diff --git a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx index 79321263f..7d1a67c88 100644 --- a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx +++ b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx @@ -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, diff --git a/packages/plugins/workflow/src/locale/zh-CN.ts b/packages/plugins/workflow/src/locale/zh-CN.ts index 07e249902..f4c6b0484 100644 --- a/packages/plugins/workflow/src/locale/zh-CN.ts +++ b/packages/plugins/workflow/src/locale/zh-CN.ts @@ -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': '根据数据表时间字段', diff --git a/packages/plugins/workflow/src/server/Plugin.ts b/packages/plugins/workflow/src/server/Plugin.ts index 6cfe09f8f..41a11fb60 100644 --- a/packages/plugins/workflow/src/server/Plugin.ts +++ b/packages/plugins/workflow/src/server/Plugin.ts @@ -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'), diff --git a/packages/plugins/workflow/src/server/__tests__/collections/posts.ts b/packages/plugins/workflow/src/server/__tests__/collections/posts.ts index 60135ce1f..2d672ab43 100644 --- a/packages/plugins/workflow/src/server/__tests__/collections/posts.ts +++ b/packages/plugins/workflow/src/server/__tests__/collections/posts.ts @@ -2,6 +2,8 @@ import { CollectionOptions } from '@nocobase/database'; export default { name: 'posts', + createdBy: true, + updatedBy: true, fields: [ { type: 'string', diff --git a/packages/plugins/workflow/src/server/__tests__/triggers/form.test.ts b/packages/plugins/workflow/src/server/__tests__/triggers/form.test.ts new file mode 100644 index 000000000..479d9afaf --- /dev/null +++ b/packages/plugins/workflow/src/server/__tests__/triggers/form.test.ts @@ -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' }); + }); + }); +}); diff --git a/packages/plugins/workflow/src/server/triggers/form.ts b/packages/plugins/workflow/src/server/triggers/form.ts new file mode 100644 index 000000000..23fc0aff7 --- /dev/null +++ b/packages/plugins/workflow/src/server/triggers/form.ts @@ -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 = modelAssociationByKey(payload, field); + payload = await payload[association.accessors.get](); + } + } + } + const { collection, appends = [] } = workflow.config; + const 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) {} +} diff --git a/packages/plugins/workflow/src/server/triggers/index.ts b/packages/plugins/workflow/src/server/triggers/index.ts index e89c9b47a..35bab363d 100644 --- a/packages/plugins/workflow/src/server/triggers/index.ts +++ b/packages/plugins/workflow/src/server/triggers/index.ts @@ -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 (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)) {