Randomly shuffling the order of checkboxes to shake off bias

Interesting thread over on the Nation, where user DN asked:

I am trying to create an unsubscribe form that has various values for the reason someone is unsubscribing. For example: “I no longer want to receive these emails,” “The emails are too frequent,” “I never signed up for this mailing list,” and “The emails are inappropriate.”
Is there any way I can randomize the order the above values appear for each new user so that people do not just choose the first option?

Here’s the form in question:

Note it also has an “Other” option, which (as I suspected) DN wants to keep at the bottom, not shuffle. When “Other” is checked, there’s a a Visibility Rule that reveals a textbox for Unsubscribed - Other Reason, so we need to make sure that functionality is intact.

The code, Take 1

It’s easy to randomly shuffle DOM child elements, as long as you know how the markup is structured (which we definitely do with Marketo forms). You merely create an array of the elements, shuffle the array, and then reinsert the shuffled array from top to bottom.

The only quirks here are:

  1. pairing each <input> element with its related <label> element (the label is always the input’s next element sibling in the DOM)
  2. making sure the “Other” input + label are fixed at the end

So here you go:

(function () {
   /**
    * Shuffle Marketo Checkbox/Radio sets
    * @author Sanford Whiteman
    * @version v1 2022-06-20
    * @copyright © 2022 Sanford Whiteman
    * @license Hippocratic 3.0: This license must appear with all reproductions of this software.
    *
    */
   const shuffleField = "UnsubscribedReason",
         fixedAtBottom = ["Other"];

   /* NO NEED TO TOUCH BELOW THIS LINE */
   
   const arrayify = getSelection.call.bind([].slice);

   MktoForms2.whenReady(function (mktoForm) {
      let formEl = mktoForm.getFormElem()[0];

      let shuffleContainer = formEl.querySelector("[name='" + shuffleField + "']").parentElement;

      let originalOptions = arrayify(shuffleContainer.querySelectorAll("input"))
      .map(function (input) {
        let label = input.nextElementSibling,
            value = input.value;
        
        return {
          value: value,
          input: input,
          label: label
        };
      });

      let fixedOptions = [],
          shuffleOptions = [];      
      originalOptions
      .forEach(function (desc) {
        return fixedAtBottom.indexOf(desc.value) == -1 
           ? shuffleOptions.push(desc)
           : fixedOptions.push(desc)
      });
            
      let finalOptions = makeShuffled(shuffleOptions).concat(fixedOptions);
      
      finalOptions
      .forEach(function (desc) {
        shuffleContainer.appendChild(desc.input), shuffleContainer.appendChild(desc.label);
      });     
      
   });
   
   /**
    * Fisher-Yates shuffle {@link https://javascript.info/task/shuffle}
    * @author Kantor Ilya Aleksandrovich
    * @license [CC-BY-NC-SA]{@link https://creativecommons.org/licenses/by-nc-sa/3.0/legalcode}
    *
    */   
   function makeShuffled(array) {
      let shuffled = [].concat(array);
      for (let i = shuffled.length - 1; i > 0; i--) {
         let j = Math.floor(Math.random() * (i + 1));
         let t = shuffled[i];
         shuffled[i] = shuffled[j];
         shuffled[j] = t;
      }
      return shuffled;
   }   
   
})();

That’ll randomly alternate between

and

and so on. All 24 permutations without the agony of building a 24-way A/B test!

Just swap in your target field for the shuffleField and put your fixed fields, if any, in the fixedAtBottom array. Remember to use SOAP field names.

Not quite there

There’s still a problem, though.

You shuffled the order in hopes that people would seek out their real reason instead of clicking the first one. But how can you be sure they still didn’t just click the top one, which is now going to hold a different value for different people?

For that, you’d need to also reflect the order — the specific order they saw at runtime — in a hidden field.

The code, Take 2

The expanded v2 below can save the indexes (1-based for friendliness) of all selected items to a hidden String field, the indexesField.

For example, it’ll store “2” or “1;3;5”. Set indexesField to undefined to skip this feature.

(function () {
   /**
    * Shuffle Marketo Checkbox/Radio sets
    * @author Sanford Whiteman
    * @version v2 2022-06-20
    * @copyright © 2022 Sanford Whiteman
    * @license Hippocratic 3.0: This license must appear with all reproductions of this software.
    *
    */
   const shuffleField = "UnsubscribedReason",
         fixedAtBottom = ["Other"],
         indexesField = "UnsubscribedReasonIndexes";

   /* NO NEED TO TOUCH BELOW THIS LINE */
   
   const arrayify = getSelection.call.bind([].slice);

   MktoForms2.whenReady(function (mktoForm) {
      let formEl = mktoForm.getFormElem()[0];

      let shuffleContainer = formEl.querySelector("[name='" + shuffleField + "']").parentElement;

      let originalOptions = arrayify(shuffleContainer.querySelectorAll("input"))
      .map(function (input) {
        let label = input.nextElementSibling,
            value = input.value;
        
        return {
          value: value,
          input: input,
          label: label
        };
      });

      let fixedOptions = [],
          shuffleOptions = [];      
      originalOptions
      .forEach(function (desc) {
        return fixedAtBottom.indexOf(desc.value) == -1 
           ? shuffleOptions.push(desc)
           : fixedOptions.push(desc)
      });
            
      let finalOptions = makeShuffled(shuffleOptions).concat(fixedOptions);
      
      finalOptions
      .forEach(function (desc) {
        shuffleContainer.appendChild(desc.input), shuffleContainer.appendChild(desc.label);
      });
      
      mktoForm.onSubmit(function(mktoForm){
         if(!indexesField) return;
                   
         let currentValues = mktoForm.getValues()[shuffleField];         
         currentValues = Array.isArray(currentValues) ? currentValues : [];
         
         let currentIndexes = [];         
         finalOptions
         .forEach(function(desc,idx){
           if(currentValues.indexOf(desc.value) != -1){
             currentIndexes.push(++idx);
           }
         });            
         
         let mktoFieldsObj = {};
         mktoFieldsObj[indexesField] = currentIndexes.join(";");
         mktoForm.addHiddenFields(mktoFieldsObj);
         
      });
      
   });
   
   /**
    * Fisher-Yates shuffle {@link https://javascript.info/task/shuffle}
    * @author Kantor Ilya Aleksandrovich
    * @license [CC-BY-NC-SA]{@link https://creativecommons.org/licenses/by-nc-sa/3.0/legalcode}
    *
    */   
   function makeShuffled(array) {
      let shuffled = [].concat(array);
      for (let i = shuffled.length - 1; i > 0; i--) {
         let j = Math.floor(Math.random() * (i + 1));
         let t = shuffled[i];
         shuffled[i] = shuffled[j];
         shuffled[j] = t;
      }
      return shuffled;
   }   
   
})();