1import { Controller } from "@hotwired/stimulus"; 2 3// Handles server-side sorting for data tables via Turbo Frames. 4// Clicking a sortable column header navigates to a URL with sort params, 5// and Turbo replaces the frame with sorted content from the server. 6export default class extends Controller { 7 static targets = ["header", "filterForm"]; 8 9 static values = { 10 frame: { type: String, default: "" }, // Turbo Frame ID for navigation. If empty, falls back to full page reload. 11 sortColumn: { type: String, default: "" }, // Currently sorted column name (used for toggle logic) 12 sortDirection: { type: String, default: "asc" }, // Current sort direction ("asc" or "desc"). Default is "asc" to match the default sort direction in the Rails controller. 13 columnParam: { type: String, default: "column" }, // URL parameter name for the sort column 14 directionParam: { type: String, default: "direction" }, // URL parameter name for the sort direction 15 }; 16 17 connect() { 18 this.isSorting = false; 19 this.unlockTimeout = null; 20 } 21 22 disconnect() { 23 this.clearUnlockFallback(); 24 } 25 26 // Sort by the clicked column header 27 sort(event) { 28 if (this.isSorting) { 29 event.preventDefault(); 30 return; 31 } 32 33 const header = event.currentTarget; 34 const column = header.dataset.column; 35 36 if (!column) return; 37 38 // Toggle direction if clicking the same column 39 const direction = this.sortColumnValue === column && this.sortDirectionValue === "asc" ? "desc" : "asc"; 40 41 // Prevent repeated sort requests while this one is in flight 42 this.lockSorting(); 43 44 // Show loading spinner 45 this.showLoading(header); 46 47 // Navigate via Turbo Frame or full page 48 this.navigate(column, direction); 49 } 50 51 // Show loading spinner on the clicked header 52 showLoading(header) { 53 const indicator = header.querySelector("[data-datatable-target='indicator']"); 54 const loader = header.querySelector("[data-datatable-target='loader']"); 55 const sortButton = header.querySelector("button[type='button']"); 56 57 if (indicator) indicator.classList.add("hidden"); 58 if (loader) loader.classList.remove("hidden"); 59 if (sortButton) sortButton.disabled = true; 60 } 61 62 hideAllLoadingStates() { 63 this.headerTargets.forEach((header) => { 64 const indicator = header.querySelector("[data-datatable-target='indicator']"); 65 const loader = header.querySelector("[data-datatable-target='loader']"); 66 const sortButton = header.querySelector("button[type='button']"); 67 68 if (indicator) indicator.classList.remove("hidden"); 69 if (loader) loader.classList.add("hidden"); 70 if (sortButton) sortButton.disabled = false; 71 }); 72 } 73 74 setHeadersDisabled(disabled) { 75 this.headerTargets.forEach((header) => { 76 header.classList.toggle("pointer-events-none", disabled); 77 header.classList.toggle("opacity-70", disabled); 78 header.setAttribute("aria-disabled", disabled ? "true" : "false"); 79 }); 80 } 81 82 lockSorting() { 83 this.isSorting = true; 84 this.setHeadersDisabled(true); 85 this.startUnlockFallback(); 86 } 87 88 unlockSorting() { 89 this.isSorting = false; 90 this.setHeadersDisabled(false); 91 this.hideAllLoadingStates(); 92 this.clearUnlockFallback(); 93 } 94 95 startUnlockFallback() { 96 this.clearUnlockFallback(); 97 98 // Fallback unlock so the table doesn't stay locked if Turbo events fail. 99 this.unlockTimeout = setTimeout(() => { 100 this.unlockSorting(); 101 }, 10000); 102 } 103 104 clearUnlockFallback() { 105 if (this.unlockTimeout) { 106 clearTimeout(this.unlockTimeout); 107 this.unlockTimeout = null; 108 } 109 } 110 111 attachFrameUnlockListeners(frame) { 112 const unlock = () => { 113 frame.removeEventListener("turbo:frame-load", unlock); 114 frame.removeEventListener("turbo:fetch-request-error", unlock); 115 this.unlockSorting(); 116 }; 117 118 frame.addEventListener("turbo:frame-load", unlock); 119 frame.addEventListener("turbo:fetch-request-error", unlock); 120 } 121 122 // Navigate to sorted URL 123 navigate(column, direction) { 124 const url = new URL(window.location.href); 125 126 // If there's a filter form, include all its current values in the URL 127 // This preserves filters when sorting (form submission doesn't update browser URL) 128 if (this.hasFilterFormTarget) { 129 const formData = new FormData(this.filterFormTarget); 130 131 // Clear existing filter params that might be stale in the URL 132 // but keep non-form params (like page params from pagination) 133 const formFieldNames = new Set(); 134 for (const [key] of formData.entries()) { 135 formFieldNames.add(key); 136 } 137 138 // Remove old values for form fields 139 for (const name of formFieldNames) { 140 url.searchParams.delete(name); 141 } 142 143 // Add current form values 144 for (const [key, value] of formData.entries()) { 145 if (value !== "" && value !== null) { 146 url.searchParams.append(key, value); 147 } 148 } 149 } 150 151 // Set the sort parameters (these will override any from the form's hidden fields) 152 url.searchParams.set(this.columnParamValue, column); 153 url.searchParams.set(this.directionParamValue, direction); 154 155 if (this.frameValue) { 156 const frame = document.getElementById(this.frameValue); 157 if (frame) { 158 this.attachFrameUnlockListeners(frame); 159 frame.src = url.toString(); 160 return; 161 } 162 } 163 164 window.location.href = url.toString(); 165 } 166} 167 168/* 169 * Usage: 170 * <div data-controller="datatable" 171 * data-datatable-frame-value="my-table-frame" 172 * data-datatable-sort-column-value="name" 173 * data-datatable-sort-direction-value="asc"
174 * data-datatable-column-param-value="sort_column" 175 * data-datatable-direction-param-value="sort_direction"> 176 * 177 * <!-- Optional: filter form to preserve filter values when sorting --> 178 * <form data-datatable-target="filterForm">...</form> 179 * 180 * <th data-datatable-target="header" 181 * data-column="name" 182 * data-action="click->datatable#sort"> 183 * Name 184 * <%= render "shared/sort_indicator", ... %> 185 * </th> 186 * </div> 187 */;
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.