Merged in fix/loy-514-fix-validation-tracking-in-signup-form (pull request #3445)

fix(LOY-514): Fix validation error tracking in SignupForm

* Fix issue with form submit handling

* Fix

* Remove browser validation

* Add automatic tracking of validatione rrors


Approved-by: Rasmus Langvad
Approved-by: Matilda Landström
This commit is contained in:
Anton Gunnarsson
2026-01-19 08:34:24 +00:00
parent 0c5670823b
commit 66b7af877a
11 changed files with 43 additions and 47 deletions

View File

@@ -249,8 +249,8 @@ export default function SignupForm({
defaultMessage: "Email address", defaultMessage: "Email address",
})} })}
name="email" name="email"
registerOptions={{ required: true }}
type="email" type="email"
registerOptions={{ required: true }}
/> />
<Phone <Phone
countryLabel={intl.formatMessage({ countryLabel={intl.formatMessage({
@@ -307,9 +307,7 @@ export default function SignupForm({
</header> </header>
<Checkbox <Checkbox
name="profilingConsent" name="profilingConsent"
registerOptions={{ registerOptions={{ required: true }}
required: false,
}}
> >
{intl.formatMessage({ {intl.formatMessage({
id: "signup.yesConsent", id: "signup.yesConsent",
@@ -344,9 +342,7 @@ export default function SignupForm({
</header> </header>
<Checkbox <Checkbox
name="termsAccepted" name="termsAccepted"
registerOptions={{ registerOptions={{ required: true }}
required: true,
}}
errorCodeMessages={{ errorCodeMessages={{
[signupErrors.TERMS_REQUIRED]: intl.formatMessage({ [signupErrors.TERMS_REQUIRED]: intl.formatMessage({
id: "common.mustAcceptTermsError", id: "common.mustAcceptTermsError",
@@ -394,33 +390,15 @@ export default function SignupForm({
</Typography> </Typography>
</section> </section>
{/* <Button
This is a manual validation trigger workaround: className={styles.signUpButton}
- The Controller component (which Input uses) doesn't re-render on submit, type="submit"
which prevents automatic error display. variant="Primary"
- Future fix requires Input component refactoring (out of scope for now). isDisabled={methods.formState.isSubmitting || signup.isPending}
*/} data-testid="submit"
{!methods.formState.isValid ? ( >
<Button {signupButtonText}
className={styles.signUpButton} </Button>
type="submit"
variant="Primary"
onPress={() => methods.trigger()}
data-testid="trigger-validation"
>
{signupButtonText}
</Button>
) : (
<Button
className={styles.signUpButton}
type="submit"
variant="Primary"
isDisabled={methods.formState.isSubmitting || signup.isPending}
data-testid="submit"
>
{signupButtonText}
</Button>
)}
</form> </form>
</FormProvider> </FormProvider>
</div> </div>

View File

@@ -146,7 +146,6 @@ export default function ChildInfoSelector({
<div key={index} className={styles.childInfoContainer}> <div key={index} className={styles.childInfoContainer}>
<div ref={ageSelectRef}> <div ref={ageSelectRef}>
<Select <Select
isRequired
items={ageList} items={ageList}
name={ageFieldName} name={ageFieldName}
label={ageLabel} label={ageLabel}
@@ -161,7 +160,6 @@ export default function ChildInfoSelector({
<div ref={bedPrefSelectRef}> <div ref={bedPrefSelectRef}>
{child.age >= 0 ? ( {child.age >= 0 ? (
<Select <Select
isRequired
items={getAvailableBeds(child.age)} items={getAvailableBeds(child.age)}
name={bedFieldName} name={bedFieldName}
label={bedLabel} label={bedLabel}

View File

@@ -85,7 +85,6 @@ export default function CountryCombobox({
className={styles.select} className={styles.select}
data-testid={name} data-testid={name}
isDisabled={disabled} isDisabled={disabled}
isRequired={Boolean(registerOptions?.required)}
isInvalid={fieldState.invalid} isInvalid={fieldState.invalid}
name={name} name={name}
onBlur={field.onBlur} onBlur={field.onBlur}

View File

@@ -54,7 +54,6 @@ export default function CountrySelect({
items={items} items={items}
label={label} label={label}
isDisabled={disabled} isDisabled={disabled}
isRequired={Boolean(registerOptions?.required)}
isInvalid={fieldState.invalid} isInvalid={fieldState.invalid}
name={name} name={name}
onBlur={field.onBlur} onBlur={field.onBlur}

View File

@@ -127,7 +127,6 @@ export default function DateSelect({
label={labels.day} label={labels.day}
name={DateName.day} name={DateName.day}
onSelectionChange={(key) => setValue(DateName.day, Number(key))} onSelectionChange={(key) => setValue(DateName.day, Number(key))}
isRequired
enableFiltering={isDesktop} enableFiltering={isDesktop}
isInvalid={fieldState.invalid} isInvalid={fieldState.invalid}
onBlur={field.onBlur} onBlur={field.onBlur}
@@ -142,7 +141,6 @@ export default function DateSelect({
label={labels.month} label={labels.month}
name={DateName.month} name={DateName.month}
onSelectionChange={(key) => setValue(DateName.month, Number(key))} onSelectionChange={(key) => setValue(DateName.month, Number(key))}
isRequired
enableFiltering={isDesktop} enableFiltering={isDesktop}
isInvalid={fieldState.invalid} isInvalid={fieldState.invalid}
onBlur={field.onBlur} onBlur={field.onBlur}
@@ -156,7 +154,6 @@ export default function DateSelect({
label={labels.year} label={labels.year}
name={DateName.year} name={DateName.year}
onSelectionChange={(key) => setValue(DateName.year, Number(key))} onSelectionChange={(key) => setValue(DateName.year, Number(key))}
isRequired
enableFiltering={isDesktop} enableFiltering={isDesktop}
isInvalid={fieldState.invalid} isInvalid={fieldState.invalid}
onBlur={field.onBlur} onBlur={field.onBlur}

View File

@@ -78,7 +78,6 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
isDisabled={isDisabled} isDisabled={isDisabled}
isReadOnly={readOnly} isReadOnly={readOnly}
isInvalid={fieldState.invalid} isInvalid={fieldState.invalid}
isRequired={!!registerOptions.required}
> >
<Input <Input
{...props} {...props}

View File

@@ -59,7 +59,6 @@ export const FormTextArea = forwardRef<HTMLTextAreaElement, FormTextAreaProps>(
isDisabled={isDisabled} isDisabled={isDisabled}
isReadOnly={readOnly} isReadOnly={readOnly}
isInvalid={fieldState.invalid} isInvalid={fieldState.invalid}
isRequired={!!registerOptions.required}
> >
<TextArea <TextArea
{...props} {...props}

View File

@@ -131,7 +131,6 @@ export default function Phone({
aria-label={ariaLabel} aria-label={ariaLabel}
isDisabled={disabled || registerOptions.disabled} isDisabled={disabled || registerOptions.disabled}
isInvalid={fieldState.invalid} isInvalid={fieldState.invalid}
isRequired={!!registerOptions?.required}
name={name} name={name}
type="tel" type="tel"
value={phoneNumber} value={phoneNumber}

View File

@@ -89,7 +89,6 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent(
<AriaInput <AriaInput
{...props} {...props}
id={inputId} id={inputId}
required={required}
// Avoid duplicating label text in placeholder when label is positioned above // Avoid duplicating label text in placeholder when label is positioned above
// Screen readers would announce the label twice (once as label, once as placeholder) // Screen readers would announce the label twice (once as label, once as placeholder)
// Only use placeholder if explicitly provided, otherwise use empty string // Only use placeholder if explicitly provided, otherwise use empty string
@@ -140,7 +139,6 @@ const InputComponent = forwardRef(function AriaInputWithLabelComponent(
<AriaInput <AriaInput
{...props} {...props}
id={id} id={id}
required={required}
// For floating labels, only set placeholder if explicitly provided // For floating labels, only set placeholder if explicitly provided
// The label itself acts as the placeholder, so we don't want to duplicate it // The label itself acts as the placeholder, so we don't want to duplicate it
// This ensures the label only floats when focused or has value // This ensures the label only floats when focused or has value

View File

@@ -84,7 +84,6 @@ export const PasswordInput = ({
aria-describedby={describedBy} aria-describedby={describedBy}
isDisabled={field.disabled} isDisabled={field.disabled}
isInvalid={fieldState.invalid} isInvalid={fieldState.invalid}
isRequired={!!registerOptions.required}
name={field.name} name={field.name}
onBlur={field.onBlur} onBlur={field.onBlur}
onChange={field.onChange} onChange={field.onChange}

View File

@@ -13,8 +13,11 @@ import {
trackFormAbandonment, trackFormAbandonment,
trackFormCompletion, trackFormCompletion,
trackFormInputStarted, trackFormInputStarted,
trackFormValidationError,
} from "./form" } from "./form"
import type { FieldErrors } from "react-hook-form"
export function useFormTracking<T extends FieldValues>( export function useFormTracking<T extends FieldValues>(
formType: FormType, formType: FormType,
subscribe: UseFormSubscribe<T>, subscribe: UseFormSubscribe<T>,
@@ -66,6 +69,13 @@ export function useFormTracking<T extends FieldValues>(
} }
}, [formStarted, formType, nameSuffix, formState.isValid]) }, [formStarted, formType, nameSuffix, formState.isValid])
useEffect(() => {
if (formState.submitCount === 0) return
if (formState.isValid) return
trackErrors(formState.errors, formType, nameSuffix)
}, [formState.submitCount, formState.errors, formType, nameSuffix])
const trackFormSubmit = useCallback(() => { const trackFormSubmit = useCallback(() => {
if (formState.isValid) { if (formState.isValid) {
trackFormCompletion(formType, nameSuffix) trackFormCompletion(formType, nameSuffix)
@@ -76,3 +86,24 @@ export function useFormTracking<T extends FieldValues>(
trackFormSubmit, trackFormSubmit,
} }
} }
function trackErrors<T extends FieldValues>(
errors: FieldErrors<T>,
formType: FormType,
nameSuffix: string
) {
const errorKeys = Object.getOwnPropertyNames(errors)
errorKeys.forEach((key) => {
const msg = errors[key]?.message
if (!msg) {
// Handle nested errors
trackErrors(errors[key] as FieldErrors<T>, formType, nameSuffix)
return
}
if (!msg || typeof msg !== "string") return
trackFormValidationError(formType, nameSuffix, msg)
})
}