mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-21 18:06:51 +08:00
fix(form,editor): 完善表单校验与样式属性匹配
This commit is contained in:
parent
932974f0fa
commit
ef66e8a598
@ -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>
|
||||
|
||||
47
packages/design/src/formValidateMessage.ts
Normal file
47
packages/design/src/formValidateMessage.ts
Normal 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];
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 生成「请使用以下某一个值:xxx;xxx」形式的参考建议;无可选值时返回空字符串(不追加建议)。
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -60,6 +60,7 @@ vi.mock('@tmagic/design', () => ({
|
||||
return () => h('button', { class: 'fake-btn' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
tMagicMessage: () => {},
|
||||
}));
|
||||
|
||||
// 可控的 submitForm 实现:默认校验成功,测试可将其改为 reject 以模拟校验失败
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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 优先', () => {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>({});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 生成「请使用以下某一个值:xxx;xxx」形式的参考建议;无可选值时返回空字符串(不追加建议)。
|
||||
* 可选值超过 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;
|
||||
}
|
||||
|
||||
@ -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 按 valueFormat(Day.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请使用以下某一个值:true;false',
|
||||
);
|
||||
});
|
||||
|
||||
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请使用以下某一个值:1;0',
|
||||
);
|
||||
});
|
||||
|
||||
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请使用以下某一个值:1;2');
|
||||
});
|
||||
|
||||
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请使用以下某一个值:1;2;3;4;5 等',
|
||||
);
|
||||
});
|
||||
|
||||
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请使用以下某一个值:1;2');
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user