fix(linkageRules): fix autorun (#3105)

* fix(linkageRules): avoid accidentally triggering autorun

* fix(linkageRules): fix the order of autorun triggers

* fix: avoid crashing

* fix: fix variable of expression

* perf: use throttle to improve performance

* chore: reduce wait time from 1000 to 500

* fix: fix init state

* Revert "fix: fix init state"

This reverts commit 3f469731770b7e1224ebbb63277ac86adc51b97d.

* refactor: rename

* fix: fix init state
This commit is contained in:
Zeke Zhang 2023-11-29 14:57:03 +08:00 committed by GitHub
parent 79d2647c51
commit 357a081199
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 225 additions and 99 deletions

View File

@ -75,7 +75,7 @@ export const Action: ComposedAction = observer(
);
useEffect(() => {
field.linkageProperty = {};
field.stateOfLinkageRules = {};
linkageRules
.filter((k) => !k.disabled)
.forEach((v) => {

View File

@ -84,8 +84,8 @@ export const linkageAction = async ({
variables: VariablesContextType;
localVariables: VariableOption[];
}) => {
const disableResult = field?.linkageProperty?.disabled || [false];
const displayResult = field?.linkageProperty?.display || ['visible'];
const disableResult = field?.stateOfLinkageRules?.disabled || [false];
const displayResult = field?.stateOfLinkageRules?.display || ['visible'];
switch (operator) {
case ActionType.Visible:
@ -94,8 +94,8 @@ export const linkageAction = async ({
field.data = field.data || {};
field.data.hidden = false;
}
field.linkageProperty = {
...field.linkageProperty,
field.stateOfLinkageRules = {
...field.stateOfLinkageRules,
display: displayResult,
};
field.display = last(displayResult);
@ -113,8 +113,8 @@ export const linkageAction = async ({
if (await conditionAnalyses({ rules: condition, variables, localVariables })) {
disableResult.push(true);
}
field.linkageProperty = {
...field.linkageProperty,
field.stateOfLinkageRules = {
...field.stateOfLinkageRules,
disabled: disableResult,
};
field.disabled = last(disableResult);
@ -124,8 +124,8 @@ export const linkageAction = async ({
if (await conditionAnalyses({ rules: condition, variables, localVariables })) {
disableResult.push(false);
}
field.linkageProperty = {
...field.linkageProperty,
field.stateOfLinkageRules = {
...field.stateOfLinkageRules,
disabled: disableResult,
};
field.disabled = last(disableResult);

View File

@ -13,6 +13,7 @@ import { Collection, useCollection, useCollectionManager } from '../../../collec
import { useRecord } from '../../../record-provider';
import { GeneralSchemaItems } from '../../../schema-items/GeneralSchemaItems';
import { GeneralSchemaDesigner, SchemaSettings, isPatternDisabled } from '../../../schema-settings';
import { ActionType } from '../../../schema-settings/LinkageRules/type';
import { VariableInput, getShouldChange } from '../../../schema-settings/VariableInput/VariableInput';
import useIsAllowToSetDefaultValue from '../../../schema-settings/hooks/useIsAllowToSetDefaultValue';
import { useIsShowMultipleSwitch } from '../../../schema-settings/hooks/useIsShowMultipleSwitch';
@ -23,6 +24,7 @@ import { isSubMode } from '../association-field/util';
import { BlockItem } from '../block-item';
import { removeNullCondition } from '../filter';
import { DynamicComponentProps } from '../filter/DynamicComponent';
import { getTempFieldState } from '../form-v2/utils';
import { HTMLEncode } from '../input/shared';
import { useColorFields } from '../table-v2/Table.Column.Designer';
import { FilterFormDesigner } from './FormItem.FilterFormDesigner';
@ -128,12 +130,6 @@ FormItem.Designer = function Designer() {
const isAssociationField = ['obo', 'oho', 'o2o', 'o2m', 'm2m', 'm2o'].includes(collectionField?.interface);
const isTableField = fieldSchema['x-component'] === 'TableField';
const isFileField = isFileCollection(targetCollection as any);
const initialValue = {
title: field.title === originalTitle ? undefined : field.title,
};
if (!field.readPretty) {
initialValue['required'] = field.required;
}
const options = targetFields
.filter((field) => {
return isTitleField(field);
@ -607,6 +603,7 @@ FormItem.Designer = function Designer() {
schema['x-disabled'] = true;
field.readPretty = false;
field.disabled = true;
_.set(field, 'initStateOfLinkageRules.pattern', getTempFieldState(true, ActionType.ReadOnly));
break;
}
case 'read-pretty': {
@ -615,6 +612,7 @@ FormItem.Designer = function Designer() {
schema['x-read-pretty'] = true;
schema['x-disabled'] = false;
field.readPretty = true;
_.set(field, 'initStateOfLinkageRules.pattern', getTempFieldState(true, ActionType.ReadPretty));
break;
}
default: {
@ -624,6 +622,7 @@ FormItem.Designer = function Designer() {
schema['x-disabled'] = false;
field.readPretty = false;
field.disabled = false;
_.set(field, 'initStateOfLinkageRules.pattern', getTempFieldState(true, ActionType.Editable));
break;
}
}

View File

@ -2,14 +2,21 @@ import { css } from '@emotion/css';
import { FormLayout } from '@formily/antd-v5';
import { createForm, Field, Form as FormilyForm, onFieldInit, onFormInputChange } from '@formily/core';
import { FieldContext, FormContext, observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { autorun, toJS } from '@formily/reactive';
import { reaction } from '@formily/reactive';
import { uid } from '@formily/shared';
import { getValuesByPath } from '@nocobase/utils/client';
import { ConfigProvider, Spin } from 'antd';
import _ from 'lodash';
import React, { useEffect, useMemo } from 'react';
import { useActionContext } from '..';
import { useAttach, useComponent } from '../..';
import { ActionType } from '../../../schema-settings/LinkageRules/type';
import { useLocalVariables, useVariables } from '../../../variables';
import { VariableOption, VariablesContextType } from '../../../variables/types';
import { getPath } from '../../../variables/utils/getPath';
import { getVariableName } from '../../../variables/utils/getVariableName';
import { isVariable, REGEX_OF_VARIABLE } from '../../../variables/utils/isVariable';
import { getInnermostKeyAndValue, getTargetField } from '../../common/utils/uitls';
import { useProps } from '../../hooks/useProps';
import { collectFieldStateOfLinkageRules, getTempFieldState } from './utils';
@ -106,65 +113,46 @@ const WithForm = (props: WithFormProps) => {
const disposes = [];
form.addEffects(id, () => {
linkageRules.forEach((v) => {
v.actions?.forEach((h) => {
if (h.targetFields?.length) {
const fields = h.targetFields.join(',');
linkageRules.forEach((rule) => {
rule.actions?.forEach((action) => {
if (action.targetFields?.length) {
const fields = action.targetFields.join(',');
// 之前使用的 `onFieldReact` 有问题,没有办法被取消监听,所以这里用 `onFieldInit` 和 `reaction` 代替
onFieldInit(`*(${fields})`, (field: any, form) => {
field['initProperty'] = field?.['initProperty'] ?? {
display: getTempFieldState(true, field.display),
required: getTempFieldState(true, field.required),
pattern: getTempFieldState(true, field.pattern),
value: getTempFieldState(true, field.value || field.initialValue),
field['initStateOfLinkageRules'] = {
display: field.initStateOfLinkageRules?.display || getTempFieldState(true, field.display),
required: field.initStateOfLinkageRules?.required || getTempFieldState(true, field.required || false),
pattern: field.initStateOfLinkageRules?.pattern || getTempFieldState(true, field.pattern),
value:
field.initStateOfLinkageRules?.value || getTempFieldState(true, field.value || field.initialValue),
};
});
// 之前使用的 `onFieldReact` 有问题,没有办法被取消监听,所以这里用 `onFieldInit` 和 `autorun` 代替
onFieldInit(`*(${fields})`, (field: any, form) => {
disposes.push(
autorun(() => {
// 当条件改变触发 autorun 时,会同步收集字段状态,并保存到 field.linkageProperty 中
collectFieldStateOfLinkageRules({
operator: h.operator,
value: h.value,
field,
condition: v.condition,
values: toJS(form?.values),
variables,
localVariables,
});
reaction(
// 这里共依赖 3 部分,当这 3 部分中的任意一部分发生变更后,需要触发联动规则:
// 1. 条件中的字段值;
// 2. 条件中的变量值;
// 3. value 表达式中的变量值;
() => {
// 获取条件中的字段值
const fieldValuesInCondition = getFieldValuesInCondition({ linkageRules, formValues: form.values });
// 当条件改变时,有可能会触发多个 autorun所以这里需要延迟一下确保所有的 autorun 都执行完毕后,
// 再从 field.linkageProperty 中取值,因为此时 field.linkageProperty 中的值才是全的。
setTimeout(async () => {
const fieldName = getFieldNameByOperator(h.operator);
// 获取条件中的变量值
const variableValuesInCondition = getVariableValuesInCondition({ linkageRules, localVariables });
// 防止重复赋值
if (!field.linkageProperty[fieldName]) {
return;
}
// 获取 value 表达式中的变量值
const variableValuesInExpression = getVariableValuesInExpression({ action, localVariables });
let stateList = field.linkageProperty[fieldName];
const result = [fieldValuesInCondition, variableValuesInCondition, variableValuesInExpression]
.map((item) => JSON.stringify(item))
.join(',');
stateList = await Promise.all(stateList);
stateList = stateList.filter((v) => v.condition);
const lastState = stateList[stateList.length - 1];
if (fieldName === 'value') {
// value 比较特殊,它只有在匹配条件时才需要赋值,当条件不匹配时,维持现在的值;
// stateList 中肯定会有一个初始值,所以当 stateList.length > 1 时,就说明有匹配条件的情况;
if (stateList.length > 1) {
field.value = lastState.value;
}
} else {
field[fieldName] = lastState?.value;
}
// 在这里清空 field.linkageProperty就可以保证当条件再次改变时如果该字段没有和任何条件匹配则需要把对应的值恢复到初始值
field.linkageProperty[fieldName] = null;
});
}),
return result;
},
getSubscriber(action, field, rule, form, variables, localVariables),
{ fireImmediately: true },
),
);
});
}
@ -238,6 +226,59 @@ export const Form: React.FC<FormProps> & {
{ displayName: 'Form' },
);
function getSubscriber(
action: any,
field: any,
rule: any,
form: FormilyForm<any>,
variables: VariablesContextType,
localVariables: VariableOption[],
): (value: string, oldValue: string) => void {
return _.throttle(() => {
// 当条件改变触发 reaction 时,会同步收集字段状态,并保存到 field.stateOfLinkageRules 中
collectFieldStateOfLinkageRules({
operator: action.operator,
value: action.value,
field,
condition: rule.condition,
values: form.values,
variables,
localVariables,
});
// 当条件改变时,有可能会触发多个 reaction所以这里需要延迟一下确保所有的 reaction 都执行完毕后,
// 再从 field.stateOfLinkageRules 中取值,因为此时 field.stateOfLinkageRules 中的值才是全的。
setTimeout(async () => {
const fieldName = getFieldNameByOperator(action.operator);
// 防止重复赋值
if (!field.stateOfLinkageRules[fieldName]) {
return;
}
let stateList = field.stateOfLinkageRules[fieldName];
stateList = await Promise.all(stateList);
stateList = stateList.filter((v) => v.condition);
const lastState = stateList[stateList.length - 1];
if (fieldName === 'value') {
// value 比较特殊,它只有在匹配条件时才需要赋值,当条件不匹配时,维持现在的值;
// stateList 中肯定会有一个初始值,所以当 stateList.length > 1 时,就说明有匹配条件的情况;
if (stateList.length > 1) {
field.value = lastState.value;
}
} else {
field[fieldName] = lastState?.value;
}
// 在这里清空 field.stateOfLinkageRules就可以保证当条件再次改变时如果该字段没有和任何条件匹配则需要把对应的值恢复到初始值
field.stateOfLinkageRules[fieldName] = null;
});
}, 500);
}
function getFieldNameByOperator(operator: ActionType) {
switch (operator) {
case ActionType.Required:
@ -257,3 +298,78 @@ function getFieldNameByOperator(operator: ActionType) {
return null;
}
}
function getFieldValuesInCondition({ linkageRules, formValues }) {
return linkageRules.map((rule) => {
const type = Object.keys(rule.condition)[0] || '$and';
const conditions = rule.condition[type];
return conditions
.map((condition) => {
const path = getTargetField(condition).join('.');
return getValuesByPath(formValues, path);
})
.filter(Boolean);
});
}
function getVariableValuesInCondition({
linkageRules,
localVariables,
}: {
linkageRules: any[];
localVariables: VariableOption[];
}) {
return linkageRules.map((rule) => {
const type = Object.keys(rule.condition)[0] || '$and';
const conditions = rule.condition[type];
return conditions
.map((condition) => {
const jsonlogic = getInnermostKeyAndValue(condition);
if (!jsonlogic) {
return null;
}
if (isVariable(jsonlogic.value)) {
return getVariableValue(jsonlogic.value, localVariables);
}
return jsonlogic.value;
})
.filter(Boolean);
});
}
function getVariableValuesInExpression({ action, localVariables }) {
const actionValue = action.value;
const mode = actionValue?.mode;
const value = actionValue?.value || actionValue?.result;
if (mode !== 'express') {
return;
}
if (value == null) {
return;
}
return value
.match(REGEX_OF_VARIABLE)
?.map((variableString: string) => {
return getVariableValue(variableString, localVariables);
})
.filter(Boolean);
}
function getVariableValue(variableString: string, localVariables: VariableOption[]) {
if (!isVariable(variableString)) {
return;
}
const variableName = getVariableName(variableString);
const ctx = {
[variableName]: localVariables.find((item) => item.name === variableName)?.ctx,
};
return getValuesByPath(ctx, getPath(variableString));
}

View File

@ -40,24 +40,24 @@ export const collectFieldStateOfLinkageRules = ({
variables,
localVariables,
}: Props) => {
const requiredResult = field?.linkageProperty?.required || [field?.initProperty?.required || false];
const displayResult = field?.linkageProperty?.display || [field?.initProperty?.display];
const patternResult = field?.linkageProperty?.pattern || [field?.initProperty?.pattern];
const valueResult = field?.linkageProperty?.value || [field?.initProperty?.value];
const requiredResult = field?.stateOfLinkageRules?.required || [field?.initStateOfLinkageRules?.required];
const displayResult = field?.stateOfLinkageRules?.display || [field?.initStateOfLinkageRules?.display];
const patternResult = field?.stateOfLinkageRules?.pattern || [field?.initStateOfLinkageRules?.pattern];
const valueResult = field?.stateOfLinkageRules?.value || [field?.initStateOfLinkageRules?.value];
const { evaluate } = evaluators.get('formula.js');
switch (operator) {
case ActionType.Required:
requiredResult.push(getTempFieldState(conditionAnalyses({ rules: condition, variables, localVariables }), true));
field.linkageProperty = {
...field.linkageProperty,
field.stateOfLinkageRules = {
...field.stateOfLinkageRules,
required: requiredResult,
};
break;
case ActionType.InRequired:
requiredResult.push(getTempFieldState(conditionAnalyses({ rules: condition, variables, localVariables }), false));
field.linkageProperty = {
...field.linkageProperty,
field.stateOfLinkageRules = {
...field.stateOfLinkageRules,
required: requiredResult,
};
break;
@ -67,8 +67,8 @@ export const collectFieldStateOfLinkageRules = ({
displayResult.push(
getTempFieldState(conditionAnalyses({ rules: condition, variables, localVariables }), operator),
);
field.linkageProperty = {
...field.linkageProperty,
field.stateOfLinkageRules = {
...field.stateOfLinkageRules,
display: displayResult,
};
break;
@ -78,8 +78,8 @@ export const collectFieldStateOfLinkageRules = ({
patternResult.push(
getTempFieldState(conditionAnalyses({ rules: condition, variables, localVariables }), operator),
);
field.linkageProperty = {
...field.linkageProperty,
field.stateOfLinkageRules = {
...field.stateOfLinkageRules,
pattern: patternResult,
};
break;
@ -117,8 +117,8 @@ export const collectFieldStateOfLinkageRules = ({
getTempFieldState(conditionAnalyses({ rules: condition, variables, localVariables }), getValue()),
);
}
field.linkageProperty = {
...field.linkageProperty,
field.stateOfLinkageRules = {
...field.stateOfLinkageRules,
value: valueResult,
};
}

View File

@ -38,7 +38,7 @@ export function getInnermostKeyAndValue(obj) {
return null;
}
const getTargetField = (obj) => {
export const getTargetField = (obj) => {
const keys = getAllKeys(obj);
const index = findIndex(keys, (key, index, keys) => {
if (key.includes('$') && index > 0) {
@ -83,21 +83,20 @@ export const conditionAnalyses = async ({
const type = Object.keys(rules)[0] || '$and';
const conditions = rules[type];
let results = conditions.map(async (c) => {
const jsonlogic = getInnermostKeyAndValue(c);
let results = conditions.map(async (condition) => {
const jsonlogic = getInnermostKeyAndValue(condition);
const operator = jsonlogic?.key;
if (!operator) {
return true;
}
const targetVariableName = targetFieldToVariableString(getTargetField(c));
const targetVariableName = targetFieldToVariableString(getTargetField(condition));
const targetValue = variables.parseVariable(targetVariableName, localVariables);
const parsingResult = isVariable(jsonlogic?.value)
? [
variables.parseVariable(jsonlogic?.value, localVariables),
variables.parseVariable(targetVariableName, localVariables),
]
: [jsonlogic?.value, variables.parseVariable(targetVariableName, localVariables)];
? [variables.parseVariable(jsonlogic?.value, localVariables), targetValue]
: [jsonlogic?.value, targetValue];
try {
const jsonLogic = getJsonLogic();
@ -127,7 +126,7 @@ export const conditionAnalyses = async ({
* @param targetField
* @returns
*/
function targetFieldToVariableString(targetField: string[]) {
export function targetFieldToVariableString(targetField: string[]) {
// Action 中的联动规则虽然没有 form 上下文但是在这里也使用的是 `$nForm` 变量,这样实现更简单
return `{{ $nForm.${targetField.join('.')} }}`;
}

View File

@ -97,7 +97,7 @@ export const CreateRecordAction = observer(
const localVariables = useLocalVariables({ currentForm: { values } as any });
useEffect(() => {
field.linkageProperty = {};
field.stateOfLinkageRules = {};
linkageRules
.filter((k) => !k.disabled)
.forEach((v) => {
@ -201,7 +201,7 @@ export const CreateAction = observer(
}, [menuItems]);
useEffect(() => {
field.linkageProperty = {};
field.stateOfLinkageRules = {};
linkageRules
.filter((k) => !k.disabled)
.forEach((v) => {

View File

@ -1,9 +1,11 @@
import { Field } from '@formily/core';
import { ISchema, observer, useField, useFieldSchema } from '@formily/react';
import _ from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCollection, useCollectionManager } from '../collection-manager';
import { useDesignable } from '../schema-component';
import { getTempFieldState } from '../schema-component/antd/form-v2/utils';
import { SchemaSettings } from '../schema-settings';
export const GeneralSchemaItems: React.FC<{
@ -149,6 +151,7 @@ export const GeneralSchemaItems: React.FC<{
['x-uid']: fieldSchema['x-uid'],
};
field.required = required;
_.set(field, 'initStateOfLinkageRules.required', getTempFieldState(true, required));
fieldSchema['required'] = required;
schema['required'] = required;
dn.emit('patch', {

View File

@ -1,3 +1,4 @@
import { untracked } from '@formily/reactive';
import { getValuesByPath } from '@nocobase/utils/client';
import _ from 'lodash';
import React, { createContext, useCallback, useEffect, useMemo, useRef } from 'react';
@ -278,11 +279,19 @@ VariablesProvider.displayName = 'VariablesProvider';
export default VariablesProvider;
function shouldToRequest(value) {
// fix https://nocobase.height.app/T-2502
// 兼容 `对多` 和 `对一` 子表单子表格字段的情况
if (JSON.stringify(value) === '[{}]' || JSON.stringify(value) === '{}') {
return true;
}
let result = false;
return _.isEmpty(value);
// value 有可能是一个响应式对象,使用 untracked 可以避免意外触发 autorun
untracked(() => {
// fix https://nocobase.height.app/T-2502
// 兼容 `对多` 和 `对一` 子表单子表格字段的情况
if (JSON.stringify(value) === '[{}]' || JSON.stringify(value) === '{}') {
result = true;
return;
}
result = _.isEmpty(value);
});
return result;
}