Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enhance useFormValidator for flexible field-level control and external state injection #5033

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 67 additions & 109 deletions apps/web/src/common/composables/form-validator/index.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
import type { ComputedRef, UnwrapRef } from 'vue';
import type { ComputedRef, UnwrapRef, Ref } from 'vue';
import {
computed, reactive, ref,
computed, reactive, ref, isRef, isReadonly, readonly,
} from 'vue';
import type { TranslateResult } from 'vue-i18n';

import { clone } from 'lodash';

type ValidatorResult = boolean|TranslateResult;
// eslint-disable-next-line no-unused-vars
interface Validator { (value?: any): ValidatorResult }
export interface Validator<T> { (value: T): ValidatorResult }
type ValidationResult = boolean;

function useValueValidator<T = any>(
value: T,
validator?: Validator,
export function useFieldValidator<T = any>(
value: T|Ref<T>,
validator?: Validator<T>,
immediate = false,
setter?: (val: T) => void, // setter is used to set value for readonly ref
) {
const valueRef = ref<T>(value);
const valueRef = isRef(value) ? value : ref<T>(value);
const validationStarted = ref(immediate);

const setValue = (_value: UnwrapRef<T>) => {
const setValue = (_value: T) => {
if (!validationStarted.value) validationStarted.value = true;
valueRef.value = _value;
if (isReadonly(valueRef)) {
if (setter) setter(_value);
} else valueRef.value = _value;
};

const validatorResult = computed<ValidatorResult>(() => {
if (validator) return validator(valueRef.value);
if (validator) return validator(valueRef.value as T);
return true;
});

Expand All @@ -49,7 +51,9 @@ function useValueValidator<T = any>(
});

const reset = () => {
valueRef.value = value as UnwrapRef<T>;
if (isReadonly(valueRef)) {
if (setter) setter(valueRef.value as T);
} else valueRef.value = value as UnwrapRef<T>;
validationStarted.value = immediate;
};

Expand All @@ -59,12 +63,16 @@ function useValueValidator<T = any>(

const validate = () => {
if (!validationStarted.value) validationStarted.value = true;
if (validator) valueRef.value = clone(valueRef.value);
if (validator) {
if (isReadonly(valueRef)) {
if (setter) setter(clone(valueRef.value as T));
} else valueRef.value = clone(valueRef.value);
}
return true;
};

return {
value: computed<UnwrapRef<T>>(() => valueRef.value),
value: isReadonly(valueRef) ? valueRef as Readonly<Ref<T>> : readonly(valueRef) as Readonly<Ref<T>>,
validationResult,
isInvalid,
invalidText,
Expand All @@ -75,59 +83,55 @@ function useValueValidator<T = any>(
};
}

type UnwrapRefFormValue<T> = T extends Ref<infer V> ? V : T;

type Forms<T> = {
[K in keyof T]: ComputedRef<T[K]>
[K in keyof T]: Readonly<Ref<UnwrapRefFormValue<T[K]>>>
};

type Validators<T> = {
// eslint-disable-next-line no-unused-vars
[K in keyof T]: Validator
[K in keyof T]: Validator<UnwrapRefFormValue<T[K]>>
| ReturnType<typeof useFieldValidator<UnwrapRefFormValue<T[K]>>>
};

type ValueSetters<T> = {
// eslint-disable-next-line no-unused-vars
[K in keyof T]: (val: T[K]) => void
[K in keyof T]: (val: UnwrapRefFormValue<T[K]>) => void
};

type InvalidTexts<T> = {
// eslint-disable-next-line no-unused-vars
[K in keyof T]: ComputedRef<TranslateResult>
};

type InvalidState<T> = {
// eslint-disable-next-line no-unused-vars
[K in keyof T]: ComputedRef<ValidationResult|undefined>
};

type ValidationResults<T> = {
// eslint-disable-next-line no-unused-vars
[K in keyof T]: ComputedRef<ValidationResult>
};

type Resets<T> = {
// eslint-disable-next-line no-unused-vars
[K in keyof T]: () => void
};

type Validates<T> = {
// eslint-disable-next-line no-unused-vars
[K in keyof T]: () => void
};

type ImmediateMap<T> = {
// eslint-disable-next-line no-unused-vars
[K in keyof T]: boolean
};

/**
* @param _forms
* A set of form input data.
* e.g. { a: '', b: 0, c: [], d: {} }
* e.g. { a: '', b: 0, c: ref([]), d: computed(() => state.value) }
* Each form value can be a primitive value, ref, or computed value.
*
* @param validators
* A set of validators.
* e.g. { a: (val) => !!val, b: (val) => val > 0 ? '' : 'Invalid' }
* Validators can return boolean, string or undefined.
* A set of validator function or useFieldValidator result.
* e.g. { a: (val) => !!val, b: (val) => val > 0 ? '' : 'Invalid', c: useFieldValidator([], (val) => !!val) }
* Validator functions can return boolean, string or undefined.
* When it returns boolean, true means valid, and false means invalid.
* When it returns string, empty string means valid, and others mean invalid.
* When it returns undefined, it means validation is not executed.
Expand All @@ -140,6 +144,14 @@ type ImmediateMap<T> = {
* Otherwise, it starts validation when value is updated at least once.
* Default: false
*
* @param _valueSetters
* @params [_valueSetters]
* A set of value setters.
* e.g. { a: (val) => { state.a = val; } }
* Default: {}
* If the form value is readonly, it is required to set value by using setter.
* If the form value is not readonly, it is not required to set value by using setter.
*
* @returns {Object} {forms, invalidState, invalidTexts, isAllValid,
* setForm, resetAll, resetValidations, validateAll, validate}
* forms: A set of ComputedRef<T> form value.
Expand All @@ -152,109 +164,50 @@ type ImmediateMap<T> = {
* resetValidations: Function. Reset validation states.
* validateAll: Function. Validate all form values. Returns result.
* validate: Function. Validate form's value by given property name. Returns result.
*
* @example
*
<template>
<p-field-group :invalid="invalidState.name" :invalid-text="invalidTexts.name">
<p-text-input :value="name" @input="handleNameInput" />
</p-field-group>
<p-field-group :invalid="invalidState.address" :invalid-text="invalidTexts.address">
<p-text-input :value="address" @input="handleAddressInput" />
</p-field-group>
<p-button :disabled="isAllValid">confirm</p-button>
<p-button @click="handleReset">reset</p-button>
</template>

setup() {
const {
forms: { name, address },
invalidState,
invalidTexts,
setForm, isAllValid,
} = useFormValidator({
name: 'name',
address: '',
}, {
name: (val: string) => {
if (!val.trim()) return '이름을 작성하세요';
return true;
},
address: (val: string) => {
if (val.trim().length < 5) return '주소는 5자 이상 작성하세요';
return true;
},
});

const handleNameInput = (value) => {
setForm('name', value);
};

const handleAddressInput = () => {
setForm('address', value);
};

const handleReset = () => {
initForm({
name: 'name',
address: '',
})
};

return {
name,
address,
invalidState,
invalidTexts,
isAllValid,
handleNameInput,
handleAddressInput,
handleReset,
};
}
*/
export function useFormValidator<T extends Record<string, any> = any>(
_forms: T,
validators: Partial<Validators<T>>,
_immediate: Partial<ImmediateMap<T>>|boolean = false,
_valueSetters: Partial<ValueSetters<T>> = {},
) {
const formKeys: Array<keyof T> = Object.keys(_forms);

const forms = {} as Forms<T>;
const valueSetters = {} as ValueSetters<T>;
const valueSetters = { ..._valueSetters } as ValueSetters<T>;
const invalidTexts = {} as InvalidTexts<T>;
const invalidState = {} as InvalidState<T>;
const validationResults = {} as ValidationResults<T>;
const resets = {} as Resets<T>;
const resetValidationMap = {} as Resets<T>;
const validateMap = {} as Validates<T>;

formKeys.forEach((key) => {
formKeys.forEach((key: keyof T) => {
const validator = validators[key];
const immediate = typeof _immediate === 'boolean' ? _immediate : _immediate[key];

const {
value, setValue, validationResult, isInvalid, invalidText, reset, resetValidation, validate,
} = useValueValidator(_forms[key], validator, immediate);

forms[key] = value;
valueSetters[key] = setValue;
validationResults[key] = validationResult;
invalidState[key] = isInvalid;
invalidTexts[key] = invalidText;
resets[key] = reset;
resetValidationMap[key] = resetValidation;
validateMap[key] = validate;
if (validator) {
const {
value, setValue, validationResult, isInvalid, invalidText, reset, resetValidation, validate,
} = typeof validator === 'function' ? useFieldValidator<T[keyof T]>(_forms[key], validator, immediate) : validator;
forms[key] = value as Forms<T>[keyof T];
valueSetters[key] = setValue;
validationResults[key] = validationResult;
invalidState[key] = isInvalid;
invalidTexts[key] = invalidText;
resets[key] = reset;
resetValidationMap[key] = resetValidation;
validateMap[key] = validate;
}
});

const isAllValid = computed<boolean>(() => Object.values(validationResults).every((validationResult) => validationResult.value));

const setForm = (key: keyof T | T, value?: T[keyof T]) => {
if (typeof key === 'object') {
const newForm = key;
Object.keys(newForm).forEach((k) => {
const setForm = (key: keyof T | Partial<T>, value?: T[keyof T]) => {
if (typeof key === 'object') { // if key is an object, that means, it is batch update
const newForm: Partial<T> = key;
Object.keys(newForm).forEach((k: keyof T) => {
const setter = valueSetters[k];
if (setter) setter(newForm[k]);
if (setter) setter(newForm[k] as UnwrapRefFormValue<T[keyof T]>);
});
} else {
const setter = valueSetters[key];
Expand Down Expand Up @@ -287,6 +240,10 @@ export function useFormValidator<T extends Record<string, any> = any>(
});
};

const resetValidation = (key: keyof T) => {
if (resetValidationMap[key]) resetValidationMap[key]();
};

const resetValidations = () => {
formKeys.forEach((key) => {
resetValidationMap[key]();
Expand All @@ -311,6 +268,7 @@ export function useFormValidator<T extends Record<string, any> = any>(
initForm,
setForm,
resetAll,
resetValidation,
resetValidations,
validateAll,
validate,
Expand Down
Loading