form icon indicating copy to clipboard operation
form copied to clipboard

Add a way to display only one error for a field

Open francois-pasquier opened this issue 10 months ago • 3 comments

Describe the bug

Currently we return field.state.meta.touchedErrors ValidationError[] as a string by concataining the different errors of a field with commas.

It would be great to be able to display only one error.

e.g:

         validators={{
            onChange: v.string([
              v.minLength(1, t(i18n)`This field is mandatory`),
              v.custom(isDateStringValid, t(i18n)`The birth date format is incorrect`),
              v.custom(isDateStringOver18, t(i18n)`You have to be at least 18 years`),
            ]),
          }}

These valibot validators will end up on showing all three errors just by typing a letter and deleting it. Imo, this is not a good UX.

I would expect some kind of priority in the error messages, thus only displaying the first validation to return an error.

Your minimal, reproducible example

https://github.com/TanStack/form/

Steps to reproduce

     validators={{
        onChange: v.string([
          v.minLength(1, t(i18n)`This field is mandatory`),
          v.custom(isDateStringValid, t(i18n)`The birth date format is incorrect`),
          v.custom(isDateStringOver18, t(i18n)`You have to be at least 18 years`),
        ]),
      }}

Expected behavior

More control over the errors display

How often does this bug happen?

None

Screenshots or Videos

No response

Platform

macOS

TanStack Form adapter

react-form

TanStack Form version

0.19.0

TypeScript version

No response

Additional context

No response

francois-pasquier avatar Apr 05 '24 12:04 francois-pasquier

This should be done at the validator level where you can pass transformErrors to transformErrors: errors => errors[0] rather than transformErrors: errors => errors.join(', ')

Will mark this as "Good first issue" and a feature request

crutchcorn avatar Apr 08 '24 23:04 crutchcorn

@crutchcorn Hello, can I work on this issue? If possible, please assign it to me. 😊

seobbang avatar May 11 '24 03:05 seobbang

Not sure if this is relevant to this issue or if I should create a separate one but being able to return an array from

validators={{
    onChange: ["err1", "err2"] // string | string[]
}}

instead of having to return a single .join("\n")ed string would be a very nice addition as well.

jessekelly881 avatar May 13 '24 12:05 jessekelly881

@jessekelly881 let's open a different issue on it :)

@seobbang PRs welcome! No need to assign it out, but do feel free to tackle the issue :)

crutchcorn avatar May 28 '24 18:05 crutchcorn

Would love this fixed @seobbang did you manage to work on this?

TheKnightCoder avatar Jun 10 '24 00:06 TheKnightCoder

@TheKnightCoder I'm going to try again 🤗

seobbang avatar Jun 11 '24 06:06 seobbang

It would be nice to accept ValidatonError[] as return value for FieldValidateOrFn since there could be some cases when we want all errors to be shown from a single validation. For example, when setting password we may want to show multiple errors under the input like 'length should be longer than 8', 'should contain special characters', ...

ha1fstack avatar Jun 11 '24 11:06 ha1fstack

I quickly passed by here and was seeing something like this, it probably doesn't work and I don't have time to test/research in depth but maybe it will help:

import { safeParse, safeParseAsync } from 'valibot'
import type { BaseIssue, BaseSchema, BaseSchemaAsync } from 'valibot'
import type { Validator } from '@tanstack/form-core'

type Params = {
  errorMap?: (
    errors: [BaseIssue<unknown>, ...BaseIssue<unknown>[]],
  ) => BaseIssue<unknown>
}

export const valibotValidator = (params: Params) =>
  (() => {
    return {
      validate({ value }, fn) {
        if (fn.async) return
        const result = safeParse(fn, value)
        if (result.success) return
        if (params.errorMap) {
          return params.errorMap(result.issues)
        }
        return result.issues.map((i) => i.message).join(', ')
      },
      async validateAsync({ value }, fn) {
        const result = await safeParseAsync(fn, value)
        if (result.success) return
        if (params.errorMap) {
          return params.errorMap(result.issues)
        }
        return result.issues.map((i) => i.message).join(', ')
      },
    }
  }) as Validator<
    unknown,
    | BaseSchema<unknown, unknown, BaseIssue<unknown>>
    | BaseSchemaAsync<unknown, unknown, BaseIssue<unknown>>
  >

t-rosa avatar Jun 15 '24 20:06 t-rosa

@t-rosa @crutchcorn
I have a question.

According to what you(@t-rosa ) suggested, when valibotValidator is used in useForm it can be used like:

validatorAdapter: valibotValidator({errorMap: errors => errors[0]}),

But the problem is that even if I want to use the default value, I have to use it with function call:

validatorAdapter: valibotValidator(),

But because we've been used like:

validatorAdapter: valibotValidator,

it's going to be a problem for people who are already using this validator.

So, should I make another validator, for example, valibotValidatorWithOption`? Please tell me if I'm wrong or if there's any other way.

Thank you for your help

seobbang avatar Jun 16 '24 07:06 seobbang

Maaaybe this? (Sorry, it's not very clean and I'm not sure at all)

import { safeParse, safeParseAsync } from 'valibot'
import type { BaseIssue, BaseSchema, BaseSchemaAsync } from 'valibot'
import type { Validator } from '@tanstack/form-core'

type Params = {
  errorMap?: (
    errors: [BaseIssue<unknown>, ...BaseIssue<unknown>[]],
  ) => BaseIssue<unknown>
}
type validateValue = { value: unknown }
type validateFn =
  | BaseSchema<unknown, unknown, BaseIssue<unknown>>
  | BaseSchemaAsync<unknown, unknown, BaseIssue<unknown>>

export const valibotValidator = ((params: Params = {}) => {
  return {
    validate({ value }: validateValue, fn: validateFn) {
      if (fn.async) return
      const result = safeParse(fn, value)
      if (result.success) return
      if (params.errorMap) {
        return params.errorMap(result.issues)
      }
      return result.issues.map((i) => i.message).join(', ')
    },
    async validateAsync({ value }: validateValue, fn: validateFn) {
      const result = await safeParseAsync(fn, value)
      if (result.success) return
      if (params.errorMap) {
        return params.errorMap(result.issues)
      }
      return result.issues.map((i) => i.message).join(', ')
    },
  }
}) as Validator<
  unknown,
  | BaseSchema<unknown, unknown, BaseIssue<unknown>>
  | BaseSchemaAsync<unknown, unknown, BaseIssue<unknown>>
> &
  ((
    params: Params,
  ) => Validator<
    unknown,
    | BaseSchema<unknown, unknown, BaseIssue<unknown>>
    | BaseSchemaAsync<unknown, unknown, BaseIssue<unknown>>
  >)

t-rosa avatar Jun 16 '24 08:06 t-rosa

@t-rosa this looks awesome! Let's move forward with it in a PR :)

@seobbang this WILL be a breaking change, but a very minor one. We're prior to 1.0 so that shouldn't be a problem

crutchcorn avatar Jun 16 '24 14:06 crutchcorn

I'm sorry for my slow understanding.

The current type of validatorAdapter:

export type Validator<Type, Fn = unknown> = () => {
    validate(options: {
        value: Type;
    }, fn: Fn): ValidationError;
    validateAsync(options: {
        value: Type;
    }, fn: Fn): Promise<ValidationError>;
};

But if i use valibotValidator like this with @t-rosa 's code

 validatorAdapter: valibotValidator({ errorMap: (errors) => errors[0] }),

valibotValidator return just

 {
    validate({ value }: validateValue, fn: validateFn) {
      if (fn.async) return
      const result = safeParse(fn, value)
      if (result.success) return
      if (params.errorMap) {
        return params.errorMap(result.issues)
      }
      return result.issues.map((i) => i.message).join(', ')
    },
    async validateAsync({ value }: validateValue, fn: validateFn) {
      const result = await safeParseAsync(fn, value)
      if (result.success) return
      if (params.errorMap) {
        return params.errorMap(result.issues)
      }
      return result.issues.map((i) => i.message).join(', ')
    },
  }

Not function, just object.

So the types don't match. How can this be solved?

seobbang avatar Jun 17 '24 03:06 seobbang

I'm sorry for my slow understanding.

The current type of validatorAdapter:

export type Validator<Type, Fn = unknown> = () => {
    validate(options: {
        value: Type;
    }, fn: Fn): ValidationError;
    validateAsync(options: {
        value: Type;
    }, fn: Fn): Promise<ValidationError>;
};

But if i use valibotValidator like this with @t-rosa 's code

 validatorAdapter: valibotValidator({ errorMap: (errors) => errors[0] }),

valibotValidator return just

 {
    validate({ value }: validateValue, fn: validateFn) {
      if (fn.async) return
      const result = safeParse(fn, value)
      if (result.success) return
      if (params.errorMap) {
        return params.errorMap(result.issues)
      }
      return result.issues.map((i) => i.message).join(', ')
    },
    async validateAsync({ value }: validateValue, fn: validateFn) {
      const result = await safeParseAsync(fn, value)
      if (result.success) return
      if (params.errorMap) {
        return params.errorMap(result.issues)
      }
      return result.issues.map((i) => i.message).join(', ')
    },
  }

Not function, just object.

So the types don't match. How can this be solved?

Yes, you're right.

I think we should use the first version, even if it's a mini breaking change. as crutchcorn said it shouldn't be a problem :)

So this one maybe the best option:

import { safeParse, safeParseAsync } from 'valibot'
import type { BaseIssue, BaseSchema, BaseSchemaAsync } from 'valibot'
import type { Validator } from '@tanstack/form-core'

type Params = {
  errorMap?: (
    errors: [BaseIssue<unknown>, ...BaseIssue<unknown>[]],
  ) => BaseIssue<unknown>
}

export const valibotValidator = (params: Params = {}) =>
  (() => {
    return {
      validate({ value }, fn) {
        if (fn.async) return
        const result = safeParse(fn, value)
        if (result.success) return
        if (params.errorMap) {
          return params.errorMap(result.issues)
        }
        return result.issues.map((i) => i.message).join(', ')
      },
      async validateAsync({ value }, fn) {
        const result = await safeParseAsync(fn, value)
        if (result.success) return
        if (params.errorMap) {
          return params.errorMap(result.issues)
        }
        return result.issues.map((i) => i.message).join(', ')
      },
    }
  }) as Validator<
    unknown,
    | BaseSchema<unknown, unknown, BaseIssue<unknown>>
    | BaseSchemaAsync<unknown, unknown, BaseIssue<unknown>>
  >

t-rosa avatar Jun 17 '24 05:06 t-rosa

It should be ok for valibot, the PR is open, we should be able to do the same for the other validators

t-rosa avatar Jun 17 '24 06:06 t-rosa

I just submitted a PR to be able to pass a config to the valibot adaptator: https://github.com/TanStack/form/pull/980

Using the abortPipeEarly option of valibot felt cleaner for me, and would fix this issue too...

lalexdotcom avatar Oct 16 '24 17:10 lalexdotcom