2

I have a drupal form with repeatable fields allowing to type in as many phone numbers as one wants. Simplifying to the extreme, the code looks like this:

function form_test($form, $form_state) {
    $form = array(
        '#attributes' => array(
            'enctype' => 'multipart/form-data'
        ),
        'phoneNumbers' => array(
            '#type' => 'fieldset',
            '#attributes' => array(
                'class' => array('repeatable_container')
            ),
            '#tree' => true,
            0 => array(
                '#type' => 'fieldset',
                '#attributes' => array(
                    'class' => array('repeatable')
                ),
                'phoneNumber' => array(
                    '#type' => 'textfield'
                )
            )
        ),
        'enregistrer' => array(
            '#type' => 'submit',
            '#value' => t('Enregistrer'),
            '#submit' => array('form_test_enregistrer')
        )
    );

    return $form;
}

function form_test_validate($form, &$form_state) {
    drupal_set_message("<pre>" . print_r($form_state['input']['phoneNumbers'], true) . "</pre>", 'warning');
}

function form_test_enregistrer($form, &$form_state) {
    // Whatever
}

As you can see, the validation function simply prints out the phone numbers that are submitted through the form. The classes 'repeatable_container' and 'repeatable' are used by the following javascript (simplified here) to allow the addition or reletion of phone numbers:

jQuery.noConflict();
(function ($) {
    "use strict";

    var getNextIdentifier, repeatButton, deleteButton;

    getNextIdentifier = function getNextIdentifier(original) {
        var re_lastNumber = /\d+(?!.*\d+)/, // One or more digits NOT followed by anything (.*) plus a number (\d+ : one or more digits). Basically, the last number of the string.
            m,
            newIdentifier = original;
        m = original.match(re_lastNumber);
        if (m !== null) {
            newIdentifier = original.replace(re_lastNumber, parseInt(m[0], 10) + 1);
        }
        return newIdentifier;
    };

    $.prototype.hasAttr = function (attrName) {
        var attr = $(this).attr(attrName);
        return (typeof(attr) !== 'undefined' && attr !== false);
    }

    $.prototype.increaseID = function (element) {
        return element.attr('id', getNextIdentifier(element.attr('id')));
    }

    $.prototype.increaseName = function (element) {
        return element.attr('name', getNextIdentifier(element.attr('name')));
    }

    $.prototype.increaseClassNames = function (element) {
        var classes = element.attr('class').split(' '),
            nbClasses = classes.length,
            i;
        for (i = 0; i < nbClasses; i += 1) {
            classes[i] = getNextIdentifier(classes[i]);
        }
        return element.attr('class', classes.join(' '));
    }

    $.prototype.increaseIdentifiers = function () {
        var attrs = [
                { attrName: 'id',           func: $(this).increaseID},
                { attrName: 'name',     func: $(this).increaseName },
                { attrName: 'class',    func: $(this).increaseClassNames }
            ],
            nbAttrs = attrs.length,
            i;
        for (i = 0; i < nbAttrs; i += 1) {
            if (this.hasAttr(attrs[i].attrName)) {
                attrs[i].func(this);
            }
            $('[' + attrs[i].attrName + ']', this).each(function () {
                attrs[i].func($(this));
            });
        }
        return this;
    };

    repeatButton = function (eventObject) {
        eventObject.preventDefault();
        var parent = $(this).prev(),
            element = $('.repeatable', parent).last(),
            clone = element.clone(true, true).increaseIdentifiers(true).appendTo(parent);
    };

    deleteButton = function (eventObject) {
        eventObject.preventDefault();
        $(this).closest('.repeatable').remove();
    };

    $('document').ready(function () {
        $('.repeatable_container').append($('<a href="#" class="repeater">+</a>'));
        $('.repeatable').append($('<a href="#" class="deleter">x</a>'));
        $('a.repeater').click(repeatButton);
        $('a.deleter').click(deleteButton);
    });
}(jQuery));

The problem arises when I create multiple phone numbers and I erase the very first one. For example, if I duplicate the phone number field and erase the first one, I would expect the validation function to obtain one phone number with id of 1 (the id 0 having been erased). But that's not what happens. In fact, what I get are two phone number, the first with id 1 (expected) and the second with id 0 (which should have been erased), in that order.

If I inspect the dom, I see that the javascript part works perfectly, so I'm pretty sure drupal is doing something here that I don't understand. Any clues on what's happening here?

9
  • I realise my question may be hard to understand, so please do ask questions...
    – Shawn
    Commented Jan 31, 2013 at 16:43
  • 1
    As side note, you add form elements to the $form argument the form builder receives; you don't initialize $form to an new array.
    – avpaderno
    Commented Jan 31, 2013 at 16:46
  • 1
    @Shawn I recommend you install the devel module (drupal.org/project/devel) as it will let you quickly print out variables, arrays, objects, etc., via the dpm() function; for example, dpm($form_state) in your validate or submit handler will give you a navigable $form_state array that you can browse in the messages area on the next page view. Very helpful! Commented Jan 31, 2013 at 16:49
  • @Shawn regarding your question – if you alter a form by adding elements that don't exist on the server-side, those elements will not be usable to you when processing the form. This is a validation / security thing. If you want to add elements / alter a form, check out the #ajax property in the form API api.drupal.org/api/drupal/…. If this is indeed the issue, I can post an answer that includes some sample code to duplicate a form element via an "add another" button. Commented Jan 31, 2013 at 16:51
  • Even if you remove the form item using Javascript the actual value is still stored in the form state. Commented Jan 31, 2013 at 17:21

1 Answer 1

2

Generally, you can let Drupal handle more of this. I don't think I've ever had a need to touch $form_state['input'], since $form_state['values'] always holds user submitted data that is considered "acceptable" – e.g. if a user added an option to a <select> via javascript and then picked it, I don't want that value!

Of course, that means that you yourself can't add options or entirely new form elements via javascript, right? The solution lies in altering the $form on the server side and letting Drupal return the new markup the user via ajax. This markup could be the entire form, rewritten to have new or different elements based on some user activity, or it could just be a new piece of the form. This is done via the #ajax property on any form element (textfield, checkbox, button, etc.).

Consider this code:

function my_form($form, &$form_state) {
  // Wrap our form in a div with an ID that we know – this way we can 
  // replace the entire form with a new version that has modified fields.
  $form['#prefix'] = '<div id="my_form_wrapper">';
  $form['#suffix'] = '</div>';

  $form['phone_numbers'] = array(
    '#type' => 'fieldset',
    '#title' => 'Phone Numbers',
  );

  // Use a custom property in the $form_state to track how many numbers we should allow.
  if (!isset($form_state['#num_phone_numbers'])) {
    $form_state['#num_phone_numbers'] = 1;
  }

  // Add as many phone number fields as the user has requested.
  for ($i=1;$i<=$form_state['#num_phone_numbers'];$i++) {
    $form['phone_numbers'][$i] = array(
      '#type' => 'textfield',
      '#title' => 'Phone Number ' . $i,
    );
  }

  // Button that has an #ajax callback which will be run whenever the user clicks it.
  $form['phone_numbers']['add'] = array(
    '#type' => 'button',
    '#value' => 'Add Another Phone Number',
    '#ajax' => array(
      'callback' => 'my_form_ajax_addPhoneNumber',
      'wrapper' => 'my_form_wrapper',
    ),
  );
}

function my_form_ajax_addPhoneNumber(&$form, &$form_state) {
  // All we need to do in this ajax callback is increase the number of
  // phone numbers that we want the form to include. By calling
  // drupal_rebuild_form() the original form builder will run again,
  // see the new count, and add an additional phone number field.
  $form_state['#num_phone_numbers']++;
  return drupal_rebuild_form('my_form', $form_state, $form);
}

function my_form_submit(&$form, &$form_state) {
  foreach ($form_state['values']['phone_numbers'] as $key => $phone_number) {
    drupal_set_message("Phone Number #{$key}: {$phone_number}");
  }
}

Note that not a single line of javascript is needed. Post any questions!

9
  • Won't my_form_rebuild_form have undesirable side effects? It seems like it would erase the data the user had entered (but not yet saved), remove validation error messages and highlights, mess up the other repeatable fields in the form, mess up which tab is currently selected (part of my form uses tabs built with javascript), etc.
    – Shawn
    Commented Jan 31, 2013 at 21:09
  • How would I go about doing something similar without reloading the entire page?
    – Shawn
    Commented Jan 31, 2013 at 21:10
  • Because we also pass $form_state to the rebuild function, it prefills all form fields with whatever the user entered. It does not remove validation messages because they will be included at the top of the form. It just reloads the form (via ajax) the rest of the page is untouched. Commented Jan 31, 2013 at 21:12
  • Let me also add that, if you were to have other ajax functions run to duplicate other fields, as long as you used $form_state to handle some storage of what needs doing, your original form builder will always know how to act. Commented Jan 31, 2013 at 21:13
  • OK, well the real form is actually quite huge with all sorts of different things happening in it, so it will take me a while to try this out... I will report back when I run into problems or when I get everything working without javascript. In the meantime, I'm still slightly confused about how Drupal's validate function can get fields that are no longer in the html (my original question)... Care to elaborate?
    – Shawn
    Commented Jan 31, 2013 at 21:19

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.