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

Feature/2.0 #18

Merged
merged 17 commits into from
Mar 6, 2024
6 changes: 5 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"extends": "@antfu",
"ignorePatterns": ["/docs/.vitepress/**/*", "/docs/**/*.svg", "/docs/**/*.css"]
"ignorePatterns": ["/docs/.vitepress/**/*", "/docs/**/*.svg", "/docs/**/*.css"],
"rules": {
"no-mixed-spaces-and-tabs": "off",
"no-tabs": "off"
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "formango",
"type": "module",
"version": "1.0.3",
"version": "2.0.11",
"packageManager": "[email protected]",
"description": "",
"author": "Wouter Laermans <[email protected]>",
Expand Down
61 changes: 53 additions & 8 deletions src/lib/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { ComputedRef } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { z } from 'zod'
import deepClone from 'deep-clone'
import type { DeepPartial, Field, FieldArray, Form, Path, Register, RegisterArray, Unregister } from '../types'
import { type MaybeRefOrGetter, toValue } from '@vueuse/core'
import type { DeepPartial, Field, FieldArray, Form, MaybePromise, NullableKeys, Path, Register, RegisterArray, Unregister } from '../types'
import { generateId, get, set, unset } from '../utils'

interface UseFormReturnType<TSchema extends z.ZodType> {
Expand All @@ -19,7 +20,7 @@ interface UseFormReturnType<TSchema extends z.ZodType> {

interface UseFormOptions<TSchema extends z.ZodType> {
schema: TSchema
initialState?: z.infer<TSchema>
initialState?: MaybeRefOrGetter<NullableKeys<z.infer<TSchema>>>
}

export function useForm<TSchema extends z.ZodType>(
Expand All @@ -35,14 +36,14 @@ export function useForm<TSchema extends z.ZodType>(
// The errors of the form
const errors = ref<z.ZodFormattedError<TSchema>>({} as z.ZodFormattedError<TSchema>)

let onSubmitCb: UseFormReturnType<TSchema>['onSubmitForm'] | null = null
let onSubmitCb: ((data: z.infer<TSchema>) => MaybePromise<void>) | null = null

const isSubmitting = ref<boolean>(false)
const hasAttemptedToSubmit = ref<boolean>(false)

// The initial state of the form
// This is used to keep track of whether a field has been modified (isDirty)
const initialFormState = ref<DeepPartial<z.infer<TSchema>> | null>(initialState ? deepClone(initialState) : null)
const initialFormState = ref<DeepPartial<z.infer<TSchema>> | null>(initialState ? deepClone(toValue(initialState)) : null)

// Tracks all the registered paths (id, path)
const paths = reactive(new Map<string, string>())
Expand All @@ -57,6 +58,9 @@ export function useForm<TSchema extends z.ZodType>(
const registeredFields = reactive(new Map<string, Field<any, any>>())
const registeredFieldArrays = reactive(new Map<string, FieldArray<any>>())

if (initialState != null)
Object.assign(form, deepClone(toValue(initialState)))

const isDirty = computed<boolean>(() => {
return [
...registeredFields.values(),
Expand All @@ -66,6 +70,15 @@ export function useForm<TSchema extends z.ZodType>(

const isValid = computed<boolean>(() => Object.keys(errors.value).length === 0)

watch(() => initialState, (newInitialState) => {
if (!isDirty.value && newInitialState != null) {
initialFormState.value = deepClone(toValue(newInitialState))
Object.assign(form, deepClone(toValue(newInitialState)))
}
}, {
deep: true,
})

const getIdByPath = (
paths: Map<string, string>,
path: string,
Expand Down Expand Up @@ -119,6 +132,15 @@ export function useForm<TSchema extends z.ZodType>(
}
}

const getChildPaths = (path: string): (Field<any, any> | FieldArray<any>)[] => {
return [
...registeredFields.values(),
...registeredFieldArrays.values(),
].filter((field) => {
return field._path.startsWith(path) && field._path !== path
})
}

const createField = (
id: string,
path: string,
Expand All @@ -128,6 +150,7 @@ export function useForm<TSchema extends z.ZodType>(
'_id': id,
'_path': path,
'isValid': false,
'_isTouched': false,
'isDirty': false,
'isTouched': false,
'isChanged': false,
Expand All @@ -137,7 +160,7 @@ export function useForm<TSchema extends z.ZodType>(
set(form, path, newValue)
},
'onBlur': () => {
field.isTouched = true
field._isTouched = true
},
'onChange': () => {
field.isChanged = true
Expand Down Expand Up @@ -259,6 +282,7 @@ export function useForm<TSchema extends z.ZodType>(
_path: path,
isValid: false,
isDirty: false,
isTouched: false,
modelValue: defaultOrExistingValue,
errors: undefined,
append,
Expand Down Expand Up @@ -288,6 +312,10 @@ export function useForm<TSchema extends z.ZodType>(
return fieldArray
}

const isField = (field: Field<any, any> | FieldArray<any>): field is Field<any, any> => {
return (field as Field<any, any>)._isTouched !== undefined
}

const getFieldWithTrackedDepencies = <TFieldArray extends Field<any, any> | FieldArray<any>>(
field: TFieldArray,
initialValue: unknown,
Expand Down Expand Up @@ -320,6 +348,20 @@ export function useForm<TSchema extends z.ZodType>(
return JSON.stringify(field.modelValue) !== JSON.stringify(initialValue)
}) as unknown as boolean

field.isTouched = computed<boolean>(() => {
const children = getChildPaths(field._path)

const areAnyOfItsChildrenTouched = children.some(child => child.isTouched)

if (areAnyOfItsChildrenTouched)
return true

if (isField(field))
return field._isTouched

return false
}) as unknown as boolean

field.errors = computed<z.ZodFormattedError<TSchema>>(() => {
return get(errors.value, field._path)
}) as unknown as z.ZodFormattedError<TSchema>
Expand Down Expand Up @@ -452,7 +494,7 @@ export function useForm<TSchema extends z.ZodType>(
const id = getIdByPath(paths, path)

if (id === null)
throw new Error(`Could not unregister ${path} because it is not registered`)
throw new Error(`Could not unregister ${path} because it does not exist. This might be because it was never registered or because it was already unregistered.`)

updatePaths(path)
unset(form, path)
Expand All @@ -475,14 +517,17 @@ export function useForm<TSchema extends z.ZodType>(
if (!isValid.value)
return

// We need to keep track of the current form state, because the form might change while submitting
const currentFormState = deepClone(form)

isSubmitting.value = true

if (onSubmitCb == null)
throw new Error('Attempted to submit form but `onSubmitForm` callback is not registered')

await onSubmitCb(schema.parse(form))

initialFormState.value = deepClone(form)
initialFormState.value = deepClone(currentFormState)

isSubmitting.value = false
}
Expand Down Expand Up @@ -546,7 +591,7 @@ export function useForm<TSchema extends z.ZodType>(
setValues,
addErrors,
}),
onSubmitForm: (cb: (data: z.infer<TSchema>) => void) => {
onSubmitForm: (cb: (data: z.infer<TSchema>) => MaybePromise<void>) => {
onSubmitCb = cb
},
}
Expand Down
51 changes: 33 additions & 18 deletions src/types/form.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export interface Field<TValue, TDefaultValue = undefined> {
* The unique id of the field.
*/
_id: string
/**
* Internal flag to track if the field has been touched (blurred).
*/
_isTouched: boolean
/**
* The current value of the field.
*/
Expand Down Expand Up @@ -61,7 +65,7 @@ export interface Field<TValue, TDefaultValue = undefined> {
*
* @param value The new value of the field.
*/
'setValue': (value: TDefaultValue extends undefined ? TValue | null : TValue) => void
setValue: (value: TDefaultValue extends undefined ? TValue | null : TValue) => void
/**
* Called when the field input is blurred.
*/
Expand All @@ -72,16 +76,23 @@ export interface Field<TValue, TDefaultValue = undefined> {
onChange: () => void

register: <
TChildPath extends TValue extends FieldValues ? FieldPath<TValue> : never,
TChildDefaultValue extends TValue extends FieldValues ? FieldPathValue<TValue, TChildPath> | undefined : never,
TValueAsFieldValues extends TValue extends FieldValues ? TValue : never,
TChildPath extends FieldPath<TValueAsFieldValues>,
TChildDefaultValue extends FieldPathValue<TValueAsFieldValues, TChildPath> | undefined,
>(
path: TChildPath,
defaultValue?: TChildDefaultValue
) => TValue extends FieldValues ? Field<FieldPathValue<TValue, TChildPath>, any> : never
) => Field<
FieldPathValue<TValueAsFieldValues, TChildPath>,
TChildDefaultValue
>

registerArray: <TPath extends TValue extends FieldValues ? FieldPath<TValue> : never>(
registerArray: <
TValueAsFieldValues extends TValue extends FieldValues ? TValue : never,
TPath extends FieldPath<TValueAsFieldValues>,
>(
path: TPath
) => TValue extends FieldValues ? FieldArray<FieldPathValue<TValue, TPath>> : never
) => FieldArray<FieldPathValue<TValueAsFieldValues, TPath>>
}

/**
Expand Down Expand Up @@ -114,6 +125,10 @@ export interface FieldArray<TValue extends any[]> {
* Indicates whether the field has any errors.
*/
isValid: boolean
/**
* Indicates whether the field or any of its children have been touched (blurred).
*/
isTouched: boolean
/**
* Indicates whether the field value is different from its initial value.
*/
Expand Down Expand Up @@ -170,10 +185,10 @@ export interface FieldArray<TValue extends any[]> {
) => TValue extends FieldValues ? FieldArray<FieldPathValue<TValue, TPath>> : never
}

export type Register<TSchema extends z.ZodType> = <
TPath extends FieldPath<z.infer<TSchema>>,
TValue extends FieldPathValue<z.infer<TSchema>, TPath>,
TDefaultValue extends FieldPathValue<z.infer<TSchema>, TPath> | undefined,
export type Register<TSchema extends FieldValues> = <
TPath extends FieldPath<TSchema>,
TValue extends FieldPathValue<TSchema, TPath>,
TDefaultValue extends FieldPathValue<TSchema, TPath> | undefined,
>(field: TPath, defaultValue?: TDefaultValue) => Field<TValue, TDefaultValue>

export type RegisterArray<TSchema extends z.ZodType> = <
Expand All @@ -186,19 +201,19 @@ export type Unregister<T extends z.ZodType> = <
P extends FieldPath<z.infer<T>>,
>(field: P) => void

export interface Form<T extends z.ZodType> {
export interface Form<TSchema extends z.ZodType> {
/**
* Internal id of the form, to track it in the devtools.
*/
// _id: string
/**
* The current state of the form.
*/
state: Readonly<DeepPartial<z.infer<T>>>
state: Readonly<DeepPartial<z.infer<TSchema>>>
/**
* The collection of all registered fields' errors.
*/
errors: z.ZodFormattedError<z.infer<T>>
errors: z.ZodFormattedError<z.infer<TSchema>>
/**
* Indicates whether the form is dirty or not.
*
Expand All @@ -224,31 +239,31 @@ export interface Form<T extends z.ZodType> {
*
* @returns A `Field` instance that can be used to interact with the field.
*/
register: Register<T>
register: Register<z.infer<TSchema>>
/**
* Registers a new form field array.
*
* @returns A `FieldArray` instance that can be used to interact with the field array.
*/
registerArray: RegisterArray<T>
registerArray: RegisterArray<TSchema>
/**
* Unregisters a previously registered field.
*
* @param path The path of the field to unregister.
*/
unregister: Unregister<T>
unregister: Unregister<TSchema>
/**
* Sets errors in the form.
*
* @param errors The new errors for the form fields.
*/
addErrors: (errors: DeepPartial<z.ZodFormattedError<z.infer<T>>>) => void
addErrors: (errors: DeepPartial<z.ZodFormattedError<z.infer<TSchema>>>) => void
/**
* Sets values in the form.
*
* @param values The new values for the form fields.
*/
setValues: (values: DeepPartial<z.infer<T>>) => void
setValues: (values: DeepPartial<z.infer<TSchema>>) => void
/**
* Submits the form.
*
Expand Down
4 changes: 4 additions & 0 deletions src/types/utils.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,7 @@ export type Merge<A, B> = {
? B[K]
: never;
}

export type NullableKeys<T> = {
[K in keyof T]: T[K] | null
}
Loading
Loading