fix(form,editor): 完善表单校验与样式属性匹配

This commit is contained in:
roymondchen 2026-07-20 19:09:55 +08:00
parent 932974f0fa
commit ef66e8a598
32 changed files with 1256 additions and 568 deletions

View File

@ -8,6 +8,10 @@
<slot></slot>
<div v-if="adapterType === 'element-plus' && extra" v-html="extra" class="m-form-tip"></div>
</template>
<template v-if="adapterType === 'element-plus'" #error="{ error }">
<div class="el-form-item__error">{{ resolveErrorText(error) }}</div>
</template>
</component>
</template>
@ -15,6 +19,7 @@
import { computed } from 'vue';
import { getDesignConfig } from './config';
import { stripValidateSuggestion } from './formValidateMessage';
import type { FormItemProps } from './types';
defineOptions({
@ -33,4 +38,10 @@ const uiProps = computed<FormItemProps>(() => {
const { extra, ...rest } = ui?.props(props) || props;
return rest;
});
/**
* 校验错误文案中修改建议仅用于错误汇总展示
* form-item 行内错误只展示主错误描述不展示修改建议
*/
const resolveErrorText = (error?: string) => stripValidateSuggestion(error);
</script>

View File

@ -0,0 +1,47 @@
/*
* 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.
*/
/**
*
*
* typeMatch
* `主错误描述${VALIDATE_SUGGESTION_SEPARATOR}修改建议`
* - form-item tooltip
* -
*
* /
* / helper
*/
export const VALIDATE_SUGGESTION_SEPARATOR = '\n\n';
/**
*
*
* @param message
* @param suggestion
*/
export const appendValidateSuggestion = (message: string, suggestion?: string): string =>
suggestion ? `${message}${VALIDATE_SUGGESTION_SEPARATOR}${suggestion}` : message;
/**
*
*
* @param text `主错误描述\n\n修改建议`
*/
export const stripValidateSuggestion = (text?: string): string =>
String(text ?? '').split(VALIDATE_SUGGESTION_SEPARATOR)[0];

View File

@ -8,6 +8,7 @@ import './theme/index.scss';
export * from './types';
export * from './config';
export * from './formValidateMessage';
export { default as TMagicAutocomplete } from './Autocomplete.vue';
export { default as TMagicBadge } from './Badge.vue';

View File

@ -7,6 +7,7 @@
v-if="item.component"
:is="item.component"
:values="model[name]"
:prop="prop ? `${prop}.${name}` : name"
:last-values="lastValues?.[name]"
:is-compare="isCompare"
:size="size"
@ -35,7 +36,7 @@ defineOptions({
name: 'MFieldsStyleSetter',
});
const props = defineProps<FieldProps<StyleSchema>>();
defineProps<FieldProps<StyleSchema>>();
const emit = defineEmits<{
change: [v: any, eventData: ContainerChangeEventData];
@ -77,13 +78,6 @@ const collapseValue = shallowRef(
);
const change = (v: any, eventData: ContainerChangeEventData) => {
eventData.changeRecords?.forEach((record) => {
if (props.prop) {
record.propPath = `${props.prop}.${record.propPath}`;
} else if (props.name) {
record.propPath = `${props.name}.${record.propPath}`;
}
});
emit('change', v, eventData);
};

View File

@ -1,6 +1,9 @@
<template>
<MContainer
:config="config"
v-for="item in formConfig"
:prop="prop"
:key="item.name"
:config="item"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
@ -14,7 +17,7 @@
<script lang="ts" setup>
import { markRaw } from 'vue';
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import BackgroundPosition from '../components/BackgroundPosition.vue';
@ -26,6 +29,7 @@ defineProps<{
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
prop?: string;
}>();
const emit = defineEmits<{
@ -33,60 +37,95 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const config = defineFormItem({
items: [
{
name: 'backgroundColor',
text: '背景色',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
const formConfig = defineFormConfig([
{
name: 'backgroundColor',
text: '背景色',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
},
{
name: 'backgroundImage',
text: '背景图',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'img-upload',
} as any,
},
{
name: 'backgroundSize',
text: '背景尺寸',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'auto', text: '默认', tooltip: '默认 auto' },
{ value: 'contain', text: '等比填充', tooltip: '等比填充 contain' },
{ value: 'cover', text: '等比覆盖', tooltip: '等比覆盖 cover' },
],
rules: [
{
typeMatch: false,
},
},
{
name: 'backgroundImage',
text: '背景图',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'img-upload',
} as any,
},
{
name: 'backgroundSize',
text: '背景尺寸',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'auto', text: '默认', tooltip: '默认 auto' },
{ value: 'contain', text: '等比填充', tooltip: '等比填充 contain' },
{ value: 'cover', text: '等比覆盖', tooltip: '等比覆盖 cover' },
],
},
{
name: 'backgroundRepeat',
text: '重复显示',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'repeat', icon: markRaw(BackgroundRepeat), tooltip: '垂直和水平方向重复 repeat' },
{ value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' },
{ value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' },
{ value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
],
},
{
name: 'backgroundPosition',
text: '背景定位',
type: 'component',
component: BackgroundPosition,
labelWidth: '68px',
},
],
});
{
validator: ({ value, callback }) => {
if (value === '' || value === null || value === undefined) {
return callback();
}
const keywords = ['auto', 'cover', 'contain', 'inherit', 'initial', 'revert', 'unset'];
// /
const lengthPercent = /^-?\d+(\.\d+)?(px|em|rem|ex|ch|vw|vh|vmin|vmax|cm|mm|in|pt|pc|%)$/;
const singleValue = (v: string) => keywords.includes(v) || lengthPercent.test(v);
const str = String(value).trim();
const parts = str.split(/\s+/);
// cover / contain
if (parts.length > 1 && (parts.includes('cover') || parts.includes('contain'))) {
return callback('cover/contain 不能与其他值组合');
}
//
if (parts.length > 2) {
return callback('backgroundSize 最多支持两个值');
}
// auto /
if (parts.every((part) => singleValue(part))) {
return callback();
}
return callback('backgroundSize 值不合法');
},
},
],
},
{
name: 'backgroundRepeat',
text: '重复显示',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'repeat', icon: markRaw(BackgroundRepeat), tooltip: '垂直和水平方向重复 repeat' },
{ value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' },
{ value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' },
{ value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
],
},
{
name: 'backgroundPosition',
text: '背景定位',
type: 'component',
component: BackgroundPosition,
labelWidth: '68px',
},
]);
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -1,5 +1,6 @@
<template>
<MContainer
:prop="prop"
:config="config"
:model="values"
:last-values="lastValues"
@ -32,6 +33,7 @@ defineProps<{
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
prop?: string;
}>();
const emit = defineEmits<{
@ -40,17 +42,13 @@ const emit = defineEmits<{
}>();
const config = defineFormItem({
items: [
{
labelWidth: '68px',
name: 'borderRadius',
text: '圆角',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
],
labelWidth: '68px',
name: 'borderRadius',
text: '圆角',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
});
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {

View File

@ -1,6 +1,9 @@
<template>
<MContainer
:config="config"
v-for="(item, index) in formConfig"
:prop="prop"
:key="index"
:config="item"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
@ -14,7 +17,7 @@
<script lang="ts" setup>
import { markRaw } from 'vue';
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
@ -25,6 +28,7 @@ defineProps<{
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
prop?: string;
}>();
const emit = defineEmits<{
@ -32,73 +36,71 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const config = defineFormItem({
items: [
{
type: 'row',
items: [
{
labelWidth: '68px',
name: 'fontSize',
text: '字号',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
const formConfig = defineFormConfig([
{
type: 'row',
items: [
{
labelWidth: '68px',
name: 'fontSize',
text: '字号',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
{
labelWidth: '68px',
name: 'lineHeight',
text: '行高',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
{
labelWidth: '68px',
name: 'lineHeight',
text: '行高',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
],
},
{
name: 'fontWeight',
text: '字重',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'select',
options: ['normal', 'bold']
.concat(
Array(7)
.fill(1)
.map((x, i) => `${i + 1}00`),
)
.map((item) => ({
value: item,
text: item,
})),
},
],
},
{
name: 'fontWeight',
text: '字重',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'select',
options: ['normal', 'bold']
.concat(
Array(7)
.fill(1)
.map((x, i) => `${i + 1}00`),
)
.map((item) => ({
value: item,
text: item,
})),
},
{
labelWidth: '68px',
name: 'color',
text: '颜色',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
},
{
labelWidth: '68px',
name: 'color',
text: '颜色',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
{
name: 'textAlign',
text: '对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row' },
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center' },
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right' },
],
},
],
});
},
{
name: 'textAlign',
text: '对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row' },
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center' },
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right' },
],
},
]);
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -1,6 +1,9 @@
<template>
<MContainer
:config="config"
v-for="item in formConfig"
:prop="prop"
:key="item.name"
:config="item"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
@ -23,8 +26,8 @@
<script lang="ts" setup>
import { markRaw } from 'vue';
import type { ChildConfig, ContainerChangeEventData } from '@tmagic/form';
import { defineFormItem, MContainer } from '@tmagic/form';
import type { ContainerChangeEventData } from '@tmagic/form';
import { defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import Box from '../components/Box.vue';
@ -49,6 +52,7 @@ defineProps<{
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
prop?: string;
}>();
const emit = defineEmits<{
@ -56,145 +60,143 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const config = defineFormItem({
items: [
{
name: 'display',
text: '模式',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'inline', icon: markRaw(DisplayInline), tooltip: '内联布局 inline' },
{ value: 'flex', icon: markRaw(DisplayFlex), tooltip: '弹性布局 flex' },
{ value: 'block', icon: markRaw(DisplayBlock), tooltip: '块级布局 block' },
{ value: 'inline-block', icon: markRaw(DisplayInlineBlock), tooltip: '内联块布局 inline-block' },
{ value: 'none', icon: markRaw(DisplayNone), tooltip: '隐藏 none' },
],
},
{
name: 'flexDirection',
text: '主轴方向',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' },
{ value: 'row-reverse', icon: markRaw(FlexDirectionRowReverse), tooltip: '水平方向 起点在右侧 row-reverse' },
{ value: 'column', icon: markRaw(FlexDirectionColumn), tooltip: '垂直方向 起点在上沿 column' },
{
value: 'column-reverse',
icon: markRaw(FlexDirectionColumnReverse),
tooltip: '垂直方向 起点在下沿 column-reverse',
const formConfig = defineFormConfig([
{
name: 'display',
text: '模式',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'inline', icon: markRaw(DisplayInline), tooltip: '内联布局 inline' },
{ value: 'flex', icon: markRaw(DisplayFlex), tooltip: '弹性布局 flex' },
{ value: 'block', icon: markRaw(DisplayBlock), tooltip: '块级布局 block' },
{ value: 'inline-block', icon: markRaw(DisplayInlineBlock), tooltip: '内联块布局 inline-block' },
{ value: 'none', icon: markRaw(DisplayNone), tooltip: '隐藏 none' },
],
},
{
name: 'flexDirection',
text: '主轴方向',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' },
{ value: 'row-reverse', icon: markRaw(FlexDirectionRowReverse), tooltip: '水平方向 起点在右侧 row-reverse' },
{ value: 'column', icon: markRaw(FlexDirectionColumn), tooltip: '垂直方向 起点在上沿 column' },
{
value: 'column-reverse',
icon: markRaw(FlexDirectionColumnReverse),
tooltip: '垂直方向 起点在下沿 column-reverse',
},
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'justifyContent',
text: '主轴对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
{ value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
{ value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'alignItems',
text: '辅轴对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
{ value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
{ value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'flexWrap',
text: '换行',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' },
{ value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
{ value: 'wrap-reverse', text: '逆换行', tooltip: '第一行在下方 wrap-reverse' },
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
type: 'row',
items: [
{
name: 'width',
text: '宽度',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'justifyContent',
text: '主轴对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
{ value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
{ value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'alignItems',
text: '辅轴对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
{ value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
{ value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'flexWrap',
text: '换行',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' },
{ value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
{ value: 'wrap-reverse', text: '逆换行', tooltip: '第一行在下方 wrap-reverse' },
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
type: 'row',
items: [
{
name: 'width',
text: '宽度',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
{
name: 'height',
text: '高度',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
{
name: 'height',
text: '高度',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
],
},
{
type: 'row',
items: [
{
type: 'data-source-field-select',
text: 'overflow',
name: 'overflow',
labelWidth: '68px',
checkStrictly: false,
dataSourceFieldType: ['string'],
fieldConfig: {
type: 'select',
clearable: true,
allowCreate: true,
options: [
{ text: 'visible', value: 'visible' },
{ text: 'hidden', value: 'hidden' },
{ text: 'clip', value: 'clip' },
{ text: 'scroll', value: 'scroll' },
{ text: 'auto', value: 'auto' },
{ text: 'overlay', value: 'overlay' },
{ text: 'initial', value: 'initial' },
],
},
],
},
{
type: 'row',
items: [
{
type: 'data-source-field-select',
text: 'overflow',
name: 'overflow',
labelWidth: '68px',
checkStrictly: false,
dataSourceFieldType: ['string'],
fieldConfig: {
type: 'select',
clearable: true,
allowCreate: true,
options: [
{ text: 'visible', value: 'visible' },
{ text: 'hidden', value: 'hidden' },
{ text: 'clip', value: 'clip' },
{ text: 'scroll', value: 'scroll' },
{ text: 'auto', value: 'auto' },
{ text: 'overlay', value: 'overlay' },
{ text: 'initial', value: 'initial' },
],
},
},
{
type: 'data-source-field-select',
text: '透明度',
name: 'opacity',
labelWidth: '68px',
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
{
type: 'data-source-field-select',
text: '透明度',
name: 'opacity',
labelWidth: '68px',
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
],
},
],
}) as ChildConfig;
},
],
},
]);
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -1,6 +1,9 @@
<template>
<MContainer
:config="config"
v-for="(item, index) in formConfig"
:prop="prop"
:key="index"
:config="item"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
@ -12,7 +15,7 @@
</template>
<script lang="ts" setup>
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
const props = defineProps<{
@ -21,6 +24,7 @@ const props = defineProps<{
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
prop?: string;
}>();
const emit = defineEmits<{
@ -36,78 +40,76 @@ const positionText: Record<string, string> = {
sticky: '粘性定位',
};
const config = defineFormItem({
items: [
{
name: 'position',
text: '定位',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'select',
options: Object.keys(positionText).map((item) => ({
value: item,
text: `${item}(${positionText[item]})`,
})),
const formConfig = defineFormConfig([
{
name: 'position',
text: '定位',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'select',
options: Object.keys(positionText).map((item) => ({
value: item,
text: `${item}(${positionText[item]})`,
})),
},
},
{
type: 'row',
labelWidth: '68px',
display: () => props.values.position !== 'static',
items: [
{
name: 'left',
type: 'data-source-field-select',
text: 'left',
fieldConfig: {
type: 'text',
},
},
},
{
type: 'row',
labelWidth: '68px',
display: () => props.values.position !== 'static',
items: [
{
name: 'left',
type: 'data-source-field-select',
text: 'left',
fieldConfig: {
type: 'text',
},
{
name: 'top',
type: 'data-source-field-select',
text: 'top',
fieldConfig: {
type: 'text',
},
{
name: 'top',
type: 'data-source-field-select',
text: 'top',
fieldConfig: {
type: 'text',
},
},
],
},
{
type: 'row',
labelWidth: '68px',
display: () => props.values.position !== 'static',
items: [
{
name: 'right',
type: 'data-source-field-select',
text: 'right',
fieldConfig: {
type: 'text',
},
},
{
name: 'bottom',
type: 'data-source-field-select',
text: 'bottom',
fieldConfig: {
type: 'text',
},
},
],
},
{
labelWidth: '68px',
name: 'zIndex',
text: 'zIndex',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
],
},
{
type: 'row',
labelWidth: '68px',
display: () => props.values.position !== 'static',
items: [
{
name: 'right',
type: 'data-source-field-select',
text: 'right',
fieldConfig: {
type: 'text',
},
},
{
name: 'bottom',
type: 'data-source-field-select',
text: 'bottom',
fieldConfig: {
type: 'text',
},
},
],
},
{
labelWidth: '68px',
name: 'zIndex',
text: 'zIndex',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
],
});
},
]);
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -1,5 +1,6 @@
<template>
<MContainer
:prop="prop"
:config="config"
:model="values"
:last-values="lastValues"
@ -21,6 +22,7 @@ defineProps<{
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
prop?: string;
}>();
const emit = defineEmits<{
@ -29,33 +31,29 @@ const emit = defineEmits<{
}>();
const config = defineFormItem({
name: 'transform',
items: [
{
name: 'transform',
items: [
{
name: 'rotate',
text: '旋转角度',
labelWidth: '68px',
type: 'data-source-field-select',
checkStrictly: false,
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
{
name: 'scale',
text: '缩放',
labelWidth: '68px',
type: 'data-source-field-select',
checkStrictly: false,
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
],
name: 'rotate',
text: '旋转角度',
labelWidth: '68px',
type: 'data-source-field-select',
checkStrictly: false,
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
{
name: 'scale',
text: '缩放',
labelWidth: '68px',
type: 'data-source-field-select',
checkStrictly: false,
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
],
});

View File

@ -12,6 +12,7 @@
:size="propsPanelSize"
:init-values="values"
:config="config"
:type-match-valid="true"
:extend-state="extendState"
@change="submit"
@error="errorHandler"
@ -46,9 +47,10 @@
import { computed, getCurrentInstance, inject, onMounted, onUnmounted, ref, useTemplateRef, watchEffect } from 'vue';
import { Document as DocumentIcon } from '@element-plus/icons-vue';
import { TMagicButton, TMagicScrollbar } from '@tmagic/design';
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form';
import { MForm } from '@tmagic/form';
import { filterXSS } from '@tmagic/utils';
import MIcon from '@editor/components/Icon.vue';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
@ -134,6 +136,19 @@ const errorHandler = (e: any) => {
emit('form-error', e);
};
/**
* 将校验错误文案安全地转为用于弹窗展示的 HTML
*
* 文案形如 `字段 -> 主文案\n\n建议`多条以结构性 `<br>` 拼接为在保留换行排版的同时
* 避免 `${value}`/字段名中可能夹带的 HTML 造成 XSS先按结构性 `<br>` 拆分对每段做 HTML
* 转义后再把段内换行 `\n` 替换为 `<br>`最后用 `<br>` 拼回
*/
const formatValidateErrorHtml = (error: string): string =>
error
.split(/<br\s*\/?>/i)
.map((segment) => filterXSS(segment).replaceAll('\n', '<br>'))
.join('<br>');
const saveCode = async (values: any) => {
const newValues = props.codeValueKey ? { [props.codeValueKey]: values } : values;
@ -153,8 +168,18 @@ const saveCode = async (values: any) => {
appContext: internalInstance?.appContext ?? null,
services,
stage: stage.value,
typeMatchValid: true,
extendState: props.extendState,
});
if (error) {
tMagicMessage({
type: 'error',
message: formatValidateErrorHtml(error),
dangerouslyUseHTMLString: true,
});
}
emit('submit', newValues, undefined, error ? new Error(error) : undefined);
} catch (e: any) {
console.log('validateForm error', e);

View File

@ -14,7 +14,7 @@
import { computed } from 'vue';
import { WarningFilled } from '@element-plus/icons-vue';
import { TMagicTooltip } from '@tmagic/design';
import { stripValidateSuggestion, TMagicTooltip } from '@tmagic/design';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
@ -37,6 +37,21 @@ const invalidInfo = computed(() => editorService.get('invalidNodeIds').get(props
const hasError = computed(() => Boolean(invalidInfo.value?.props || invalidInfo.value?.style));
/** 合并属性表单与样式表单的错误文案(本身可能已是含 <br> 的 HTML */
const errorMessage = computed(() => [invalidInfo.value?.props, invalidInfo.value?.style].filter(Boolean).join('<br>'));
/**
* 去掉单条校验文案中的修改建议部分
*
* 错误文案可能是多条错误以 `<br>` 拼接的 HTML每条本身形如 `主文案\n\n建议`
* 组件树 tooltip 仅展示错误描述不展示修改建议建议仅在错误汇总面板展示
* 故先按 `<br>` 拆分每段用 `stripValidateSuggestion` 截断建议后再拼回
*/
const stripSuggestion = (text?: string): string =>
String(text ?? '')
.split(/<br\s*\/?>/i)
.map((segment) => stripValidateSuggestion(segment))
.join('<br>');
/** 合并属性表单与样式表单的错误文案(去掉建议,本身可能仍是含 <br> 的 HTML */
const errorMessage = computed(() =>
[stripSuggestion(invalidInfo.value?.props), stripSuggestion(invalidInfo.value?.style)].filter(Boolean).join('<br>'),
);
</script>

View File

@ -387,6 +387,7 @@ export interface ValidatePropsFormOptions {
* `false`
*/
debug?: boolean;
typeMatchValid?: boolean;
}
// #endregion ValidatePropsFormOptions
@ -426,10 +427,12 @@ export const validatePropsForm = ({
stage,
extendState,
debug,
typeMatchValid,
}: ValidatePropsFormOptions): Promise<string> =>
validateForm({
config,
debug,
typeMatchValid,
initValues: values,
// 将当前组件实例的 provides含 Editor 顶层的 services / codeOptions 等组件级 provide
// 合入 appContext使临时 MForm 中的编辑器字段组件DataSourceInput 等)能正常 inject

View File

@ -18,6 +18,7 @@
import type { DataSourceFieldType, DataSourceSchema, Id } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import { appendValidateSuggestion } from '@tmagic/design';
import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form';
import {
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
@ -39,7 +40,73 @@ import {
numberOptions,
} from '@editor/utils/props';
const defaultMessage = (message: string | undefined, fallback: string) => message || fallback;
/**
*
*/
const stringifyExampleValue = (value: any): string => {
if (typeof value === 'string') return `"${value}"`;
if (value === null || value === undefined) return String(value);
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
};
// 参考建议中最多展示的可选值个数,超出以「等」省略。
const MAX_SUGGESTION_OPTIONS = 5;
/**
* 使xxxxxx
*/
const listSuggestion = (values: any[]): string => {
const list = values.filter((item) => typeof item !== 'undefined' && item !== null && item !== '');
if (!list.length) return '';
const shown = list.slice(0, MAX_SUGGESTION_OPTIONS).map(stringifyExampleValue);
const suffix = list.length > MAX_SUGGESTION_OPTIONS ? ' 等' : '';
return `请使用以下某一个值:${shown.join('')}${suffix}`;
};
/**
* id
*/
const dataSourceIdSuggestion = (): string => listSuggestion(getDataSources().map((ds) => `${ds.id}`));
// 各类型 / 结构的参考示例值
const SUGGESTION_STRING = '请参考以下示例值:"文本内容"';
const SUGGESTION_STRING_OR_NUMBER = '请参考以下示例值:"文本内容" 或 123';
const SUGGESTION_OBJECT = '请参考以下示例值:{ "key": "value" }';
const SUGGESTION_ARRAY = '请参考以下示例值:[]';
const SUGGESTION_STRING_ARRAY = '请参考以下示例值:["字段1", "字段2"]';
const SUGGESTION_METHOD_TUPLE = '请参考以下示例值:["数据源ID", "方法名"]';
const SUGGESTION_DS_BINDING = '请参考以下示例值:"${数据源ID.字段名}"';
const SUGGESTION_DS_BIND_OBJECT = '请参考以下示例值:{ "isBindDataSource": true, "dataSourceId": "数据源ID" }';
const SUGGESTION_UI_ID = '请参考以下示例值:画布中已存在组件的 id';
const SUGGESTION_FIELD_ITEM = '请参考以下示例值:{ "name": "字段名", "type": "string" }';
const SUGGESTION_CODE_SELECT = '请参考以下示例值:{ "hookData": [{ "codeId": "代码块ID", "params": {} }] }';
const SUGGESTION_HOOK_ITEM = '请参考以下示例值:{ "codeId": "代码块ID", "params": {} }';
const SUGGESTION_MOCK_ITEM =
'请参考以下示例值:{ "title": "mock 名称", "enable": true, "useInEditor": false, "data": {} }';
const SUGGESTION_METHOD_ITEM = '请参考以下示例值:{ "name": "方法名", "content": "() => {}" }';
const SUGGESTION_EVENT_ITEM = '请参考以下示例值:{ "name": "事件名", "actions": [] }';
const SUGGESTION_DISPLAY_COND = '请参考以下示例值:[{ "cond": [{ "field": ["数据源ID", "字段名"], "op": "==" }] }]';
/**
*
*
* - `message` 使
* - 使 `fallback`
*
* form-item @tmagic/design FormItem
* / @tmagic/design `appendValidateSuggestion`
*/
const defaultMessage = (message: string | undefined, fallback: string, suggestion?: string) => {
if (message) return message;
return appendValidateSuggestion(fallback, suggestion);
};
const isPlainObject = (value: any): value is Record<string, any> =>
Object.prototype.toString.call(value) === '[object Object]';
@ -82,6 +149,87 @@ const getMethodNamesForDataSource = (ds: DataSourceSchema): Set<string> => {
return names;
};
/**
* id
*/
const collectNodeIds = (limit = MAX_SUGGESTION_OPTIONS): Id[] => {
const ids: Id[] = [];
const walk = (nodes?: any[]): boolean => {
for (const node of nodes || []) {
if (typeof node?.id !== 'undefined') {
ids.push(node.id);
if (ids.length >= limit) return true;
}
if (Array.isArray(node?.items) && walk(node.items)) return true;
}
return false;
};
walk(editorService.get('root')?.items);
return ids;
};
/**
*
*/
const firstDataSourceFieldName = (ds?: DataSourceSchema): string | undefined =>
(ds?.fields || []).find((item) => typeof item?.name === 'string')?.name;
/**
* id 退
*/
const uiIdSuggestion = (): string => listSuggestion(collectNodeIds().map((id) => `${id}`)) || SUGGESTION_UI_ID;
/**
* id退
*/
const dataSourceBindingSuggestion = (): string => {
const ds = getDataSources()[0];
if (!ds) return SUGGESTION_DS_BINDING;
const field = firstDataSourceFieldName(ds);
const path = field ? `${ds.id}.${field}` : `${ds.id}`;
return `请参考以下示例值:"\${${path}}"`;
};
/**
* id 退
*/
const dataSourceBindObjectSuggestion = (dataSources: DataSourceSchema[] = getDataSources()): string => {
const ds = dataSources[0];
if (!ds) return SUGGESTION_DS_BIND_OBJECT;
return `请参考以下示例值:${stringifyExampleValue({ isBindDataSource: true, dataSourceId: `${ds.id}` })}`;
};
/**
* id退
*/
const dataSourceFieldPathSuggestion = (): string => {
const ds = getDataSources()[0];
if (!ds) return SUGGESTION_STRING_ARRAY;
const field = firstDataSourceFieldName(ds);
return `请参考以下示例值:${stringifyExampleValue(field ? [`${ds.id}`, field] : [`${ds.id}`])}`;
};
/**
* id退
*/
const methodTupleSuggestion = (): string => {
const ds = getDataSources()[0];
if (!ds) return SUGGESTION_METHOD_TUPLE;
const [methodName] = [...getMethodNamesForDataSource(ds)];
return `请参考以下示例值:${stringifyExampleValue([`${ds.id}`, methodName])}`;
};
/**
* id退
*/
const displayCondSuggestion = (): string => {
const ds = getDataSources()[0];
if (!ds) return SUGGESTION_DISPLAY_COND;
const field = firstDataSourceFieldName(ds);
const fieldPath = field ? [`${ds.id}`, field] : [`${ds.id}`];
return `请参考以下示例值:${stringifyExampleValue([{ cond: [{ field: fieldPath, op: '==' }] }])}`;
};
const validateDataSourceFieldPath = (
path: string[],
options: {
@ -101,7 +249,7 @@ const validateDataSourceFieldPath = (
if (!dsId) {
if (!path.length) {
return defaultMessage(options.message, '值不在可选项中');
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
}
const rawDsId = path[0];
dsId =
@ -113,7 +261,7 @@ const validateDataSourceFieldPath = (
const ds = findDataSource(`${dsId}`);
if (!ds) {
return defaultMessage(options.message, '值不在可选项中');
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
}
if (!fieldNames.length) {
@ -122,7 +270,7 @@ const validateDataSourceFieldPath = (
const { field, ok } = resolveFieldByPath(ds.fields, fieldNames);
if (!ok) {
return defaultMessage(options.message, '值不在可选项中');
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
}
const allowedTypes = options.dataSourceFieldType || ['any'];
@ -135,12 +283,12 @@ const validateDataSourceFieldPath = (
return undefined;
}
return defaultMessage(options.message, '值不在可选项中');
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
};
const validateDataSourceMethodTuple = (value: any, message?: string): string | undefined => {
if (!Array.isArray(value) || value.length !== 2 || value.some((item) => typeof item !== 'string')) {
return defaultMessage(message, `${value}类型应为长度为 2 的字符串数组`);
return defaultMessage(message, `${value}类型应为长度为 2 的字符串数组`, methodTupleSuggestion());
}
const dataSources = getDataSources();
@ -151,11 +299,15 @@ const validateDataSourceMethodTuple = (value: any, message?: string): string | u
const [dsId, methodName] = value;
const ds = findDataSource(dsId);
if (!ds) {
return defaultMessage(message, `数据源(${dsId})不存在`);
return defaultMessage(message, `数据源(${dsId})不存在`, dataSourceIdSuggestion());
}
if (!getMethodNamesForDataSource(ds).has(methodName)) {
return defaultMessage(message, `数据源方法(${methodName})不存在`);
return defaultMessage(
message,
`数据源方法(${methodName})不存在`,
listSuggestion([...getMethodNamesForDataSource(ds)]),
);
}
return undefined;
@ -171,18 +323,18 @@ const validateDataSourceTemplateBindings = (value: string, message?: string): st
for (const match of matches) {
const keys = getKeysArray(match[1]);
if (!keys.length) {
return defaultMessage(message, `数据源绑定(${value})不合法`);
return defaultMessage(message, `数据源绑定(${value})不合法`, dataSourceBindingSuggestion());
}
const [dsId, ...fieldNames] = keys;
const ds = findDataSource(`${dsId}`);
if (!ds) {
return defaultMessage(message, `数据源绑定(${value})不合法`);
return defaultMessage(message, `数据源绑定(${value})不合法`, dataSourceBindingSuggestion());
}
const { ok } = resolveFieldByPath(ds.fields, fieldNames, { skipNumberIndices: true });
if (!ok) {
return defaultMessage(message, `数据源绑定(${value})不合法`);
return defaultMessage(message, `数据源绑定(${value})不合法`, dataSourceBindingSuggestion());
}
}
@ -191,16 +343,16 @@ const validateDataSourceTemplateBindings = (value: string, message?: string): st
const validateDataSourceFieldsItem = (item: any, message?: string): string | undefined => {
if (!isPlainObject(item) || typeof item.name !== 'string') {
return defaultMessage(message, '字段项结构不合法');
return defaultMessage(message, '字段项结构不合法', SUGGESTION_FIELD_ITEM);
}
if (typeof item.type !== 'undefined' && !DATA_SOURCE_FIELD_TYPES.has(item.type)) {
return defaultMessage(message, '字段类型不合法');
return defaultMessage(message, '字段类型不合法', listSuggestion([...DATA_SOURCE_FIELD_TYPES]));
}
if (typeof item.fields !== 'undefined') {
if (!Array.isArray(item.fields)) {
return defaultMessage(message, '字段项结构不合法');
return defaultMessage(message, '字段项结构不合法', SUGGESTION_FIELD_ITEM);
}
for (const child of item.fields) {
const error = validateDataSourceFieldsItem(child, message);
@ -216,21 +368,21 @@ const validateKeyValue: TypeMatchValidator = (value, { message, props }) => {
return undefined;
}
if (!isPlainObject(value)) {
return defaultMessage(message, '值类型应为对象');
return defaultMessage(message, '值类型应为对象', SUGGESTION_OBJECT);
}
return undefined;
};
const validateStyleSetter: TypeMatchValidator = (value, { message }) => {
if (!isPlainObject(value)) {
return defaultMessage(message, '值类型应为对象');
return defaultMessage(message, '值类型应为对象', SUGGESTION_OBJECT);
}
return undefined;
};
const validateCondOpSelect: TypeMatchValidator = (value, { message, props }) => {
if (typeof value !== 'string') {
return defaultMessage(message, '值类型应为字符串');
return defaultMessage(message, '值类型应为字符串', SUGGESTION_STRING);
}
const parentFields = props.config?.parentFields || [];
@ -241,14 +393,14 @@ const validateCondOpSelect: TypeMatchValidator = (value, { message, props }) =>
const allowed = getCondOpsByFieldType(fieldType);
if (!allowed.has(value)) {
return defaultMessage(message, `${value} 不在可选项中`);
return defaultMessage(message, `${value} 不在可选项中`, listSuggestion([...allowed]));
}
return undefined;
};
const validateCodeSelectCol: TypeMatchValidator = (value, { message }) => {
if (typeof value !== 'string') {
return defaultMessage(message, `${value}类型应为字符串`);
return defaultMessage(message, `${value}类型应为字符串`, SUGGESTION_STRING);
}
const codeDsl = codeBlockService.getCodeDsl();
@ -257,14 +409,14 @@ const validateCodeSelectCol: TypeMatchValidator = (value, { message }) => {
}
if (!(value in codeDsl)) {
return defaultMessage(message, `代码块(${value})不存在`);
return defaultMessage(message, `代码块(${value})不存在`, listSuggestion(Object.keys(codeDsl)));
}
return undefined;
};
const validatePageFragmentSelect: TypeMatchValidator = (value, { message }) => {
if (!isId(value)) {
return defaultMessage(message, `${value}类型应为字符串或数字`);
return defaultMessage(message, `${value}类型应为字符串或数字`, SUGGESTION_STRING_OR_NUMBER);
}
const items = editorService.get('root')?.items;
@ -272,16 +424,17 @@ const validatePageFragmentSelect: TypeMatchValidator = (value, { message }) => {
return undefined;
}
const exists = items.some((item) => item.type === NodeType.PAGE_FRAGMENT && `${item.id}` === `${value}`);
const fragments = items.filter((item) => item.type === NodeType.PAGE_FRAGMENT);
const exists = fragments.some((item) => `${item.id}` === `${value}`);
if (!exists) {
return defaultMessage(message, `页面片段(${value})不存在`);
return defaultMessage(message, `页面片段(${value})不存在`, listSuggestion(fragments.map((item) => `${item.id}`)));
}
return undefined;
};
const validateUiSelect: TypeMatchValidator = (value, { message }) => {
if (!isId(value)) {
return defaultMessage(message, `${value}类型应为字符串或数字`);
return defaultMessage(message, `${value}类型应为字符串或数字`, SUGGESTION_STRING_OR_NUMBER);
}
const root = editorService.get('root');
@ -290,14 +443,14 @@ const validateUiSelect: TypeMatchValidator = (value, { message }) => {
}
if (!editorService.getNodeById(value)) {
return defaultMessage(message, `组件(${value})不存在`);
return defaultMessage(message, `组件(${value})不存在`, uiIdSuggestion());
}
return undefined;
};
const validateDataSourceInput: TypeMatchValidator = (value, { message }) => {
if (typeof value !== 'string') {
return defaultMessage(message, `${value}类型应为字符串`);
return defaultMessage(message, `${value}类型应为字符串`, SUGGESTION_STRING);
}
return validateDataSourceTemplateBindings(value, message);
};
@ -326,7 +479,7 @@ const validateDataSourceFieldSelect: TypeMatchValidator = (value, { message, pro
}
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
return defaultMessage(message, `${value}类型应为字符串数组`);
return defaultMessage(message, `${value}类型应为字符串数组`, dataSourceFieldPathSuggestion());
}
return validateDataSourceFieldPath(value, {
@ -344,19 +497,19 @@ const validateDataSourceSelect: TypeMatchValidator = (value, { message, props })
if (config.value === 'id') {
if (typeof value !== 'string') {
return defaultMessage(message, `${value}类型应为字符串`);
return defaultMessage(message, `${value}类型应为字符串`, SUGGESTION_STRING);
}
if (!filtered.length) {
return undefined;
}
if (!filtered.some((ds) => `${ds.id}` === `${value}`)) {
return defaultMessage(message, `${value}不在可选项中`);
return defaultMessage(message, `${value}不在可选项中`, listSuggestion(filtered.map((ds) => `${ds.id}`)));
}
return undefined;
}
if (!isPlainObject(value) || value.isBindDataSource !== true || typeof value.dataSourceId === 'undefined') {
return defaultMessage(message, `${value}类型不合法`);
return defaultMessage(message, `${value}类型不合法`, dataSourceBindObjectSuggestion(filtered));
}
if (!filtered.length) {
@ -365,11 +518,11 @@ const validateDataSourceSelect: TypeMatchValidator = (value, { message, props })
const ds = filtered.find((item) => `${item.id}` === `${value.dataSourceId}`);
if (!ds) {
return defaultMessage(message, `${value}不在可选项中`);
return defaultMessage(message, `${value}不在可选项中`, listSuggestion(filtered.map((item) => `${item.id}`)));
}
if (typeof value.dataSourceType !== 'undefined' && value.dataSourceType !== ds.type) {
return defaultMessage(message, `${value}不在可选项中`);
return defaultMessage(message, `${value}不在可选项中`, listSuggestion(filtered.map((item) => `${item.id}`)));
}
return undefined;
@ -377,18 +530,18 @@ const validateDataSourceSelect: TypeMatchValidator = (value, { message, props })
const validateCodeSelect: TypeMatchValidator = (value, { message }) => {
if (!isPlainObject(value) || !Array.isArray(value.hookData)) {
return defaultMessage(message, `${value}类型不合法`);
return defaultMessage(message, `${value}类型不合法`, SUGGESTION_CODE_SELECT);
}
// 「codeId 是否存在 / 数据源方法是否存在」由 CodeSelect 内部 code-select-col、
// data-source-method-select 单元格各自的 typeMatch 校验,只标红出错单元格。此处仅做结构校验。
for (const item of value.hookData) {
if (!isPlainObject(item)) {
return defaultMessage(message, '钩子项结构不合法');
return defaultMessage(message, '钩子项结构不合法', SUGGESTION_HOOK_ITEM);
}
if (typeof item.params !== 'undefined' && !isPlainObject(item.params)) {
return defaultMessage(message, '钩子项结构不合法');
return defaultMessage(message, '钩子项结构不合法', SUGGESTION_HOOK_ITEM);
}
}
@ -397,7 +550,7 @@ const validateCodeSelect: TypeMatchValidator = (value, { message }) => {
const validateDataSourceFields: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value}类型应为数组`);
return defaultMessage(message, `${value}类型应为数组`, SUGGESTION_ARRAY);
}
for (const item of value) {
const error = validateDataSourceFieldsItem(item, message);
@ -408,7 +561,7 @@ const validateDataSourceFields: TypeMatchValidator = (value, { message }) => {
const validateDataSourceMocks: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value}类型应为数组`);
return defaultMessage(message, `${value}类型应为数组`, SUGGESTION_ARRAY);
}
for (const item of value) {
@ -419,7 +572,7 @@ const validateDataSourceMocks: TypeMatchValidator = (value, { message }) => {
typeof item.useInEditor !== 'boolean' ||
!isPlainObject(item.data)
) {
return defaultMessage(message, 'mock 项结构不合法');
return defaultMessage(message, 'mock 项结构不合法', SUGGESTION_MOCK_ITEM);
}
}
@ -428,18 +581,18 @@ const validateDataSourceMocks: TypeMatchValidator = (value, { message }) => {
const validateDataSourceMethods: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value}类型应为数组`);
return defaultMessage(message, `${value}类型应为数组`, SUGGESTION_ARRAY);
}
for (const item of value) {
if (!isPlainObject(item) || typeof item.name !== 'string') {
return defaultMessage(message, '方法项结构不合法');
return defaultMessage(message, '方法项结构不合法', SUGGESTION_METHOD_ITEM);
}
if (typeof item.content !== 'undefined' && typeof item.content !== 'string' && typeof item.content !== 'function') {
return defaultMessage(message, '方法项结构不合法');
return defaultMessage(message, '方法项结构不合法', SUGGESTION_METHOD_ITEM);
}
if (typeof item.params !== 'undefined' && !Array.isArray(item.params)) {
return defaultMessage(message, '方法项结构不合法');
return defaultMessage(message, '方法项结构不合法', SUGGESTION_METHOD_ITEM);
}
}
@ -448,27 +601,27 @@ const validateDataSourceMethods: TypeMatchValidator = (value, { message }) => {
const validateEventSelect: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value}类型应为数组`);
return defaultMessage(message, `${value}类型应为数组`, SUGGESTION_ARRAY);
}
// 「事件名 / 动作名是否在可选项中」由 EventSelect 的 eventNameConfig.rules、compActionConfig.rules 单独校验,
// 以便只标红对应 select而不是整张联动卡片。此处仅做结构校验。
for (const item of value) {
if (!isPlainObject(item) || typeof item.name !== 'string') {
return defaultMessage(message, '事件项结构不合法');
return defaultMessage(message, '事件项结构不合法', SUGGESTION_EVENT_ITEM);
}
if (Array.isArray(item.actions)) {
for (const action of item.actions) {
if (!isPlainObject(action) || typeof action.actionType === 'undefined') {
return defaultMessage(message, '事件项结构不合法');
return defaultMessage(message, '事件项结构不合法', SUGGESTION_EVENT_ITEM);
}
}
continue;
}
if (typeof item.to === 'undefined' || typeof item.method !== 'string') {
return defaultMessage(message, '事件项结构不合法');
return defaultMessage(message, '事件项结构不合法', SUGGESTION_EVENT_ITEM);
}
}
@ -477,7 +630,7 @@ const validateEventSelect: TypeMatchValidator = (value, { message }) => {
const validateDisplayConds: TypeMatchValidator = (value, { message }) => {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value}类型应为数组`);
return defaultMessage(message, `${value}类型应为数组`, SUGGESTION_ARRAY);
}
// 「op 是否为合法算子 / 字段路径是否存在」由 DisplayConds 内部 cond-op-select、
@ -485,7 +638,7 @@ const validateDisplayConds: TypeMatchValidator = (value, { message }) => {
// 此处仅做结构校验。
for (const item of value) {
if (!isPlainObject(item) || !Array.isArray(item.cond)) {
return defaultMessage(message, '显示条件结构不合法');
return defaultMessage(message, '显示条件结构不合法', displayCondSuggestion());
}
for (const cond of item.cond) {
@ -495,7 +648,7 @@ const validateDisplayConds: TypeMatchValidator = (value, { message }) => {
cond.field.some((part: any) => typeof part !== 'string') ||
typeof cond.op !== 'string'
) {
return defaultMessage(message, '显示条件结构不合法');
return defaultMessage(message, '显示条件结构不合法', displayCondSuggestion());
}
}
}

View File

@ -34,7 +34,7 @@ vi.mock('@editor/fields/StyleSetter/pro/index', () => {
const make = (name: string) =>
defineComponent({
name,
props: ['values', 'lastValues', 'isCompare', 'size', 'disabled'],
props: ['values', 'lastValues', 'isCompare', 'size', 'disabled', 'prop'],
emits: ['change', 'addDiffCount'],
setup(_p, { emit }) {
return () =>
@ -63,26 +63,6 @@ describe('StyleSetter Index', () => {
expect(wrapper.findAll('.collapse-item').length).toBe(6);
});
test('change 时为 propPath 添加 prop 前缀', async () => {
const wrapper = mount(StyleSetter, {
props: { model: { style: {} }, name: 'style', prop: 'style' } as any,
});
await wrapper.find('.Layout').trigger('click');
const events = wrapper.emitted('change');
expect(events).toBeTruthy();
expect((events?.[0]?.[1] as any).changeRecords[0].propPath).toBe('style.foo');
});
test('prop 与 name 不一致时propPath 使用完整的 prop 路径而非 name', async () => {
const wrapper = mount(StyleSetter, {
props: { model: { style: {} }, name: 'style', prop: 'data.items.0.style' } as any,
});
await wrapper.find('.Position').trigger('click');
const events = wrapper.emitted('change');
expect(events).toBeTruthy();
expect((events?.[0]?.[1] as any).changeRecords[0].propPath).toBe('data.items.0.style.foo');
});
test('change 透传原始 value 并保留 changeRecords 其他字段', async () => {
const wrapper = mount(StyleSetter, {
props: { model: { style: {} }, name: 'style', prop: 'style' } as any,
@ -93,7 +73,7 @@ describe('StyleSetter Index', () => {
const [value, eventData] = events![0] as any[];
expect(value).toEqual({ foo: 1 });
expect(eventData.changeRecords).toHaveLength(1);
expect(eventData.changeRecords[0]).toEqual({ propPath: 'style.foo', value: 1 });
expect(eventData.changeRecords[0]).toEqual({ propPath: 'foo', value: 1 });
});
test('eventData 无 changeRecords 时也能正常 emit', async () => {

View File

@ -11,6 +11,7 @@ import Layout from '@editor/fields/StyleSetter/pro/Layout.vue';
vi.mock('@tmagic/form', () => ({
defineFormItem: (cfg: any) => cfg,
defineFormConfig: (cfg: any) => cfg,
MContainer: defineComponent({
name: 'FakeMContainer',
props: ['config', 'model', 'lastValues', 'isCompare', 'size', 'disabled'],
@ -84,8 +85,8 @@ describe('StyleSetter/Layout.vue', () => {
const wrapper = mount(Layout, {
props: { values: { position: 'static', display: 'flex' } } as any,
});
const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
const flexItem = config.items.find((it: any) => it.name === 'flexDirection');
const configs = wrapper.findAllComponents({ name: 'FakeMContainer' }).map((c) => c.props('config') as any);
const flexItem = configs.find((it: any) => it.name === 'flexDirection');
expect(flexItem.display(null, { model: { display: 'flex' } })).toBe(true);
expect(flexItem.display(null, { model: { display: 'block' } })).toBe(false);
});
@ -94,9 +95,9 @@ describe('StyleSetter/Layout.vue', () => {
const wrapper = mount(Layout, {
props: { values: { position: 'static', display: 'flex' } } as any,
});
const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
const configs = wrapper.findAllComponents({ name: 'FakeMContainer' }).map((c) => c.props('config') as any);
['justifyContent', 'alignItems', 'flexWrap'].forEach((name) => {
const item = config.items.find((it: any) => it.name === name);
const item = configs.find((it: any) => it.name === name);
expect(item.display(null, { model: { display: 'flex' } })).toBe(true);
expect(item.display(null, { model: { display: 'block' } })).toBe(false);
});
@ -106,8 +107,8 @@ describe('StyleSetter/Layout.vue', () => {
const wrapper = mount(Layout, {
props: { values: { position: 'static', display: 'flex' } } as any,
});
const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
const displayItem = config.items.find((it: any) => it.name === 'display');
const configs = wrapper.findAllComponents({ name: 'FakeMContainer' }).map((c) => c.props('config') as any);
const displayItem = configs.find((it: any) => it.name === 'display');
const values = displayItem.options.map((o: any) => o.value);
expect(values).toEqual(['inline', 'flex', 'block', 'inline-block', 'none']);
});

View File

@ -11,6 +11,7 @@ import Position from '@editor/fields/StyleSetter/pro/Position.vue';
vi.mock('@tmagic/form', () => ({
defineFormItem: (cfg: any) => cfg,
defineFormConfig: (cfg: any) => cfg,
MContainer: defineComponent({
name: 'FakeMContainer',
props: ['config', 'model', 'lastValues', 'isCompare', 'size', 'disabled'],
@ -48,8 +49,8 @@ describe('StyleSetter/Position.vue', () => {
values: { position: 'static' },
} as any,
});
const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
const rowItems = config.items.filter((it: any) => it.type === 'row');
const configs = wrapper.findAllComponents({ name: 'FakeMContainer' }).map((c) => c.props('config') as any);
const rowItems = configs.filter((it: any) => it.type === 'row');
expect(rowItems[0].display()).toBe(false);
});
@ -59,8 +60,8 @@ describe('StyleSetter/Position.vue', () => {
values: { position: 'absolute' },
} as any,
});
const config = wrapper.findComponent({ name: 'FakeMContainer' }).props('config') as any;
const rowItems = config.items.filter((it: any) => it.type === 'row');
const configs = wrapper.findAllComponents({ name: 'FakeMContainer' }).map((c) => c.props('config') as any);
const rowItems = configs.filter((it: any) => it.type === 'row');
expect(rowItems[0].display()).toBe(true);
});

View File

@ -15,6 +15,7 @@ import Transform from '@editor/fields/StyleSetter/pro/Transform.vue';
vi.mock('@tmagic/form', () => ({
defineFormItem: (cfg: any) => cfg,
defineFormConfig: (cfg: any) => cfg,
MContainer: defineComponent({
name: 'MContainer',
props: ['config', 'model', 'lastValues', 'isCompare', 'size', 'disabled'],

View File

@ -60,6 +60,7 @@ vi.mock('@tmagic/design', () => ({
return () => h('button', { class: 'fake-btn' }, slots.default?.());
},
}),
tMagicMessage: () => {},
}));
// 可控的 submitForm 实现:默认校验成功,测试可将其改为 reject 以模拟校验失败

View File

@ -38,6 +38,7 @@ vi.mock('@tmagic/design', () => ({
return () => h('div', { class: 'fake-tooltip' }, [slots.content?.(), slots.default?.()]);
},
}),
stripValidateSuggestion: (text?: string) => String(text ?? '').split('\n\n')[0],
}));
describe('LayerNodeContent', () => {
@ -78,6 +79,40 @@ describe('LayerNodeContent', () => {
expect(tooltip.html()).toContain('width 非法');
});
test('tooltip 不展示 \\n\\n 之后的修改建议', () => {
invalidNodeIds.clear();
invalidNodeIds.set('n1', {
props: 'name 类型应为字符串\n\n请参考以下示例值"文本内容"',
style: 'width 不在可选项中\n\n请使用以下某一个值100',
});
const wrapper = mount(LayerNodeContent, {
props: { data: { id: 'n1', name: '文本', type: 'text' } as any },
});
const html = wrapper.find('.fake-tooltip').html();
// 错误描述保留
expect(html).toContain('name 类型应为字符串');
expect(html).toContain('width 不在可选项中');
// 修改建议被截断
expect(html).not.toContain('请参考以下示例值');
expect(html).not.toContain('请使用以下某一个值');
});
test('多条错误以 <br> 拼接时逐条截断建议', () => {
invalidNodeIds.clear();
invalidNodeIds.set('n1', {
props: 'name 必填\n\n建议A<br>id 类型应为数字\n\n建议B',
});
const wrapper = mount(LayerNodeContent, {
props: { data: { id: 'n1', name: '文本', type: 'text' } as any },
});
const html = wrapper.find('.fake-tooltip').html();
expect(html).toContain('name 必填');
expect(html).toContain('id 类型应为数字');
expect(html).toContain('<br>');
expect(html).not.toContain('建议A');
expect(html).not.toContain('建议B');
});
test('错误状态变化时响应式更新', async () => {
invalidNodeIds.clear();
const wrapper = mount(LayerNodeContent, {

View File

@ -80,6 +80,15 @@ const run = (
message?: string,
) => editorTypeMatchRules[type](value, ctx({ ...config, type }, model, message));
/**
* @tmagic/design FormItem
* `\n\n`
*/
const firstLine = (value: any): any => {
if (typeof value !== 'string') return value;
return value.split('\n\n')[0];
};
describe('editorTypeMatchRules', () => {
beforeEach(() => {
codeDslState.value = null;
@ -93,14 +102,14 @@ describe('editorTypeMatchRules', () => {
test('key-value / style-setter 形态校验', () => {
expect(run('key-value', { a: '1' })).toBeUndefined();
expect(run('key-value', [])).toBe('值类型应为对象');
expect(firstLine(run('key-value', []))).toBe('值类型应为对象');
expect(run('key-value', () => 1, { advanced: true })).toBeUndefined();
expect(run('style-setter', { width: '1px' })).toBeUndefined();
expect(run('style-setter', [])).toBe('值类型应为对象');
expect(firstLine(run('style-setter', []))).toBe('值类型应为对象');
});
test('cond-op-select 按字段类型收窄,未知类型用默认全集', () => {
expect(run('cond-op-select', 1)).toBe('值类型应为字符串');
expect(firstLine(run('cond-op-select', 1))).toBe('值类型应为字符串');
expect(run('cond-op-select', '=')).toBeUndefined();
expect(ALL_COND_OPS.has('between')).toBe(true);
expect(ALL_COND_OPS.has('is')).toBe(true);
@ -117,24 +126,24 @@ describe('editorTypeMatchRules', () => {
},
];
expect(run('cond-op-select', '>', {}, { field: ['ds1', 'age'] })).toBeUndefined();
expect(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();
// 未知类型与 UI 默认选项对齐:不含 boolean ops
expect(run('cond-op-select', 'is', {}, { field: ['ds1', 'missing'] })).toBe('is 不在可选项中');
expect(firstLine(run('cond-op-select', 'is', {}, { field: ['ds1', 'missing'] }))).toBe('is 不在可选项中');
expect(run('cond-op-select', 'include', {}, { field: ['ds1', 'missing'] })).toBeUndefined();
});
test('code-select-col 有 DSL 时校验 key', () => {
expect(run('code-select-col', 1)).toBe('1类型应为字符串');
expect(firstLine(run('code-select-col', 1))).toBe('1类型应为字符串');
expect(run('code-select-col', 'code_1')).toBeUndefined();
codeDslState.value = { code_1: { name: 'A' } };
expect(run('code-select-col', 'code_1')).toBeUndefined();
expect(run('code-select-col', 'missing')).toBe('代码块(missing)不存在');
expect(firstLine(run('code-select-col', 'missing'))).toBe('代码块(missing)不存在');
});
test('page-fragment-select / ui-select 校验节点存在性', () => {
expect(run('page-fragment-select', true)).toBe('true类型应为字符串或数字');
expect(firstLine(run('page-fragment-select', true))).toBe('true类型应为字符串或数字');
expect(run('page-fragment-select', 'pf_1')).toBeUndefined();
rootState.value = {
@ -144,7 +153,7 @@ describe('editorTypeMatchRules', () => {
],
};
expect(run('page-fragment-select', 'pf_1')).toBeUndefined();
expect(run('page-fragment-select', 'page_1')).toBe('页面片段(page_1)不存在');
expect(firstLine(run('page-fragment-select', 'page_1'))).toBe('页面片段(page_1)不存在');
rootState.value = null;
expect(run('ui-select', 'node_1')).toBeUndefined();
@ -152,11 +161,11 @@ describe('editorTypeMatchRules', () => {
rootState.value = { items: [] };
nodesState.value = { node_1: { id: 'node_1' } };
expect(run('ui-select', 'node_1')).toBeUndefined();
expect(run('ui-select', 'missing')).toBe('组件(missing)不存在');
expect(firstLine(run('ui-select', 'missing'))).toBe('组件(missing)不存在');
});
test('data-source-input 校验模板绑定', () => {
expect(run('data-source-input', 1)).toBe('1类型应为字符串');
expect(firstLine(run('data-source-input', 1))).toBe('1类型应为字符串');
expect(run('data-source-input', 'plain')).toBeUndefined();
expect(run('data-source-input', '${ds1.title}')).toBeUndefined();
@ -173,12 +182,12 @@ describe('editorTypeMatchRules', () => {
];
expect(run('data-source-input', 'hello ${ds1.title}')).toBeUndefined();
expect(run('data-source-input', '${ds1.arr[0].item}')).toBeUndefined();
expect(run('data-source-input', '${missing.title}')).toBe('数据源绑定(${missing.title})不合法');
expect(run('data-source-input', '${ds1.missing}')).toBe('数据源绑定(${ds1.missing})不合法');
expect(firstLine(run('data-source-input', '${missing.title}'))).toBe('数据源绑定(${missing.title})不合法');
expect(firstLine(run('data-source-input', '${ds1.missing}'))).toBe('数据源绑定(${ds1.missing})不合法');
});
test('data-source-method-select 校验方法元组', () => {
expect(run('data-source-method-select', ['ds1'])).toBe('ds1类型应为长度为 2 的字符串数组');
expect(firstLine(run('data-source-method-select', ['ds1']))).toBe('ds1类型应为长度为 2 的字符串数组');
expect(run('data-source-method-select', ['ds1', 'foo'])).toBeUndefined();
dataSourcesState.value = [
@ -194,12 +203,12 @@ describe('editorTypeMatchRules', () => {
expect(run('data-source-method-select', ['ds1', DATA_SOURCE_SET_DATA_METHOD_NAME])).toBeUndefined();
expect(run('data-source-method-select', ['ds1', 'builtin'])).toBeUndefined();
expect(run('data-source-method-select', ['ds1', 'custom'])).toBeUndefined();
expect(run('data-source-method-select', ['ds1', 'missing'])).toBe('数据源方法(missing)不存在');
expect(run('data-source-method-select', ['missing', 'custom'])).toBe('数据源(missing)不存在');
expect(firstLine(run('data-source-method-select', ['ds1', 'missing']))).toBe('数据源方法(missing)不存在');
expect(firstLine(run('data-source-method-select', ['missing', 'custom']))).toBe('数据源(missing)不存在');
});
test('data-source-field-select 校验路径与 fieldConfig 跳过', () => {
expect(run('data-source-field-select', 'x')).toBe('x类型应为字符串数组');
expect(firstLine(run('data-source-field-select', 'x'))).toBe('x类型应为字符串数组');
expect(run('data-source-field-select', 'text-value', { fieldConfig: { type: 'text' } })).toBeUndefined();
dataSourcesState.value = [
@ -216,12 +225,14 @@ describe('editorTypeMatchRules', () => {
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', ['ds1', 'missing'], { value: 'key' })).toBe('值不在可选项中');
expect(firstLine(run('data-source-field-select', ['ds1', 'missing'], { value: 'key' }))).toBe('值不在可选项中');
expect(
run('data-source-field-select', ['title'], {
dataSourceId: 'ds1',
dataSourceFieldType: ['number'],
}),
firstLine(
run('data-source-field-select', ['title'], {
dataSourceId: 'ds1',
dataSourceFieldType: ['number'],
}),
),
).toBe('值不在可选项中');
expect(
run('data-source-field-select', ['obj', 'a'], {
@ -241,15 +252,17 @@ describe('editorTypeMatchRules', () => {
];
expect(run('data-source-select', 'ds1', { value: 'id' })).toBeUndefined();
expect(run('data-source-select', 'missing', { value: 'id' })).toBe('missing不在可选项中');
expect(run('data-source-select', 'ds2', { value: 'id', dataSourceType: 'base' })).toBe('ds2不在可选项中');
expect(firstLine(run('data-source-select', 'missing', { value: 'id' }))).toBe('missing不在可选项中');
expect(firstLine(run('data-source-select', 'ds2', { value: 'id', dataSourceType: 'base' }))).toBe(
'ds2不在可选项中',
);
expect(
run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1', dataSourceType: 'base' }),
).toBeUndefined();
expect(run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1', dataSourceType: 'http' })).toBe(
'[object Object]不在可选项中',
);
expect(run('data-source-select', { isBindDataSource: false, dataSourceId: 'ds1' })).toBe(
expect(
firstLine(run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1', dataSourceType: 'http' })),
).toBe('[object Object]不在可选项中');
expect(firstLine(run('data-source-select', { isBindDataSource: false, dataSourceId: 'ds1' }))).toBe(
'[object Object]类型不合法',
);
});
@ -304,34 +317,36 @@ describe('editorTypeMatchRules', () => {
test('容器类浅层结构校验', () => {
expect(run('data-source-fields', [{ name: 'a', type: 'string' }])).toBeUndefined();
expect(run('data-source-fields', [{ name: 'a', type: 'oops' }])).toBe('字段类型不合法');
expect(firstLine(run('data-source-fields', [{ name: 'a', type: 'oops' }]))).toBe('字段类型不合法');
expect(
run('data-source-fields', [{ name: 'obj', type: 'object', fields: [{ name: 'child', type: 'number' }] }]),
).toBeUndefined();
expect(run('data-source-fields', [{ name: 'obj', type: 'object', fields: 'bad' }])).toBe('字段项结构不合法');
expect(firstLine(run('data-source-fields', [{ name: 'obj', type: 'object', fields: 'bad' }]))).toBe(
'字段项结构不合法',
);
expect(
run('data-source-mocks', [{ title: 'm', enable: true, useInEditor: false, data: { a: 1 } }]),
).toBeUndefined();
expect(run('data-source-mocks', [{ title: 'm', enable: true, useInEditor: false, data: [] }])).toBe(
expect(firstLine(run('data-source-mocks', [{ title: 'm', enable: true, useInEditor: false, data: [] }]))).toBe(
'mock 项结构不合法',
);
expect(run('data-source-methods', [{ name: 'fn', content: '() => {}', params: [] }])).toBeUndefined();
expect(run('data-source-methods', [{ name: 'fn', content: 1 }])).toBe('方法项结构不合法');
expect(firstLine(run('data-source-methods', [{ name: 'fn', content: 1 }]))).toBe('方法项结构不合法');
expect(run('event-select', [{ name: 'click', actions: [{ actionType: 'comp' }] }])).toBeUndefined();
expect(run('event-select', [{ name: 'click', to: 'node_1', method: 'show' }])).toBeUndefined();
expect(run('event-select', [{ name: 'click' }])).toBe('事件项结构不合法');
expect(run('event-select', [{ name: 'click', actions: [{}] }])).toBe('事件项结构不合法');
expect(firstLine(run('event-select', [{ name: 'click' }]))).toBe('事件项结构不合法');
expect(firstLine(run('event-select', [{ name: 'click', actions: [{}] }]))).toBe('事件项结构不合法');
expect(run('display-conds', [{ cond: [{ field: ['ds1', 'title'], op: '=' }] }])).toBeUndefined();
// op 是否合法、字段路径是否存在已下沉到 cond-op-select / field 单元格校验,容器级仅校验结构
expect(run('display-conds', [{ cond: [{ field: ['ds1'], op: 'bad' }] }])).toBeUndefined();
expect(run('display-conds', [{ cond: [{ field: ['ds1', 'missing'], op: '=' }] }])).toBeUndefined();
expect(run('display-conds', [{ cond: 'bad' }])).toBe('显示条件结构不合法');
expect(run('display-conds', [{ cond: [{ field: 'bad', op: '=' }] }])).toBe('显示条件结构不合法');
expect(run('display-conds', [{ cond: [{ field: ['ds1'], op: 1 }] }])).toBe('显示条件结构不合法');
expect(firstLine(run('display-conds', [{ cond: 'bad' }]))).toBe('显示条件结构不合法');
expect(firstLine(run('display-conds', [{ cond: [{ field: 'bad', op: '=' }] }]))).toBe('显示条件结构不合法');
expect(firstLine(run('display-conds', [{ cond: [{ field: ['ds1'], op: 1 }] }]))).toBe('显示条件结构不合法');
});
test('自定义 message 优先', () => {

View File

@ -896,6 +896,10 @@ export interface StepConfig<T = never> extends FormItem {
}
// #endregion StepConfig
export interface ImgUploadConfig extends FormItem {
type: 'img-upload';
}
// #region ComponentConfig
export interface ComponentConfig extends FormItem {
type: 'component';

View File

@ -32,7 +32,7 @@
</template>
<script setup lang="ts">
import { nextTick, provide, reactive, ref, shallowRef, toRaw, useTemplateRef, watch, watchEffect } from 'vue';
import { computed, nextTick, provide, reactive, ref, shallowRef, toRaw, useTemplateRef, watch, watchEffect } from 'vue';
import { cloneDeep, isEqual } from 'lodash-es';
import { TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
@ -50,7 +50,7 @@ import type {
FormValue,
ValidateError,
} from './schema';
import { FORM_DIFF_CONFIG_KEY } from './schema';
import { FORM_DIFF_CONFIG_KEY, FORM_TYPE_MATCH_VALID_KEY } from './schema';
defineOptions({
name: 'MForm',
@ -70,6 +70,8 @@ const props = withDefaults(
isCompare?: boolean;
parentValues?: Record<string, any>;
labelWidth?: string;
/** 是否开启类型匹配校验 */
typeMatchValid?: boolean;
disabled?: boolean;
height?: string;
stepActive?: string | number;
@ -134,6 +136,11 @@ const props = withDefaults(
const emit = defineEmits(['change', 'error', 'field-input', 'field-change', 'update:stepActive']);
provide(
FORM_TYPE_MATCH_VALID_KEY,
computed(() => props.typeMatchValid),
);
const tMagicFormRef = useTemplateRef('tMagicForm');
const initialized = ref(false);
const values = ref<FormValue>({});

View File

@ -15,6 +15,7 @@
:prevent-submit-default="preventSubmitDefault"
:use-field-text-in-error="useFieldTextInError"
:extend-state="extendState"
:type-match-valid="typeMatchValid"
@change="changeHandler"
></Form>
<slot></slot>
@ -56,6 +57,8 @@ const props = withDefaults(
width?: number;
height?: number;
labelWidth?: string;
/** 是否开启类型匹配校验 */
typeMatchValid?: boolean;
disabled?: boolean;
size?: 'small' | 'default' | 'large';
confirmText?: string;

View File

@ -32,6 +32,7 @@
:inline="inline"
:prevent-submit-default="preventSubmitDefault"
:use-field-text-in-error="useFieldTextInError"
:type-match-valid="typeMatchValid"
:extend-state="extendState"
@change="changeHandler"
></Form>
@ -83,6 +84,8 @@ const props = withDefaults(
parentValues?: Object;
width?: string | number;
labelWidth?: string;
/** 是否开启类型匹配校验 */
typeMatchValid?: boolean;
fullscreen?: boolean;
disabled?: boolean;
title?: string;

View File

@ -29,6 +29,7 @@
:inline="inline"
:prevent-submit-default="preventSubmitDefault"
:use-field-text-in-error="useFieldTextInError"
:type-match-valid="typeMatchValid"
:extend-state="extendState"
@change="changeHandler"
></Form>
@ -74,6 +75,8 @@ withDefaults(
parentValues?: Object;
width?: string | number;
labelWidth?: string;
/** 是否开启类型匹配校验 */
typeMatchValid?: boolean;
disabled?: boolean;
closeOnPressEscape?: boolean;
title?: string;

View File

@ -249,7 +249,7 @@ import type {
FormValue,
ToolTipConfigType,
} from '../schema';
import { FORM_DIFF_CONFIG_KEY } from '../schema';
import { FORM_DIFF_CONFIG_KEY, FORM_TYPE_MATCH_VALID_KEY } from '../schema';
import { getField } from '../utils/config';
import { createObjectProp, display as displayFunction, filterFunction, getRules } from '../utils/form';
@ -296,6 +296,8 @@ const mForm = inject<FormState | undefined>('mForm');
// MForm provide inject prop
const diffConfig = inject<FormDiffConfig>(FORM_DIFF_CONFIG_KEY, {});
const typeMatchValid = inject(FORM_TYPE_MATCH_VALID_KEY);
const expand = ref(false);
const name = computed(() => props.config.name || '');
@ -411,7 +413,7 @@ const tooltip = computed(() => {
};
});
const rule = computed(() => getRules(mForm, props.config.rules, props));
const rule = computed(() => getRules(mForm, props.config.rules, props, typeMatchValid));
const display = computed((): boolean => {
const value = displayFunction(mForm, props.config.display, props);

View File

@ -1,4 +1,4 @@
import type { InjectionKey } from 'vue';
import type { ComputedRef, InjectionKey } from 'vue';
import type { FormItemConfig } from '@tmagic/form-schema';
@ -27,6 +27,7 @@ export interface FormDiffConfig {
}
export const FORM_DIFF_CONFIG_KEY: InjectionKey<FormDiffConfig> = Symbol('mFormDiffConfig');
export const FORM_TYPE_MATCH_VALID_KEY: InjectionKey<ComputedRef<boolean>> = Symbol('mFormTypeMatchValid');
export interface ValidateError {
message: string;

View File

@ -74,6 +74,7 @@ export interface SubmitFormOptions {
* `timeout`
*/
debug?: boolean;
typeMatchValid?: boolean;
}
// #endregion SubmitFormOptions
@ -538,6 +539,7 @@ export interface ValidateFormOptions {
* resolve `timeout`
*/
debug?: boolean;
typeMatchValid?: boolean;
}
// #endregion ValidateFormOptions

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { readonly } from 'vue';
import { ComputedRef, readonly } from 'vue';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { cloneDeep } from 'lodash-es';
@ -306,13 +306,24 @@ export const display = function (mForm: FormState | undefined, config: any, prop
return true;
};
export const getRules = function (mForm: FormState | undefined, rules: Rule[] | Rule = [], props: any) {
rules = cloneDeep(rules);
export const getRules = function (
mForm: FormState | undefined,
r: Rule[] | Rule = [],
props: any,
typeMatchValid?: ComputedRef<boolean>,
) {
let rules = cloneDeep(r);
if (typeof rules === 'object' && !Array.isArray(rules)) {
rules = [rules];
}
if (typeMatchValid?.value && !rules.some((r) => typeof r.typeMatch !== 'undefined')) {
rules.push({
typeMatch: true,
});
}
return rules.map((item) => {
if (item.typeMatch) {
(item as any).validator = adaptFormValidator(createTypeMatchValidator(mForm, props, item));

View File

@ -20,6 +20,7 @@ import { readonly } from 'vue';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import { appendValidateSuggestion } from '@tmagic/design';
import { getValueByKeyPath } from '@tmagic/utils';
import type { CascaderOption, FormState, Rule } from '../schema';
@ -123,7 +124,106 @@ const DATE_LIKE_DEFAULT_VALUE_FORMAT: Record<string, string> = {
const TIMESTAMP_VALUE_FORMATS = new Set(['x', 'timestamp']);
const defaultMessage = (message: string | undefined, fallback: string) => message || fallback;
/**
*
*/
const stringifyExampleValue = (value: any): string => {
if (typeof value === 'string') return `"${value}"`;
if (value === null || value === undefined) return String(value);
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
};
// 参考建议中最多展示的可选值个数,超出以「等」省略。
const MAX_SUGGESTION_OPTIONS = 5;
/**
* 使xxxxxx
* MAX_SUGGESTION_OPTIONS
*/
const optionSuggestion = (optionValues: any[]): string => {
const values = optionValues.filter((item) => typeof item !== 'undefined');
if (!values.length) return '';
const shown = values.slice(0, MAX_SUGGESTION_OPTIONS).map(stringifyExampleValue);
const suffix = values.length > MAX_SUGGESTION_OPTIONS ? ' 等' : '';
return `请使用以下某一个值:${shown.join('')}${suffix}`;
};
/**
* options
*
* - expectArray true
* -
* options 退
*/
const optionExampleSuggestion = (optionValues: any[], fallbackExample: string, expectArray: boolean): string => {
const values = optionValues.filter((item) => typeof item !== 'undefined');
if (!values.length) return `请参考以下示例值:${fallbackExample}`;
const example = expectArray ? stringifyExampleValue(values.slice(0, 2)) : stringifyExampleValue(values[0]);
return `请参考以下示例值:${example}`;
};
/**
*
*/
const toggleSuggestion = (activeValue: any, inactiveValue: any): string =>
`请使用以下某一个值:${stringifyExampleValue(activeValue)}${stringifyExampleValue(inactiveValue)}`;
/**
* defaultValue
* defaultValue resolveConfig undefined
*/
const resolveFieldDefaultValue = (mForm: FormState | undefined, props: any): any => {
const { defaultValue } = props.config || {};
if (typeof defaultValue === 'undefined' || defaultValue === 'undefined') return undefined;
return resolveConfig(mForm, defaultValue, props);
};
const isNumberValue = (value: any) => typeof value === 'number' && !Number.isNaN(value);
const isStringValue = (value: any) => typeof value === 'string';
const isObjectValue = (value: any) => typeof value === 'object' && value !== null && !Array.isArray(value);
const isObjectArrayValue = (value: any) => Array.isArray(value) && value.every((item) => isObjectValue(item));
const isNumberRangeValue = (value: any) =>
Array.isArray(value) && value.length === 2 && value.every((item) => isNumberValue(item));
/**
*
*
* 使defaultValue退
*/
const typeExampleSuggestion = (
mForm: FormState | undefined,
props: any,
fallbackExample: string,
isValid: (value: any) => boolean,
): string => {
const defaultValue = resolveFieldDefaultValue(mForm, props);
const example =
typeof defaultValue !== 'undefined' && isValid(defaultValue)
? stringifyExampleValue(defaultValue)
: fallbackExample;
return `请参考以下示例值:${example}`;
};
/**
*
*
* - `message` 使
* - 使 `fallback`
*
* form-item @tmagic/design FormItem
* / @tmagic/design `appendValidateSuggestion`
*/
const defaultMessage = (message: string | undefined, fallback: string, suggestion?: string) => {
if (message) return message;
return appendValidateSuggestion(fallback, suggestion);
};
const isEmptyValue = (value: any) => value === undefined || value === null || value === '';
@ -145,8 +245,31 @@ const isValidDateValueByFormat = (value: any, valueFormat: string): boolean => {
return dayjs(value, valueFormat, true).isValid();
};
/**
* valueFormat
*/
const dateSuggestion = (valueFormat: string): string =>
isTimestampValueFormat(valueFormat)
? `请参考以下示例值:${Date.now()}`
: `请参考以下示例值:"${dayjs().format(valueFormat)}"`;
/**
* 2
*/
const dateRangeSuggestion = (valueFormat: string): string => {
if (isTimestampValueFormat(valueFormat)) {
return `请参考以下示例值:[${Date.now()}, ${Date.now()}]`;
}
const example = dayjs().format(valueFormat);
return `请参考以下示例值:["${example}", "${example}"]`;
};
const dateValueFormatErrorMessage = (message: string | undefined, valueFormat: string) =>
defaultMessage(message, isTimestampValueFormat(valueFormat) ? '值类型应为时间戳数字' : `值格式应为 ${valueFormat}`);
defaultMessage(
message,
isTimestampValueFormat(valueFormat) ? '值类型应为时间戳数字' : `值格式应为 ${valueFormat}`,
dateSuggestion(valueFormat),
);
const resolveFieldType = (mForm: FormState | undefined, props: any): string => {
let type = 'type' in (props.config || {}) ? props.config.type : '';
@ -230,6 +353,51 @@ const isValidCascaderPath = (options: CascaderOption[], path: any[]): boolean =>
return false;
};
/**
* cascader options value
*/
const firstCascaderPath = (options: CascaderOption[]): any[] => {
const path: any[] = [];
let current: CascaderOption[] | undefined = options;
while (current?.length) {
const node: CascaderOption = current[0];
if (typeof node.value === 'undefined') break;
path.push(node.value);
current = node.children;
}
return path;
};
/**
* cascader options
*
* valueSeparator / multiple / emitPath options 退
*/
const cascaderExampleSuggestion = (
options: CascaderOption[],
config: any,
valueSeparator: string | undefined,
fallbackExample: string,
): string => {
const path = firstCascaderPath(options);
if (!path.length) return `请参考以下示例值:${fallbackExample}`;
const emitPath = config.emitPath !== false;
const multiple = Boolean(config.multiple);
// 单个选中值emitPath 时为完整路径数组,否则为叶子值
const single = emitPath ? path : path[path.length - 1];
let example: any;
if (valueSeparator) {
example = path.join(valueSeparator);
} else if (multiple) {
example = [single];
} else {
example = single;
}
return `请参考以下示例值:${stringifyExampleValue(example)}`;
};
const validateSelectValue = (
value: any,
config: any,
@ -239,29 +407,44 @@ const validateSelectValue = (
if (config.allowCreate || config.remote) {
if (config.multiple) {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value} 类型应为数组`);
return defaultMessage(
message,
`${value} 类型应为数组`,
optionExampleSuggestion(optionValues, '["选项1", "选项2"]', true),
);
}
return undefined;
}
if (typeof value === 'object') {
return defaultMessage(message, `${value} 类型不合法`);
return defaultMessage(
message,
`${value} 类型不合法`,
optionExampleSuggestion(optionValues, '"文本内容" 或 123', false),
);
}
return undefined;
}
// 仅当 options 为静态数组时才校验值是否在可选项中,动态 options函数形式跳过
const isStaticOptions = Array.isArray(config.options);
if (config.multiple) {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value} 类型应为数组`);
return defaultMessage(
message,
`${value} 类型应为数组`,
optionExampleSuggestion(optionValues, '["选项1", "选项2"]', true),
);
}
if (value.some((item) => !includesOptionValue(optionValues, item))) {
return defaultMessage(message, `${value} 不在可选项中`);
if (isStaticOptions && value.some((item) => !includesOptionValue(optionValues, item))) {
return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(optionValues));
}
return undefined;
}
if (!includesOptionValue(optionValues, value)) {
return defaultMessage(message, `${value} 不在可选项中`);
if (isStaticOptions && !includesOptionValue(optionValues, value)) {
return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(optionValues));
}
return undefined;
};
@ -276,24 +459,30 @@ const validateCascaderValue = (
const valueSeparator = resolveConfig<string | undefined>(mForm, config.valueSeparator, props);
const emitPath = config.emitPath !== false;
const multiple = Boolean(config.multiple);
const options = resolveOptions(mForm, props) as CascaderOption[];
const cascaderExample = (fallbackExample: string) =>
cascaderExampleSuggestion(options, config, valueSeparator, fallbackExample);
if (valueSeparator) {
if (typeof value !== 'string' && !Array.isArray(value)) {
return defaultMessage(message, `${value} 类型应为字符串或数组`);
return defaultMessage(
message,
`${value} 类型应为字符串或数组`,
cascaderExample('"选项1,选项2" 或 ["选项1", "选项2"]'),
);
}
} else if (multiple) {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value} 类型应为数组`);
return defaultMessage(message, `${value} 类型应为数组`, cascaderExample('["选项1", "选项2"]'));
}
} else if (emitPath && !Array.isArray(value)) {
return defaultMessage(message, `${value} 类型应为数组`);
return defaultMessage(message, `${value} 类型应为数组`, cascaderExample('["选项1", "选项2"]'));
}
if (config.remote) {
return undefined;
}
const options = resolveOptions(mForm, props) as CascaderOption[];
if (!options.length) {
return undefined;
}
@ -302,7 +491,7 @@ const validateCascaderValue = (
if (multiple) {
if (!Array.isArray(normalizedValue)) {
return defaultMessage(message, `${value} 类型应为数组`);
return defaultMessage(message, `${value} 类型应为数组`, cascaderExample('["选项1", "选项2"]'));
}
const invalid = normalizedValue.some((item) => {
@ -313,20 +502,20 @@ const validateCascaderValue = (
});
if (invalid) {
return defaultMessage(message, `${value} 不在可选项中`);
return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(collectCascaderLeafValues(options)));
}
return undefined;
}
if (emitPath) {
if (!Array.isArray(normalizedValue) || !isValidCascaderPath(options, normalizedValue)) {
return defaultMessage(message, `${value} 不在可选项中`);
return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(collectCascaderLeafValues(options)));
}
return undefined;
}
if (!includesOptionValue(collectCascaderLeafValues(options), normalizedValue)) {
return defaultMessage(message, `${value} 不在可选项中`);
return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(collectCascaderLeafValues(options)));
}
return undefined;
};
@ -347,7 +536,11 @@ const validateBuiltinTypeMatch = (
if (STRING_TYPES.has(fieldType)) {
if (config.filter === 'number') {
if (typeof value !== 'number' || Number.isNaN(value)) {
return defaultMessage(message, `${value} 类型应为数字`);
return defaultMessage(
message,
`${value} 类型应为数字`,
typeExampleSuggestion(mForm, props, '123', isNumberValue),
);
}
return undefined;
}
@ -358,7 +551,11 @@ const validateBuiltinTypeMatch = (
}
if (typeof value !== 'string') {
return defaultMessage(message, `${value} 类型应为字符串`);
return defaultMessage(
message,
`${value} 类型应为字符串`,
typeExampleSuggestion(mForm, props, '"文本内容"', isStringValue),
);
}
return undefined;
}
@ -373,7 +570,11 @@ const validateBuiltinTypeMatch = (
if (fieldType === 'number') {
if (typeof value !== 'number' || Number.isNaN(value)) {
return defaultMessage(message, `${value} 类型应为数字`);
return defaultMessage(
message,
`${value} 类型应为数字`,
typeExampleSuggestion(mForm, props, '123', isNumberValue),
);
}
return undefined;
}
@ -384,7 +585,11 @@ const validateBuiltinTypeMatch = (
value.length !== 2 ||
value.some((item) => typeof item !== 'number' || Number.isNaN(item))
) {
return defaultMessage(message, `${value} 类型应为长度为 2 的数字数组`);
return defaultMessage(
message,
`${value} 类型应为长度为 2 的数字数组`,
typeExampleSuggestion(mForm, props, '[0, 100]', isNumberRangeValue),
);
}
return undefined;
}
@ -392,7 +597,7 @@ const validateBuiltinTypeMatch = (
if (fieldType === 'switch' || fieldType === 'checkbox') {
const { activeValue, inactiveValue } = resolveToggleValues(config);
if (!Object.is(value, activeValue) && !Object.is(value, inactiveValue)) {
return defaultMessage(message, `${value} 不在合法开关值中`);
return defaultMessage(message, `${value} 不在合法开关值中`, toggleSuggestion(activeValue, inactiveValue));
}
return undefined;
}
@ -405,18 +610,22 @@ const validateBuiltinTypeMatch = (
if (fieldType === 'radio-group') {
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
if (!includesOptionValue(optionValues, value)) {
return defaultMessage(message, `${value} 不在可选项中`);
return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(optionValues));
}
return undefined;
}
if (fieldType === 'checkbox-group') {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value} 类型应为数组`);
}
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
if (!Array.isArray(value)) {
return defaultMessage(
message,
`${value} 类型应为数组`,
optionExampleSuggestion(optionValues, '["选项1", "选项2"]', true),
);
}
if (value.some((item) => !includesOptionValue(optionValues, item))) {
return defaultMessage(message, `${value} 不在可选项中`);
return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(optionValues));
}
return undefined;
}
@ -440,14 +649,19 @@ const validateBuiltinTypeMatch = (
isTimestampValueFormat(valueFormat)
? `${value} 类型应为长度为 2 的时间戳数字数组`
: `${value} 格式应为长度为 2 的 ${valueFormat} 数组`,
dateRangeSuggestion(valueFormat),
);
}
return undefined;
}
if (fieldType === 'table' || fieldType === 'group-list' || fieldType === 'grouplist') {
if (!Array.isArray(value)) {
return defaultMessage(message, `${value} 类型应为数组`);
if (!Array.isArray(value) || value.some((item) => !isObjectValue(item))) {
return defaultMessage(
message,
`${value} 类型应为对象数组`,
typeExampleSuggestion(mForm, props, '[{}]', isObjectArrayValue),
);
}
return undefined;
}

View File

@ -76,15 +76,21 @@ describe('validateTypeMatch', () => {
test('text 期望 string', () => {
expect(validateTypeMatch('ok', mForm, propsOf({ type: 'text' }))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({ type: 'text' }))).toBe('1 类型应为字符串');
expect(validateTypeMatch(1, mForm, propsOf({ type: 'text' }))).toBe(
'1 类型应为字符串\n\n请参考以下示例值"文本内容"',
);
expect(validateTypeMatch(1, mForm, propsOf({ type: 'text' }), '自定义错误')).toBe('自定义错误');
});
test('text filter=number 时期望 number', () => {
expect(validateTypeMatch(1, mForm, propsOf({ type: 'text', filter: 'number' }))).toBeUndefined();
expect(validateTypeMatch(0, mForm, propsOf({ type: 'text', filter: 'number' }))).toBeUndefined();
expect(validateTypeMatch('1', mForm, propsOf({ type: 'text', filter: 'number' }))).toBe('1 类型应为数字');
expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'textarea', filter: 'number' }))).toBe('NaN 类型应为数字');
expect(validateTypeMatch('1', mForm, propsOf({ type: 'text', filter: 'number' }))).toBe(
'1 类型应为数字\n\n请参考以下示例值123',
);
expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'textarea', filter: 'number' }))).toBe(
'NaN 类型应为数字\n\n请参考以下示例值123',
);
});
test('text 自定义 filter 函数时跳过内置类型校验', () => {
@ -93,16 +99,24 @@ describe('validateTypeMatch', () => {
test('number 期望 number', () => {
expect(validateTypeMatch(1, mForm, propsOf({ type: 'number' }))).toBeUndefined();
expect(validateTypeMatch('1', mForm, propsOf({ type: 'number' }))).toBe('1 类型应为数字');
expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'number' }))).toBe('NaN 类型应为数字');
expect(validateTypeMatch('1', mForm, propsOf({ type: 'number' }))).toBe('1 类型应为数字\n\n请参考以下示例值123');
expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'number' }))).toBe(
'NaN 类型应为数字\n\n请参考以下示例值123',
);
});
test('date / time / datetime 按 valueFormatDay.js format校验', () => {
// 默认 date: YYYY/MM/DD
expect(validateTypeMatch('2020/01/01', mForm, propsOf({ type: 'date' }))).toBeUndefined();
expect(validateTypeMatch('2020-01-01', mForm, propsOf({ type: 'date' }))).toBe('值格式应为 YYYY/MM/DD');
expect(validateTypeMatch(new Date(), mForm, propsOf({ type: 'datetime' }))).toBe('值格式应为 YYYY/MM/DD HH:mm:ss');
expect(validateTypeMatch(1710000000000, mForm, propsOf({ type: 'time' }))).toBe('值格式应为 HH:mm:ss');
expect(validateTypeMatch('2020-01-01', mForm, propsOf({ type: 'date' }))).toMatch(
/^值格式应为 YYYY\/MM\/DD\n\n请参考以下示例值"\d{4}\/\d{2}\/\d{2}"$/,
);
expect(validateTypeMatch(new Date(), mForm, propsOf({ type: 'datetime' }))).toMatch(
/^值格式应为 YYYY\/MM\/DD HH:mm:ss\n\n请参考以下示例值"\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}"$/,
);
expect(validateTypeMatch(1710000000000, mForm, propsOf({ type: 'time' }))).toMatch(
/^值格式应为 HH:mm:ss\n\n请参考以下示例值"\d{2}:\d{2}:\d{2}"$/,
);
expect(validateTypeMatch('12:30:00', mForm, propsOf({ type: 'time' }))).toBeUndefined();
// 自定义格式
@ -118,28 +132,34 @@ describe('validateTypeMatch', () => {
validateTypeMatch(1710000000000, mForm, propsOf({ type: 'date', valueFormat: 'timestamp' })),
).toBeUndefined();
expect(validateTypeMatch(1710000000000, mForm, propsOf({ type: 'datetime', valueFormat: 'x' }))).toBeUndefined();
expect(validateTypeMatch('2020-01-01', mForm, propsOf({ type: 'date', valueFormat: 'x' }))).toBe(
'值类型应为时间戳数字',
expect(validateTypeMatch('2020-01-01', mForm, propsOf({ type: 'date', valueFormat: 'x' }))).toMatch(
/^值类型应为时间戳数字\n\n请参考以下示例值\d+$/,
);
});
test('switch / checkbox 默认 true/false', () => {
expect(validateTypeMatch(true, mForm, propsOf({ type: 'switch' }))).toBeUndefined();
expect(validateTypeMatch(false, mForm, propsOf({ type: 'checkbox' }))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({ type: 'switch' }))).toBe('1 不在合法开关值中');
expect(validateTypeMatch(1, mForm, propsOf({ type: 'switch' }))).toBe(
'1 不在合法开关值中\n\n请使用以下某一个值truefalse',
);
});
test('switch / checkbox filter=number 时为 1/0', () => {
expect(validateTypeMatch(1, mForm, propsOf({ type: 'switch', filter: 'number' }))).toBeUndefined();
expect(validateTypeMatch(0, mForm, propsOf({ type: 'checkbox', filter: 'number' }))).toBeUndefined();
expect(validateTypeMatch(true, mForm, propsOf({ type: 'switch', filter: 'number' }))).toBe('true 不在合法开关值中');
expect(validateTypeMatch(true, mForm, propsOf({ type: 'switch', filter: 'number' }))).toBe(
'true 不在合法开关值中\n\n请使用以下某一个值10',
);
});
test('switch / checkbox 自定义 activeValue/inactiveValue', () => {
const config = { type: 'switch', activeValue: 'on', inactiveValue: 'off' };
expect(validateTypeMatch('on', mForm, propsOf(config))).toBeUndefined();
expect(validateTypeMatch('off', mForm, propsOf(config))).toBeUndefined();
expect(validateTypeMatch('maybe', mForm, propsOf(config))).toBe('maybe 不在合法开关值中');
expect(validateTypeMatch('maybe', mForm, propsOf(config))).toBe(
'maybe 不在合法开关值中\n\n请使用以下某一个值"on""off"',
);
});
test('select 单选值必须在 options 中', () => {
@ -151,7 +171,17 @@ describe('validateTypeMatch', () => {
],
};
expect(validateTypeMatch(1, mForm, propsOf(config))).toBeUndefined();
expect(validateTypeMatch(3, mForm, propsOf(config))).toBe('3 不在可选项中');
expect(validateTypeMatch(3, mForm, propsOf(config))).toBe('3 不在可选项中\n\n请使用以下某一个值12');
});
test('可选项超过 5 个时建议仅展示前 5 个并以「等」省略', () => {
const config = {
type: 'select',
options: [1, 2, 3, 4, 5, 6, 7].map((v) => ({ text: `${v}`, value: v })),
};
expect(validateTypeMatch(99, mForm, propsOf(config))).toBe(
'99 不在可选项中\n\n请使用以下某一个值12345 等',
);
});
test('select multiple 校验数组元素', () => {
@ -164,8 +194,20 @@ describe('validateTypeMatch', () => {
],
};
expect(validateTypeMatch(['a'], mForm, propsOf(config))).toBeUndefined();
expect(validateTypeMatch(['a', 'c'], mForm, propsOf(config))).toBe('a,c 不在可选项中');
expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('a 类型应为数组');
expect(validateTypeMatch(['a', 'c'], mForm, propsOf(config))).toBe(
'a,c 不在可选项中\n\n请使用以下某一个值"a""b"',
);
// multiple 类型不匹配时,示例值基于真实 options前 2 个值组成的数组)
expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('a 类型应为数组\n\n请参考以下示例值["a","b"]');
});
test('select multiple 仅 1 个 option 时类型不匹配示例为单元素数组', () => {
const config = {
type: 'select',
multiple: true,
options: [{ text: 'A', value: 'a' }],
};
expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('a 类型应为数组\n\n请参考以下示例值["a"]');
});
test('select options 为函数 / group', () => {
@ -174,7 +216,8 @@ describe('validateTypeMatch', () => {
options: () => [{ text: 'A', value: 1 }],
};
expect(validateTypeMatch(1, mForm, propsOf(fnConfig))).toBeUndefined();
expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBe('2 不在可选项中');
// options 为函数(动态)时,跳过「不在可选项中」枚举校验
expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBeUndefined();
const groupConfig = {
type: 'select',
@ -188,17 +231,38 @@ describe('validateTypeMatch', () => {
],
};
expect(validateTypeMatch('a', mForm, propsOf(groupConfig))).toBeUndefined();
expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('b 不在可选项中');
expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('b 不在可选项中\n\n请使用以下某一个值"a"');
});
test('select allowCreate / remote 不做枚举', () => {
expect(validateTypeMatch('custom', mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined();
// allowCreate 无 options 时类型不合法示例回退到通用示例
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe(
'[object Object] 类型不合法',
'[object Object] 类型不合法\n\n请参考以下示例值"文本内容" 或 123',
);
expect(validateTypeMatch(['x'], mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBeUndefined();
// remote multiple 无 options 时类型不匹配示例回退到通用数组示例
expect(validateTypeMatch('x', mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBe(
'x 类型应为数组',
'x 类型应为数组\n\n请参考以下示例值["选项1", "选项2"]',
);
});
test('select allowCreate 有 options 时类型不匹配示例用真实 options', () => {
// allowCreate + options非 multiple 传 object示例取第一个真实 option 值
const config = {
type: 'select',
allowCreate: true,
options: [
{ text: 'A', value: 'a' },
{ text: 'B', value: 'b' },
],
};
expect(validateTypeMatch({ a: 1 }, mForm, propsOf(config))).toBe(
'[object Object] 类型不合法\n\n请参考以下示例值"a"',
);
// allowCreate + multiple + options传非数组示例取前 2 个真实 option 值组成数组
expect(validateTypeMatch('a', mForm, propsOf({ ...config, multiple: true }))).toBe(
'a 类型应为数组\n\n请参考以下示例值["a","b"]',
);
});
@ -211,7 +275,7 @@ describe('validateTypeMatch', () => {
],
};
expect(validateTypeMatch(1, mForm, propsOf(radio))).toBeUndefined();
expect(validateTypeMatch(3, mForm, propsOf(radio))).toBe('3 不在可选项中');
expect(validateTypeMatch(3, mForm, propsOf(radio))).toBe('3 不在可选项中\n\n请使用以下某一个值12');
const checkboxGroup = {
type: 'checkbox-group',
@ -221,7 +285,9 @@ describe('validateTypeMatch', () => {
],
};
expect(validateTypeMatch(['a', 'b'], mForm, propsOf(checkboxGroup))).toBeUndefined();
expect(validateTypeMatch(['c'], mForm, propsOf(checkboxGroup))).toBe('c 不在可选项中');
expect(validateTypeMatch(['c'], mForm, propsOf(checkboxGroup))).toBe(
'c 不在可选项中\n\n请使用以下某一个值"a""b"',
);
});
test('cascader 静态路径与 valueSeparator', () => {
@ -240,33 +306,52 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch(['zhejiang', 'hangzhou'], mForm, propsOf({ type: 'cascader', options }))).toBeUndefined();
expect(validateTypeMatch(['zhejiang', 'ningbo'], mForm, propsOf({ type: 'cascader', options }))).toBe(
'zhejiang,ningbo 不在可选项中',
'zhejiang,ningbo 不在可选项中\n\n请使用以下某一个值"hangzhou"',
);
expect(
validateTypeMatch('zhejiang/hangzhou', mForm, propsOf({ type: 'cascader', options, valueSeparator: '/' })),
).toBeUndefined();
expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', remote: true }))).toBe('bad 类型应为数组');
// remote 无 options 时类型不匹配示例回退到通用数组示例
expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', remote: true }))).toBe(
'bad 类型应为数组\n\n请参考以下示例值["选项1", "选项2"]',
);
expect(validateTypeMatch(['a'], mForm, propsOf({ type: 'cascader', remote: true }))).toBeUndefined();
});
test('number-range / daterange / table', () => {
expect(validateTypeMatch([1, 2], mForm, propsOf({ type: 'number-range' }))).toBeUndefined();
expect(validateTypeMatch([1], mForm, propsOf({ type: 'number-range' }))).toBe('1 类型应为长度为 2 的数字数组');
expect(validateTypeMatch([1], mForm, propsOf({ type: 'number-range' }))).toBe(
'1 类型应为长度为 2 的数字数组\n\n请参考以下示例值[0, 100]',
);
expect(
validateTypeMatch(['2020/01/01 00:00:00', '2020/01/02 00:00:00'], mForm, propsOf({ type: 'daterange' })),
).toBeUndefined();
expect(validateTypeMatch(['2020-01-01', '2020-01-02'], mForm, propsOf({ type: 'daterange' }))).toBe(
'2020-01-01,2020-01-02 格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组',
expect(validateTypeMatch(['2020-01-01', '2020-01-02'], mForm, propsOf({ type: 'daterange' }))).toMatch(
/^2020-01-01,2020-01-02 格式应为长度为 2 的 YYYY\/MM\/DD HH:mm:ss 数组\n\n请参考以下示例值\["\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}", "\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}"\]$/,
);
expect(validateTypeMatch(['a'], mForm, propsOf({ type: 'daterange' }))).toBe(
'a 格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组',
expect(validateTypeMatch(['a'], mForm, propsOf({ type: 'daterange' }))).toMatch(
/^a 格式应为长度为 2 的 YYYY\/MM\/DD HH:mm:ss 数组\n\n请参考以下示例值\["\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}", "\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}"\]$/,
);
expect(validateTypeMatch([1, 2], mForm, propsOf({ type: 'daterange', valueFormat: 'timestamp' }))).toBeUndefined();
expect(validateTypeMatch('x', mForm, propsOf({ type: 'daterange', names: ['a', 'b'] }))).toBeUndefined();
expect(validateTypeMatch([{ id: 1 }], mForm, propsOf({ type: 'table' }))).toBeUndefined();
expect(validateTypeMatch({}, mForm, propsOf({ type: 'groupList' }))).toBe('[object Object] 类型应为数组');
// table 无 options类型不匹配示例回退到通用对象数组示例
expect(validateTypeMatch({}, mForm, propsOf({ type: 'groupList' }))).toBe(
'[object Object] 类型应为对象数组\n\n请参考以下示例值[{}]',
);
// table / group-list 元素必须为对象,字符串数组不合法
expect(validateTypeMatch(['a', 'b'], mForm, propsOf({ type: 'table' }))).toBe(
'a,b 类型应为对象数组\n\n请参考以下示例值[{}]',
);
expect(validateTypeMatch([1], mForm, propsOf({ type: 'group-list' }))).toBe(
'1 类型应为对象数组\n\n请参考以下示例值[{}]',
);
// 数组中混入非对象元素也不合法
expect(validateTypeMatch([{ id: 1 }, 'x'], mForm, propsOf({ type: 'grouplist' }))).toBe(
'[object Object],x 类型应为对象数组\n\n请参考以下示例值[{}]',
);
});
test('容器类字段 no-op', () => {
@ -276,25 +361,28 @@ describe('validateTypeMatch', () => {
test('无 type 默认按 text 校验', () => {
expect(validateTypeMatch('ok', mForm, propsOf({}))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({}))).toBe('1 类型应为字符串');
expect(validateTypeMatch(1, mForm, propsOf({}))).toBe('1 类型应为字符串\n\n请参考以下示例值"文本内容"');
});
test('textarea / color-picker / html 期望 string', () => {
expect(validateTypeMatch('ok', mForm, propsOf({ type: 'textarea' }))).toBeUndefined();
expect(validateTypeMatch('ok', mForm, propsOf({ type: 'color-picker' }))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({ type: 'html' }))).toBe('1 类型应为字符串');
expect(validateTypeMatch(1, mForm, propsOf({ type: 'html' }))).toBe(
'1 类型应为字符串\n\n请参考以下示例值"文本内容"',
);
});
test('checkbox-group 非数组', () => {
// 类型不匹配时示例基于真实 options仅 1 个 option → 单元素数组)
expect(
validateTypeMatch('a', mForm, propsOf({ type: 'checkbox-group', options: [{ text: 'A', value: 'a' }] })),
).toBe('a 类型应为数组');
).toBe('a 类型应为数组\n\n请参考以下示例值["a"]');
});
test('timerange 按 valueFormat 校验', () => {
expect(validateTypeMatch(['12:00:00', '13:00:00'], mForm, propsOf({ type: 'timerange' }))).toBeUndefined();
expect(validateTypeMatch(['bad'], mForm, propsOf({ type: 'timerange' }))).toBe(
'bad 格式应为长度为 2 的 HH:mm:ss 数组',
expect(validateTypeMatch(['bad'], mForm, propsOf({ type: 'timerange' }))).toMatch(
/^bad 格式应为长度为 2 的 HH:mm:ss 数组\n\n请参考以下示例值\["\d{2}:\d{2}:\d{2}", "\d{2}:\d{2}:\d{2}"\]$/,
);
});
@ -310,7 +398,7 @@ describe('validateTypeMatch', () => {
validateTypeMatch('hangzhou', mForm, propsOf({ type: 'cascader', options, emitPath: false })),
).toBeUndefined();
expect(validateTypeMatch('ningbo', mForm, propsOf({ type: 'cascader', options, emitPath: false }))).toBe(
'ningbo 不在可选项中',
'ningbo 不在可选项中\n\n请使用以下某一个值"hangzhou"',
);
});
@ -327,7 +415,7 @@ describe('validateTypeMatch', () => {
).toBeUndefined();
expect(
validateTypeMatch(['ningbo'], mForm, propsOf({ type: 'cascader', options, multiple: true, emitPath: false })),
).toBe('ningbo 不在可选项中');
).toBe('ningbo 不在可选项中\n\n请使用以下某一个值"hangzhou"');
});
test('cascader valueSeparator 时数组值', () => {
@ -343,9 +431,35 @@ describe('validateTypeMatch', () => {
).toBeUndefined();
});
test('cascader 类型不匹配时示例基于真实路径', () => {
const options = [
{
value: 'zhejiang',
label: 'Zhejiang',
children: [{ value: 'hangzhou', label: 'Hangzhou' }],
},
];
// emitPath默认单选示例为完整路径数组
expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', options }))).toBe(
'bad 类型应为数组\n\n请参考以下示例值["zhejiang","hangzhou"]',
);
// multiple + emitPath示例为路径数组的数组
expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', options, multiple: true }))).toBe(
'bad 类型应为数组\n\n请参考以下示例值[["zhejiang","hangzhou"]]',
);
// valueSeparator示例为路径拼接字符串
expect(validateTypeMatch(123, mForm, propsOf({ type: 'cascader', options, valueSeparator: '/' }))).toBe(
'123 类型应为字符串或数组\n\n请参考以下示例值"zhejiang/hangzhou"',
);
// emitPath=false + multiple示例为叶子值组成的数组
expect(
validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', options, multiple: true, emitPath: false })),
).toBe('bad 类型应为数组\n\n请参考以下示例值["hangzhou"]');
});
test('select allowCreate 对象值非法', () => {
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe(
'[object Object] 类型不合法',
'[object Object] 类型不合法\n\n请参考以下示例值"文本内容" 或 123',
);
});