fix(editor): 统一数据源字段路径解析并优化 typeMatch 校验

修复 ds-field:: 前缀解析、checkStrictly 数据源取值及字段类型不匹配时的校验提示。
This commit is contained in:
roymondchen 2026-08-13 15:03:49 +08:00
parent 050814f3f8
commit 05fbdb17b5
8 changed files with 126 additions and 54 deletions

View File

@ -31,6 +31,7 @@ import { computed } from 'vue';
import { getDesignConfig, TMagicSelect } from '@tmagic/design'; import { getDesignConfig, TMagicSelect } from '@tmagic/design';
import type { CondOpSelectConfig, FieldProps } from '@tmagic/form'; import type { CondOpSelectConfig, FieldProps } from '@tmagic/form';
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
import { useServices } from '@editor/hooks/use-services'; import { useServices } from '@editor/hooks/use-services';
import { getCondOpOptionsByFieldType, getFieldType } from '@editor/utils'; import { getCondOpOptionsByFieldType, getFieldType } from '@editor/utils';
@ -50,8 +51,8 @@ const props = defineProps<FieldProps<CondOpSelectConfig>>();
const optionComponent = getDesignConfig('components')?.option; const optionComponent = getDesignConfig('components')?.option;
const options = computed(() => { const options = computed(() => {
const [id, ...fieldNames] = [...(props.config.parentFields || []), ...props.model.field]; const [id, ...fieldNames] = [...(props.config.parentFields || []), ...(props.model.field || [])];
const ds = dataSourceService.getDataSourceById(id); const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(id)) : undefined;
const type = getFieldType(ds, fieldNames); const type = getFieldType(ds, fieldNames);
return getCondOpOptionsByFieldType(type); return getCondOpOptionsByFieldType(type);
}); });

View File

@ -143,7 +143,10 @@ const checkStrictly = computed(() => {
if (typeof props.config.checkStrictly !== 'function') { if (typeof props.config.checkStrictly !== 'function') {
value = props.config.checkStrictly; value = props.config.checkStrictly;
} else { } else {
const dsId = removeDataSourceFieldPrefix(props.model[0]); const fieldValue = props.model[props.name];
// dataSourceId id config
const rawDsId = props.config.dataSourceId ?? (Array.isArray(fieldValue) ? fieldValue[0] : fieldValue);
const dsId = removeDataSourceFieldPrefix(`${rawDsId}`);
const dataSource = dataSources.value.find((ds) => ds.id === dsId); const dataSource = dataSources.value.find((ds) => ds.id === dsId);
value = props.config.checkStrictly(mForm, { value = props.config.checkStrictly(mForm, {

View File

@ -25,6 +25,7 @@ import {
type GroupListConfig, type GroupListConfig,
MGroupList, MGroupList,
} from '@tmagic/form'; } from '@tmagic/form';
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
import { useServices } from '@editor/hooks/use-services'; import { useServices } from '@editor/hooks/use-services';
import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils'; import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils';
@ -46,9 +47,14 @@ const mForm = inject<FormState | undefined>('mForm');
const parentFields = computed(() => filterFunction<string[]>(mForm, props.config.parentFields, props) || []); const parentFields = computed(() => filterFunction<string[]>(mForm, props.config.parentFields, props) || []);
const resolveFieldPath = (path: string[]) => {
const [id, ...fieldNames] = path;
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`)) : undefined;
return { ds, fieldNames };
};
const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => { const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
const [id, ...fieldNames] = [...parentFields.value, ...v]; const { ds, fieldNames } = resolveFieldPath([...parentFields.value, ...v]);
const ds = dataSourceService.getDataSourceById(id);
const type = getFieldType(ds, fieldNames); const type = getFieldType(ds, fieldNames);
if (type === 'number') { if (type === 'number') {
model.value = Number(model.value); model.value = Number(model.value);
@ -82,14 +88,13 @@ const config = computed<GroupListConfig>(() => ({
? { ? {
type: 'cascader', type: 'cascader',
options: () => { options: () => {
const [dsId, ...keys] = parentFields.value; const { ds, fieldNames } = resolveFieldPath(parentFields.value);
const ds = dataSourceService.getDataSourceById(dsId);
if (!ds) { if (!ds) {
return []; return [];
} }
let fields = ds.fields || []; let fields = ds.fields || [];
keys.forEach((key) => { fieldNames.forEach((key) => {
const field = fields.find((f) => f.name === key); const field = fields.find((f) => f.name === key);
fields = field?.fields || []; fields = field?.fields || [];
}); });
@ -139,8 +144,7 @@ const config = computed<GroupListConfig>(() => ({
{ {
name: 'value', name: 'value',
type: (_mForm, { model }) => { type: (_mForm, { model }) => {
const [id, ...fieldNames] = [...parentFields.value, ...model.field]; const { ds, fieldNames } = resolveFieldPath([...parentFields.value, ...(model.field || [])]);
const ds = dataSourceService.getDataSourceById(id);
const type = getFieldType(ds, fieldNames); const type = getFieldType(ds, fieldNames);
if (type === 'number') { if (type === 'number') {

View File

@ -21,7 +21,7 @@ import { appendValidateSuggestion } from '@tmagic/design';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form'; import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema'; import type { StyleSchema } from '@tmagic/schema';
import { validateDataSourceFieldSelectValue } from '@editor/utils/type-match-rules'; import { validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align'; import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
@ -99,7 +99,7 @@ const formConfig = defineFormConfig([
return callback(); return callback();
} }
const result = validateDataSourceFieldSelectValue( const result = validateDataSourceFieldSelect(
value, value,
{ {
fieldType: 'data-source-field-select', fieldType: 'data-source-field-select',

View File

@ -209,7 +209,6 @@ const validateDataSourceFieldPath = (
path: string[], path: string[],
options: { options: {
dataSourceId?: string; dataSourceId?: string;
valueMode?: 'key' | 'value';
dataSourceFieldType?: DataSourceFieldType[]; dataSourceFieldType?: DataSourceFieldType[];
message?: string; message?: string;
} = {}, } = {},
@ -219,19 +218,11 @@ const validateDataSourceFieldPath = (
return undefined; return undefined;
} }
let dsId = options.dataSourceId; const dsId = options.dataSourceId;
let fieldNames = path;
if (!dsId) { if (!dsId) {
if (!path.length) { // 仅当取值为空数组或首项为空串时命中。表单校验管道里 validateTypeMatch 对空数组会短路,
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion()); // 因此这里只可能来自直接调用 validateDataSourceFieldSelect 的自定义 validator
} return defaultMessage(options.message, '数据源不存在', dataSourceIdSuggestion());
const rawDsId = path[0];
dsId =
options.valueMode === 'key' || !`${rawDsId}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX)
? `${rawDsId}`
: removeDataSourceFieldPrefix(`${rawDsId}`);
fieldNames = path.slice(1);
} }
const ds = findDataSource(`${dsId}`); const ds = findDataSource(`${dsId}`);
@ -239,11 +230,11 @@ const validateDataSourceFieldPath = (
return defaultMessage(options.message, `数据源(${dsId})不存在`, dataSourceIdSuggestion()); return defaultMessage(options.message, `数据源(${dsId})不存在`, dataSourceIdSuggestion());
} }
if (!fieldNames.length) { if (!path.length) {
return undefined; return undefined;
} }
const { field, ok, fields, failedName } = resolveFieldByPath(ds.fields, fieldNames); const { field, ok, fields, failedName } = resolveFieldByPath(ds.fields, path);
if (!ok) { if (!ok) {
return defaultMessage( return defaultMessage(
options.message, options.message,
@ -258,11 +249,15 @@ const validateDataSourceFieldPath = (
} }
const leafType = field?.type || 'any'; const leafType = field?.type || 'any';
if (leafType === 'any' || allowedTypes.includes(leafType)) { if (leafType !== 'any' && !allowedTypes.includes(leafType)) {
return undefined; const fieldName = field?.name || path[path.length - 1];
}
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion()); // 文案已点明当前字段类型与要求类型,无需再列同级可选字段
return defaultMessage(
options.message,
`请选择类型为${allowedTypes.join('或')}的字段,字段(${fieldName})的类型为${leafType}`,
);
}
}; };
const validateDataSourceMethodTuple = (value: any, message?: string): string | undefined => { const validateDataSourceMethodTuple = (value: any, message?: string): string | undefined => {
@ -367,7 +362,7 @@ const validateCondOpSelect: TypeMatchValidator = (value, { message, props }) =>
const parentFields = props.config?.parentFields || []; const parentFields = props.config?.parentFields || [];
const fieldPath = Array.isArray(props.model?.field) ? props.model.field : []; const fieldPath = Array.isArray(props.model?.field) ? props.model.field : [];
const [id, ...fieldNames] = [...parentFields, ...fieldPath]; const [id, ...fieldNames] = [...parentFields, ...fieldPath];
const ds = id ? findDataSource(`${id}`) : undefined; const ds = id ? findDataSource(removeDataSourceFieldPrefix(`${id}`)) : undefined;
const fieldType = getFieldType(ds, fieldNames); const fieldType = getFieldType(ds, fieldNames);
const allowed = getCondOpsByFieldType(fieldType); const allowed = getCondOpsByFieldType(fieldType);
@ -464,7 +459,7 @@ export type ValidateDataSourceFieldSelectOptions = {
/** /**
* data-source-field-select typeMatch rules.validator * data-source-field-select typeMatch rules.validator
*/ */
export const validateDataSourceFieldSelectValue = ( export const validateDataSourceFieldSelect = (
value: any, value: any,
context: TypeMatchValidateContext, context: TypeMatchValidateContext,
options?: ValidateDataSourceFieldSelectOptions, options?: ValidateDataSourceFieldSelectOptions,
@ -492,17 +487,22 @@ export const validateDataSourceFieldSelectValue = (
return defaultMessage(message, `${value}类型应为字符串数组`, dataSourceFieldPathSuggestion()); return defaultMessage(message, `${value}类型应为字符串数组`, dataSourceFieldPathSuggestion());
} }
return validateDataSourceFieldPath(value, { // 未指定 dataSourceId 时,路径首项为数据源 id其余为字段名。
dataSourceId: config.dataSourceId, // value 模式的首项带 ds-field:: 前缀、key 模式不带removeDataSourceFieldPrefix 对后者是幂等的,无需分支。
valueMode: config.value, let dataSourceId = config.dataSourceId ? `${config.dataSourceId}` : undefined;
let fieldNames = value;
if (!dataSourceId) {
dataSourceId = value.length ? removeDataSourceFieldPrefix(value[0]) : undefined;
fieldNames = value.slice(1);
}
return validateDataSourceFieldPath(fieldNames, {
dataSourceId,
dataSourceFieldType: config.dataSourceFieldType, dataSourceFieldType: config.dataSourceFieldType,
message, message,
}); });
}; };
const validateDataSourceFieldSelect: TypeMatchValidator = (value, context) =>
validateDataSourceFieldSelectValue(value, context);
const validateDataSourceSelect: TypeMatchValidator = (value, { message, props }) => { const validateDataSourceSelect: TypeMatchValidator = (value, { message, props }) => {
const config = props.config || {}; const config = props.config || {};
const dataSources = getDataSources(); const dataSources = getDataSources();
@ -678,7 +678,8 @@ export const editorTypeMatchRules: Record<string, TypeMatchValidator> = {
'ui-select': validateUiSelect, 'ui-select': validateUiSelect,
'data-source-input': validateDataSourceInput, 'data-source-input': validateDataSourceInput,
'data-source-method-select': validateDataSourceMethodSelect, 'data-source-method-select': validateDataSourceMethodSelect,
'data-source-field-select': validateDataSourceFieldSelect, /** 注册进 typeMatch 规则表的入口,收窄为 TypeMatchValidator避免调用方误传第三个参数 */
'data-source-field-select': (value, context) => validateDataSourceFieldSelect(value, context),
'data-source-select': validateDataSourceSelect, 'data-source-select': validateDataSourceSelect,
'code-select': validateCodeSelect, 'code-select': validateCodeSelect,
'data-source-fields': validateDataSourceFields, 'data-source-fields': validateDataSourceFields,

View File

@ -100,4 +100,14 @@ describe('CondOpSelect', () => {
await wrapper.findComponent({ name: 'TMagicSelect' }).vm.$emit('change', '='); await wrapper.findComponent({ name: 'TMagicSelect' }).vm.$emit('change', '=');
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('='); expect(wrapper.emitted('change')?.[0]?.[0]).toBe('=');
}); });
test('字段路径带 ds-field:: 前缀时仍按数据源 id 查询', () => {
(getFieldType as any).mockReturnValue('number');
mount(CondOpSelect, {
props: baseProps({
model: { field: ['ds-field::ds1', 'a'], op: '' },
}) as any,
});
expect(dataSourceService.getDataSourceById).toHaveBeenCalledWith('ds1');
});
}); });

View File

@ -324,4 +324,17 @@ describe('DataSourceFieldSelect Index', () => {
await toggleBtn.trigger('click'); await toggleBtn.trigger('click');
expect(wrapper.findAll('.fake-cascader').length).toBeGreaterThan(0); expect(wrapper.findAll('.fake-cascader').length).toBeGreaterThan(0);
}); });
test('checkStrictly 为函数时从当前字段值解析数据源', () => {
const checkStrictly = vi.fn(() => true);
mount(DSFSIndex, {
props: {
config: { checkStrictly },
model: { v: ['ds-ds1', 'a'] },
name: 'v',
} as any,
});
expect(checkStrictly).toHaveBeenCalled();
expect(checkStrictly.mock.calls[0][1].dataSource?.id).toBe('ds1');
});
}); });

View File

@ -21,7 +21,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { HookCodeType, HookType, NodeType } from '@tmagic/core'; import { HookCodeType, HookType, NodeType } from '@tmagic/core';
import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, DATA_SOURCE_SET_DATA_METHOD_NAME } from '@tmagic/utils'; import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, DATA_SOURCE_SET_DATA_METHOD_NAME } from '@tmagic/utils';
import { ALL_COND_OPS, editorTypeMatchRules, validateDataSourceFieldSelectValue } from '@editor/utils/type-match-rules'; import { ALL_COND_OPS, editorTypeMatchRules, validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
const codeDslState = vi.hoisted(() => ({ value: null as Record<string, any> | null })); const codeDslState = vi.hoisted(() => ({ value: null as Record<string, any> | null }));
const dataSourcesState = vi.hoisted(() => ({ value: [] as any[] })); const dataSourcesState = vi.hoisted(() => ({ value: [] as any[] }));
@ -126,6 +126,9 @@ describe('editorTypeMatchRules', () => {
}, },
]; ];
expect(run('cond-op-select', '>', {}, { field: ['ds1', 'age'] })).toBeUndefined(); expect(run('cond-op-select', '>', {}, { field: ['ds1', 'age'] })).toBeUndefined();
expect(
run('cond-op-select', '>', {}, { field: [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'age'] }),
).toBeUndefined();
expect(firstLine(run('cond-op-select', 'include', {}, { field: ['ds1', 'age'] }))).toBe('include 不在可选项中'); expect(firstLine(run('cond-op-select', 'include', {}, { field: ['ds1', 'age'] }))).toBe('include 不在可选项中');
expect(run('cond-op-select', 'is', {}, { field: ['ds1', 'flag'] })).toBeUndefined(); expect(run('cond-op-select', 'is', {}, { field: ['ds1', 'flag'] })).toBeUndefined();
// 未知类型与 UI 默认选项对齐:不含 boolean ops // 未知类型与 UI 默认选项对齐:不含 boolean ops
@ -234,6 +237,11 @@ describe('editorTypeMatchRules', () => {
expect(run('data-source-field-select', ['ds1', 'title'], { value: 'key' })).toBeUndefined(); expect(run('data-source-field-select', ['ds1', 'title'], { value: 'key' })).toBeUndefined();
expect(run('data-source-field-select', [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'title'])).toBeUndefined(); expect(run('data-source-field-select', [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'title'])).toBeUndefined();
// 未指定 dataSourceId 且路径为空:缺少数据源 id
expect(firstLine(run('data-source-field-select', [], { value: 'key' }))).toBe('数据源不存在');
// 已有 dataSourceId 时,空字段路径视为只选了数据源,不报错
expect(run('data-source-field-select', [], { dataSourceId: 'ds1' })).toBeUndefined();
expect(run('data-source-field-select', ['ds1'], { value: 'key' })).toBeUndefined();
// 数据源不存在时直出具体的数据源 id // 数据源不存在时直出具体的数据源 id
expect(firstLine(run('data-source-field-select', ['missing', 'title'], { value: 'key' }))).toBe( expect(firstLine(run('data-source-field-select', ['missing', 'title'], { value: 'key' }))).toBe(
'数据源(missing)不存在', '数据源(missing)不存在',
@ -254,23 +262,55 @@ describe('editorTypeMatchRules', () => {
); );
// 父字段无子字段时无建议 // 父字段无子字段时无建议
expect(run('data-source-field-select', ['ds1', 'title', 'x'], { value: 'key' })).toBe('数据源字段(x)不存在'); expect(run('data-source-field-select', ['ds1', 'title', 'x'], { value: 'key' })).toBe('数据源字段(x)不存在');
// 类型不匹配时,文案直出当前字段与其类型,不追加建议
expect( expect(
firstLine( run('data-source-field-select', ['title'], {
run('data-source-field-select', ['title'], { dataSourceId: 'ds1',
dataSourceId: 'ds1', dataSourceFieldType: ['number'],
dataSourceFieldType: ['number'], }),
}), ).toBe('请选择类型为number的字段字段(title)的类型为string');
),
).toBe('值不在可选项中');
expect( expect(
run('data-source-field-select', ['obj', 'a'], { run('data-source-field-select', ['obj', 'a'], {
dataSourceId: 'ds1', dataSourceId: 'ds1',
dataSourceFieldType: ['number'], dataSourceFieldType: ['number'],
}), }),
).toBeUndefined(); ).toBeUndefined();
expect(
run('data-source-field-select', ['obj', 'a'], {
dataSourceId: 'ds1',
dataSourceFieldType: ['string'],
}),
).toBe('请选择类型为string的字段字段(a)的类型为number');
// 有 fieldConfig、未声明 value/dataSourceId 时,仍按取值中的 ds-field:: 前缀解析数据源路径
dataSourcesState.value = [
{
id: 'ds_4554243a',
type: 'base',
fields: [{ name: 'marqueeModId', type: 'string' }],
methods: [],
},
];
expect(
run('data-source-field-select', [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds_4554243a`, 'marqueeModId'], {
name: 'modId',
checkStrictly: false,
dataSourceFieldType: ['string'],
fieldConfig: { type: 'mod-select', modType: 100119 },
}),
).toBeUndefined();
expect(
firstLine(
run('data-source-field-select', [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds_4554243a`, 'missing'], {
name: 'modId',
dataSourceFieldType: ['string'],
fieldConfig: { type: 'mod-select', modType: 100119 },
}),
),
).toBe('数据源字段(missing)不存在');
}); });
test('validateDataSourceFieldSelectValue 支持自定义 plain 值校验并保留数据源路径校验', () => { test('validateDataSourceFieldSelect 支持自定义 plain 值校验并保留数据源路径校验', () => {
const config = { const config = {
name: 'fontWeight', name: 'fontWeight',
type: 'data-source-field-select', type: 'data-source-field-select',
@ -287,9 +327,9 @@ describe('editorTypeMatchRules', () => {
}, },
}; };
expect(validateDataSourceFieldSelectValue(700, context, allowStringOrNumber)).toBeUndefined(); expect(validateDataSourceFieldSelect(700, context, allowStringOrNumber)).toBeUndefined();
expect(validateDataSourceFieldSelectValue('bold', context, allowStringOrNumber)).toBeUndefined(); expect(validateDataSourceFieldSelect('bold', context, allowStringOrNumber)).toBeUndefined();
expect(firstLine(validateDataSourceFieldSelectValue({ a: 1 }, context, allowStringOrNumber))).toBe( expect(firstLine(validateDataSourceFieldSelect({ a: 1 }, context, allowStringOrNumber))).toBe(
'fontWeight 类型应为字符串或数字', 'fontWeight 类型应为字符串或数字',
); );
@ -306,7 +346,7 @@ describe('editorTypeMatchRules', () => {
]; ];
expect( expect(
validateDataSourceFieldSelectValue( validateDataSourceFieldSelect(
[`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'weight'], [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'weight'],
context, context,
allowStringOrNumber, allowStringOrNumber,
@ -314,7 +354,7 @@ describe('editorTypeMatchRules', () => {
).toBeUndefined(); ).toBeUndefined();
expect( expect(
firstLine( firstLine(
validateDataSourceFieldSelectValue( validateDataSourceFieldSelect(
[`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'missing'], [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'missing'],
context, context,
allowStringOrNumber, allowStringOrNumber,