1import ApplicationController from "mensa/controllers/application_controller"; 2import { get, post, patch } from "@rails/request.js"; 3 4export default class TableComponentController extends ApplicationController { 5 static targets = [ 6 "controlBar", 7 "filterList", 8 "views", 9 "view", 10 "scrollContainer", 11 "turboFrame", 12 "saveViewDialog", 13 "saveViewName", 14 "saveViewDescription", 15 "exportDialog", 16 "exportIcon", 17 "saveResetButtons", 18 "saveDropdown", 19 "saveSimple", 20 "saveSplit", 21 "eyeButton", 22 ]; 23 static outlets = ["mensa-filter-pill-list", "mensa-column-customizer"]; 24 static values = { 25 supportsViews: Boolean, 26 tableUrl: String, 27 saveViewUrl: String, 28 viewsUrl: String, 29 exportsUrl: String, 30 }; 31 32 connect() { 33 super.connect(); 34 35 this.frameLoadFallback = setTimeout(() => this.loadFrame(), 100); 36 this.scrollLeft = 0; 37 } 38 39 disconnect() { 40 if (this.frameLoadFallback) { 41 clearTimeout(this.frameLoadFallback); 42 this.frameLoadFallback = null; 43 } 44 } 45 46 saveDropdownOutsideHandler(event) { 47 if ( 48 !this.hasSaveDropdownTarget || 49 this.saveDropdownTarget.classList.contains("hidden") 50 ) { 51 return; 52 } 53 54 const saveArea = this.saveDropdownTarget.closest(".relative"); 55 if (saveArea && !saveArea.contains(event.target)) { 56 this.saveDropdownTarget.classList.add("hidden"); 57 } 58 } 59 60 captureNavigation() { 61 if (!this.hasMensaFilterPillListOutlet) return; 62 if (!this.hasTurboFrameTarget) return; 63 const src = this.turboFrameTarget.getAttribute("src"); 64 if (!src) return; 65 this.mensaFilterPillListOutlet.captureNavigation(src); 66 } 67 68 beforeFrameRender() { 69 this.captureScrollPosition(); 70 this.isReplacingFrame = true; 71 } 72 73 scrollContainerTargetConnected(element) { 74 this.scrollContainer = this.scrollableElementFor(element); 75 this.restoreScrollPosition(); 76 this.scrollHandler = () => this.captureScrollPosition(); 77 this.scrollContainer.addEventListener("scroll", this.scrollHandler); 78 } 79 80 scrollContainerTargetDisconnected(element) { 81 const scrollContainer = 82 this.scrollContainer || this.scrollableElementFor(element); 83 if (this.scrollHandler && scrollContainer) { 84 scrollContainer.removeEventListener("scroll", this.scrollHandler); 85 } 86 this.scrollContainer = null; 87 } 88 89 captureScrollPosition(source = null) { 90 if (this.isReplacingFrame || this.isRestoringScroll) return; 91 const scrollContainer = this.scrollContainerFrom(source); 92 if (!scrollContainer) return; 93 this.scrollLeft = scrollContainer.scrollLeft; 94 } 95 96 restoreScrollPosition() { 97 const scrollContainer = this.scrollContainerFrom(); 98 if (!scrollContainer) return; 99 100 this.isRestoringScroll = true; 101 scrollContainer.scrollLeft = this.scrollLeft || 0; 102 requestAnimationFrame(() => { 103 this.isRestoringScroll = false; 104 this.isReplacingFrame = false; 105 }); 106 } 107 108 scrollContainerFrom(source = null) { 109 if (source?.currentTarget) { 110 return this.scrollableElementFor(source.currentTarget); 111 } 112 if (source?.scrollLeft !== undefined) return source; 113 if (this.scrollContainer) return this.scrollContainer; 114 if (this.hasScrollContainerTarget) { 115 return this.scrollableElementFor(this.scrollContainerTarget); 116 } 117 return null; 118 } 119 120 scrollableElementFor(element) { 121 if (!element) return null; 122 if (element.scrollWidth > element.clientWidth) return element; 123 124 return ( 125 Array.from(element.querySelectorAll("*")).find( 126 (candidate) => candidate.scrollWidth > candidate.clientWidth, 127 ) || element 128 ); 129 } 130 131 loadFrame() { 132 if (this.frameLoadHandled) return; 133 this.frameLoadHandled = true; 134 135 if (this.frameLoadFallback) { 136 clearTimeout(this.frameLoadFallback); 137 this.frameLoadFallback = null; 138 } 139 140 if (!this.hasTurboFrameTarget) return; 141 142 // When the filter-pill-list outlet is available, build the frame URL from the 143 // full saved state (filters, view, order, â¦) so that captureNavigation doesn't 144 // call persistView("") and wipe the active view from localStorage. 145 // This happens on back-navigation: the outlet is connected before loadFrame is 146 // called, but tableUrlValue was computed from the initial page URL which never 147 // carries table_view_id. 148 if (this.hasMensaFilterPillListOutlet) { 149 const outlet = this.mensaFilterPillListOutlet; 150 const state = { 151 query: outlet.loadQuery(), 152 view: outlet.loadView(), 153 order: outlet.loadOrder(), 154 page: outlet.loadPage(), 155 }; 156 if (outlet.hasPersistedFilters()) 157 state.filters = outlet.loadFilters(); 158 this.turboFrameTarget.setAttribute( 159 "src", 160 outlet.buildUrl(state).toString(), 161 ); 162 } else if (this.hasTableUrlValue) { 163 this.turboFrameTarget.setAttribute("src", this.tableUrlValue); 164 } 165 } 166 167 // --- Save/Reset button visibility --- 168 169 showSaveReset() { 170 if (this.hasSaveResetButtonsTarget) { 171 this.saveResetButtonsTarget.classList.remove("hidden"); 172 } 173 } 174 175 hideSaveReset() { 176 if (this.hasSaveResetButtonsTarget) { 177 this.saveResetButtonsTarget.classList.add("hidden"); 178 } 179 } 180 181 // Called whenever a filter or search changes so the save/reset buttons appear. 182 notifyUnsavedState() { 183 this._updateSaveButtonMode(); 184 this.showSaveReset(); 185 } 186 187 // --- Cancel / Reset --- 188 189 // Resets all filters, search, and column customisation to the active view's clean state. 190 cancelFiltersAndSearch(event) { 191 if (event) event.preventDefault(); 192 this.hideSaveReset(); 193 if (this.hasSaveDropdownTarget) 194 this.saveDropdownTarget.classList.add("hidden"); 195 196 if (this.hasMensaFilterPillListOutlet) { 197 this.mensaFilterPillListOutlet.clearFiltersAndSearch(); 198 } 199 if (this.hasMensaColumnCustomizerOutlet) { 200 this.mensaColumnCustomizerOutlet.resetToDefault(); 201 } 202 } 203 204 // --- Save --- 205 206 toggleSaveDropdown(event) { 207 event.preventDefault(); 208 event.stopPropagation(); 209 if (!this.hasSaveDropdownTarget) return; 210 const isHidden = this.saveDropdownTarget.classList.contains("hidden"); 211 this.saveDropdownTarget.classList.toggle("hidden");
212 if (isHidden) { 213 const rect = event.currentTarget.getBoundingClientRect(); 214 this.saveDropdownTarget.style.top = `${rect.bottom + 4}px`; 215 this.saveDropdownTarget.style.right = `${window.innerWidth - rect.right}px`; 216 this.saveDropdownTarget.style.left = "auto"; 217 } 218 } 219 220 // "Save as new view" â always opens the name dialog 221 saveAsNewView(event) { 222 if (event) event.preventDefault(); 223 if (this.hasSaveDropdownTarget) 224 this.saveDropdownTarget.classList.add("hidden"); 225 this._openSaveDialog(); 226 } 227 228 // "Update view" â updates the currently selected user-owned view in place 229 async updateCurrentViewAction(event) { 230 if (event) event.preventDefault(); 231 if (this.hasSaveDropdownTarget) 232 this.saveDropdownTarget.classList.add("hidden"); 233 234 const viewId = this._selectedUserViewId(); 235 if (!viewId) { 236 this._openSaveDialog(); 237 return; 238 } 239 await this._updateCurrentView(viewId); 240 } 241 242 // Legacy: called from the old "Save" button. Routes to update-in-place for 243 // user views, otherwise opens the dialog. 244 saveFiltersAndSearch(event) { 245 if (event) event.preventDefault(); 246 const viewId = this._selectedUserViewId(); 247 if (viewId) { 248 this._updateCurrentView(viewId); 249 } else { 250 this._openSaveDialog(); 251 } 252 } 253 254 async _updateCurrentView(viewId) { 255 const state = this.currentViewState(); 256 this.hideSaveReset(); 257 258 const response = await patch(`${this.saveViewUrlValue}/${viewId}`, { 259 body: JSON.stringify({ 260 query: state.query, 261 filters: state.filters, 262 order: state.order, 263 column_order: state.column_order, 264 hidden_columns: state.hidden_columns, 265 turbo_frame_id: this.hasTurboFrameTarget 266 ? this.turboFrameTarget.id 267 : null, 268 }), 269 contentType: "application/json", 270 responseKind: "turbo-stream", 271 }); 272 273 if (response.ok && this.hasMensaFilterPillListOutlet) { 274 this.mensaFilterPillListOutlet.clearPersistedDirtyState(); 275 this.mensaFilterPillListOutlet.persistView(viewId); 276 } 277 } 278 279 cancelSaveView(event) { 280 if (event) event.preventDefault(); 281 this.closeSaveViewDialog(); 282 } 283 284 saveViewDialogBackdrop(event) { 285 if (event.target === this.saveViewDialogTarget) { 286 this.closeSaveViewDialog(); 287 } 288 } 289 290 closeSaveViewDialog() { 291 if (!this.hasSaveViewDialogTarget) return; 292 if (typeof this.saveViewDialogTarget.close === "function") { 293 this.saveViewDialogTarget.close(); 294 } else { 295 this.saveViewDialogTarget.removeAttribute("open"); 296 } 297 } 298 299 async confirmSaveView(event) { 300 event.preventDefault(); 301 302 const name = this.hasSaveViewNameTarget 303 ? this.saveViewNameTarget.value.trim() 304 : ""; 305 if (!name) { 306 if (this.hasSaveViewNameTarget) 307 this.saveViewNameTarget.reportValidity(); 308 return; 309 } 310 311 const description = this.hasSaveViewDescriptionTarget 312 ? this.saveViewDescriptionTarget.value.trim() 313 : ""; 314 315 const state = this.currentViewState(); 316 317 const response = await post(this.saveViewUrlValue, { 318 body: JSON.stringify({ 319 name, 320 description, 321 query: state.query, 322 filters: state.filters, 323 order: state.order, 324 column_order: state.column_order, 325 hidden_columns: state.hidden_columns, 326 turbo_frame_id: this.hasTurboFrameTarget 327 ? this.turboFrameTarget.id 328 : null, 329 }), 330 contentType: "application/json", 331 responseKind: "turbo-stream", 332 }); 333 334 if (response.ok) { 335 this.closeSaveViewDialog(); 336 this.hideSaveReset(); 337 if (this.hasMensaFilterPillListOutlet) { 338 this.mensaFilterPillListOutlet.clearPersistedDirtyState(); 339 } 340 // After Turbo processes the stream the views component re-renders with 341 // the new view selected â read its ID from the DOM and persist it. 342 setTimeout(() => this._persistSelectedViewId(), 0); 343 } 344 } 345 346 currentViewState() { 347 if (!this.hasMensaFilterPillListOutlet) { 348 return { filters: {}, query: "", order: {} }; 349 } 350 351 const outlet = this.mensaFilterPillListOutlet; 352 const input = outlet.searchInputElement(); 353 354 return { 355 filters: outlet.collectFilters(), 356 query: input ? input.value : outlet.loadQuery(), 357 order: outlet.loadOrder(), 358 column_order: outlet.loadColumnOrder(), 359 hidden_columns: outlet.loadHiddenColumns(), 360 }; 361 } 362 363 viewTargetConnected() {} 364 365 filterListTargetConnected(element) { 366 // The filter bar is always visible in the new design â nothing to toggle. 367 } 368 369 // Called when the filter-pill-list outlet connects (including after turbo-stream re-renders). 370 // Re-applied on every connect so late-arriving pills (from restoreState) get the right state. 371 mensaFilterPillListOutletConnected() { 372 const hasViewFilters = this._hasViewFilterPills(); 373 if (hasViewFilters) { 374 const visible = this._loadViewFiltersVisible(); 375 this.element.classList.toggle( 376 "mensa-table--view-filters-hidden", 377 !visible, 378 ); 379 } else { 380 this.element.classList.remove("mensa-table--view-filters-hidden"); 381 } 382 this._updateEyeButton(); 383 } 384 385 // Called from the filter-pill-list controller when the user selects a different view. 386 // Resets the eye preference to hidden so the new view's filters start collapsed.
387 // Does NOT call _updateEyeButton() â the old DOM is still present here and would cause 388 // _updateEyeButton to remove the class we just added. The proper update happens in 389 // mensaFilterPillListOutletConnected after the turbo-stream re-render. 390 viewChanged() { 391 this._saveViewFiltersVisible(false); 392 this.element.classList.add("mensa-table--view-filters-hidden"); 393 } 394 395 // --- Eye toggle (view-origin filter pills) --- 396 397 toggleViewFilters(event) { 398 if (event) event.preventDefault(); 399 const nowHidden = this.element.classList.toggle( 400 "mensa-table--view-filters-hidden", 401 ); 402 this._saveViewFiltersVisible(!nowHidden); 403 this._updateEyeButton(); 404 } 405 406 _hasViewFilterPills() { 407 return ( 408 this.element.querySelectorAll('[data-view-filter="true"]').length > 409 0 410 ); 411 } 412 413 // Only updates button visibility and icon â never touches the hidden class. 414 // Class management is the sole responsibility of mensaFilterPillListOutletConnected 415 // and toggleViewFilters. 416 _updateEyeButton() { 417 const hasViewFilters = this._hasViewFilterPills(); 418 419 if (!this.hasEyeButtonTarget) return; 420 421 this.eyeButtonTarget.classList.toggle("hidden", !hasViewFilters); 422 423 if (hasViewFilters) { 424 const hidden = this.element.classList.contains( 425 "mensa-table--view-filters-hidden", 426 ); 427 const visibleIconClass = 428 this.eyeButtonTarget.dataset.iconVisibleClass || ""; 429 const hiddenIconClass = 430 this.eyeButtonTarget.dataset.iconHiddenClass || ""; 431 432 // Replace innerHTML so FontAwesome's MutationObserver re-processes the new <i> 433 this.eyeButtonTarget.innerHTML = hidden 434 ? `<i class="${visibleIconClass}"></i>` 435 : `<i class="${hiddenIconClass}"></i>`; 436 } 437 } 438 439 // --- View filter visibility persistence --- 440 441 _viewFiltersStorageKey() { 442 return `mensa:view-filters-visible:${this._tableName()}`; 443 } 444 445 _tableName() { 446 if (this.hasMensaFilterPillListOutlet) { 447 return this.mensaFilterPillListOutlet.tableNameValue; 448 } 449 const el = this.element.querySelector( 450 "[data-mensa-filter-pill-list-table-name-value]", 451 ); 452 return el?.dataset?.mensaFilterPillListTableNameValue || ""; 453 } 454 455 _loadViewFiltersVisible() { 456 try { 457 return ( 458 window.localStorage.getItem(this._viewFiltersStorageKey()) === 459 "true" 460 ); 461 } catch (e) { 462 return false; 463 } 464 } 465 466 _saveViewFiltersVisible(visible) { 467 try { 468 if (visible) { 469 window.localStorage.setItem( 470 this._viewFiltersStorageKey(), 471 "true", 472 ); 473 } else { 474 window.localStorage.removeItem(this._viewFiltersStorageKey()); 475 } 476 } catch (e) {} 477 } 478 479 // --- Export --- 480 481 export(event) { 482 event.preventDefault(); 483 if (!this.hasExportDialogTarget) return; 484 485 if (this.hasExportsUrlValue && this.exportsUrlValue) { 486 get(this.exportsUrlValue, { responseKind: "turbo-stream" }).finally( 487 () => this.openExportDialog(), 488 ); 489 } else { 490 this.openExportDialog(); 491 } 492 } 493 494 openExportDialog() { 495 if (!this.hasExportDialogTarget) return; 496 if (typeof this.exportDialogTarget.showModal === "function") { 497 this.exportDialogTarget.showModal(); 498 } else { 499 this.exportDialogTarget.setAttribute("open", ""); 500 } 501 } 502 503 cancelExport(event) { 504 if (event) event.preventDefault(); 505 this.closeExportDialog(); 506 } 507 508 exportDialogBackdrop(event) { 509 if (event.target === this.exportDialogTarget) { 510 this.closeExportDialog(); 511 } 512 } 513 514 closeExportDialog() { 515 if (!this.hasExportDialogTarget) return; 516 if (typeof this.exportDialogTarget.close === "function") { 517 this.exportDialogTarget.close(); 518 } else { 519 this.exportDialogTarget.removeAttribute("open"); 520 } 521 } 522 523 toggleExportRepeat() { 524 const dialog = this.exportDialogTarget; 525 const options = dialog.querySelector( 526 "[data-mensa-table-repeat-options]", 527 ); 528 if (!options) return; 529 530 const mode = dialog.querySelector( 531 'input[name="repeat_mode"]:checked', 532 )?.value; 533 options.hidden = mode !== "repeating"; 534 } 535 536 confirmExport(event) { 537 event.preventDefault();
538 if (!this.hasExportsUrlValue) return; 539 540 const dialog = this.exportDialogTarget; 541 const scope = 542 dialog.querySelector('input[name="scope"]:checked')?.value || "all"; 543 const exportFormat = 544 dialog.querySelector('input[name="export_format"]:checked') 545 ?.value || "csv_excel"; 546 const repeatMode = dialog.querySelector( 547 'input[name="repeat_mode"]:checked', 548 )?.value; 549 const repeat = 550 repeatMode === "repeating" 551 ? dialog.querySelector('select[name="repeat"]')?.value || "" 552 : ""; 553 554 const state = this.currentViewState(); 555 const nav = this.hasMensaFilterPillListOutlet 556 ? this.mensaFilterPillListOutlet.currentRequestState() 557 : { page: "", query: "", view: "" }; 558 const view = this.hasMensaFilterPillListOutlet 559 ? this.mensaFilterPillListOutlet.loadView() || nav.view 560 : ""; 561 562 post(this.exportsUrlValue, { 563 body: JSON.stringify({ 564 scope, 565 export_format: exportFormat, 566 repeat, 567 table_view_id: view, 568 page: nav.page, 569 query: state.query || nav.query, 570 filters: state.filters, 571 order: state.order, 572 column_order: state.column_order, 573 hidden_columns: state.hidden_columns, 574 }), 575 contentType: "application/json", 576 responseKind: "turbo-stream", 577 }); 578 } 579 580 get ourUrl() { 581 if ( 582 this.hasTurboFrameTarget && 583 this.turboFrameTarget.getAttribute("src") 584 ) { 585 return new URL( 586 this.turboFrameTarget.getAttribute("src"), 587 window.location.origin, 588 ); 589 } 590 if (this.hasTableUrlValue && this.tableUrlValue) { 591 return new URL(this.tableUrlValue, window.location.origin); 592 } 593 return new URL(window.location.href); 594 } 595 596 // --- Private --- 597 598 // Reads the currently-selected view ID from the DOM (after Turbo re-renders 599 // the views component) and persists it to localStorage. 600 _persistSelectedViewId() { 601 if (!this.hasMensaFilterPillListOutlet) return; 602 const checked = Array.from( 603 this.element.querySelectorAll('[data-mensa-views-target="view"]'), 604 ).find((el) => { 605 const check = el.querySelector(".mensa-table__views__option-check"); 606 return check && !check.classList.contains("invisible"); 607 }); 608 const viewId = checked?.dataset.viewId || ""; 609 this.mensaFilterPillListOutlet.persistView(viewId); 610 } 611 612 // Show the plain "Save" button for system/default views, and the dropdown 613 // "Save â¾" button for user-created views. 614 _updateSaveButtonMode() { 615 if (!this.hasSaveSimpleTarget && !this.hasSaveSplitTarget) return; 616 const isUserView = !!this._selectedUserViewId(); 617 this.saveSimpleTargets.forEach((t) => 618 t.classList.toggle("hidden", isUserView), 619 ); 620 this.saveSplitTargets.forEach((t) => 621 t.classList.toggle("hidden", !isUserView), 622 ); 623 } 624 625 _selectedUserViewId() { 626 const selectedViewEl = this.element.querySelector( 627 '[data-mensa-views-target="view"]', 628 ); 629 // Find the one whose check is visible 630 const checked = Array.from( 631 this.element.querySelectorAll('[data-mensa-views-target="view"]'), 632 ).find((el) => { 633 const check = el.querySelector(".mensa-table__views__option-check"); 634 return check && !check.classList.contains("invisible"); 635 }); 636 const viewId = checked?.getAttribute("data-view-id") || ""; 637 // UUID-based IDs are user-created views 638 return viewId && /[a-f0-9-]{32}$/.test(viewId) ? viewId : null; 639 } 640 641 _openSaveDialog() { 642 if (!this.hasSaveViewDialogTarget) return; 643 if (this.hasSaveViewNameTarget) this.saveViewNameTarget.value = ""; 644 if (this.hasSaveViewDescriptionTarget) 645 this.saveViewDescriptionTarget.value = ""; 646 if (typeof this.saveViewDialogTarget.showModal === "function") { 647 this.saveViewDialogTarget.showModal(); 648 } else { 649 this.saveViewDialogTarget.setAttribute("open", ""); 650 } 651 if (this.hasSaveViewNameTarget) this.saveViewNameTarget.focu
651s(); 652 } 653};
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.