PageSourceSearch

https://railsblocks.com/assets/controllers/datatable_controller-f8…4f4bff7a1fbafae2904c98bcdfe48d5ca6fa393c.js

js railsblocks.com collected 2026-09-25 20:23:04 UTC 6,112 bytes, 187 lines download raw bytes

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.