PageSourceSearch

https://www.ojp.gov/modules/custom/ojp_datatables/js/ojp_datatables.js?v=1.x

js ojp.gov collected 2026-09-24 07:33:36 UTC 19,431 bytes, 613 lines download raw bytes

1/**
2 * @file
3 * OJP Datatables JS.
4 */
5
6(function ($, Drupal) {
7  "use strict";
8  Drupal.behaviors.ojpDatatables = {
9    attach: function (context, settings) {
10      $(once("ojpDatatables", "table.datatable", context)).each(function () {
11        // Set the table width to 100%.
12        $(this).css("width", "100%");
13
14        // Store table reference for use in observer
15        const $table = $(this);
16
17        // Validate table structure before initializing DataTables
18        // Ensure table has proper thead/tbody structure
19        if (!$table.find("thead").length) {
20          // If no thead, create one from the first row
21          const $firstRow = $table.find("tr:first");
22          if ($firstRow.length) {
23            const $thead = $("<thead></thead>");
24            $firstRow.find("td").each(function() {
25              const $td = $(this);
26              const $th = $("<th></th>").html($td.html());
27
28              // Preserve the hidden-column class if present
29              if ($td.hasClass('hidden-column')) {
30                $th.addClass('hidden-column');
31              }
32
33              // Preserve any other classes
34              const tdClasses = $td.attr('class');
35              if (tdClasses) {
36                $th.addClass(tdClasses);
37              }
38
39              // Preserve data attributes (like data-has-facet)
40              $.each($td.data(), function(key, value) {
41                $th.attr('data-' + key, value);
42              });
43
44              $td.replaceWith($th);
45            });
46            $thead.append($firstRow);
47            $table.prepend($thead);
48          }
49        }
50
51        if (!$table.find("tbody").length) {
52          // If no tbody, wrap all non-thead rows in tbody
53          const $tbody = $("<tbody></tbody>");
54          $table.find("tr").not($table.find("thead tr")).appendTo($tbody);
55          $table.append($tbody);
56        }
57
58        // Calculate actual column count accounting for colspan
59        const $headerRow = $table.find("thead tr:first");
60        let expectedColumns = 0;
61        $headerRow.find("th, td").each(function() {
62          const colspan = parseInt($(this).attr("colspan")) || 1;
63          expectedColumns += colspan;
64        });
65
66        // Validate all body rows have consistent column counts
67        const $bodyRows = $table.find("tbody tr");
68        let hasInconsistentColumns = false;
69
70        $bodyRows.each(function() {
71          let rowColumns = 0;
72          $(this).find("td, th").each(function() {
73            const colspan = parseInt($(this).attr("colspan")) || 1;
74            rowColumns += colspan;
75          });
76
77          if (rowColumns !== expectedColumns) {
78            hasInconsistentColumns = true;
79          }
80        });
81
82        if (hasInconsistentColumns) {
83          // Surface this so future regressions of this class are visible in
84          // devtools rather than silently failing to initialize.
85          console.warn(
86            "[ojp_datatables] Skipping DataTables init: inconsistent column counts.",
87            { table: $table[0], expectedColumns, classes: $table.attr("class") }
88          );
89          return;
90        }
91
92        // Create a mutation observer to watch for data-facets attribute changes
93        const facetsObserver = new MutationObserver(function (mutations) {
94          mutations.forEach(function (mutation) {
95            if (
96              mutation.type === "attributes" &&
97              mutation.attributeName === "data-facets"
98            ) {
99              const hasFacets = $table.attr("data-facets") === "true";
100              const $existingFacets = $table.prev(".table-facets");
101
102              if (hasFacets && !$existingFacets.length) {
103                createTableFacets($table);
104              } else if (!hasFacets && $existingFacets.length) {
105                $existingFacets.remove();
106              }
107            }
108          });
109        });
110
111        // Start observing the table for data-facets changes
112        facetsObserver.observe(this, {
113          attributes: true,
114          attributeFilter: ["data-facets"],
115        });
116
117        // Initial facets setup
118        if ($table.attr("data-facets") === "true") {
119          createTableFacets($table);
120        }
121
122        // Configure DataTable options.
123        var dataTableOptions = {
124          responsive: true,
125          paging: $(this).hasClass("datatablePagination") ? true : false,
126          searching: $(this).hasClass("datatableSearch") ? true : false,
127          // Set DOM layout when pagination is at the top.
128          dom: $(this).hasClass("table--pagination-top")
129            ? '<"top"lifp><"clear">rt<"clear"><"bottom"ip><"clear">'
130            : '<"top"lif>rt<"clear"><"bottom"ip><"clear">',
131          // Include hidden columns in search
132          columnDefs: [
133            {
134              targets: "_all",
135              searchable: true,
136              className: "dt-searchable",
137            },
138          ],
139          // Ensure search works on hidden columns
140          search: {
141            smart: true,
142            searchable: true,
143            search: "",
144          },
145        };
146
147        // Call special condition function for tables with the class 'table--related-topics'
148        if ($(this).hasClass("table--related-topics")) {
149          handleRelatedTopicsTable($(this));
150        } else if ($(this).hasClass("table--outcomes-rbo")) {
151          handleProgramOutcomeRBOTable($(this));
152        } else if ($(this).hasClass("table--outcome-list")) {
153          handleProgramOutcomeListTable($(this));
154        } else {
155          // Initialize DataTable with the configured options.
156          const dataTable = $(this).DataTable(dataTableOptions);
157
158          // After initialization, hide columns with hidden-column class using DataTables API
159          $table.find('thead th').each(function(index) {
160            if ($(this).hasClass('hidden-column')) {
161              dataTable.column(index).visible(false);
162            }
163          });
164        }
165      });
166      // Handle containers with related topics.
167      $(once("ojpDatatables", ".container--related-topics", context)).each(
168        function () {
169          handleRelatedTopicsContainer($(this));
170        }
171      );
172      $(once("ojpDatatables", "body")).each(function () {
173        var $window = $(window);
174        var $document = $(document);
175        var scrollFromBottom;
176        var scrollTimer;
177
178        // Function to calculate the current scroll percentage
179        function calculateScrollPercent() {
180          return (
181            (100 * $window.scrollTop()) /
182            ($document.height() - $window.height())
183          );
184        }
185
186        function updateScrollFromBottom() {
187          scrollFromBottom = $document.height() - $window.scrollTop();
188        }
189
190        // Initialize scrollFromBottom
191        updateScrollFromBottom();
192
193        $window.scroll(function () {
194          clearTimeout(scrollTimer);
195          scrollTimer = setTimeout(function () {
196            if (calculateScrollPercent() > 30) {
197              updateScrollFromBottom();
198            }
199          }, 250);
200        });
201
202        $document.on("click", "a.paginate_button", function () {
203          if (calculateScrollPercent() > 30) {
204            $window.scrollTop($document.height() - scrollFromBottom);
205          }
206        });
207      });
208    },
209  };
210
211  /**
212   * Function to handle tables with the class 'table--related-topics'.
213   *
214   * @param {jQuery} $table
215   *   The table element.
216   */
217  function handleRelatedTopicsTable($table) {
218    // Configure DataTable options.
219    let dataTableOptions = {
220      responsive: true,
221      // Set DOM layout when pagination is at the top.
222      dom: '<"top"pli><"clear">rt<"clear"><"bottom"ip><"clear">',
223      order: [],
224      columnDefs: [
225        {
226          targets: 1,
227          className: "second-column-programs",
228          width: "200px",
229        },
230      ],
231    };
232
233    // Initialize DataTable with the configured options.
234    const dataTable = $table.DataTable(dataTableOptions);
235
236    // After initialization, hide columns with hidden-column class using DataTables API
237    $table.find('thead th').each(function(index) {
238      if ($(this).hasClass('hidden-column')) {
239        dataTable.column(index).visible(false);
240      }
241    });
242  }
243
244  /**
245   * Function to handle tables with the class 'table--outcomes-rbo'.
246   *
247   * @param {jQuery} $table
248   *   The table element.
249   */
250  function handleProgramOutcomeListTable($table) {
251    // Configure DataTable options.
252    let dataTableOptions = {
253      responsive: true,
254      // Set DOM layout when pagination and search are at the top.
255      dom: '<"top"pi><"clear">rt<"clear"><"bottom"ip><"clear">',
256      order: [],
257      // Add a caption to the table with Drupal's translation system.
258      initComplete: function (settings, json) {
259        let captionText = Drupal.t(
260          "Specific Outcomes (select to review details for each outcome)"
261        );
262        $table.prepend("<caption>
262" + captionText + "</caption>");
263      },
264    };
265
266    // Initialize DataTable with the configured options.
267    const dataTable = $table.DataTable(dataTableOptions);
268
269    // After initialization, hide columns with hidden-column class using DataTables API
270    $table.find('thead th').each(function(index) {
271      if ($(this).hasClass('hidden-column')) {
272        dataTable.column(index).visible(false);
273      }
274    });
275  }
276
277  /**
278   * Function to handle tables with the class 'table--outcomes-rbo'.
279   *
280   * @param {jQuery} $table
281   *   The table element.
282   */
283  function handleProgramOutcomeRBOTable($table) {
284    // Configure DataTable options.
285    let dataTableOptions = {
286      responsive: {
287        details: {
288          type: "column",
289          target: 0,
290        },
291      },
292      // Pagination is disabled: RBO programs have a small, bounded number of
293      // outcomes (typically well under 15), so all rows are shown on one page.
294      paging: false,
295      dom: '<"top"i><"clear">rt<"clear"><"bottom"i><"clear">',
296      order: [],
297      columnDefs: [
298        {
299          targets: -1,
300          className: "none",
301          responsivePriority: 1,
302        },
303        {
304          targets: 0,
305          className: "dtr-control",
306          orderable: false,
307        },
308        {
309          targets: -2,
310          className: "desktop",
311          responsivePriority: 2,
312        },
313        {
314          targets: -3,
315          className: "desktop tablet",
316          responsivePriority: 3,
317        },
318      ],
319      initComplete: function (settings, json) {
320        let captionText = Drupal.t(
321          "Specific Outcomes (select to review details for each outcome)"
322        );
323        $table.prepend("<caption>" + captionText + "</caption>");
324      },
325    };
326    // Initialize DataTable with the configured options.
327    const dataTable = $table.DataTable(dataTableOptions);
328
329    // After initialization, hide columns with hidden-column class using DataTables API
330    $table.find('thead th').each(function(index) {
331      if ($(this).hasClass('hidden-column')) {
332        dataTable.column(index).visible(false);
333      }
334    });
335  }
336
337  /**
338   * Function to handle the container with the class 'container--related_topics'.
339   *
340   * @param {jQuery} $container
341   *   The container element.
342   */
343  function handleRelatedTopicsContainer($container) {
344    // Find #appliedfilters and .download-csv within the container and detach them
345    let $appliedFilters = $container.find("#appliedfilters").hide().detach();
346    let $downloadCsv = $container
347      .find(".download-csv")
348      .css({
349        float: "right",
350        "margin-top": "0.75rem",
351      })
352      .hide()
353      .detach();
354
355    let intervalId = setInterval(function () {
356      let $topElement = $container.find(".dataTables_wrapper .top");
357      if ($topElement.length) {
358        let $dataTablesLength = $topElement.find("div.dataTables_length");
359
360        if ($appliedFilters.length) {
361          $topElement.prepend($appliedFilters.show());
362        }
363
364        if ($downloadCsv.length && $dataTablesLength.length) {
365          $dataTablesLength.after($downloadCsv.show());
366        } else if ($downloadCsv.length) {
367          $topElement.append($downloadCsv.show());
368        }
369
370        clearInterval(intervalId);
371      }
372    }, 100);
373
374    // Stop checking after 5 seconds
375    setTimeout(function () {
376      clearInterval(intervalId);
377    }, 5000);
378  }
379
380  /**
381   * Creates dropdown facets for each column in the table.
382   *
383   * @param {jQuery} $table
384   *   The table element.
385   */
386  function createTableFacets($table) {
387    // Get the first row of headers
388    const $headers = $table.find("thead tr:first-child th");
389
390    // Generate unique ID for this table's accordion
391    const accordionId = `table-facets-${Math.random()
392      .toString(36)
393      .substr(2, 9)}`;
394
395    // Create the accordion structure
396    const facetsContainer = $("<div>", {
397      class:
398        "table-facets views-exposed-form usa-accordion js-accordion-container-modified",
399      "data-drupal-selector": "views-exposed-form-awards-awards-list-block",
400      "aria-label": "accordion-group",
401    });
402
403    // Create the accordion heading and button
404    const accordionHeading = $("<h2>", {
405      class: "usa-accordion__heading js-accordion-heading-modified",
406    });
407
408    const accordionDiv = $("<div>", {
409      class: "usa-accordion",
410    });
411
412    const accordionButton = $("<button>", {
413      class: "usa-accordion__button",
414      "aria-expanded": "true",
415      "aria-controls": accordionId,
416      type: "button",
417      "data-once": "ojpAccordion",
418      text: "Use Search Filters",
419    });
420
421    const accordionContent = $("<div>", {
422      id: accordionId,
423      class: "usa-accordion__content usa-prose ui-widget-content",
424    });
425
426    // Position the facets container above the table
427    $table.before(facetsContainer);
428
429    // Build the accordion structure
430    accordionDiv.append(accordionButton);
431    accordionDiv.append(accordionContent);
432    accordionHeading.append(accordionDiv);
433    facetsContainer.append(accordionHeading);
434
435    // Create a dropdown for each column that has facets enabled
436    $headers.each(function (columnIndex) {
437      // Check if any cell in this column has facets enabled (including hidden columns)
438      const hasFacets =
439        $table.find(
440          `tbody tr td:nth-child(${columnIndex + 1})[data-has-facet="true"]`
441        ).length > 0;
442      if (!hasFacets) {
443        return;
444      }
445
446      const columnHeader = $(this).text().trim();
447      const uniqueValues = new Set();
448      const orderedValues = [];
449
450      // Get all values from this column (including hidden cells)
451      $table
452        .find(`tbody tr td:nth-child(${columnIndex + 1})`)
453        .each(function () {
454          const value = $(this).text().trim();
455          if (!uniqueValues.has(value)) {
456            uniqueValues.add(value);
457            orderedValues.push(value);
458          }
459        });
460
461      // Create the dropdown
462      const selectId = `facet-select-${columnIndex}`;
463      const select = $("<select>", {
464        class: "usa-select facet-select",
465        "data-column": columnIndex,
466        id: selectId,
467      });
468
469      // Add the "Any" option
470      select.append(
471        $("<option>", {
472          value: "",
473          text: "Any",
474        })
475      );
476
477      // Add all values in their original order
478      orderedValues.forEach((value) => {
479        select.append(
480          $("<option>", {
481            value: value,
482            text: value,
483          })
484        );
485      });
486
487      // Create a label for the dropdown
488      const label = $("<label>", {
489        class: "facet-label",
490        text: columnHeader,
491        for: selectId,
492      });
493
494      // Create a wrapper div for positioning
495      const wrapper = $("<div>", {
496        class: "facet-wrapper",
497        "data-column": columnIndex,
498      })
499        .append(label)
500        .append(select);
501
502      // If the column is hidden, initially hide the facet wrapper
503      if ($(this).hasClass("hidden-column")) {
504        wrapper.addClass("hidden-facet");
505      }
506
507      // Append the wrapper to the accordion content
508      accordionContent.append(wrapper);
509
510      // Handle change event
511      select.on("change", function () {
512        // Get reference to the DataTable instance
513        const dataTable = $table.DataTable();
514
515        // Get all current filter values
516        const filters = {};
517        accordionContent.find("select.facet-select").each(function () {
518          const value = $(this).val();
519          const column = $(this).data("column");
520          if (value !== "") {
521            filters[column] = value;
522          }
523        });
524
525        // Clear any existing custom filtering function
526        $.fn.dataTable.ext.search.pop();
527
528        // Add custom filtering function if we have filters
529        if (Object.keys(filters).length > 0) {
530          $.fn.dataTable.ext.search.push(function (settings, data, dataIndex) {
531            // Only apply to this specific table
532            if (settings.nTable !== $table[0]) {
533              return true;
534            }
535
536            // Check each filter
537            return Object.entries(filters).every(([column, value]) => {
538              // Get the text value from the cell (data array is 0-indexed, columns are 1-indexed)
539              const cellValue = data[parseInt(column)].trim();
540              return cellValue === value;
541            });
542          });
543        }
544
545        // Redraw the table with the filters applied
546        dataTable.draw();
547      });
548    });
549
550    // Only add Clear Filters button if we have any facets
551    if (accordionContent.find(".facet-wrapper").length > 0) {
552      const clearFiltersWrapper = $("<div>", {
553        class: "clear-filters-wrapper",
554      });
555
556      const clearFiltersButton = $("<button>", {
557        class: "usa-button clear-filters-button",
558        text: "Clear Filters",
559      });
560
561      clearFiltersButton.on("click", function () {
562        // Get reference to the DataTable instance
563        const dataTable = $table.DataTable();
564
565        // Reset all select elements to their default "Any" option
566        accordionContent.find("select.facet-select").each(function () {
567          $(this).val("");
568        });
569
570        // Clear any filtering function
571        $.fn.dataTable.ext.search.pop();
572
573        // Redraw the table
574        dataTable.draw();
575      });
576
577      clearFiltersWrapper.append(clearFiltersButton);
578      accordionContent.append(clearFiltersWrapper);
579    }
580
581    // Update the mutation observer to handle hidden facets
582    const observer = new MutationObserver(function (mutations) {
583      mutations.forEach(function (mutation) {
584        if (
585          mutation.type === "attributes" &&
586          mutation.attributeName === "class"
587        ) {
588          const $element = $(mutation.target);
589          const columnIndex = $element.index();
590          const $facet = accordionContent.find(
591            `.facet-wrapper[data-column="${columnIndex}"]`
592          );
593
594          if ($element.hasClass("hidden-column")) {
595            // Add hidden-facet class if the column is hidden
596            $facet.addClass("hidden-facet");
597          } else {
598            // Remove hidden-facet class if the column is visible
599            $facet.removeClass("hidden-facet");
600          }
601        }
602      });
603    });
604
605    // Observe all th elements for class changes
606    $headers.each(function () {
607      observer.observe(this, {
608        attributes: true,
609        attributeFilter: ["class"],
610      });
611    });
612  }
613})(jQuery, Drupal);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.