fix(SW-2385): handle error messages provided from zod validation in forms client side

This commit is contained in:
Christian Andolf
2025-04-15 15:04:32 +02:00
committed by Michael Zetterberg
parent 2648b17744
commit 595eb575d7
7 changed files with 257 additions and 99 deletions

View File

@@ -3,26 +3,36 @@ import { z } from "zod"
import { passwordValidator } from "@/utils/zod/passwordValidator" import { passwordValidator } from "@/utils/zod/passwordValidator"
import { phoneValidator } from "@/utils/zod/phoneValidator" import { phoneValidator } from "@/utils/zod/phoneValidator"
const countryRequiredMsg = "Country is required" export const editProfileErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
ZIP_CODE_REQUIRED: "ZIP_CODE_REQUIRED",
PHONE_REQUIRED: "PHONE_REQUIRED",
PHONE_REQUESTED: "PHONE_REQUESTED",
PASSWORD_NEW_REQUIRED: "PASSWORD_NEW_REQUIRED",
PASSWORD_RETYPE_NEW_REQUIRED: "PASSWORD_RETYPE_NEW_REQUIRED",
PASSWORD_CURRENT_REQUIRED: "PASSWORD_CURRENT_REQUIRED",
PASSWORD_NEW_NOT_MATCH: "PASSWORD_NEW_NOT_MATCH",
} as const
export const editProfileSchema = z export const editProfileSchema = z
.object({ .object({
address: z.object({ address: z.object({
city: z.string().optional(), city: z.string().optional(),
countryCode: z countryCode: z
.string({ .string({
required_error: countryRequiredMsg, required_error: editProfileErrors.COUNTRY_REQUIRED,
invalid_type_error: countryRequiredMsg, invalid_type_error: editProfileErrors.COUNTRY_REQUIRED,
}) })
.min(1, countryRequiredMsg), .min(1, editProfileErrors.COUNTRY_REQUIRED),
streetAddress: z.string().optional(), streetAddress: z.string().optional(),
zipCode: z.string().min(1, "Zip code is required"), zipCode: z.string().min(1, editProfileErrors.ZIP_CODE_REQUIRED),
}), }),
dateOfBirth: z.string().min(1), dateOfBirth: z.string().min(1),
email: z.string().email(), email: z.string().email(),
language: z.string(), language: z.string(),
phoneNumber: phoneValidator( phoneNumber: phoneValidator(
"Phone is required", editProfileErrors.PHONE_REQUIRED,
"Please enter a valid phone number" editProfileErrors.PHONE_REQUESTED
), ),
password: z.string().optional(), password: z.string().optional(),
@@ -34,14 +44,14 @@ export const editProfileSchema = z
if (!data.newPassword) { if (!data.newPassword) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "New password is required", message: editProfileErrors.PASSWORD_NEW_REQUIRED,
path: ["newPassword"], path: ["newPassword"],
}) })
} }
if (!data.retypeNewPassword) { if (!data.retypeNewPassword) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "Retype new password is required", message: editProfileErrors.PASSWORD_RETYPE_NEW_REQUIRED,
path: ["retypeNewPassword"], path: ["retypeNewPassword"],
}) })
} }
@@ -49,7 +59,7 @@ export const editProfileSchema = z
if (data.newPassword || data.retypeNewPassword) { if (data.newPassword || data.retypeNewPassword) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "Current password is required", message: editProfileErrors.PASSWORD_CURRENT_REQUIRED,
path: ["password"], path: ["password"],
}) })
} }
@@ -58,7 +68,7 @@ export const editProfileSchema = z
if (data.newPassword && !data.retypeNewPassword) { if (data.newPassword && !data.retypeNewPassword) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "Retype new password is required", message: editProfileErrors.PASSWORD_RETYPE_NEW_REQUIRED,
path: ["retypeNewPassword"], path: ["retypeNewPassword"],
}) })
} }
@@ -66,7 +76,7 @@ export const editProfileSchema = z
if (data.retypeNewPassword !== data.newPassword) { if (data.retypeNewPassword !== data.newPassword) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "Retype new password does not match new password", message: editProfileErrors.PASSWORD_NEW_NOT_MATCH,
path: ["retypeNewPassword"], path: ["retypeNewPassword"],
}) })
} }

View File

@@ -3,35 +3,45 @@ import { z } from "zod"
import { passwordValidator } from "@/utils/zod/passwordValidator" import { passwordValidator } from "@/utils/zod/passwordValidator"
import { phoneValidator } from "@/utils/zod/phoneValidator" import { phoneValidator } from "@/utils/zod/phoneValidator"
const countryRequiredMsg = "Country is required" export const signupErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
PHONE_REQUIRED: "PHONE_REQUIRED",
PHONE_REQUESTED: "PHONE_REQUESTED",
BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED",
PASSWORD_REQUIRED: "PASSWORD_REQUIRED",
TERMS_REQUIRED: "TERMS_REQUIRED",
} as const
export const signUpSchema = z.object({ export const signUpSchema = z.object({
firstName: z.string().max(250).trim().min(1, { firstName: z
message: "First name is required", .string()
}), .max(250)
lastName: z.string().max(250).trim().min(1, { .trim()
message: "Last name is required", .min(1, signupErrors.FIRST_NAME_REQUIRED),
}), lastName: z.string().max(250).trim().min(1, signupErrors.LAST_NAME_REQUIRED),
email: z.string().max(250).email(), email: z.string().max(250).email(),
phoneNumber: phoneValidator( phoneNumber: phoneValidator(
"Phone is required", signupErrors.PHONE_REQUIRED,
"Please enter a valid phone number" signupErrors.PHONE_REQUESTED
), ),
dateOfBirth: z.string().min(1, { dateOfBirth: z.string().min(1, {
message: "Date of birth is required", message: signupErrors.BIRTH_DATE_REQUIRED,
}), }),
address: z.object({ address: z.object({
countryCode: z countryCode: z
.string({ .string({
required_error: countryRequiredMsg, required_error: signupErrors.COUNTRY_REQUIRED,
invalid_type_error: countryRequiredMsg, invalid_type_error: signupErrors.COUNTRY_REQUIRED,
}) })
.min(1, countryRequiredMsg), .min(1, signupErrors.COUNTRY_REQUIRED),
zipCode: z.string().min(1), zipCode: z.string().min(1),
}), }),
password: passwordValidator("Password is required"), password: passwordValidator(signupErrors.PASSWORD_REQUIRED),
termsAccepted: z.boolean().refine((value) => value === true, { termsAccepted: z
message: "You must accept the terms and conditions", .boolean()
}), .refine((value) => value === true, signupErrors.TERMS_REQUIRED),
}) })
export type SignUpSchema = z.infer<typeof signUpSchema> export type SignUpSchema = z.infer<typeof signUpSchema>

View File

@@ -9,23 +9,35 @@ const stringMatcher =
const isValidString = (key: string) => stringMatcher.test(key) const isValidString = (key: string) => stringMatcher.test(key)
export const multiroomErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
FIRST_NAME_SPECIAL_CHARACTERS: "FIRST_NAME_SPECIAL_CHARACTERS",
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
LAST_NAME_SPECIAL_CHARACTERS: "LAST_NAME_SPECIAL_CHARACTERS",
PHONE_REQUIRED: "PHONE_REQUIRED",
PHONE_REQUESTED: "PHONE_REQUESTED",
EMAIL_REQUIRED: "EMAIL_REQUIRED",
MEMBERSHIP_NO_ONLY_DIGITS: "MEMBERSHIP_NO_ONLY_DIGITS",
MEMBERSHIP_NO_INVALID: "MEMBERSHIP_NO_INVALID",
} as const
export const multiroomDetailsSchema = z.object({ export const multiroomDetailsSchema = z.object({
countryCode: z.string().min(1, { message: "Country is required" }), countryCode: z.string().min(1, multiroomErrors.COUNTRY_REQUIRED),
email: z.string().email({ message: "Email address is required" }), email: z.string().email(multiroomErrors.EMAIL_REQUIRED),
firstName: z firstName: z
.string() .string()
.min(1, { message: "First name is required" }) .min(1, multiroomErrors.FIRST_NAME_REQUIRED)
.refine(isValidString, { .refine(isValidString, multiroomErrors.FIRST_NAME_SPECIAL_CHARACTERS),
message: "First name can't contain any special characters",
}),
join: z.boolean().default(false), join: z.boolean().default(false),
lastName: z lastName: z
.string() .string()
.min(1, { message: "Last name is required" }) .min(1, multiroomErrors.LAST_NAME_REQUIRED)
.refine(isValidString, { .refine(isValidString, multiroomErrors.LAST_NAME_SPECIAL_CHARACTERS),
message: "Last name can't contain any special characters", phoneNumber: phoneValidator(
}), multiroomErrors.PHONE_REQUIRED,
phoneNumber: phoneValidator(), multiroomErrors.PHONE_REQUESTED
),
membershipNo: z membershipNo: z
.string() .string()
.optional() .optional()
@@ -34,12 +46,12 @@ export const multiroomDetailsSchema = z.object({
return !val.match(/[^0-9]/g) return !val.match(/[^0-9]/g)
} }
return true return true
}, "Only digits are allowed") }, multiroomErrors.MEMBERSHIP_NO_ONLY_DIGITS)
.refine((num) => { .refine((num) => {
if (num) { if (num) {
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/) return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
} }
return true return true
}, "Invalid membership number format"), }, multiroomErrors.MEMBERSHIP_NO_INVALID),
specialRequest: specialRequestSchema, specialRequest: specialRequestSchema,
}) })

View File

@@ -11,22 +11,37 @@ const stringMatcher =
const isValidString = (key: string) => stringMatcher.test(key) const isValidString = (key: string) => stringMatcher.test(key)
export const roomOneErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
FIRST_NAME_SPECIAL_CHARACTERS: "FIRST_NAME_SPECIAL_CHARACTERS",
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
LAST_NAME_SPECIAL_CHARACTERS: "LAST_NAME_SPECIAL_CHARACTERS",
PHONE_REQUIRED: "PHONE_REQUIRED",
PHONE_REQUESTED: "PHONE_REQUESTED",
EMAIL_REQUIRED: "EMAIL_REQUIRED",
MEMBERSHIP_NO_ONLY_DIGITS: "MEMBERSHIP_NO_ONLY_DIGITS",
MEMBERSHIP_NO_INVALID: "MEMBERSHIP_NO_INVALID",
ZIP_CODE_REQUIRED: "ZIP_CODE_REQUIRED",
BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED",
BIRTH_DATE_AGE_18: "BIRTH_DATE_AGE_18",
} as const
export const baseDetailsSchema = z.object({ export const baseDetailsSchema = z.object({
countryCode: z.string().min(1, { message: "Country is required" }), countryCode: z.string().min(1, roomOneErrors.COUNTRY_REQUIRED),
email: z.string().email({ message: "Email address is required" }), email: z.string().email(roomOneErrors.EMAIL_REQUIRED),
firstName: z firstName: z
.string() .string()
.min(1, { message: "First name is required" }) .min(1, roomOneErrors.FIRST_NAME_REQUIRED)
.refine(isValidString, { .refine(isValidString, roomOneErrors.FIRST_NAME_SPECIAL_CHARACTERS),
message: "First name can't contain any special characters",
}),
lastName: z lastName: z
.string() .string()
.min(1, { message: "Last name is required" }) .min(1, roomOneErrors.LAST_NAME_REQUIRED)
.refine(isValidString, { .refine(isValidString, roomOneErrors.LAST_NAME_SPECIAL_CHARACTERS),
message: "Last name can't contain any special characters", phoneNumber: phoneValidator(
}), roomOneErrors.PHONE_REQUIRED,
phoneNumber: phoneValidator(), roomOneErrors.PHONE_REQUESTED
),
specialRequest: specialRequestSchema, specialRequest: specialRequestSchema,
}) })
@@ -43,32 +58,29 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge(
return !val.match(/[^0-9]/g) return !val.match(/[^0-9]/g)
} }
return true return true
}, "Only digits are allowed") }, roomOneErrors.MEMBERSHIP_NO_ONLY_DIGITS)
.refine((num) => { .refine((num) => {
if (num) { if (num) {
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/) return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
} }
return true return true
}, "Invalid membership number format"), }, roomOneErrors.MEMBERSHIP_NO_INVALID),
}) })
) )
export const joinDetailsSchema = baseDetailsSchema.merge( export const joinDetailsSchema = baseDetailsSchema.merge(
z.object({ z.object({
join: z.literal<boolean>(true), join: z.literal<boolean>(true),
zipCode: z.string().min(1, { message: "Zip code is required" }), zipCode: z.string().min(1, roomOneErrors.ZIP_CODE_REQUIRED),
dateOfBirth: z dateOfBirth: z
.string() .string()
.min(1, { message: "Date of birth is required" }) .min(1, roomOneErrors.BIRTH_DATE_REQUIRED)
.refine( .refine((date) => {
(date) => {
const today = dt() const today = dt()
const dob = dt(date) const dob = dt(date)
const age = today.diff(dob, "year") const age = today.diff(dob, "year")
return age >= 18 return age >= 18
}, }, roomOneErrors.BIRTH_DATE_AGE_18),
{ message: "Must be at least 18 years of age to continue" }
),
membershipNo: z.string().default(""), membershipNo: z.string().default(""),
}) })
) )

View File

@@ -1,49 +1,41 @@
import { defineMessage } from "react-intl"
import { z } from "zod" import { z } from "zod"
export { export {
type AdditionalInfoFormSchema, type AdditionalInfoFormSchema,
additionalInfoFormSchema, additionalInfoFormSchema,
findMyBookingErrors,
type FindMyBookingFormSchema, type FindMyBookingFormSchema,
findMyBookingFormSchema, findMyBookingFormSchema,
} }
defineMessage({ const findMyBookingErrors = {
defaultMessage: "Invalid booking number", BOOKING_NUMBER_INVALID: "BOOKING_NUMBER_INVALID",
}) BOOKING_NUMBER_REQUIRED: "BOOKING_NUMBER_REQUIRED",
defineMessage({ FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
defaultMessage: "Booking number is required", LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
}) EMAIL_REQUIRED: "EMAIL_REQUIRED",
defineMessage({ } as const
defaultMessage: "First name is required",
})
defineMessage({
defaultMessage: "Last name is required",
})
defineMessage({
defaultMessage: "Email address is required",
})
const additionalInfoFormSchema = z.object({ const additionalInfoFormSchema = z.object({
firstName: z.string().trim().max(250).min(1, { firstName: z
message: "First name is required", .string()
}), .trim()
email: z.string().max(250).email({ message: "Email address is required" }), .max(250)
.min(1, findMyBookingErrors.FIRST_NAME_REQUIRED),
email: z.string().max(250).email(findMyBookingErrors.EMAIL_REQUIRED),
}) })
const findMyBookingFormSchema = additionalInfoFormSchema.extend({ const findMyBookingFormSchema = additionalInfoFormSchema.extend({
confirmationNumber: z confirmationNumber: z
.string() .string()
.trim() .trim()
.min(1, { .min(1, findMyBookingErrors.BOOKING_NUMBER_REQUIRED)
message: "Booking number is required", .regex(/^[0-9]+(-[0-9])?$/, findMyBookingErrors.BOOKING_NUMBER_INVALID),
}) lastName: z
.regex(/^[0-9]+(-[0-9])?$/, { .string()
message: "Invalid booking number", .trim()
}), .max(250)
lastName: z.string().trim().max(250).min(1, { .min(1, findMyBookingErrors.LAST_NAME_REQUIRED),
message: "Last name is required",
}),
}) })
type AdditionalInfoFormSchema = z.output<typeof additionalInfoFormSchema> type AdditionalInfoFormSchema = z.output<typeof additionalInfoFormSchema>

View File

@@ -0,0 +1,122 @@
import { editProfileErrors } from "@/components/Forms/Edit/Profile/schema"
import { signupErrors } from "@/components/Forms/Signup/schema"
import { multiroomErrors } from "@/components/HotelReservation/EnterDetails/Details/Multiroom/schema"
import { roomOneErrors } from "@/components/HotelReservation/EnterDetails/Details/RoomOne/schema"
import { findMyBookingErrors } from "@/components/HotelReservation/FindMyBooking/schema"
import type { IntlShape } from "react-intl"
export function getErrorMessage(intl: IntlShape, errorCode?: string) {
switch (errorCode) {
case findMyBookingErrors.BOOKING_NUMBER_INVALID:
return intl.formatMessage({
defaultMessage: "Invalid booking number",
})
case findMyBookingErrors.BOOKING_NUMBER_REQUIRED:
return intl.formatMessage({
defaultMessage: "Invalid booking number",
})
case findMyBookingErrors.FIRST_NAME_REQUIRED:
case signupErrors.FIRST_NAME_REQUIRED:
case multiroomErrors.FIRST_NAME_REQUIRED:
case roomOneErrors.FIRST_NAME_REQUIRED:
return intl.formatMessage({
defaultMessage: "First name is required",
})
case multiroomErrors.FIRST_NAME_SPECIAL_CHARACTERS:
case roomOneErrors.FIRST_NAME_SPECIAL_CHARACTERS:
return intl.formatMessage({
defaultMessage: "First name can't contain any special characters",
})
case findMyBookingErrors.LAST_NAME_REQUIRED:
case signupErrors.LAST_NAME_REQUIRED:
case multiroomErrors.LAST_NAME_REQUIRED:
case roomOneErrors.LAST_NAME_REQUIRED:
return intl.formatMessage({
defaultMessage: "Last name is required",
})
case multiroomErrors.LAST_NAME_SPECIAL_CHARACTERS:
case roomOneErrors.LAST_NAME_SPECIAL_CHARACTERS:
return intl.formatMessage({
defaultMessage: "Last name can't contain any special characters",
})
case findMyBookingErrors.EMAIL_REQUIRED:
case multiroomErrors.EMAIL_REQUIRED:
case roomOneErrors.EMAIL_REQUIRED:
return intl.formatMessage({
defaultMessage: "Email address is required",
})
case signupErrors.COUNTRY_REQUIRED:
case multiroomErrors.COUNTRY_REQUIRED:
case roomOneErrors.COUNTRY_REQUIRED:
case editProfileErrors.COUNTRY_REQUIRED:
return intl.formatMessage({
defaultMessage: "Country is required",
})
case signupErrors.PHONE_REQUIRED:
case multiroomErrors.PHONE_REQUIRED:
case roomOneErrors.PHONE_REQUIRED:
case editProfileErrors.PHONE_REQUIRED:
return intl.formatMessage({
defaultMessage: "Phone is required",
})
case signupErrors.PHONE_REQUESTED:
case multiroomErrors.PHONE_REQUESTED:
case roomOneErrors.PHONE_REQUESTED:
case editProfileErrors.PHONE_REQUESTED:
return intl.formatMessage({
defaultMessage: "Please enter a valid phone number",
})
case signupErrors.BIRTH_DATE_REQUIRED:
case roomOneErrors.BIRTH_DATE_REQUIRED:
return intl.formatMessage({
defaultMessage: "Date of birth is required",
})
case roomOneErrors.BIRTH_DATE_AGE_18:
return intl.formatMessage({
defaultMessage: "Must be at least 18 years of age to continue",
})
case roomOneErrors.ZIP_CODE_REQUIRED:
case editProfileErrors.ZIP_CODE_REQUIRED:
return intl.formatMessage({
defaultMessage: "Zip code is required",
})
case signupErrors.PASSWORD_REQUIRED:
return intl.formatMessage({
defaultMessage: "Password is required",
})
case editProfileErrors.PASSWORD_NEW_REQUIRED:
return intl.formatMessage({
defaultMessage: "New password is required",
})
case editProfileErrors.PASSWORD_RETYPE_NEW_REQUIRED:
return intl.formatMessage({
defaultMessage: "Retype new password is required",
})
case editProfileErrors.PASSWORD_CURRENT_REQUIRED:
return intl.formatMessage({
defaultMessage: "Current password is required",
})
case editProfileErrors.PASSWORD_NEW_NOT_MATCH:
return intl.formatMessage({
defaultMessage: "Retype new password does not match new password",
})
case signupErrors.TERMS_REQUIRED:
return intl.formatMessage({
defaultMessage: "You must accept the terms and conditions",
})
case multiroomErrors.MEMBERSHIP_NO_ONLY_DIGITS:
case roomOneErrors.MEMBERSHIP_NO_ONLY_DIGITS:
return intl.formatMessage({
defaultMessage: "Only digits are allowed",
})
case multiroomErrors.MEMBERSHIP_NO_INVALID:
case roomOneErrors.MEMBERSHIP_NO_INVALID:
return intl.formatMessage({
defaultMessage: "Invalid membership number format",
})
default:
console.warn("Error code not supported:", errorCode)
return errorCode
}
}

View File

@@ -1,4 +1,5 @@
"use client" "use client"
import { forwardRef, type HTMLAttributes, type WheelEvent } from "react" import { forwardRef, type HTMLAttributes, type WheelEvent } from "react"
import { Text, TextField } from "react-aria-components" import { Text, TextField } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form" import { Controller, useFormContext } from "react-hook-form"
@@ -9,6 +10,8 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel" import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getErrorMessage } from "./errors"
import styles from "./input.module.css" import styles from "./input.module.css"
import type { InputProps } from "./input" import type { InputProps } from "./input"
@@ -81,10 +84,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{fieldState.error && !hideError ? ( {fieldState.error && !hideError ? (
<Caption className={styles.error} fontOnly> <Caption className={styles.error} fontOnly>
<MaterialIcon icon="info" color="Icon/Interactive/Accent" /> <MaterialIcon icon="info" color="Icon/Interactive/Accent" />
{intl.formatMessage({ {getErrorMessage(intl, fieldState.error.message)}
// eslint-disable-next-line formatjs/enforce-default-message
defaultMessage: fieldState.error.message,
})}
</Caption> </Caption>
) : null} ) : null}
</TextField> </TextField>