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.