final-form / final-form

🏁 Framework agnostic, high performance, subscription-based form state management

Home Page:https://final-form.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Allow flat "field.name" strings as keys in errors/submitErrors object instead of only {field: {name: ...}}

TylerRick opened this issue · comments

Are you submitting a bug report or a feature request?

Feature request

What is the current behavior?

Given a field named user.username:

          <Field name="user.username">

, I'd like to be able to set a key with the same name in the errors object from a validate method:

          errors['user.username'] = 'Required'

or in the submitErrors object from an onSubmit method:

    return {
      'user.username': 'Unknown username',
    }

This code sandbox illustrates both of those things not working: the error does not show up in meta.error/meta.submitError, respectively, for the field with name matching the given key.

Workarounds

Instead, you have to format your errors/submitErrors object as a nested error like:

      user: { username: 'Unknown username' }

(this working solution is the commented lines in the above sandbox)

It's easy enough to control/change the shape of the error objects in a simple, contrived example like this one, but in my case the errors are coming to me as a flat JSON object from the backend server and I'd like to be able to just use it.

Alternatively, is there an easy way to convert from {'deeply.nested.field.name': 'error'} to {deeply: {nested: {field: {name: 'error'}}}}? Maybe final-form could export a helper that can convert between nested and flat objects?

What is the expected behavior?

To be able to use the same "flat" names as keys of the errors/submitErrors object as can be used as names of the Field.

What's your environment?

final-form 4.18.5

I looked into how final-form was setting errors internally and discovered it was using setIn. Thankfully, it is exported so we can reuse it in our validate/onSubmit functions!

Here (new sandbox) is how you can take your flat field names (like you might be getting back from a server) and convert them into an a deeply nested errors object like final-form expects/requires it to be in:

  const setError = (key, value) => {
    errors = setIn(errors, key, value)
  }
  if (values.username !== 'erikras') {
    setError('user.username', 'Unknown username')
  }
  if (values.password !== 'finalformrocks') {
    setError('user.password', 'Wrong password')
  }

This seems like a decent, general-purpose workaround for this problem.

I still find it a bit confusing and inconsistent, though, how sometimes it's okay to use flat field names as keys (such as with form.change('my.nested.field.name', value)) and sometimes it's not (such as with the keys used for these "errors" objects).

Would it be reasonable to make it so the errors objects returned by validate/onSubmit could use either flat or nested structure, and final-form would be responsible to normalize it into the format it prefers? (Obviously there would have to be an order of precedence in case the same value were given by two different but equivalent keys (one flat key at top level and one nested).)

I still think it would be nice to be able to treat (or to retrieve) errors/submitErrors as a flat object, so that you can, for example, loop over it to display all errors (since there could possibly be errors sent back from the server for fields that aren't present in the form (although ideally this should be avoided as much as possible), and we may still want a way to show them so they know why their submission could not be saved) as a list, like this, for example:

          {Object.entries(submitErrors).map(([field, errorsForField], i) => (
            <ErrorsForField field={field} errors={errorsForField} key={i} />
          ))

As it is, it looks like I will have to find a different means to communicate a flat array of errors back from the onSubmit function since the return value is currently reserved for returning a (nested) object for submitErrors...

(This would be less of a problem if there was a trivial way to convert a nested object into a flat object. I could always add https://www.npmjs.com/package/flat, but it might be nice if final-form itself provided conversion functions between nested and flat.)


I also noticed a couple more places in the docs where the object is expected to be a flat object — further supporting my concern over inconsistency between flat vs. nested objects:

https://final-form.org/docs/final-form/types/FormState:

touched['addresses.shipping.street'].
visited['addresses.shipping.street'].

but:

submitErrors
An object containing all the current submission errors. The shape will match the shape of the form's values [nested].

One idea would be to add a new value to FormState in case you wanted a flat object (since submitErrors is already defined to be nested), like flatSubmitErrors.

Do you ever receive a response about this? I think nested format for errors is very annoying it really makes very difficult to map errors to fields. And of course it is counterintuitive because much of the final-form constructs spects flat field name.

A flat error object would also provide an easy solution to provide errors for different levels of nested fields at once.

{
  'foo': 'someError',
  'foo.bar': 'anotherError',
}

@erikras can you please take a look over this?

I made this utility function to get flatten keys that have errors, as long as errors are strings or React elements

import React from 'react';
import { ARRAY_ERROR } from 'final-form';

type Error = string | React.ReactElement;

interface ErrorsObject {
  [key: string]: Error | ErrorsObject | Array<ErrorsObject>;
}

export const getErrorFields = (target: ErrorsObject): string[] => {
  const output: string[] = [];

  function step(
    object: Error | ErrorsObject | Array<ErrorsObject>,
    prev?: string,
    isParentArray?: boolean
  ) {
    Object.keys(object).forEach(function (key) {
      if (key !== ARRAY_ERROR) {
        const value = object[key];
        const type = Object.prototype.toString.call(value);
        const isObject = type === '[object Object]';
        const isArray = type === '[object Array]';
        const stop = React.isValidElement(value);
        const newKey = prev ? (isParentArray ? `${prev}[${key}]` : `${prev}.${key}`) : key;
        if (isArray && value[ARRAY_ERROR]) output.push(newKey);
        if (!stop && (isObject || isArray)) {
          return step(value, newKey, isArray);
        }
        value && output.push(newKey);
      }
    });
  }

  step(target, undefined, false);

  return output;
};

Usage:

  const { errors } = useFormState();
  const errorFields: string[] = getErrorFields(errors);

Explanation:

An errors object like this

{
   "foo":"someError",
   "bar":{
      "baz":"anotherError"
   },
   "qux":[
      "errorArrayPos0",
      null,
      "errorArrayPos2"
   ]
}

Will return

[
'foo',
'bar.baz',
'qux[0]',
'qux[1]'
]

It could be easily modified to return errors within the keys

NOTE: it also handles ARRAY_ERROR key used for array wide error information, base array field name is added to the list when ARRAY_ERROR is present. In the example above if "qux" have a property qux[ARRAY_ERROR] = arrayWideError, then 'qux' field name would be in the result.