Using hooks to change "Individual Prefix" to an auto-complete text input

Published
2010-04-20 12:02
Written by
Originally posted at TwoMiceAndAStrawberry.com. I recently had to modify CiviCRM to support a specific use case for a client. In this project I needed to allow the user to enter anything in the Individual Prefix field, rather than choosing from a list of options. The client in this case was very sensitive about presenting the individual's honorific precisely as the individual wants, so that "Dr.," "Rev. Dr.," and "Most Rt. Rev. Dr." should all be possible, along with anything else the user might want to enter. To illustrate the scope of issue: out of the aprox. 20K records in the client's existing database, there were over 400 distinct values for this field. A select list that long is unwieldy in numerous ways, so we knew we'd have to come up with a solution before importing all those records to CiviCRM. What I really wanted -- and what the client needed -- was to present the "Individual Prefix" field as a text input that offers type-ahead auto-complete for a short list of preferred options, but still allows the user to enter whatever they want. My solution was to implement a custom module which would use two CiviCRM hooks, hook_civicrm_buildForm and hook_civicrm_postProcess, to modify the field display and processing, and add a little custom JavaScript to provide the type-ahead effect. A quick check on the incredibly helpful, active, and responsive CiviCRM Forums confirmed that my intended solution was probably a good one, so I created the module below. Skip ahead if you just want the code. In the mean time, a little explanation on why it does what it does: In CiviCRM, the individual prefix is stored as an integer, not as text. This number matches the unique ID of an item in CiviCRM's list of individual prefixes, which are normally configured manually at Administer CiviCRM > Option Lists > Individual Prefixes. Naturally CiviCRM already has a simple way of translating between this stored numeric value and the text that a user sees or enters; in the form for editing an individual contact, that translation happens with an HTML <select> element. For my modifications to work, the module has to be able to handle that translation all by itself. To allow the user to enter any arbitrary text, the module must present the user with a text field instead of a <select> element. That text field must be displayed in the right place in the form, and it must display the correct value when the form is first opened if the user is editing an existing contact. When the form is submitted, the module has to accept whatever text the user has entered and match it to one of the items in CiviCRM's list of individual prefixes -- or, if no match exists, it has to add an item to the list and then use that value -- and store the correct numeric value as the contact's prefix. Finally, at the client's request, this text field needs to offer some kind of type-ahead auto-complete functionality to steer the user toward a short list of common prefixes. This is accomplished with a simple JavaScript function triggered by the onKeyUp event.

The module

Here, then is the module. I've included some lengthy notes in the code comments, explaining some of the unexpected turns I had to take to get the effect I was after. Jump below to see the JavaScript.
<?php
/**
 * Project:     TwoMice CiviCRM modifications
 * File: tmcivicrm.module
 */


/**
 * Implementation of hook_civicrm_buildForm()
 */
function tmcivicrm_civicrm_buildForm($formName, &$form) {

    if (
        $formName == 'CRM_Contact_Form_Contact' &&
        $form->getElementType('prefix_id') == 'select'
    ) {
        /* For this form, we'll remove the existing <select> list for the
        Individual Prefix field and replace it with a text field,
        allowing the user to enter any value in this field. */

        /* Get the text value for the existing prefix, if any
         */
        /* First get the numeric value. getValue() returns an array, or
        NULL if there is no existing value */
        $values = $form->getElement('prefix_id')->getValue();

        if (isset($values)) {
        /* If there is any value for this field (as there will not be when
        adding a contact) */
            /* Get the first element in the array; there should be only
            one anyway) */
            $value = (int)$values[0];

            /* Loop through the option lists to find one matching the
            numeric value, then use the text of that option as the text
            to display to the user. */
            foreach ($form->getElement('prefix_id')->_options as $option) {
                if($option['attr']['value'] == $value) {
                    $textVal = $option['text'];
                    break;
                }
            }
        }

        // Remove existing prefix_id field (<select>); we have no need of it.
        $form->removeElement('prefix_id');

        /* Add a couple of new fields for the template.  Be sure to assign an
         * "ID" attribute, so they can be more easily accessed via JQuery in the
         * template.
         */

        // Add a new prefix_id field, this time a text input.
        $form->addElement(
            'text', 'prefix_id', 'Title', array('id'=>'prefix_id')
        );
        /* Set the value to any existing NUMERIC prefix value.  When the form is
        submitted, CiviCRM will be expecting this value as an integer and will
        use it to record the Individual Prefix.  See
        tmcivicrm_civicrm_postProcess() for how this works and how it is
        reconciled with what the user may type in. */
        $form->getElement('prefix_id')->setValue($value);

        /* Add a hidden field, also containing any existing prefix_id text; We
        will use this in postProcess to check for changed values. */
        $form->addElement(
            'hidden', 'hidden_prefix_id_text', NULL,
            array('id'=>'hidden_prefix_id_text')
        );
        $form->getElement('hidden_prefix_id_text')->setValue($textVal);

        /* The ony form element we're NOT adding here is the one the user will
         * actually see.  Instead of adding it here, we use JQuery within the
         * template to append it in the correct location in the form.  This is
         * the only way we can do it without changing the template itself, since
         * the template is hard-coded to put in only specific form elements at
         * specific spots. Any new form elements we add here -- other than
         * hidden inputs -- will not make it into the template's HTML output.
         * See the template JavaScript for the code that inserts this element.
         */
         // Insert the JavaScript file that will do the above-described magic.
        drupal_add_js(
            drupal_get_path('module','tmcivicrm') .'/modules/tmcivicrm/js/Contact_Form_Contact.js'
        );
    }
}

/**
 * Implementation of hook_civicrm_postProcess()
 *
 * This function performs additional handling of form inputs after CiviCRM has
processed the form.
 */
function tmcivicrm_civicrm_postProcess($formName, &$form) {

    if ($formName == 'CRM_Contact_Form_Contact') {

        /* Get the submitted prefix text and any existing prefix text, and
        compare to see if the prefix has changed. */
        $prefix = CRM_Utils_Request::retrieve(
            'prefix_id_text', 'String', $form, false, null, 'REQUEST'
        );
        $originalPrefix = CRM_Utils_Request::retrieve(
            'hidden_prefix_id_text', 'String', $form, false, null,
            'REQUEST'
        );

        /* If the prefix has changed, we'll need to do some work to update it.
         * If it hasn't changed, it's been saved correctly already, because
         * the form contained our the numeric value of the existing prefix in
         * the element prefix_id (added in tmcivicrm_civicrm_buildForm()).
         */
        if ($prefix <> $originalPrefix) {
            /* Prefix has been changed. Get the correct numeric value for the
             * new prefix (creating a new Individual Prefix option list item if
             * necessary), and save the value in the contact record.
             */

            // We'll need this path below for including some files
            global $civicrm_root;

            /* Search for the numeric value in case the option list item already
            exists, and store that as the new prefix numeric value. */
            $prefixValue = array_search(
                $prefix ,
                CRM_Core_PseudoConstant::individualPrefix()
            );

            /* If no matching option list item was found, we need to add the new
             * prefix to the Individual Prefix option list.
             */
            if (!$prefixValue) {
                /* We'll use CiviCRM BAO functions to add the new option.  To be
                sure we're adding to the correct option list (Individual
                Prefixes and not some other option list), fetch the option group
                ID from the database. */
                $group = new CRM_Core_BAO_OptionGroup;
                $group->name = 'individual_prefix';
                $group->find(true);

                // Record the correct option group ID
                $optionGroupId = $group->id;

                // Prep the correct parameters for the new option list item
                $optionParams = array(
                    'label'=>$prefix, 'name'=>$prefix, 'is_active'=>true
                );
                /* We'll also need these parameters to create the item in the
                correct option list: */
                $groupParams  =array('id'=>$optionGroupId);

                /* Now use all those parameters and the Option Value functions
                to add the item to the list. */
                $action = CRM_Core_Action::ADD;
                $option = CRM_Core_OptionValue::addOptionValue(
                    $optionParams, $groupParams, $action, $optionValueID
                );
                /* $option now contains the newly added item, with its numeric
                value in $option->value.  Store that as the correct prefix
                value. */
                $prefixValue = $option->value;

                /* Now that we've added a new value to the Individual Prefix
                 * option list, we need to refresh CiviCRM's cache of possible
                 * Individual Prefix values.  This cache is built once per page,
                 * and it's already been built by the time this hook is fired.
                 * We need to refresh it so that the newly created prefix can be
                 * found and used in the contact's Display Name when we update
                 * the contact record below.
                 */
                require_once($civicrm_root .
                    DIR_SEP . 'CRM' .
                    DIR_SEP . 'Core' .
                    DIR_SEP . 'PseudoConstant.php'
                );
                CRM_Core_PseudoConstant::flush('individualPrefix');
            }

            /* By now, one way or another, we have a numeric value in
             * $prefixValue. Update the individual record using that value.
             */
            $params = array(
                'contact_id'    => $form->_contactId,
                'contact_type'  => 'Individual',
                'prefix_id'     => $prefixValue
            );

            require_once(
                $civicrm_root .
                DIR_SEP . 'api' .
                DIR_SEP . 'v2' .
                DIR_SEP . 'Contact.php'
            );
            $ret = civicrm_contact_add($params);
            if ($ret['is_error']) {
                drupal_set_message(t('Could not save changed Individual Prefix.
                    Error: '. $ret['error_message']), 'error');
            }
        } else {
            /* Prefix is unchanged. Nothing to do, as the value will have been
            passed in from the form in prefix_id field. */
        }
    }
}

The JavaScript

The module above makes use of Drupal's drupal_add_js() function to add the following JavaScript file. Thanks to hershel for pointing out this method as a way to insert JavaScript code without relying on custom templates techniques. As far as possible I've tried to avoid using custom templates in order to preserve forward compatibility for future CiviCRM releases. This JavaScript code provides both the type-ahead effect this field requires and some small but important data processing when the form is submitted. See the comments for detailed explanation of some points.

<script type="text/javascript">
    /* Since we need our custom text element to be in exactly the right
     * place in the form, namely in the same place as the existing prefix
     * element, we add it here using JQuery's .append() method, by appending
     * it to the parent of the prefix element.  We give it the same value
     * as the value of the hidden_prefix_id element (see
     * tmcivicrm_civicrm_buildForm() in tmcivicrm.module).
     */
    $("#prefix_id").parent().append(
        '<input type="text" name="prefix_id_text" ' +
        'id="prefix_id_text" autocomplete="off" value="' +
        $('#hidden_prefix_id_text').val() +'">'
    );

    // Add an onKeyUp event handler to the new field.
    $("#prefix_id_text").keyup(function(event){
            prefix_auto_complete(this,event)
    });

    /* Finally, hide (but don't remove) the prefix field, which
     is actually a text field containing the numeric value of any existing
     prefix (see tmcivicrm_civicrm_buildForm() in tmcivicrm.module).
     CiviCRM will process this value when the form is submitted, as
     explained in tmcivicrm_civicrm_postProcess() in tmcivicrm.module)  */
    $("#prefix_id").css('display', 'none');

    /* I had this function lying around from previous projects.  How it works
    is not especially relevant to this article, but I leave it here for your
    reference. */
    function prefix_auto_complete(element, event) {
        titles = new Array(
            "Rev.", "Dr.", "Prof.", "Father", "Mr.", "Mrs.", "Ms.", "Hon.",
            "Ven.", "H.E.", "Bishop", "Sheikh", "Imam"
        );
        if (element.value == "") return 0;

        /* if it's a backspace (keycode=8), just return (we do nothing on a
        backspace)*/
        if (event.keyCode == 8 ){
            return 0;
        }

        /* Find out if element has the function createTextRange (otherwise we
        should use setSelectionRange()) */
        if (element.createTextRange) {
            useTextRange = true;
        } else {
            useTextRange = false;
        }

        /* set up some temp variables
         */
        origValue = element.value;

        // if the box is empty, just return.
        if (origValue == "")return 0;

        // cycle through each element of Array titles() until we get a match:
        for (n=0; n < titles.length; n++){
            title = titles[n];
            count = 0;
            for (i = 0; i < origValue.length; i++){
                if (
                    title.toLowerCase().charAt(i) ==
                    origValue.toLowerCase().charAt(i)
                ){
                    count++
                }
            }

            /* if count == origValue.length, we know all the characters matched,
            so we have a match! */
            if (count == origValue.length){

                /* find out how many characters remain to be filled in by
                auto-completion */
                diff = title.length - origValue.length;
                if (diff <= 0) break;

                /* remainingChars is going to be a string of the remaining
                characters to be filled in */
                remainingChars = "";
                for (i=0; i < title.length; i++){
                    if (i >= origValue.length) {
                        remainingChars += title.charAt(i);
                    }
                }
                element.backspace = true;

                if (useTextRange) {
                // If useTextRange (Internet Explorer, et al)
                    var textRange = element.createTextRange();

                    /* truncate the matching array item to the length of what
                    was typed */
                    textRange.text=title.substr(0,textRange.text.length);

                    // then we add remainingChars, select it, and we're done!
                    textRange.text += remainingChars;
                    textRange.findText(remainingChars,diff*-2);
                    textRange.select();
                    return 0;
                } else {
                // (Mozilla, et al)
                    element.value =
                        title.substr(0,origValue.length) +
                        remainingChars
                    element.setSelectionRange(
                        origValue.length,
                        remainingChars.length + origValue.length
                    );
                    return 0;
                }
            }
        }
        element.backspace = false;
        return 0;
    }
</script>

Conclusion

This solution is working well for me now. I'm sure there are ways to improve it. Since I'm very new to CiviCRM, I look forward to any suggestions for improvements in the code above or in the general approach.
Filed under

Comments

To avoid customizing templates, you could use drupal_add_js in your function tmcivicrm_civicrm_buildForm. Then it's only loaded when that form is loaded.

Another option is to put some unique identifier into the HTML, whether a unique ID for the BODY tag or simply using your own unique ID hidden_prefix_id_text. Once you have that ID you can put your JS in a global file and have it execute only when that ID is detected on the page.

Those are two ideas anyhow.

Thanks for pointing that out, hershel. I suppose that, as new as I am to CiviCRM, I tend to forget that I'm working in Drupal. Good call.

I've made this change in my own code, and modified the post to reflect the change. The great thing about this is that now the entire modification is contained within the module and doesn't rely on any tweaks to other code or templates. Thanks!

Also, to avoid hard-coding your path, you should use drupal_get_path('module', 'tmcivicrm');

I can imagine others might have use for this exact modification. Any plans to add the module to the Drupal contrib repository? If you need assistance doing so, feel free to contact me directly.

Anonymous (not verified)
2015-09-07 - 02:41

Hi,

What is the license your code is under? Can I use it freely in my project?