diff --git a/.eslintrc.js b/.eslintrc.js index 11d53324d..da8d49bbe 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -36,6 +36,7 @@ module.exports = { 'no-trailing-spaces': 'error', 'object-curly-spacing': ['error', 'always'], quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }], + 'prefer-const': ['error', { destructuring: 'all' }], semi: ['error', 'never'], // ts diff --git a/jest.config.js b/jest.config.js index f35ff5c79..69b236475 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,7 @@ module.exports = { // u can change this option to a more specific folder for test single component or util when dev // for example, ['/packages/components/button'] roots: [''], - + testEnvironment: 'jsdom', transform: { '^.+\\.vue$': 'vue-jest', diff --git a/packages/cdk/breakpoint/src/breakpoints.ts b/packages/cdk/breakpoint/src/breakpoints.ts index aae2a1ece..c244c9348 100644 --- a/packages/cdk/breakpoint/src/breakpoints.ts +++ b/packages/cdk/breakpoint/src/breakpoints.ts @@ -1,3 +1,4 @@ +// PascalCase is being used as Breakpoints is used like an enum. export const Breakpoints = { XSmall: '(max-width: 767.99px)', Small: '(min-width: 768px) and (max-width: 1023.99px)', diff --git a/packages/cdk/forms/__tests__/typeof.spec.ts b/packages/cdk/forms/__tests__/typeof.spec.ts new file mode 100644 index 000000000..99402f0da --- /dev/null +++ b/packages/cdk/forms/__tests__/typeof.spec.ts @@ -0,0 +1,12 @@ +import { isFormControl } from '../src/typeof' +import { ModelType } from '../src/constant' +import { useFormControl } from '../src/useFormControl' + +describe('typeof.ts', () => { + test('isFormControl work', () => { + expect(isFormControl(null)).toEqual(false) + expect(isFormControl({})).toEqual(false) + expect(isFormControl({ __type: ModelType.Control })).toEqual(true) + expect(isFormControl(useFormControl())).toEqual(true) + }) +}) diff --git a/packages/cdk/forms/__tests__/useFormControl.spec.ts b/packages/cdk/forms/__tests__/useFormControl.spec.ts new file mode 100644 index 000000000..8b6db4739 --- /dev/null +++ b/packages/cdk/forms/__tests__/useFormControl.spec.ts @@ -0,0 +1,149 @@ +import { flushPromises } from '@vue/test-utils' + +import { AsyncValidatorFn, Errors } from '../src/types' +import { FormControl, useFormControl } from '../src/useFormControl' +import { Validators } from '../src/validators' + +describe('useFormControl.ts', () => { + describe('basic work', () => { + let control: FormControl + + beforeEach(() => { + control = useFormControl() + }) + + test('init modelRef work', () => { + expect(control.modelRef.value).toBeNull() + }) + + test('init status work', () => { + expect(control.status.value).toEqual('valid') + expect(control.valid.value).toEqual(true) + expect(control.invalid.value).toEqual(false) + expect(control.validating.value).toEqual(false) + }) + + test('init errors work', () => { + expect(control.errors.value).toBeNull() + }) + + test('init blurred work', () => { + expect(control.blurred.value).toEqual(false) + expect(control.unblurred.value).toEqual(true) + }) + + test('validate work', async () => { + expect(await control.validate()).toBeNull() + }) + + test('reset work', async () => { + control.setValue('test') + control.markAsBlurred() + + expect(control.modelRef.value).toEqual('test') + expect(control.blurred.value).toEqual(true) + + control.reset() + + expect(control.modelRef.value).toBeNull() + expect(control.blurred.value).toEqual(false) + }) + + test('setValue work', async () => { + expect(control.modelRef.value).toBeNull() + + control.setValue('test') + + expect(control.modelRef.value).toEqual('test') + + control.setValue('') + + expect(control.modelRef.value).toEqual('') + }) + + test('setValidator work', async () => { + const { required, minLength, email } = Validators + control.setValidator(required) + + expect(await control.validate()).toEqual({ required: { message: '' } }) + + control.setValidator([email, minLength(5)]) + control.setValue('test') + + expect(await control.validate()).toEqual({ + email: { message: '', actual: 'test' }, + minLength: { message: '', minLength: 5, actual: 4 }, + }) + }) + + test('setAsyncValidator work', async () => { + const _asyncValidator = (key: string, error: unknown): AsyncValidatorFn => { + return (_: unknown) => { + return Promise.resolve({ [key]: error } as Errors) + } + } + const message1 = { message: 1 } + const message2 = { message: 2 } + + control.setAsyncValidator(_asyncValidator('a', message1)) + + expect(await control.validate()).toEqual({ a: message1 }) + + control.setAsyncValidator([_asyncValidator('a', message1), _asyncValidator('b', message2)]) + + expect(await control.validate()).toEqual({ a: message1, b: message2 }) + }) + + test('setErrors work', async () => { + expect(control.errors.value).toBeNull() + expect(control.getError('required')).toBeNull() + + const errors = { required: { message: '' } } + control.setErrors(errors) + + expect(control.errors.value).toEqual(errors) + + expect(control.getError('required')).toEqual(errors.required) + expect(control.getError('max')).toBeNull() + + expect(control.hasError('required')).toEqual(true) + expect(control.hasError('max')).toEqual(false) + }) + }) + + describe('trigger work', () => { + let control: FormControl + + test('default change work', async () => { + control = useFormControl(null, { validators: Validators.required }) + + control.setValue('') + await flushPromises() + + expect(control.errors.value).toEqual({ required: { message: '' } }) + }) + + test('blur trigger validate work', async () => { + control = useFormControl(null, { trigger: 'blur', validators: Validators.required }) + control.markAsBlurred() + await flushPromises() + expect(control.errors.value).toEqual({ required: { message: '' } }) + }) + }) + + describe('validator work', () => { + let control: FormControl + + test('validate work', async () => { + control = useFormControl(null, [Validators.required]) + + control.setValue('test') + await flushPromises() + expect(control.errors.value).toBeNull() + + control.setValue('') + await flushPromises() + expect(control.errors.value).toEqual({ required: { message: '' } }) + }) + }) +}) diff --git a/packages/cdk/forms/__tests__/validators.spec.ts b/packages/cdk/forms/__tests__/validators.spec.ts new file mode 100644 index 000000000..bb7b2d871 --- /dev/null +++ b/packages/cdk/forms/__tests__/validators.spec.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { AsyncValidatorFn, ErrorMessages, Errors, ValidatorFn } from '../src/types' +import { Validators } from '../src/validators' + +describe('validators.ts', () => { + test('required work', () => { + const required = Validators.required + + expect(required(0)).toBeNull() + expect(required('value')).toBeNull() + expect(required([1, 2])).toBeNull() + + const errorMessage = { required: { message: '' } } + expect(required(null)).toEqual(errorMessage) + expect(required(undefined)).toEqual(errorMessage) + expect(required('')).toEqual(errorMessage) + expect(required([])).toEqual(errorMessage) + }) + + test('requiredTrue work', () => { + const requiredTrue = Validators.requiredTrue + + expect(requiredTrue(true)).toBeNull() + + const errorMessage = (actual: unknown) => ({ requiredTrue: { message: '', actual } }) + expect(requiredTrue(null)).toEqual(errorMessage(null)) + expect(requiredTrue(undefined)).toEqual(errorMessage(undefined)) + expect(requiredTrue('')).toEqual(errorMessage('')) + expect(requiredTrue([])).toEqual(errorMessage([])) + expect(requiredTrue({})).toEqual(errorMessage({})) + expect(requiredTrue(false)).toEqual(errorMessage(false)) + }) + + test('email work', () => { + const email = Validators.email + + expect(email('')).toBeNull() + expect(email(null)).toBeNull() + expect(email('test@gmail.com')).toBeNull() + + const errorMessage = (actual: unknown) => ({ email: { message: '', actual } }) + expect(email({})).toEqual(errorMessage({})) + expect(email('test')).toEqual(errorMessage('test')) + }) + + test('min work', () => { + const minOne = Validators.min(1) + + expect(minOne('')).toBeNull() + expect(minOne(null)).toBeNull() + expect(minOne('test')).toBeNull() + expect(minOne('1')).toBeNull() + expect(minOne(1)).toBeNull() + expect(minOne(2)).toBeNull() + + const errorMessage = (actual: unknown) => ({ min: { message: '', min: 1, actual } }) + expect(minOne(0)).toEqual(errorMessage(0)) + expect(minOne('0')).toEqual(errorMessage('0')) + }) + + test('max work', () => { + const maxOne = Validators.max(1) + + expect(maxOne('')).toBeNull() + expect(maxOne(null)).toBeNull() + expect(maxOne('test')).toBeNull() + expect(maxOne('1')).toBeNull() + expect(maxOne(1)).toBeNull() + expect(maxOne(0)).toBeNull() + + const errorMessage = (actual: unknown) => ({ max: { message: '', max: 1, actual } }) + expect(maxOne(2)).toEqual(errorMessage(2)) + expect(maxOne('2')).toEqual(errorMessage('2')) + }) + + test('minLength work', () => { + const minLengthTwo = Validators.minLength(2) + + expect(minLengthTwo('')).toBeNull() + expect(minLengthTwo(null)).toBeNull() + expect(minLengthTwo(1)).toBeNull() + expect(minLengthTwo('te')).toBeNull() + expect(minLengthTwo('test')).toBeNull() + expect(minLengthTwo([])).toBeNull() + expect(minLengthTwo([1, 2])).toBeNull() + expect(minLengthTwo([1, 2, 3])).toBeNull() + + const errorMessage = (actual: unknown) => ({ minLength: { message: '', minLength: 2, actual } }) + expect(minLengthTwo('t')).toEqual(errorMessage(1)) + expect(minLengthTwo([1])).toEqual(errorMessage(1)) + }) + + test('maxLength work', () => { + const maxLengthTwo = Validators.maxLength(2) + + expect(maxLengthTwo('')).toBeNull() + expect(maxLengthTwo(1)).toBeNull() + expect(maxLengthTwo(null)).toBeNull() + expect(maxLengthTwo('te')).toBeNull() + expect(maxLengthTwo('t')).toBeNull() + expect(maxLengthTwo([])).toBeNull() + expect(maxLengthTwo([1, 2])).toBeNull() + expect(maxLengthTwo([1])).toBeNull() + + const errorMessage = (actual: unknown) => ({ maxLength: { message: '', maxLength: 2, actual } }) + expect(maxLengthTwo('test')).toEqual(errorMessage(4)) + expect(maxLengthTwo([1, 2, 3])).toEqual(errorMessage(3)) + }) + + test('pattern work', () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(Validators.pattern(null!)('test')).toBeNull() + + let stringPattern = Validators.pattern('[a-zA-Z]+') + + expect(stringPattern('')).toBeNull() + expect(stringPattern(null)).toBeNull() + expect(stringPattern('test')).toBeNull() + + let errorMessage = (actual: unknown) => ({ pattern: { message: '', pattern: '^[a-zA-Z]+$', actual } }) + expect(stringPattern('test1')).toEqual(errorMessage('test1')) + expect(stringPattern(1)).toEqual(errorMessage(1)) + + stringPattern = Validators.pattern('^[a-zA-Z]+$') + expect(stringPattern('test1')).toEqual(errorMessage('test1')) + expect(stringPattern(1)).toEqual(errorMessage(1)) + + const regExpPattern = Validators.pattern(new RegExp('[a-zA-Z]+')) + + expect(regExpPattern('')).toBeNull() + expect(regExpPattern(null)).toBeNull() + expect(regExpPattern('test')).toBeNull() + expect(regExpPattern('test1')).toBeNull() + + errorMessage = (actual: unknown) => ({ pattern: { message: '', pattern: '/[a-zA-Z]+/', actual } }) + expect(regExpPattern(1)).toEqual(errorMessage(1)) + + const regExpPattern2 = Validators.pattern(new RegExp('^[a-zA-Z]+$')) + + expect(regExpPattern2('test')).toBeNull() + + errorMessage = (actual: unknown) => ({ pattern: { message: '', pattern: '/^[a-zA-Z]+$/', actual } }) + expect(regExpPattern2('test1')).toEqual(errorMessage('test1')) + expect(regExpPattern2(1)).toEqual(errorMessage(1)) + }) + + test('compose work', () => { + const _validator = (key: string, error: unknown): ValidatorFn => { + return (_: unknown) => { + return { [key]: error } as Errors + } + } + const message1 = { message: 1 } + const message2 = { message: 2 } + const { compose, nullValidator, required } = Validators + + expect(compose(null)).toBeNull() + expect(compose([])).toBe(null) + expect(compose([nullValidator, nullValidator])!('test')).toBeNull() + + expect(compose([_validator('a', message1), _validator('b', message2)])!('test')).toEqual({ + a: message1, + b: message2, + }) + expect(compose([_validator('a', message1), _validator('a', message2)])!('test')).toEqual({ a: message2 }) + expect(compose([null, nullValidator, required])!('')).toEqual({ required: { message: '' } }) + }) + + test('composeAsync work', async () => { + const _asyncValidator = (key: string, error: unknown): AsyncValidatorFn => { + return (_: unknown) => { + return Promise.resolve({ [key]: error } as Errors) + } + } + const message1 = { message: 1 } + const message2 = { message: 2 } + const composeAsync = Validators.composeAsync + + expect(composeAsync(null)).toBe(null) + expect(composeAsync([])).toBe(null) + + let errors = await composeAsync([_asyncValidator('a', message1), _asyncValidator('b', message2)])!('test') + + expect(errors).toEqual({ a: message1, b: message2 }) + + errors = await composeAsync([_asyncValidator('a', message1), _asyncValidator('a', message2)])!('test') + expect(errors).toEqual({ a: message2 }) + + errors = await composeAsync([null, _asyncValidator('a', message1)])!('test') + expect(errors).toEqual({ a: message1 }) + }) + + test('setMessages work', () => { + const { setMessages, required, requiredTrue } = Validators + + const messages: ErrorMessages = { default: 'invalid input', required: 'please input' } + setMessages(messages) + + expect(required('')).toEqual({ required: { message: messages.required } }) + expect(requiredTrue(false)).toEqual({ requiredTrue: { message: messages.default, actual: false } }) + + setMessages({ requiredTrue: () => 'please input true' }) + + expect(requiredTrue(false)).toEqual({ requiredTrue: { message: 'please input true', actual: false } }) + }) +}) diff --git a/packages/cdk/forms/index.ts b/packages/cdk/forms/index.ts new file mode 100644 index 000000000..c38dc75e5 --- /dev/null +++ b/packages/cdk/forms/index.ts @@ -0,0 +1,4 @@ +export * from './src/typeof' +export * from './src/types' +export * from './src/useFormControl' +export * from './src/validators' diff --git a/packages/cdk/forms/src/constant.ts b/packages/cdk/forms/src/constant.ts new file mode 100644 index 000000000..9eb9c44a7 --- /dev/null +++ b/packages/cdk/forms/src/constant.ts @@ -0,0 +1,4 @@ +// PascalCase is being used as Breakpoints is used like an enum. +export const ModelType = { + Control: '__control', +} diff --git a/packages/cdk/forms/src/typeof.ts b/packages/cdk/forms/src/typeof.ts new file mode 100644 index 000000000..f808a7630 --- /dev/null +++ b/packages/cdk/forms/src/typeof.ts @@ -0,0 +1,7 @@ +import { isNonNil } from '@idux/cdk/utils' +import { ModelType } from './constant' +import { FormControl } from './useFormControl' + +export const isFormControl = (val: unknown): val is FormControl => { + return isNonNil(val) && (val as { __type: string }).__type === ModelType.Control +} diff --git a/packages/cdk/forms/src/types.ts b/packages/cdk/forms/src/types.ts new file mode 100644 index 000000000..9706193a6 --- /dev/null +++ b/packages/cdk/forms/src/types.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface ValidationError { + message: string + actual?: any + [key: string]: any +} + +type ErrorMessage = string | ((info: Omit) => string) + +export interface ErrorMessages { + default?: ErrorMessage + required?: ErrorMessage + requiredTrue?: ErrorMessage + email?: ErrorMessage + min?: ErrorMessage + max?: ErrorMessage + minLength?: ErrorMessage + maxLength?: ErrorMessage + pattern?: ErrorMessage + [key: string]: ErrorMessage | undefined +} + +export type Errors = { [key in keyof ErrorMessages]: ValidationError } + +export interface ValidatorFn { + (value: any): Errors | null +} + +export interface AsyncValidatorFn { + (value: any): Promise +} + +export type TriggerType = 'change' | 'blur' | 'submit' + +export interface ValidatorOptions { + validators?: ValidatorFn | ValidatorFn[] | null + asyncValidators?: AsyncValidatorFn | AsyncValidatorFn[] | null + trigger?: TriggerType +} + +export type ValidStatus = 'valid' | 'invalid' | 'validating' diff --git a/packages/cdk/forms/src/useFormControl.ts b/packages/cdk/forms/src/useFormControl.ts new file mode 100644 index 000000000..a30862904 --- /dev/null +++ b/packages/cdk/forms/src/useFormControl.ts @@ -0,0 +1,255 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import type { ComputedRef, DeepReadonly, Ref, UnwrapRef } from 'vue' +import type { + AsyncValidatorFn, + ValidatorFn, + ValidatorOptions, + TriggerType, + ValidationError, + ErrorMessages, + ValidStatus, + Errors, +} from './types' + +import { computed, readonly, ref, watchEffect } from 'vue' +import { isArray, isNil, isObject } from '@idux/cdk/utils' +import { Validators } from './validators' +import { ModelType } from './constant' + +export interface FormControl { + /** + * The ref value for the control. + */ + modelRef: Ref | null> + + /** + * The validation status of the control, there are three possible validation status values: + * * **valid**: This control has passed all validation checks. + * * **invalid**: This control has failed at least one validation check. + * * **validating**: This control is in the midst of conducting a validation check. + */ + status: DeepReadonly> + + /** + * An object containing any errors generated by failing validation, or null if there are no errors. + */ + errors: DeepReadonly> + + /** + * Running validations manually, rather than automatically. + */ + validate: () => Promise + + /** + * Resets the form control, marking it `unblurred`, and setting the value to initialization value. + */ + reset: () => void + + /** + * Sets a new value for the form control. + * + * @param value The new value. + */ + setValue: (value: T | null) => void + + /** + * Sets the new sync validator for the form control, it overwrites existing sync validators. + * If you want to clear all sync validators, you can pass in a null. + */ + setValidator: (newValidator: ValidatorFn | ValidatorFn[] | null) => void + + /** + * Sets the new async validator for the form control, it overwrites existing async validators. + * If you want to clear all async validators, you can pass in a null. + */ + setAsyncValidator: (newAsyncValidator: AsyncValidatorFn | AsyncValidatorFn[] | null) => void + + /** + * Sets errors on a form control when running validations manually, rather than automatically. + */ + setErrors: (newErrors: Errors | null) => void + + /** + * Reports error data for the control. + * + * @param errorCode The code of the error to check + */ + getError: (errorCode: keyof ErrorMessages) => ValidationError | null + + /** + * Reports whether the control has the error specified. + * + * @param errorCode The code of the error to check + */ + hasError: (errorCode: keyof ErrorMessages) => boolean + + /** + * Marks the control as `blurred`. + */ + markAsBlurred: () => void + + /** + * Marks the control as `unblurred`. + */ + markAsUnblurred: () => void + + /** + * A control is valid when its `status` is `valid`. + */ + valid: ComputedRef + + /** + * A control is invalid when its `status` is `invalid`. + */ + invalid: ComputedRef + + /** + * A control is validating when its `status` is `validating`. + */ + validating: ComputedRef + + /** + * A control is marked `blurred` once the user has triggered a `blur` event on it. + */ + blurred: ComputedRef + + /** + * A control is `unblurred` if the user has not yet triggered a `blur` event on it. + */ + unblurred: ComputedRef +} + +export function useFormControl( + initValue?: T | null, + validator?: ValidatorFn | ValidatorFn[] | null, + asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null, +): FormControl +export function useFormControl(initValue?: T | null, options?: ValidatorOptions | null): FormControl +export function useFormControl( + initValue: T | null = null, + validatorOrOptions?: ValidatorFn | ValidatorFn[] | ValidatorOptions | null, + asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null, +): FormControl { + const modelRef = ref(initValue) + const status = ref('valid') + const blurred = ref(false) + const errors = ref(null) + + let { _validator, _asyncValidator, _trigger } = convertOptions(validatorOrOptions, asyncValidator) + + const reset = () => { + modelRef.value = initValue as any + markAsUnblurred() + } + + const setValue = (value: T | null) => { + modelRef.value = value as any + } + + const setValidator = (newValidator: ValidatorFn | ValidatorFn[] | null) => { + _validator = toValidator(newValidator) + } + + const setAsyncValidator = (newAsyncValidator: AsyncValidatorFn | AsyncValidatorFn[] | null) => { + _asyncValidator = toAsyncValidator(newAsyncValidator) + } + + const setErrors = (newErrors: Errors | null) => { + errors.value = newErrors + status.value = newErrors ? 'invalid' : 'valid' + } + + const getError = (errorCode: keyof ErrorMessages) => { + return errors.value ? errors.value[errorCode] ?? null : null + } + + const hasError = (errorCode: keyof ErrorMessages) => !!getError(errorCode) + + const markAsBlurred = () => { + blurred.value = true + } + + const markAsUnblurred = () => { + blurred.value = false + } + + const _runValidator = () => { + return _validator ? _validator(modelRef.value) : null + } + const _runAsyncValidator = () => { + if (!_asyncValidator) { + return null + } + status.value = 'validating' + return _asyncValidator(modelRef.value) + } + + const validate = async () => { + let newErrors = _runValidator() + if (isNil(newErrors)) { + newErrors = await _runAsyncValidator() + } + setErrors(newErrors) + return newErrors + } + + watchEffect(() => { + if (_trigger === 'change' || (_trigger === 'blur' && blurred.value)) { + validate() + } + }) + + return { + __type: ModelType.Control, + modelRef, + status: readonly(status), + errors: readonly(errors), + validate, + reset, + setValue, + setValidator, + setAsyncValidator, + setErrors, + getError, + hasError, + markAsBlurred, + markAsUnblurred, + valid: computed(() => status.value === 'valid'), + invalid: computed(() => status.value === 'invalid'), + validating: computed(() => status.value === 'validating'), + blurred: computed(() => blurred.value), + unblurred: computed(() => !blurred.value), + } as FormControl +} + +function toValidator(validator?: ValidatorFn | ValidatorFn[] | null): ValidatorFn | null { + return isArray(validator) ? Validators.compose(validator) : validator || null +} + +function toAsyncValidator(asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): AsyncValidatorFn | null { + return isArray(asyncValidator) ? Validators.composeAsync(asyncValidator) : asyncValidator || null +} + +function isOptions(val?: ValidatorFn | ValidatorFn[] | ValidatorOptions | null): val is ValidatorOptions { + return isObject(val) && !isArray(val) +} + +function convertOptions( + validatorOrOptions: ValidatorFn | ValidatorFn[] | ValidatorOptions | null | undefined, + asyncValidator: AsyncValidatorFn | AsyncValidatorFn[] | null | undefined, +) { + let _trigger: TriggerType = 'change' + let _validator: ValidatorFn | null + let _asyncValidator: AsyncValidatorFn | null + if (isOptions(validatorOrOptions)) { + _trigger = validatorOrOptions.trigger ?? _trigger + _validator = toValidator(validatorOrOptions.validators) + _asyncValidator = toAsyncValidator(validatorOrOptions.asyncValidators) + } else { + _validator = toValidator(validatorOrOptions) + _asyncValidator = toAsyncValidator(asyncValidator) + } + return { _validator, _asyncValidator, _trigger } +} diff --git a/packages/cdk/forms/src/validators.ts b/packages/cdk/forms/src/validators.ts new file mode 100644 index 000000000..3bfc2fc28 --- /dev/null +++ b/packages/cdk/forms/src/validators.ts @@ -0,0 +1,172 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import type { AsyncValidatorFn, ValidationError, ValidatorFn, Errors, ErrorMessages } from './types' + +import { isArray, isFunction, isNil, isNonNil, isNumber, isNumeric, isString } from '@idux/cdk/utils' + +/** See [this commit](https://github.com/angular/angular.js/commit/f3f5cf72e) for more details. */ +const emailRegexp = /^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + +export class Validators { + private static messages: ErrorMessages = {} + + private static getMessage(key: keyof ErrorMessages, errors: Omit): ValidationError { + let message = '' + const validMessage = Validators.messages[key] || Validators.messages.default + if (isString(validMessage)) { + message = validMessage + } else if (isFunction(validMessage)) { + message = validMessage(errors) + } + return { ...errors, message } + } + + static setMessages(messages: ErrorMessages): void { + Validators.messages = { ...Validators.messages, ...messages } + } + + static required(value: any): { required: ValidationError } | null { + if (isEmpty(value)) { + return { required: Validators.getMessage('required', {}) } + } + return null + } + + static requiredTrue(value: any): { requiredTrue: ValidationError } | null { + if (value === true) { + return null + } + return { requiredTrue: Validators.getMessage('requiredTrue', { actual: value }) } + } + + static email(value: any): { email: ValidationError } | null { + if (isEmpty(value) || emailRegexp.test(value)) { + return null + } + return { email: Validators.getMessage('email', { actual: value }) } + } + + static min(min: number): ValidatorFn { + return (value: any): { min: ValidationError } | null => { + if (isEmpty(value) || !isNumeric(value) || Number(value) >= min) { + return null + } + return { min: Validators.getMessage('min', { min, actual: value }) } + } + } + + static max(max: number): ValidatorFn { + return (value: any): { max: ValidationError } | null => { + if (isEmpty(value) || !isNumeric(value) || Number(value) <= max) { + return null + } + return { max: Validators.getMessage('max', { max, actual: value }) } + } + } + + static minLength(minLength: number): ValidatorFn { + return (value: any): { minLength: ValidationError } | null => { + if (isEmpty(value) || !hasLength(value) || value.length >= minLength) { + return null + } + return { minLength: Validators.getMessage('minLength', { minLength, actual: value.length }) } + } + } + + static maxLength(maxLength: number): ValidatorFn { + return (value: any): { maxLength: ValidationError } | null => { + if (isEmpty(value) || !hasLength(value) || value.length <= maxLength) { + return null + } + return { maxLength: Validators.getMessage('maxLength', { maxLength, actual: value.length }) } + } + } + + static pattern(pattern: string | RegExp): ValidatorFn { + if (!pattern) { + return Validators.nullValidator + } + let regex: RegExp + let regexStr: string + if (typeof pattern === 'string') { + regexStr = '' + if (pattern.charAt(0) !== '^') { + regexStr += '^' + } + regexStr += pattern + if (pattern.charAt(pattern.length - 1) !== '$') { + regexStr += '$' + } + regex = new RegExp(regexStr) + } else { + regexStr = pattern.toString() + regex = pattern + } + return (value: any): { pattern: ValidationError } | null => { + if (isEmpty(value) || regex.test(value)) { + return null + } + return { pattern: Validators.getMessage('pattern', { pattern: regexStr, actual: value }) } + } + } + + static nullValidator(_: any): null { + return null + } + + static compose(validators: (ValidatorFn | null | undefined)[] | null): ValidatorFn | null { + if (!validators) { + return null + } + const presentValidators: ValidatorFn[] = validators.filter(isNonNil) + if (presentValidators.length == 0) { + return null + } + + return (value: any) => mergeMessages(executeValidators(value, presentValidators)) + } + + static composeAsync(validators: (AsyncValidatorFn | null)[] | null): AsyncValidatorFn | null { + if (!validators) { + return null + } + const presentValidators: AsyncValidatorFn[] = validators.filter(isNonNil) + if (presentValidators.length == 0) { + return null + } + + return (value: any) => { + const validationErrors = executeValidators(value, presentValidators) + return Promise.all(validationErrors).then(mergeMessages) + } + } +} + +/** checks whether string or array is empty */ +function isEmpty(val: any): boolean { + return isNil(val) || (val.length === 0 && (isString(val) || isArray(val))) +} + +/** checks whether variable has length props */ +function hasLength(val: any): boolean { + return isNonNil(val) && isNumber(val.length) +} + +type GenericValidatorFn = (value: any) => any + +function executeValidators(value: any, validators: V[]): ReturnType[] { + return validators.map(validator => validator(value)) +} + +function mergeMessages(validationErrors: (Errors | null)[]): Errors | null { + let res: { [key: string]: any } = {} + + // Not using Array.reduce here due to a Chrome 80 bug + // https://bugs.chromium.org/p/chromium/issues/detail?id=1049982 + validationErrors.forEach((errors: Errors | null) => { + res = isNonNil(errors) ? { ...res, ...errors } : res + }) + + return Object.keys(res).length === 0 ? null : res +} diff --git a/packages/cdk/utils/convert.ts b/packages/cdk/utils/convert.ts index a27c0c9d1..d1836d0eb 100644 --- a/packages/cdk/utils/convert.ts +++ b/packages/cdk/utils/convert.ts @@ -3,6 +3,9 @@ import { isNil, isNumeric } from './typeof' export function toArray(value: T | T[]): T[] export function toArray(value: T | readonly T[]): readonly T[] export function toArray(value: T | T[]): T[] { + if (isNil(value)) { + return [] + } return Array.isArray(value) ? value : [value] } diff --git a/packages/components/spin/demo/Tip.vue b/packages/components/spin/demo/Tip.vue index 5d1110b5f..9725c2dd2 100644 --- a/packages/components/spin/demo/Tip.vue +++ b/packages/components/spin/demo/Tip.vue @@ -14,7 +14,7 @@ import { ref, defineComponent, computed } from 'vue' export default defineComponent({ setup() { - let tipAlignIsHorizontal = ref(true) + const tipAlignIsHorizontal = ref(true) const tip = ref('数据加载中,请等待') const tipAlign = computed(() => (tipAlignIsHorizontal.value ? 'horizontal' : 'vertical'))