PageSourceSearch

https://dillema.net/assets/mensa/table/component_controller-1b1b89…5222af1e79e1f11731954d2d9686e17608e859f7.js

js dillema.net collected 2026-10-06 18:50:38 UTC 22,224 bytes, 653 lines download raw bytes

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.