Localizing Global Form Validation Error Messages

Global Form Validation Rules — a.k.a. server-enforced banned email domains — came out last year for Marketo forms.

They’re a step up from JavaScript-driven domain validation: server-side rules can’t be bypassed by hackers, and they also hide your list of “interesting” domains from end users. (That is, if a person is blocked, they  can’t see the other domains you block all at once.[1])

But they have one downside that custom JS validation doesn’t: they only officially support a single language. So if you have this Error Message text:

That's what everybody sees, regardless of other translations at the form level:

So let's do something fun to enable additional language support.

The form error payload

You wouldn't have any reason to know this, but when there's a submission error (that is, an error after sending data to Marketo, not validation errors that occur entirely within JS) the Forms 2.0 library passes around a JS object shaped like this:[2]

{
  "mktoResponse": {
    "for": "mktoFormMessage0",
    "error": true,
    "data": {
      "errorCode": "400",
      "errorType": "invalid",
      "errorFields": [
        "Email"
      ],
      "invalidInputMsg": "Sorry, no free/seemingly free addresses allowed.",
      "error": true
    }
  }
}

Note the source of the message can be either:

(a) a cross-domain postMessage, when using the embed code, or
(b) a direct jQuery ajax response, when on a Marketo LP

Making the error payload multilingual

First order of business is packing multiple languages into the string. This is quite easy using JSON:

Now, the generated error object will look like this:

{
  "mktoResponse": {
    "for": "mktoFormMessage0",
    "error": true,
    "data": {
      "errorCode": "400",
      "errorType": "invalid",
      "errorFields": [
        "Email"
      ],
      "invalidInputMsg": "{\"en\":\"Sorry, no free/seemingly free addresses allowed.\",\"de\":\"Entschuldigung, E-Mail nicht erlaubt\"}",
      "error": true
    }
  }
}

We haven’t done the special stuff yet. Just laying the groundwork. So for now, the whole JSON string is shown:

Get the code

Next, we add custom JS to narrow down to just the current language:

Include the handleGlobalRulesJSONErrors function before the embed code or named mktoForm. (In the <head> is fine. Usually, custom form behaviors go after the embed code, but in this case we need to get in before Forms 2.0’s native message listener.)

   /**
    * Customize server-emitted Marketo Forms 2.0 error messages 
    * @author Sanford Whiteman
    * @version v1.0 2023-03-30
    * @copyright © 2023 Sanford Whiteman
    * @license Hippocratic 3.0: This license must appear with all reproductions of this software.
    *
    */
   function handleGlobalRulesJSONErrors(options){

      // error message relay and direct ajax error have different depth
      function getMarketoFlavoredError(obj, mktoField) {
         if (obj && obj.error) {
            let data = obj.data || obj;
            if (data && data.errorFields && data.errorFields.includes(mktoField)) {
               return data;
            }
         }
      }

      // enhance showErrorMessage by also reeenabling form
      function showMarketoError(mktoForm, errorElemName, message, buttonHTML) {
         const jqForm = mktoForm.getFormElem(),
               jqSubmitButton = jqForm.find("button[type='submit']"),
               jqInput = jqForm.find("[name='" + errorElemName + "']");

         jqSubmitButton.removeAttr("disabled");
         jqSubmitButton.html(buttonHTML || jqSubmitButton.attr("data-original-html") || "Submit");
         mktoForm.showErrorMessage(message, jqInput);
      }

      function getInvalidInputTranslations(errorData) {
         try {
            return JSON.parse(errorData.invalidInputMsg);
         } catch (e) {
            throw "Marketo-flavored event detected, but error message text could not be expanded as JSON.";
         }
      }

      // utility fn to allow for async and/or later lib load
      function onMktoFormsLibReady(callback) {
         let reenter = !callback ? onMktoFormsLibReady : onMktoFormsLibReady.bind(this, callback);

         if ( typeof MktoForms2 != "object" && !onMktoFormsLibReady.handling ) {
            document.addEventListener("load", reenter, true);
            onMktoFormsLibReady.handling = true;
         } else if ( typeof MktoForms2 == "object" ) {
            if( !onMktoFormsLibReady.done ) {
               document.removeEventListener("load", reenter, true);
               onMktoFormsLibReady.done = true;
               window.dispatchEvent(new Event("FormsPlus.MktoForms2Ready"));
            }
            callback && callback();            
         }
      }

      // for form embed
      function handleEmbeddedFormElementError(originalEvent) {
         if (originalEvent.origin != options.formOrigin || !originalEvent.isTrusted) return;

         let eventData;
         try {
            eventData = JSON.parse(originalEvent.data);
         } catch (e) {
            return;
         }

         let mktoErrorData = getMarketoFlavoredError(eventData.mktoResponse, "Email");
         if (mktoErrorData) {
            const firstForm = MktoForms2.allForms()[0];
            const messageTranslations = options.translateMap || getInvalidInputTranslations(mktoErrorData);
            showMarketoError(firstForm, "Email", messageTranslations[options.errorContext]);
            originalEvent.stopImmediatePropagation();
         }
      }

      // for named form on Marketo LP
      function handleNamedFormElementError(e) {
         MktoForms2.$.ajaxPrefilter("json", function(ajaxOptions, originaAjaxOptions, jqXHR) {
            const originalErrorCb = ajaxOptions.error;

            ajaxOptions.error = function(jqXHR, jqStatusText, httpStatusText) {
               let mktoErrorData = getMarketoFlavoredError(jqXHR.responseJSON, "Email");
               if (mktoErrorData) {
                  const firstForm = MktoForms2.allForms()[0];
                  const messageTranslations = options.translateMap || getInvalidInputTranslations(mktoErrorData);
                  showMarketoError(firstForm, "Email", messageTranslations[options.errorContext]);
               } else {
                  originalErrorCb.apply(this, arguments);
               }
            };
         });
      };

      // cache original button look, since we can't access the descriptor
      function cacheButtonText(){
         MktoForms2.whenReady(function(readyForm){
            const jqForm = readyForm.getFormElem(),
                  jqSubmitButton = jqForm.find("button[type='submit']");

            jqSubmitButton.attr("data-original-html", jqSubmitButton.html());
         });
      }

      // setup form for manual error management
      onMktoFormsLibReady(cacheButtonText);

      // bind 2 different error listeners, one for Marketo LPs and one for 3rd-party embeds
      onMktoFormsLibReady(handleNamedFormElementError);
      window.addEventListener("message", handleEmbeddedFormElementError);      

   }

Use the code

Pass 2 required options to handleGlobalRulesJSONErrors:

  • errorContext: the current language
  • formOrigin: the form source (embed code <script src> or LP URL[3])
 handleGlobalRulesJSONErrors({ 
   errorContext: "de"
   formOrigin: "https://pages.example.com"
 });

That’s it! Now the message will switch based on errorContext.

If you have a lot of translations, use a separate map

This solution is made tricky-slick by packing JSON into the existing Error Message box. The max length of that value is 255 bytes, so it’s possible you’ll run out of room if you support many languages (especially ones with wider characters).

In that case, you can pass a third option translateMap to the function, shaped like this:

{
    "en" : "English message",
    "de" : "German message",
    "jp" : "Japanese message"
    /* etc… */
}
Notes

[1] There’s a way to keep blocked domains secret even when using JavaScript validation, though I haven’t had a chance to blog about it.

[2] On Marketo LPs, the shape is slightly different from what’s shown. But the code covers both cases.

[3] Technically the formOrigin is only used by the embed code, but always include it for portability.