gcanti / tcomb-form

Forms library for react
https://gcanti.github.io/tcomb-form
MIT License
1.16k stars 136 forks source link

Customized error message for the same field of different items in a list. #180

Closed etshi closed 8 years ago

etshi commented 9 years ago

I have a form with a list of bank accounts.

let BankAccount = t.struct({
       AccountHolder: t.Str,
       IBAN: Iban
 });
// Removed the rest of the structures for simplicity
let Form = t.struct({
      BankAccounts: t.list(BankAccount)
})

For now I managed to dynamically customize and set the error message in options. The problem is when displaying errors for the same field of different items in a list.

Let me explain on my form example, the IBAN's error message can be either invalid (returns 'Error msg x') or not accepted depending on country (returns 'Error msg y'). Say we have a list of two BankAccount items. The Iban is wrong in both items but for different reasons. Item[0] should display 'Error msg x' and Item[1] should display 'Error msg y'. Unfortunately this is not what happens they both display the same error msg.

Is there a way to set the options of each item separately :

options: {
        fields: {
         BankAccounts: {
            item:{
             0:{
               fields: {
                  IBAN:{
                    error: customized error msg
                  }
                }
              }
            }
          }
        }
      }

If not Is there another way to implement this ?

volkanunsal commented 9 years ago

It's been a while since I've done this, but if I recall correctly, I think all the fields will be given to you in the locals argument, and you can validate and render the error fields yourself.

var options = {
  template: function(locals) {
     let error = [];
     //.... push the errors into the error array.

     return <span className='field'>
         <input className='form-control' type='text' value={locals.value} />
         {error}
  }
}
etshi commented 9 years ago

Thanks for the prompt response. But from what I understand that this will allow me to display several errors for the same field, which doesn't solve my problem.

My issue is say for example I have a list of IDs. The first one is invalid because it is too short while the second is invalid as it is too long. What I would like to achieve is render the appropriate message for each field displaying "ID is too short" and "ID too long" for first and second fields respectively. Now I can only display one of the two messages for both fields depending on which error occurred later. So if the user entered the short ID after the long one both fields will show an error message of "ID too short" and vice versa.

gcanti commented 9 years ago

Hi @etshi

This issue should be helpful:

https://github.com/gcanti/tcomb-form/issues/177

etshi commented 9 years ago

Thanks

gcanti commented 9 years ago

Let me know how it goes or if you need more help.

Giulio

etshi commented 9 years ago

I had already tried using factories taking issue (#177)[https://github.com/gcanti/tcomb-form/issues/177] as a reference before I posted my question, but failed. The thing is I want something more general without any prior knowledge of the field info. To use in a project skeleton.

What I am currently doing is kind of expensive by calling a function every time an error occurs to create and returns the options nested object with the updated error message. And it works fine for all cases except for that stated above.

I am sure that there is a better way of doing it. so do you think for this should I try the template/locale or the factories approach again?

gcanti commented 9 years ago

My issue is say for example I have a list of IDs. The first one is invalid because it is too short while the second is invalid as it is too long

In general, if you can express the validation rules synchronously you can use the error option implemented as a function:

https://github.com/gcanti/tcomb-form/blob/master/GUIDE.md#error-message

Example:

function isTooShort(s) {
  return s.length < 2;
}

function isTooLong(s) {
  return s.length > 4;
}

var ID = t.subtype(t.Str, function (s) {
  return !isTooShort(s) && !isTooLong(s);
});

var Type = t.list(ID);

var options = {
  item: {
    error: function (s) {
      if (t.Str.is(s)) {
        if (isTooLong(s)) {
          return 'is too long';
        }
        if (isTooShort(s)) {
          return 'is too short';
        }
      }
    }
  }
};

const App = React.createClass({

  onSubmit(evt) {
    evt.preventDefault();
    var value = this.refs.form.getValue();
    if (value) {
      console.log(value);
    }
  },

  render() {
    return (
      <form onSubmit={this.onSubmit}>
        <t.form.Form
          ref="form"
          type={Type}
          options={options}
        />
        <button className="btn btn-primary">Save</button>
      </form>
    );
  }

});
gcanti commented 9 years ago

In general, if you can express the validation rules synchronously

Otherwise let's use this playground:

function fakeServerCheck(ids, callback) {
  setTimeout(() => {
    var errors = [];
    ids.forEach(function (id) {
      if (['a', 'b'].indexOf(id) !== -1) {
        errors.push(id);
      }
    });
    callback(errors);
  }, 500);
}

var Type = t.list(t.Str);

const App = React.createClass({

  getInitialState() {
    return {
      value: ['a', 'b', 'c'],
      options: {}
    };
  },

  onChange(value) {
    this.setState({value});
  },

  onSubmit(evt) {
    evt.preventDefault();
    var value = this.refs.form.getValue();
    if (value) {
      fakeServerCheck(value, (errors) => {

        //
        // how to set the proper error for each item??? (i.e. in this case 'a' and 'b')
        //

      });
    }
  },

  render() {
    return (
      <form onSubmit={this.onSubmit}>
        <t.form.Form
          ref="form"
          type={Type}
          options={this.state.options}
          value={this.state.value}
          onChange={this.onChange}
        />
        <button className="btn btn-primary">Save</button>
      </form>
    );
  }

});

How to set the proper error for each item

At the moment I've just a workaround:

  onSubmit(evt) {
    evt.preventDefault();
    var value = this.refs.form.getValue();
    if (value) {
      fakeServerCheck(value, (errors) => {
        this.setState({
          options: {
            item: {
              hasError: errors.length > 0,
              error: function (value) {
                if (errors.indexOf(value) !== -1) {
                  return 'my error';
                }
              }
            }
          }
        });
      });
    }
  },

I'll think about a clean solution

gcanti commented 9 years ago

Two other observations then I must run to the office, I'll read your replies during the weekend...

1.

The thing is I want something more general without any prior knowledge of the field info. To use in a project skeleton.

This is interesting! Could you elaborate a little bit on your use case?

2.

What I am currently doing is kind of expensive by calling a function every time an error occurs to create and returns the options nested object with the updated error message

What do you mean by "expensive"? If your options object is really big you could use the t.update function:

https://github.com/gcanti/tcomb/blob/master/GUIDE.md#updateinstance-object-spec-object-object

Example

var options = {
  ...other options
  item: {
    fields: {
      name: {},
      ... other fields
    }
  }
};

// let's say you just want to disable the name field...

options = t.update(options, {
  item: {
    fields: {
      name: {
        disabled: {$set: true}
      }
    }
  }
});
etshi commented 9 years ago

1) what I mean is that I want to use it not for only one form but for a project with many forms, so instead of coding the options object for every form I want it to be done through a function in the project 'util' that generates the options object with the updated error msg.

2) I already use t.update. But as explained in the first point I am trying to use a more generic approach so depending on the path of field in question the function iterates through the current object and if the path exists it just updates the error message if it doesn't exist then it adds it to the object. All by using either '$set' or '$merge'

etshi commented 9 years ago

2) function similar to the following:

function _updateOptions(message,pathList,options) {
  let updatedOptions = options || {};
  // Remove any numbers from path array in case of a list
  let path = pathList.filter(function(value) {
    return isNaN(value);
  });
  // Create a nested object with respect to the given path.
  path.reduce(function(prev, currentValue,index) {
    let isList = !(isNaN(pathList[index + 1]));

    prev['fields'] = (prev['fields']) ?  t.update(prev['fields'], {'$merge': {}}) : {};
    prev['fields'][currentValue] = (prev['fields'][currentValue]) ?
        t.update(prev['fields'][currentValue], {'$merge': {}})
      : {};
    if (index === path.length - 1) {
      if (isList) {
        return prev['fields'][currentValue]['item'] = (prev['fields'][currentValue]['item']) ?
            t.update(prev['fields'][currentValue]['item'], {error : {'$set': message} })
          : {'error' : message};
      } else {
        return prev['fields'][currentValue] = (prev['fields'][currentValue]) ?
            t.update(prev['fields'][currentValue], {error : {'$set': message} })
          : {'error' : message};
      }
    } else {
      if (isList) {
        return  prev['fields'][currentValue]['item'] = (prev['fields'][currentValue]['item']) ?
            t.update(prev['fields'][currentValue]['item'], {'$merge': {}})
          : {};
      } else {
        return  prev['fields'][currentValue]
      }
    }
  }.bind(this), updatedOptions);

  return updatedOptions;
}
etshi commented 9 years ago

@gcanti: Thank you. Just figured it out. Solved by passing the message in the above function as a Function instead of just a String.

gcanti commented 9 years ago

Great. I'll leave this open for the moment as a reminder