feat(plugin-workflow): change to unlimited depth preloading associations in workflow (#2142)
* refactor(plugin-snapshot): move AppendsTreeSelect component into client package * refactor(plugin-workflow): change all appends fields select to AppendsTreeSelect * refactor(plugin-workflow): change appends and toJSON logic on server side * fix(plugin-workflow): fix toJSON logic and build error * fix(plugin-workflow): fix missing component injection * fix(plugin-workflow): fix cycle association in variables * refactor(client): change AppendsTreeSelect to lazy load * fix(client): fix lazy load in option
This commit is contained in:
		
							parent
							
								
									70d5b9e44b
								
							
						
					
					
						commit
						9f8460ca22
					
				@ -23,8 +23,9 @@ export const useCollectionManager = () => {
 | 
			
		||||
    return inheritedFields.filter(Boolean);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getCollectionFields = (name: string): CollectionFieldOptions[] => {
 | 
			
		||||
    const currentFields = collections?.find((collection) => collection.name === name)?.fields || [];
 | 
			
		||||
  const getCollectionFields = (name: any): CollectionFieldOptions[] => {
 | 
			
		||||
    const collection = getCollection(name);
 | 
			
		||||
    const currentFields = collection?.fields || [];
 | 
			
		||||
    const inheritedFields = getInheritedFields(name);
 | 
			
		||||
    const totalFields = unionBy(currentFields?.concat(inheritedFields) || [], 'name').filter((v: any) => {
 | 
			
		||||
      return !v.isForeignKey;
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,172 @@
 | 
			
		||||
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 { useTranslation } from 'react-i18next';
 | 
			
		||||
 | 
			
		||||
export type AppendsTreeSelectProps = {
 | 
			
		||||
  value: string[];
 | 
			
		||||
  onChange: (value: string[]) => void;
 | 
			
		||||
  collection?: string;
 | 
			
		||||
  useCollection?(props: Pick<AppendsTreeSelectProps, 'collection'>): string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type TreeOptionType = Omit<DefaultOptionType, 'value'> & { value: string };
 | 
			
		||||
 | 
			
		||||
function usePropsCollection({ collection }) {
 | 
			
		||||
  return collection;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type CallScope = {
 | 
			
		||||
  compile?(value: string): string;
 | 
			
		||||
  getCollectionFields?(name: any): CollectionFieldOptions[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function loadChildren(this, option) {
 | 
			
		||||
  const result = getCollectionFieldOptions.call(this, option.field.target, option);
 | 
			
		||||
  if (result.length) {
 | 
			
		||||
    if (!result.some((item) => isAssociation(item.field))) {
 | 
			
		||||
      option.isLeaf = true;
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    option.isLeaf = true;
 | 
			
		||||
  }
 | 
			
		||||
  return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isAssociation(field) {
 | 
			
		||||
  return field.target && field.interface;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
    const fieldTitle = this.compile(field.uiSchema?.title) ?? field.name;
 | 
			
		||||
    const isLeaf = !this.getCollectionFields(field.target).filter(isAssociation).length;
 | 
			
		||||
    return {
 | 
			
		||||
      pId: parentNode?.key ?? null,
 | 
			
		||||
      id: key,
 | 
			
		||||
      key,
 | 
			
		||||
      value: key,
 | 
			
		||||
      title: fieldTitle,
 | 
			
		||||
      isLeaf,
 | 
			
		||||
      loadChildren: isLeaf ? null : boundLoadChildren,
 | 
			
		||||
      field,
 | 
			
		||||
      fullTitle: parentNode ? [...parentNode.fullTitle, fieldTitle] : [fieldTitle],
 | 
			
		||||
    };
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
 | 
			
		||||
  const { value = [], onChange, collection, useCollection = usePropsCollection, ...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 });
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }, [setOptionsMap]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const treeData = getCollectionFieldOptions.call({ compile, getCollectionFields }, baseCollection);
 | 
			
		||||
    setOptionsMap(treeData.reduce((result, item) => Object.assign(result, { [item.value]: item }), {}));
 | 
			
		||||
  }, [collection, baseCollection]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!value?.length || value.every(v => Boolean(optionsMap[v]))) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const loaded = [];
 | 
			
		||||
 | 
			
		||||
    value.forEach((v) => {
 | 
			
		||||
      const paths = v.split('.');
 | 
			
		||||
      let option = optionsMap[paths[0]];
 | 
			
		||||
      for (let i = 1; i < paths.length; i++) {
 | 
			
		||||
        if (!option) {
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
        const next = paths.slice(0, i + 1).join('.');
 | 
			
		||||
        if (optionsMap[next]) {
 | 
			
		||||
          option = optionsMap[next];
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
        if (!option.isLeaf && option.loadChildren) {
 | 
			
		||||
          const children = option.loadChildren(option);
 | 
			
		||||
          if (children?.length) {
 | 
			
		||||
            loaded.push(...children);
 | 
			
		||||
            option = children.find(item => item.value === paths.slice(0, i + 1).join('.'));
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    setOptionsMap((prev) => {
 | 
			
		||||
      return loaded.reduce((result, item) => Object.assign(result, { [item.value]: item }), { ...prev });
 | 
			
		||||
    });
 | 
			
		||||
  }, [value, treeData.length]);
 | 
			
		||||
 | 
			
		||||
  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));
 | 
			
		||||
 | 
			
		||||
    if (delValue) {
 | 
			
		||||
      const delNode = optionsMap[delValue];
 | 
			
		||||
      const prefix = `${delNode.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('.'));
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    onChange(Array.from(valueSet));
 | 
			
		||||
  }, [value, optionsMap]);
 | 
			
		||||
 | 
			
		||||
  const TreeTag = useCallback((props) => {
 | 
			
		||||
    const { value, onClose, disabled, closable } = props;
 | 
			
		||||
    const { fullTitle } = optionsMap[value];
 | 
			
		||||
    return (
 | 
			
		||||
      <Tag closable={closable && !disabled} onClose={onClose}>{fullTitle.join(' / ')}</Tag>
 | 
			
		||||
    );
 | 
			
		||||
  }, [optionsMap]);
 | 
			
		||||
 | 
			
		||||
  const filterdValue = Array.isArray(value) ? value.filter((i) => i in optionsMap) : value;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <TreeSelect
 | 
			
		||||
      value={filterdValue}
 | 
			
		||||
      dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
 | 
			
		||||
      placeholder={t('Select field')}
 | 
			
		||||
      showCheckedStrategy="SHOW_ALL"
 | 
			
		||||
      allowClear
 | 
			
		||||
      multiple
 | 
			
		||||
      treeCheckStrictly
 | 
			
		||||
      treeCheckable
 | 
			
		||||
      tagRender={TreeTag}
 | 
			
		||||
      onChange={handleChange as unknown as () => void}
 | 
			
		||||
      treeDataSimpleMode
 | 
			
		||||
      treeData={treeData}
 | 
			
		||||
      loadData={loadData}
 | 
			
		||||
      {...restProps}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@ -0,0 +1 @@
 | 
			
		||||
export * from './AppendsTreeSelect';
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
export * from './action';
 | 
			
		||||
export * from './appends-tree-select';
 | 
			
		||||
export * from './association-field';
 | 
			
		||||
export * from './association-select';
 | 
			
		||||
export * from './auto-complete';
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,6 @@ import {
 | 
			
		||||
  SchemaInitializerProvider,
 | 
			
		||||
} from '@nocobase/client';
 | 
			
		||||
import React, { useEffect } from 'react';
 | 
			
		||||
import { AppendsTreeSelect } from './components/AppendsTreeSelect';
 | 
			
		||||
import { SnapshotOwnerCollectionFieldsSelect } from './components/SnapshotOwnerCollectionFieldsSelect';
 | 
			
		||||
import { snapshot } from './interface';
 | 
			
		||||
import { SnapshotBlockInitializers } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers';
 | 
			
		||||
@ -36,7 +35,6 @@ export const SnapshotFieldProvider = React.memo((props) => {
 | 
			
		||||
              SnapshotRecordPicker,
 | 
			
		||||
              SnapshotBlockProvider,
 | 
			
		||||
              SnapshotBlockInitializersDetailItem,
 | 
			
		||||
              AppendsTreeSelect,
 | 
			
		||||
              SnapshotOwnerCollectionFieldsSelect,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
 | 
			
		||||
@ -1,122 +0,0 @@
 | 
			
		||||
import { useForm } from '@formily/react';
 | 
			
		||||
import { CollectionFieldOptions, useCollectionManager, useCompile } from '@nocobase/client';
 | 
			
		||||
import { Tag, TreeSelect } from 'antd';
 | 
			
		||||
import type { DefaultOptionType } from 'rc-tree-select/es/TreeSelect';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { useTopRecord } from '../interface';
 | 
			
		||||
import { useSnapshotTranslation } from '../locale';
 | 
			
		||||
 | 
			
		||||
export type TreeCacheMapNode = {
 | 
			
		||||
  parent?: TreeCacheMapNode;
 | 
			
		||||
  title: string;
 | 
			
		||||
  path: string;
 | 
			
		||||
  children?: TreeCacheMapNode[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type AppendsTreeSelectProps = {
 | 
			
		||||
  value: string[];
 | 
			
		||||
  onChange: (value: string[]) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type TreeOptionType = Omit<DefaultOptionType, 'value'> & { value: string };
 | 
			
		||||
 | 
			
		||||
export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
 | 
			
		||||
  const { value = [], onChange, ...restProps } = props;
 | 
			
		||||
  const record = useTopRecord();
 | 
			
		||||
  const { getCollectionFields, getCollectionField } = useCollectionManager();
 | 
			
		||||
  const compile = useCompile();
 | 
			
		||||
  const formValues = useForm().values;
 | 
			
		||||
  const { t } = useSnapshotTranslation();
 | 
			
		||||
 | 
			
		||||
  const fieldsToOptions = (
 | 
			
		||||
    fields: CollectionFieldOptions[] = [],
 | 
			
		||||
    fieldPath: CollectionFieldOptions[] = [],
 | 
			
		||||
  ): TreeOptionType[] => {
 | 
			
		||||
    const filter = (i: CollectionFieldOptions) =>
 | 
			
		||||
      !!i.target && !!i.interface && !fieldPath.find((p) => p.target === i.target);
 | 
			
		||||
    return fields.filter(filter).map((i) => ({
 | 
			
		||||
      title: compile(i.uiSchema?.title) ?? i.name,
 | 
			
		||||
      value: fieldPath
 | 
			
		||||
        .map((p) => p.name)
 | 
			
		||||
        .concat(i.name)
 | 
			
		||||
        .join('.'),
 | 
			
		||||
      children: fieldsToOptions(getCollectionFields(i.target), [...fieldPath, i]),
 | 
			
		||||
    }));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const treeData = fieldsToOptions(
 | 
			
		||||
    getCollectionFields(getCollectionField(`${record.name}.${formValues.targetField}`)?.target),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const valueMap: Record<string, TreeCacheMapNode> = {};
 | 
			
		||||
 | 
			
		||||
  function loops(list: TreeOptionType[], parent?: TreeCacheMapNode) {
 | 
			
		||||
    return (list || []).map(({ children, value, title }) => {
 | 
			
		||||
      const node: TreeCacheMapNode = (valueMap[value] = {
 | 
			
		||||
        parent,
 | 
			
		||||
        path: value,
 | 
			
		||||
        title,
 | 
			
		||||
      });
 | 
			
		||||
      node.children = loops(children, node);
 | 
			
		||||
      return node;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  loops(treeData);
 | 
			
		||||
 | 
			
		||||
  const handleChange = (newNodes: DefaultOptionType[]) => {
 | 
			
		||||
    const newValue = newNodes.map((i) => i.value) as string[];
 | 
			
		||||
    const valueSet = new Set(newValue);
 | 
			
		||||
    const delValue = value.find((i) => !newValue.includes(i));
 | 
			
		||||
 | 
			
		||||
    if (delValue) {
 | 
			
		||||
      const delNode = valueMap[delValue];
 | 
			
		||||
      const delNodeValue = (node: TreeCacheMapNode) => {
 | 
			
		||||
        valueSet.delete(node.path);
 | 
			
		||||
        node.children?.forEach((child) => delNodeValue(child));
 | 
			
		||||
      };
 | 
			
		||||
      delNodeValue(delNode);
 | 
			
		||||
    } else {
 | 
			
		||||
      newValue.forEach((v) => {
 | 
			
		||||
        let current = valueMap[v];
 | 
			
		||||
        while ((current = current.parent)) {
 | 
			
		||||
          valueSet.add(current.path);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    onChange(Array.from(valueSet));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const TreeTag = (props) => {
 | 
			
		||||
    const { value, onClose, disabled, closable } = props;
 | 
			
		||||
    let node = valueMap[value];
 | 
			
		||||
    let text = node?.title;
 | 
			
		||||
    while ((node = node?.parent)) {
 | 
			
		||||
      text = `${node.title} / ${text}`;
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
      <Tag closable={closable && !disabled} onClose={onClose}>
 | 
			
		||||
        {text}
 | 
			
		||||
      </Tag>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const filterdValue = Array.isArray(value) ? value.filter((i) => i in valueMap) : value;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <TreeSelect
 | 
			
		||||
      value={filterdValue}
 | 
			
		||||
      dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
 | 
			
		||||
      placeholder={t('Please select')}
 | 
			
		||||
      showCheckedStrategy="SHOW_ALL"
 | 
			
		||||
      allowClear
 | 
			
		||||
      multiple
 | 
			
		||||
      treeCheckStrictly
 | 
			
		||||
      treeCheckable
 | 
			
		||||
      tagRender={TreeTag}
 | 
			
		||||
      onChange={handleChange as unknown as () => void}
 | 
			
		||||
      treeData={treeData}
 | 
			
		||||
      {...restProps}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import type { Field } from '@formily/core';
 | 
			
		||||
import { ISchema } from '@formily/react';
 | 
			
		||||
import { ISchema, useForm } from '@formily/react';
 | 
			
		||||
import { IField, interfacesProperties, useCollectionManager, useRecord } from '@nocobase/client';
 | 
			
		||||
import { lodash } from '@nocobase/utils/client';
 | 
			
		||||
import { NAMESPACE } from './locale';
 | 
			
		||||
@ -18,6 +18,13 @@ export const useTopRecord = () => {
 | 
			
		||||
  return record;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function useRecordCollection() {
 | 
			
		||||
  const { getCollectionField } = useCollectionManager();
 | 
			
		||||
  const record = useTopRecord();
 | 
			
		||||
  const formValues = useForm().values;
 | 
			
		||||
  return getCollectionField(`${record.name}.${formValues.targetField}`)?.target;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const onTargetFieldChange = (field: Field) => {
 | 
			
		||||
  field.value; // for watch
 | 
			
		||||
  const targetField = field.query(`.${APPENDS}`).take() as Field | undefined;
 | 
			
		||||
@ -161,6 +168,9 @@ export const snapshot: IField = {
 | 
			
		||||
      title: `{{t("Snapshot the snapshot's association fields", {ns: "${NAMESPACE}"})}}`,
 | 
			
		||||
      'x-decorator': 'FormItem',
 | 
			
		||||
      'x-component': 'AppendsTreeSelect',
 | 
			
		||||
      'x-component-props': {
 | 
			
		||||
        useCollection: useRecordCollection,
 | 
			
		||||
      },
 | 
			
		||||
      'x-reactions': [
 | 
			
		||||
        {
 | 
			
		||||
          dependencies: [TARGET_FIELD],
 | 
			
		||||
 | 
			
		||||
@ -4,9 +4,13 @@ import React from 'react';
 | 
			
		||||
 | 
			
		||||
import { useCollectionManager, useCompile } from '@nocobase/client';
 | 
			
		||||
 | 
			
		||||
function defaultFilter() {
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const FieldsSelect = observer(
 | 
			
		||||
  (props: any) => {
 | 
			
		||||
    const { filter = () => true, ...others } = props;
 | 
			
		||||
    const { filter = defaultFilter, ...others } = props;
 | 
			
		||||
    const compile = useCompile();
 | 
			
		||||
    const { getCollectionFields } = useCollectionManager();
 | 
			
		||||
    const { values } = useForm();
 | 
			
		||||
 | 
			
		||||
@ -28,8 +28,8 @@ export default {
 | 
			
		||||
    '只有被选中的某个字段发生变动时才会触发。如果不选择,则表示任何字段变动时都会触发。新增或删除数据时,任意字段都被认为发生变动。',
 | 
			
		||||
  'Only triggers when match conditions': '满足以下条件才触发',
 | 
			
		||||
  'Preload associations': '预加载关联数据',
 | 
			
		||||
  'Please select the associated fields that need to be accessed in subsequent nodes':
 | 
			
		||||
    '请选中需要在后续节点中被访问的关系字段',
 | 
			
		||||
  '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': '定时任务',
 | 
			
		||||
  'Trigger mode': '触发模式',
 | 
			
		||||
  'Based on certain date': '自定义时间',
 | 
			
		||||
 | 
			
		||||
@ -38,7 +38,6 @@ export default {
 | 
			
		||||
  },
 | 
			
		||||
  components: {
 | 
			
		||||
    CollectionFieldset,
 | 
			
		||||
    FieldsSelect,
 | 
			
		||||
  },
 | 
			
		||||
  useVariables({ id, title, config }, options) {
 | 
			
		||||
    const compile = useCompile();
 | 
			
		||||
 | 
			
		||||
@ -41,7 +41,6 @@ export default {
 | 
			
		||||
  },
 | 
			
		||||
  components: {
 | 
			
		||||
    FilterDynamicComponent,
 | 
			
		||||
    FieldsSelect,
 | 
			
		||||
  },
 | 
			
		||||
  useVariables({ id, title, config }, options) {
 | 
			
		||||
    const compile = useCompile();
 | 
			
		||||
 | 
			
		||||
@ -52,15 +52,15 @@ export const filter = {
 | 
			
		||||
export const appends = {
 | 
			
		||||
  type: 'array',
 | 
			
		||||
  title: `{{t("Preload associations", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
  description: `{{t("Please select the associated fields that need to be accessed in subsequent nodes", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
  description: `{{t("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.", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
  'x-decorator': 'FormItem',
 | 
			
		||||
  'x-component': 'FieldsSelect',
 | 
			
		||||
  'x-component': 'AppendsTreeSelect',
 | 
			
		||||
  'x-component-props': {
 | 
			
		||||
    mode: 'multiple',
 | 
			
		||||
    placeholder: '{{t("Select field")}}',
 | 
			
		||||
    filter(field) {
 | 
			
		||||
      return ['linkTo', 'belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type);
 | 
			
		||||
    useCollection() {
 | 
			
		||||
      const { values } = useForm();
 | 
			
		||||
      return values?.collection;
 | 
			
		||||
    },
 | 
			
		||||
    className: 'full-width',
 | 
			
		||||
  },
 | 
			
		||||
  'x-reactions': [
 | 
			
		||||
    {
 | 
			
		||||
 | 
			
		||||
@ -85,6 +85,7 @@ export default {
 | 
			
		||||
      'x-decorator': 'FormItem',
 | 
			
		||||
      'x-component': 'FieldsSelect',
 | 
			
		||||
      'x-component-props': {
 | 
			
		||||
        className: 'full-width',
 | 
			
		||||
        mode: 'multiple',
 | 
			
		||||
        placeholder: '{{t("Select field")}}',
 | 
			
		||||
        filter(field) {
 | 
			
		||||
@ -109,6 +110,9 @@ export default {
 | 
			
		||||
    condition: {
 | 
			
		||||
      ...filter,
 | 
			
		||||
      title: `{{t("Only triggers when match conditions", { ns: "${NAMESPACE}" })}}`,
 | 
			
		||||
      'x-component-props': {
 | 
			
		||||
        useProps: filter['x-component-props'].useProps,
 | 
			
		||||
      },
 | 
			
		||||
      'x-reactions': [
 | 
			
		||||
        {
 | 
			
		||||
          dependencies: ['collection'],
 | 
			
		||||
 | 
			
		||||
@ -1,28 +0,0 @@
 | 
			
		||||
import { observer, useForm } from '@formily/react';
 | 
			
		||||
import { useCollectionManager, useCompile } from '@nocobase/client';
 | 
			
		||||
import { Select } from 'antd';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { useTranslation } from 'react-i18next';
 | 
			
		||||
 | 
			
		||||
export const DateFieldsSelect: React.FC<any> = observer(
 | 
			
		||||
  (props) => {
 | 
			
		||||
    const { t } = useTranslation();
 | 
			
		||||
    const compile = useCompile();
 | 
			
		||||
    const { getCollectionFields } = useCollectionManager();
 | 
			
		||||
    const { values } = useForm();
 | 
			
		||||
    const fields = getCollectionFields(values?.collection);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Select popupMatchSelectWidth={false} placeholder={t('Select field')} {...props}>
 | 
			
		||||
        {fields
 | 
			
		||||
          .filter((field) => !field.hidden && (field.uiSchema ? field.type === 'date' : false))
 | 
			
		||||
          .map((field) => (
 | 
			
		||||
            <Select.Option key={field.name} value={field.name}>
 | 
			
		||||
              {compile(field.uiSchema?.title)}
 | 
			
		||||
            </Select.Option>
 | 
			
		||||
          ))}
 | 
			
		||||
      </Select>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
  { displayName: 'DateFieldsSelect' },
 | 
			
		||||
);
 | 
			
		||||
@ -2,12 +2,16 @@ import { css } from '@nocobase/client';
 | 
			
		||||
import { InputNumber, Select } from 'antd';
 | 
			
		||||
import React, { useState } from 'react';
 | 
			
		||||
import { useTranslation } from 'react-i18next';
 | 
			
		||||
import { useWorkflowTranslation } from '../../locale';
 | 
			
		||||
import { DateFieldsSelect } from './DateFieldsSelect';
 | 
			
		||||
 | 
			
		||||
import { FieldsSelect } from '../../components/FieldsSelect';
 | 
			
		||||
import { lang } from '../../locale';
 | 
			
		||||
 | 
			
		||||
function dateFieldFilter(field) {
 | 
			
		||||
  return !field.hidden && (field.uiSchema ? field.type === 'date' : false);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function OnField({ value, onChange }) {
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
  const { t: localT } = useWorkflowTranslation();
 | 
			
		||||
  const [dir, setDir] = useState(value.offset ? value.offset / Math.abs(value.offset) : 0);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@ -17,7 +21,12 @@ export function OnField({ value, onChange }) {
 | 
			
		||||
        gap: 0.5em;
 | 
			
		||||
      `}
 | 
			
		||||
    >
 | 
			
		||||
      <DateFieldsSelect value={value.field} onChange={(field) => onChange({ ...value, field })} />
 | 
			
		||||
      <FieldsSelect
 | 
			
		||||
        value={value.field}
 | 
			
		||||
        onChange={(field) => onChange({ ...value, field })}
 | 
			
		||||
        filter={dateFieldFilter}
 | 
			
		||||
        placeholder={t('Select field')}
 | 
			
		||||
      />
 | 
			
		||||
      {value.field ? (
 | 
			
		||||
        <Select
 | 
			
		||||
          value={dir}
 | 
			
		||||
@ -25,11 +34,12 @@ export function OnField({ value, onChange }) {
 | 
			
		||||
            setDir(v);
 | 
			
		||||
            onChange({ ...value, offset: Math.abs(value.offset) * v });
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Select.Option value={0}>{localT('Exactly at')}</Select.Option>
 | 
			
		||||
          <Select.Option value={-1}>{t('Before')}</Select.Option>
 | 
			
		||||
          <Select.Option value={1}>{t('After')}</Select.Option>
 | 
			
		||||
        </Select>
 | 
			
		||||
          options={[
 | 
			
		||||
            { value: 0, label: lang('Exactly at') },
 | 
			
		||||
            { value: -1, label: t('Before') },
 | 
			
		||||
            { value: 1, label: t('After') },
 | 
			
		||||
          ]}
 | 
			
		||||
        />
 | 
			
		||||
      ) : null}
 | 
			
		||||
      {dir ? (
 | 
			
		||||
        <>
 | 
			
		||||
@ -37,12 +47,16 @@ export function OnField({ value, onChange }) {
 | 
			
		||||
            value={Math.abs(value.offset)}
 | 
			
		||||
            onChange={(v) => onChange({ ...value, offset: (v ?? 0) * dir })}
 | 
			
		||||
          />
 | 
			
		||||
          <Select value={value.unit || 86400000} onChange={(unit) => onChange({ ...value, unit })}>
 | 
			
		||||
            <Select.Option value={86400000}>{localT('Days')}</Select.Option>
 | 
			
		||||
            <Select.Option value={3600000}>{localT('Hours')}</Select.Option>
 | 
			
		||||
            <Select.Option value={60000}>{localT('Minutes')}</Select.Option>
 | 
			
		||||
            <Select.Option value={1000}>{localT('Seconds')}</Select.Option>
 | 
			
		||||
          </Select>
 | 
			
		||||
          <Select
 | 
			
		||||
            value={value.unit || 86400000}
 | 
			
		||||
            onChange={(unit) => onChange({ ...value, unit })}
 | 
			
		||||
            options={[
 | 
			
		||||
              { value: 86400000, label: lang('Days') },
 | 
			
		||||
              { value: 3600000, label: lang('Hours') },
 | 
			
		||||
              { value: 60000, label: lang('Minutes') },
 | 
			
		||||
              { value: 1000, label: lang('Seconds') },
 | 
			
		||||
            ]}
 | 
			
		||||
          />
 | 
			
		||||
        </>
 | 
			
		||||
      ) : null}
 | 
			
		||||
    </fieldset>
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import { onFieldValueChange } from '@formily/core';
 | 
			
		||||
import { useForm, useFormEffects } from '@formily/react';
 | 
			
		||||
import { useForm, useFormEffects, ISchema } from '@formily/react';
 | 
			
		||||
import { css, SchemaComponent } from '@nocobase/client';
 | 
			
		||||
import React, { useState } from 'react';
 | 
			
		||||
import { NAMESPACE } from '../../locale';
 | 
			
		||||
@ -193,27 +193,29 @@ export const ScheduleConfig = () => {
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      <SchemaComponent
 | 
			
		||||
        schema={{
 | 
			
		||||
          type: 'void',
 | 
			
		||||
          properties: {
 | 
			
		||||
            [`mode-${mode}`]: {
 | 
			
		||||
              type: 'void',
 | 
			
		||||
              'x-component': 'fieldset',
 | 
			
		||||
              'x-component-props': {
 | 
			
		||||
                className: css`
 | 
			
		||||
                  .ant-input-number {
 | 
			
		||||
                    width: 4em;
 | 
			
		||||
                  }
 | 
			
		||||
        schema={
 | 
			
		||||
          {
 | 
			
		||||
            type: 'void',
 | 
			
		||||
            properties: {
 | 
			
		||||
              [`mode-${mode}`]: {
 | 
			
		||||
                type: 'void',
 | 
			
		||||
                'x-component': 'fieldset',
 | 
			
		||||
                'x-component-props': {
 | 
			
		||||
                  className: css`
 | 
			
		||||
                    .ant-input-number {
 | 
			
		||||
                      width: 4em;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                  .ant-picker {
 | 
			
		||||
                    width: auto;
 | 
			
		||||
                  }
 | 
			
		||||
                `,
 | 
			
		||||
                    .ant-picker {
 | 
			
		||||
                      width: auto;
 | 
			
		||||
                    }
 | 
			
		||||
                  `,
 | 
			
		||||
                },
 | 
			
		||||
                properties: ModeFieldsets[mode],
 | 
			
		||||
              },
 | 
			
		||||
              properties: ModeFieldsets[mode],
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        }}
 | 
			
		||||
          } as ISchema
 | 
			
		||||
        }
 | 
			
		||||
        components={{
 | 
			
		||||
          OnField,
 | 
			
		||||
          RepeatField,
 | 
			
		||||
 | 
			
		||||
@ -22,7 +22,6 @@ export default {
 | 
			
		||||
  },
 | 
			
		||||
  components: {
 | 
			
		||||
    ScheduleConfig,
 | 
			
		||||
    FieldsSelect,
 | 
			
		||||
  },
 | 
			
		||||
  useVariables(config, opts) {
 | 
			
		||||
    const compile = useCompile();
 | 
			
		||||
@ -32,25 +31,31 @@ export default {
 | 
			
		||||
      options.push({ key: 'date', value: 'date', label: lang('Trigger time') });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const depth = config.appends?.length
 | 
			
		||||
      ? config.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + 1
 | 
			
		||||
      : 1;
 | 
			
		||||
    // const depth = config.appends?.length
 | 
			
		||||
    //   ? config.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + 1
 | 
			
		||||
    //   : 1;
 | 
			
		||||
 | 
			
		||||
    const fieldOptions = getCollectionFieldOptions({
 | 
			
		||||
      depth,
 | 
			
		||||
      ...opts,
 | 
			
		||||
      collection: config.collection,
 | 
			
		||||
      compile,
 | 
			
		||||
      getCollectionFields,
 | 
			
		||||
    });
 | 
			
		||||
    if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) {
 | 
			
		||||
      if (fieldOptions.length) {
 | 
			
		||||
        options.push({
 | 
			
		||||
          key: 'data',
 | 
			
		||||
          value: 'data',
 | 
			
		||||
          label: lang('Trigger data'),
 | 
			
		||||
          children: fieldOptions,
 | 
			
		||||
        });
 | 
			
		||||
      const [fieldOption] = getCollectionFieldOptions({
 | 
			
		||||
        // depth,
 | 
			
		||||
        ...opts,
 | 
			
		||||
        fields: [
 | 
			
		||||
          {
 | 
			
		||||
            collectionName: config.collection,
 | 
			
		||||
            name: 'data',
 | 
			
		||||
            type: 'hasOne',
 | 
			
		||||
            target: config.collection,
 | 
			
		||||
            uiSchema: {
 | 
			
		||||
              title: lang('Trigger data'),
 | 
			
		||||
            },
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
 | 
			
		||||
        compile,
 | 
			
		||||
        getCollectionFields,
 | 
			
		||||
      });
 | 
			
		||||
      if (fieldOption) {
 | 
			
		||||
        options.push(fieldOption);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return options;
 | 
			
		||||
 | 
			
		||||
@ -194,7 +194,7 @@ export default class WorkflowPlugin extends Plugin {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public trigger(workflow: WorkflowModel, context: { [key: string]: any }, options: { context?: any } = {}): void {
 | 
			
		||||
  public trigger(workflow: WorkflowModel, context: object, options: { context?: any } = {}): void {
 | 
			
		||||
    // `null` means not to trigger
 | 
			
		||||
    if (context == null) {
 | 
			
		||||
      return;
 | 
			
		||||
 | 
			
		||||
@ -19,5 +19,9 @@ export default {
 | 
			
		||||
      type: 'text',
 | 
			
		||||
      name: 'expression',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      type: 'hasMany',
 | 
			
		||||
      name: 'posts',
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
} as CollectionOptions;
 | 
			
		||||
 | 
			
		||||
@ -294,5 +294,40 @@ describe('workflow > triggers > collection', () => {
 | 
			
		||||
      const [job] = await execution.getJobs();
 | 
			
		||||
      expect(job.result.data.tags.length).toBe(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    describe('appends depth > 1', () => {
 | 
			
		||||
      it('create with associtions', async () => {
 | 
			
		||||
        const workflow = await WorkflowModel.create({
 | 
			
		||||
          enabled: true,
 | 
			
		||||
          type: 'collection',
 | 
			
		||||
          config: {
 | 
			
		||||
            mode: 1,
 | 
			
		||||
            collection: 'categories',
 | 
			
		||||
            appends: ['posts.tags'],
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const tags = await TagRepo.create({ values: [{}] });
 | 
			
		||||
        const tagIds = tags.map((item) => item.id);
 | 
			
		||||
 | 
			
		||||
        const category = await CategoryRepo.create({
 | 
			
		||||
          values: {
 | 
			
		||||
            title: 't1',
 | 
			
		||||
            posts: [
 | 
			
		||||
              { title: 't1', tags: tagIds },
 | 
			
		||||
              { title: 't2', tags: tagIds },
 | 
			
		||||
            ],
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await sleep(500);
 | 
			
		||||
 | 
			
		||||
        const [execution] = await workflow.getExecutions();
 | 
			
		||||
        expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
 | 
			
		||||
        expect(execution.context.data.posts.length).toBe(2);
 | 
			
		||||
        expect(execution.context.data.posts.map((item) => item.title)).toEqual(['t1', 't2']);
 | 
			
		||||
        expect(execution.context.data.posts.map((item) => item.tags.map((tag) => tag.id))).toEqual([tagIds, tagIds]);
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
import { JOB_STATUS } from '../constants';
 | 
			
		||||
import { toJSON } from '../utils';
 | 
			
		||||
import type { FlowNodeModel } from '../types';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
@ -7,7 +8,7 @@ export default {
 | 
			
		||||
 | 
			
		||||
    const { repository, model } = (<typeof FlowNodeModel>node.constructor).database.getCollection(collection);
 | 
			
		||||
    const options = processor.getParsedValue(params, node);
 | 
			
		||||
    const result = await repository.create({
 | 
			
		||||
    const created = await repository.create({
 | 
			
		||||
      ...options,
 | 
			
		||||
      context: {
 | 
			
		||||
        executionId: processor.execution.id,
 | 
			
		||||
@ -15,22 +16,23 @@ export default {
 | 
			
		||||
      transaction: processor.transaction,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (result && appends.length) {
 | 
			
		||||
      const includeFields = appends.filter((field) => !result.get(field) || !result[field]);
 | 
			
		||||
      const included = await model.findByPk(result[model.primaryKeyAttribute], {
 | 
			
		||||
        attributes: [model.primaryKeyAttribute],
 | 
			
		||||
        include: includeFields,
 | 
			
		||||
    let result = created;
 | 
			
		||||
    if (created && appends.length) {
 | 
			
		||||
      const includeFields = appends.reduce((set, field) => {
 | 
			
		||||
        set.add(field.split('.')[0]);
 | 
			
		||||
        set.add(field);
 | 
			
		||||
        return set;
 | 
			
		||||
      }, new Set());
 | 
			
		||||
      result = await repository.findOne({
 | 
			
		||||
        filterByTk: created[model.primaryKeyAttribute],
 | 
			
		||||
        appends: Array.from(includeFields),
 | 
			
		||||
        transaction: processor.transaction,
 | 
			
		||||
      });
 | 
			
		||||
      includeFields.forEach((field) => {
 | 
			
		||||
        const value = included!.get(field);
 | 
			
		||||
        result.set(field, Array.isArray(value) ? value.map((item) => item.toJSON()) : value.toJSON(), { raw: true });
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      // NOTE: get() for non-proxied instance (#380)
 | 
			
		||||
      result: result?.toJSON(),
 | 
			
		||||
      result: toJSON(result),
 | 
			
		||||
      status: JOB_STATUS.RESOLVED,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import Processor from '../Processor';
 | 
			
		||||
import { JOB_STATUS } from '../constants';
 | 
			
		||||
import { toJSON } from '../utils';
 | 
			
		||||
import type { FlowNodeModel } from '../types';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
@ -8,8 +9,18 @@ export default {
 | 
			
		||||
 | 
			
		||||
    const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection);
 | 
			
		||||
    const options = processor.getParsedValue(params, node);
 | 
			
		||||
    const appends = options.appends
 | 
			
		||||
      ? Array.from(
 | 
			
		||||
          options.appends.reduce((set, field) => {
 | 
			
		||||
            set.add(field.split('.')[0]);
 | 
			
		||||
            set.add(field);
 | 
			
		||||
            return set;
 | 
			
		||||
          }, new Set()),
 | 
			
		||||
        )
 | 
			
		||||
      : options.appends;
 | 
			
		||||
    const result = await (multiple ? repo.find : repo.findOne).call(repo, {
 | 
			
		||||
      ...options,
 | 
			
		||||
      appends: appends,
 | 
			
		||||
      transaction: processor.transaction,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -24,7 +35,7 @@ export default {
 | 
			
		||||
    // e.g. Object.prototype.hasOwnProperty.call(result, 'id') // false
 | 
			
		||||
    // so the properties can not be get by json-templates(object-path)
 | 
			
		||||
    return {
 | 
			
		||||
      result: multiple ? result.map((item) => item.toJSON()) : result?.toJSON(),
 | 
			
		||||
      result: toJSON(result),
 | 
			
		||||
      status: JOB_STATUS.RESOLVED,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { Collection, Model } from '@nocobase/database';
 | 
			
		||||
import { Trigger } from '..';
 | 
			
		||||
import { toJSON } from '../utils';
 | 
			
		||||
import type { WorkflowModel } from '../types';
 | 
			
		||||
 | 
			
		||||
export interface CollectionChangeTriggerConfig {
 | 
			
		||||
@ -66,24 +67,27 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let result = data;
 | 
			
		||||
 | 
			
		||||
  if (appends?.length && !(mode & MODE_BITMAP.DESTROY)) {
 | 
			
		||||
    const includeFields = appends.filter((field) => !data.get(field) || !data[field]);
 | 
			
		||||
    const included = await model.findByPk(data[model.primaryKeyAttribute], {
 | 
			
		||||
      attributes: [model.primaryKeyAttribute],
 | 
			
		||||
      include: includeFields,
 | 
			
		||||
    const includeFields = appends.reduce((set, field) => {
 | 
			
		||||
      set.add(field.split('.')[0]);
 | 
			
		||||
      set.add(field);
 | 
			
		||||
      return set;
 | 
			
		||||
    }, new Set());
 | 
			
		||||
    result = await repository.findOne({
 | 
			
		||||
      filterByTk: data[model.primaryKeyAttribute],
 | 
			
		||||
      appends: Array.from(includeFields),
 | 
			
		||||
      transaction,
 | 
			
		||||
    });
 | 
			
		||||
    includeFields.forEach((field) => {
 | 
			
		||||
      const value = included!.get(field);
 | 
			
		||||
      data.set(field, Array.isArray(value) ? value.map((item) => item.toJSON()) : value ? value.toJSON() : null, {
 | 
			
		||||
        raw: true,
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // TODO: `result.toJSON()` throws error
 | 
			
		||||
  const json = toJSON(result);
 | 
			
		||||
 | 
			
		||||
  this.plugin.trigger(
 | 
			
		||||
    workflow,
 | 
			
		||||
    { data: data.toJSON() },
 | 
			
		||||
    { data: json },
 | 
			
		||||
    {
 | 
			
		||||
      context,
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										17
									
								
								packages/plugins/workflow/src/server/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								packages/plugins/workflow/src/server/utils.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
			
		||||
import { Model } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export function toJSON(data: Model | Model[]): object {
 | 
			
		||||
  if (typeof data !== 'object' || !data) {
 | 
			
		||||
    return data;
 | 
			
		||||
  }
 | 
			
		||||
  if (Array.isArray(data)) {
 | 
			
		||||
    return data.map(toJSON);
 | 
			
		||||
  }
 | 
			
		||||
  const result = data.get();
 | 
			
		||||
  Object.keys((<typeof Model>data.constructor).associations).forEach((key) => {
 | 
			
		||||
    if (result[key] != null) {
 | 
			
		||||
      result[key] = toJSON(result[key]);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  return result;
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user