vuelidate / vuelidate

Simple, lightweight model-based validation for Vue.js
https://vuelidate-next.netlify.app/
MIT License
6.9k stars 497 forks source link

$each does not work on non-objects #1139

Open BennieVE opened 1 year ago

BennieVE commented 1 year ago

Describe the bug $each is responsible for evaluating collections. The new documentation says to use the helpers.forEach. The example in the docs uses an array of objects, but when I try to apply it to non-objects such as strings / numbers it does not work.

Reproduction URL The sandbox tries to evaluate the array of strings, but it doesn't pick up that anything is wrong.

Vuelidate 2 - Options API + Vue 3

To Reproduce Steps to reproduce the behavior:

  1. Add "not an email" to the input field
  2. Click on 'Add'
  3. See that the array is valid

Expected behavior The array should be invalid.

Additional context Regarding the code sandbox. i know i can validate the email before it gets added.

nolde commented 1 year ago

Affecting me as well.

I had to write a custom forEach to handle cases where items are not objects.

For the record, the code below is my own helper, based on vuelidate's own. It can probably be optimised, but I needed something fast to be able to move over to the new version.

/*
 * https://github.com/vuelidate/vuelidate/blob/next/packages/validators/src/utils/forEach.js
 */
import { computed } from 'vue'
import { helpers } from '@vuelidate/validators'

export default function forEach (validators, forceSimple = false) {
  return {
    $validator (collection, ...others) {
      return helpers.unwrap(collection).reduce(
        (previous, collectionItem, index) => {
          if (!forceSimple && typeof collectionItem === 'object') {
            const collectionEntryResult = objectForEach(collectionItem, index, validators, others)
            return {
              $valid: previous.$valid && collectionEntryResult.$valid,
              $data: previous.$data.concat(collectionEntryResult.$data),
              $errors: previous.$errors.concat(collectionEntryResult.$errors)
            }
          }
          const propertyResult = primitiveForEach(collectionItem, index, validators, others)
          return {
            $valid: previous.$valid && propertyResult.$valid,
            $data: previous.$data.concat(propertyResult.$data),
            $errors: previous.$errors.concat(propertyResult.$errors)
          }
        },
        { $valid: true, $data: [], $errors: [] }
      )
    },
    $message: ({ $response }) => {
      if (!$response) return []
      return $response.$errors.map(context => {
        if (context.$model) return context.$message
        return Object.values(context)
          .map(errors => (Array.isArray(errors) ? errors.map(e => e.$message) : errors.$message))
          .reduce((a, b) => a.concat(b), [])
      })
    },
    $params: computed(() => {
      return Object.entries(validators).reduce((map, [key, value]) => {
        map[key] = { ...value.$params }
        return map
      }, {})
    })
  }
}

function primitiveForEach ($model, index, validators, others) {
  const innerValidators = validators || {}
  return Object.entries(innerValidators).reduce(
    (all, [validatorName, currentValidator]) => {
      const validatorFunction = helpers.unwrapNormalizedValidator(currentValidator)
      const $response = validatorFunction.call(this, $model, index, ...others)
      const $valid = helpers.unwrapValidatorResponse($response)
      all.$data[validatorName] = $response
      all.$data.$invalid = !$valid || !!all.$data.$invalid
      all.$data.$error = all.$data.$invalid
      if (!$valid) {
        let $message = currentValidator.$message || ''
        const $params = currentValidator.$params || {}
        if (typeof $message === 'function') {
          $message = $message({
            $pending: false,
            $invalid: !$valid,
            $params,
            $model,
            $response
          })
        }
        all.$errors.push({
          $message,
          $params,
          $response,
          $model,
          $pending: false,
          $validator: validatorName
        })
      }
      return {
        $valid: all.$valid && $valid,
        $data: all.$data,
        $errors: all.$errors
      }
    },
    { $valid: true, $data: {}, $errors: [] }
  )
}

function objectForEach (collectionItem, index, validators, others) {
  return Object.entries(collectionItem).reduce(
    (all, [property, $model]) => {
      const innerValidators = validators[property] || {}
      const propertyResult = Object.entries(innerValidators).reduce(
        // eslint-disable-next-line no-shadow
        (all, [validatorName, currentValidator]) => {
          const validatorFunction = helpers.unwrapNormalizedValidator(currentValidator)
          const $response = validatorFunction.call(this, $model, collectionItem, index, ...others)
          const $valid = helpers.unwrapValidatorResponse($response)
          all.$data[validatorName] = $response
          all.$data.$invalid = !$valid || !!all.$data.$invalid
          all.$data.$error = all.$data.$invalid
          if (!$valid) {
            let $message = currentValidator.$message || ''
            const $params = currentValidator.$params || {}
            if (typeof $message === 'function') {
              $message = $message({
                $pending: false,
                $invalid: !$valid,
                $params,
                $model,
                $response
              })
            }
            all.$errors.push({
              $property: property,
              $message,
              $params,
              $response,
              $model,
              $pending: false,
              $validator: validatorName
            })
          }
          return {
            $valid: all.$valid && $valid,
            $data: all.$data,
            $errors: all.$errors
          }
        },
        { $valid: true, $data: {}, $errors: [] }
      )
      all.$data[property] = propertyResult.$data
      all.$errors[property] = propertyResult.$errors
      return {
        $valid: all.$valid && propertyResult.$valid,
        $data: all.$data,
        $errors: all.$errors
      }
    },
    { $valid: true, $data: {}, $errors: {} }
  )
}
Ulrich-Mbouna commented 1 year ago

Affecting me as well.

I had to write a custom forEach to handle cases where items are not objects.

For the record, the code below is my own helper, based on vuelidate's own. It can probably be optimised, but I needed something fast to be able to move over to the new version.

/*
 * https://github.com/vuelidate/vuelidate/blob/next/packages/validators/src/utils/forEach.js
 */
import { computed } from 'vue'
import { helpers } from '@vuelidate/validators'

export default function forEach (validators, forceSimple = false) {
  return {
    $validator (collection, ...others) {
      return helpers.unwrap(collection).reduce(
        (previous, collectionItem, index) => {
          if (!forceSimple && typeof collectionItem === 'object') {
            const collectionEntryResult = objectForEach(collectionItem, index, validators, others)
            return {
              $valid: previous.$valid && collectionEntryResult.$valid,
              $data: previous.$data.concat(collectionEntryResult.$data),
              $errors: previous.$errors.concat(collectionEntryResult.$errors)
            }
          }
          const propertyResult = primitiveForEach(collectionItem, index, validators, others)
          return {
            $valid: previous.$valid && propertyResult.$valid,
            $data: previous.$data.concat(propertyResult.$data),
            $errors: previous.$errors.concat(propertyResult.$errors)
          }
        },
        { $valid: true, $data: [], $errors: [] }
      )
    },
    $message: ({ $response }) => {
      if (!$response) return []
      return $response.$errors.map(context => {
        if (context.$model) return context.$message
        return Object.values(context)
          .map(errors => (Array.isArray(errors) ? errors.map(e => e.$message) : errors.$message))
          .reduce((a, b) => a.concat(b), [])
      })
    },
    $params: computed(() => {
      return Object.entries(validators).reduce((map, [key, value]) => {
        map[key] = { ...value.$params }
        return map
      }, {})
    })
  }
}

function primitiveForEach ($model, index, validators, others) {
  const innerValidators = validators || {}
  return Object.entries(innerValidators).reduce(
    (all, [validatorName, currentValidator]) => {
      const validatorFunction = helpers.unwrapNormalizedValidator(currentValidator)
      const $response = validatorFunction.call(this, $model, index, ...others)
      const $valid = helpers.unwrapValidatorResponse($response)
      all.$data[validatorName] = $response
      all.$data.$invalid = !$valid || !!all.$data.$invalid
      all.$data.$error = all.$data.$invalid
      if (!$valid) {
        let $message = currentValidator.$message || ''
        const $params = currentValidator.$params || {}
        if (typeof $message === 'function') {
          $message = $message({
            $pending: false,
            $invalid: !$valid,
            $params,
            $model,
            $response
          })
        }
        all.$errors.push({
          $message,
          $params,
          $response,
          $model,
          $pending: false,
          $validator: validatorName
        })
      }
      return {
        $valid: all.$valid && $valid,
        $data: all.$data,
        $errors: all.$errors
      }
    },
    { $valid: true, $data: {}, $errors: [] }
  )
}

function objectForEach (collectionItem, index, validators, others) {
  return Object.entries(collectionItem).reduce(
    (all, [property, $model]) => {
      const innerValidators = validators[property] || {}
      const propertyResult = Object.entries(innerValidators).reduce(
        // eslint-disable-next-line no-shadow
        (all, [validatorName, currentValidator]) => {
          const validatorFunction = helpers.unwrapNormalizedValidator(currentValidator)
          const $response = validatorFunction.call(this, $model, collectionItem, index, ...others)
          const $valid = helpers.unwrapValidatorResponse($response)
          all.$data[validatorName] = $response
          all.$data.$invalid = !$valid || !!all.$data.$invalid
          all.$data.$error = all.$data.$invalid
          if (!$valid) {
            let $message = currentValidator.$message || ''
            const $params = currentValidator.$params || {}
            if (typeof $message === 'function') {
              $message = $message({
                $pending: false,
                $invalid: !$valid,
                $params,
                $model,
                $response
              })
            }
            all.$errors.push({
              $property: property,
              $message,
              $params,
              $response,
              $model,
              $pending: false,
              $validator: validatorName
            })
          }
          return {
            $valid: all.$valid && $valid,
            $data: all.$data,
            $errors: all.$errors
          }
        },
        { $valid: true, $data: {}, $errors: [] }
      )
      all.$data[property] = propertyResult.$data
      all.$errors[property] = propertyResult.$errors
      return {
        $valid: all.$valid && propertyResult.$valid,
        $data: all.$data,
        $errors: all.$errors
      }
    },
    { $valid: true, $data: {}, $errors: {} }
  )
}

I'm Confused about this answer, using a librairy is suppose to reduce and facilitate the work and absorb the complicated part of implementing functionality.

I thinks i wil find another librairy making this kind of job. 🤔

nolde commented 1 year ago

Well, that code already exists, I just split it into two different parts, with small modifications. It is code already available in the library.

ting-dev-coder commented 1 year ago

I still have this problem, any helps?

wadclapp commented 1 year ago

forEach helper is for an array of objects, should use Array.every() with custom validator (see here)?

Like this?

myEmails: {
  isEmails: (vals) => vals.every(v => email.$validator(v)),
},

Documentation isn't clear on it

ting-dev-coder commented 1 year ago

@wadclapp in my case , I used v-for and an array to bind v-model. Instead of knowing error, I need to know which index in the array causes the error and show it. So using Array.every() did not solve my problem and that why I used $each.

wadclapp commented 1 year ago

If you need index when validating an array you could do this (as suggested in same issue linked above)?

function validateEach(vals, validationObj) {
  return vals.reduce((accVal, currVal, index) => {
    accVal[index] = validationObj

    return accVal
  }, {})
}
validations() {
    return {
      emails: validateEach(this.emails, {
        email,
      }),
    }
  }

Output:

{
  ...
  "$invalid": false,
  ...
  "emails": {
    "0": {
      ...
      "$invalid": false,
      ...
    },
    "1": {
nolde commented 1 year ago

@tp27933

The replacement I have published above should work for your use case. You don't have to use it all the time, just when you need primitive support on forEach. It has been working well for my use case.

ting-dev-coder commented 1 year ago

@wadclapp thanks , but which field should I use to know is there an error? $error or $invaild? @nolde don't quite understand your answer above. which version of the library cover the code? I already update it to latest version ^2.0.2, but still not working

nolde commented 1 year ago

@tp27933

Vuelidate has added a new helper called forEach that works with arrays of objects. Unfortunately, it does not work properly with arrays of primitives, such as strings.

My code above is a modification of the original forEach helper to add support to primitives. It has been working well for my use case.

Just drop it in a file on your project, import it and use it in place of the original one. It should still support object as well.