refactor(client): refactor variable components and variables in workflow (#2157)

* refactor(plugin-workflow): change collection variables to lazy load

* fix(plugin-workflow): avoid to-many reverse loading for association field

* fix(client): fix variable components

* chore(client): fix type

* fix(client): fix current user lazy load options

* refactor(client): remove compile from variable components which potencially causing bug

* fix(plugin-workflow): fix scope argument for new api

* fix(client): fix constant type options

* fix(client): fix infinity rerendering

* fix: avoid closure problem

* fix(client): should use no children when lazy load

* refactor(client): refactor AssignedField to use Variable component

* fix(client): fix type

* fix(plugin-workflow): fix variable options in some node not changes

* fix(plugin-workflow): fix select variable for operand crash (T-815)

* fix(plugin-workflow): variable types detect

* fix(plugin-workflow): detect association to match types

* fix(plugin-workflow): fix variable type filter logic

* fix(plugin-workflow): fix optional types

* fix(plugin-workflow): make changeOnSelect configurable in TextArea and JSONInput

---------

Co-authored-by: Rairn <958414905@qq.com>
This commit is contained in:
Junyi 2023-07-05 21:01:41 +07:00 committed by GitHub
parent 25a3a8affa
commit c9b726916c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 462 additions and 369 deletions

View File

@ -2,19 +2,23 @@ import { CloseCircleFilled } from '@ant-design/icons';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { useForm } from '@formily/react'; import { useForm } from '@formily/react';
import { error } from '@nocobase/utils/client'; 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 classNames from 'classnames';
import { cloneDeep } from 'lodash';
import moment from 'moment'; import moment from 'moment';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCompile } from '../..'; import { useCompile } from '../../hooks';
import { Option } from '../../../schema-settings/VariableInput/type';
import { XButton } from './XButton'; import { XButton } from './XButton';
const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/; const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/;
const groupClass = css` const groupClass = css`
width: auto; width: auto;
display: flex !important; display: flex;
&.ant-input-group-compact {
display: flex;
}
.ant-input-disabled { .ant-input-disabled {
.ant-tag { .ant-tag {
color: #bfbfbf; 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 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 { return {
value: '', value: '',
label: '{{t("Constant")}}', label: '{{t("Constant")}}',
children: types children,
? allTypes.filter((item) => (Array.isArray(types) && types.includes(item.value)) || types === true) [fieldNames.value]: '',
: allTypes, [fieldNames.label]: '{{t("Constant")}}',
[fieldNames.children]: children,
component: ConstantTypes[type]?.component, component: ConstantTypes[type]?.component,
}; };
} }
type VariableOptions = {
value: string;
label?: string;
children?: VariableOptions[];
};
export function Input(props) { export function Input(props) {
const {
value = '',
scope,
onChange,
children,
button,
useTypedConstant,
style,
className,
changeOnSelect,
fieldNames,
} = props;
const compile = useCompile(); const compile = useCompile();
const { t } = useTranslation();
const form = useForm(); const form = useForm();
const [options, setOptions] = React.useState<DefaultOptionType[]>([]);
const [variableText, setVariableText] = React.useState('');
const { value = '', scope, onChange, children, button, useTypedConstant, style, className } = props;
const parsed = useMemo(() => parseValue(value), [value]); const parsed = useMemo(() => parseValue(value), [value]);
const isConstant = typeof parsed === 'string'; const isConstant = typeof parsed === 'string';
const type = isConstant ? parsed : ''; const type = isConstant ? parsed : '';
const variable = isConstant ? null : parsed; const variable = isConstant ? null : parsed;
const names = Object.assign(
{
label: 'label',
value: 'value',
children: 'children',
},
fieldNames ?? {},
);
// 当 scope 是一个函数时,可能是一个 hook所以不能使用 useMemo const { component: ConstantComponent, ...constantOption }: DefaultOptionType & { component?: React.FC<any> } =
const variableOptions = typeof scope === 'function' ? scope() : scope ?? [];
const [variableText, setVariableText] = React.useState('');
const { component: ConstantComponent, ...constantOption }: VariableOptions & { component?: React.FC<any> } =
useMemo(() => { useMemo(() => {
if (children) { if (children) {
return { return {
value: '', value: '',
label: '{{t("Constant")}}', label: t('Constant'),
[names.value]: '',
[names.label]: t('Constant'),
}; };
} }
if (useTypedConstant) { if (useTypedConstant) {
return getTypedConstantOption(type, useTypedConstant); return getTypedConstantOption(type, useTypedConstant, names);
} }
return { return {
value: '', value: '',
label: '{{t("Null")}}', label: t('Null'),
[names.value]: '',
[names.label]: t('Null'),
component: ConstantTypes.null.component, component: ConstantTypes.null.component,
}; };
}, [type, useTypedConstant]); }, [type, useTypedConstant]);
const [options, setOptions] = React.useState<Option[]>(() => {
return compile([constantOption, ...variableOptions]);
});
useEffect(() => { useEffect(() => {
const newOptions: Option[] = [constantOption, ...variableOptions]; setOptions([compile(constantOption), ...(scope ? cloneDeep(scope) : [])]);
setOptions(deepCompileLabel(newOptions, compile)); }, [scope]);
}, [variableOptions]);
const loadData = async (selectedOptions: Option[]) => { const loadData = async (selectedOptions: DefaultOptionType[]) => {
const option = selectedOptions[selectedOptions.length - 1]; const option = selectedOptions[selectedOptions.length - 1];
if (option.loadChildren) { if (!option.children && !option.isLeaf && option.loadChildren) {
await option.loadChildren(option); await option.loadChildren(option);
setOptions((prev) => [...prev]); setOptions((prev) => [...prev]);
} }
@ -199,29 +227,29 @@ export function Input(props) {
} }
onChange(`{{${next.join('.')}}}`); onChange(`{{${next.join('.')}}}`);
}, },
[type, variable], [type, variable, onChange],
); );
useEffect(() => { useEffect(() => {
const run = async () => { const run = async () => {
if (!variable || options.length <= 1) { if (!variable || !options.length) {
return; return;
} }
let prevOption: Option = null; let prevOption: DefaultOptionType = null;
const labels = []; const labels = [];
for (let i = 0; i < variable.length; i++) { for (let i = 0; i < variable.length; i++) {
const key = variable[i]; const key = variable[i];
try { try {
if (i === 0) { if (i === 0) {
prevOption = options.find((item) => item.value === key); prevOption = options.find((item) => item[names.value] === key);
} else { } else {
if (prevOption.loadChildren && !prevOption.children?.length) { if (prevOption.loadChildren && !prevOption.children?.length) {
await prevOption.loadChildren(prevOption); 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) { } catch (err) {
error(err); error(err);
} }
@ -230,17 +258,19 @@ export function Input(props) {
setVariableText(labels.join(' / ')); setVariableText(labels.join(' / '));
}; };
// 如果没有这个延迟,会导致选择父节点时不展开子节点 run();
setTimeout(run); // NOTE: watch `options.length` and it only happens once
}, [variable, options.length]); }, [variable, options.length]);
const disabled = props.disabled || form.disabled; const disabled = props.disabled || form.disabled;
return ( return (
<AntInput.Group compact style={style} className={classNames(className, groupClass)}> <AntInput.Group compact style={style} className={classNames(groupClass, className)}>
{variable ? ( {variable ? (
<div <div
className={css` className={cx(
'variable',
css`
position: relative; position: relative;
line-height: 0; line-height: 0;
@ -263,7 +293,8 @@ export function Input(props) {
border-radius: 10px; border-radius: 10px;
} }
} }
`} `,
)}
> >
<div <div
onInput={(e) => e.preventDefault()} onInput={(e) => e.preventDefault()}
@ -297,13 +328,14 @@ export function Input(props) {
) : ( ) : (
children ?? <ConstantComponent value={value} onChange={onChange} /> children ?? <ConstantComponent value={value} onChange={onChange} />
)} )}
{options.length > 1 ? ( {options.length > 1 && !disabled ? (
<Cascader <Cascader
options={options} options={options}
value={variable ?? ['', ...(children || !constantOption.children?.length ? [] : [type])]} value={variable ?? ['', ...(children || !constantOption.children?.length ? [] : [type])]}
onChange={onSwitch} onChange={onSwitch}
loadData={loadData as any} loadData={loadData as any}
changeOnSelect changeOnSelect={changeOnSelect}
fieldNames={fieldNames}
> >
{button ?? <XButton type={variable ? 'primary' : 'default'} />} {button ?? <XButton type={variable ? 'primary' : 'default'} />}
</Cascader> </Cascader>
@ -311,14 +343,3 @@ export function Input(props) {
</AntInput.Group> </AntInput.Group>
); );
} }
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,
};
});
}

View File

@ -1,6 +1,7 @@
import React, { useRef } from 'react'; import React, { useRef, useState } from 'react';
import { Button } from 'antd'; import { Button } from 'antd';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { cloneDeep } from 'lodash';
import { Input } from '../input'; import { Input } from '../input';
import { VariableSelect } from './VariableSelect'; import { VariableSelect } from './VariableSelect';
@ -18,8 +19,8 @@ function setNativeInputValue(input, value) {
export function JSONInput(props) { export function JSONInput(props) {
const inputRef = useRef<any>(null); const inputRef = useRef<any>(null);
const { scope } = props; const { scope, changeOnSelect, ...others } = props;
const options = typeof scope === 'function' ? scope() : scope ?? []; const [options, setOptions] = useState(scope ? cloneDeep(scope) : []);
function onInsert(selected) { function onInsert(selected) {
if (!inputRef.current) { if (!inputRef.current) {
@ -45,7 +46,7 @@ export function JSONInput(props) {
} }
`} `}
> >
<Input.JSON {...props} ref={inputRef} /> <Input.JSON {...others} ref={inputRef} />
<Button.Group <Button.Group
className={css` className={css`
position: absolute; position: absolute;
@ -56,7 +57,7 @@ export function JSONInput(props) {
} }
`} `}
> >
<VariableSelect options={options} onInsert={onInsert} /> <VariableSelect options={options} setOptions={setOptions} onInsert={onInsert} changeOnSelect={changeOnSelect} />
</Button.Group> </Button.Group>
</div> </div>
); );

View File

@ -2,10 +2,12 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Input } from 'antd'; import { Input } from 'antd';
import { useForm } from '@formily/react'; import { useForm } from '@formily/react';
import { cx, css } from '@emotion/css'; import { cx, css } from '@emotion/css';
import { useTranslation } from 'react-i18next';
import * as sanitizeHTML from 'sanitize-html'; 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'; import { VariableSelect } from './VariableSelect';
type RangeIndexes = [number, number, number, number]; type RangeIndexes = [number, number, number, number];
@ -112,9 +114,9 @@ function createOptionsValueLabelMap(options: any[]) {
function createVariableTagHTML(variable, keyLabelMap) { function createVariableTagHTML(variable, keyLabelMap) {
const labels = keyLabelMap.get(variable); const labels = keyLabelMap.get(variable);
return `<span class="ant-tag ant-tag-blue" contentEditable="false" data-variable="${variable}">${labels?.join( return `<span class="ant-tag ant-tag-blue" contentEditable="false" data-variable="${variable}">${
' / ', labels ? labels.join(' / ') : '...'
)}</span>`; }</span>`;
} }
// [#, <>, #, #, <>] // [#, <>, #, #, <>]
@ -185,29 +187,31 @@ function getCurrentRange(element: HTMLElement): RangeIndexes {
} }
export function TextArea(props) { export function TextArea(props) {
const { value = '', scope, onChange, multiline = true } = props; const { value = '', scope, onChange, multiline = true, changeOnSelect } = props;
const compile = useCompile();
const inputRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLDivElement>(null);
const options = compile((typeof scope === 'function' ? scope() : scope) ?? []); const [options, setOptions] = useState([]);
const form = useForm(); const form = useForm();
const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]); const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [options]);
const [ime, setIME] = useState<boolean>(false); const [ime, setIME] = useState<boolean>(false);
const [changed, setChanged] = useState(false); const [changed, setChanged] = useState(false);
const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap)); const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap));
// NOTE: e.g. [startElementIndex, startOffset, endElementIndex, endOffset] // NOTE: e.g. [startElementIndex, startOffset, endElementIndex, endOffset]
const [range, setRange] = useState<[number, number, number, number]>([-1, 0, -1, 0]); const [range, setRange] = useState<[number, number, number, number]>([-1, 0, -1, 0]);
const [selectedVar, setSelectedVar] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
setSelectedVar([]); preloadOptions(scope, value)
}, [scope]); .then((preloaded) => {
setOptions(preloaded);
})
.catch((err) => console.error);
}, [scope, value]);
useEffect(() => { useEffect(() => {
setHtml(renderHTML(value ?? '', keyLabelMap)); setHtml(renderHTML(value ?? '', keyLabelMap));
if (!changed) { if (!changed) {
setRange([-1, 0, -1, 0]); setRange([-1, 0, -1, 0]);
} }
}, [value]); }, [value, keyLabelMap]);
useEffect(() => { useEffect(() => {
const { current } = inputRef; const { current } = inputRef;
@ -375,16 +379,51 @@ export function TextArea(props) {
contentEditable={!disabled} contentEditable={!disabled}
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
{!disabled ? <VariableSelect options={options} onInsert={onInsert} /> : null} {!disabled ? (
<VariableSelect options={options} setOptions={setOptions} onInsert={onInsert} changeOnSelect={changeOnSelect} />
) : null}
</Input.Group> </Input.Group>
); );
} }
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 { TextArea.ReadPretty = function ReadPretty(props): JSX.Element {
const { value, multiline = true, scope } = props; const { value, scope } = props;
const compile = useCompile(); const [options, setOptions] = useState([]);
const options = compile((typeof scope === 'function' ? scope() : scope) ?? []); const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [options]);
const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]);
useEffect(() => {
preloadOptions(scope, value)
.then((preloaded) => {
setOptions(preloaded);
})
.catch(error);
}, [scope, value]);
const html = renderHTML(value ?? '', keyLabelMap); const html = renderHTML(value ?? '', keyLabelMap);
const content = ( const content = (

View File

@ -1,16 +1,19 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { Button, Cascader } from 'antd'; import { Button, Cascader } from 'antd';
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export function VariableSelect(props) { export function VariableSelect({ options, setOptions, onInsert, changeOnSelect = false }): JSX.Element {
const { options, onInsert } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedVar, setSelectedVar] = useState<string[]>([]); const [selectedVar, setSelectedVar] = useState<string[]>([]);
useEffect(() => { async function loadData(selectedOptions) {
setSelectedVar([]); const option = selectedOptions[selectedOptions.length - 1];
}, [options]); if (!option.children && !option.isLeaf && option.loadChildren) {
await option.loadChildren(option);
setOptions((prev) => [...prev]);
}
}
return ( return (
<Button <Button
@ -44,6 +47,7 @@ export function VariableSelect(props) {
placeholder={t('Select a variable')} placeholder={t('Select a variable')}
value={[]} value={[]}
options={options} options={options}
loadData={loadData}
onChange={(keyPaths = [], selectedOptions = []) => { onChange={(keyPaths = [], selectedOptions = []) => {
setSelectedVar(keyPaths as string[]); setSelectedVar(keyPaths as string[]);
if (!keyPaths.length) { if (!keyPaths.length) {
@ -54,9 +58,9 @@ export function VariableSelect(props) {
onInsert(keyPaths); onInsert(keyPaths);
} }
}} }}
changeOnSelect changeOnSelect={changeOnSelect}
onClick={(e: any) => { onClick={(e: any) => {
if (e.detail !== 2) { if (e.detail !== 2 || !changeOnSelect) {
return; return;
} }
for (let n = e.target; n && n !== e.currentTarget; n = n.parentNode) { for (let n = e.target; n && n !== e.currentTarget; n = n.parentNode) {
@ -70,7 +74,9 @@ export function VariableSelect(props) {
margin-bottom: 0; margin-bottom: 0;
} }
`} `}
dropdownRender={(menu) => ( dropdownRender={
changeOnSelect
? (menu) => (
<> <>
{menu} {menu}
<div <div
@ -83,7 +89,9 @@ export function VariableSelect(props) {
{t('Double click to choose entire object')} {t('Double click to choose entire object')}
</div> </div>
</> </>
)} )
: null
}
/> />
</Button> </Button>
); );

View File

@ -1,8 +1,7 @@
import { Field } from '@formily/core'; import { Field } from '@formily/core';
import { connect, useField, useFieldSchema } from '@formily/react'; import { connect, useField, useFieldSchema } from '@formily/react';
import { merge } from '@formily/shared'; import { merge } from '@formily/shared';
import { Cascader, Select, Space } from 'antd'; import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFormBlockContext } from '../../../block-provider'; import { useFormBlockContext } from '../../../block-provider';
import { import {
@ -11,17 +10,14 @@ import {
useCollectionField, useCollectionField,
useCollectionFilterOptions, useCollectionFilterOptions,
} from '../../../collection-manager'; } from '../../../collection-manager';
import { useCompile, useComponent } from '../../../schema-component'; import { Variable, useCompile, useComponent } from '../../../schema-component';
import { DeletedField } from '../DeletedField'; import { DeletedField } from '../DeletedField';
import { css } from '@emotion/css';
const DYNAMIC_RECORD_REG = /\{\{\s*currentRecord\.(.*)\s*\}\}/;
const DYNAMIC_USER_REG = /\{\{\s*currentUser\.(.*)\s*\}\}/;
const DYNAMIC_TIME_REG = /\{\{\s*currentTime\s*\}\}/;
const InternalField: React.FC = (props) => { const InternalField: React.FC = (props) => {
const field = useField<Field>(); const field = useField<Field>();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const { name, interface: interfaceType, uiSchema } = useCollectionField(); const { uiSchema } = useCollectionField();
const component = useComponent(uiSchema?.['x-component']); const component = useComponent(uiSchema?.['x-component']);
const compile = useCompile(); const compile = useCompile();
const setFieldProps = (key, value) => { const setFieldProps = (key, value) => {
@ -87,162 +83,55 @@ export enum AssignedFieldValueType {
} }
export const AssignedField = (props: any) => { export const AssignedField = (props: any) => {
const { value, onChange } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const compile = useCompile(); const compile = useCompile();
const collection = useCollection();
const field = useField<Field>();
const fieldSchema = useFieldSchema(); 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<string>(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<string>(initFieldType['true']);
const initRecordValue = DYNAMIC_RECORD_REG.exec(field.value)?.[1]?.split('.') ?? [];
const [recordValue, setRecordValue] = useState<any>(initRecordValue);
const initUserValue = DYNAMIC_USER_REG.exec(field.value)?.[1]?.split('.') ?? [];
const [userValue, setUserValue] = useState<any>(initUserValue);
const initValue = isDynamicValue ? '' : field.value;
const [value, setValue] = useState(initValue);
const [options, setOptions] = useState<any[]>([]);
const { getField } = useCollection(); const { getField } = useCollection();
const collectionField = getField(fieldSchema.name); const collectionField = getField(fieldSchema.name);
const fields = useCollectionFilterOptions(collection?.name); const [options, setOptions] = useState<any[]>([]);
const userFields = useCollectionFilterOptions('users'); const collection = useCollection();
const dateTimeFields = ['createdAt', 'datetime', 'time', 'updatedAt']; const fields = compile(useCollectionFilterOptions(collection?.name));
const userFields = compile(useCollectionFilterOptions('users'));
useEffect(() => { useEffect(() => {
const opt = [ const opt = [
{ {
name: 'currentRecord', name: 'currentRecord',
title: t('Current record'), title: t('Current record'),
children: fields,
}, },
{ {
name: 'currentUser', name: 'currentUser',
title: t('Current user'), title: t('Current user'),
children: userFields,
}, },
]; ];
if (dateTimeFields.includes(collectionField?.interface)) { if (['createdAt', 'datetime', 'time', 'updatedAt'].includes(collectionField?.interface)) {
opt.unshift({ opt.unshift({
name: 'currentTime', name: 'currentTime',
title: t('Current time'), title: t('Current time'),
children: null,
}); });
} }
setOptions(compile(opt)); setOptions(opt);
}, []); }, [fields, userFields]);
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 <DeletedField />;
}
if (type === AssignedFieldValueType.ConstantValue) {
return <CollectionField {...props} value={value} onChange={valueChangeHandler} style={{ minWidth: 150 }} />;
} else {
return ( return (
<Select <Variable.Input
dropdownMatchSelectWidth={false} value={value}
defaultValue={fieldType} onChange={onChange}
value={fieldType} scope={options}
style={{ minWidth: 150 }} className={css`
onChange={fieldTypeChangeHandler} .variable {
width: 100%;
}
`}
fieldNames={{
label: 'title',
value: 'name',
}}
> >
{options?.map((opt) => { <CollectionField value={value} onChange={onChange} />
return ( </Variable.Input>
<Select.Option key={opt.name} value={opt.name}>
{opt.title}
</Select.Option>
);
})}
</Select>
);
}
}, [collectionField, type, value, fieldType, options]);
return (
<Space>
<Select defaultValue={type} value={type} style={{ width: 150 }} onChange={typeChangeHandler}>
<Select.Option value={AssignedFieldValueType.ConstantValue}>{t('Constant value')}</Select.Option>
<Select.Option value={AssignedFieldValueType.DynamicValue}>{t('Dynamic value')}</Select.Option>
</Select>
{useFieldMemo}
{fieldType === 'currentRecord' && (
<Cascader
fieldNames={{
label: 'title',
value: 'name',
children: 'children',
}}
style={{
minWidth: 150,
}}
options={compile(fields)}
onChange={recordChangeHandler}
defaultValue={recordValue}
/>
)}
{fieldType === 'currentUser' && (
<Cascader
fieldNames={{
label: 'title',
value: 'name',
children: 'children',
}}
style={{
minWidth: 150,
}}
options={compile(userFields)}
onChange={userChangeHandler}
defaultValue={userValue}
/>
)}
</Space>
); );
}; };

View File

@ -1,6 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useCompile, useGetFilterOptions } from '../../../schema-component'; import { useCompile, useGetFilterOptions } from '../../../schema-component';
import { FieldOption, Option } from '../type'; import { FieldOption, Option } from '../type';
import { useTranslation } from 'react-i18next';
interface GetOptionsParams { interface GetOptionsParams {
depth: number; depth: number;
@ -35,7 +36,6 @@ const getChildren = (
key: option.name, key: option.name,
value: option.name, value: option.name,
label: compile(option.title), label: compile(option.title),
children: [],
isLeaf: false, isLeaf: false,
field: option, field: option,
depth, depth,
@ -57,6 +57,7 @@ export const useFormVariable = ({
level?: number; level?: number;
}) => { }) => {
const compile = useCompile(); const compile = useCompile();
const { t } = useTranslation();
const getFilterOptions = useGetFilterOptions(); const getFilterOptions = useGetFilterOptions();
const loadChildren = (option: any): Promise<void> => { const loadChildren = (option: any): Promise<void> => {
if (!option.field?.target) { if (!option.field?.target) {
@ -94,13 +95,15 @@ export const useFormVariable = ({
}, 5); }, 5);
}); });
}; };
const label = t('Current form');
const result = useMemo(() => { const result = useMemo(() => {
return ( return (
blockForm && { blockForm && {
label: `{{t("Current form")}}`, label,
value: '$form', value: '$form',
key: '$form', key: '$form',
children: [],
isLeaf: false, isLeaf: false,
field: { field: {
target: rootCollection, target: rootCollection,

View File

@ -38,7 +38,6 @@ const getChildren = (
key: option.name, key: option.name,
value: option.name, value: option.name,
label: compile(option.title), label: compile(option.title),
children: [],
isLeaf: false, isLeaf: false,
field: option, field: option,
depth, depth,
@ -93,7 +92,6 @@ export const useUserVariable = ({ schema, maxDepth = 3 }: { schema: any; maxDept
label: t('Current user'), label: t('Current user'),
value: '$user', value: '$user',
key: '$user', key: '$user',
children: [],
isLeaf: false, isLeaf: false,
field: { field: {
target: 'users', target: 'users',

View File

@ -1,9 +1,10 @@
import { Schema } from '@formily/react'; import { Schema } from '@formily/react';
import type { DefaultOptionType } from 'antd/lib/cascader';
export interface Option { export interface Option extends DefaultOptionType {
key?: string | number; key?: string | number;
value?: string | number; value?: string | number;
label?: React.ReactNode; label: React.ReactNode;
disabled?: boolean; disabled?: boolean;
children?: Option[]; children?: Option[];
// 标记是否为叶子节点,设置了 `loadData` 时有效 // 标记是否为叶子节点,设置了 `loadData` 时有效

View File

@ -4,16 +4,18 @@ import { Tag } from 'antd';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useRecord, Variable } from '@nocobase/client'; import { useCollectionManager, useCompile, useRecord, Variable } from '@nocobase/client';
import { NAMESPACE } from '../locale'; import { NAMESPACE } from '../locale';
import { useCollectionFieldOptions } from '../variable'; import { getCollectionFieldOptions } from '../variable';
const InternalExpression = observer( const InternalExpression = observer(
(props: any) => { (props: any) => {
const { onChange } = props; const { onChange } = props;
const { values } = useForm(); const { values } = useForm();
const [collection, setCollection] = useState(values?.sourceCollection); const [collection, setCollection] = useState(values?.sourceCollection);
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
useFormEffects(() => { useFormEffects(() => {
onFormInitialValuesChange((form) => { onFormInitialValuesChange((form) => {
@ -25,7 +27,7 @@ const InternalExpression = observer(
}); });
}); });
const options = useCollectionFieldOptions({ collection: collection }); const options = getCollectionFieldOptions({ collection, compile, getCollectionFields });
return <Variable.TextArea {...props} scope={options} />; return <Variable.TextArea {...props} scope={options} />;
}, },
@ -35,8 +37,10 @@ const InternalExpression = observer(
function Result(props) { function Result(props) {
const { t } = useTranslation(); const { t } = useTranslation();
const values = useRecord(); const values = useRecord();
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const options = useMemo( const options = useMemo(
() => useCollectionFieldOptions({ collection: values.sourceCollection }), () => getCollectionFieldOptions({ collection: values.sourceCollection, compile, getCollectionFields }),
[values.sourceCollection, values.sourceCollection], [values.sourceCollection, values.sourceCollection],
); );
return props.value ? ( return props.value ? (

View File

@ -18,8 +18,12 @@ import { FieldsSelect } from '../components/FieldsSelect';
import { ValueBlock } from '../components/ValueBlock'; import { ValueBlock } from '../components/ValueBlock';
import { useNodeContext } from '.'; import { useNodeContext } from '.';
function matchToManyField(field, depth): boolean { function matchToManyField(field, appends): boolean {
return ['hasMany', 'belongsToMany'].includes(field.type) && depth; 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 { function AssociatedConfig({ value, onChange, ...props }): JSX.Element {

View File

@ -14,18 +14,20 @@ import { BaseTypeSets, useWorkflowVariableOptions } from '../variable';
import { ValueBlock } from '../components/ValueBlock'; import { ValueBlock } from '../components/ValueBlock';
function useDynamicExpressionCollectionFieldMatcher(field): boolean { function useDynamicExpressionCollectionFieldMatcher(field): boolean {
const { getCollectionFields } = useCollectionManager(); if (!['belongsTo', 'hasOne'].includes(field.type)) {
if (field.type !== 'belongsTo') {
return false; return false;
} }
const fields = getCollectionFields(field.target); const fields = this.getCollectionFields(field.target);
return fields.some((f) => f.interface === 'expression'); return fields.some((f) => f.interface === 'expression');
} }
const DynamicConfig = ({ value, onChange }) => { const DynamicConfig = ({ value, onChange }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const scope = useWorkflowVariableOptions({ types: [useDynamicExpressionCollectionFieldMatcher] }); const { getCollectionFields } = useCollectionManager();
const scope = useWorkflowVariableOptions({
types: [useDynamicExpressionCollectionFieldMatcher.bind({ getCollectionFields })],
});
return ( return (
<FormLayout layout="vertical"> <FormLayout layout="vertical">
@ -55,10 +57,6 @@ const DynamicConfig = ({ value, onChange }) => {
); );
}; };
function useWorkflowVariableEntityOptions() {
return useWorkflowVariableOptions({ types: [{ type: 'reference', options: { collection: '*', entity: true } }] });
}
export default { export default {
title: `{{t("Calculation", { ns: "${NAMESPACE}" })}}`, title: `{{t("Calculation", { ns: "${NAMESPACE}" })}}`,
type: 'calculation', type: 'calculation',
@ -94,10 +92,13 @@ export default {
type: 'string', type: 'string',
title: `{{t("Calculation expression", { ns: "${NAMESPACE}" })}}`, title: `{{t("Calculation expression", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea', 'x-component': 'CalculationExpression',
'x-component-props': { // NOTE: can not use Variable.Input and scope directly as below,
scope: '{{useWorkflowVariableOptions}}', // because the scope will be cached.
}, // 'x-component': 'Variable.Input',
// 'x-component-props': {
// scope: '{{useWorkflowVariableOptions()}}',
// },
['x-validator'](value, rules, { form }) { ['x-validator'](value, rules, { form }) {
const { values } = form; const { values } = form;
const { evaluate } = evaluators.get(values.engine) as Evaluator; const { evaluate } = evaluators.get(values.engine) as Evaluator;
@ -133,15 +134,15 @@ export default {
type: 'string', type: 'string',
title: `{{t("Variable datasource", { ns: "${NAMESPACE}" })}}`, title: `{{t("Variable datasource", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Variable.Input', 'x-component': 'ScopeSelect',
'x-component-props': { 'x-component-props': {
scope: '{{useWorkflowVariableEntityOptions}}', changeOnSelect: true,
}, },
'x-reactions': { 'x-reactions': {
dependencies: ['dynamic'], dependencies: ['dynamic'],
fulfill: { fulfill: {
state: { state: {
visible: '{{$deps[0] !== false}}', visible: '{{$deps[0]}}',
}, },
}, },
}, },
@ -149,11 +150,20 @@ export default {
}, },
view: {}, view: {},
scope: { scope: {
useWorkflowVariableOptions,
useWorkflowVariableEntityOptions,
renderEngineReference, renderEngineReference,
}, },
components: { components: {
CalculationExpression(props) {
const scope = useWorkflowVariableOptions();
return <Variable.TextArea scope={scope} {...props} />;
},
ScopeSelect(props) {
const scope = useWorkflowVariableOptions({
types: [{ type: 'reference', options: { collection: '*', entity: true } }],
});
return <Variable.Input scope={scope} {...props} />;
},
CalculationResult({ dataSource }) { CalculationResult({ dataSource }) {
const { execution } = useFlowContext(); const { execution } = useFlowContext();
if (!execution) { if (!execution) {

View File

@ -6,6 +6,7 @@ import { Registry } from '@nocobase/utils/client';
import { Button, Select } from 'antd'; import { Button, Select } from 'antd';
import React from 'react'; import React from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { cloneDeep } from 'lodash';
import { NodeDefaultView } from '.'; import { NodeDefaultView } from '.';
import { Branch } from '../Branch'; import { Branch } from '../Branch';
import { useFlowContext } from '../FlowContext'; import { useFlowContext } from '../FlowContext';
@ -371,10 +372,7 @@ export default {
type: 'string', type: 'string',
title: `{{t("Condition expression", { ns: "${NAMESPACE}" })}}`, title: `{{t("Condition expression", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea', 'x-component': 'CalculationExpression',
'x-component-props': {
scope: '{{useWorkflowVariableOptions}}',
},
['x-validator'](value, rules, { form }) { ['x-validator'](value, rules, { form }) {
const { values } = form; const { values } = form;
const { evaluate } = evaluators.get(values.engine); const { evaluate } = evaluators.get(values.engine);
@ -483,6 +481,11 @@ export default {
}, },
components: { components: {
CalculationConfig, CalculationConfig,
CalculationExpression(props) {
const scope = useWorkflowVariableOptions();
return <Variable.TextArea scope={scope} {...props} />;
},
RadioWithTooltip, RadioWithTooltip,
}, },
}; };

View File

@ -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 { appends, collection, values } from '../schemas/collection';
import CollectionFieldset from '../components/CollectionFieldset'; import CollectionFieldset from '../components/CollectionFieldset';
import { NAMESPACE } from '../locale'; import { NAMESPACE } from '../locale';
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer'; import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
import { useCollectionFieldOptions } from '../variable'; import { getCollectionFieldOptions } from '../variable';
import { FieldsSelect } from '../components/FieldsSelect'; import { FieldsSelect } from '../components/FieldsSelect';
export default { export default {
@ -41,10 +41,18 @@ export default {
FieldsSelect, FieldsSelect,
}, },
useVariables({ config }, options) { useVariables({ config }, options) {
const result = useCollectionFieldOptions({ const compile = useCompile();
collection: config?.collection, 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, ...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; return result?.length ? result : null;

View File

@ -40,8 +40,20 @@ export default {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Variable.Input', 'x-component': 'Variable.Input',
'x-component-props': { 'x-component-props': {
scope: '{{useWorkflowVariableOptions}}', scope: '{{useWorkflowVariableOptions()}}',
changeOnSelect: true,
useTypedConstant: ['string', 'number', 'null'], useTypedConstant: ['string', 'number', 'null'],
className: css`
width: 100%;
.variable {
flex: 1;
}
.ant-input.null-value {
width: 100%;
}
`,
}, },
required: true, required: true,
}, },

View File

@ -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 { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
import { useCollectionFieldOptions } from '../../variable'; import { getCollectionFieldOptions } from '../../variable';
import { NAMESPACE } from '../../locale'; import { NAMESPACE } from '../../locale';
import { SchemaConfig, SchemaConfigButton } from './SchemaConfig'; import { SchemaConfig, SchemaConfigButton } from './SchemaConfig';
import { ModeConfig } from './ModeConfig'; import { ModeConfig } from './ModeConfig';
@ -86,6 +86,8 @@ export default {
AssigneesSelect, AssigneesSelect,
}, },
useVariables({ config }, { types }) { useVariables({ config }, { types }) {
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const formKeys = Object.keys(config.forms ?? {}); const formKeys = Object.keys(config.forms ?? {});
if (!formKeys.length) { if (!formKeys.length) {
return null; return null;
@ -96,10 +98,12 @@ export default {
const form = config.forms[formKey]; const form = config.forms[formKey];
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const options = useCollectionFieldOptions({ const options = getCollectionFieldOptions({
fields: form.collection?.fields, fields: form.collection?.fields,
collection: form.collection, collection: form.collection,
types, types,
compile,
getCollectionFields,
}); });
return options.length return options.length
? { ? {

View File

@ -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 { appends, collection, filter } from '../schemas/collection';
import { NAMESPACE } from '../locale'; import { NAMESPACE } from '../locale';
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer'; import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
import { FilterDynamicComponent } from '../components/FilterDynamicComponent'; import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
import { useCollectionFieldOptions } from '../variable'; import { getCollectionFieldOptions } from '../variable';
import { FieldsSelect } from '../components/FieldsSelect'; import { FieldsSelect } from '../components/FieldsSelect';
export default { export default {
@ -44,10 +44,18 @@ export default {
FieldsSelect, FieldsSelect,
}, },
useVariables({ config }, options) { useVariables({ config }, options) {
const result = useCollectionFieldOptions({ const compile = useCompile();
collection: config?.collection, 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, ...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; return result?.length ? result : null;

View File

@ -1,5 +1,7 @@
import React from 'react';
import { ArrayItems } from '@formily/antd'; import { ArrayItems } from '@formily/antd';
import { Variable } from '@nocobase/client';
import { NAMESPACE } from '../locale'; import { NAMESPACE } from '../locale';
import { useWorkflowVariableOptions } from '../variable'; import { useWorkflowVariableOptions } from '../variable';
@ -66,7 +68,7 @@ export default {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Variable.Input', 'x-component': 'Variable.Input',
'x-component-props': { 'x-component-props': {
scope: useWorkflowVariableOptions, scope: '{{useWorkflowVariableOptions()}}',
useTypedConstant: true, useTypedConstant: true,
}, },
}, },
@ -112,7 +114,7 @@ export default {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Variable.Input', 'x-component': 'Variable.Input',
'x-component-props': { 'x-component-props': {
scope: useWorkflowVariableOptions, scope: '{{useWorkflowVariableOptions()}}',
useTypedConstant: true, useTypedConstant: true,
}, },
}, },
@ -138,9 +140,9 @@ export default {
title: `{{t("Body", { ns: "${NAMESPACE}" })}}`, title: `{{t("Body", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-decorator-props': {}, 'x-decorator-props': {},
'x-component': 'Variable.JSON', 'x-component': 'RequestBody',
'x-component-props': { 'x-component-props': {
scope: useWorkflowVariableOptions, changeOnSelect: true,
autoSize: { autoSize: {
minRows: 10, minRows: 10,
}, },
@ -170,8 +172,14 @@ export default {
}, },
}, },
view: {}, view: {},
scope: {}, scope: {
useWorkflowVariableOptions,
},
components: { components: {
ArrayItems, ArrayItems,
RequestBody(props) {
const scope = useWorkflowVariableOptions();
return <Variable.JSON scope={scope} {...props} />;
},
}, },
}; };

View File

@ -1,9 +1,9 @@
import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client'; import { SchemaInitializerItemOptions, useCollectionDataSource, useCollectionManager, useCompile } from '@nocobase/client';
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer'; import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
import { FieldsSelect } from '../components/FieldsSelect'; import { FieldsSelect } from '../components/FieldsSelect';
import { NAMESPACE, useWorkflowTranslation } from '../locale'; import { NAMESPACE, lang } from '../locale';
import { appends, collection, filter } from '../schemas/collection'; import { appends, collection, filter } from '../schemas/collection';
import { useCollectionFieldOptions } from '../variable'; import { getCollectionFieldOptions } from '../variable';
const COLLECTION_TRIGGER_MODE = { const COLLECTION_TRIGGER_MODE = {
CREATED: 1, CREATED: 1,
@ -45,6 +45,15 @@ export default {
}, },
}, },
}, },
{
target: 'appends',
effects: ['onFieldValueChange'],
fulfill: {
state: {
value: [],
},
},
},
], ],
}, },
mode: { mode: {
@ -133,7 +142,8 @@ export default {
FieldsSelect, FieldsSelect,
}, },
useVariables(config, options) { useVariables(config, options) {
const { t } = useWorkflowTranslation(); const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const rootFields = [ const rootFields = [
{ {
collectionName: config.collection, collectionName: config.collection,
@ -141,14 +151,20 @@ export default {
type: 'hasOne', type: 'hasOne',
target: config.collection, target: config.collection,
uiSchema: { 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, ...options,
fields: rootFields, fields: rootFields,
depth: options?.depth ?? (config.appends?.length ? 2 : 1), appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
compile,
getCollectionFields,
}); });
return result; return result;
}, },

View File

@ -1,10 +1,10 @@
import { useCollectionDataSource, SchemaInitializerItemOptions } from '@nocobase/client'; import { useCollectionDataSource, SchemaInitializerItemOptions, useCompile, useCollectionManager } from '@nocobase/client';
import { ScheduleConfig } from './ScheduleConfig'; import { ScheduleConfig } from './ScheduleConfig';
import { SCHEDULE_MODE } from './constants'; import { SCHEDULE_MODE } from './constants';
import { NAMESPACE, useWorkflowTranslation } from '../../locale'; import { NAMESPACE, lang } from '../../locale';
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer'; import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
import { useCollectionFieldOptions } from '../../variable'; import { getCollectionFieldOptions } from '../../variable';
import { FieldsSelect } from '../../components/FieldsSelect'; import { FieldsSelect } from '../../components/FieldsSelect';
export default { export default {
@ -24,20 +24,31 @@ export default {
ScheduleConfig, ScheduleConfig,
FieldsSelect, FieldsSelect,
}, },
useVariables(config, { types }) { useVariables(config, opts) {
const { t } = useWorkflowTranslation(); const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const options: any[] = []; const options: any[] = [];
if (!types || types.includes('date')) { if (!opts?.types || opts.types.includes('date')) {
options.push({ key: 'date', value: 'date', label: t('Trigger time') }); 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 (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) {
if (fieldOptions.length) { if (fieldOptions.length) {
options.push({ options.push({
key: 'data', key: 'data',
value: 'data', value: 'data',
label: t('Trigger data'), label: lang('Trigger data'),
children: fieldOptions, children: fieldOptions,
}); });
} }

View File

@ -1,6 +1,6 @@
import { useCollectionManager, useCompile } from '@nocobase/client'; import { useCollectionManager, useCompile } from '@nocobase/client';
import { useFlowContext } from './FlowContext'; import { useFlowContext } from './FlowContext';
import { NAMESPACE } from './locale'; import { NAMESPACE, lang } from './locale';
import { instructions, useAvailableUpstreams, useNodeContext, useUpstreamScopes } from './nodes'; import { instructions, useAvailableUpstreams, useNodeContext, useUpstreamScopes } from './nodes';
import { triggers } from './triggers'; import { triggers } from './triggers';
@ -79,7 +79,7 @@ export const systemOptions = {
{ {
key: 'now', key: 'now',
value: '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: 'attachments', multiple: false } }
// { type: 'reference', options: { collection: 'myExpressions', entity: false } } // { type: 'reference', options: { collection: 'myExpressions', entity: false } }
function matchFieldType(field, type, depth): boolean { function matchFieldType(field, type, appends): boolean {
const inputType = typeof type; const inputType = typeof type;
if (inputType === 'string') { if (inputType === 'string') {
return BaseTypeSets[type]?.has(field.interface); return BaseTypeSets[type]?.has(field.interface);
@ -132,7 +132,7 @@ function matchFieldType(field, type, depth): boolean {
} }
if (inputType === 'function') { if (inputType === 'function') {
return type(field, depth); return type(field, appends);
} }
return false; return false;
@ -142,31 +142,47 @@ function isAssociationField(field): boolean {
return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type); return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type);
} }
export function filterTypedFields(fields, types, depth = 1) { function getNextAppends(field, appends: string[]) {
if (!types) { const fieldPrefix = `${field.name}.`;
return fields; return appends.filter((item) => item.startsWith(fieldPrefix)).map((item) => item.replace(fieldPrefix, ''));
} }
function filterTypedFields({ fields, types, appends, compile, getCollectionFields }) {
return fields.filter((field) => { return fields.filter((field) => {
if ( const match = types?.length ? types.some((type) => matchFieldType(field, type, appends)) : true;
isAssociationField(field) && if (isAssociationField(field)) {
depth && const nextAppends = getNextAppends(field, appends);
filterTypedFields(useNormalizedFields(field.target), types, depth - 1).length const included = appends.includes(field.name);
) { if (match) {
return true; 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 = {}) { export function useWorkflowVariableOptions(options = {}) {
const compile = useCompile(); const compile = useCompile();
const result = [scopeOptions, nodesOptions, triggerOptions, systemOptions].map((item: any) => { 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 { return {
label: compile(item.label), label: compile(item.label),
value: item.value, value: item.value,
key: item.value, key: item.value,
children: compile(opts), children: opts,
disabled: opts && !opts.length, disabled: opts && !opts.length,
}; };
}); });
@ -174,9 +190,7 @@ export function useWorkflowVariableOptions(options = {}) {
return result; return result;
} }
function useNormalizedFields(collectionName) { function getNormalizedFields(collectionName, { compile, getCollectionFields }) {
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const fields = getCollectionFields(collectionName); const fields = getCollectionFields(collectionName);
const foreignKeyFields: any[] = []; const foreignKeyFields: any[] = [];
const otherFields: any[] = []; const otherFields: any[] = [];
@ -231,24 +245,55 @@ function useNormalizedFields(collectionName) {
return otherFields.filter((field) => field.interface && !field.hidden); return otherFields.filter((field) => field.interface && !field.hidden);
} }
export function useCollectionFieldOptions(options): VariableOption[] { async function loadChildren(option) {
const { fields, collection, types, depth = 1 } = options; const result = getCollectionFieldOptions({
const compile = useCompile(); collection: option.field.target,
const normalizedFields = useNormalizedFields(collection); 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 computedFields = fields ?? normalizedFields;
const result: VariableOption[] = filterTypedFields(computedFields, types, depth) const boundLoadChildren = loadChildren.bind({ compile, getCollectionFields });
.filter((field) => !isAssociationField(field) || depth)
.map((field) => { const result: VariableOption[] = filterTypedFields({
fields: computedFields,
types,
// depth,
appends,
compile,
getCollectionFields,
}).map((field) => {
const label = compile(field.uiSchema?.title || field.name); 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 { return {
label, label,
key: field.name, key: field.name,
value: field.name, value: field.name,
children: isLeaf,
isAssociationField(field) && depth loadChildren: isLeaf ? null : boundLoadChildren,
? useCollectionFieldOptions({ collection: field.target, types, depth: depth - 1 })
: null,
field, field,
// depth,
appends,
types,
}; };
}); });