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(rules): support for asynchronous rules #36

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
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
23 changes: 20 additions & 3 deletions example/src/pages/yup.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
<script lang="ts" setup>
import { useForm } from 'slimeform'
import { yupFieldRule } from 'slimeform/resolvers'
import { yupAsyncFieldRule, yupFieldRule } from 'slimeform/resolvers'
import * as yup from 'yup'
import RouteNav from '~/components/RouteNav.vue'

const local = ref('en')

/** mock i18n `t` function */
const mockT = (_: string) => local.value === 'en' ? 'Valid age up to 120 years old' : '有效年龄至 120 岁'
yup.object({

})
const { form, status } = useForm({
form: () => ({
age: '',
Expand All @@ -28,6 +26,24 @@ const { form, status } = useForm({
.nullable(),
),
],
asyncTest: [
yupFieldRule(yup
.string()
.required(() => local.value === 'en' ? 'Required' : '必填'),
),
yupAsyncFieldRule(yup
.number()
.integer()
.test(
'is-42',
'this isn\'t the number i want',
async (value: any) => {
await new Promise(resolve => setTimeout(resolve, 1000))
return value !== 111
},
),
),
],
},
defaultMessage: 'none',
})
Expand Down Expand Up @@ -97,6 +113,7 @@ const { form, status } = useForm({
<div>
<p>Value: {{ form.asyncTest }}</p>
<p>isDirty: {{ status.asyncTest.isDirty }}</p>
<p>verifying: {{ status.asyncTest.verifying }}</p>
<p>isError: {{ status.asyncTest.isError }}</p>
<p>message: {{ status.asyncTest.message }}</p>
</div>
Expand Down
34 changes: 26 additions & 8 deletions packages/resolvers/yup.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import type { BaseSchema, ValidationError } from 'yup'
import type { ValidateOptions } from 'yup/lib/types'
import type { RuleItem } from '../../src/type/form'

export interface ResolverOptions {
model?: 'validateSync' | 'validate'
export const parseYupError = (error: ValidationError) => {
return error.errors[0] || true
}

/** yup field rule resolver */
/** yup sync field rule resolver */
export const yupFieldRule = <SchemaT extends BaseSchema, TContext = {}>(
fieldSchema: SchemaT,
schemaOptions: ValidateOptions<TContext> = {},
) => {
return (val: unknown) => {
): RuleItem => {
return (val) => {
try {
fieldSchema.validateSync(
val,
Object.assign({ abortEarly: false }, schemaOptions),
Object.assign({ abortEarly: true }, schemaOptions),
)
return true
}
Expand All @@ -26,6 +27,23 @@ export const yupFieldRule = <SchemaT extends BaseSchema, TContext = {}>(
}
}

function parseYupError(error: ValidationError) {
return error.errors[0]
/** yup asynchronous field rule resolver */
export const yupAsyncFieldRule = <SchemaT extends BaseSchema, TContext = {}>(
fieldSchema: SchemaT,
schemaOptions: ValidateOptions<TContext> = {},
): RuleItem => {
return async (val) => {
try {
await fieldSchema.validate(
val,
Object.assign({ abortEarly: true }, schemaOptions),
)
return true
}
catch (error: any) {
if (!error?.inner)
throw error
return parseYupError(error)
}
}
}
46 changes: 40 additions & 6 deletions src/defineStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { Ref, UnwrapNestedRefs, WatchStopHandle } from 'vue'
import { computed, reactive, watchEffect } from 'vue'
import type { RuleItem, UseFormDefaultMessage, UseFormRule } from './type/form'
import type { StatusItem } from './type/formStatus'
import type { OnCleanup } from './type/util'
import { deepEqual } from './util/deepEqual'
import { invoke } from './util/invoke'
import { isFunction, isHasOwn, isObjectType } from './util/is'
import { isFunction, isHasOwn, isObjectType, isPromise } from './util/is'
import { watchIgnorable } from './util/watchIgnorable'

export function initStatus<FormT extends {}>(
Expand All @@ -27,6 +28,7 @@ export function initStatus<FormT extends {}>(
status[key] = reactive({
message: formDefaultMessage,
isError: false,
verifying: false,
isDirty: computed(() => !deepEqual((initialForm.value as any)[key], formObj[key])),
...statusControl(key, status, formObj, fieldRules, formDefaultMessage),
})
Expand All @@ -45,6 +47,7 @@ function statusControl<FormT extends {}>(
status[key].isError = isError
}

/** parsing verification result */
function parseError(result: string | boolean) {
// result as string or falsity
// Exit validation on error
Expand All @@ -58,14 +61,45 @@ function statusControl<FormT extends {}>(
}
}

function ruleEffect() {
// Traverse the ruleset and check the rules
/** Number of asynchronous validations in progress */
let verifyingCount = 0

function ruleEffect(onCleanup?: OnCleanup) {
let isEnded = false
if (onCleanup)
onCleanup(() => isEnded = true)

// Traverse the ruleset and check the rules
for (const rule of fieldRules || []) {
const result = rule(formObj[key])
const result = rule(formObj[key], onCleanup)

if (parseError(result))
break
// Determine whether it is synchronous verification
if (!isPromise(result)) {
if (parseError(result)) {
isEnded = true
break
}
}
else {
// If it's async validation, wait for its result in a new function
invoke(async () => {
verifyingCount += 1
status[key].verifying = !!verifyingCount
try {
const err = await result
// Validation will end when there is any one error result
// If the validation has ended, no further results will be processed
if (isEnded)
return
if (parseError(err))
isEnded = true
}
finally {
verifyingCount -= 1
status[key].verifying = !!verifyingCount
}
})
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/type/form.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { ComputedRef, DeepReadonly, UnwrapNestedRefs } from 'vue'
import type { FormStatus } from './formStatus'
import type { OnCleanup } from './util'

export type RuleItem<ValueT = any> = ((val: ValueT) => boolean | string)
export type RuleItem<ValueT = any> = (val: ValueT, onCleanup?: OnCleanup) => boolean | string | Promise<boolean | string>

export type UseFormBuilder<Form extends {} = {}> = () => Form
export type UseFormRule<FormT extends {}> = {
Expand Down
2 changes: 2 additions & 0 deletions src/type/formStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface StatusItem {
message: string
/** Field is modified */
isDirty: boolean
/** Whether the asynchronous validation is in progress or not */
verifying: boolean
/** Manual verify */
verify: () => boolean
init: () => void
Expand Down
2 changes: 2 additions & 0 deletions src/type/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export type BaseType =
| boolean
| symbol
| bigint

export type OnCleanup = (cleanupFn: () => void) => void
2 changes: 1 addition & 1 deletion src/util/is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ export const isBaseType = (value: unknown): value is BaseType =>
isNullOrUndefined(value)
|| !isObjectType(value)

export const isPromise = (obj: unknown) => Promise.resolve(obj) === obj
export const isPromise = <T extends Promise<any>>(obj: unknown): obj is T => Promise.resolve(obj) === obj