diff --git a/jquery_jstree.js b/jquery_jstree.js index 3369631..d509ffe 100644 --- a/jquery_jstree.js +++ b/jquery_jstree.js @@ -15,19 +15,154 @@ 'default_value' : options.core.data.default_value }; } + + // If a search field is being used, move it to the proper + // location and configure it to search the appropriate tree. + if (options.drupal && options.drupal.search_field_id) { + var $search_field = $('#' + options.drupal.search_field_id, context).once('jstree-search').eq(0); + if ($search_field.length) { + var $throbber = $('
' + Drupal.t('No results found.') + '
'); + var interval_id = false; + var search_is_queued = false; + var search_is_running = false; + var search_running_value = ''; + // The field needs to be moved out of the element that will + // have the jsTree behavior applied; otherwise jsTree will + // just remove it from the DOM. + $search_field.insertBefore(tree) + // Run a debounced search query so that if searches come + // in more often than every 250 ms, the previous search + // is superseded by the new one. This is based on the + // code example at https://www.jstree.com/plugins/ but + // with several modifications to make it more robust (for + // example, to handle the case where the search Ajax + // request itself takes longer than 250 ms). + .on('input', function () { + // Find the text that is being searched for the current + // iteration of this function. + var search_value = $search_field.val(); + // Treat single-character searches like empty searches. + // (Searching for a single character is unlikely to be + // useful - most likely if it happens it's because the + // user paused after typing the first character of a + // longer search string - and is likely to return a + // large number of results which can be slow and hold + // up future searches from happening.) + if (search_value.length == 1) { + search_value = ''; + } + // If a new search comes in while an old one is still + // waiting to run, clear the old one. + if (interval_id) { + clearInterval(interval_id); + } + // Attempt to run the search on a regular basis until + // it is allowed to proceed. + interval_id = setInterval(function () { + // If another search is running, queue this one to + // run next. + if (search_is_running) { + search_is_queued = true; + } + // Otherwise run this search now. + else { + search_is_running = true; + search_running_value = search_value; + search_is_queued = false; + // Searching can sometimes take a while, so hide + // the old results and add a throbber before + // starting the search. Also remove the "no + // results" message (if the previous search + // returned no results). + $no_results_message.remove(); + tree.show(); + tree.css('opacity', 0); + $throbber.insertAfter($search_field); + tree.jstree(true).search(search_value); + // Prevent attempting to run the same search again. + clearInterval(interval_id); + } + }, 250); + }) + // Prevent the enter key in this field from submitting + // the form. + .keydown(function (event) { + if (event.keyCode == 13) { + event.preventDefault(); + return false; + } + }); + // When the last queued search completes, remove the + // throbber and show the results. + tree.on('search.jstree', function(event, data) { + search_is_running = false; + search_running_value = ''; + if (!search_is_queued) { + $throbber.remove(); + tree.css('opacity', 1); + // If there are no results, show the "no results" + // message in place of the tree (if the tree were + // shown, jsTree would display all items within it even + // though none of them match the search, which is not + // desirable here). + if (data.nodes.length == 0) { + tree.hide(); + $no_results_message.insertAfter($search_field); + } + } + }); + // If the last queued search is a search for an empty + // string, reset everything to its original status once the + // search completes. + tree.on('clear_search.jstree', function(event, data) { + search_length = search_running_value.length; + search_is_running = false; + search_running_value = ''; + if (!search_is_queued && search_length == 0) { + $throbber.remove(); + tree.css('opacity', 1); + tree.show(); + $no_results_message.remove(); + } + }); + } + } + // Build the tree tree.jstree(options); + var form = tree.parents('form'); if (form.length) { // Sync. tree-node selection to hidden fields in parent form. var inputName = tree.data('name'); tree.on('changed.jstree', function (e, data) { - form.find('input.selected-node').remove(); + tree.nextAll('input.selected-node').remove(); for(i = 0, j = data.selected.length; i < j; i++) { var node = data.instance.get_node(data.selected[i]); + // Allow individual tree elements to use a different form + // input name by providing a data-name property of their + // own (for example, this allows custom code to have + // items from two different Drupal fields displayed in + // the same tree). + if (node.li_attr && node.li_attr['data-name']) { + // It would be cleaner to use $node.data('name') here + // (where $node is the full jQuery object obtained by + // passing a parameter to data.instance.get_node() + // above to tell it to return a jQuery object). But + // jsTree seems to have a bug where it only returns the + // jQuery object if the selected element is visible on + // the form (which it might not be, since its parent + // might be currently closed). So this less clean + // method is used instead. + var elementInputName = node.li_attr['data-name']; + } + else { + var elementInputName = inputName; + } $('').attr({ type: 'hidden', - name: inputName + '[' + i + ']', + name: elementInputName + '[]', value: node.id, 'class': 'selected-node' }).insertAfter(tree); @@ -49,9 +184,11 @@ }); } }, - detach: function (context) { - // Destroy any (automatically created) jsTree in the detached context - if ($.isPlainObject(Drupal.settings.jstree)) { + detach: function (context, settings, trigger) { + // Destroy any (automatically created) jsTree in the detached context. + // Only do this if the tree was actually removed from the DOM, not for + // other detach events such as the Ajax "serialize" event. + if (trigger == 'unload' && $.isPlainObject(Drupal.settings.jstree)) { $.each(Drupal.settings.jstree, function (id, options) { if (Drupal.settings.jstree.hasOwnProperty(id)) { var tree = $('#'+id, context); diff --git a/jquery_jstree.module b/jquery_jstree.module index 276350a..9315fd6 100644 --- a/jquery_jstree.module +++ b/jquery_jstree.module @@ -50,6 +50,7 @@ function jquery_jstree_element_info() { '#attributes' => array(), '#tree_options' => array(), '#input' => TRUE, + '#value_callback' => 'jquery_jstree_value_callback', '#process' => array('ajax_process_form'), '#theme_wrappers' => array('form_element'), ); @@ -57,6 +58,21 @@ function jquery_jstree_element_info() { } /** + * Element value callback for a jstree form element. + */ +function jquery_jstree_value_callback($element, $input = FALSE) { + // When there is no input, this function must return something other than + // NULL, since NULL would cause the form API to use default value handling + // (which would make it impossible to remove all selections from a jstree + // form element that has already been saved with some items selected). + // Therefore, an empty array is used instead. This is similar to what the + // form_type_checkbox_value() value callback does in Drupal core. + if (!isset($input)) { + return array(); + } +} + +/** * Pre-render callback for jsTree element. * * @param array $element @@ -74,9 +90,38 @@ function jquery_jstree_element_pre_render($element) { if (empty($element['#options']['plugins']) || !is_array($element['#options']['plugins'])) { $element['#tree_options']['plugins'] = empty($element['#tree_options']['plugins']) ? array() : $element['#tree_options']['plugins']; } - // Pass default value to JavaScript settings - if (!empty($element['#default_value'])) { - $element['#tree_options']['core']['data']['default_value'] = $element['#default_value']; + // Add a search field if the search plugin is in use. + if (in_array('search', $element['#tree_options']['plugins'], TRUE)) { + $element['jstree_search'] = array( + // This is based closely on the definition of the 'textfield' element in + // system_element_info(), but with several changes (such as setting + // #input to FALSE) so that this isn't processed like a normal form + // element, and other changes to make it display correctly as a search + // field. + '#theme' => 'textfield', + '#theme_wrappers' => array('form_element'), + '#title' => t('Search'), + '#title_display' => 'invisible', + '#attributes' => array( + 'placeholder' => t('Type to search, or choose items below'), + 'class' => array('jstree-search-input'), + ), + // This uses a similar method for generating the ID as form_builder() + // does. + '#id' => drupal_html_id('edit-' . implode('-', array_merge($element['#parents'], array('jstree_search')))), + '#input' => FALSE, + '#size' => 45, + '#maxlength' => 128, + '#autocomplete_path' => FALSE, + '#process' => array(), + ); + // Identify the search field so the client-side code can find it. + $element['#tree_options']['drupal']['search_field_id'] = $element['jstree_search']['#id']; + } + // Pass the element's current value to the JavaScript settings, but don't set + // this if it was already set by code that ran earlier. + if (!isset($element['#tree_options']['core']['data']['default_value'])) { + $element['#tree_options']['core']['data']['default_value'] = $element['#value']; } // Attach JavaScript settings, used by our behavior to initialize the tree. $element['#attached']['js'][] = array( diff --git a/modules/jstree_taxonomy/jstree_taxonomy.module b/modules/jstree_taxonomy/jstree_taxonomy.module index 1f0399f..0323d3c 100644 --- a/modules/jstree_taxonomy/jstree_taxonomy.module +++ b/modules/jstree_taxonomy/jstree_taxonomy.module @@ -33,6 +33,7 @@ function jstree_taxonomy_field_widget_settings_form($field, $instance) { 'checkbox' => t('Checkbox'), 'wholerow' => t('Wholerow'), 'sort' => t('Sort'), + 'search' => t('Search'), ), '#description' => t('This is not a complete list of jsTree plugins but all these can be enabled with simple toggle and further configurations are not required.'), ); @@ -63,6 +64,13 @@ function jstree_taxonomy_field_widget_form(&$form, &$form_state, $field, $instan 'url' => url('taxonomy/jstree/load/' . $vocabulary->vid), ), ), + // These settings will be ignored if the search plugin is not enabled. + 'search' => array( + 'show_only_matches' => TRUE, + 'ajax' => array( + 'url' => url('taxonomy/jstree/search/' . $vocabulary->vid), + ), + ), ), ); return $element; @@ -83,6 +91,12 @@ function jstree_taxonomy_validate($element, &$form_state) { * Implements hook_menu() */ function jstree_taxonomy_menu() { + $items['taxonomy/jstree/search/%taxonomy_vocabulary'] = array( + 'page callback' => 'jstree_taxonomy_search', + 'page arguments' => array(3), + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); $items['taxonomy/jstree/load/%taxonomy_vocabulary'] = array( 'page callback' => 'jstree_taxonomy_options', 'page arguments' => array(3, 4), @@ -93,6 +107,102 @@ function jstree_taxonomy_menu() { } /** + * jsTree callback to search a taxonomy vocabulary. + * + * The function prints JSON output representing the parent terms that need to + * be opened to find terms that match the search string. + * + * @param stdClass $vocabulary + * The taxonomy vocabulary to search within. + */ +function jstree_taxonomy_search($vocabulary) { + $query = drupal_get_query_parameters(); + drupal_json_output(jstree_taxonomy_search_result_parents_from_query($vocabulary, $query)); +} + +/** + * Returns the parent term IDs that need to be opened for a jsTree search. + * + * @param $vocabulary + * The taxonomy vocabulary object representing the vocabulary that is being + * searched. + * @param $query + * An array of jsTree parameters including at least 'str' (which specifies + * the string being searched for). Typically drupal_get_query_parameters() + * should be passed in for this parameter (to use what the jsTree Ajax + * request asked for) but advanced use cases can modify the query before + * passing it in. + * @param $results_exist + * (Optional) An already-defined variable that will be altered by reference + * and that can be used to determine if search results exist. After the + * function completes, this will be FALSE if no search results exist for the + * given search string, or TRUE if they do. This can be useful since there is + * no other way to distinguish a search that has no results from one whose + * results are all top-level terms; in both cases there are no parents that + * need to be opened to reveal the results, so the function's return value is + * the same. + * + * @return + * An array of term IDs that need to be opened to reveal terms underneath + * which appear in the search results. + */ +function jstree_taxonomy_search_result_parents_from_query($vocabulary, $query, &$results_exist = NULL) { + $tids_to_open = array(); + $results_exist = FALSE; + + if (isset($query['str'])) { + $search_string = trim(drupal_strtolower($query['str'])); + if ($search_string !== '') { + // This query is based on the one in taxonomy_autocomplete(), but done as + // an EntityFieldQuery so that it works more accurately in scenarios + // where the term title isn't necessarily stored in {taxonomy_term_data} + // (for example, if modules such as Taxonomy Revision and CPS are in + // use). + $entity_query = new EntityFieldQuery(); + $entity_query->addTag('translatable'); + $entity_query->addTag('term_access'); + $result = $entity_query->entityCondition('entity_type', 'taxonomy_term') + ->entityCondition('bundle', $vocabulary->machine_name) + ->propertyCondition('name', '%' . db_like($search_string) . '%', 'LIKE') + ->execute(); + $tids = isset($result['taxonomy_term']) ? array_keys($result['taxonomy_term']) : array(); + if ($tids) { + $results_exist = TRUE; + // Collect the term IDs of all parents that need to be opened to find + // each term. The parents are determined from taxonomy_get_tree() + // rather than taxonomy_get_parents_all() because this avoids doing a + // full entity load for all the terms and consequently has + // substantially better performance. + $term_parents_by_tid = array(); + foreach (taxonomy_get_tree($vocabulary->vid) as $term) { + $term_parents_by_tid[$term->tid] = array_filter($term->parents); + } + foreach ($tids as $tid) { + if (!empty($term_parents_by_tid[$tid])) { + $parents = $term_parents_by_tid[$tid]; + while ($parents) { + // Add each parent to the list of term IDs to open, then add its + // parents (if any) to the list of parents to check. + $parent = array_shift($parents); + $tids_to_open[] = $parent; + if (!empty($term_parents_by_tid[$parent])) { + $parents = array_merge($parents, $term_parents_by_tid[$parent]); + } + } + } + } + } + } + } + + // Return a unique set of term IDs that need to be opened. The array_values() + // call is important here, in order to make sure drupal_json_output() doesn't + // include array keys in the output (which jsTree will not be able to + // interpret). + return array_values(array_unique($tids_to_open)); +} + +/** * jstree options json callback * * @param $vocabulary @@ -103,7 +213,27 @@ function jstree_taxonomy_menu() { */ function jstree_taxonomy_options($vocabulary) { $query = drupal_get_query_parameters(); + $options = jstree_taxonomy_options_from_query($vocabulary, $query); + drupal_json_output($options); +} +/** + * Returns an array of jsTree options for part of a taxonomy vocabulary tree. + * + * @param $vocabulary + * The taxonomy vocabulary object. + * @param $query + * An array of jsTree parameters including at least 'id' (specifying the + * parent term ID, if any, whose children should be returned) and potentially + * also including 'default_value' (an array of term IDs that are currently + * selected). Typically drupal_get_query_parameters() should be passed in for + * this parameter (to use what the jsTree Ajax request asked for) but + * advanced use cases can modify the query before passing it in. + * + * @return + * An array of options in the format expected by jsTree. + */ +function jstree_taxonomy_options_from_query($vocabulary, $query) { // parent is the node of the tree that is about to get opened. $parent = 0; if (is_numeric($query['id'])) { @@ -129,12 +259,14 @@ function jstree_taxonomy_options($vocabulary) { } } $options = array(); - foreach (taxonomy_get_tree($vocabulary->vid, $parent, 1) as $term) { - $children = !empty(taxonomy_get_tree($vocabulary->vid, $term->tid, 1)); + foreach (taxonomy_get_tree($vocabulary->vid, $parent, 1, TRUE) as $term) { + $children = taxonomy_get_tree($vocabulary->vid, $term->tid); + $has_children = (bool) $children; + $options[] = array( 'id' => $term->tid, 'text' => entity_label('taxonomy_term', $term), - 'children' => $children, + 'children' => $has_children, 'state' => array( 'opened' => isset($parents[$term->tid]), 'disabled' => FALSE, @@ -142,5 +274,6 @@ function jstree_taxonomy_options($vocabulary) { ), ); } - drupal_json_output($options); + + return $options; }