fix(subtable): prevent empty rows (#5990)

* fix(subtable): prevent empty rows

* refactor: rename params

* chore: fix unit tests
This commit is contained in:
Zeke Zhang 2025-01-04 15:14:14 +08:00 committed by GitHub
parent 122d33aaf5
commit 98098ab6cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 182 additions and 12 deletions

View File

@ -87,7 +87,7 @@ const tableClassName = css`
export const SubTable: any = observer(
(props: any) => {
const { openSize } = props;
const { field, options: collectionField } = useAssociationFieldContext<ArrayField>();
const { field, options: collectionField, fieldSchema: schema } = useAssociationFieldContext<ArrayField>();
const { t } = useTranslation();
const [visibleSelector, setVisibleSelector] = useState(false);
const [selectedRows, setSelectedRows] = useState([]);
@ -98,7 +98,9 @@ export const SubTable: any = observer(
const recordV2 = useCollectionRecord();
const collection = useCollection();
const { allowSelectExistingRecord, allowAddnew, allowDisassociation } = field.componentProps;
useSubTableSpecialCase({ field });
useSubTableSpecialCase({ rootField: field, rootSchema: schema });
const move = (fromIndex: number, toIndex: number) => {
if (toIndex === undefined) return;
if (!isArr(field.value)) return;

View File

@ -0,0 +1,121 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Schema } from '@formily/json-schema';
import { renderHook } from '@nocobase/test/client';
import { describe, expect, it } from 'vitest';
import { useHasUsedVariable } from '../hooks/useSpecialCase';
describe('useHasUsedVariable', () => {
it('should return true when FormItem has variable in default value', () => {
const schema = new Schema({
type: 'object',
properties: {
field1: {
type: 'string',
'x-decorator': 'FormItem',
default: '{{$context.field1}}',
},
},
});
const { result } = renderHook(() => useHasUsedVariable());
const { hasUsedVariable } = result.current;
expect(hasUsedVariable('$context', schema)).toBe(true);
});
it('should return false when FormItem has no variable', () => {
const schema = new Schema({
type: 'object',
properties: {
field1: {
type: 'string',
'x-decorator': 'FormItem',
default: 'static value',
},
},
});
const { result } = renderHook(() => useHasUsedVariable());
const { hasUsedVariable } = result.current;
expect(hasUsedVariable('$context', schema)).toBe(false);
});
it('should return true when nested FormItem has variable', () => {
const schema = new Schema({
type: 'object',
properties: {
parent: {
type: 'object',
properties: {
child: {
type: 'string',
'x-decorator': 'FormItem',
default: '{{$context.child}}',
},
},
},
},
});
const { result } = renderHook(() => useHasUsedVariable());
const { hasUsedVariable } = result.current;
expect(hasUsedVariable('$context', schema)).toBe(true);
});
it('should return false when schema has no FormItem decorator', () => {
const schema = new Schema({
type: 'object',
properties: {
field1: {
type: 'string',
default: '{{$context.field1}}',
},
},
});
const { result } = renderHook(() => useHasUsedVariable());
const { hasUsedVariable } = result.current;
expect(hasUsedVariable('$context', schema)).toBe(false);
});
it('should return false when default value is not a string', () => {
const schema = new Schema({
type: 'object',
properties: {
field1: {
type: 'string',
'x-decorator': 'FormItem',
default: 123,
},
},
});
const { result } = renderHook(() => useHasUsedVariable());
const { hasUsedVariable } = result.current;
expect(hasUsedVariable('$context', schema)).toBe(false);
});
it('should return false when default value is not a variable', () => {
const schema = new Schema({
type: 'object',
properties: {
field1: {
type: 'string',
'x-decorator': 'FormItem',
default: '$context.field1',
},
},
});
const { result } = renderHook(() => useHasUsedVariable());
const { hasUsedVariable } = result.current;
expect(hasUsedVariable('$context', schema)).toBe(false);
});
});

View File

@ -128,10 +128,8 @@ const useParseDefaultValue = () => {
});
if (value == null || value === '') {
// fix https://nocobase.height.app/T-4350/description
// 如果 field.mounted 为 false说明 field 已经被卸载了,不需要再设置默认值
if (field.mounted) {
// fix https://nocobase.height.app/T-2805
field.setInitialValue(null);
await field.reset({ forceClear: true });
}

View File

@ -10,15 +10,17 @@
import { Field } from '@formily/core';
import { Schema, useFieldSchema, useForm } from '@formily/react';
import _ from 'lodash';
import { useCallback, useEffect, useMemo } from 'react';
import {
CollectionFieldOptions_deprecated,
useCollectionManager_deprecated,
useCollection_deprecated,
} from '../../../../collection-manager';
import { markRecordAsNew } from '../../../../data-source/collection-record/isNewRecord';
import { isVariable } from '../../../../variables/utils/isVariable';
import { isSubMode } from '../../association-field/util';
import { useCallback, useEffect, useMemo } from 'react';
/**
* #### `子表单` `子表格`
*
@ -187,20 +189,22 @@ export function isFromDatabase(value: Record<string, any>) {
* 3.
* @param param0
*/
export const useSubTableSpecialCase = ({ field }) => {
export const useSubTableSpecialCase = ({ rootField, rootSchema }) => {
const { hasUsedVariable } = useHasUsedVariable();
useEffect(() => {
if (_.isEmpty(field.value)) {
const emptyValue = field.value;
if (_.isEmpty(rootField.value) && hasUsedVariable('$context', rootSchema)) {
const emptyValue = rootField.value;
const newValue = [markRecordAsNew({})];
field.value = newValue;
rootField.value = newValue;
// 因为默认值的解析是异步的,所以下面的代码会优先于默认值的设置,这样就防止了设置完默认值后又被清空的问题
setTimeout(() => {
if (JSON.stringify(field.value) === JSON.stringify(newValue)) {
field.value = emptyValue;
if (JSON.stringify(rootField.value) === JSON.stringify(newValue)) {
rootField.value = emptyValue;
}
});
}
}, []);
}, [rootField, rootSchema, hasUsedVariable]);
};
/**
@ -228,3 +232,48 @@ export function isSubset(subset: any[], superset: any[]): boolean {
// All elements match, it's a subset
return true;
}
/**
* Recursively checks a schema and its properties for usage of a variable name
* @param schema The Schema object to check
* @param variableName The variable name to search for
* @returns True if the variable is used in the schema or its properties
*/
const checkSchema = (schema: Schema, variableName: string): boolean => {
// Check if current node is a FormItem and has a default value containing the variable
if (schema['x-decorator'] === 'FormItem') {
const defaultValue = schema.default;
if (
defaultValue &&
typeof defaultValue === 'string' &&
isVariable(defaultValue) &&
defaultValue.includes(variableName)
) {
return true;
}
}
// Recursively check all child properties
let hasVariable = false;
schema.mapProperties((childSchema) => {
if (checkSchema(childSchema, variableName)) {
hasVariable = true;
}
});
return hasVariable;
};
export function useHasUsedVariable() {
/**
* Checks if a variable name is used anywhere in a schema
* @param variableName The variable name to search for
* @param rootSchema The root Schema object to check
* @returns True if the variable is used in the schema
*/
const hasUsedVariable = useCallback((variableName: string, rootSchema: Schema): boolean => {
return checkSchema(rootSchema, variableName);
}, []);
return { hasUsedVariable };
}