ember-changeset-validations icon indicating copy to clipboard operation
ember-changeset-validations copied to clipboard

Changing validation error messages at runtime

Open makepanic opened this issue 7 years ago • 34 comments

Hi, great addon.

I'm wondering, if there is a recommended way of changing validation messages at runtime?

I know it's possible to change it initially by defining a module that matches /validations\/messages$/gi but it's only loaded once.

define(['foo/validations/messages'], () => {
  return {default: {
     ...messages
  }};
})

If a user changes its locale, it's impossible to update validation messages from what i can tell. Are there any known workarounds for supporting this kind of use-case?

makepanic avatar Sep 01 '16 09:09 makepanic

@makepanic Are you using ember-i18n or equivalent?

If so, I could write a patch that tries to lookup the messages from an i18n service at run-time, that should account for a locale change.

ghost avatar Sep 01 '16 09:09 ghost

Yes i'm using ember-intl.

A configurable service sounds like a great idea.

makepanic avatar Sep 01 '16 09:09 makepanic

I'm not a fan of the service idea in the addon itself, it would require a lot of rework since most of the addon are vanilla JS modules with no knowledge of Ember. Instead, you can use computed properties on an Ember.Object:

const Messages = Ember.Object.extend({
  intl: Ember.inject.service(),

  inclusion: computed('intl', function() {
    // ...
  })
});

export default Messages.create();

poteto avatar Sep 01 '16 14:09 poteto

That would work.

ghost avatar Sep 01 '16 14:09 ghost

Thanks, Using CPs inside the messages object works without problems. (Except that i have to manually inject the intl service using a container)

Another question: Would it make sense to allow users to pass their own buildMessage function to move string template interpolation away from the validation-errors module to a pluggable part of the app (primarily i18n related libs)? That way one isn't coupled to the string format ember-changeset-validations is using.

It could be solved by allowing the user to pass validatorOptions to the lookupValidator method: lookupValidator(Validator, {buildMessage: () => 'foo'}) which in turn will pass the validatorOptions into each validator.

Something like this: https://github.com/makepanic/ember-changeset-validations/commit/8cca573dfaebf71c0dec1368d66bdf7cf683eb18

makepanic avatar Sep 01 '16 20:09 makepanic

@makepanic How did you get this to work? I can't seem to sort out the manual injection of the (I'm using ember-i18n) service using container. Do you have a link to your implementation?

jmacqueen avatar Oct 05 '16 19:10 jmacqueen

Here's what I ended up doing, in case anyone else ends up here looking for help: https://gist.github.com/jmacqueen/6d31aaa5ba5f5841d123d0406f1f86c5

jmacqueen avatar Oct 06 '16 16:10 jmacqueen

@jmacqueen i went with passing a buildMessage option to the lookupValidator method (see https://github.com/makepanic/ember-changeset-validations/commit/8cca573dfaebf71c0dec1368d66bdf7cf683eb18). Message building happens by calling the provided buildMessage function which in turn calls ember-intl to translate the given incoming validation message key.

makepanic avatar Oct 10 '16 11:10 makepanic

Hei @jmacqueen, i'm seeing this error in my console:

Uncaught TypeError: _siteUtilsApplication.default.instance.lookup is not a function

Any ideias why?

Thank you! <3

celicoo avatar Jan 08 '17 16:01 celicoo

I was wondering if this addon could check whether message.js export a POJO or an Ember.Object. If it is an Ember object, it could take care of the creation and owner injection:

import Ember from 'ember';
const { computed } = Ember;

export default Ember.Object.extend({
  i18n: Ember.inject.service(),

  i18nName: computed('i18n.locale', function () {
    return this.t('validations.i18nName');
  })
});

// I do not create or export the object 

And the addons would be responsible to:

import Messages from 'validations/messages.js';

Messages.create(
  Ember.getOwner(this).ownerInjection()
);

Does it sound like it would work? And is it something you'd be willing to implement? cc @poteto @martndemus

karellm avatar Mar 13 '17 17:03 karellm

@karellm I would be open to this. Do you have time to open a PR?

poteto avatar Mar 13 '17 20:03 poteto

@poteto I tried but I'm not very familiar with Ember internals and add-ons development. this is actually undefined inside the utils. It looks like it should be passed by buildMessage which makes the API a little weird.

Also I'm not sure how to go about testing this since the resolver looks for validations/messages so I can't have 2 different files (one POJO, one Ember.Object).

I'm happy to do it but I would really appreciate some pointers. Thanks!

karellm avatar Mar 13 '17 23:03 karellm

@poteto, @karellm Any progress on this? I find myself stuck trying to get an Ember.Object to be recognized from messages.js. As long as it's a simple POJO - no issues, but when wrapped in an Ember.Object, the add-on reverts to its defaults rather than using the provided object from messages.js.

CmdrChaos avatar Aug 07 '17 14:08 CmdrChaos

Hi all,

I have just forked this repo in order to implement this feature. @jmacqueen gave a good example on how to connect the Ember IoC with the messages.

Following his advice, I created an instance initializer that would pass the Application instance to the get-messages.js file in order to lookup them up via Ember container. Using the container there is no need to iterate through all defined modules and addons in order to find the custom validations/messages.js file.

You can check the fork here

To use this feature, you would simply need to create an instance-initializer in your application and define it like so:

export {default} from 'ember-changeset-validations/instance-initializers/ember-changeset-validations';

Then in your validations/messages.js file, you can define a property or implement the messageForKey function to load a message from either i18n, intl or any Ember Service:

import Ember from 'ember';
const { get, computed } = Ember;

export default Ember.Object.extend({
  i18n: Ember.inject.service(),

  i18nName: computed('i18n.locale', function () {
    return this.t('validations.i18nName');
  }),

  messageForKey(key) {
    const i18n = this.get('i18n');
    key = `validations.${key}`;

    if (i18n.exists(key)) {
      return i18n.t(key).toString();
    }
  }
});

If you are ok with this approach, I would gladly make a PR to incorporate these changes.

While working on this, I realised that that a simple API could be exposed in order to load custom messages and field descriptions. From the current issues it seems that there is a need for further customisation. It would be great if we could make this library be even more flexible with validation messages.

My suggestions would be to expose messageForKey or descriptionForKey functions. Message and description lookup would look like this:

Ember.Mixin.create({
  // default messages
  defaults: {},

  getDescriptionFor(key = '') {
    let value;

    // Check if there is a custom lookup function defined
    if (canInvoke(this, 'descriptionForKey')) {
      value = this.descriptionForKey(key);
    }
    // Generate default description
    if (isNone(value)) {
      value = capitalize(dasherize(key).split(/[_-]/g).join(' '));
    }
    return value;
  },

  // use the unknown property hook in Ember.Object in order to find
  // messages which were not defined directly on the object
  unknownProperty(key = '') {
    let value;

    // Check if there is a custom lookup function defined
    if (canInvoke(this, 'messageForKey')) {
      value = this.messageForKey(key);
    }
    // Play nice with ObjectProxy instances
    if (isNone(value)) {
      value = this._super(key);
    }
    // Load default message
    if (isNone(value)) {
      value = get(this, `defaults.${key}`);
    }

    return value;
  }
})

As this change would be a rather big one, I would be good to agree on this beforehand :)

vladaspasic avatar Aug 21 '17 00:08 vladaspasic

Does the solution https://github.com/DockYard/ember-changeset-validations/issues/94#issuecomment-244089956 still work? It seems computed properties aren't merged by Ember.merge in https://github.com/DockYard/ember-changeset-validations/blob/c638e81a537c5e9fb427227cc0e35852d01a6919/addon/utils/with-defaults.js#L13-L15

sebastianhelbig avatar Sep 27 '17 11:09 sebastianhelbig

We implemented the changes @jmacqueen did, and the breakpoint on the initialize method in instance-initializers/application.js is hitting. However nothing is hitting for our validations/messages.js file. Is there something that needs to be added to our validators file to get our translated messages file loaded?

A sample of our validator file...

    import {
    validateLength,
    validateFormat
} from 'ember-changeset-validations/validators';
import { validatePhone, validateExtension } from 'ember-engine-contacts/validators/phone';
import { regularExpressions } from 'ember-validators/format';
import validateTwitter from 'ember-engine-contacts/validators/twitter';

export default {
    workEmail: [
        validateLength({ max: 254, allowNone: true, allowBlank: true }),
        validateFormat({ type: 'email', allowNone: true, allowBlank: true, regex: regularExpressions.email })
    ]
};

I also tried removing any code we had in both app/validations/messages and addon/validations/messages with

export default {
    email: 'FOOBAR'
}

as the doc states to do, but still the default message showed.

I can add 'message' to the object parameters in the validateFormat method, which works...but not sure how to get our overridden messages file to work. Any help would be greatly appreciated!

hornetnz avatar Oct 04 '17 18:10 hornetnz

@martndemus @poteto Could you suggest some solution for this issue, guys? I'm using ember-i18n service for my translations, and I've tried the option https://github.com/poteto/ember-changeset-validations/issues/94#issuecomment-244089956 , but this doesn't work anymore. Any other elegant suggestion? Thanks for your great work btw!

nandorstanko avatar Nov 14 '17 09:11 nandorstanko

@makepanic @martndemus @karellm @nandorstanko I've released an Ember Addon to apply ember-i18n translations to these messages in a beta version here https://github.com/mirai-audio/ember-i18n-changeset-validations. Could use a few hands to try it before I release a v1.0.0

Edit. I release v1.0.0

0xadada avatar Jan 10 '18 20:01 0xadada

@0xadada Correct me if I'm wrong, but your solution doesn't support changing locale without reloading the app. That's a huge feature, that is a MUST HAVE for me... That is why I use ember-i18n addon in the first place. I've switched to ember-changeset-cp-validations+ember-i18n-cp-validations combo, and it's working for me. Probably slower this way, but not an issue so far.

nandorstanko avatar Jan 11 '18 14:01 nandorstanko

@nandorstanko you are correct

0xadada avatar Jan 11 '18 15:01 0xadada

While working on a new project with intl + changeset-validations, I've found another workaround (hack) to get this working without touching too many things:

  • changeset-validations is retrieveing messages from ember-validators/messages
  • if one overwrites Messages.formatMessage, one can provide a custom translation solution:
    • configure validations/messages to be a simple identity map where each key is its value. This causes formatMessage to be called with the error code (i.e. inclusion).
export default {
  inclusion: 'inclusion',
  exclusion: 'exclusion',
  ...
  url: 'url'
}
  • change Message.formatMessage to take the error code "message" and context and pass it into intl:
    Messages.formatMessage = (message, context = {}) => {
      let errorKey = message;

      if (isNone(errorKey) || typeof errorKey !== 'string') {
        return intl.t('validations.invalid');
      }
      return intl.t(`validations.${errorKey}`, context);
    }

Now if changeset-validations tries to format the translated template, it passes the error code and get's the translation using whatever you configured.

In ember-intl context one can easily add these error key translations.

validations:
  inclusion: 'Ist nicht in der Liste enthalten'
  ...

I've added overwriting formatMessage into the intl service init hook and it looks like it's working fine.

makepanic avatar Feb 13 '18 14:02 makepanic

Yesterday, I finally ran into the need to use a service in my validations. In my particular case, I needed to fetch a dynamic array of strings from my API to validate inclusion against. After reading this thread, I almost tried to make this work in this addon.

However, I realized that I would essentially be rebuilding a bunch of the stuff in ember-changeset-cp-validations. I decided to just start using that addon instead. I think that model is a lot more robust and fits better with any scenario where you need to access dynamic data provided by an Ember service. It was a pretty simple transition and I will probably move my other validations over if the need arises. Definitely recommended!

bgentry avatar Mar 23 '18 19:03 bgentry

I've tried different approaches and addon to display/translate validations messages at runtime and finally I found an easy way to do it. I've followed https://github.com/poteto/ember-changeset-validations#overriding-validation-messages but instead of returning a real message I return a valid translation key for ember-i18n.

// app/validations/messages.js
export default {
  inclusion: 'errors.validations.inclusion',
  exclusion: 'errors.validations.invalid',
  invalid: 'errors.validations.invalid',
  // ...
}

And now I my view I can use this key to translate validation message at runtime using the t helper. Like this:

{{#if changeset.isInvalid}}
  <p>There were errors in your form:</p>
  <ul>
    {{#each changeset.errors as |error|}}
      <li>{{error.key}}: {{t error.validation}}</li>
    {{/each}}
  </ul>
{{/if}}

KamiKillertO avatar Aug 24 '18 08:08 KamiKillertO

what you can do is to register a seperate lookup util.

app/utils/lookup-validator.js

import originalLookupValidator from 'ember-changeset-validations';
import { merge } from '@ember/polyfills';
import { isArray } from '@ember/array';

export default function lookupValidator(validationMap = {}, options = {}) {
  return ({ key, newValue, oldValue, changes, content }) => {
    let validationResult = originalLookupValidator(validationMap)({ key, newValue, oldValue, changes, content });

    if (options.intl && options.intl.t && isArray(validationResult)) {
      validationResult = validationResult.map((v) => {
        if (v.message && v.attrs) {
          return options.intl.t(v.message, merge(v.attrs, { key: options.intl.t(`validations.changeset.keys.${key}`) }));
        }

        return v;
      })
    }

    return validationResult;
  }
}

app/validations/messages.js

export default {
  inclusion: 'validations.changeset.messages.inclusion',
  exclusion: 'validations.changeset.messages.exclusion',
  invalid: 'validations.changeset.messages.invalid',
  confirmation: "validations.changeset.messages.confirmation",
  accepted: 'validations.changeset.messages.accepted',
  empty: "validations.changeset.messages.empty",
  blank: 'validations.changeset.messages.blank',
  present: "validations.changeset.messages.present",
  collection: 'validations.changeset.messages.collection',
  singular: "validations.changeset.messages.singular",
  tooLong: 'validations.changeset.messages.tooLong',
  tooShort: 'validations.changeset.messages.tooShort',
  between: 'validations.changeset.messages.between',
  before: 'validations.changeset.messages.before',
  onOrBefore: 'validations.changeset.messages.onOrBefore',
  after: 'validations.changeset.messages.after',
  onOrAfter: 'validations.changeset.messages.onOrAfter',
  wrongDateFormat: 'validations.changeset.messages.wrongDateFormat',
  wrongLength: 'validations.changeset.messages.wrongLength',
  notANumber: 'validations.changeset.messages.notANumber',
  notAnInteger: 'validations.changeset.messages.notAnInteger',
  greaterThan: 'validations.changeset.messages.greaterThan',
  greaterThanOrEqualTo: 'validations.changeset.messages.greaterThanOrEqualTo',
  equalTo: 'validations.changeset.messages.equalTo',
  lessThan: 'validations.changeset.messages.lessThan',
  lessThanOrEqualTo: 'validations.changeset.messages.lessThanOrEqualTo',
  otherThan: 'validations.changeset.messages.otherThan',
  odd: 'validations.changeset.messages.odd',
  even: 'validations.changeset.messages.even',
  positive: 'validations.changeset.messages.positive',
  multipleOf: 'validations.changeset.messages.multipleOf',
  date: 'validations.changeset.messages.date',
  email: 'validations.changeset.messages.email',
  phone: 'validations.changeset.messages.phone',
  url: 'validations.changeset.messages.url',

  formatMessage: (message, attrs) => {
    return { message, attrs };
  }
}

/translations/de-de.yml

##############################
# VALIDATIONS
##############################
validations:
  changeset:
    messages:
      inclusion: '{key} is not included in the list'
      exclusion: '{key} is reserved'
      invalid: '{key} is invalid'
      confirmation: "{key} doesn't match {on}"
      accepted: '{key} must be accepted'
      empty: "{key} can't be empty"
      blank: '{key} must be blank'
      present: "{key} can't be blank"
      collection: '{key} must be a collection'
      singular: "{key} can't be a collection"
      tooLong: '{key} is too long (maximum is {max} characters)'
      tooShort: '{key} is too kurz (minimum is {min} characters)'
      between: '{key} must be between {min} and {max} characters'
      before: '{key} must be before {before}'
      onOrBefore: '{key} must be on or before {onOrBefore}'
      after: '{key} must be after {after}'
      onOrAfter: '{key} must be on or after {onOrAfter}'
      wrongDateFormat: '{key} must be in the format of {format}'
      wrongLength: '{key} is the wrong length (should be {is} characters)'
      notANumber: '{key} must be a number'
      notAnInteger: '{key} must be an integer'
      greaterThan: '{key} must be greater than {gt}'
      greaterThanOrEqualTo: '{key} must be greater than or equal to {gte}'
      equalTo: '{key} must be equal to {is}'
      lessThan: '{key} must be less than {lt}'
      lessThanOrEqualTo: '{key} must be less than or equal to {lte}'
      otherThan: '{key} must be other than {value}'
      odd: '{key} must be odd'
      even: '{key} must be even'
      positive: '{key} must be positive'
      multipleOf: '{key} must be a multiple of {multipleOf}'
      date: '{key} must be a valid date'
      email: '{key} ist keine valide email'
      phone: '{key} must be a valid phone number'
      url: '{key} must be a valid url'
    keys:
      email: E-Mail
      password: Passwort

and finally use it....

...
import lookupValidator from '../../../utils/lookup-validator';
...
return new Changeset({}, lookupValidator(UserValidations, {intl: this.intl}), UserValidations);

this way no other changes are required and it works out of the box with ember-form-for for example....

little hacky yes but this works as it should with every validator and its also possible to change locales at runtime. Hope this helps

ghost avatar Aug 29 '18 14:08 ghost

Hi, I've come up with a PR that allows to postpone i18n/intl work outside of ember-changeset-validations by making it return (optionally) an object instead of a String. It works wonders for us: https://github.com/poteto/ember-changeset-validations/pull/185

xcambar avatar Nov 16 '18 13:11 xcambar

I can confirm that @xcambar's solution is very flexible and works great for my use case.

scottkidder avatar Nov 16 '18 22:11 scottkidder

FYI, #185 has been merged.

See the PR and this setion of the docs for info.

Should this issue be closed now?

xcambar avatar Nov 19 '18 10:11 xcambar

Looks good. Can we maybe add an example on how to use this with intl/i18n?

I'm ok with closing this

makepanic avatar Nov 19 '18 10:11 makepanic

I'm using it with something of the form (wrapped in a helper):

{{t (concat "path.to.errors." error.type) errors.context}}

xcambar avatar Nov 19 '18 10:11 xcambar

I have kind of tackled this and our migration from ember-i18n to ember-intl at the same time. The one gotcha I ran into was that the {{t }} helper from ember-i18n allowed passing a hash of i18n paramaters, where the one from ember-intl requires passing each as it's own parameter, which isn't ideal when we don't know the parameters upfront.

scottkidder avatar Nov 19 '18 15:11 scottkidder

@xcambar passing errors.context with name attribute is not working on my end ? Can you help with that ?

gauravjain028 avatar Feb 24 '19 19:02 gauravjain028

I can try :) In order to ease investigation, can you share a twiddle or a repo that reproduces the behaviour, please?

xcambar avatar Feb 24 '19 20:02 xcambar

@xcambar I have setup it on https://github.com/gauravjain028/ember-intl-changeset-error-handling Template File : https://github.com/gauravjain028/ember-intl-changeset-error-handling/blob/master/app/templates/application.hbs When in load the app, it give me js error The intl string context variable 'description' was not provided to the string '{description} can't be blank'

gauravjain028 avatar Feb 25 '19 18:02 gauravjain028

I was having issues in octane but I was able to get a good workaround with custom validators.

import Component from '@glimmer/component';
import Changeset from 'ember-changeset';
import lookupValidator from 'ember-changeset-validations';
import  { inject as service } from '@ember/service';

export default class MyComponent extends Component {
  @service intl;

  validator({ errorKey }) {
    let errorMsg = this.intl.t(`${errorKey}.error.msg`);

    return function(_, value) {
      let isInValid = ....; // custom validation logic

      if (isInValid) {
        return errorMsg;
      }

      return true;
    }
  }

  validators = {
    name: this.validator({ errorKey: 'name.field' }),
    email: this.validator({ errorKey: 'email.field' })
  };

  changeset = new Changeset(
    { 
      name: this.name,
      email: this.email
    },
    lookupValidator(this.validators),
    this.validators
  );
}

k-dauda avatar Feb 10 '20 18:02 k-dauda