CiviCRM: “Individual Prefix” field as auto-complete text input

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 is value matching 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 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.

  1. <?php
  2. /**
  3.  * Project: TwoMice CiviCRM modifications
  4.  * File: tmcivicrm.module
  5.  */
  6.  
  7.  
  8. /**
  9.  * Implementation of hook_civicrm_buildForm()
  10.  */
  11. function tmcivicrm_civicrm_buildForm($formName, &$form) {
  12.  
  13. if (
  14. $formName == 'CRM_Contact_Form_Contact' &&
  15. $form->getElementType('prefix_id') == 'select'
  16. ) {
  17. /* For this form, we'll remove the existing <select> list for the
  18.   Individual Prefix field and replace it with a text field,
  19.   allowing the user to enter any value in this field. */
  20.  
  21. /* Get the text value for the existing prefix, if any
  22.   */
  23. /* First get the numeric value. getValue() returns an array, or
  24.   NULL if there is no existing value */
  25. $values = $form->getElement('prefix_id')->getValue();
  26.  
  27. if (isset($values)) {
  28. /* If there is any value for this field (as there will not be when
  29.   adding a contact) */
  30. /* Get the first element in the array; there should be only
  31.   one anyway) */
  32. $value = (int)$values[0];
  33.  
  34. /* Loop through the option lists to find one matching the
  35.   numeric value, then use the text of that option as the text
  36.   to display to the user. */
  37. foreach ($form->getElement('prefix_id')->_options as $option) {
  38. if($option['attr']['value'] == $value) {
  39. $textVal = $option['text'];
  40. break;
  41. }
  42. }
  43. }
  44.  
  45. // Remove existing prefix_id field (<select>); we have no need of it.
  46. $form->removeElement('prefix_id');
  47.  
  48. /* Add a couple of new fields for the template. Be sure to assign an
  49.   * "ID" attribute, so they can be more easily accessed via JQuery in the
  50.   * template.
  51.   */
  52.  
  53. // Add a new prefix_id field, this time a text input.
  54. $form->addElement(
  55. 'text', 'prefix_id', 'Title', array('id'=>'prefix_id')
  56. );
  57. /* Set the value to any existing NUMERIC prefix value. When the form is
  58.   submitted, CiviCRM will be expecting this value as an integer and will
  59.   use it to record the Individual Prefix. See
  60.   tmcivicrm_civicrm_postProcess() for how this works and how it is
  61.   reconciled with what the user may type in. */
  62. $form->getElement('prefix_id')->setValue($value);
  63.  
  64. /* Add a hidden field, also containing any existing prefix_id text; We
  65.   will use this in postProcess to check for changed values. */
  66. $form->addElement(
  67. 'hidden', 'hidden_prefix_id_text', NULL,
  68. array('id'=>'hidden_prefix_id_text')
  69. );
  70. $form->getElement('hidden_prefix_id_text')->setValue($textVal);
  71.  
  72. /* The ony form element we're NOT adding here is the one the user will
  73.   * actually see. Instead of adding it here, we use JQuery within the
  74.   * template to append it in the correct location in the form. This is
  75.   * the only way we can do it without changing the template itself, since
  76.   * the template is hard-coded to put in only specific form elements at
  77.   * specific spots. Any new form elements we add here -- other than
  78.   * hidden inputs -- will not make it into the template's HTML output.
  79.   * See the template JavaScript for the code that inserts this element.
  80.   */
  81. // Insert the JavaScript file that will do the above-described magic.
  82. drupal_add_js(
  83. drupal_get_path('module', 'tmcivicrm') .'/modules/tmcivicrm/js/Contact_Form_Contact.js'
  84. );
  85. }
  86. }
  87.  
  88. /**
  89.  * Implementation of hook_civicrm_postProcess()
  90.  *
  91.  * This function performs additional handling of form inputs after CiviCRM has
  92. processed the form.
  93.  */
  94. function tmcivicrm_civicrm_postProcess($formName, &$form) {
  95.  
  96. if ($formName == 'CRM_Contact_Form_Contact') {
  97.  
  98. /* Get the submitted prefix text and any existing prefix text, and
  99.   compare to see if the prefix has changed. */
  100. $prefix = CRM_Utils_Request::retrieve(
  101. 'prefix_id_text', 'String', $form, false, null, 'REQUEST'
  102. );
  103. $originalPrefix = CRM_Utils_Request::retrieve(
  104. 'hidden_prefix_id_text', 'String', $form, false, null,
  105. 'REQUEST'
  106. );
  107.  
  108. /* If the prefix has changed, we'll need to do some work to update it.
  109.   * If it hasn't changed, it's been saved correctly already, because
  110.   * the form contained our the numeric value of the existing prefix in
  111.   * the element prefix_id (added in tmcivicrm_civicrm_buildForm()).
  112.   */
  113. if ($prefix <> $originalPrefix) {
  114. /* Prefix has been changed. Get the correct numeric value for the
  115.   * new prefix (creating a new Individual Prefix option list item if
  116.   * necessary), and save the value in the contact record.
  117.   */
  118.  
  119. // We'll need this path below for including some files
  120. global $civicrm_root;
  121.  
  122. /* Search for the numeric value in case the option list item already
  123.   exists, and store that as the new prefix numeric value. */
  124. $prefixValue = array_search(
  125. $prefix ,
  126. CRM_Core_PseudoConstant::individualPrefix()
  127. );
  128.  
  129. /* If no matching option list item was found, we need to add the new
  130.   * prefix to the Individual Prefix option list.
  131.   */
  132. if (!$prefixValue) {
  133. /* We'll use CiviCRM BAO functions to add the new option. To be
  134.   sure we're adding to the correct option list (Individual
  135.   Prefixes and not some other option list), fetch the option group
  136.   ID from the database. */
  137. $group = new CRM_Core_BAO_OptionGroup;
  138. $group->name = 'individual_prefix';
  139. $group->find(true);
  140.  
  141. // Record the correct option group ID
  142. $optionGroupId = $group->id;
  143.  
  144. // Prep the correct parameters for the new option list item
  145. $optionParams = array(
  146. 'label'=>$prefix, 'name'=>$prefix, 'is_active'=>true
  147. );
  148. /* We'll also need these parameters to create the item in the
  149.   correct option list: */
  150. $groupParams =array('id'=>$optionGroupId);
  151.  
  152. /* Now use all those parameters and the Option Value functions
  153.   to add the item to the list. */
  154. $action = CRM_Core_Action::ADD;
  155. $option = CRM_Core_OptionValue::addOptionValue(
  156. $optionParams, $groupParams, $action, $optionValueID
  157. );
  158. /* $option now contains the newly added item, with its numeric
  159.   value in $option->value. Store that as the correct prefix
  160.   value. */
  161. $prefixValue = $option->value;
  162.  
  163. /* Now that we've added a new value to the Individual Prefix
  164.   * option list, we need to refresh CiviCRM's cache of possible
  165.   * Individual Prefix values. This cache is built once per page,
  166.   * and it's already been built by the time this hook is fired.
  167.   * We need to refresh it so that the newly created prefix can be
  168.   * found and used in the contact's Display Name when we update
  169.   * the contact record below.
  170.   */
  171. require_once($civicrm_root .
  172. DIR_SEP . 'CRM' .
  173. DIR_SEP . 'Core' .
  174. DIR_SEP . 'PseudoConstant.php'
  175. );
  176. CRM_Core_PseudoConstant::flush('individualPrefix');
  177. }
  178.  
  179. /* By now, one way or another, we have a numeric value in
  180.   * $prefixValue. Update the individual record using that value.
  181.   */
  182. $params = array(
  183. 'contact_id' => $form->_contactId,
  184. 'contact_type' => 'Individual',
  185. 'prefix_id' => $prefixValue
  186. );
  187.  
  188. require_once(
  189. $civicrm_root .
  190. DIR_SEP . 'api' .
  191. DIR_SEP . 'v2' .
  192. DIR_SEP . 'Contact.php'
  193. );
  194. $ret = civicrm_contact_add($params);
  195. if ($ret['is_error']) {
  196. drupal_set_message(t('Could not save changed Individual Prefix.
  197. Error: '. $ret['error_message']), 'error');
  198. }
  199. } else {
  200. /* Prefix is unchanged. Nothing to do, as the value will have been
  201.   passed in from the form in prefix_id field. */
  202. }
  203. }
  204. }

The JavaScript

The module above makes use of Drupal’s drupal_add_js() function to add the following JavaScript file. Thanks to hershel’s comment 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.

  1. <script type="text/javascript">
  2. /* Since we need our custom text element to be in exactly the right
  3.   * place in the form, namely in the same place as the existing prefix
  4.   * element, we add it here using JQuery's .append() method, by appending
  5.   * it to the parent of the prefix element. We give it the same value
  6.   * as the value of the hidden_prefix_id element (see
  7.   * tmcivicrm_civicrm_buildForm() in tmcivicrm.module).
  8.   */
  9. $("#prefix_id").parent().append(
  10. '<input type="text" name="prefix_id_text" ' +
  11. 'id="prefix_id_text" autocomplete="off" value="' +
  12. $('#hidden_prefix_id_text').val() +'">'
  13. );
  14.  
  15. // Add an onKeyUp event handler to the new field.
  16. $("#prefix_id_text").keyup(function(event){
  17. prefix_auto_complete(this,event)
  18. });
  19.  
  20. /* Finally, hide (but don't remove) the prefix field, which
  21.   is actually a text field containing the numeric value of any existing
  22.   prefix (see tmcivicrm_civicrm_buildForm() in tmcivicrm.module).
  23.   CiviCRM will process this value when the form is submitted, as
  24.   explained in tmcivicrm_civicrm_postProcess() in tmcivicrm.module) */
  25. $("#prefix_id").css('display', 'none');
  26.  
  27. /* I had this function lying around from previous projects. How it works
  28.   is not especially relevant to this article, but I leave it here for your
  29.   reference. */
  30. function prefix_auto_complete(element, event) {
  31. titles = new Array(
  32. "Rev.", "Dr.", "Prof.", "Father", "Mr.", "Mrs.", "Ms.", "Hon.",
  33. "Ven.", "H.E.", "Bishop", "Sheikh", "Imam"
  34. );
  35. if (element.value == "") return 0;
  36.  
  37. /* if it's a backspace (keycode=8), just return (we do nothing on a
  38.   backspace)*/
  39. if (event.keyCode == 8 ){
  40. return 0;
  41. }
  42.  
  43. /* Find out if element has the function createTextRange (otherwise we
  44.   should use setSelectionRange()) */
  45. if (element.createTextRange) {
  46. useTextRange = true;
  47. } else {
  48. useTextRange = false;
  49. }
  50.  
  51. /* set up some temp variables
  52.   */
  53. origValue = element.value;
  54.  
  55. // if the box is empty, just return.
  56. if (origValue == "")return 0;
  57.  
  58. // cycle through each element of Array titles() until we get a match:
  59. for (n=0; n < titles.length; n++){
  60. title = titles[n];
  61. count = 0;
  62. for (i = 0; i < origValue.length; i++){
  63. if (
  64. title.toLowerCase().charAt(i) ==
  65. origValue.toLowerCase().charAt(i)
  66. ){
  67. count++
  68. }
  69. }
  70.  
  71. /* if count == origValue.length, we know all the characters matched,
  72.   so we have a match! */
  73. if (count == origValue.length){
  74.  
  75. /* find out how many characters remain to be filled in by
  76.   auto-completion */
  77. diff = title.length - origValue.length;
  78. if (diff <= 0) break;
  79.  
  80. /* remainingChars is going to be a string of the remaining
  81.   characters to be filled in */
  82. remainingChars = "";
  83. for (i=0; i < title.length; i++){
  84. if (i >= origValue.length) {
  85. remainingChars += title.charAt(i);
  86. }
  87. }
  88. element.backspace = true;
  89.  
  90. if (useTextRange) {
  91. // If useTextRange (Internet Explorer, et al)
  92. var textRange = element.createTextRange();
  93.  
  94. /* truncate the matching array item to the length of what
  95.   was typed */
  96. textRange.text=title.substr(0,textRange.text.length);
  97.  
  98. // then we add remainingChars, select it, and we're done!
  99. textRange.text += remainingChars;
  100. textRange.findText(remainingChars,diff*-2);
  101. textRange.select();
  102. return 0;
  103. } else {
  104. // (Mozilla, et al)
  105. element.value =
  106. title.substr(0,origValue.length) +
  107. remainingChars
  108. element.setSelectionRange(
  109. origValue.length,
  110. remainingChars.length + origValue.length
  111. );
  112. return 0;
  113. }
  114. }
  115. }
  116. element.backspace = false;
  117. return 0;
  118. }
  119. </script>

Conclusion

This solution is working well for me now. I’m sure there are ways to improve it. I look forward to any suggestions for improvements in the code above or in the general approach.

Comments (5)

hey, you should repost this

hey,

you should repost this on the civicrm blog if you have time. any ideas for making it easier for you to avoid customising the template in this case?

I’d be happy to

@Michael: I’d be happy to put it on the CiviCRM blog. Any idea how I can do that?

As for avoiding customized templates: if there were a way to inject code into a given template, that’s all that’s needed. A system I’ve used before uses naming conventions to automatically load like-named JS files for a template, so that template1.tpl always looks for and includes template1.js. This doesn’t seem to be the Civi way, though. Better for CiviCRM might be some way to modify template code using hook_civicrm_pageLoad — is that already possible? If we could do that I might never customize a template again.

Allen - great post. I’ve

Allen - great post. I’ve added blogging rights to your account on civicrm.org - so u can create a blog a repost by going to your My Account page.

Thanks

Thanks, David. I’ve added the blog post here: http://civicrm.org/blogs/allenshaw/using-hooks-change-individual-prefix-...

> Thanks to hershel’s comment

> Thanks to hershel’s comment for pointing out this method as a way to insert JavaScript code without relying on custom templates techniques.

You’re welcome. :)

Add a comment

The content of this field is kept private and will not be shown publicly.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Allowed HTML tags: <a> <img> <br> <em> <p> <strong> <cite> <sub> <sup> <span> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd> <pre> <address> <h2> <h3> <h4> <h5> <h6>
  • You can enable syntax highlighting of source code with the following tags: <code>, <blockcode>, <c>, <cpp>, <drupal5>, <drupal6>, <java>, <javascript>, <php>, <python>, <ruby>. The supported tag styles are: <foo>, [foo].
  • Lines and paragraphs break automatically.

More information about formatting options