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 { 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<DefaultOptionType[]>([]);
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<any> } =
const { component: ConstantComponent, ...constantOption }: DefaultOptionType & { component?: React.FC<any> } =
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<Option[]>(() => {
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,17 +258,19 @@ 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 (
<AntInput.Group compact style={style} className={classNames(className, groupClass)}>
<AntInput.Group compact style={style} className={classNames(groupClass, className)}>
{variable ? (
<div
className={css`
className={cx(
'variable',
css`
position: relative;
line-height: 0;
@ -263,7 +293,8 @@ export function Input(props) {
border-radius: 10px;
}
}
`}
`,
)}
>
<div
onInput={(e) => e.preventDefault()}
@ -297,13 +328,14 @@ export function Input(props) {
) : (
children ?? <ConstantComponent value={value} onChange={onChange} />
)}
{options.length > 1 ? (
{options.length > 1 && !disabled ? (
<Cascader
options={options}
value={variable ?? ['', ...(children || !constantOption.children?.length ? [] : [type])]}
onChange={onSwitch}
loadData={loadData as any}
changeOnSelect
changeOnSelect={changeOnSelect}
fieldNames={fieldNames}
>
{button ?? <XButton type={variable ? 'primary' : 'default'} />}
</Cascader>
@ -311,14 +343,3 @@ export function Input(props) {
</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 { 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<any>(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) {
}
`}
>
<Input.JSON {...props} ref={inputRef} />
<Input.JSON {...others} ref={inputRef} />
<Button.Group
className={css`
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>
</div>
);

View File

@ -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 `<span class="ant-tag ant-tag-blue" contentEditable="false" data-variable="${variable}">${labels?.join(
' / ',
)}</span>`;
return `<span class="ant-tag ant-tag-blue" contentEditable="false" data-variable="${variable}">${
labels ? labels.join(' / ') : '...'
}</span>`;
}
// [#, <>, #, #, <>]
@ -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<HTMLDivElement>(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<boolean>(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<string[]>([]);
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 ? <VariableSelect options={options} onInsert={onInsert} /> : null}
{!disabled ? (
<VariableSelect options={options} setOptions={setOptions} onInsert={onInsert} changeOnSelect={changeOnSelect} />
) : null}
</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 {
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 = (

View File

@ -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<string[]>([]);
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 (
<Button
@ -44,6 +47,7 @@ export function VariableSelect(props) {
placeholder={t('Select a variable')}
value={[]}
options={options}
loadData={loadData}
onChange={(keyPaths = [], selectedOptions = []) => {
setSelectedVar(keyPaths as string[]);
if (!keyPaths.length) {
@ -54,9 +58,9 @@ export function VariableSelect(props) {
onInsert(keyPaths);
}
}}
changeOnSelect
changeOnSelect={changeOnSelect}
onClick={(e: any) => {
if (e.detail !== 2) {
if (e.detail !== 2 || !changeOnSelect) {
return;
}
for (let n = e.target; n && n !== e.currentTarget; n = n.parentNode) {
@ -70,7 +74,9 @@ export function VariableSelect(props) {
margin-bottom: 0;
}
`}
dropdownRender={(menu) => (
dropdownRender={
changeOnSelect
? (menu) => (
<>
{menu}
<div
@ -83,7 +89,9 @@ export function VariableSelect(props) {
{t('Double click to choose entire object')}
</div>
</>
)}
)
: null
}
/>
</Button>
);

View File

@ -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<Field>();
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<Field>();
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 collectionField = getField(fieldSchema.name);
const fields = useCollectionFilterOptions(collection?.name);
const userFields = useCollectionFilterOptions('users');
const dateTimeFields = ['createdAt', 'datetime', 'time', 'updatedAt'];
const [options, setOptions] = useState<any[]>([]);
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));
}, []);
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 (
<Select
dropdownMatchSelectWidth={false}
defaultValue={fieldType}
value={fieldType}
style={{ minWidth: 150 }}
onChange={fieldTypeChangeHandler}
<Variable.Input
value={value}
onChange={onChange}
scope={options}
className={css`
.variable {
width: 100%;
}
`}
fieldNames={{
label: 'title',
value: 'name',
}}
>
{options?.map((opt) => {
return (
<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>
<CollectionField value={value} onChange={onChange} />
</Variable.Input>
);
};

View File

@ -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<void> => {
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,

View File

@ -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',

View File

@ -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` 时有效

View File

@ -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 <Variable.TextArea {...props} scope={options} />;
},
@ -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 ? (

View File

@ -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 {

View File

@ -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 (
<FormLayout layout="vertical">
@ -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 <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 }) {
const { execution } = useFlowContext();
if (!execution) {

View File

@ -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 <Variable.TextArea scope={scope} {...props} />;
},
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 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;

View File

@ -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,
},

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 { 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
? {

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 { 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;

View File

@ -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 <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 { 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;
},

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 { 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,
});
}

View File

@ -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,24 +245,55 @@ 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 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,
children:
isAssociationField(field) && depth
? useCollectionFieldOptions({ collection: field.target, types, depth: depth - 1 })
: null,
isLeaf,
loadChildren: isLeaf ? null : boundLoadChildren,
field,
// depth,
appends,
types,
};
});