diff --git a/app/src/composables/form.ts b/app/src/composables/form.ts index fcc25cc3..71150f0d 100644 --- a/app/src/composables/form.ts +++ b/app/src/composables/form.ts @@ -1,6 +1,17 @@ -import type { BaseValidation } from '@vuelidate/core' -import type { InjectionKey, MaybeRefOrGetter } from 'vue' -import { inject, provide, toValue } from 'vue' +import type { + BaseValidation, + ServerErrors, + ValidationArgs, + ValidationRuleCollection, +} from '@vuelidate/core' +import useVuelidate from '@vuelidate/core' +import type { InjectionKey, MaybeRefOrGetter, Ref } from 'vue' +import { inject, provide, reactive, toValue } from 'vue' +import { computedWithControl } from '@vueuse/core' + +import { APIBadRequestError, type APIError } from '@/api/errors' +import type { Obj } from '@/types/commons' +import type { FormField, FormFieldDict } from '@/types/form' export const clearServerErrorsSymbol = Symbol() as InjectionKey< (key?: string) => void @@ -30,3 +41,90 @@ export function useTouch( return touch } + +export function useForm< + MV extends Obj, + FFD extends FormFieldDict = FormFieldDict, +>(form: Ref, fields: MaybeRefOrGetter) { + const serverErrors = reactive({}) + const validByDefault: ValidationRuleCollection = { true: () => true } + const rules = computedWithControl( + () => toValue(fields), + () => { + const fs = toValue(fields) + const validations = Object.keys(form.value).map((key: keyof MV) => [ + key, + (fs[key] as FormField).rules ?? validByDefault, + ]) + const rules: ValidationArgs = Object.fromEntries(validations) + return { + // create a fake validation rule for global state to be able to add $externalResult errors to it + global: { true: () => true }, + form: rules, + } + }, + ) + + const v = useVuelidate( + rules, + { form, global: null }, + { $externalResults: serverErrors }, + ) + + function onErrorFn(err: APIError, errorMessage?: string) { + if (!(err instanceof APIBadRequestError)) throw err + if (errorMessage || !err.data.name) { + serverErrors.global = [errorMessage || err.message] + } else { + deepSetErrors( + serverErrors, + [err.message], + 'form', + ...err.data.name.split('.'), + ) + } + } + + function onSubmit( + fn: (onError: typeof onErrorFn, serverErrors: ServerErrors) => void, + ) { + // FIXME add option to ask confirmation (with param text confirm) + return async (e: SubmitEvent) => { + e.preventDefault() + if (!(await v.value.$validate())) return + fn(onErrorFn, serverErrors) + } + } + + provide(clearServerErrorsSymbol, (key?: string) => { + const keys = key?.split('.') + if (keys?.length) { + deepSetErrors(serverErrors, [], ...keys) + } + }) + + return { + v, + serverErrors, + onSubmit, + } +} + +export function deepSetErrors( + serverErrors: ServerErrors, + value: string[], + ...keys: string[] +) { + const [k, ...ks] = keys + if (ks.length) { + if (!(k in serverErrors) && !value.length) { + serverErrors[k] = {} + deepSetErrors(serverErrors[k] as ServerErrors, value, ...ks) + } else if (k in serverErrors) { + deepSetErrors(serverErrors[k] as ServerErrors, value, ...ks) + } + } else { + if (!(k in serverErrors) && !value.length) return + serverErrors[k] = value + } +} diff --git a/app/src/helpers/commons.ts b/app/src/helpers/commons.ts index 669e3c63..421aee72 100644 --- a/app/src/helpers/commons.ts +++ b/app/src/helpers/commons.ts @@ -162,3 +162,7 @@ export function omit( .map((key) => [key, obj[key]]), ) as Omit } + +export function asUnreffed(value: T): UnwrapRef { + return value as UnwrapRef +} diff --git a/app/src/types/form.ts b/app/src/types/form.ts index 37c8802f..4bbb06f7 100644 --- a/app/src/types/form.ts +++ b/app/src/types/form.ts @@ -275,3 +275,12 @@ export type FormFieldDict = { | FormFieldReadonly | FormFieldDisplay } + +export type FieldProps< + C extends AnyItemComponents = 'InputItem', + MV extends any = never, +> = C extends AnyWritableComponents + ? FormField | FormFieldReadonly + : C extends AnyDisplayComponents + ? FormFieldDisplay + : never