diff --git a/packages/core/client/src/schema-component/antd/variable/Input.tsx b/packages/core/client/src/schema-component/antd/variable/Input.tsx index 48c30da11..63a217d08 100644 --- a/packages/core/client/src/schema-component/antd/variable/Input.tsx +++ b/packages/core/client/src/schema-component/antd/variable/Input.tsx @@ -2,19 +2,23 @@ import { CloseCircleFilled } from '@ant-design/icons'; import { css, cx } from '@emotion/css'; import { useForm } from '@formily/react'; import { error } from '@nocobase/utils/client'; -import { Input as AntInput, Cascader, DatePicker, InputNumber, Select, Tag } from 'antd'; +import { Cascader, DatePicker, Input as AntInput, InputNumber, Select, Tag } from 'antd'; +import type { DefaultOptionType } from 'antd/lib/cascader'; import classNames from 'classnames'; +import { cloneDeep } from 'lodash'; import moment from 'moment'; import React, { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useCompile } from '../..'; -import { Option } from '../../../schema-settings/VariableInput/type'; +import { useCompile } from '../../hooks'; import { XButton } from './XButton'; const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/; const groupClass = css` width: auto; - display: flex !important; + display: flex; + &.ant-input-group-compact { + display: flex; + } .ant-input-disabled { .ant-tag { color: #bfbfbf; @@ -115,69 +119,93 @@ const ConstantTypes = { }, }; -function getTypedConstantOption(type: string, types?: true | string[]) { +function getTypedConstantOption(type: string, types: true | string[], fieldNames) { const allTypes = Object.values(ConstantTypes); + const children = ( + types ? allTypes.filter((item) => (Array.isArray(types) && types.includes(item.value)) || types === true) : allTypes + ).map((item) => + Object.keys(item).reduce( + (result, key) => + fieldNames[key] in item + ? result + : Object.assign(result, { + [fieldNames[key]]: item[key], + }), + item, + ), + ); return { value: '', label: '{{t("Constant")}}', - children: types - ? allTypes.filter((item) => (Array.isArray(types) && types.includes(item.value)) || types === true) - : allTypes, + children, + [fieldNames.value]: '', + [fieldNames.label]: '{{t("Constant")}}', + [fieldNames.children]: children, component: ConstantTypes[type]?.component, }; } -type VariableOptions = { - value: string; - label?: string; - children?: VariableOptions[]; -}; - export function Input(props) { + const { + value = '', + scope, + onChange, + children, + button, + useTypedConstant, + style, + className, + changeOnSelect, + fieldNames, + } = props; const compile = useCompile(); + const { t } = useTranslation(); const form = useForm(); + const [options, setOptions] = React.useState([]); + const [variableText, setVariableText] = React.useState(''); - const { value = '', scope, onChange, children, button, useTypedConstant, style, className } = props; const parsed = useMemo(() => parseValue(value), [value]); const isConstant = typeof parsed === 'string'; const type = isConstant ? parsed : ''; const variable = isConstant ? null : parsed; + const names = Object.assign( + { + label: 'label', + value: 'value', + children: 'children', + }, + fieldNames ?? {}, + ); - // 当 scope 是一个函数时,可能是一个 hook,所以不能使用 useMemo - const variableOptions = typeof scope === 'function' ? scope() : scope ?? []; - - const [variableText, setVariableText] = React.useState(''); - - const { component: ConstantComponent, ...constantOption }: VariableOptions & { component?: React.FC } = + const { component: ConstantComponent, ...constantOption }: DefaultOptionType & { component?: React.FC } = useMemo(() => { if (children) { return { value: '', - label: '{{t("Constant")}}', + label: t('Constant'), + [names.value]: '', + [names.label]: t('Constant'), }; } if (useTypedConstant) { - return getTypedConstantOption(type, useTypedConstant); + return getTypedConstantOption(type, useTypedConstant, names); } return { value: '', - label: '{{t("Null")}}', + label: t('Null'), + [names.value]: '', + [names.label]: t('Null'), component: ConstantTypes.null.component, }; }, [type, useTypedConstant]); - const [options, setOptions] = React.useState(() => { - return compile([constantOption, ...variableOptions]); - }); - useEffect(() => { - const newOptions: Option[] = [constantOption, ...variableOptions]; - setOptions(deepCompileLabel(newOptions, compile)); - }, [variableOptions]); + setOptions([compile(constantOption), ...(scope ? cloneDeep(scope) : [])]); + }, [scope]); - const loadData = async (selectedOptions: Option[]) => { + const loadData = async (selectedOptions: DefaultOptionType[]) => { const option = selectedOptions[selectedOptions.length - 1]; - if (option.loadChildren) { + if (!option.children && !option.isLeaf && option.loadChildren) { await option.loadChildren(option); setOptions((prev) => [...prev]); } @@ -199,29 +227,29 @@ export function Input(props) { } onChange(`{{${next.join('.')}}}`); }, - [type, variable], + [type, variable, onChange], ); useEffect(() => { const run = async () => { - if (!variable || options.length <= 1) { + if (!variable || !options.length) { return; } - let prevOption: Option = null; + let prevOption: DefaultOptionType = null; const labels = []; for (let i = 0; i < variable.length; i++) { const key = variable[i]; try { if (i === 0) { - prevOption = options.find((item) => item.value === key); + prevOption = options.find((item) => item[names.value] === key); } else { if (prevOption.loadChildren && !prevOption.children?.length) { await prevOption.loadChildren(prevOption); } - prevOption = prevOption.children.find((item) => item.value === key); + prevOption = prevOption.children.find((item) => item[names.value] === key); } - labels.push(prevOption.label); + labels.push(prevOption[names.label]); } catch (err) { error(err); } @@ -230,40 +258,43 @@ export function Input(props) { setVariableText(labels.join(' / ')); }; - // 如果没有这个延迟,会导致选择父节点时不展开子节点 - setTimeout(run); + run(); + // NOTE: watch `options.length` and it only happens once }, [variable, options.length]); const disabled = props.disabled || form.disabled; return ( - + {variable ? (
e.preventDefault()} @@ -297,13 +328,14 @@ export function Input(props) { ) : ( children ?? )} - {options.length > 1 ? ( + {options.length > 1 && !disabled ? ( {button ?? } @@ -311,14 +343,3 @@ export function Input(props) { ); } - -function deepCompileLabel(list: Option[], compile) { - return list.map((item) => { - const children = item.children ? deepCompileLabel(item.children, compile) : null; - return { - ...item, - label: compile(item.label), - children, - }; - }); -} diff --git a/packages/core/client/src/schema-component/antd/variable/JSONInput.tsx b/packages/core/client/src/schema-component/antd/variable/JSONInput.tsx index 10c8d105d..833999d66 100644 --- a/packages/core/client/src/schema-component/antd/variable/JSONInput.tsx +++ b/packages/core/client/src/schema-component/antd/variable/JSONInput.tsx @@ -1,6 +1,7 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import { Button } from 'antd'; import { css } from '@emotion/css'; +import { cloneDeep } from 'lodash'; import { Input } from '../input'; import { VariableSelect } from './VariableSelect'; @@ -18,8 +19,8 @@ function setNativeInputValue(input, value) { export function JSONInput(props) { const inputRef = useRef(null); - const { scope } = props; - const options = typeof scope === 'function' ? scope() : scope ?? []; + const { scope, changeOnSelect, ...others } = props; + const [options, setOptions] = useState(scope ? cloneDeep(scope) : []); function onInsert(selected) { if (!inputRef.current) { @@ -45,7 +46,7 @@ export function JSONInput(props) { } `} > - + - +
); diff --git a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx index ea0e01ed4..8f1bec99e 100644 --- a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx +++ b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx @@ -2,10 +2,12 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Input } from 'antd'; import { useForm } from '@formily/react'; import { cx, css } from '@emotion/css'; -import { useTranslation } from 'react-i18next'; import * as sanitizeHTML from 'sanitize-html'; +import { cloneDeep } from 'lodash'; -import { EllipsisWithTooltip, useCompile } from '../..'; +import { error } from '@nocobase/utils/client'; + +import { EllipsisWithTooltip } from '../..'; import { VariableSelect } from './VariableSelect'; type RangeIndexes = [number, number, number, number]; @@ -112,9 +114,9 @@ function createOptionsValueLabelMap(options: any[]) { function createVariableTagHTML(variable, keyLabelMap) { const labels = keyLabelMap.get(variable); - return `${labels?.join( - ' / ', - )}`; + return `${ + labels ? labels.join(' / ') : '...' + }`; } // [#, <>, #, #, <>] @@ -185,29 +187,31 @@ function getCurrentRange(element: HTMLElement): RangeIndexes { } export function TextArea(props) { - const { value = '', scope, onChange, multiline = true } = props; - const compile = useCompile(); + const { value = '', scope, onChange, multiline = true, changeOnSelect } = props; const inputRef = useRef(null); - const options = compile((typeof scope === 'function' ? scope() : scope) ?? []); + const [options, setOptions] = useState([]); const form = useForm(); - const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]); + const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [options]); const [ime, setIME] = useState(false); const [changed, setChanged] = useState(false); const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap)); // NOTE: e.g. [startElementIndex, startOffset, endElementIndex, endOffset] const [range, setRange] = useState<[number, number, number, number]>([-1, 0, -1, 0]); - const [selectedVar, setSelectedVar] = useState([]); useEffect(() => { - setSelectedVar([]); - }, [scope]); + preloadOptions(scope, value) + .then((preloaded) => { + setOptions(preloaded); + }) + .catch((err) => console.error); + }, [scope, value]); useEffect(() => { setHtml(renderHTML(value ?? '', keyLabelMap)); if (!changed) { setRange([-1, 0, -1, 0]); } - }, [value]); + }, [value, keyLabelMap]); useEffect(() => { const { current } = inputRef; @@ -375,16 +379,51 @@ export function TextArea(props) { contentEditable={!disabled} dangerouslySetInnerHTML={{ __html: html }} /> - {!disabled ? : null} + {!disabled ? ( + + ) : null} ); } +async function preloadOptions(scope, value) { + const options = cloneDeep(scope ?? []); + for (let matcher; (matcher = VARIABLE_RE.exec(value ?? '')); ) { + const keys = matcher[1].split('.'); + + let prevOption = null; + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + try { + if (i === 0) { + prevOption = options.find((item) => item.value === key); + } else { + if (prevOption.loadChildren && !prevOption.children?.length) { + await prevOption.loadChildren(prevOption); + } + prevOption = prevOption.children.find((item) => item.value === key); + } + } catch (err) { + error(err); + } + } + } + return options; +} + TextArea.ReadPretty = function ReadPretty(props): JSX.Element { - const { value, multiline = true, scope } = props; - const compile = useCompile(); - const options = compile((typeof scope === 'function' ? scope() : scope) ?? []); - const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]); + const { value, scope } = props; + const [options, setOptions] = useState([]); + const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [options]); + + useEffect(() => { + preloadOptions(scope, value) + .then((preloaded) => { + setOptions(preloaded); + }) + .catch(error); + }, [scope, value]); const html = renderHTML(value ?? '', keyLabelMap); const content = ( diff --git a/packages/core/client/src/schema-component/antd/variable/VariableSelect.tsx b/packages/core/client/src/schema-component/antd/variable/VariableSelect.tsx index 407457be9..448a3f66a 100644 --- a/packages/core/client/src/schema-component/antd/variable/VariableSelect.tsx +++ b/packages/core/client/src/schema-component/antd/variable/VariableSelect.tsx @@ -1,16 +1,19 @@ import { css, cx } from '@emotion/css'; import { Button, Cascader } from 'antd'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -export function VariableSelect(props) { - const { options, onInsert } = props; +export function VariableSelect({ options, setOptions, onInsert, changeOnSelect = false }): JSX.Element { const { t } = useTranslation(); const [selectedVar, setSelectedVar] = useState([]); - useEffect(() => { - setSelectedVar([]); - }, [options]); + async function loadData(selectedOptions) { + const option = selectedOptions[selectedOptions.length - 1]; + if (!option.children && !option.isLeaf && option.loadChildren) { + await option.loadChildren(option); + setOptions((prev) => [...prev]); + } + } return ( ); diff --git a/packages/core/client/src/schema-initializer/components/assigned-field/AssignedField.tsx b/packages/core/client/src/schema-initializer/components/assigned-field/AssignedField.tsx index 0476389e6..39d7f42fb 100644 --- a/packages/core/client/src/schema-initializer/components/assigned-field/AssignedField.tsx +++ b/packages/core/client/src/schema-initializer/components/assigned-field/AssignedField.tsx @@ -1,8 +1,7 @@ import { Field } from '@formily/core'; import { connect, useField, useFieldSchema } from '@formily/react'; import { merge } from '@formily/shared'; -import { Cascader, Select, Space } from 'antd'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useFormBlockContext } from '../../../block-provider'; import { @@ -11,17 +10,14 @@ import { useCollectionField, useCollectionFilterOptions, } from '../../../collection-manager'; -import { useCompile, useComponent } from '../../../schema-component'; +import { Variable, useCompile, useComponent } from '../../../schema-component'; import { DeletedField } from '../DeletedField'; - -const DYNAMIC_RECORD_REG = /\{\{\s*currentRecord\.(.*)\s*\}\}/; -const DYNAMIC_USER_REG = /\{\{\s*currentUser\.(.*)\s*\}\}/; -const DYNAMIC_TIME_REG = /\{\{\s*currentTime\s*\}\}/; +import { css } from '@emotion/css'; const InternalField: React.FC = (props) => { const field = useField(); const fieldSchema = useFieldSchema(); - const { name, interface: interfaceType, uiSchema } = useCollectionField(); + const { uiSchema } = useCollectionField(); const component = useComponent(uiSchema?.['x-component']); const compile = useCompile(); const setFieldProps = (key, value) => { @@ -87,162 +83,55 @@ export enum AssignedFieldValueType { } export const AssignedField = (props: any) => { + const { value, onChange } = props; const { t } = useTranslation(); const compile = useCompile(); - const collection = useCollection(); - const field = useField(); const fieldSchema = useFieldSchema(); - const isDynamicValue = - DYNAMIC_RECORD_REG.test(field.value) || DYNAMIC_USER_REG.test(field.value) || DYNAMIC_TIME_REG.test(field.value); - const initType = isDynamicValue ? AssignedFieldValueType.DynamicValue : AssignedFieldValueType.ConstantValue; - const [type, setType] = useState(initType); - const initFieldType = { - [`${DYNAMIC_TIME_REG.test(field.value)}`]: 'currentTime', - [`${DYNAMIC_USER_REG.test(field.value)}`]: 'currentUser', - [`${DYNAMIC_RECORD_REG.test(field.value)}`]: 'currentRecord', - }; - const [fieldType, setFieldType] = useState(initFieldType['true']); - const initRecordValue = DYNAMIC_RECORD_REG.exec(field.value)?.[1]?.split('.') ?? []; - const [recordValue, setRecordValue] = useState(initRecordValue); - const initUserValue = DYNAMIC_USER_REG.exec(field.value)?.[1]?.split('.') ?? []; - const [userValue, setUserValue] = useState(initUserValue); - const initValue = isDynamicValue ? '' : field.value; - const [value, setValue] = useState(initValue); - const [options, setOptions] = useState([]); const { getField } = useCollection(); const collectionField = getField(fieldSchema.name); - const fields = useCollectionFilterOptions(collection?.name); - const userFields = useCollectionFilterOptions('users'); - const dateTimeFields = ['createdAt', 'datetime', 'time', 'updatedAt']; + const [options, setOptions] = useState([]); + const collection = useCollection(); + const fields = compile(useCollectionFilterOptions(collection?.name)); + const userFields = compile(useCollectionFilterOptions('users')); useEffect(() => { const opt = [ { name: 'currentRecord', title: t('Current record'), + children: fields, }, { name: 'currentUser', title: t('Current user'), + children: userFields, }, ]; - if (dateTimeFields.includes(collectionField?.interface)) { + if (['createdAt', 'datetime', 'time', 'updatedAt'].includes(collectionField?.interface)) { opt.unshift({ name: 'currentTime', title: t('Current time'), + children: null, }); } - setOptions(compile(opt)); - }, []); - - useEffect(() => { - if (type === AssignedFieldValueType.ConstantValue) { - field.value = value; - } else { - if (fieldType === 'currentTime') { - field.value = '{{currentTime}}'; - } else if (fieldType === 'currentUser') { - userValue?.length > 0 && (field.value = `{{currentUser.${userValue.join('.')}}}`); - } else if (fieldType === 'currentRecord') { - recordValue?.length > 0 && (field.value = `{{currentRecord.${recordValue.join('.')}}}`); - } - } - }, [type, value, fieldType, userValue, recordValue]); - - useEffect(() => { - if (type === AssignedFieldValueType.ConstantValue) { - setFieldType(null); - setUserValue([]); - setRecordValue([]); - } - }, [type]); - - const typeChangeHandler = (val) => { - setType(val); - if (val === AssignedFieldValueType.DynamicValue) { - field.validator = null; - field.form.clearErrors(); - } - }; - - const valueChangeHandler = (val) => { - setValue(val?.target?.value ?? val); - }; - - const fieldTypeChangeHandler = (val) => { - setFieldType(val); - }; - const recordChangeHandler = (val) => { - setRecordValue(val); - }; - const userChangeHandler = (val) => { - setUserValue(val); - }; - const useFieldMemo = useMemo(() => { - if (!collectionField) { - return ; - } - if (type === AssignedFieldValueType.ConstantValue) { - return ; - } else { - return ( - - ); - } - }, [collectionField, type, value, fieldType, options]); + setOptions(opt); + }, [fields, userFields]); return ( - - - - {useFieldMemo} - - {fieldType === 'currentRecord' && ( - - )} - {fieldType === 'currentUser' && ( - - )} - + + + ); }; diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useFormVariable.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useFormVariable.ts index 83036ed03..40736a55d 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useFormVariable.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useFormVariable.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useCompile, useGetFilterOptions } from '../../../schema-component'; import { FieldOption, Option } from '../type'; +import { useTranslation } from 'react-i18next'; interface GetOptionsParams { depth: number; @@ -35,7 +36,6 @@ const getChildren = ( key: option.name, value: option.name, label: compile(option.title), - children: [], isLeaf: false, field: option, depth, @@ -57,6 +57,7 @@ export const useFormVariable = ({ level?: number; }) => { const compile = useCompile(); + const { t } = useTranslation(); const getFilterOptions = useGetFilterOptions(); const loadChildren = (option: any): Promise => { if (!option.field?.target) { @@ -94,13 +95,15 @@ export const useFormVariable = ({ }, 5); }); }; + + const label = t('Current form'); + const result = useMemo(() => { return ( blockForm && { - label: `{{t("Current form")}}`, + label, value: '$form', key: '$form', - children: [], isLeaf: false, field: { target: rootCollection, diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts index c27a2decf..1010ec40e 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts @@ -38,7 +38,6 @@ const getChildren = ( key: option.name, value: option.name, label: compile(option.title), - children: [], isLeaf: false, field: option, depth, @@ -93,7 +92,6 @@ export const useUserVariable = ({ schema, maxDepth = 3 }: { schema: any; maxDept label: t('Current user'), value: '$user', key: '$user', - children: [], isLeaf: false, field: { target: 'users', diff --git a/packages/core/client/src/schema-settings/VariableInput/type.ts b/packages/core/client/src/schema-settings/VariableInput/type.ts index 252c653a6..edba1ebab 100644 --- a/packages/core/client/src/schema-settings/VariableInput/type.ts +++ b/packages/core/client/src/schema-settings/VariableInput/type.ts @@ -1,9 +1,10 @@ import { Schema } from '@formily/react'; +import type { DefaultOptionType } from 'antd/lib/cascader'; -export interface Option { +export interface Option extends DefaultOptionType { key?: string | number; value?: string | number; - label?: React.ReactNode; + label: React.ReactNode; disabled?: boolean; children?: Option[]; // 标记是否为叶子节点,设置了 `loadData` 时有效 diff --git a/packages/plugins/workflow/src/client/components/DynamicExpression.tsx b/packages/plugins/workflow/src/client/components/DynamicExpression.tsx index 8eb852294..385b998f7 100644 --- a/packages/plugins/workflow/src/client/components/DynamicExpression.tsx +++ b/packages/plugins/workflow/src/client/components/DynamicExpression.tsx @@ -4,16 +4,18 @@ import { Tag } from 'antd'; import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useRecord, Variable } from '@nocobase/client'; +import { useCollectionManager, useCompile, useRecord, Variable } from '@nocobase/client'; import { NAMESPACE } from '../locale'; -import { useCollectionFieldOptions } from '../variable'; +import { getCollectionFieldOptions } from '../variable'; const InternalExpression = observer( (props: any) => { const { onChange } = props; const { values } = useForm(); const [collection, setCollection] = useState(values?.sourceCollection); + const compile = useCompile(); + const { getCollectionFields } = useCollectionManager(); useFormEffects(() => { onFormInitialValuesChange((form) => { @@ -25,7 +27,7 @@ const InternalExpression = observer( }); }); - const options = useCollectionFieldOptions({ collection: collection }); + const options = getCollectionFieldOptions({ collection, compile, getCollectionFields }); return ; }, @@ -35,8 +37,10 @@ const InternalExpression = observer( function Result(props) { const { t } = useTranslation(); const values = useRecord(); + const compile = useCompile(); + const { getCollectionFields } = useCollectionManager(); const options = useMemo( - () => useCollectionFieldOptions({ collection: values.sourceCollection }), + () => getCollectionFieldOptions({ collection: values.sourceCollection, compile, getCollectionFields }), [values.sourceCollection, values.sourceCollection], ); return props.value ? ( diff --git a/packages/plugins/workflow/src/client/nodes/aggregate.tsx b/packages/plugins/workflow/src/client/nodes/aggregate.tsx index 9d9e619f0..8dcd4f086 100644 --- a/packages/plugins/workflow/src/client/nodes/aggregate.tsx +++ b/packages/plugins/workflow/src/client/nodes/aggregate.tsx @@ -18,8 +18,12 @@ import { FieldsSelect } from '../components/FieldsSelect'; import { ValueBlock } from '../components/ValueBlock'; import { useNodeContext } from '.'; -function matchToManyField(field, depth): boolean { - return ['hasMany', 'belongsToMany'].includes(field.type) && depth; +function matchToManyField(field, appends): boolean { + const fieldPrefix = `${field.name}.`; + return ( + ['hasMany', 'belongsToMany'].includes(field.type) && + (appends.includes(field.name) || appends.some((item) => item.startsWith(fieldPrefix))) + ); } function AssociatedConfig({ value, onChange, ...props }): JSX.Element { diff --git a/packages/plugins/workflow/src/client/nodes/calculation.tsx b/packages/plugins/workflow/src/client/nodes/calculation.tsx index 8a0c7193f..c1937c411 100644 --- a/packages/plugins/workflow/src/client/nodes/calculation.tsx +++ b/packages/plugins/workflow/src/client/nodes/calculation.tsx @@ -14,18 +14,20 @@ import { BaseTypeSets, useWorkflowVariableOptions } from '../variable'; import { ValueBlock } from '../components/ValueBlock'; function useDynamicExpressionCollectionFieldMatcher(field): boolean { - const { getCollectionFields } = useCollectionManager(); - if (field.type !== 'belongsTo') { + if (!['belongsTo', 'hasOne'].includes(field.type)) { return false; } - const fields = getCollectionFields(field.target); + const fields = this.getCollectionFields(field.target); return fields.some((f) => f.interface === 'expression'); } const DynamicConfig = ({ value, onChange }) => { const { t } = useTranslation(); - const scope = useWorkflowVariableOptions({ types: [useDynamicExpressionCollectionFieldMatcher] }); + const { getCollectionFields } = useCollectionManager(); + const scope = useWorkflowVariableOptions({ + types: [useDynamicExpressionCollectionFieldMatcher.bind({ getCollectionFields })], + }); return ( @@ -55,10 +57,6 @@ const DynamicConfig = ({ value, onChange }) => { ); }; -function useWorkflowVariableEntityOptions() { - return useWorkflowVariableOptions({ types: [{ type: 'reference', options: { collection: '*', entity: true } }] }); -} - export default { title: `{{t("Calculation", { ns: "${NAMESPACE}" })}}`, type: 'calculation', @@ -94,10 +92,13 @@ export default { type: 'string', title: `{{t("Calculation expression", { ns: "${NAMESPACE}" })}}`, 'x-decorator': 'FormItem', - 'x-component': 'Variable.TextArea', - 'x-component-props': { - scope: '{{useWorkflowVariableOptions}}', - }, + 'x-component': 'CalculationExpression', + // NOTE: can not use Variable.Input and scope directly as below, + // because the scope will be cached. + // 'x-component': 'Variable.Input', + // 'x-component-props': { + // scope: '{{useWorkflowVariableOptions()}}', + // }, ['x-validator'](value, rules, { form }) { const { values } = form; const { evaluate } = evaluators.get(values.engine) as Evaluator; @@ -133,15 +134,15 @@ export default { type: 'string', title: `{{t("Variable datasource", { ns: "${NAMESPACE}" })}}`, 'x-decorator': 'FormItem', - 'x-component': 'Variable.Input', + 'x-component': 'ScopeSelect', 'x-component-props': { - scope: '{{useWorkflowVariableEntityOptions}}', + changeOnSelect: true, }, 'x-reactions': { dependencies: ['dynamic'], fulfill: { state: { - visible: '{{$deps[0] !== false}}', + visible: '{{$deps[0]}}', }, }, }, @@ -149,11 +150,20 @@ export default { }, view: {}, scope: { - useWorkflowVariableOptions, - useWorkflowVariableEntityOptions, renderEngineReference, }, components: { + CalculationExpression(props) { + const scope = useWorkflowVariableOptions(); + + return ; + }, + ScopeSelect(props) { + const scope = useWorkflowVariableOptions({ + types: [{ type: 'reference', options: { collection: '*', entity: true } }], + }); + return ; + }, CalculationResult({ dataSource }) { const { execution } = useFlowContext(); if (!execution) { diff --git a/packages/plugins/workflow/src/client/nodes/condition.tsx b/packages/plugins/workflow/src/client/nodes/condition.tsx index 5ce957618..d67cdb90a 100644 --- a/packages/plugins/workflow/src/client/nodes/condition.tsx +++ b/packages/plugins/workflow/src/client/nodes/condition.tsx @@ -6,6 +6,7 @@ import { Registry } from '@nocobase/utils/client'; import { Button, Select } from 'antd'; import React from 'react'; import { Trans, useTranslation } from 'react-i18next'; +import { cloneDeep } from 'lodash'; import { NodeDefaultView } from '.'; import { Branch } from '../Branch'; import { useFlowContext } from '../FlowContext'; @@ -371,10 +372,7 @@ export default { type: 'string', title: `{{t("Condition expression", { ns: "${NAMESPACE}" })}}`, 'x-decorator': 'FormItem', - 'x-component': 'Variable.TextArea', - 'x-component-props': { - scope: '{{useWorkflowVariableOptions}}', - }, + 'x-component': 'CalculationExpression', ['x-validator'](value, rules, { form }) { const { values } = form; const { evaluate } = evaluators.get(values.engine); @@ -483,6 +481,11 @@ export default { }, components: { CalculationConfig, + CalculationExpression(props) { + const scope = useWorkflowVariableOptions(); + + return ; + }, RadioWithTooltip, }, }; diff --git a/packages/plugins/workflow/src/client/nodes/create.tsx b/packages/plugins/workflow/src/client/nodes/create.tsx index 2d696e0b6..a0036a9b9 100644 --- a/packages/plugins/workflow/src/client/nodes/create.tsx +++ b/packages/plugins/workflow/src/client/nodes/create.tsx @@ -1,10 +1,10 @@ -import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client'; +import { SchemaInitializerItemOptions, useCollectionDataSource, useCollectionManager, useCompile } from '@nocobase/client'; import { appends, collection, values } from '../schemas/collection'; import CollectionFieldset from '../components/CollectionFieldset'; import { NAMESPACE } from '../locale'; import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer'; -import { useCollectionFieldOptions } from '../variable'; +import { getCollectionFieldOptions } from '../variable'; import { FieldsSelect } from '../components/FieldsSelect'; export default { @@ -41,10 +41,18 @@ export default { FieldsSelect, }, useVariables({ config }, options) { - const result = useCollectionFieldOptions({ - collection: config?.collection, + const compile = useCompile(); + const { getCollectionFields } = useCollectionManager(); + // const depth = config?.params?.appends?.length + // ? config?.params?.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + // : 0; + const result = getCollectionFieldOptions({ + collection: config.collection, ...options, - depth: options?.depth ?? config?.params?.appends?.length ? 1 : 0, + // depth: options?.depth ?? depth, + appends: config.params?.appends, + compile, + getCollectionFields, }); return result?.length ? result : null; diff --git a/packages/plugins/workflow/src/client/nodes/loop.tsx b/packages/plugins/workflow/src/client/nodes/loop.tsx index dd6e8226f..95a6906c3 100644 --- a/packages/plugins/workflow/src/client/nodes/loop.tsx +++ b/packages/plugins/workflow/src/client/nodes/loop.tsx @@ -40,8 +40,20 @@ export default { 'x-decorator': 'FormItem', 'x-component': 'Variable.Input', 'x-component-props': { - scope: '{{useWorkflowVariableOptions}}', + scope: '{{useWorkflowVariableOptions()}}', + changeOnSelect: true, useTypedConstant: ['string', 'number', 'null'], + className: css` + width: 100%; + + .variable { + flex: 1; + } + + .ant-input.null-value { + width: 100%; + } + `, }, required: true, }, diff --git a/packages/plugins/workflow/src/client/nodes/manual/index.tsx b/packages/plugins/workflow/src/client/nodes/manual/index.tsx index cb563e9cb..e087ec2cf 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/index.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/index.tsx @@ -1,7 +1,7 @@ -import { BlockInitializers, SchemaInitializerItemOptions, useCollectionManager } from '@nocobase/client'; +import { BlockInitializers, SchemaInitializerItemOptions, useCollectionManager, useCompile } from '@nocobase/client'; import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer'; -import { useCollectionFieldOptions } from '../../variable'; +import { getCollectionFieldOptions } from '../../variable'; import { NAMESPACE } from '../../locale'; import { SchemaConfig, SchemaConfigButton } from './SchemaConfig'; import { ModeConfig } from './ModeConfig'; @@ -86,6 +86,8 @@ export default { AssigneesSelect, }, useVariables({ config }, { types }) { + const compile = useCompile(); + const { getCollectionFields } = useCollectionManager(); const formKeys = Object.keys(config.forms ?? {}); if (!formKeys.length) { return null; @@ -96,10 +98,12 @@ export default { const form = config.forms[formKey]; // eslint-disable-next-line react-hooks/rules-of-hooks - const options = useCollectionFieldOptions({ + const options = getCollectionFieldOptions({ fields: form.collection?.fields, collection: form.collection, types, + compile, + getCollectionFields, }); return options.length ? { diff --git a/packages/plugins/workflow/src/client/nodes/query.tsx b/packages/plugins/workflow/src/client/nodes/query.tsx index 7ddacd372..e52a0a7e5 100644 --- a/packages/plugins/workflow/src/client/nodes/query.tsx +++ b/packages/plugins/workflow/src/client/nodes/query.tsx @@ -1,10 +1,10 @@ -import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client'; +import { SchemaInitializerItemOptions, useCollectionDataSource, useCollectionManager, useCompile } from '@nocobase/client'; import { appends, collection, filter } from '../schemas/collection'; import { NAMESPACE } from '../locale'; import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer'; import { FilterDynamicComponent } from '../components/FilterDynamicComponent'; -import { useCollectionFieldOptions } from '../variable'; +import { getCollectionFieldOptions } from '../variable'; import { FieldsSelect } from '../components/FieldsSelect'; export default { @@ -44,10 +44,18 @@ export default { FieldsSelect, }, useVariables({ config }, options) { - const result = useCollectionFieldOptions({ - collection: config?.collection, + const compile = useCompile(); + const { getCollectionFields } = useCollectionManager(); + // const depth = config?.params?.appends?.length + // ? config?.params?.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + // : 0; + const result = getCollectionFieldOptions({ + collection: config.collection, ...options, - depth: options?.depth ?? config?.params?.appends?.length ? 1 : 0, + // depth: options?.depth ?? depth, + appends: config.params?.appends, + compile, + getCollectionFields, }); return result?.length ? result : null; diff --git a/packages/plugins/workflow/src/client/nodes/request.tsx b/packages/plugins/workflow/src/client/nodes/request.tsx index c99337040..2ffc1acfb 100644 --- a/packages/plugins/workflow/src/client/nodes/request.tsx +++ b/packages/plugins/workflow/src/client/nodes/request.tsx @@ -1,5 +1,7 @@ +import React from 'react'; import { ArrayItems } from '@formily/antd'; +import { Variable } from '@nocobase/client'; import { NAMESPACE } from '../locale'; import { useWorkflowVariableOptions } from '../variable'; @@ -66,7 +68,7 @@ export default { 'x-decorator': 'FormItem', 'x-component': 'Variable.Input', 'x-component-props': { - scope: useWorkflowVariableOptions, + scope: '{{useWorkflowVariableOptions()}}', useTypedConstant: true, }, }, @@ -112,7 +114,7 @@ export default { 'x-decorator': 'FormItem', 'x-component': 'Variable.Input', 'x-component-props': { - scope: useWorkflowVariableOptions, + scope: '{{useWorkflowVariableOptions()}}', useTypedConstant: true, }, }, @@ -138,9 +140,9 @@ export default { title: `{{t("Body", { ns: "${NAMESPACE}" })}}`, 'x-decorator': 'FormItem', 'x-decorator-props': {}, - 'x-component': 'Variable.JSON', + 'x-component': 'RequestBody', 'x-component-props': { - scope: useWorkflowVariableOptions, + changeOnSelect: true, autoSize: { minRows: 10, }, @@ -170,8 +172,14 @@ export default { }, }, view: {}, - scope: {}, + scope: { + useWorkflowVariableOptions, + }, components: { ArrayItems, + RequestBody(props) { + const scope = useWorkflowVariableOptions(); + return ; + }, }, }; diff --git a/packages/plugins/workflow/src/client/triggers/collection.tsx b/packages/plugins/workflow/src/client/triggers/collection.tsx index e583c3388..f49b14602 100644 --- a/packages/plugins/workflow/src/client/triggers/collection.tsx +++ b/packages/plugins/workflow/src/client/triggers/collection.tsx @@ -1,9 +1,9 @@ -import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client'; +import { SchemaInitializerItemOptions, useCollectionDataSource, useCollectionManager, useCompile } from '@nocobase/client'; import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer'; import { FieldsSelect } from '../components/FieldsSelect'; -import { NAMESPACE, useWorkflowTranslation } from '../locale'; +import { NAMESPACE, lang } from '../locale'; import { appends, collection, filter } from '../schemas/collection'; -import { useCollectionFieldOptions } from '../variable'; +import { getCollectionFieldOptions } from '../variable'; const COLLECTION_TRIGGER_MODE = { CREATED: 1, @@ -45,6 +45,15 @@ export default { }, }, }, + { + target: 'appends', + effects: ['onFieldValueChange'], + fulfill: { + state: { + value: [], + }, + }, + }, ], }, mode: { @@ -133,7 +142,8 @@ export default { FieldsSelect, }, useVariables(config, options) { - const { t } = useWorkflowTranslation(); + const compile = useCompile(); + const { getCollectionFields } = useCollectionManager(); const rootFields = [ { collectionName: config.collection, @@ -141,14 +151,20 @@ export default { type: 'hasOne', target: config.collection, uiSchema: { - title: t('Trigger data'), + title: lang('Trigger data'), }, }, ]; - const result = useCollectionFieldOptions({ + // const depth = config.appends?.length + // ? config.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + 1 + // : 1; + const result = getCollectionFieldOptions({ + // depth, ...options, fields: rootFields, - depth: options?.depth ?? (config.appends?.length ? 2 : 1), + appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])], + compile, + getCollectionFields, }); return result; }, diff --git a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx index 76e9813d9..4119ba45d 100644 --- a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx +++ b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx @@ -1,10 +1,10 @@ -import { useCollectionDataSource, SchemaInitializerItemOptions } from '@nocobase/client'; +import { useCollectionDataSource, SchemaInitializerItemOptions, useCompile, useCollectionManager } from '@nocobase/client'; import { ScheduleConfig } from './ScheduleConfig'; import { SCHEDULE_MODE } from './constants'; -import { NAMESPACE, useWorkflowTranslation } from '../../locale'; +import { NAMESPACE, lang } from '../../locale'; import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer'; -import { useCollectionFieldOptions } from '../../variable'; +import { getCollectionFieldOptions } from '../../variable'; import { FieldsSelect } from '../../components/FieldsSelect'; export default { @@ -24,20 +24,31 @@ export default { ScheduleConfig, FieldsSelect, }, - useVariables(config, { types }) { - const { t } = useWorkflowTranslation(); + useVariables(config, opts) { + const compile = useCompile(); + const { getCollectionFields } = useCollectionManager(); const options: any[] = []; - if (!types || types.includes('date')) { - options.push({ key: 'date', value: 'date', label: t('Trigger time') }); + if (!opts?.types || opts.types.includes('date')) { + options.push({ key: 'date', value: 'date', label: lang('Trigger time') }); } - const fieldOptions = useCollectionFieldOptions({ collection: config.collection }); + 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: t('Trigger data'), + label: lang('Trigger data'), children: fieldOptions, }); } diff --git a/packages/plugins/workflow/src/client/variable.tsx b/packages/plugins/workflow/src/client/variable.tsx index a57459441..fd7d0d4de 100644 --- a/packages/plugins/workflow/src/client/variable.tsx +++ b/packages/plugins/workflow/src/client/variable.tsx @@ -1,6 +1,6 @@ import { useCollectionManager, useCompile } from '@nocobase/client'; import { useFlowContext } from './FlowContext'; -import { NAMESPACE } from './locale'; +import { NAMESPACE, lang } from './locale'; import { instructions, useAvailableUpstreams, useNodeContext, useUpstreamScopes } from './nodes'; import { triggers } from './triggers'; @@ -79,7 +79,7 @@ export const systemOptions = { { key: 'now', value: 'now', - label: `{{t("System time")}}`, + label: lang('System time'), }, ] : []), @@ -110,7 +110,7 @@ export const BaseTypeSets = { // { type: 'reference', options: { collection: 'attachments', multiple: false } } // { type: 'reference', options: { collection: 'myExpressions', entity: false } } -function matchFieldType(field, type, depth): boolean { +function matchFieldType(field, type, appends): boolean { const inputType = typeof type; if (inputType === 'string') { return BaseTypeSets[type]?.has(field.interface); @@ -132,7 +132,7 @@ function matchFieldType(field, type, depth): boolean { } if (inputType === 'function') { - return type(field, depth); + return type(field, appends); } return false; @@ -142,31 +142,47 @@ function isAssociationField(field): boolean { return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type); } -export function filterTypedFields(fields, types, depth = 1) { - if (!types) { - return fields; - } +function getNextAppends(field, appends: string[]) { + const fieldPrefix = `${field.name}.`; + return appends.filter((item) => item.startsWith(fieldPrefix)).map((item) => item.replace(fieldPrefix, '')); +} + +function filterTypedFields({ fields, types, appends, compile, getCollectionFields }) { return fields.filter((field) => { - if ( - isAssociationField(field) && - depth && - filterTypedFields(useNormalizedFields(field.target), types, depth - 1).length - ) { - return true; + const match = types?.length ? types.some((type) => matchFieldType(field, type, appends)) : true; + if (isAssociationField(field)) { + const nextAppends = getNextAppends(field, appends); + const included = appends.includes(field.name); + if (match) { + return included; + } else { + return ( + (nextAppends.length || included) && + filterTypedFields({ + fields: getNormalizedFields(field.target, { compile, getCollectionFields }), + types, + // depth: depth - 1, + appends: nextAppends, + compile, + getCollectionFields, + }).length + ); + } + } else { + return match; } - return types.some((type) => matchFieldType(field, type, depth)); }); } export function useWorkflowVariableOptions(options = {}) { const compile = useCompile(); const result = [scopeOptions, nodesOptions, triggerOptions, systemOptions].map((item: any) => { - const opts = typeof item.useOptions === 'function' ? item.useOptions(options).filter(Boolean) : null; + const opts = item.useOptions(options).filter(Boolean); return { label: compile(item.label), value: item.value, key: item.value, - children: compile(opts), + children: opts, disabled: opts && !opts.length, }; }); @@ -174,9 +190,7 @@ export function useWorkflowVariableOptions(options = {}) { return result; } -function useNormalizedFields(collectionName) { - const compile = useCompile(); - const { getCollectionFields } = useCollectionManager(); +function getNormalizedFields(collectionName, { compile, getCollectionFields }) { const fields = getCollectionFields(collectionName); const foreignKeyFields: any[] = []; const otherFields: any[] = []; @@ -231,26 +245,57 @@ function useNormalizedFields(collectionName) { return otherFields.filter((field) => field.interface && !field.hidden); } -export function useCollectionFieldOptions(options): VariableOption[] { - const { fields, collection, types, depth = 1 } = options; - const compile = useCompile(); - const normalizedFields = useNormalizedFields(collection); +async function loadChildren(option) { + const result = getCollectionFieldOptions({ + collection: option.field.target, + types: option.types, + appends: getNextAppends(option.field, option.appends), + sourceKey: option.field.key, + compile: this.compile, + getCollectionFields: this.getCollectionFields, + }); + if (result.length) { + option.children = result; + } else { + option.isLeaf = true; + option.loadChildren = null; + const matchingType = option.types?.some((type) => matchFieldType(option.field, type, 0)); + if (!matchingType) { + option.disabled = true; + } + } +} + +export function getCollectionFieldOptions(options): VariableOption[] { + const { fields, collection, types, appends = [], compile, getCollectionFields } = options; + const normalizedFields = getNormalizedFields(collection, { compile, getCollectionFields }); const computedFields = fields ?? normalizedFields; - const result: VariableOption[] = filterTypedFields(computedFields, types, depth) - .filter((field) => !isAssociationField(field) || depth) - .map((field) => { - const label = compile(field.uiSchema?.title || field.name); - return { - label, - key: field.name, - value: field.name, - children: - isAssociationField(field) && depth - ? useCollectionFieldOptions({ collection: field.target, types, depth: depth - 1 }) - : null, - field, - }; - }); + const boundLoadChildren = loadChildren.bind({ compile, getCollectionFields }); + + const result: VariableOption[] = filterTypedFields({ + fields: computedFields, + types, + // depth, + appends, + compile, + getCollectionFields, + }).map((field) => { + const label = compile(field.uiSchema?.title || field.name); + const nextAppends = getNextAppends(field, appends); + // TODO: no matching fields in next appends should consider isLeaf as true + const isLeaf = !isAssociationField(field) || (!nextAppends.length && !appends.includes(field.name)); + return { + label, + key: field.name, + value: field.name, + isLeaf, + loadChildren: isLeaf ? null : boundLoadChildren, + field, + // depth, + appends, + types, + }; + }); return result; }