From d72595d9ba2051cce1c4e32e3b224764e5ac4a2e Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 16 Jan 2023 12:02:56 -0500 Subject: [PATCH] Support server-only validations --- README.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.tsx | 37 +++++++++++++++-------- 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9f44062..5a0b526 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,90 @@ function FavoriteSkill() { } ``` +#### Server-only Validations + +While this library tries to lean-into shared validations between client and server, there are also good reasons not to share validations entirely. Most of the time, this comes down to keeping client-bundles small and/or needing direct server or DB access for certain validations. + +One approach is to just perform server-only validations manually _after_ calling `validateServerFormInfo`: + +```js +import { validateServerFormData } from "remix-validity-state"; + +export async function action({ request }: ActionArgs) { + const formData = await request.formData(); + const serverFormInfo = await validateServerFormData( + formData, + formDefinitions + ); + if (!serverFormInfo.valid) { + return json({ serverFormInfo }); + } + + // Now that we know our shared validations passed, we can perform more complex validations + let isEmailUnique = await checkEmail( + serverFormInfo.submittedValues.emailAddress + ); + if (!isEmailUnique) { + return json({ + serverFormInfo, + errors: { email: "Email address is not unique " }, + }); + } + // ... +} +``` + +This may be sufficient in some cases, but also now requires you to support a new error messaging UI separate from the one already handled via `` and/or provided by `useValidatedInput().info.errorMessages`. + +To support this common use-case, you can pass a set of `customValidations` server-only implementations to `validateServerFormData`, which will be used instead of the validations you define in the shared `formDefinition`. Usually, you'll just put a stub `() => true` in your shared validations so the client is aware of the validation. + +```ts +import type { ServerOnlyCustomValidations } from 'remix-validity-state' +import { validateServerFormData } from 'remix-validity-state' + +let formDefinition: FormSchema = { + inputs: { + emailAddress: { + validationAttrs: { + type: "email", + required: true, + }, + customValidations: { + // always "valid" in shared validations + uniqueEmail: () => true, + }, + errorMessages: { + uniqueEmail: () => 'Email address already in use!'', + }, + }, + } +}; + +const serverCustomValidations: ServerOnlyCustomValidations = { + emailAddress: { + async uniqueEmail(value) { + let isUnique = await checkEmail(value); + return isUnique; + }, + }, +}; + +export async function action({ request }: ActionArgs) { + const formData = await request.formData(); + const serverFormInfo = await validateServerFormData( + formData, + formDefinitions, + serverCustomValidations + ); + // serverFormInfo.valid here will be reflective of the custom server-only + // validation and will leverage your shared `errorMessages` + if (!serverFormInfo.valid) { + return json({ serverFormInfo }); + } + // ... +} +``` + #### Error Messages Basic error messaging is handled out of the box by `` for built-in HTML validations. If you are using custom validations, or if you want to override the built-in messaging, you can provide custom error messages in our `formDefinition`. Custom error messages can either be a static string, or a function that receives the attribute value (built-in validations only), the input name, and the input value: diff --git a/src/index.tsx b/src/index.tsx index 828fd84..52cdd89 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -176,6 +176,10 @@ export interface InputInfo { errorMessages?: Record; } +export type ServerOnlyCustomValidations = Partial<{ + [key in KeyOf]: CustomValidations; +}>; + // Server-side only (currently) - validate all specified inputs in the formData export type ServerFormInfo = { submittedValues: Record, string | string[] | null>; @@ -365,7 +369,8 @@ function IsInputDefinition( // Called in a useEffect client side and from validateServerFormIno server-side async function validateInput( inputName: string, - inputDef: ControlDefinition, + validationAttrs: ControlDefinition["validationAttrs"], + customValidations: ControlDefinition["customValidations"], value: string, inputEl?: SupportedHTMLElements | SupportedHTMLElements[], // CSR formData?: FormData // SSR @@ -381,8 +386,8 @@ async function validateInput( formData = new FormData(formEl); } - if (inputDef.validationAttrs) { - for (let _attr of Object.keys(inputDef.validationAttrs)) { + if (validationAttrs) { + for (let _attr of Object.keys(validationAttrs)) { let attr = _attr as KeyOf; // Ignoring this "error" since the type narrowing to accomplish this // would be nasty due to the differences in attribute values per input @@ -390,7 +395,7 @@ async function validateInput( // users are specifying valid attributes up front in their schemas and // just yolo this lookup // @ts-expect-error - let _attrValue = inputDef.validationAttrs[attr] || null; + let _attrValue = validationAttrs[attr] || null; let attrValue = calculateValidationAttr(_attrValue, formData); // Undefined attr values means the attribute doesn't exist and there's // nothing to validate @@ -413,9 +418,9 @@ async function validateInput( } } - if (inputDef.customValidations) { - for (let name of Object.keys(inputDef.customValidations)) { - let validate = inputDef.customValidations[name]; + if (customValidations) { + for (let name of Object.keys(customValidations)) { + let validate = customValidations[name]; let isInvalid = !(await validate(value, formData)); validity[name] = isInvalid; validity.valid = validity.valid && !isInvalid; @@ -428,7 +433,8 @@ async function validateInput( // Perform all validations for a submitted form on the server export async function validateServerFormData( formData: FormData, - formDefinition: T + formDefinition: T, + serverCustomValidations?: ServerOnlyCustomValidations ): Promise> { // Unsure if there's a better way to do this type of object mapping while // keeping the keys strongly typed - but this currently complains since we @@ -454,7 +460,11 @@ export async function validateServerFormData( state: "done", validity: await validateInput( inputName, - inputDef, + inputDef.validationAttrs, + { + ...inputDef.customValidations, + ...serverCustomValidations?.[inputName], + }, value, undefined, formData @@ -482,7 +492,8 @@ export async function validateServerFormData( state: "done", validity: await validateInput( inputName, - inputDef, + inputDef.validationAttrs, + inputDef.customValidations, "", undefined, formData @@ -882,7 +893,8 @@ function useValidatedControl< if (inputType === "radio" || inputType === "checkbox") { validity = await validateInput( name, - inputDef, + inputDef.validationAttrs, + inputDef.customValidations, value, Array.from( inputRef.current?.form?.querySelectorAll( @@ -893,7 +905,8 @@ function useValidatedControl< } else { validity = await validateInput( name, - inputDef, + inputDef.validationAttrs, + inputDef.customValidations, value, inputRef.current || undefined );