diff --git a/packages/editor/src/fields/CondOpSelect.vue b/packages/editor/src/fields/CondOpSelect.vue index bb7585bf..8bb059ce 100644 --- a/packages/editor/src/fields/CondOpSelect.vue +++ b/packages/editor/src/fields/CondOpSelect.vue @@ -33,7 +33,7 @@ import { getDesignConfig, TMagicSelect } from '@tmagic/design'; import type { CondOpSelectConfig, FieldProps } from '@tmagic/form'; import { useServices } from '@editor/hooks/use-services'; -import { arrayOptions, eqOptions, numberOptions } from '@editor/utils'; +import { arrayOptions, eqOptions, getFieldType, numberOptions } from '@editor/utils'; defineOptions({ name: 'MFieldsCondOpSelect', @@ -54,19 +54,13 @@ const options = computed(() => { const ds = dataSourceService.getDataSourceById(id); - let fields = ds?.fields || []; - let type = ''; - (fieldNames || []).forEach((fieldName: string) => { - const field = fields.find((f) => f.name === fieldName); - fields = field?.fields || []; - type = field?.type || ''; - }); + const type = getFieldType(ds, fieldNames); if (type === 'array') { return arrayOptions; } - if (type === 'boolean') { + if (type === 'boolean' || type === 'null') { return [ { text: '是', value: 'is' }, { text: '不是', value: 'not' }, diff --git a/packages/editor/src/fields/DataSourceFieldSelect/FieldSelect.vue b/packages/editor/src/fields/DataSourceFieldSelect/FieldSelect.vue index ec69245b..940d1d76 100644 --- a/packages/editor/src/fields/DataSourceFieldSelect/FieldSelect.vue +++ b/packages/editor/src/fields/DataSourceFieldSelect/FieldSelect.vue @@ -72,12 +72,12 @@ import { Edit, View } from '@element-plus/icons-vue'; import type { DataSourceFieldType } from '@tmagic/core'; import { getDesignConfig, TMagicButton, TMagicCascader, TMagicSelect, TMagicTooltip } from '@tmagic/design'; import { type FilterFunction, filterFunction, type FormState, type SelectOption } from '@tmagic/form'; -import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX } from '@tmagic/utils'; +import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, removeDataSourceFieldPrefix } from '@tmagic/utils'; import MIcon from '@editor/components/Icon.vue'; import { useServices } from '@editor/hooks/use-services'; import { type EventBus, SideItemKey } from '@editor/type'; -import { getCascaderOptionsFromFields, removeDataSourceFieldPrefix } from '@editor/utils'; +import { getCascaderOptionsFromFields } from '@editor/utils'; const props = defineProps<{ /** diff --git a/packages/editor/src/fields/DataSourceFieldSelect/Index.vue b/packages/editor/src/fields/DataSourceFieldSelect/Index.vue index 55659d8d..264f36db 100644 --- a/packages/editor/src/fields/DataSourceFieldSelect/Index.vue +++ b/packages/editor/src/fields/DataSourceFieldSelect/Index.vue @@ -48,11 +48,10 @@ import { Coin } from '@element-plus/icons-vue'; import { DataSchema } from '@tmagic/core'; import { TMagicButton, tMagicMessage, TMagicTooltip } from '@tmagic/design'; import type { ContainerChangeEventData, DataSourceFieldSelectConfig, FieldProps, FormState } from '@tmagic/form'; -import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX } from '@tmagic/utils'; +import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, removeDataSourceFieldPrefix } from '@tmagic/utils'; import MIcon from '@editor/components/Icon.vue'; import { useServices } from '@editor/hooks/use-services'; -import { removeDataSourceFieldPrefix } from '@editor/utils'; import FieldSelect from './FieldSelect.vue'; diff --git a/packages/editor/src/fields/DisplayConds.vue b/packages/editor/src/fields/DisplayConds.vue index be890be7..865a8701 100644 --- a/packages/editor/src/fields/DisplayConds.vue +++ b/packages/editor/src/fields/DisplayConds.vue @@ -27,7 +27,7 @@ import { } from '@tmagic/form'; import { useServices } from '@editor/hooks/use-services'; -import { getCascaderOptionsFromFields } from '@editor/utils'; +import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils'; defineOptions({ name: 'm-fields-display-conds', @@ -46,6 +46,22 @@ const mForm = inject('mForm'); const parentFields = computed(() => filterFunction(mForm, props.config.parentFields, props) || []); +const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record }) => { + const [id, ...fieldNames] = [...parentFields.value, ...v]; + const ds = dataSourceService.getDataSourceById(id); + const type = getFieldType(ds, fieldNames); + if (type === 'number') { + model.value = Number(model.value); + } else if (type === 'boolean') { + model.value = Boolean(model.value); + } else if (type === 'null') { + model.value = null; + } else { + model.value = `${model.value}`; + } + return v; +}; + const config = computed(() => ({ type: 'groupList', name: props.name, @@ -80,6 +96,7 @@ const config = computed(() => ({ value: 'key', label: '字段', checkStrictly: false, + onChange: fieldOnChange, } : { type: 'data-source-field-select', @@ -88,6 +105,7 @@ const config = computed(() => ({ label: '字段', checkStrictly: false, dataSourceFieldType: ['string', 'number', 'boolean', 'any'], + onChange: fieldOnChange, }, { type: 'cond-op-select', @@ -102,18 +120,10 @@ const config = computed(() => ({ items: [ { name: 'value', - type: (mForm, { model }) => { + type: (_mForm, { model }) => { const [id, ...fieldNames] = [...parentFields.value, ...model.field]; - const ds = dataSourceService.getDataSourceById(id); - - let fields = ds?.fields || []; - let type = ''; - (fieldNames || []).forEach((fieldName: string) => { - const field = fields.find((f) => f.name === fieldName); - fields = field?.fields || []; - type = field?.type || ''; - }); + const type = getFieldType(ds, fieldNames); if (type === 'number') { return 'number'; @@ -123,13 +133,23 @@ const config = computed(() => ({ return 'select'; } + if (type === 'null') { + return 'display'; + } + return 'text'; }, options: [ { text: 'true', value: true }, { text: 'false', value: false }, ], - display: (vm, { model }) => !['between', 'not_between'].includes(model.op), + display: (_mForm, { model }) => !['between', 'not_between'].includes(model.op), + displayText: (_mForm: FormState | undefined, { model }: any) => { + if (model.value === null) { + return 'null'; + } + return model.value; + }, }, { name: 'range', diff --git a/packages/editor/src/utils/data-source/index.ts b/packages/editor/src/utils/data-source/index.ts index 450f039f..941f7b1b 100644 --- a/packages/editor/src/utils/data-source/index.ts +++ b/packages/editor/src/utils/data-source/index.ts @@ -1,11 +1,6 @@ import { DataSchema, DataSourceFieldType, DataSourceSchema } from '@tmagic/core'; import { CascaderOption, FormConfig, FormState } from '@tmagic/form'; -import { - DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, - dataSourceTemplateRegExp, - getKeysArray, - isNumber, -} from '@tmagic/utils'; +import { dataSourceTemplateRegExp, getKeysArray, isNumber } from '@tmagic/utils'; import BaseFormConfig from './formConfigs/base'; import HttpFormConfig from './formConfigs/http'; @@ -211,41 +206,44 @@ export const getCascaderOptionsFromFields = ( fields: DataSchema[] = [], dataSourceFieldType: DataSourceFieldType[] = ['any'], ): CascaderOption[] => { - const child: CascaderOption[] = []; - fields.forEach((field) => { - if (!dataSourceFieldType.length) { - dataSourceFieldType.push('any'); - } + const typeSet = new Set(dataSourceFieldType.length ? dataSourceFieldType : ['any']); + const includesAny = typeSet.has('any'); - let children: CascaderOption[] = []; - if (field.type && ['any', 'array', 'object'].includes(field.type)) { - children = getCascaderOptionsFromFields(field.fields, dataSourceFieldType); - } - - const item = { - label: `${field.title || field.name}(${field.type})`, - value: field.name, - children, - }; + const result: CascaderOption[] = []; + for (const field of fields) { const fieldType = field.type || 'any'; - if (dataSourceFieldType.includes('any') || dataSourceFieldType.includes(fieldType)) { - child.push(item); - return; - } + const isContainerType = fieldType === 'any' || fieldType === 'array' || fieldType === 'object'; - if (!dataSourceFieldType.includes(fieldType) && !['array', 'object', 'any'].includes(fieldType)) { - return; - } + const children = isContainerType ? getCascaderOptionsFromFields(field.fields, dataSourceFieldType) : []; - if (!children.length && ['object', 'array', 'any'].includes(field.type || '')) { - return; - } + const matchesType = includesAny || typeSet.has(fieldType); - child.push(item); - }); - return child; + if (matchesType || (isContainerType && children.length)) { + result.push({ + label: `${field.title || field.name}(${field.type})`, + value: field.name, + children, + }); + } + } + + return result; }; -export const removeDataSourceFieldPrefix = (id?: string) => - id?.replace(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, '') || ''; +export const getFieldType = (ds: DataSourceSchema | undefined, fieldNames: string[]) => { + let fields = ds?.fields; + let type = ''; + + for (const fieldName of fieldNames) { + if (!fields?.length) return ''; + + const field = fields.find((f) => f.name === fieldName); + if (!field) return ''; + + type = field.type || ''; + fields = field.fields; + } + + return type; +}; diff --git a/packages/editor/tests/unit/utils/data-source.spec.ts b/packages/editor/tests/unit/utils/data-source.spec.ts new file mode 100644 index 00000000..875cc240 --- /dev/null +++ b/packages/editor/tests/unit/utils/data-source.spec.ts @@ -0,0 +1,293 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { describe, expect, test } from 'vitest'; + +import type { DataSchema, DataSourceSchema } from '@tmagic/core'; + +import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils/data-source'; + +describe('getFieldType', () => { + test('返回空字符串当ds为undefined', () => { + const type = getFieldType(undefined, ['field1']); + expect(type).toBe(''); + }); + + test('返回空字符串当ds.fields为空', () => { + const ds: DataSourceSchema = { id: 'ds1', type: 'base', fields: [], methods: [], events: [] }; + const type = getFieldType(ds, ['field1']); + expect(type).toBe(''); + }); + + test('返回空字符串当fieldNames为空数组', () => { + const ds: DataSourceSchema = { + id: 'ds1', + type: 'base', + fields: [{ name: 'field1', type: 'string' }], + methods: [], + events: [], + }; + const type = getFieldType(ds, []); + expect(type).toBe(''); + }); + + test('返回一级字段类型', () => { + const ds: DataSourceSchema = { + id: 'ds1', + type: 'base', + fields: [ + { name: 'field1', type: 'string' }, + { name: 'field2', type: 'number' }, + { name: 'field3', type: 'boolean' }, + ], + methods: [], + events: [], + }; + + expect(getFieldType(ds, ['field1'])).toBe('string'); + expect(getFieldType(ds, ['field2'])).toBe('number'); + expect(getFieldType(ds, ['field3'])).toBe('boolean'); + }); + + test('返回嵌套字段类型', () => { + const ds: DataSourceSchema = { + id: 'ds1', + type: 'base', + fields: [ + { + name: 'obj', + type: 'object', + fields: [ + { name: 'nested1', type: 'string' }, + { name: 'nested2', type: 'number' }, + ], + }, + ], + methods: [], + events: [], + }; + + expect(getFieldType(ds, ['obj', 'nested1'])).toBe('string'); + expect(getFieldType(ds, ['obj', 'nested2'])).toBe('number'); + }); + + test('返回深层嵌套字段类型', () => { + const ds: DataSourceSchema = { + id: 'ds1', + type: 'base', + fields: [ + { + name: 'level1', + type: 'object', + fields: [ + { + name: 'level2', + type: 'object', + fields: [{ name: 'level3', type: 'boolean' }], + }, + ], + }, + ], + methods: [], + events: [], + }; + + expect(getFieldType(ds, ['level1', 'level2', 'level3'])).toBe('boolean'); + }); + + test('返回空字符串当字段不存在', () => { + const ds: DataSourceSchema = { + id: 'ds1', + type: 'base', + fields: [{ name: 'field1', type: 'string' }], + methods: [], + events: [], + }; + + expect(getFieldType(ds, ['nonexistent'])).toBe(''); + }); + + test('返回空字符串当嵌套字段不存在', () => { + const ds: DataSourceSchema = { + id: 'ds1', + type: 'base', + fields: [ + { + name: 'obj', + type: 'object', + fields: [{ name: 'nested1', type: 'string' }], + }, + ], + methods: [], + events: [], + }; + + expect(getFieldType(ds, ['obj', 'nonexistent'])).toBe(''); + }); + + test('返回空字符串当字段type未定义', () => { + const ds: DataSourceSchema = { + id: 'ds1', + type: 'base', + fields: [{ name: 'field1' }], + methods: [], + events: [], + }; + + expect(getFieldType(ds, ['field1'])).toBe(''); + }); +}); + +describe('getCascaderOptionsFromFields', () => { + test('返回空数组当fields为空', () => { + const result = getCascaderOptionsFromFields([]); + expect(result).toEqual([]); + }); + + test('返回空数组当fields为undefined', () => { + const result = getCascaderOptionsFromFields(undefined); + expect(result).toEqual([]); + }); + + test('返回基本字段选项(默认any类型过滤)', () => { + const fields: DataSchema[] = [ + { name: 'field1', type: 'string' }, + { name: 'field2', type: 'number' }, + ]; + const result = getCascaderOptionsFromFields(fields); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + label: 'field1(string)', + value: 'field1', + children: [], + }); + expect(result[1]).toEqual({ + label: 'field2(number)', + value: 'field2', + children: [], + }); + }); + + test('使用title作为label(如果存在)', () => { + const fields: DataSchema[] = [{ name: 'field1', title: '字段1', type: 'string' }]; + const result = getCascaderOptionsFromFields(fields); + + expect(result[0].label).toBe('字段1(string)'); + }); + + test('按类型过滤字段', () => { + const fields: DataSchema[] = [ + { name: 'field1', type: 'string' }, + { name: 'field2', type: 'number' }, + { name: 'field3', type: 'boolean' }, + ]; + const result = getCascaderOptionsFromFields(fields, ['string', 'number']); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.value)).toEqual(['field1', 'field2']); + }); + + test('递归处理嵌套object字段', () => { + const fields: DataSchema[] = [ + { + name: 'obj', + type: 'object', + fields: [ + { name: 'nested1', type: 'string' }, + { name: 'nested2', type: 'number' }, + ], + }, + ]; + const result = getCascaderOptionsFromFields(fields); + + expect(result).toHaveLength(1); + expect(result[0].children).toHaveLength(2); + expect(result[0].children![0]).toEqual({ + label: 'nested1(string)', + value: 'nested1', + children: [], + }); + }); + + test('递归处理嵌套array字段', () => { + const fields: DataSchema[] = [ + { + name: 'arr', + type: 'array', + fields: [{ name: 'item', type: 'string' }], + }, + ]; + const result = getCascaderOptionsFromFields(fields); + + expect(result).toHaveLength(1); + expect(result[0].children).toHaveLength(1); + }); + + test('过滤掉不匹配类型且无子项的object/array字段', () => { + const fields: DataSchema[] = [ + { name: 'obj', type: 'object', fields: [] }, + { name: 'str', type: 'string' }, + ]; + const result = getCascaderOptionsFromFields(fields, ['string']); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe('str'); + }); + + test('保留有匹配子项的object字段', () => { + const fields: DataSchema[] = [ + { + name: 'obj', + type: 'object', + fields: [{ name: 'nested', type: 'string' }], + }, + ]; + const result = getCascaderOptionsFromFields(fields, ['string']); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe('obj'); + expect(result[0].children).toHaveLength(1); + }); + + test('深层嵌套字段', () => { + const fields: DataSchema[] = [ + { + name: 'level1', + type: 'object', + fields: [ + { + name: 'level2', + type: 'object', + fields: [{ name: 'level3', type: 'string' }], + }, + ], + }, + ]; + const result = getCascaderOptionsFromFields(fields); + + expect(result[0].children![0].children![0].value).toBe('level3'); + }); + + test('字段type未定义时视为any', () => { + const fields: DataSchema[] = [{ name: 'field1' }]; + const result = getCascaderOptionsFromFields(fields); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('field1(undefined)'); + }); +}); diff --git a/packages/form-schema/src/base.ts b/packages/form-schema/src/base.ts index 021fc935..3d464c85 100644 --- a/packages/form-schema/src/base.ts +++ b/packages/form-schema/src/base.ts @@ -344,6 +344,7 @@ export interface HtmlField extends FormItem { export interface DisplayConfig extends FormItem { type: 'display'; initValue?: string | number | boolean; + displayText: FilterFunction | string; } /** 文本输入框 */ diff --git a/packages/form/src/fields/Display.vue b/packages/form/src/fields/Display.vue index c9bb96ae..4c890e5f 100644 --- a/packages/form/src/fields/Display.vue +++ b/packages/form/src/fields/Display.vue @@ -1,9 +1,12 @@ diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 4dc19853..070035c0 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -635,3 +635,6 @@ export const isValueIncludeDataSource = (value: any) => { } return false; }; + +export const removeDataSourceFieldPrefix = (id?: string) => + id?.replace(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, '') || ''; diff --git a/react-components/iterator-container/src/formConfig.ts b/react-components/iterator-container/src/formConfig.ts index e8d585ef..2533a60f 100644 --- a/react-components/iterator-container/src/formConfig.ts +++ b/react-components/iterator-container/src/formConfig.ts @@ -15,7 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, NODE_CONDS_KEY } from '@tmagic/core'; +import { NODE_CONDS_KEY, removeDataSourceFieldPrefix } from '@tmagic/core'; import { defineFormConfig } from '@tmagic/form-schema'; export default defineFormConfig([ @@ -29,7 +29,7 @@ export default defineFormConfig([ onChange: (_vm: any, v: string[] = [], { setModel }: any) => { if (Array.isArray(v) && v.length > 1) { const [dsId, ...keys] = v; - setModel('dsField', [dsId.replace(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, ''), ...keys]); + setModel('dsField', [removeDataSourceFieldPrefix(dsId), ...keys]); } else { setModel('dsField', []); } diff --git a/vue-components/iterator-container/src/formConfig.ts b/vue-components/iterator-container/src/formConfig.ts index 2733b090..9f759664 100644 --- a/vue-components/iterator-container/src/formConfig.ts +++ b/vue-components/iterator-container/src/formConfig.ts @@ -15,7 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, NODE_CONDS_KEY } from '@tmagic/core'; +import { NODE_CONDS_KEY, removeDataSourceFieldPrefix } from '@tmagic/core'; import { defineFormConfig } from '@tmagic/form-schema'; export default defineFormConfig([ @@ -34,7 +34,7 @@ export default defineFormConfig([ onChange: (_vm: any, v: string[] = [], { setModel }: any) => { if (Array.isArray(v) && v.length > 1) { const [dsId, ...keys] = v; - setModel('dsField', [dsId.replace(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, ''), ...keys]); + setModel('dsField', [removeDataSourceFieldPrefix(dsId), ...keys]); } else { setModel('dsField', []); }