diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index c44f2c92..2a661e8e 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -488,6 +488,15 @@ export default defineConfig({ } ] }, + { + text: '表单校验', + items: [ + { + text: '表单校验', + link: '/form-config/rules.md', + } + ] + }, { text: '表单对比', items: [ diff --git a/docs/api/form/submit-form.md b/docs/api/form/submit-form.md index 30258340..8de8a6bc 100644 --- a/docs/api/form/submit-form.md +++ b/docs/api/form/submit-form.md @@ -68,7 +68,10 @@ try { type: 'text', name: 'username', text: '用户名', - rules: [{ required: true, message: '请输入用户名' }], + rules: [ + { required: true, message: '请输入用户名' }, + { typeMatch: true, message: '用户名类型不合法' }, + ], }, ], initValues: { username: '' }, diff --git a/docs/form-config/fields/select.md b/docs/form-config/fields/select.md index b855c8a0..af6fc8f4 100644 --- a/docs/form-config/fields/select.md +++ b/docs/form-config/fields/select.md @@ -24,6 +24,26 @@ type为'select' +## 选项值校验 + +可在 `rules` 中开启 `typeMatch`,校验当前值是否落在 `options` 中(`multiple` 时校验数组元素)。详见[表单校验](/form-config/rules.md)。 + +```ts +{ + type: 'select', + name: 'status', + text: '状态', + options: [ + { text: '启用', value: 1 }, + { text: '禁用', value: 0 }, + ], + rules: [ + { required: true, message: '请选择状态' }, + { typeMatch: true, message: '状态值不合法' }, + ], +} +``` + ## 有禁用选项 { + if (typeof value !== 'string') { + return message || 'my-field 应为字符串'; + } + }, + }, +}); +``` + 以上代码便完成了 @tmagic/form 的引入。需要注意的是,Element Plus 的样式文件需要单独引入。 diff --git a/packages/form-schema/src/base.ts b/packages/form-schema/src/base.ts index 9a2338d8..86aad1b7 100644 --- a/packages/form-schema/src/base.ts +++ b/packages/form-schema/src/base.ts @@ -163,10 +163,13 @@ export interface ContainerCommonConfig extends FormItem { } // #endregion ContainerCommonConfig +// #region Rule export interface Rule { message?: string; /** 系统提供的验证器类型。有:string,number,boolean,method,regexp,integer,float,array,object,enum,date,url,hex,email,any */ type?: string; + /** 是否按字段 config.type 校验值类型/选项匹配 */ + typeMatch?: boolean; /** 是否必填 */ required?: boolean; trigger?: string; @@ -195,6 +198,7 @@ export interface Rule { mForm: FormState | undefined, ) => void; } +// #endregion Rule // #region Input export interface Input { diff --git a/packages/form/src/index.ts b/packages/form/src/index.ts index 9583e5e1..f2cd6314 100644 --- a/packages/form/src/index.ts +++ b/packages/form/src/index.ts @@ -63,6 +63,17 @@ export { registerField as registerFormField, } from './utils/config'; +export { + clearTypeMatchRules, + deleteTypeMatchRule, + getTypeMatchRule, + registerTypeMatchRule, + registerTypeMatchRules, + validateTypeMatch, +} from './utils/typeMatch'; + +export type { TypeMatchValidateContext, TypeMatchValidator } from './utils/typeMatch'; + export type { FormInstallOptions } from './plugin'; export const createForm = (config: FormConfig | T) => config; diff --git a/packages/form/src/plugin.ts b/packages/form/src/plugin.ts index 46ffedd3..4a121745 100644 --- a/packages/form/src/plugin.ts +++ b/packages/form/src/plugin.ts @@ -47,14 +47,19 @@ import Textarea from './fields/Textarea.vue'; import Time from './fields/Time.vue'; import Timerange from './fields/Timerange.vue'; import { setConfig } from './utils/config'; +import { registerTypeMatchRules, type TypeMatchValidator } from './utils/typeMatch'; import Form from './Form.vue'; import FormDialog from './FormDialog.vue'; import './theme/index.scss'; +// #region FormInstallOptions export interface FormInstallOptions { + /** 自定义字段 type 的 typeMatch 校验规则,可覆盖内置规则或扩展业务字段 */ + typeMatchRules?: Record; [key: string]: any; } +// #endregion FormInstallOptions const defaultInstallOpt: FormInstallOptions = {}; @@ -65,6 +70,10 @@ export default { app.config.globalProperties.$MAGIC_FORM = option; setConfig(option); + if (option.typeMatchRules) { + registerTypeMatchRules(option.typeMatchRules); + } + app.component('m-form', Form); app.component('m-form-dialog', FormDialog); app.component('m-form-container', Container); diff --git a/packages/form/src/utils/form.ts b/packages/form/src/utils/form.ts index 6ff56b86..d2e96856 100644 --- a/packages/form/src/utils/form.ts +++ b/packages/form/src/utils/form.ts @@ -39,6 +39,8 @@ import type { TypeFunction, } from '../schema'; +import { createTypeMatchValidator } from './typeMatch'; + interface DefaultItem { defaultValue: any; type: string; @@ -261,6 +263,11 @@ export const getRules = function (mForm: FormState | undefined, rules: Rule[] | } return rules.map((item) => { + if (item.typeMatch) { + (item as any).validator = createTypeMatchValidator(mForm, props, item); + return item; + } + if (typeof item.validator === 'function') { const fnc = item.validator; diff --git a/packages/form/src/utils/typeMatch.ts b/packages/form/src/utils/typeMatch.ts new file mode 100644 index 00000000..f8ab2b56 --- /dev/null +++ b/packages/form/src/utils/typeMatch.ts @@ -0,0 +1,514 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readonly } from 'vue'; +import dayjs from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; + +import { getValueByKeyPath } from '@tmagic/utils'; + +import type { CascaderOption, FormState, Rule } from '../schema'; + +// dayjs.extend 内部有防重复机制,可安全多次调用 +dayjs.extend(customParseFormat); + +// #region TypeMatchValidateContext +export interface TypeMatchValidateContext { + fieldType: string; + mForm: FormState | undefined; + props: any; + message?: string; +} +// #endregion TypeMatchValidateContext + +// #region TypeMatchValidator +/** 自定义 type 校验器:返回错误文案;通过则返回 undefined */ +export type TypeMatchValidator = (value: any, context: TypeMatchValidateContext) => string | undefined; +// #endregion TypeMatchValidator + +const typeMatchRuleRegistry = new Map(); + +const normalizeType = (type: string) => type.replace(/([A-Z])/g, '-$1').toLowerCase(); + +/** 注册或覆盖某个字段 type 的 typeMatch 校验规则 */ +export const registerTypeMatchRule = (type: string, validator: TypeMatchValidator): void => { + typeMatchRuleRegistry.set(normalizeType(type), validator); +}; + +/** 批量注册 typeMatch 校验规则 */ +export const registerTypeMatchRules = (rules: Record): void => { + Object.entries(rules).forEach(([type, validator]) => { + registerTypeMatchRule(type, validator); + }); +}; + +/** 获取某个字段 type 的自定义 typeMatch 校验规则 */ +export const getTypeMatchRule = (type: string): TypeMatchValidator | undefined => + typeMatchRuleRegistry.get(normalizeType(type)); + +/** 删除某个字段 type 的自定义 typeMatch 校验规则 */ +export const deleteTypeMatchRule = (type: string): boolean => typeMatchRuleRegistry.delete(normalizeType(type)); + +/** 清空所有自定义 typeMatch 校验规则 */ +export const clearTypeMatchRules = (): void => { + typeMatchRuleRegistry.clear(); +}; + +/** 本地解析配置函数,避免与 form.ts 循环依赖 */ +const resolveConfig = ( + mForm: FormState | undefined, + config: T | ((...args: any[]) => T) | undefined, + props: any, +): T | undefined => { + if (typeof config === 'function') { + return (config as Function)(mForm, { + values: readonly(mForm?.initValues || {}), + model: readonly(props.model), + parent: readonly(mForm?.parentValues || {}), + formValue: readonly(mForm?.values || props.model), + prop: props.prop, + config: props.config, + index: props.index, + getFormValue: (prop: string) => getValueByKeyPath(prop, mForm?.values || props.model), + }); + } + + return config; +}; + +const SKIP_TYPES = new Set([ + 'display', + 'hidden', + 'row', + 'tab', + 'dynamic-tab', + 'fieldset', + 'panel', + 'step', + 'flex-layout', + 'link', + 'component', + 'dynamic-field', + 'dynamicfield', + 'form', + 'container', +]); + +const STRING_TYPES = new Set(['text', 'textarea', 'color-picker', 'html', '']); + +const DATE_LIKE_TYPES = new Set(['date', 'datetime', 'time']); + +const DATE_LIKE_DEFAULT_VALUE_FORMAT: Record = { + date: 'YYYY/MM/DD', + datetime: 'YYYY/MM/DD HH:mm:ss', + time: 'HH:mm:ss', + daterange: 'YYYY/MM/DD HH:mm:ss', + timerange: 'HH:mm:ss', +}; + +const TIMESTAMP_VALUE_FORMATS = new Set(['x', 'timestamp']); + +const defaultMessage = (message: string | undefined, fallback: string) => message || fallback; + +const isEmptyValue = (value: any) => value === undefined || value === null || value === ''; + +const isEmptyArray = (value: any) => Array.isArray(value) && value.length === 0; + +const resolveDateValueFormat = (fieldType: string, config: any): string => + config.valueFormat || DATE_LIKE_DEFAULT_VALUE_FORMAT[fieldType] || 'YYYY/MM/DD HH:mm:ss'; + +const isTimestampValueFormat = (valueFormat: string) => TIMESTAMP_VALUE_FORMATS.has(valueFormat); + +const isValidDateValueByFormat = (value: any, valueFormat: string): boolean => { + if (isTimestampValueFormat(valueFormat)) { + return typeof value === 'number' && !Number.isNaN(value); + } + if (typeof value !== 'string') { + return false; + } + // 按 Day.js format tokens 严格解析,参见 https://day.js.org/docs/en/display/format + return dayjs(value, valueFormat, true).isValid(); +}; + +const dateValueFormatErrorMessage = (message: string | undefined, valueFormat: string) => + defaultMessage(message, isTimestampValueFormat(valueFormat) ? '值类型应为时间戳数字' : `值格式应为 ${valueFormat}`); + +const resolveFieldType = (mForm: FormState | undefined, props: any): string => { + let type = 'type' in (props.config || {}) ? props.config.type : ''; + type = type ? resolveConfig(mForm, type, props) || '' : ''; + if (type === 'form' || type === 'container') return ''; + return normalizeType(type || '') || (props.config?.items ? '' : 'text'); +}; + +const resolveToggleValues = (config: any) => { + const filterIsNumber = config.filter === 'number'; + const { activeValue: configActiveValue, inactiveValue: configInactiveValue } = config; + + let activeValue = configActiveValue; + let inactiveValue = configInactiveValue; + + if (typeof activeValue === 'undefined') { + activeValue = filterIsNumber ? 1 : true; + } + + if (typeof inactiveValue === 'undefined') { + inactiveValue = filterIsNumber ? 0 : false; + } + + return { activeValue, inactiveValue }; +}; + +const flattenSelectOptions = (options: any[]): any[] => { + const values: any[] = []; + + options.forEach((option) => { + if (Array.isArray(option?.options)) { + option.options.forEach((child: any) => { + if (typeof child?.value !== 'undefined') { + values.push(child.value); + } + }); + return; + } + + if (typeof option?.value !== 'undefined') { + values.push(option.value); + } + }); + + return values; +}; + +const resolveOptions = (mForm: FormState | undefined, props: any): any[] => { + const { options } = props.config || {}; + if (Array.isArray(options)) return options; + if (typeof options === 'function') { + return resolveConfig(mForm, options, props) || []; + } + return []; +}; + +const includesOptionValue = (optionValues: any[], value: any) => optionValues.some((item) => Object.is(item, value)); + +const collectCascaderLeafValues = (options: CascaderOption[], result: any[] = []) => { + options.forEach((option) => { + if (option.children?.length) { + collectCascaderLeafValues(option.children, result); + } else if (typeof option.value !== 'undefined') { + result.push(option.value); + } + }); + return result; +}; + +const isValidCascaderPath = (options: CascaderOption[], path: any[]): boolean => { + if (!path.length) return false; + + let currentOptions = options; + for (let i = 0; i < path.length; i++) { + const node = currentOptions.find((option) => Object.is(option.value, path[i])); + if (!node) return false; + if (i === path.length - 1) return true; + currentOptions = node.children || []; + } + + return false; +}; + +const validateSelectValue = ( + value: any, + config: any, + optionValues: any[], + message: string | undefined, +): string | undefined => { + if (config.allowCreate || config.remote) { + if (config.multiple) { + if (!Array.isArray(value)) { + return defaultMessage(message, '值类型应为数组'); + } + return undefined; + } + + if (typeof value === 'object') { + return defaultMessage(message, '值类型不合法'); + } + return undefined; + } + + if (config.multiple) { + if (!Array.isArray(value)) { + return defaultMessage(message, '值类型应为数组'); + } + if (value.some((item) => !includesOptionValue(optionValues, item))) { + return defaultMessage(message, '值不在可选项中'); + } + return undefined; + } + + if (!includesOptionValue(optionValues, value)) { + return defaultMessage(message, '值不在可选项中'); + } + return undefined; +}; + +const validateCascaderValue = ( + value: any, + config: any, + props: any, + mForm: FormState | undefined, + message: string | undefined, +): string | undefined => { + const valueSeparator = resolveConfig(mForm, config.valueSeparator, props); + const emitPath = config.emitPath !== false; + const multiple = Boolean(config.multiple); + + if (valueSeparator) { + if (typeof value !== 'string' && !Array.isArray(value)) { + return defaultMessage(message, '值类型应为字符串或数组'); + } + } else if (multiple) { + if (!Array.isArray(value)) { + return defaultMessage(message, '值类型应为数组'); + } + } else if (emitPath && !Array.isArray(value)) { + return defaultMessage(message, '值类型应为数组'); + } + + if (config.remote) { + return undefined; + } + + const options = resolveOptions(mForm, props) as CascaderOption[]; + if (!options.length) { + return undefined; + } + + const normalizedValue = typeof value === 'string' && valueSeparator ? value.split(valueSeparator) : value; + + if (multiple) { + if (!Array.isArray(normalizedValue)) { + return defaultMessage(message, '值类型应为数组'); + } + + const invalid = normalizedValue.some((item) => { + if (emitPath) { + return !Array.isArray(item) || !isValidCascaderPath(options, item); + } + return !includesOptionValue(collectCascaderLeafValues(options), item); + }); + + if (invalid) { + return defaultMessage(message, '值不在可选项中'); + } + return undefined; + } + + if (emitPath) { + if (!Array.isArray(normalizedValue) || !isValidCascaderPath(options, normalizedValue)) { + return defaultMessage(message, '值不在可选项中'); + } + return undefined; + } + + if (!includesOptionValue(collectCascaderLeafValues(options), normalizedValue)) { + return defaultMessage(message, '值不在可选项中'); + } + return undefined; +}; + +const validateBuiltinTypeMatch = ( + value: any, + fieldType: string, + mForm: FormState | undefined, + props: any, + message?: string, +): string | undefined => { + if (SKIP_TYPES.has(fieldType)) { + return undefined; + } + + const config = props.config || {}; + + if (STRING_TYPES.has(fieldType)) { + if (config.filter === 'number') { + if (typeof value !== 'number' || Number.isNaN(value)) { + return defaultMessage(message, '值类型应为数字'); + } + return undefined; + } + + // 自定义 filter 函数会改写值类型,内置规则无法推断,跳过 + if (typeof config.filter === 'function') { + return undefined; + } + + if (typeof value !== 'string') { + return defaultMessage(message, '值类型应为字符串'); + } + return undefined; + } + + if (DATE_LIKE_TYPES.has(fieldType)) { + const valueFormat = resolveDateValueFormat(fieldType, config); + if (!isValidDateValueByFormat(value, valueFormat)) { + return dateValueFormatErrorMessage(message, valueFormat); + } + return undefined; + } + + if (fieldType === 'number') { + if (typeof value !== 'number' || Number.isNaN(value)) { + return defaultMessage(message, '值类型应为数字'); + } + return undefined; + } + + if (fieldType === 'number-range') { + if ( + !Array.isArray(value) || + value.length !== 2 || + value.some((item) => typeof item !== 'number' || Number.isNaN(item)) + ) { + return defaultMessage(message, '值类型应为长度为 2 的数字数组'); + } + return undefined; + } + + if (fieldType === 'switch' || fieldType === 'checkbox') { + const { activeValue, inactiveValue } = resolveToggleValues(config); + if (!Object.is(value, activeValue) && !Object.is(value, inactiveValue)) { + return defaultMessage(message, '值不在合法开关值中'); + } + return undefined; + } + + if (fieldType === 'select') { + const optionValues = flattenSelectOptions(resolveOptions(mForm, props)); + return validateSelectValue(value, config, optionValues, message); + } + + if (fieldType === 'radio-group') { + const optionValues = flattenSelectOptions(resolveOptions(mForm, props)); + if (!includesOptionValue(optionValues, value)) { + return defaultMessage(message, '值不在可选项中'); + } + return undefined; + } + + if (fieldType === 'checkbox-group') { + if (!Array.isArray(value)) { + return defaultMessage(message, '值类型应为数组'); + } + const optionValues = flattenSelectOptions(resolveOptions(mForm, props)); + if (value.some((item) => !includesOptionValue(optionValues, item))) { + return defaultMessage(message, '值不在可选项中'); + } + return undefined; + } + + if (fieldType === 'cascader') { + return validateCascaderValue(value, config, props, mForm, message); + } + + if (fieldType === 'daterange' || fieldType === 'timerange') { + if (config.names) { + return undefined; + } + const valueFormat = resolveDateValueFormat(fieldType, config); + if ( + !Array.isArray(value) || + value.length !== 2 || + value.some((item) => !isValidDateValueByFormat(item, valueFormat)) + ) { + return defaultMessage( + message, + isTimestampValueFormat(valueFormat) + ? '值类型应为长度为 2 的时间戳数字数组' + : `值格式应为长度为 2 的 ${valueFormat} 数组`, + ); + } + return undefined; + } + + if (fieldType === 'table' || fieldType === 'group-list' || fieldType === 'grouplist') { + if (!Array.isArray(value)) { + return defaultMessage(message, '值类型应为数组'); + } + return undefined; + } + + return undefined; +}; + +export const validateTypeMatch = ( + value: any, + mForm: FormState | undefined, + props: any, + message?: string, +): string | undefined => { + if (isEmptyValue(value) || isEmptyArray(value)) { + return undefined; + } + + const fieldType = resolveFieldType(mForm, props); + const customValidator = getTypeMatchRule(fieldType); + + // 自定义规则优先:可覆盖内置规则,也可为业务自定义字段 type 扩展校验 + if (customValidator) { + return customValidator(value, { fieldType, mForm, props, message }); + } + + return validateBuiltinTypeMatch(value, fieldType, mForm, props, message); +}; + +export const createTypeMatchValidator = (mForm: FormState | undefined, props: any, rule: Rule) => { + const originalValidator = typeof rule.validator === 'function' ? rule.validator : undefined; + + return (asyncValidatorRule: any, value: any, callback: Function, source: any, options: any) => { + const actualValue = props.config?.names ? props.model : value; + const error = validateTypeMatch(actualValue, mForm, props, rule.message); + + if (error) { + callback(new Error(error)); + return; + } + + if (originalValidator) { + return originalValidator( + { + rule: asyncValidatorRule, + value: actualValue, + callback, + source, + options, + }, + { + values: mForm?.initValues || {}, + model: props.model, + parent: mForm?.parentValues || {}, + formValue: mForm?.values || props.model, + prop: props.prop, + config: props.config, + }, + mForm, + ); + } + + callback(); + }; +}; diff --git a/packages/form/tests/unit/utils/typeMatch.spec.ts b/packages/form/tests/unit/utils/typeMatch.spec.ts new file mode 100644 index 00000000..5c90a0ce --- /dev/null +++ b/packages/form/tests/unit/utils/typeMatch.spec.ts @@ -0,0 +1,491 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { FormState } from '@form/index'; +import { getRules } from '@form/utils/form'; +import { + clearTypeMatchRules, + deleteTypeMatchRule, + getTypeMatchRule, + registerTypeMatchRule, + registerTypeMatchRules, + validateTypeMatch, +} from '@form/utils/typeMatch'; + +const mForm: FormState = { + config: [], + initValues: {}, + parentValues: {}, + values: {}, + lastValues: {}, + isCompare: false, + $emit: (event: string) => event, + setField: (prop: string, field: any) => field, + getField: (prop: string) => prop, + deleteField: (prop: string) => prop, + $messageBox: { + alert: () => Promise.resolve(), + confirm: () => Promise.resolve(), + prompt: () => Promise.resolve(), + close: () => undefined, + }, + $message: (() => Promise.resolve()) as any, +}; + +const propsOf = (config: Record, model: Record = {}) => ({ + config, + model, + prop: config.name || 'field', +}); + +describe('validateTypeMatch', () => { + test('空值直接通过', () => { + expect(validateTypeMatch(undefined, mForm, propsOf({ type: 'text' }))).toBeUndefined(); + expect(validateTypeMatch(null, mForm, propsOf({ type: 'text' }))).toBeUndefined(); + expect(validateTypeMatch('', mForm, propsOf({ type: 'text' }))).toBeUndefined(); + expect(validateTypeMatch([], mForm, propsOf({ type: 'select', multiple: true }))).toBeUndefined(); + }); + + test('0 / false 不视为空值', () => { + expect(validateTypeMatch(0, mForm, propsOf({ type: 'number' }))).toBeUndefined(); + expect(validateTypeMatch(false, mForm, propsOf({ type: 'switch' }))).toBeUndefined(); + }); + + test('display / hidden 不校验', () => { + expect(validateTypeMatch(123, mForm, propsOf({ type: 'display' }))).toBeUndefined(); + expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'hidden' }))).toBeUndefined(); + }); + + test('text 期望 string', () => { + expect(validateTypeMatch('ok', mForm, propsOf({ type: 'text' }))).toBeUndefined(); + expect(validateTypeMatch(1, mForm, propsOf({ type: 'text' }))).toBe('值类型应为字符串'); + 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('值类型应为数字'); + expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'textarea', filter: 'number' }))).toBe('值类型应为数字'); + }); + + test('text 自定义 filter 函数时跳过内置类型校验', () => { + expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'text', filter: () => ({ a: 1 }) }))).toBeUndefined(); + }); + + test('number 期望 number', () => { + expect(validateTypeMatch(1, mForm, propsOf({ type: 'number' }))).toBeUndefined(); + expect(validateTypeMatch('1', mForm, propsOf({ type: 'number' }))).toBe('值类型应为数字'); + expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'number' }))).toBe('值类型应为数字'); + }); + + 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('12:30:00', mForm, propsOf({ type: 'time' }))).toBeUndefined(); + + // 自定义格式 + expect( + validateTypeMatch('2020-01-25', mForm, propsOf({ type: 'date', valueFormat: 'YYYY-MM-DD' })), + ).toBeUndefined(); + expect( + validateTypeMatch('25/01/2019', mForm, propsOf({ type: 'date', valueFormat: 'DD/MM/YYYY' })), + ).toBeUndefined(); + + // timestamp / x → number + expect( + 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( + '值类型应为时间戳数字', + ); + }); + + 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('值不在合法开关值中'); + }); + + 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('值不在合法开关值中'); + }); + + 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('值不在合法开关值中'); + }); + + test('select 单选值必须在 options 中', () => { + const config = { + type: 'select', + options: [ + { text: 'A', value: 1 }, + { text: 'B', value: 2 }, + ], + }; + expect(validateTypeMatch(1, mForm, propsOf(config))).toBeUndefined(); + expect(validateTypeMatch(3, mForm, propsOf(config))).toBe('值不在可选项中'); + }); + + test('select multiple 校验数组元素', () => { + const config = { + type: 'select', + multiple: true, + options: [ + { text: 'A', value: 'a' }, + { text: 'B', value: 'b' }, + ], + }; + expect(validateTypeMatch(['a'], mForm, propsOf(config))).toBeUndefined(); + expect(validateTypeMatch(['a', 'c'], mForm, propsOf(config))).toBe('值不在可选项中'); + expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('值类型应为数组'); + }); + + test('select options 为函数 / group', () => { + const fnConfig = { + type: 'select', + options: () => [{ text: 'A', value: 1 }], + }; + expect(validateTypeMatch(1, mForm, propsOf(fnConfig))).toBeUndefined(); + expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBe('值不在可选项中'); + + const groupConfig = { + type: 'select', + group: true, + options: [ + { + label: 'g', + disabled: false, + options: [{ text: 'A', value: 'a' }], + }, + ], + }; + expect(validateTypeMatch('a', mForm, propsOf(groupConfig))).toBeUndefined(); + expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('值不在可选项中'); + }); + + test('select allowCreate / remote 不做枚举', () => { + expect(validateTypeMatch('custom', mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined(); + expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe('值类型不合法'); + expect(validateTypeMatch(['x'], mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBeUndefined(); + expect(validateTypeMatch('x', mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBe( + '值类型应为数组', + ); + }); + + test('radio-group / checkbox-group', () => { + const radio = { + type: 'radioGroup', + options: [ + { text: 'A', value: 1 }, + { text: 'B', value: 2 }, + ], + }; + expect(validateTypeMatch(1, mForm, propsOf(radio))).toBeUndefined(); + expect(validateTypeMatch(3, mForm, propsOf(radio))).toBe('值不在可选项中'); + + const checkboxGroup = { + type: 'checkbox-group', + options: [ + { text: 'A', value: 'a' }, + { text: 'B', value: 'b' }, + ], + }; + expect(validateTypeMatch(['a', 'b'], mForm, propsOf(checkboxGroup))).toBeUndefined(); + expect(validateTypeMatch(['c'], mForm, propsOf(checkboxGroup))).toBe('值不在可选项中'); + }); + + test('cascader 静态路径与 valueSeparator', () => { + const options = [ + { + value: 'zhejiang', + label: 'Zhejiang', + children: [ + { + value: 'hangzhou', + label: 'Hangzhou', + }, + ], + }, + ]; + + expect(validateTypeMatch(['zhejiang', 'hangzhou'], mForm, propsOf({ type: 'cascader', options }))).toBeUndefined(); + expect(validateTypeMatch(['zhejiang', 'ningbo'], mForm, propsOf({ type: 'cascader', options }))).toBe( + '值不在可选项中', + ); + expect( + validateTypeMatch('zhejiang/hangzhou', mForm, propsOf({ type: 'cascader', options, valueSeparator: '/' })), + ).toBeUndefined(); + expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', remote: true }))).toBe('值类型应为数组'); + 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('值类型应为长度为 2 的数字数组'); + + 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( + '值格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组', + ); + expect(validateTypeMatch(['a'], mForm, propsOf({ type: 'daterange' }))).toBe( + '值格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组', + ); + 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('值类型应为数组'); + }); + + test('容器类字段 no-op', () => { + expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'fieldset', items: [] }))).toBeUndefined(); + expect(validateTypeMatch(1, mForm, propsOf({ type: 'panel', items: [] }))).toBeUndefined(); + }); + + test('无 type 默认按 text 校验', () => { + expect(validateTypeMatch('ok', mForm, propsOf({}))).toBeUndefined(); + expect(validateTypeMatch(1, mForm, propsOf({}))).toBe('值类型应为字符串'); + }); + + 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('值类型应为字符串'); + }); + + test('checkbox-group 非数组', () => { + expect( + validateTypeMatch('a', mForm, propsOf({ type: 'checkbox-group', options: [{ text: 'A', value: 'a' }] })), + ).toBe('值类型应为数组'); + }); + + test('timerange 按 valueFormat 校验', () => { + expect(validateTypeMatch(['12:00:00', '13:00:00'], mForm, propsOf({ type: 'timerange' }))).toBeUndefined(); + expect(validateTypeMatch(['bad'], mForm, propsOf({ type: 'timerange' }))).toBe( + '值格式应为长度为 2 的 HH:mm:ss 数组', + ); + }); + + test('cascader emitPath=false 校验叶子值', () => { + const options = [ + { + value: 'zhejiang', + label: 'Zhejiang', + children: [{ value: 'hangzhou', label: 'Hangzhou' }], + }, + ]; + expect( + validateTypeMatch('hangzhou', mForm, propsOf({ type: 'cascader', options, emitPath: false })), + ).toBeUndefined(); + expect(validateTypeMatch('ningbo', mForm, propsOf({ type: 'cascader', options, emitPath: false }))).toBe( + '值不在可选项中', + ); + }); + + test('cascader multiple 且 emitPath=false', () => { + const options = [ + { + value: 'zhejiang', + label: 'Zhejiang', + children: [{ value: 'hangzhou', label: 'Hangzhou' }], + }, + ]; + expect( + validateTypeMatch(['hangzhou'], mForm, propsOf({ type: 'cascader', options, multiple: true, emitPath: false })), + ).toBeUndefined(); + expect( + validateTypeMatch(['ningbo'], mForm, propsOf({ type: 'cascader', options, multiple: true, emitPath: false })), + ).toBe('值不在可选项中'); + }); + + test('cascader valueSeparator 时数组值', () => { + const options = [ + { + value: 'zhejiang', + label: 'Zhejiang', + children: [{ value: 'hangzhou', label: 'Hangzhou' }], + }, + ]; + expect( + validateTypeMatch(['zhejiang', 'hangzhou'], mForm, propsOf({ type: 'cascader', options, valueSeparator: '/' })), + ).toBeUndefined(); + }); + + test('select allowCreate 对象值非法', () => { + expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe('值类型不合法'); + }); + + test('动态 type 函数解析', () => { + expect(validateTypeMatch('ok', mForm, propsOf({ type: () => 'text', name: 'field' }))).toBeUndefined(); + expect(validateTypeMatch(1, mForm, propsOf({ type: () => 'number', name: 'field' }))).toBeUndefined(); + }); +}); + +describe('getRules typeMatch', () => { + test('未配置 typeMatch 时行为不变', () => { + const rules: any = [{ required: true, message: '必填' }]; + const newRules = getRules(mForm, rules, propsOf({ type: 'text' })); + expect(newRules).toHaveLength(1); + expect(newRules[0].required).toBe(true); + expect((newRules[0] as any).validator).toBeUndefined(); + }); + + test('typeMatch 注入 validator 并校验失败', () => { + const rules: any = [{ typeMatch: true, message: '类型错误' }]; + const newRules: any = getRules(mForm, rules, propsOf({ type: 'text' })); + const callback = vi.fn(); + newRules[0].validator({}, 123, callback); + expect(callback).toHaveBeenCalledWith(expect.any(Error)); + expect(callback.mock.calls[0][0].message).toBe('类型错误'); + }); + + test('typeMatch 校验通过时 callback 无参调用', () => { + const rules: any = [{ typeMatch: true }]; + const newRules: any = getRules(mForm, rules, propsOf({ type: 'text' })); + const callback = vi.fn(); + newRules[0].validator({}, 'ok', callback); + expect(callback).toHaveBeenCalledWith(); + }); + + test('typeMatch 与自定义 validator 共存,先做类型校验', () => { + const custom = vi.fn((_params: any, _ctx: any, _form: any) => { + _params.callback(); + }); + const rules: any = [{ typeMatch: true, validator: custom }]; + const newRules: any = getRules(mForm, rules, propsOf({ type: 'number' })); + + const failCallback = vi.fn(); + newRules[0].validator({}, 'bad', failCallback); + expect(failCallback).toHaveBeenCalledWith(expect.any(Error)); + expect(custom).not.toHaveBeenCalled(); + + const okCallback = vi.fn(); + newRules[0].validator({}, 1, okCallback); + expect(custom).toHaveBeenCalled(); + expect(okCallback).toHaveBeenCalledWith(); + }); +}); + +describe('typeMatch 扩展注册', () => { + beforeEach(() => { + clearTypeMatchRules(); + }); + + afterEach(() => { + clearTypeMatchRules(); + }); + + test('registerTypeMatchRule 可覆盖内置 text 规则', () => { + registerTypeMatchRule('text', (value, { message }) => { + if (typeof value !== 'string' || !value.startsWith('magic')) { + return message || '必须以 magic 开头'; + } + return undefined; + }); + + // 覆盖后:普通 string 不再直接通过 + expect(validateTypeMatch('hello', mForm, propsOf({ type: 'text' }))).toBe('必须以 magic 开头'); + expect(validateTypeMatch('magic-ok', mForm, propsOf({ type: 'text' }))).toBeUndefined(); + }); + + test('可为业务自定义字段 type 扩展校验', () => { + registerTypeMatchRule('vs-code', (value, { message }) => { + if (typeof value !== 'string') { + return message || '代码字段应为字符串'; + } + return undefined; + }); + + expect(validateTypeMatch(123, mForm, propsOf({ type: 'vs-code' }))).toBe('代码字段应为字符串'); + expect(validateTypeMatch('const a = 1', mForm, propsOf({ type: 'vsCode' }))).toBeUndefined(); + }); + + test('registerTypeMatchRules 批量注册 + delete/get', () => { + registerTypeMatchRules({ + foo: () => 'foo error', + bar: () => undefined, + }); + + expect(getTypeMatchRule('foo')).toBeTypeOf('function'); + expect(validateTypeMatch(1, mForm, propsOf({ type: 'foo' }))).toBe('foo error'); + expect(validateTypeMatch(1, mForm, propsOf({ type: 'bar' }))).toBeUndefined(); + + expect(deleteTypeMatchRule('foo')).toBe(true); + expect(getTypeMatchRule('foo')).toBeUndefined(); + // 删除后回退到内置:未知 type 默认通过 + expect(validateTypeMatch(1, mForm, propsOf({ type: 'foo' }))).toBeUndefined(); + }); + + test('自定义规则可覆盖 display(内置跳过)', () => { + expect(validateTypeMatch(123, mForm, propsOf({ type: 'display' }))).toBeUndefined(); + + registerTypeMatchRule('display', (value, { message }) => { + if (typeof value !== 'string') { + return message || 'display 自定义为 string'; + } + return undefined; + }); + + expect(validateTypeMatch(123, mForm, propsOf({ type: 'display' }))).toBe('display 自定义为 string'); + }); + + test('registerTypeMatchRules 批量注册', () => { + registerTypeMatchRules({ + batch: () => 'batch error', + }); + expect(validateTypeMatch(1, mForm, propsOf({ type: 'batch' }))).toBe('batch error'); + }); +}); + +describe('plugin typeMatchRules', () => { + beforeEach(() => { + clearTypeMatchRules(); + }); + + afterEach(() => { + clearTypeMatchRules(); + }); + + test('install 时注册 typeMatchRules', async () => { + const { createApp } = await import('vue'); + const plugin = (await import('@form/plugin')).default; + + const app = createApp({}); + plugin.install(app, { + typeMatchRules: { + 'install-type': (value) => (value === 'ok' ? undefined : 'install error'), + }, + }); + + expect(validateTypeMatch('ok', mForm, propsOf({ type: 'install-type' }))).toBeUndefined(); + expect(validateTypeMatch('bad', mForm, propsOf({ type: 'install-type' }))).toBe('install error'); + }); +});