feat(plugin-workflow): add form trigger type (#2347)
* feat(plugin-workflow): add form trigger * test(plugin-workflow): add test cases * fix(client): fix component warning * fix(plugin-workflow): fix context data for create and update * fix(plugin-workflow): allow to select any form type workflow in configuration * fix(client): fix tree component value * fix(client): fix value render in component * fix(plugin-workflow): fix context load * fix(client): fix type * fix(client): fix type * fix(plugin-workflow): fix params * fix(plugin-workflow): fix required * fix(plugin): fix context not matching collection error * fix(plugin-workflow): fix test cases * refactor(plugin-workflow): change trigger workflow action config to cascaded * fix(plugin-workflow): remove useless locale * fix(client): adjust locale * fix(client): remove useless locale
This commit is contained in:
		
							parent
							
								
									ceea649276
								
							
						
					
					
						commit
						86e672e9bb
					
				@ -50,8 +50,7 @@
 | 
			
		||||
      "prettier --write"
 | 
			
		||||
    ],
 | 
			
		||||
    "*.ts?(x)": [
 | 
			
		||||
      "eslint --fix",
 | 
			
		||||
      "prettier --parser=typescript --write"
 | 
			
		||||
      "eslint --fix"
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
 | 
			
		||||
@ -56,6 +56,8 @@ const useResource = (props: UseResourceProps) => {
 | 
			
		||||
  const association = useAssociation(props);
 | 
			
		||||
  const sourceId = useSourceId?.();
 | 
			
		||||
  const field = useField<Field>();
 | 
			
		||||
  const withoutTableFieldResource = useContext(WithoutTableFieldResource);
 | 
			
		||||
  const __parent = useContext(BlockRequestContext);
 | 
			
		||||
  if (block === 'TableField') {
 | 
			
		||||
    const options = {
 | 
			
		||||
      field,
 | 
			
		||||
@ -68,8 +70,6 @@ const useResource = (props: UseResourceProps) => {
 | 
			
		||||
    return new TableFieldResource(options);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const withoutTableFieldResource = useContext(WithoutTableFieldResource);
 | 
			
		||||
  const __parent = useContext(BlockRequestContext);
 | 
			
		||||
  if (
 | 
			
		||||
    !withoutTableFieldResource &&
 | 
			
		||||
    __parent?.block === 'TableField' &&
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import { SchemaExpressionScopeContext, useField, useFieldSchema, useForm } from '@formily/react';
 | 
			
		||||
import { parse } from '@nocobase/utils/client';
 | 
			
		||||
import { isURL, parse } from '@nocobase/utils/client';
 | 
			
		||||
import { App, message } from 'antd';
 | 
			
		||||
import { cloneDeep } from 'lodash';
 | 
			
		||||
import get from 'lodash/get';
 | 
			
		||||
@ -38,18 +38,6 @@ function renderTemplate(str: string, data: any) {
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isURL(string) {
 | 
			
		||||
  let url;
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    url = new URL(string);
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return url.protocol === 'http:' || url.protocol === 'https:';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const filterValue = (value) => {
 | 
			
		||||
  if (typeof value !== 'object') {
 | 
			
		||||
    return value;
 | 
			
		||||
@ -84,7 +72,7 @@ function getFormValues(filterByTk, field, form, fieldNames, getField, resource)
 | 
			
		||||
      return omit({ ...form.values }, keys);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  console.log('form.values', form.values);
 | 
			
		||||
 | 
			
		||||
  return form.values;
 | 
			
		||||
  const values = {};
 | 
			
		||||
  for (const key in form.values) {
 | 
			
		||||
@ -151,8 +139,13 @@ export const useCreateActionProps = () => {
 | 
			
		||||
  return {
 | 
			
		||||
    async onClick() {
 | 
			
		||||
      const fieldNames = fields.map((field) => field.name);
 | 
			
		||||
      const { assignedValues: originalAssignedValues = {}, onSuccess, overwriteValues, skipValidator } =
 | 
			
		||||
        actionSchema?.['x-action-settings'] ?? {};
 | 
			
		||||
      const {
 | 
			
		||||
        assignedValues: originalAssignedValues = {},
 | 
			
		||||
        onSuccess,
 | 
			
		||||
        overwriteValues,
 | 
			
		||||
        skipValidator,
 | 
			
		||||
        triggerWorkflows,
 | 
			
		||||
      } = actionSchema?.['x-action-settings'] ?? {};
 | 
			
		||||
      const addChild = fieldSchema?.['x-component-props']?.addChild;
 | 
			
		||||
      const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser });
 | 
			
		||||
      if (!skipValidator) {
 | 
			
		||||
@ -175,6 +168,10 @@ export const useCreateActionProps = () => {
 | 
			
		||||
            ...assignedValues,
 | 
			
		||||
          },
 | 
			
		||||
          filterKeys: filterKeys,
 | 
			
		||||
          // TODO(refactor): should change to inject by plugin
 | 
			
		||||
          triggerWorkflows: triggerWorkflows?.length
 | 
			
		||||
            ? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',')
 | 
			
		||||
            : undefined,
 | 
			
		||||
        });
 | 
			
		||||
        actionField.data.loading = false;
 | 
			
		||||
        actionField.data.data = data;
 | 
			
		||||
@ -718,8 +715,13 @@ export const useUpdateActionProps = () => {
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    async onClick() {
 | 
			
		||||
      const { assignedValues: originalAssignedValues = {}, onSuccess, overwriteValues, skipValidator } =
 | 
			
		||||
        actionSchema?.['x-action-settings'] ?? {};
 | 
			
		||||
      const {
 | 
			
		||||
        assignedValues: originalAssignedValues = {},
 | 
			
		||||
        onSuccess,
 | 
			
		||||
        overwriteValues,
 | 
			
		||||
        skipValidator,
 | 
			
		||||
        triggerWorkflows,
 | 
			
		||||
      } = actionSchema?.['x-action-settings'] ?? {};
 | 
			
		||||
      const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser });
 | 
			
		||||
      if (!skipValidator) {
 | 
			
		||||
        await form.submit();
 | 
			
		||||
@ -738,6 +740,10 @@ export const useUpdateActionProps = () => {
 | 
			
		||||
          },
 | 
			
		||||
          ...data,
 | 
			
		||||
          updateAssociationValues,
 | 
			
		||||
          // TODO(refactor): should change to inject by plugin
 | 
			
		||||
          triggerWorkflows: triggerWorkflows?.length
 | 
			
		||||
            ? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',')
 | 
			
		||||
            : undefined,
 | 
			
		||||
        });
 | 
			
		||||
        actionField.data.loading = false;
 | 
			
		||||
        __parent?.service?.refresh?.();
 | 
			
		||||
 | 
			
		||||
@ -797,6 +797,7 @@ export default {
 | 
			
		||||
  "Date display format":"日期显示格式",
 | 
			
		||||
  "Assign  data scope for the template":"为模板指定数据范围",
 | 
			
		||||
  "Table selected records":"表格中选中的记录",
 | 
			
		||||
 | 
			
		||||
  "Tag":"标签",
 | 
			
		||||
  "Tag color field":"标签颜色字段",
 | 
			
		||||
  "Sync successfully":"同步成功",
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,11 @@
 | 
			
		||||
import { connect, ISchema, mapProps, useField, useFieldSchema, useForm } from '@formily/react';
 | 
			
		||||
import { ArrayTable } from '@formily/antd-v5';
 | 
			
		||||
import { connect, ISchema, mapProps, useField, useFieldSchema, useForm, useFormEffects } from '@formily/react';
 | 
			
		||||
import { isValid, uid } from '@formily/shared';
 | 
			
		||||
import { Tree as AntdTree } from 'antd';
 | 
			
		||||
import { Alert, Tree as AntdTree } from 'antd';
 | 
			
		||||
import { cloneDeep } from 'lodash';
 | 
			
		||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
 | 
			
		||||
import { useTranslation } from 'react-i18next';
 | 
			
		||||
import { useDesignable } from '../..';
 | 
			
		||||
import { RemoteSelect, useCompile, useDesignable } from '../..';
 | 
			
		||||
import { useCollection, useCollectionManager } from '../../../collection-manager';
 | 
			
		||||
import { OpenModeSchemaItems } from '../../../schema-items';
 | 
			
		||||
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
 | 
			
		||||
@ -13,6 +14,7 @@ import { useLinkageAction } from './hooks';
 | 
			
		||||
import { requestSettingsSchema } from './utils';
 | 
			
		||||
import { useRecord } from '../../../record-provider';
 | 
			
		||||
import { useSyncFromForm } from '../../../schema-settings/DataTemplates/utils';
 | 
			
		||||
import { onFieldValueChange } from '@formily/core';
 | 
			
		||||
 | 
			
		||||
const Tree = connect(
 | 
			
		||||
  AntdTree,
 | 
			
		||||
@ -32,30 +34,15 @@ const Tree = connect(
 | 
			
		||||
);
 | 
			
		||||
const MenuGroup = (props) => {
 | 
			
		||||
  const fieldSchema = useFieldSchema();
 | 
			
		||||
  const actionType = fieldSchema['x-action'] || '';
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
  const actionTitles = {
 | 
			
		||||
    'customize:popup': t('Popup'),
 | 
			
		||||
    'customize:update': t('Update record'),
 | 
			
		||||
    'customize:save': t('Save record'),
 | 
			
		||||
    'customize:table:request': t('Custom request'),
 | 
			
		||||
    'customize:form:request': t('Custom request'),
 | 
			
		||||
  };
 | 
			
		||||
  if (
 | 
			
		||||
    ![
 | 
			
		||||
      'customize:popup',
 | 
			
		||||
      'customize:update',
 | 
			
		||||
      'customize:save',
 | 
			
		||||
      'customize:table:request',
 | 
			
		||||
      'customize:form:request',
 | 
			
		||||
    ].includes(actionType)
 | 
			
		||||
  ) {
 | 
			
		||||
    return <>{props.children}</>;
 | 
			
		||||
  const compile = useCompile();
 | 
			
		||||
  const actionTitle = fieldSchema.title ? compile(fieldSchema.title) : '';
 | 
			
		||||
  const actionType = fieldSchema['x-action'] ?? '';
 | 
			
		||||
  if (!actionType.startsWith('customize:') || !actionTitle) {
 | 
			
		||||
    return props.children;
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <SchemaSettings.ItemGroup title={`${t('Customize')} > ${actionTitles[actionType]}`}>
 | 
			
		||||
      {props.children}
 | 
			
		||||
    </SchemaSettings.ItemGroup>
 | 
			
		||||
    <SchemaSettings.ItemGroup title={`${t('Customize')} > ${actionTitle}`}>{props.children}</SchemaSettings.ItemGroup>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -568,30 +555,15 @@ function AfterSuccess() {
 | 
			
		||||
  const { dn } = useDesignable();
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
  const fieldSchema = useFieldSchema();
 | 
			
		||||
  const actionType = fieldSchema['x-action'] ?? '';
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SchemaSettings.ModalItem
 | 
			
		||||
      title={
 | 
			
		||||
        {
 | 
			
		||||
          'customize:save': t('After successful save'),
 | 
			
		||||
          'customize:update': t('After successful update'),
 | 
			
		||||
          'customize:table:request': t('After successful request'),
 | 
			
		||||
          'customize:form:request': t('After successful request'),
 | 
			
		||||
          'customize:bulkUpdate': t('After successful bulk update'),
 | 
			
		||||
        }[actionType]
 | 
			
		||||
      }
 | 
			
		||||
      title={t('After successful submission')}
 | 
			
		||||
      initialValues={fieldSchema?.['x-action-settings']?.['onSuccess']}
 | 
			
		||||
      schema={
 | 
			
		||||
        {
 | 
			
		||||
          type: 'object',
 | 
			
		||||
          title: {
 | 
			
		||||
            'customize:save': t('After successful save'),
 | 
			
		||||
            'customize:update': t('After successful update'),
 | 
			
		||||
            'customize:table:request': t('After successful request'),
 | 
			
		||||
            'customize:form:request': t('After successful request'),
 | 
			
		||||
            'customize:bulkUpdate': t('After successful bulk update'),
 | 
			
		||||
          }[actionType],
 | 
			
		||||
          title: t('After successful submission'),
 | 
			
		||||
          properties: {
 | 
			
		||||
            successMessage: {
 | 
			
		||||
              title: t('Popup message'),
 | 
			
		||||
@ -673,6 +645,184 @@ function RemoveButton() {
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FormWorkflowSelect(props) {
 | 
			
		||||
  const index = ArrayTable.useIndex();
 | 
			
		||||
  const { setValuesIn } = useForm();
 | 
			
		||||
  const baseCollection = useCollection();
 | 
			
		||||
  const { getCollection } = useCollectionManager();
 | 
			
		||||
  const [workflowCollection, setWorkflowCollection] = useState(baseCollection.name);
 | 
			
		||||
  useFormEffects(() => {
 | 
			
		||||
    onFieldValueChange(`group[${index}].context`, (field) => {
 | 
			
		||||
      let collection = baseCollection;
 | 
			
		||||
      if (field.value) {
 | 
			
		||||
        const paths = field.value.split('.');
 | 
			
		||||
        for (let i = 0; i < paths.length && collection; i++) {
 | 
			
		||||
          const path = paths[i];
 | 
			
		||||
          const associationField = collection.fields.find((f) => f.name === path);
 | 
			
		||||
          if (associationField) {
 | 
			
		||||
            collection = getCollection(associationField.target);
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      setWorkflowCollection(collection.name);
 | 
			
		||||
      setValuesIn(`group[${index}].workflowKey`, null);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <RemoteSelect
 | 
			
		||||
      {...props}
 | 
			
		||||
      service={{
 | 
			
		||||
        resource: 'workflows',
 | 
			
		||||
        action: 'list',
 | 
			
		||||
        params: {
 | 
			
		||||
          filter: {
 | 
			
		||||
            type: 'form',
 | 
			
		||||
            enabled: true,
 | 
			
		||||
            'config.collection': workflowCollection,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function WorkflowConfig() {
 | 
			
		||||
  const { dn } = useDesignable();
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
  const fieldSchema = useFieldSchema();
 | 
			
		||||
  const { name: collection } = useCollection();
 | 
			
		||||
  const description = {
 | 
			
		||||
    submit: t('Workflow will be triggered after submitting succeeded.', { ns: 'workflow' }),
 | 
			
		||||
    'customize:save': t('Workflow will be triggered after saving succeeded.', { ns: 'workflow' }),
 | 
			
		||||
    'customize:triggerWorkflows': t('Workflow will be triggered directly once the button clicked.', { ns: 'workflow' }),
 | 
			
		||||
  }[fieldSchema?.['x-action']];
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SchemaSettings.ModalItem
 | 
			
		||||
      title={t('Bind workflows', { ns: 'workflow' })}
 | 
			
		||||
      scope={{
 | 
			
		||||
        fieldFilter(field) {
 | 
			
		||||
          return ['belongsTo', 'hasOne'].includes(field.type);
 | 
			
		||||
        },
 | 
			
		||||
      }}
 | 
			
		||||
      components={{
 | 
			
		||||
        Alert,
 | 
			
		||||
        ArrayTable,
 | 
			
		||||
        FormWorkflowSelect,
 | 
			
		||||
      }}
 | 
			
		||||
      schema={
 | 
			
		||||
        {
 | 
			
		||||
          type: 'void',
 | 
			
		||||
          title: t('Bind workflows', { ns: 'workflow' }),
 | 
			
		||||
          properties: {
 | 
			
		||||
            description: description && {
 | 
			
		||||
              type: 'void',
 | 
			
		||||
              'x-component': 'Alert',
 | 
			
		||||
              'x-component-props': {
 | 
			
		||||
                message: description,
 | 
			
		||||
                style: {
 | 
			
		||||
                  marginBottom: '1em',
 | 
			
		||||
                },
 | 
			
		||||
              },
 | 
			
		||||
            },
 | 
			
		||||
            group: {
 | 
			
		||||
              type: 'array',
 | 
			
		||||
              'x-component': 'ArrayTable',
 | 
			
		||||
              'x-decorator': 'FormItem',
 | 
			
		||||
              items: {
 | 
			
		||||
                type: 'object',
 | 
			
		||||
                properties: {
 | 
			
		||||
                  context: {
 | 
			
		||||
                    type: 'void',
 | 
			
		||||
                    'x-component': 'ArrayTable.Column',
 | 
			
		||||
                    'x-component-props': {
 | 
			
		||||
                      title: t('Trigger data context', { ns: 'workflow' }),
 | 
			
		||||
                      width: 200,
 | 
			
		||||
                    },
 | 
			
		||||
                    properties: {
 | 
			
		||||
                      context: {
 | 
			
		||||
                        type: 'string',
 | 
			
		||||
                        'x-decorator': 'FormItem',
 | 
			
		||||
                        'x-component': 'AppendsTreeSelect',
 | 
			
		||||
                        'x-component-props': {
 | 
			
		||||
                          placeholder: t('Select context', { ns: 'workflow' }),
 | 
			
		||||
                          popupMatchSelectWidth: false,
 | 
			
		||||
                          collection,
 | 
			
		||||
                          filter: '{{ fieldFilter }}',
 | 
			
		||||
                          rootOption: {
 | 
			
		||||
                            label: t('Full form data', { ns: 'workflow' }),
 | 
			
		||||
                            value: '',
 | 
			
		||||
                          },
 | 
			
		||||
                          allowClear: false,
 | 
			
		||||
                        },
 | 
			
		||||
                        default: '',
 | 
			
		||||
                      },
 | 
			
		||||
                    },
 | 
			
		||||
                  },
 | 
			
		||||
                  workflowKey: {
 | 
			
		||||
                    type: 'void',
 | 
			
		||||
                    'x-component': 'ArrayTable.Column',
 | 
			
		||||
                    'x-component-props': {
 | 
			
		||||
                      title: t('Workflow', { ns: 'workflow' }),
 | 
			
		||||
                    },
 | 
			
		||||
                    properties: {
 | 
			
		||||
                      workflowKey: {
 | 
			
		||||
                        type: 'number',
 | 
			
		||||
                        'x-decorator': 'FormItem',
 | 
			
		||||
                        'x-component': 'FormWorkflowSelect',
 | 
			
		||||
                        'x-component-props': {
 | 
			
		||||
                          placeholder: t('Select workflow', { ns: 'workflow' }),
 | 
			
		||||
                          fieldNames: {
 | 
			
		||||
                            label: 'title',
 | 
			
		||||
                            value: 'key',
 | 
			
		||||
                          },
 | 
			
		||||
                        },
 | 
			
		||||
                        required: true,
 | 
			
		||||
                      },
 | 
			
		||||
                    },
 | 
			
		||||
                  },
 | 
			
		||||
                  operations: {
 | 
			
		||||
                    type: 'void',
 | 
			
		||||
                    'x-component': 'ArrayTable.Column',
 | 
			
		||||
                    'x-component-props': {
 | 
			
		||||
                      width: 32,
 | 
			
		||||
                    },
 | 
			
		||||
                    properties: {
 | 
			
		||||
                      remove: {
 | 
			
		||||
                        type: 'void',
 | 
			
		||||
                        'x-decorator': 'FormItem',
 | 
			
		||||
                        'x-component': 'ArrayTable.Remove',
 | 
			
		||||
                      },
 | 
			
		||||
                    },
 | 
			
		||||
                  },
 | 
			
		||||
                },
 | 
			
		||||
              },
 | 
			
		||||
              properties: {
 | 
			
		||||
                add: {
 | 
			
		||||
                  type: 'void',
 | 
			
		||||
                  title: t('Add workflow', { ns: 'workflow' }),
 | 
			
		||||
                  'x-component': 'ArrayTable.Addition',
 | 
			
		||||
                },
 | 
			
		||||
              },
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        } as ISchema
 | 
			
		||||
      }
 | 
			
		||||
      initialValues={{ group: fieldSchema?.['x-action-settings']?.triggerWorkflows }}
 | 
			
		||||
      onSubmit={({ group }) => {
 | 
			
		||||
        fieldSchema['x-action-settings']['triggerWorkflows'] = group;
 | 
			
		||||
        dn.emit('patch', {
 | 
			
		||||
          schema: {
 | 
			
		||||
            ['x-uid']: fieldSchema['x-uid'],
 | 
			
		||||
            'x-action-settings': fieldSchema['x-action-settings'],
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ActionDesigner = (props) => {
 | 
			
		||||
  const { modalTip, linkageAction, ...restProps } = props;
 | 
			
		||||
  const fieldSchema = useFieldSchema();
 | 
			
		||||
@ -702,6 +852,7 @@ export const ActionDesigner = (props) => {
 | 
			
		||||
        {isValid(fieldSchema?.['x-action-settings']?.requestSettings) && <RequestSettings />}
 | 
			
		||||
        {isValid(fieldSchema?.['x-action-settings']?.skipValidator) && <SkipValidation />}
 | 
			
		||||
        {isValid(fieldSchema?.['x-action-settings']?.['onSuccess']) && <AfterSuccess />}
 | 
			
		||||
        {isValid(fieldSchema?.['x-action-settings']?.triggerWorkflows) && <WorkflowConfig />}
 | 
			
		||||
 | 
			
		||||
        {isChildCollectionAction && <SchemaSettings.EnableChildCollections collectionName={name} />}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -35,11 +35,11 @@ export const Action: ComposedAction = observer(
 | 
			
		||||
      className,
 | 
			
		||||
      icon,
 | 
			
		||||
      title,
 | 
			
		||||
      onClick,
 | 
			
		||||
      ...others
 | 
			
		||||
    } = props;
 | 
			
		||||
    } = useProps(props);
 | 
			
		||||
    const { wrapSSR, componentCls, hashId } = useStyles();
 | 
			
		||||
    const { t } = useTranslation();
 | 
			
		||||
    const { onClick } = useProps(props);
 | 
			
		||||
    const [visible, setVisible] = useState(false);
 | 
			
		||||
    const [formValueChanged, setFormValueChanged] = useState(false);
 | 
			
		||||
    const Designer = useDesigner();
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,20 @@
 | 
			
		||||
import { CollectionFieldOptions, useCollectionManager, useCompile } from '../../..';
 | 
			
		||||
import { Tag, TreeSelect } from 'antd';
 | 
			
		||||
import type { DefaultOptionType } from 'rc-tree-select/es/TreeSelect';
 | 
			
		||||
import React, { useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import { useTranslation } from 'react-i18next';
 | 
			
		||||
import { CollectionFieldOptions, useCollectionManager, useCompile } from '../../..';
 | 
			
		||||
 | 
			
		||||
export type AppendsTreeSelectProps = {
 | 
			
		||||
  value: string[];
 | 
			
		||||
  onChange: (value: string[]) => void;
 | 
			
		||||
  value: string[] | string;
 | 
			
		||||
  onChange: (value: string[] | string) => void;
 | 
			
		||||
  multiple?: boolean;
 | 
			
		||||
  filter?(field): boolean;
 | 
			
		||||
  collection?: string;
 | 
			
		||||
  useCollection?(props: Pick<AppendsTreeSelectProps, 'collection'>): string;
 | 
			
		||||
  rootOption?: {
 | 
			
		||||
    label: string;
 | 
			
		||||
    value: string;
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type TreeOptionType = Omit<DefaultOptionType, 'value'> & { value: string };
 | 
			
		||||
@ -20,7 +26,8 @@ function usePropsCollection({ collection }) {
 | 
			
		||||
type CallScope = {
 | 
			
		||||
  compile?(value: string): string;
 | 
			
		||||
  getCollectionFields?(name: any): CollectionFieldOptions[];
 | 
			
		||||
}
 | 
			
		||||
  filter(field): boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function loadChildren(this, option) {
 | 
			
		||||
  const result = getCollectionFieldOptions.call(this, option.field.target, option);
 | 
			
		||||
@ -38,13 +45,17 @@ function isAssociation(field) {
 | 
			
		||||
  return field.target && field.interface;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function trueFilter(field) {
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getCollectionFieldOptions(this: CallScope, collection, parentNode?): TreeOptionType[] {
 | 
			
		||||
  const fields = this.getCollectionFields(collection).filter(isAssociation);
 | 
			
		||||
  const boundLoadChildren = loadChildren.bind(this);
 | 
			
		||||
  return fields.map((field) => {
 | 
			
		||||
    const key = parentNode ? `${parentNode.value}.${field.name}` : field.name;
 | 
			
		||||
  return fields.filter(this.filter).map((field) => {
 | 
			
		||||
    const key = parentNode ? `${parentNode.value ? `${parentNode.value}.` : ''}${field.name}` : field.name;
 | 
			
		||||
    const fieldTitle = this.compile(field.uiSchema?.title) ?? field.name;
 | 
			
		||||
    const isLeaf = !this.getCollectionFields(field.target).filter(isAssociation).length;
 | 
			
		||||
    const isLeaf = !this.getCollectionFields(field.target).filter(isAssociation).filter(this.filter).length;
 | 
			
		||||
    return {
 | 
			
		||||
      pId: parentNode?.key ?? null,
 | 
			
		||||
      id: key,
 | 
			
		||||
@ -60,35 +71,71 @@ function getCollectionFieldOptions(this: CallScope, collection, parentNode?): Tr
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
 | 
			
		||||
  const { value = [], onChange, collection, useCollection = usePropsCollection, ...restProps } = props;
 | 
			
		||||
  const {
 | 
			
		||||
    value: propsValue,
 | 
			
		||||
    onChange,
 | 
			
		||||
    collection,
 | 
			
		||||
    useCollection = usePropsCollection,
 | 
			
		||||
    filter = trueFilter,
 | 
			
		||||
    rootOption,
 | 
			
		||||
    ...restProps
 | 
			
		||||
  } = props;
 | 
			
		||||
  const { getCollectionFields } = useCollectionManager();
 | 
			
		||||
  const compile = useCompile();
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
  const [optionsMap, setOptionsMap] = useState({});
 | 
			
		||||
  const baseCollection = useCollection({ collection });
 | 
			
		||||
  const treeData = Object.values(optionsMap);
 | 
			
		||||
 | 
			
		||||
  const 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<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,57 +159,75 @@ 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 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 (
 | 
			
		||||
      <Tag closable={closable && !disabled} onClose={onClose}>{fullTitle.join(' / ')}</Tag>
 | 
			
		||||
    );
 | 
			
		||||
  }, [optionsMap]);
 | 
			
		||||
  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 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 (
 | 
			
		||||
    <TreeSelect
 | 
			
		||||
      value={filterdValue}
 | 
			
		||||
      dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
 | 
			
		||||
      value={filteredValue}
 | 
			
		||||
      placeholder={t('Select field')}
 | 
			
		||||
      showCheckedStrategy="SHOW_ALL"
 | 
			
		||||
      showCheckedStrategy={TreeSelect.SHOW_ALL}
 | 
			
		||||
      treeDefaultExpandedKeys={valueKeys}
 | 
			
		||||
      allowClear
 | 
			
		||||
      multiple
 | 
			
		||||
      treeCheckStrictly
 | 
			
		||||
      treeCheckable
 | 
			
		||||
      treeCheckStrictly={props.multiple}
 | 
			
		||||
      treeCheckable={props.multiple}
 | 
			
		||||
      tagRender={TreeTag}
 | 
			
		||||
      onChange={handleChange as unknown as () => void}
 | 
			
		||||
      onChange={handleChange as (next) => void}
 | 
			
		||||
      treeDataSimpleMode
 | 
			
		||||
      treeData={treeData}
 | 
			
		||||
      loadData={loadData}
 | 
			
		||||
 | 
			
		||||
@ -55,7 +55,7 @@ export const getTabFormatValue = (labelUiSchema: ISchema, value: any, tagColor):
 | 
			
		||||
  if (Array.isArray(options) && value) {
 | 
			
		||||
    const values = toArr(value).map((val) => {
 | 
			
		||||
      const opt: any = options.find((option: any) => option.value === val);
 | 
			
		||||
      return React.createElement(Tag, { color: tagColor||opt?.color }, opt?.label);
 | 
			
		||||
      return React.createElement(Tag, { color: tagColor || opt?.color }, opt?.label);
 | 
			
		||||
    });
 | 
			
		||||
    return values;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@ ReadPretty.ColorPicker = function ColorPicker(props: any) {
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={cls(prefixCls, props.className)}>
 | 
			
		||||
      <ColorPicker showText disabled value={props.value} size='small'/>
 | 
			
		||||
      <ColorPicker showText disabled value={props.value} size="small" />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,9 @@ const toStringByPicker = (value, picker, timezone: 'gmt' | 'local') => {
 | 
			
		||||
  if (!dayjs.isDayjs(value)) return value;
 | 
			
		||||
  if (timezone === 'local') {
 | 
			
		||||
    const offset = new Date().getTimezoneOffset();
 | 
			
		||||
    return dayjs(toStringByPicker(value, picker, 'gmt')).add(offset, 'minutes').toISOString();
 | 
			
		||||
    return dayjs(toStringByPicker(value, picker, 'gmt'))
 | 
			
		||||
      .add(offset, 'minutes')
 | 
			
		||||
      .toISOString();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (picker === 'year') {
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import { DeleteOutlined, MenuOutlined } from '@ant-design/icons';
 | 
			
		||||
import { TinyColor } from '@ctrl/tinycolor';
 | 
			
		||||
import { SortableContext, useSortable } from '@dnd-kit/sortable';
 | 
			
		||||
import { SortableContext, SortableContextProps, useSortable } from '@dnd-kit/sortable';
 | 
			
		||||
import { css } from '@emotion/css';
 | 
			
		||||
import { ArrayField, Field } from '@formily/core';
 | 
			
		||||
import { spliceArrayState } from '@formily/core/esm/shared/internals';
 | 
			
		||||
@ -454,13 +454,14 @@ export const Table: any = observer(
 | 
			
		||||
    const SortableWrapper = useCallback<React.FC>(
 | 
			
		||||
      ({ children }) => {
 | 
			
		||||
        return dragSort
 | 
			
		||||
          ? React.createElement(SortableContext, {
 | 
			
		||||
              items: field.value?.map?.(getRowKey) || [],
 | 
			
		||||
          ? React.createElement<Omit<SortableContextProps, 'children'>>(
 | 
			
		||||
              SortableContext,
 | 
			
		||||
              {
 | 
			
		||||
                items: field.value?.map?.(getRowKey) || [],
 | 
			
		||||
              },
 | 
			
		||||
              children,
 | 
			
		||||
            })
 | 
			
		||||
          : React.createElement(React.Fragment, {
 | 
			
		||||
              children,
 | 
			
		||||
            });
 | 
			
		||||
            )
 | 
			
		||||
          : React.createElement(React.Fragment, {}, children);
 | 
			
		||||
      },
 | 
			
		||||
      [field, dragSort],
 | 
			
		||||
    );
 | 
			
		||||
@ -530,7 +531,7 @@ export const Table: any = observer(
 | 
			
		||||
        {field.errors.length > 0 && (
 | 
			
		||||
          <div className="ant-formily-item-error-help ant-formily-item-help ant-formily-item-help-enter ant-formily-item-help-enter-active">
 | 
			
		||||
            {field.errors.map((error) => {
 | 
			
		||||
              return error.messages.map((message) => <div>{message}</div>);
 | 
			
		||||
              return error.messages.map((message) => <div key={message}>{message}</div>);
 | 
			
		||||
            })}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,29 @@
 | 
			
		||||
// TODO(refactor): should be moved to workflow plugin
 | 
			
		||||
const FormTriggerWorkflowActionInitializer = {
 | 
			
		||||
  type: 'item',
 | 
			
		||||
  title: '{{t("Submit to workflow", { ns: "workflow" })}}',
 | 
			
		||||
  component: 'CustomizeActionInitializer',
 | 
			
		||||
  schema: {
 | 
			
		||||
    title: '{{t("Submit to workflow", { ns: "workflow" })}}',
 | 
			
		||||
    'x-component': 'Action',
 | 
			
		||||
    'x-component-props': {
 | 
			
		||||
      useProps: '{{ useTriggerWorkflowsActionProps }}',
 | 
			
		||||
    },
 | 
			
		||||
    'x-designer': 'Action.Designer',
 | 
			
		||||
    'x-action-settings': {
 | 
			
		||||
      assignedValues: {},
 | 
			
		||||
      skipValidator: false,
 | 
			
		||||
      onSuccess: {
 | 
			
		||||
        manualClose: true,
 | 
			
		||||
        redirecting: false,
 | 
			
		||||
        successMessage: '{{t("Submitted successfully")}}',
 | 
			
		||||
      },
 | 
			
		||||
      triggerWorkflows: [],
 | 
			
		||||
    },
 | 
			
		||||
    'x-action': 'customize:triggerWorkflows',
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 表单的操作配置
 | 
			
		||||
export const FormActionInitializers = {
 | 
			
		||||
  title: '{{t("Configure actions")}}',
 | 
			
		||||
@ -94,14 +120,16 @@ export const FormActionInitializers = {
 | 
			
		||||
              onSuccess: {
 | 
			
		||||
                manualClose: true,
 | 
			
		||||
                redirecting: false,
 | 
			
		||||
                successMessage: '{{t("Saved successfully")}}',
 | 
			
		||||
                successMessage: '{{t("Submitted successfully")}}',
 | 
			
		||||
              },
 | 
			
		||||
              triggerWorkflows: [],
 | 
			
		||||
            },
 | 
			
		||||
            'x-component-props': {
 | 
			
		||||
              useProps: '{{ useCreateActionProps }}',
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        FormTriggerWorkflowActionInitializer,
 | 
			
		||||
        {
 | 
			
		||||
          type: 'item',
 | 
			
		||||
          title: '{{t("Custom request")}}',
 | 
			
		||||
@ -225,14 +253,16 @@ export const CreateFormActionInitializers = {
 | 
			
		||||
              onSuccess: {
 | 
			
		||||
                manualClose: true,
 | 
			
		||||
                redirecting: false,
 | 
			
		||||
                successMessage: '{{t("Saved successfully")}}',
 | 
			
		||||
                successMessage: '{{t("Submitted successfully")}}',
 | 
			
		||||
              },
 | 
			
		||||
              triggerWorkflows: [],
 | 
			
		||||
            },
 | 
			
		||||
            'x-component-props': {
 | 
			
		||||
              useProps: '{{ useCreateActionProps }}',
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        FormTriggerWorkflowActionInitializer,
 | 
			
		||||
        {
 | 
			
		||||
          type: 'item',
 | 
			
		||||
          title: '{{t("Custom request")}}',
 | 
			
		||||
@ -355,14 +385,16 @@ export const UpdateFormActionInitializers = {
 | 
			
		||||
              onSuccess: {
 | 
			
		||||
                manualClose: true,
 | 
			
		||||
                redirecting: false,
 | 
			
		||||
                successMessage: '{{t("Saved successfully")}}',
 | 
			
		||||
                successMessage: '{{t("Submitted successfully")}}',
 | 
			
		||||
              },
 | 
			
		||||
              triggerWorkflows: [],
 | 
			
		||||
            },
 | 
			
		||||
            'x-component-props': {
 | 
			
		||||
              useProps: '{{ useUpdateActionProps }}',
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        FormTriggerWorkflowActionInitializer,
 | 
			
		||||
        {
 | 
			
		||||
          type: 'item',
 | 
			
		||||
          title: '{{t("Custom request")}}',
 | 
			
		||||
@ -378,7 +410,7 @@ export const UpdateFormActionInitializers = {
 | 
			
		||||
              onSuccess: {
 | 
			
		||||
                manualClose: false,
 | 
			
		||||
                redirecting: false,
 | 
			
		||||
                successMessage: '{{t("Request success")}}',
 | 
			
		||||
                successMessage: '{{t("Submitted successfully")}}',
 | 
			
		||||
              },
 | 
			
		||||
            },
 | 
			
		||||
            'x-component-props': {
 | 
			
		||||
@ -485,7 +517,7 @@ export const BulkEditFormActionInitializers = {
 | 
			
		||||
              onSuccess: {
 | 
			
		||||
                manualClose: true,
 | 
			
		||||
                redirecting: false,
 | 
			
		||||
                successMessage: '{{t("Saved successfully")}}',
 | 
			
		||||
                successMessage: '{{t("Submitted successfully")}}',
 | 
			
		||||
              },
 | 
			
		||||
            },
 | 
			
		||||
            'x-component-props': {
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,9 @@ export const CreateSubmitActionInitializer = (props) => {
 | 
			
		||||
      htmlType: 'submit',
 | 
			
		||||
      useProps: '{{ useCreateActionProps }}',
 | 
			
		||||
    },
 | 
			
		||||
    'x-action-settings': {
 | 
			
		||||
      triggerWorkflows: [],
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
  return <ActionInitializer {...props} schema={schema} />;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,9 @@ export const UpdateSubmitActionInitializer = (props) => {
 | 
			
		||||
      htmlType: 'submit',
 | 
			
		||||
      useProps: '{{ useUpdateActionProps }}',
 | 
			
		||||
    },
 | 
			
		||||
    'x-action-settings': {
 | 
			
		||||
      triggerWorkflows: [],
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
  return <ActionInitializer {...props} schema={schema} />;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -100,9 +100,7 @@ export const useSyncFromForm = (fieldSchema, collection?, callBack?) => {
 | 
			
		||||
      cache.set(cacheKey, result);
 | 
			
		||||
      return result;
 | 
			
		||||
    };
 | 
			
		||||
  })(
 | 
			
		||||
    new LRUCache<string, any>({ max: 100 }),
 | 
			
		||||
  );
 | 
			
		||||
  })(new LRUCache<string, any>({ max: 100 }));
 | 
			
		||||
 | 
			
		||||
  const traverseAssociations = ((cache) => {
 | 
			
		||||
    return (collectionName, { prefix, maxDepth, depth = 0, exclude = [] }) => {
 | 
			
		||||
@ -149,9 +147,7 @@ export const useSyncFromForm = (fieldSchema, collection?, callBack?) => {
 | 
			
		||||
      cache.set(cacheKey, result);
 | 
			
		||||
      return result;
 | 
			
		||||
    };
 | 
			
		||||
  })(
 | 
			
		||||
    new LRUCache<string, any>({ max: 100 }),
 | 
			
		||||
  );
 | 
			
		||||
  })(new LRUCache<string, any>({ max: 100 }));
 | 
			
		||||
  const getEnableFieldTree = useCallback((collectionName: string, formData) => {
 | 
			
		||||
    if (!collectionName) {
 | 
			
		||||
      return [];
 | 
			
		||||
 | 
			
		||||
@ -16,3 +16,4 @@ export * from './registry';
 | 
			
		||||
// export * from './toposort';
 | 
			
		||||
export * from './uid';
 | 
			
		||||
export { dayjs, lodash };
 | 
			
		||||
export * from './url';
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										11
									
								
								packages/core/utils/src/url.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/core/utils/src/url.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
			
		||||
export function isURL(string) {
 | 
			
		||||
  let url;
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    url = new URL(string);
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return url.protocol === 'http:' || url.protocol === 'https:';
 | 
			
		||||
}
 | 
			
		||||
@ -382,11 +382,11 @@ export const GraphDrawPage = React.memo(() => {
 | 
			
		||||
  const categoryCtx = useContext(CollectionCategroriesContext);
 | 
			
		||||
  const scope = { ...options?.scope };
 | 
			
		||||
  const components = { ...options?.components };
 | 
			
		||||
  const useSaveGraphPositionAction = async (data) => {
 | 
			
		||||
  const saveGraphPositionAction = async (data) => {
 | 
			
		||||
    await api.resource('graphPositions').create({ values: data });
 | 
			
		||||
    await refreshPositions();
 | 
			
		||||
  };
 | 
			
		||||
  const useUpdatePositionAction = async (data, isbatch = false) => {
 | 
			
		||||
  const updatePositionAction = async (data, isbatch = false) => {
 | 
			
		||||
    if (isbatch) {
 | 
			
		||||
      await api.resource('graphPositions').update({
 | 
			
		||||
        values: data,
 | 
			
		||||
@ -413,7 +413,7 @@ export const GraphDrawPage = React.memo(() => {
 | 
			
		||||
  const refreshGM = async () => {
 | 
			
		||||
    const data = await refreshCM();
 | 
			
		||||
    targetGraph.collections = data;
 | 
			
		||||
    targetGraph.updatePositionAction = useUpdatePositionAction;
 | 
			
		||||
    targetGraph.updatePositionAction = updatePositionAction;
 | 
			
		||||
    const currentNodes = targetGraph.getNodes();
 | 
			
		||||
    setCollectionData(data);
 | 
			
		||||
    setCollectionList(data);
 | 
			
		||||
@ -568,12 +568,12 @@ export const GraphDrawPage = React.memo(() => {
 | 
			
		||||
      const oldPosition = targetGraph.positions.find((v) => v.collectionName === node.store.data.name);
 | 
			
		||||
      if (oldPosition) {
 | 
			
		||||
        (oldPosition.x !== currentPosition.x || oldPosition.y !== currentPosition.y) &&
 | 
			
		||||
          useUpdatePositionAction({
 | 
			
		||||
          updatePositionAction({
 | 
			
		||||
            collectionName: node.store.data.name,
 | 
			
		||||
            ...currentPosition,
 | 
			
		||||
          });
 | 
			
		||||
      } else {
 | 
			
		||||
        useSaveGraphPositionAction({
 | 
			
		||||
        saveGraphPositionAction({
 | 
			
		||||
          collectionName: node.store.data.name,
 | 
			
		||||
          ...currentPosition,
 | 
			
		||||
        });
 | 
			
		||||
@ -609,7 +609,7 @@ export const GraphDrawPage = React.memo(() => {
 | 
			
		||||
 | 
			
		||||
  const handleEdgeUnActive = (targetEdge) => {
 | 
			
		||||
    targetGraph.activeEdge = null;
 | 
			
		||||
    const { m2m, connectionType } = targetEdge.store?.data;
 | 
			
		||||
    const { m2m, connectionType } = targetEdge.store?.data ?? {};
 | 
			
		||||
    const m2mLineId = m2m?.find((v) => v !== targetEdge.id);
 | 
			
		||||
    const m2mEdge = targetGraph.getCellById(m2mLineId);
 | 
			
		||||
    const lightsOut = (edge) => {
 | 
			
		||||
@ -646,7 +646,7 @@ export const GraphDrawPage = React.memo(() => {
 | 
			
		||||
  };
 | 
			
		||||
  const handleEdgeActive = (targetEdge) => {
 | 
			
		||||
    targetGraph.activeEdge = targetEdge;
 | 
			
		||||
    const { associated, m2m, connectionType } = targetEdge.store?.data;
 | 
			
		||||
    const { associated, m2m, connectionType } = targetEdge.store?.data ?? {};
 | 
			
		||||
    const m2mLineId = m2m?.find((v) => v !== targetEdge.id);
 | 
			
		||||
    const m2mEdge = targetGraph.getCellById(m2mLineId);
 | 
			
		||||
    const lightUp = (edge) => {
 | 
			
		||||
@ -702,7 +702,7 @@ export const GraphDrawPage = React.memo(() => {
 | 
			
		||||
    getNodes(nodesData);
 | 
			
		||||
    getEdges(edgesData);
 | 
			
		||||
    getEdges(inheritEdges);
 | 
			
		||||
    layout(useSaveGraphPositionAction);
 | 
			
		||||
    layout(saveGraphPositionAction);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // 增量渲染
 | 
			
		||||
@ -721,18 +721,19 @@ export const GraphDrawPage = React.memo(() => {
 | 
			
		||||
    const diffNodes = getDiffNode(nodesData, currentNodes);
 | 
			
		||||
    const diffEdges = getDiffEdge(edgesData, currentEdgesGroup.currentRelateEdges || []);
 | 
			
		||||
    const diffInheritEdge = getDiffEdge(inheritEdges, currentEdgesGroup.currentInheritEdges || []);
 | 
			
		||||
    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) => ({
 | 
			
		||||
 | 
			
		||||
@ -169,6 +169,7 @@ export const snapshot: IField = {
 | 
			
		||||
      'x-decorator': 'FormItem',
 | 
			
		||||
      'x-component': 'AppendsTreeSelect',
 | 
			
		||||
      'x-component-props': {
 | 
			
		||||
        multiple: true,
 | 
			
		||||
        useCollection: useRecordCollection,
 | 
			
		||||
      },
 | 
			
		||||
      'x-reactions': [
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
import { createStyles, cx } from '@nocobase/client';
 | 
			
		||||
import { Tag } from 'antd';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { lang } from '../locale';
 | 
			
		||||
 | 
			
		||||
const useStyles = createStyles(({ css, token }) => {
 | 
			
		||||
  return {
 | 
			
		||||
@ -16,6 +15,7 @@ const useStyles = createStyles(({ css, token }) => {
 | 
			
		||||
 | 
			
		||||
      dl {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        align-items: baseline;
 | 
			
		||||
 | 
			
		||||
        dt {
 | 
			
		||||
          color: ${token.colorText};
 | 
			
		||||
@ -33,19 +33,19 @@ const useStyles = createStyles(({ css, token }) => {
 | 
			
		||||
  };
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export function NodeDescription(props) {
 | 
			
		||||
  const { instruction } = props;
 | 
			
		||||
export function DrawerDescription(props) {
 | 
			
		||||
  const { label, title, description } = props;
 | 
			
		||||
  const { styles } = useStyles();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={cx(styles.container, props.className)}>
 | 
			
		||||
      <dl>
 | 
			
		||||
        <dt>{lang('Node type')}</dt>
 | 
			
		||||
        <dt>{label}</dt>
 | 
			
		||||
        <dd>
 | 
			
		||||
          <Tag style={{ background: 'none' }}>{instruction.title}</Tag>
 | 
			
		||||
          <Tag style={{ background: 'none' }}>{title}</Tag>
 | 
			
		||||
        </dd>
 | 
			
		||||
      </dl>
 | 
			
		||||
      {instruction.description ? <p>{instruction.description}</p> : null}
 | 
			
		||||
      {description ? <p>{description}</p> : null}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -4,7 +4,7 @@ export * from './nodes';
 | 
			
		||||
export { triggers } from './triggers';
 | 
			
		||||
export { useWorkflowVariableOptions } from './variable';
 | 
			
		||||
 | 
			
		||||
import { Plugin, useCollectionDataSource } from '@nocobase/client';
 | 
			
		||||
import { Plugin } from '@nocobase/client';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { ExecutionPage } from './ExecutionPage';
 | 
			
		||||
import { WorkflowPage } from './WorkflowPage';
 | 
			
		||||
@ -12,17 +12,19 @@ import { WorkflowProvider } from './WorkflowProvider';
 | 
			
		||||
import { DynamicExpression } from './components/DynamicExpression';
 | 
			
		||||
import { WorkflowTodo } from './nodes/manual/WorkflowTodo';
 | 
			
		||||
import { WorkflowTodoBlockInitializer } from './nodes/manual/WorkflowTodoBlockInitializer';
 | 
			
		||||
import { useTriggerWorkflowsActionProps } from './triggers/form';
 | 
			
		||||
 | 
			
		||||
export class WorkflowPlugin extends Plugin {
 | 
			
		||||
  async load() {
 | 
			
		||||
    this.addRoutes();
 | 
			
		||||
    this.addScopes();
 | 
			
		||||
    this.addComponents();
 | 
			
		||||
    this.app.use(WorkflowProvider);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  addScopes() {
 | 
			
		||||
    this.app.addScopes({
 | 
			
		||||
      useCollectionDataSource,
 | 
			
		||||
      useTriggerWorkflowsActionProps,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@ import React, { useContext, useState } from 'react';
 | 
			
		||||
import { useTranslation } from 'react-i18next';
 | 
			
		||||
import { AddButton } from '../AddButton';
 | 
			
		||||
import { useFlowContext } from '../FlowContext';
 | 
			
		||||
import { NodeDescription } from '../components/NodeDescription';
 | 
			
		||||
import { DrawerDescription } from '../components/DrawerDescription';
 | 
			
		||||
import { JobStatusOptionsMap } from '../constants';
 | 
			
		||||
import { NAMESPACE, lang } from '../locale';
 | 
			
		||||
import useStyles from '../style';
 | 
			
		||||
@ -426,9 +426,11 @@ export function NodeDefaultView(props) {
 | 
			
		||||
                      ? {
 | 
			
		||||
                          description: {
 | 
			
		||||
                            type: 'void',
 | 
			
		||||
                            'x-component': NodeDescription,
 | 
			
		||||
                            'x-component': DrawerDescription,
 | 
			
		||||
                            'x-component-props': {
 | 
			
		||||
                              instruction,
 | 
			
		||||
                              label: lang('Node type'),
 | 
			
		||||
                              title: instruction.title,
 | 
			
		||||
                              description: instruction.description,
 | 
			
		||||
                            },
 | 
			
		||||
                          },
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
@ -56,6 +56,7 @@ export const appends = {
 | 
			
		||||
  'x-decorator': 'FormItem',
 | 
			
		||||
  'x-component': 'AppendsTreeSelect',
 | 
			
		||||
  'x-component-props': {
 | 
			
		||||
    multiple: true,
 | 
			
		||||
    useCollection() {
 | 
			
		||||
      const { values } = useForm();
 | 
			
		||||
      return values?.collection;
 | 
			
		||||
 | 
			
		||||
@ -243,7 +243,9 @@ const useStyles = createStyles(({ css, token }) => {
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
 | 
			
		||||
        &:not(:focus) {
 | 
			
		||||
          transition: background-color 0.3s ease, border-color 0.3s ease;
 | 
			
		||||
          transition:
 | 
			
		||||
            background-color 0.3s ease,
 | 
			
		||||
            border-color 0.3s ease;
 | 
			
		||||
          border-color: ${token.colorBorderBg};
 | 
			
		||||
          background-color: ${token.colorBgContainerDisabled};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -27,6 +27,7 @@ const collectionModeOptions = [
 | 
			
		||||
export default {
 | 
			
		||||
  title: `{{t("Collection event", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
  type: 'collection',
 | 
			
		||||
  description: `{{t("Event will be triggered on collection data row created, updated or deleted.", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
  fieldset: {
 | 
			
		||||
    collection: {
 | 
			
		||||
      ...collection,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										195
									
								
								packages/plugins/workflow/src/client/triggers/form.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								packages/plugins/workflow/src/client/triggers/form.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,195 @@
 | 
			
		||||
import { useField, useFieldSchema, useForm } from '@formily/react';
 | 
			
		||||
import {
 | 
			
		||||
  SchemaInitializerItemOptions,
 | 
			
		||||
  useAPIClient,
 | 
			
		||||
  useActionContext,
 | 
			
		||||
  useBlockRequestContext,
 | 
			
		||||
  useCollection,
 | 
			
		||||
  useCollectionDataSource,
 | 
			
		||||
  useCollectionManager,
 | 
			
		||||
  useCompile,
 | 
			
		||||
  useCurrentUserContext,
 | 
			
		||||
  useFilterByTk,
 | 
			
		||||
  useRecord,
 | 
			
		||||
} from '@nocobase/client';
 | 
			
		||||
import { isURL, parse } from '@nocobase/utils/client';
 | 
			
		||||
import { App, message } from 'antd';
 | 
			
		||||
import omit from 'lodash/omit';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
 | 
			
		||||
import { NAMESPACE, lang } from '../locale';
 | 
			
		||||
import { appends, collection } from '../schemas/collection';
 | 
			
		||||
import { getCollectionFieldOptions } from '../variable';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  title: `{{t("Form event", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
  type: 'form',
 | 
			
		||||
  description: `{{t("Event triggers when submitted a workflow bound form action.", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
  fieldset: {
 | 
			
		||||
    collection: {
 | 
			
		||||
      ...collection,
 | 
			
		||||
      title: `{{t("Form data model", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
      description: `{{t("Use a collection to match form data.", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
      ['x-reactions']: [
 | 
			
		||||
        ...collection['x-reactions'],
 | 
			
		||||
        {
 | 
			
		||||
          target: 'appends',
 | 
			
		||||
          effects: ['onFieldValueChange'],
 | 
			
		||||
          fulfill: {
 | 
			
		||||
            state: {
 | 
			
		||||
              value: [],
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
    appends: {
 | 
			
		||||
      ...appends,
 | 
			
		||||
      title: `{{t("Associations to use", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  scope: {
 | 
			
		||||
    useCollectionDataSource,
 | 
			
		||||
  },
 | 
			
		||||
  components: {},
 | 
			
		||||
  useVariables(config, options) {
 | 
			
		||||
    const compile = useCompile();
 | 
			
		||||
    const { getCollectionFields } = useCollectionManager();
 | 
			
		||||
    const rootFields = [
 | 
			
		||||
      {
 | 
			
		||||
        collectionName: config.collection,
 | 
			
		||||
        name: 'data',
 | 
			
		||||
        type: 'hasOne',
 | 
			
		||||
        target: config.collection,
 | 
			
		||||
        uiSchema: {
 | 
			
		||||
          title: lang('Trigger data'),
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    ];
 | 
			
		||||
    const result = getCollectionFieldOptions({
 | 
			
		||||
      // depth,
 | 
			
		||||
      ...options,
 | 
			
		||||
      fields: rootFields,
 | 
			
		||||
      appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
 | 
			
		||||
      compile,
 | 
			
		||||
      getCollectionFields,
 | 
			
		||||
    });
 | 
			
		||||
    return result;
 | 
			
		||||
  },
 | 
			
		||||
  useInitializers(config): SchemaInitializerItemOptions | null {
 | 
			
		||||
    if (!config.collection) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      type: 'item',
 | 
			
		||||
      key: 'triggerData',
 | 
			
		||||
      title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
      component: CollectionBlockInitializer,
 | 
			
		||||
      collection: config.collection,
 | 
			
		||||
      dataSource: '{{$context.data}}',
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  initializers: {},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function getFormValues(filterByTk, field, form, fieldNames, getField, resource) {
 | 
			
		||||
  if (filterByTk) {
 | 
			
		||||
    const actionFields = field?.data?.activeFields as Set<string>;
 | 
			
		||||
    if (actionFields) {
 | 
			
		||||
      const keys = Object.keys(form.values).filter((key) => {
 | 
			
		||||
        const f = getField(key);
 | 
			
		||||
        return !actionFields.has(key) && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(f?.type);
 | 
			
		||||
      });
 | 
			
		||||
      return omit({ ...form.values }, keys);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return form.values;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useTriggerWorkflowsActionProps() {
 | 
			
		||||
  const api = useAPIClient();
 | 
			
		||||
  const form = useForm();
 | 
			
		||||
  const { field, resource, __parent } = useBlockRequestContext();
 | 
			
		||||
  const { setVisible, fieldSchema } = useActionContext();
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const actionSchema = useFieldSchema();
 | 
			
		||||
  const actionField = useField();
 | 
			
		||||
  const { fields, getField, getTreeParentField } = useCollection();
 | 
			
		||||
  const compile = useCompile();
 | 
			
		||||
  const filterByTk = useFilterByTk();
 | 
			
		||||
  const currentRecord = useRecord();
 | 
			
		||||
  const currentUserContext = useCurrentUserContext();
 | 
			
		||||
  const { modal } = App.useApp();
 | 
			
		||||
 | 
			
		||||
  const currentUser = currentUserContext?.data?.data;
 | 
			
		||||
  const filterKeys = actionField.componentProps.filterKeys || [];
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    async onClick() {
 | 
			
		||||
      const fieldNames = fields.map((field) => field.name);
 | 
			
		||||
      const {
 | 
			
		||||
        assignedValues: originalAssignedValues = {},
 | 
			
		||||
        onSuccess,
 | 
			
		||||
        overwriteValues,
 | 
			
		||||
        skipValidator,
 | 
			
		||||
        triggerWorkflows,
 | 
			
		||||
      } = actionSchema?.['x-action-settings'] ?? {};
 | 
			
		||||
      const addChild = fieldSchema?.['x-component-props']?.addChild;
 | 
			
		||||
      const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser });
 | 
			
		||||
      if (!skipValidator) {
 | 
			
		||||
        await form.submit();
 | 
			
		||||
      }
 | 
			
		||||
      const values = getFormValues(filterByTk, field, form, fieldNames, getField, resource);
 | 
			
		||||
      // const values = omitBy(formValues, (value) => isEqual(JSON.stringify(value), '[{}]'));
 | 
			
		||||
      if (addChild) {
 | 
			
		||||
        const treeParentField = getTreeParentField();
 | 
			
		||||
        values[treeParentField?.name ?? 'parent'] = currentRecord;
 | 
			
		||||
        values[treeParentField?.foreignKey ?? 'parentId'] = currentRecord.id;
 | 
			
		||||
      }
 | 
			
		||||
      actionField.data = field.data || {};
 | 
			
		||||
      actionField.data.loading = true;
 | 
			
		||||
      try {
 | 
			
		||||
        const data = await api.resource('workflows').trigger({
 | 
			
		||||
          values: {
 | 
			
		||||
            ...values,
 | 
			
		||||
            ...overwriteValues,
 | 
			
		||||
            ...assignedValues,
 | 
			
		||||
          },
 | 
			
		||||
          filterKeys: filterKeys,
 | 
			
		||||
          // TODO(refactor): should change to inject by plugin
 | 
			
		||||
          triggerWorkflows: triggerWorkflows?.length
 | 
			
		||||
            ? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',')
 | 
			
		||||
            : undefined,
 | 
			
		||||
        });
 | 
			
		||||
        actionField.data.loading = false;
 | 
			
		||||
        actionField.data.data = data;
 | 
			
		||||
        __parent?.service?.refresh?.();
 | 
			
		||||
        setVisible?.(false);
 | 
			
		||||
        if (!onSuccess?.successMessage) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        if (onSuccess?.manualClose) {
 | 
			
		||||
          modal.success({
 | 
			
		||||
            title: compile(onSuccess?.successMessage),
 | 
			
		||||
            onOk: async () => {
 | 
			
		||||
              await form.reset();
 | 
			
		||||
              if (onSuccess?.redirecting && onSuccess?.redirectTo) {
 | 
			
		||||
                if (isURL(onSuccess.redirectTo)) {
 | 
			
		||||
                  window.location.href = onSuccess.redirectTo;
 | 
			
		||||
                } else {
 | 
			
		||||
                  navigate(onSuccess.redirectTo);
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
          });
 | 
			
		||||
        } else {
 | 
			
		||||
          message.success(compile(onSuccess?.successMessage));
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        actionField.data.loading = false;
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@ -16,10 +16,12 @@ import { Registry } from '@nocobase/utils/client';
 | 
			
		||||
import { Alert, Button, Input, Tag, message } from 'antd';
 | 
			
		||||
import React, { useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import { useFlowContext } from '../FlowContext';
 | 
			
		||||
import { DrawerDescription } from '../components/DrawerDescription';
 | 
			
		||||
import { NAMESPACE, lang } from '../locale';
 | 
			
		||||
import useStyles from '../style';
 | 
			
		||||
import { VariableOptions } from '../variable';
 | 
			
		||||
import collection from './collection';
 | 
			
		||||
import formTrigger from './form';
 | 
			
		||||
import schedule from './schedule/';
 | 
			
		||||
 | 
			
		||||
function useUpdateConfigAction() {
 | 
			
		||||
@ -50,6 +52,7 @@ function useUpdateConfigAction() {
 | 
			
		||||
export interface Trigger {
 | 
			
		||||
  title: string;
 | 
			
		||||
  type: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  // group: string;
 | 
			
		||||
  useVariables?(config: any, options?): VariableOptions;
 | 
			
		||||
  fieldset: { [key: string]: ISchema };
 | 
			
		||||
@ -62,6 +65,7 @@ export interface Trigger {
 | 
			
		||||
 | 
			
		||||
export const triggers = new Registry<Trigger>();
 | 
			
		||||
 | 
			
		||||
triggers.register(formTrigger.type, formTrigger);
 | 
			
		||||
triggers.register(collection.type, collection);
 | 
			
		||||
triggers.register(schedule.type, schedule);
 | 
			
		||||
 | 
			
		||||
@ -168,7 +172,7 @@ export const TriggerConfig = () => {
 | 
			
		||||
  const { fieldset, scope, components } = trigger;
 | 
			
		||||
  typeTitle = trigger.title;
 | 
			
		||||
  const detailText = executed ? '{{t("View")}}' : '{{t("Configure")}}';
 | 
			
		||||
  const titleText = `${lang('Trigger')}: ${compile(typeTitle)}`;
 | 
			
		||||
  const titleText = lang('Trigger');
 | 
			
		||||
 | 
			
		||||
  async function onChangeTitle(next) {
 | 
			
		||||
    const t = next || typeTitle;
 | 
			
		||||
@ -254,6 +258,18 @@ export const TriggerConfig = () => {
 | 
			
		||||
                          },
 | 
			
		||||
                        },
 | 
			
		||||
                      }
 | 
			
		||||
                    : trigger.description
 | 
			
		||||
                    ? {
 | 
			
		||||
                        description: {
 | 
			
		||||
                          type: 'void',
 | 
			
		||||
                          'x-component': DrawerDescription,
 | 
			
		||||
                          'x-component-props': {
 | 
			
		||||
                            label: lang('Trigger type'),
 | 
			
		||||
                            title: trigger.title,
 | 
			
		||||
                            description: trigger.description,
 | 
			
		||||
                          },
 | 
			
		||||
                        },
 | 
			
		||||
                      }
 | 
			
		||||
                    : {}),
 | 
			
		||||
                  fieldset: {
 | 
			
		||||
                    type: 'void',
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,20 @@
 | 
			
		||||
import { useCollectionDataSource, SchemaInitializerItemOptions, useCompile, useCollectionManager } from '@nocobase/client';
 | 
			
		||||
import {
 | 
			
		||||
  SchemaInitializerItemOptions,
 | 
			
		||||
  useCollectionDataSource,
 | 
			
		||||
  useCollectionManager,
 | 
			
		||||
  useCompile,
 | 
			
		||||
} from '@nocobase/client';
 | 
			
		||||
 | 
			
		||||
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
 | 
			
		||||
import { NAMESPACE, lang } from '../../locale';
 | 
			
		||||
import { getCollectionFieldOptions } from '../../variable';
 | 
			
		||||
import { ScheduleConfig } from './ScheduleConfig';
 | 
			
		||||
import { SCHEDULE_MODE } from './constants';
 | 
			
		||||
import { NAMESPACE, lang } from '../../locale';
 | 
			
		||||
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
 | 
			
		||||
import { defaultFieldNames, getCollectionFieldOptions } from '../../variable';
 | 
			
		||||
import { FieldsSelect } from '../../components/FieldsSelect';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  title: `{{t("Schedule event", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
  type: 'schedule',
 | 
			
		||||
  description: `{{t("Event will be scheduled and triggered base on time conditions.", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
  fieldset: {
 | 
			
		||||
    config: {
 | 
			
		||||
      type: 'void',
 | 
			
		||||
@ -26,7 +31,6 @@ export default {
 | 
			
		||||
  useVariables(config, opts) {
 | 
			
		||||
    const compile = useCompile();
 | 
			
		||||
    const { getCollectionFields } = useCollectionManager();
 | 
			
		||||
    const { fieldNames = defaultFieldNames } = opts;
 | 
			
		||||
    const options: any[] = [];
 | 
			
		||||
    if (!opts?.types || opts.types.includes('date')) {
 | 
			
		||||
      options.push({ key: 'date', value: 'date', label: lang('Trigger time') });
 | 
			
		||||
@ -49,7 +53,7 @@ export default {
 | 
			
		||||
            uiSchema: {
 | 
			
		||||
              title: lang('Trigger data'),
 | 
			
		||||
            },
 | 
			
		||||
          }
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
        appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
 | 
			
		||||
        compile,
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,25 @@ export default {
 | 
			
		||||
  'Trigger data': '触发数据',
 | 
			
		||||
  'Trigger time': '触发时间',
 | 
			
		||||
  'Triggered at': '触发时间',
 | 
			
		||||
 | 
			
		||||
  'Form event': '表单事件',
 | 
			
		||||
  'Event triggers when submitted a workflow bound form action.': '在提交绑定工作流的表单操作按钮后触发。',
 | 
			
		||||
  'Form data model': '表单数据模型',
 | 
			
		||||
  'Use a collection to match form data.': '使用一个数据表来匹配表单数据。',
 | 
			
		||||
  'Associations to use': '待使用的关系数据',
 | 
			
		||||
  'Bind workflows': '绑定工作流',
 | 
			
		||||
  'Workflow will be triggered after submitting succeeded.': '提交成功后触发工作流。',
 | 
			
		||||
  'Workflow will be triggered after saving succeeded.': '保存成功后触发工作流。',
 | 
			
		||||
  'Workflow will be triggered directly once the button clicked.': '按钮点击后直接触发工作流。',
 | 
			
		||||
  'Submit to workflow': '提交至工作流',
 | 
			
		||||
  'Add workflow': '添加工作流',
 | 
			
		||||
  'Select workflow': '选择工作流',
 | 
			
		||||
  'Trigger data context': '触发数据上下文',
 | 
			
		||||
  'Full form data': '完整表单数据',
 | 
			
		||||
  'Select context': '选择上下文',
 | 
			
		||||
  'Collection event': '数据表事件',
 | 
			
		||||
  'Event will be triggered on collection data row created, updated or deleted.':
 | 
			
		||||
    '当数据表中的数据被新增、更新或删除时触发。',
 | 
			
		||||
  'Trigger on': '触发时机',
 | 
			
		||||
  'After record added': '新增数据后',
 | 
			
		||||
  'After record updated': '更新数据后',
 | 
			
		||||
@ -37,6 +55,7 @@ export default {
 | 
			
		||||
  'Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.':
 | 
			
		||||
    '请选中需要在后续节点中被访问的关系字段。超过两层的对多关联可能会导致性能问题,请谨慎使用。',
 | 
			
		||||
  'Schedule event': '定时任务',
 | 
			
		||||
  'Scheduled job base on time conditions.': '基于时间条件的计划任务',
 | 
			
		||||
  'Trigger mode': '触发模式',
 | 
			
		||||
  'Based on certain date': '自定义时间',
 | 
			
		||||
  'Based on date field of collection': '根据数据表时间字段',
 | 
			
		||||
 | 
			
		||||
@ -129,7 +129,13 @@ export default class WorkflowPlugin extends Plugin {
 | 
			
		||||
      ],
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.app.acl.registerSnippet({
 | 
			
		||||
      name: 'ui.*',
 | 
			
		||||
      actions: ['workflows:list'],
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.app.acl.allow('users_jobs', ['list', 'get', 'submit'], 'loggedIn');
 | 
			
		||||
    this.app.acl.allow('workflows', ['trigger'], 'loggedIn');
 | 
			
		||||
 | 
			
		||||
    await db.import({
 | 
			
		||||
      directory: path.resolve(__dirname, 'collections'),
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,8 @@ import { CollectionOptions } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'posts',
 | 
			
		||||
  createdBy: true,
 | 
			
		||||
  updatedBy: true,
 | 
			
		||||
  fields: [
 | 
			
		||||
    {
 | 
			
		||||
      type: 'string',
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,354 @@
 | 
			
		||||
import Database from '@nocobase/database';
 | 
			
		||||
import { MockServer } from '@nocobase/test';
 | 
			
		||||
import { getApp, sleep } from '..';
 | 
			
		||||
import { EXECUTION_STATUS } from '../../constants';
 | 
			
		||||
 | 
			
		||||
describe('workflow > triggers > form', () => {
 | 
			
		||||
  let app: MockServer;
 | 
			
		||||
  let db: Database;
 | 
			
		||||
  let agent;
 | 
			
		||||
  let PostRepo;
 | 
			
		||||
  let CommentRepo;
 | 
			
		||||
  let WorkflowModel;
 | 
			
		||||
  let UserModel;
 | 
			
		||||
  let users;
 | 
			
		||||
  let userAgents;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    app = await getApp({
 | 
			
		||||
      plugins: ['users', 'auth'],
 | 
			
		||||
    });
 | 
			
		||||
    await app.getPlugin('auth').install();
 | 
			
		||||
    agent = app.agent();
 | 
			
		||||
    db = app.db;
 | 
			
		||||
    WorkflowModel = db.getCollection('workflows').model;
 | 
			
		||||
    PostRepo = db.getCollection('posts').repository;
 | 
			
		||||
    CommentRepo = db.getCollection('comments').repository;
 | 
			
		||||
    UserModel = db.getCollection('users').model;
 | 
			
		||||
 | 
			
		||||
    users = await UserModel.bulkCreate([
 | 
			
		||||
      { id: 1, nickname: 'a' },
 | 
			
		||||
      { id: 2, nickname: 'b' },
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    userAgents = users.map((user) => app.agent().login(user));
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterEach(() => app.stop());
 | 
			
		||||
 | 
			
		||||
  describe('create', () => {
 | 
			
		||||
    it('enabled / disabled', async () => {
 | 
			
		||||
      const workflow = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
        config: {
 | 
			
		||||
          collection: 'posts',
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res1 = await userAgents[0].resource('posts').create({
 | 
			
		||||
        values: { title: 't1' },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res1.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const e1 = await workflow.getExecutions();
 | 
			
		||||
      expect(e1.length).toBe(1);
 | 
			
		||||
      expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e1[0].context.data).toMatchObject({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      await workflow.update({
 | 
			
		||||
        enabled: false,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res2 = await userAgents[0].resource('posts').create({
 | 
			
		||||
        values: { title: 't2' },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res2.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const e2 = await workflow.getExecutions({ order: [['id', 'ASC']] });
 | 
			
		||||
      expect(e2.length).toBe(1);
 | 
			
		||||
 | 
			
		||||
      await workflow.update({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res3 = await userAgents[0].resource('posts').create({
 | 
			
		||||
        values: { title: 't3' },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res2.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const e3 = await workflow.getExecutions({ order: [['id', 'ASC']] });
 | 
			
		||||
      expect(e3.length).toBe(2);
 | 
			
		||||
      expect(e3[1].status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e3[1].context.data).toMatchObject({ title: 't3' });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('only trigger if params provided matching collection config', async () => {
 | 
			
		||||
      const workflow = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
        config: {
 | 
			
		||||
          collection: 'posts',
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res1 = await userAgents[0].resource('posts').create({
 | 
			
		||||
        values: { title: 't1' },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res1.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const e1 = await workflow.getExecutions();
 | 
			
		||||
      expect(e1.length).toBe(1);
 | 
			
		||||
      expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e1[0].context.data).toMatchObject({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      await workflow.update({
 | 
			
		||||
        config: {
 | 
			
		||||
          collection: 'comments',
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res2 = await userAgents[0].resource('posts').create({
 | 
			
		||||
        values: { title: 't2' },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res2.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const e2 = await workflow.getExecutions({ order: [['id', 'ASC']] });
 | 
			
		||||
      expect(e2.length).toBe(1);
 | 
			
		||||
      // expect(e2[1].status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      // expect(e2[1].context.data).toMatchObject({ title: 't2' });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('system fields could be accessed', async () => {
 | 
			
		||||
      const workflow = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
        config: {
 | 
			
		||||
          collection: 'posts',
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res1 = await userAgents[0].resource('posts').create({
 | 
			
		||||
        values: { title: 't1' },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res1.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const e1 = await workflow.getExecutions();
 | 
			
		||||
      expect(e1.length).toBe(1);
 | 
			
		||||
      expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e1[0].context.data).toHaveProperty('createdAt');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('appends', async () => {
 | 
			
		||||
      const workflow = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
        config: {
 | 
			
		||||
          collection: 'posts',
 | 
			
		||||
          appends: ['createdBy'],
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res1 = await userAgents[0].resource('posts').create({
 | 
			
		||||
        values: { title: 't1' },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res1.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const e1 = await workflow.getExecutions();
 | 
			
		||||
      expect(e1.length).toBe(1);
 | 
			
		||||
      expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e1[0].context.data).toHaveProperty('createdBy');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('update', () => {
 | 
			
		||||
    it('trigger after updated', async () => {
 | 
			
		||||
      const workflow = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
        config: {
 | 
			
		||||
          collection: 'posts',
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res1 = await userAgents[0].resource('posts').create({
 | 
			
		||||
        values: { title: 't1' },
 | 
			
		||||
      });
 | 
			
		||||
      expect(res1.status).toBe(200);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const e1 = await workflow.getExecutions();
 | 
			
		||||
      expect(e1.length).toBe(0);
 | 
			
		||||
 | 
			
		||||
      const res2 = await userAgents[0].resource('posts').update({
 | 
			
		||||
        filterByTk: res1.body.data.id,
 | 
			
		||||
        values: { title: 't2' },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}`,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const [e2] = await workflow.getExecutions();
 | 
			
		||||
      expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e2.context.data).toHaveProperty('title', 't2');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('directly trigger', () => {
 | 
			
		||||
    it('trigger data', async () => {
 | 
			
		||||
      const workflow = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res1 = await userAgents[0].resource('workflows').trigger({
 | 
			
		||||
        values: { title: 't1' },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res1.status).toBe(202);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const [e1] = await workflow.getExecutions();
 | 
			
		||||
      expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e1.context.data).toMatchObject({ title: 't1' });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('multi trigger', async () => {
 | 
			
		||||
      const w1 = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const w2 = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res1 = await userAgents[0].resource('workflows').trigger({
 | 
			
		||||
        values: { title: 't1' },
 | 
			
		||||
        triggerWorkflows: `${w1.key},${w2.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res1.status).toBe(202);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const [e1] = await w1.getExecutions();
 | 
			
		||||
      expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e1.context.data).toMatchObject({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const [e2] = await w2.getExecutions();
 | 
			
		||||
      expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e2.context.data).toMatchObject({ title: 't1' });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('context data path', () => {
 | 
			
		||||
    it('level: 1', async () => {
 | 
			
		||||
      const workflow = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res1 = await userAgents[0].resource('workflows').trigger({
 | 
			
		||||
        values: { title: 't1', category: { title: 'c1' } },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}!category`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res1.status).toBe(202);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const [e1] = await workflow.getExecutions();
 | 
			
		||||
      expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e1.context.data).toMatchObject({ title: 'c1' });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('level: 2', async () => {
 | 
			
		||||
      const workflow = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res1 = await userAgents[0].resource('workflows').trigger({
 | 
			
		||||
        values: { content: 'comment1', post: { category: { title: 'c1' } } },
 | 
			
		||||
        triggerWorkflows: `${workflow.key}!post.category`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res1.status).toBe(202);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const [e1] = await workflow.getExecutions();
 | 
			
		||||
      expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e1.context.data).toMatchObject({ title: 'c1' });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('workflow key', () => {
 | 
			
		||||
    it('revision', async () => {
 | 
			
		||||
      const w1 = await WorkflowModel.create({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res1 = await userAgents[0].resource('workflows').trigger({
 | 
			
		||||
        values: { title: 't1' },
 | 
			
		||||
        triggerWorkflows: `${w1.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res1.status).toBe(202);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const [e1] = await w1.getExecutions();
 | 
			
		||||
      expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e1.context.data).toMatchObject({ title: 't1' });
 | 
			
		||||
 | 
			
		||||
      const res2 = await userAgents[0].resource('workflows').revision({
 | 
			
		||||
        filterByTk: w1.id,
 | 
			
		||||
        filter: {
 | 
			
		||||
          key: w1.key,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      const w2 = await WorkflowModel.findByPk(res2.body.data.id);
 | 
			
		||||
      await w2.update({
 | 
			
		||||
        enabled: true,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const res3 = await userAgents[0].resource('workflows').trigger({
 | 
			
		||||
        values: { title: 't2' },
 | 
			
		||||
        triggerWorkflows: `${w1.key}`,
 | 
			
		||||
      });
 | 
			
		||||
      expect(res3.status).toBe(202);
 | 
			
		||||
 | 
			
		||||
      await sleep(500);
 | 
			
		||||
 | 
			
		||||
      const e2 = await w1.getExecutions();
 | 
			
		||||
      expect(e2.length).toBe(1);
 | 
			
		||||
      const e3 = await w2.getExecutions();
 | 
			
		||||
      expect(e3.length).toBe(1);
 | 
			
		||||
      expect(e3[0].status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
      expect(e3[0].context.data).toMatchObject({ title: 't2' });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										99
									
								
								packages/plugins/workflow/src/server/triggers/form.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								packages/plugins/workflow/src/server/triggers/form.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,99 @@
 | 
			
		||||
import { get } from 'lodash';
 | 
			
		||||
 | 
			
		||||
import { Trigger } from '.';
 | 
			
		||||
import Plugin from '..';
 | 
			
		||||
import { WorkflowModel } from '../types';
 | 
			
		||||
import { Model, modelAssociationByKey } from '@nocobase/database';
 | 
			
		||||
import { BelongsTo, HasOne } from 'sequelize';
 | 
			
		||||
 | 
			
		||||
export default class FormTrigger extends Trigger {
 | 
			
		||||
  constructor(plugin: Plugin) {
 | 
			
		||||
    super(plugin);
 | 
			
		||||
 | 
			
		||||
    plugin.app.resourcer.use(this.middleware);
 | 
			
		||||
    plugin.app.actions({
 | 
			
		||||
      ['workflows:trigger']: this.triggerAction,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  triggerAction = async (context, next) => {
 | 
			
		||||
    const { triggerWorkflows } = context.action.params;
 | 
			
		||||
 | 
			
		||||
    if (!triggerWorkflows) {
 | 
			
		||||
      return context.throw(400);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    context.status = 202;
 | 
			
		||||
    await next();
 | 
			
		||||
 | 
			
		||||
    this.trigger(context);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  middleware = async (context, next) => {
 | 
			
		||||
    await next();
 | 
			
		||||
 | 
			
		||||
    const { resourceName, actionName } = context.action;
 | 
			
		||||
 | 
			
		||||
    if ((resourceName === 'workflows' && actionName === 'trigger') || !['create', 'update'].includes(actionName)) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.trigger(context);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  async trigger(context) {
 | 
			
		||||
    const { triggerWorkflows, values } = context.action.params;
 | 
			
		||||
    if (!triggerWorkflows) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const triggers = triggerWorkflows.split(',').map((trigger) => trigger.split('!'));
 | 
			
		||||
    const workflowRepo = this.plugin.db.getRepository('workflows');
 | 
			
		||||
    const workflows = await workflowRepo.find({
 | 
			
		||||
      filter: {
 | 
			
		||||
        key: triggers.map((trigger) => trigger[0]),
 | 
			
		||||
        current: true,
 | 
			
		||||
        type: 'form',
 | 
			
		||||
        enabled: true,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    workflows.forEach((workflow) => {
 | 
			
		||||
      const trigger = triggers.find((trigger) => trigger[0] == workflow.key);
 | 
			
		||||
      if (context.body?.data) {
 | 
			
		||||
        const { data } = context.body;
 | 
			
		||||
        (Array.isArray(data) ? data : [data]).forEach(async (row: Model) => {
 | 
			
		||||
          let payload = row;
 | 
			
		||||
          if (trigger[1]) {
 | 
			
		||||
            const paths = trigger[1].split('.');
 | 
			
		||||
            for await (const field of paths) {
 | 
			
		||||
              if (payload.get(field)) {
 | 
			
		||||
                payload = payload.get(field);
 | 
			
		||||
              } else {
 | 
			
		||||
                const association = <HasOne | BelongsTo>modelAssociationByKey(payload, field);
 | 
			
		||||
                payload = await payload[association.accessors.get]();
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
          const { collection, appends = [] } = workflow.config;
 | 
			
		||||
          const model = <typeof Model>payload.constructor;
 | 
			
		||||
          if (collection !== model.collection.name) {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
          if (appends.length) {
 | 
			
		||||
            payload = await model.collection.repository.findOne({
 | 
			
		||||
              filterByTk: payload.get(model.primaryKeyAttribute),
 | 
			
		||||
              appends,
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
          this.plugin.trigger(workflow, { data: payload });
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        this.plugin.trigger(workflow, { data: trigger[1] ? get(values, trigger[1]) : values });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  on(workflow: WorkflowModel) {}
 | 
			
		||||
 | 
			
		||||
  off(workflow: WorkflowModel) {}
 | 
			
		||||
}
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import path from 'path';
 | 
			
		||||
import { requireModule } from '@nocobase/utils';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
 | 
			
		||||
import Plugin from '..';
 | 
			
		||||
import type { WorkflowModel } from '../types';
 | 
			
		||||
@ -14,6 +14,7 @@ export default function <T extends Trigger>(plugin, more: { [key: string]: { new
 | 
			
		||||
  const { triggers } = plugin;
 | 
			
		||||
 | 
			
		||||
  triggers.register('collection', new (requireModule(path.join(__dirname, 'collection')))(plugin));
 | 
			
		||||
  triggers.register('form', new (requireModule(path.join(__dirname, 'form')))(plugin));
 | 
			
		||||
  triggers.register('schedule', new (requireModule(path.join(__dirname, 'schedule')))(plugin));
 | 
			
		||||
 | 
			
		||||
  for (const [name, TClass] of Object.entries(more)) {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user