mensa 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +6 -2
  3. data/.devcontainer/compose.yaml +1 -1
  4. data/.devcontainer/devcontainer.json +31 -29
  5. data/.devcontainer/postCreate.sh +8 -0
  6. data/.devcontainer/postStart.sh +9 -0
  7. data/.gitignore +3 -1
  8. data/.zed/tasks.json +12 -0
  9. data/Gemfile.lock +155 -153
  10. data/Procfile +1 -1
  11. data/README.md +85 -60
  12. data/app/assets/stylesheets/mensa/application.css +14 -11
  13. data/app/components/mensa/add_filter/component.css +110 -5
  14. data/app/components/mensa/add_filter/component.html.slim +10 -12
  15. data/app/components/mensa/add_filter/component.rb +7 -1
  16. data/app/components/mensa/add_filter/component_controller.js +697 -83
  17. data/app/components/mensa/cell/component.css +9 -0
  18. data/app/components/mensa/column_customizer/component.css +40 -0
  19. data/app/components/mensa/column_customizer/component.html.slim +14 -0
  20. data/app/components/mensa/column_customizer/component.rb +13 -0
  21. data/app/components/mensa/column_customizer/component_controller.js +383 -0
  22. data/app/components/mensa/control_bar/component.css +127 -4
  23. data/app/components/mensa/control_bar/component.html.slim +41 -14
  24. data/app/components/mensa/control_bar/component.rb +0 -4
  25. data/app/components/mensa/empty_state/component.css +20 -0
  26. data/app/components/mensa/empty_state/component.html.slim +7 -0
  27. data/app/components/mensa/empty_state/component.rb +18 -0
  28. data/app/components/mensa/filter_pill/component.css +23 -0
  29. data/app/components/mensa/filter_pill/component.html.slim +9 -6
  30. data/app/components/mensa/filter_pill/component.rb +9 -0
  31. data/app/components/mensa/filter_pill/component_controller.js +50 -10
  32. data/app/components/mensa/filter_pill_list/component.css +58 -9
  33. data/app/components/mensa/filter_pill_list/component.html.slim +11 -8
  34. data/app/components/mensa/filter_pill_list/component_controller.js +747 -48
  35. data/app/components/mensa/header/component.css +41 -43
  36. data/app/components/mensa/header/component.html.slim +7 -7
  37. data/app/components/mensa/row_action/component.html.slim +2 -2
  38. data/app/components/mensa/search/component.css +68 -9
  39. data/app/components/mensa/search/component.html.slim +19 -15
  40. data/app/components/mensa/search/component_controller.js +39 -49
  41. data/app/components/mensa/selection/component_controller.js +147 -0
  42. data/app/components/mensa/table/component.css +28 -0
  43. data/app/components/mensa/table/component.html.slim +9 -6
  44. data/app/components/mensa/table/component.rb +1 -0
  45. data/app/components/mensa/table/component_controller.js +524 -88
  46. data/app/components/mensa/table_row/component.css +6 -0
  47. data/app/components/mensa/table_row/component.html.slim +8 -3
  48. data/app/components/mensa/view/component.css +97 -29
  49. data/app/components/mensa/view/component.html.slim +23 -10
  50. data/app/components/mensa/view/component.rb +5 -0
  51. data/app/components/mensa/views/component.css +106 -13
  52. data/app/components/mensa/views/component.html.slim +51 -17
  53. data/app/components/mensa/views/component_controller.js +245 -20
  54. data/app/controllers/mensa/tables/batch_actions_controller.rb +24 -0
  55. data/app/controllers/mensa/tables/exports_controller.rb +96 -0
  56. data/app/controllers/mensa/tables/filters_controller.rb +4 -1
  57. data/app/controllers/mensa/tables/views_controller.rb +108 -0
  58. data/app/controllers/mensa/tables_controller.rb +3 -6
  59. data/app/helpers/mensa/application_helper.rb +4 -0
  60. data/app/javascript/mensa/application.js +2 -2
  61. data/app/javascript/mensa/controllers/index.js +13 -4
  62. data/app/jobs/mensa/export_job.rb +77 -84
  63. data/app/models/mensa/export.rb +93 -0
  64. data/app/tables/mensa/base.rb +103 -12
  65. data/app/tables/mensa/batch_action.rb +27 -0
  66. data/app/tables/mensa/cell.rb +15 -0
  67. data/app/tables/mensa/column.rb +15 -2
  68. data/app/tables/mensa/config/batch_dsl.rb +13 -0
  69. data/app/tables/mensa/config/column_dsl.rb +1 -0
  70. data/app/tables/mensa/config/filter_dsl.rb +4 -1
  71. data/app/tables/mensa/config/render_dsl.rb +1 -1
  72. data/app/tables/mensa/config/table_dsl.rb +12 -5
  73. data/app/tables/mensa/config/view_dsl.rb +2 -0
  74. data/app/tables/mensa/config_readers.rb +20 -1
  75. data/app/tables/mensa/filter.rb +86 -3
  76. data/app/tables/mensa/scope.rb +24 -12
  77. data/app/views/mensa/exports/_badge.html.slim +5 -0
  78. data/app/views/mensa/exports/_dialog.html.slim +42 -0
  79. data/app/views/mensa/exports/_list.html.slim +29 -0
  80. data/app/views/mensa/tables/filters/show.turbo_stream.slim +35 -8
  81. data/app/views/mensa/tables/views/create.turbo_stream.slim +11 -0
  82. data/app/views/mensa/tables/views/destroy.turbo_stream.slim +11 -0
  83. data/app/views/mensa/tables/views/update.turbo_stream.slim +11 -0
  84. data/config/locales/en.yml +44 -0
  85. data/config/locales/nl.yml +45 -0
  86. data/config/routes.rb +7 -0
  87. data/db/migrate/20260604120000_create_mensa_exports.rb +25 -0
  88. data/docs/columns.png +0 -0
  89. data/docs/export.png +0 -0
  90. data/docs/filters.png +0 -0
  91. data/docs/table.png +0 -0
  92. data/lib/mensa/configuration.rb +33 -12
  93. data/lib/mensa/engine.rb +7 -2
  94. data/lib/mensa/version.rb +1 -1
  95. data/mensa.gemspec +2 -1
  96. data/mise.toml +8 -0
  97. data/package-lock.json +0 -7
  98. metadata +50 -8
@@ -1,50 +1,749 @@
1
- import ApplicationController from 'mensa/controllers/application_controller'
2
- import { get } from '@rails/request.js'
1
+ import ApplicationController from "mensa/controllers/application_controller";
2
+ import { get } from "@rails/request.js";
3
3
 
4
4
  export default class FilterPillListComponentController extends ApplicationController {
5
- static outlets = [
6
- "mensa-table",
7
- "mensa-filter-pill",
8
- "mensa-add-filter"
9
- ]
10
-
11
- static targets = [
12
- ]
13
-
14
- static values = {
15
- supportsViews: Boolean
16
- }
17
-
18
- connect() {
19
- super.connect()
20
- }
21
-
22
- refreshFilters() {
23
- let url = this.mensaTableOutlet.ourUrl
24
-
25
- let filters = url.searchParams.get('filters') || {}
26
- this.mensaFilterPillOutlets.forEach((filterOutlet) => {
27
- url.searchParams.append(`filters[${filterOutlet.columnNameValue}][value]`, filterOutlet.valueValue)
28
- url.searchParams.append(`filters[${filterOutlet.columnNameValue}][operator]`, filterOutlet.operatorValue)
29
- })
30
-
31
- url.searchParams.append(`filters[${this.mensaAddFilterOutlet.selectedFilterColumn}][value]`, this.mensaAddFilterOutlet.valueTarget.value)
32
- url.searchParams.append(`filters[${this.mensaAddFilterOutlet.selectedFilterColumn}][operator]`, 'equals')
33
-
34
-
35
- get(url, {
36
- responseKind: 'turbo-stream'
37
- }).then(() => {
38
- // FIXME: There should be a better way to do this, possibly using
39
- // this.mensaTableOutlet.filterListTarget.addEventListener("turbo:after-stream-render", this.unhide.bind(this)) ?
40
- setTimeout(() => {
41
- this.mensaTableOutlet.filterListTarget.classList.remove('hidden')
42
- }, 50)
43
- })
44
- }
45
-
46
- get ourUrl() {
47
- let url = this.mensaTableOutlet.ourUrl
48
- return url
49
- }
50
- }
5
+ static outlets = ["mensa-table", "mensa-filter-pill", "mensa-add-filter"];
6
+ static targets = ["searchInput", "resetSearchButton"];
7
+
8
+ static values = {
9
+ supportsViews: Boolean,
10
+ tableName: String,
11
+ };
12
+
13
+ connect() {
14
+ super.connect();
15
+ this._monitorResetButton();
16
+ }
17
+
18
+ // The mensa-table outlet provides `ourUrl`, which we need both to apply and
19
+ // to restore state. Outlets connect asynchronously, so we trigger the restore
20
+ // from the outlet-connected callback to be sure it's available.
21
+ mensaTableOutletConnected() {
22
+ this.restoreState();
23
+ }
24
+
25
+ // --- Search actions (formerly in search component controller) ---
26
+
27
+ searchFocused(event) {
28
+ if (this._supportsColumnFilters()) {
29
+ // Don't open the column list while the value popover is already showing —
30
+ // focusing the search input for keyboard nav must not clobber the popover.
31
+ if (this.mensaAddFilterOutlet.isValuePopoverOpen) return;
32
+ this.mensaAddFilterOutlet.filterColumns("");
33
+ this.mensaAddFilterOutlet.showList(this.searchInputTarget);
34
+ }
35
+ }
36
+
37
+ monitorSearch(event) {
38
+ const value = this.hasSearchInputTarget
39
+ ? this.searchInputTarget.value
40
+ : "";
41
+ this._monitorResetButton();
42
+
43
+ if (this._supportsColumnFilters()) {
44
+ if (this.mensaAddFilterOutlet.isValuePopoverOpen) {
45
+ this.mensaAddFilterOutlet.filterValues(value);
46
+ return;
47
+ }
48
+ this.mensaAddFilterOutlet.filterColumns(value);
49
+ if (value.length > 0) {
50
+ if (this.mensaAddFilterOutlet.visibleColumnCount > 0) {
51
+ this.mensaAddFilterOutlet.showList(this.searchInputTarget);
52
+ } else {
53
+ // No matching columns — hide column list so Enter does a text search
54
+ this.mensaAddFilterOutlet.hideList();
55
+ }
56
+ } else {
57
+ this.mensaAddFilterOutlet.showList(this.searchInputTarget);
58
+ }
59
+ }
60
+ }
61
+
62
+ resetSearch(event) {
63
+ if (event) event.preventDefault();
64
+
65
+ if (this.hasSearchInputTarget) {
66
+ this.searchInputTarget.value = "";
67
+ this.searchInputTarget.focus();
68
+ }
69
+ if (this.hasResetSearchButtonTarget) {
70
+ this.resetSearchButtonTarget.classList.add("hidden");
71
+ }
72
+ if (this._supportsColumnFilters()) {
73
+ this.mensaAddFilterOutlet.hideList();
74
+ this.mensaAddFilterOutlet.closeValuePopover?.();
75
+ }
76
+
77
+ this.setQuery("");
78
+ }
79
+
80
+ search(event) {
81
+ event.preventDefault();
82
+
83
+ // If the value popover is open, Enter confirms the highlighted/selected item
84
+ if (
85
+ this._supportsColumnFilters() &&
86
+ this.mensaAddFilterOutlet.isValuePopoverOpen
87
+ ) {
88
+ this.mensaAddFilterOutlet.confirmHighlightedValue();
89
+ return;
90
+ }
91
+
92
+ // If the column autocomplete has visible options and one is selected via
93
+ // keyboard, let the add-filter controller handle it
94
+ if (
95
+ this._supportsColumnFilters() &&
96
+ this.mensaAddFilterOutlet.hasHighlightedColumn
97
+ ) {
98
+ this.mensaAddFilterOutlet.confirmHighlightedColumn();
99
+ return;
100
+ }
101
+
102
+ // Hide column dropdown before searching
103
+ if (this._supportsColumnFilters()) {
104
+ this.mensaAddFilterOutlet.hideList();
105
+ }
106
+
107
+ const query = this.hasSearchInputTarget
108
+ ? this.searchInputTarget.value
109
+ : "";
110
+ if (query.length > 0 && query.length < 3) return;
111
+
112
+ this.setQuery(query);
113
+ }
114
+
115
+ navigateDown(event) {
116
+ if (!this._supportsColumnFilters()) return;
117
+ event.preventDefault();
118
+ if (this.mensaAddFilterOutlet.isValuePopoverOpen) {
119
+ this.mensaAddFilterOutlet.highlightNextValue();
120
+ } else {
121
+ this.mensaAddFilterOutlet.highlightNext();
122
+ }
123
+ }
124
+
125
+ navigateUp(event) {
126
+ if (!this._supportsColumnFilters()) return;
127
+ event.preventDefault();
128
+ if (this.mensaAddFilterOutlet.isValuePopoverOpen) {
129
+ this.mensaAddFilterOutlet.highlightPrevValue();
130
+ } else {
131
+ this.mensaAddFilterOutlet.highlightPrev();
132
+ }
133
+ }
134
+
135
+ // --- Filter state management ---
136
+
137
+ // Called when an existing filter pill is clicked.
138
+ editFilter(columnName, value, operator, anchor) {
139
+ if (!this.hasMensaAddFilterOutlet) return;
140
+ this.mensaAddFilterOutlet.editColumn(
141
+ columnName,
142
+ value,
143
+ operator,
144
+ anchor,
145
+ );
146
+ }
147
+
148
+ // Called when a filter is added/changed.
149
+ refreshFilters() {
150
+ this._notifyUnsavedState();
151
+ return this.applyState({
152
+ filters: this.collectFilters(),
153
+ query: this.loadQuery(),
154
+ view: this.loadView(),
155
+ order: this.loadOrder(),
156
+ page: "",
157
+ });
158
+ }
159
+
160
+ // Called by the search controller when the query is submitted or reset.
161
+ setQuery(query) {
162
+ if (query && query.length > 0) this._notifyUnsavedState();
163
+ this.applyState({
164
+ filters: this.collectFilters(),
165
+ query,
166
+ view: this.loadView(),
167
+ order: this.loadOrder(),
168
+ page: "",
169
+ });
170
+ }
171
+
172
+ // Called by the views controller when a view tab is selected.
173
+ viewSelected(view) {
174
+ const state = {
175
+ filters: {},
176
+ query: "",
177
+ view,
178
+ order: this.loadOrder(),
179
+ page: "",
180
+ };
181
+ this.persistState(state);
182
+ this.setSearchField("");
183
+ this.updateSearchPlaceholder();
184
+ if (this.hasMensaTableOutlet) {
185
+ this.mensaTableOutlet.viewChanged();
186
+ }
187
+ this.requestState(state);
188
+ }
189
+
190
+ // Called by the table controller after a turbo-frame navigation.
191
+ captureNavigation(src) {
192
+ let url;
193
+ try {
194
+ url = new URL(src, window.location.origin);
195
+ } catch (e) {
196
+ return;
197
+ }
198
+
199
+ this.lastTableUrl = url.toString();
200
+ this.persistPage(url.searchParams.get("page") || "");
201
+ // Only update the stored view when the param is explicitly present.
202
+ // An absent table_view_id (e.g. the plain tableUrlValue on back-navigation)
203
+ // must not erase a view the user deliberately selected.
204
+ if (url.searchParams.has("table_view_id")) {
205
+ this.persistView(url.searchParams.get("table_view_id") || "");
206
+ }
207
+ const order = this.parseOrderParams(url.searchParams);
208
+ this.persistOrder(order);
209
+
210
+ if (Object.keys(order).length > 0) {
211
+ this._notifyUnsavedState();
212
+ }
213
+ }
214
+
215
+ currentRequestState() {
216
+ let url;
217
+ try {
218
+ url = this.lastTableUrl
219
+ ? new URL(this.lastTableUrl, window.location.origin)
220
+ : this.mensaTableOutlet.ourUrl;
221
+ } catch (e) {
222
+ url = this.mensaTableOutlet.ourUrl;
223
+ }
224
+
225
+ const params = url.searchParams;
226
+ const filters = {};
227
+ const order = {};
228
+ params.forEach((value, key) => {
229
+ // Multi-select: filters[col][value][]
230
+ const multiMatch = key.match(/^filters\[(.+?)\]\[value\]\[\]$/);
231
+ if (multiMatch) {
232
+ const column = multiMatch[1];
233
+ const f = (filters[column] = filters[column] || {});
234
+ f.value = Array.isArray(f.value)
235
+ ? [...f.value, value]
236
+ : [value];
237
+ return;
238
+ }
239
+ const filterMatch = key.match(
240
+ /^filters\[(.+?)\]\[(value|operator)\]$/,
241
+ );
242
+ if (filterMatch) {
243
+ const column = filterMatch[1];
244
+ (filters[column] = filters[column] || {})[filterMatch[2]] =
245
+ value;
246
+ return;
247
+ }
248
+ const orderMatch = key.match(/^order\[(.+)\]$/);
249
+ if (orderMatch) order[orderMatch[1]] = value;
250
+ });
251
+
252
+ return {
253
+ filters,
254
+ query: params.get("query") || "",
255
+ view: params.get("table_view_id") || "",
256
+ page: params.get("page") || "",
257
+ order,
258
+ };
259
+ }
260
+
261
+ clearFiltersAndSearch() {
262
+ const view = this.loadView();
263
+ // Wipe all dirty-state entries; keep only the active view.
264
+ this.clearPersistedDirtyState();
265
+ this.persistView(view);
266
+ this.setSearchField("");
267
+ // buildUrl reads column_order/hidden_columns from localStorage — now empty,
268
+ // so the server receives a completely clean request for the current view.
269
+ this.requestState({
270
+ filters: {},
271
+ query: "",
272
+ view,
273
+ order: {},
274
+ page: "",
275
+ });
276
+ }
277
+
278
+ restoreState() {
279
+ const table = this.mensaTableOutlet;
280
+ const filters = this.loadFilters();
281
+ const query = this.loadQuery();
282
+ const view = this.loadView();
283
+ const page = this.loadPage();
284
+ const order = this.loadOrder();
285
+ const columnOrder = this.loadColumnOrder();
286
+ const hiddenColumns = this.loadHiddenColumns();
287
+
288
+ // Show save/reset if any persistent state exists (order, column layout, filters, search)
289
+ if (
290
+ Object.keys(filters).length > 0 ||
291
+ query.length > 0 ||
292
+ Object.keys(order).length > 0 ||
293
+ columnOrder.length > 0 ||
294
+ hiddenColumns.length > 0
295
+ ) {
296
+ this._notifyUnsavedState();
297
+ }
298
+
299
+ const hasFilterOrSearch =
300
+ Object.keys(filters).length > 0 || query.length > 0;
301
+ const hasState =
302
+ hasFilterOrSearch ||
303
+ view.length > 0 ||
304
+ page.length > 0 ||
305
+ Object.keys(order).length > 0 ||
306
+ columnOrder.length > 0;
307
+
308
+ const alreadyRendered = Object.keys(this.renderedFilters()).length > 0;
309
+
310
+ if (!hasState || alreadyRendered || table.frameLoadHandled) {
311
+ if (typeof table.loadFrame === "function") {
312
+ table.loadFrame();
313
+ }
314
+ return;
315
+ }
316
+
317
+ table.frameLoadHandled = true;
318
+
319
+ this.setSearchField(query);
320
+ this.setViewHighlight(view);
321
+
322
+ const state = { filters, query, view, page, order };
323
+
324
+ // No need to show/hide the filter bar — it's always visible.
325
+ // Just apply state and load the frame.
326
+ this.applyState(state);
327
+ }
328
+
329
+ applyState(state) {
330
+ return this.persistAndRequest(state);
331
+ }
332
+
333
+ persistAndRequest(state) {
334
+ this.persistState(state);
335
+ return this.requestState(state);
336
+ }
337
+
338
+ requestState(state) {
339
+ const url = this.buildUrl(state);
340
+ this.lastTableUrl = url.toString();
341
+ return get(url, { responseKind: "turbo-stream" });
342
+ }
343
+
344
+ buildUrl(state) {
345
+ const url = this.mensaTableOutlet.ourUrl;
346
+
347
+ this.removeFilterParams(url);
348
+ this.removeOrderParams(url);
349
+ this.removeColumnParams(url);
350
+ url.searchParams.delete("query");
351
+ url.searchParams.delete("page");
352
+ url.searchParams.delete("table_view_id");
353
+
354
+ Object.entries(state.filters || {}).forEach(([columnName, filter]) => {
355
+ if (Object.prototype.hasOwnProperty.call(filter, "value")) {
356
+ if (Array.isArray(filter.value)) {
357
+ filter.value.forEach((v) =>
358
+ url.searchParams.append(
359
+ `filters[${columnName}][value][]`,
360
+ v,
361
+ ),
362
+ );
363
+ } else {
364
+ url.searchParams.append(
365
+ `filters[${columnName}][value]`,
366
+ filter.value ?? "",
367
+ );
368
+ }
369
+ }
370
+ url.searchParams.append(
371
+ `filters[${columnName}][operator]`,
372
+ filter.operator,
373
+ );
374
+ });
375
+
376
+ Object.entries(state.order || {}).forEach(([columnName, direction]) => {
377
+ url.searchParams.set(`order[${columnName}]`, direction);
378
+ });
379
+
380
+ if (state.query) url.searchParams.set("query", state.query);
381
+ if (state.view) url.searchParams.set("table_view_id", state.view);
382
+ if (state.page) url.searchParams.set("page", state.page);
383
+
384
+ this.loadColumnOrder().forEach((col) =>
385
+ url.searchParams.append("column_order[]", col),
386
+ );
387
+ this.loadHiddenColumns().forEach((col) =>
388
+ url.searchParams.append("hidden_columns[]", col),
389
+ );
390
+
391
+ return url;
392
+ }
393
+
394
+ removeFilterParams(url) {
395
+ const keys = new Set();
396
+ url.searchParams.forEach((_v, key) => {
397
+ if (key.startsWith("filters[")) keys.add(key);
398
+ });
399
+ keys.forEach((key) => url.searchParams.delete(key));
400
+ }
401
+
402
+ removeColumnParams(url) {
403
+ const keys = [];
404
+ url.searchParams.forEach((_v, key) => {
405
+ if (
406
+ key.startsWith("column_order") ||
407
+ key.startsWith("hidden_columns")
408
+ )
409
+ keys.push(key);
410
+ });
411
+ keys.forEach((key) => url.searchParams.delete(key));
412
+ }
413
+
414
+ removeOrderParams(url) {
415
+ const keys = [];
416
+ url.searchParams.forEach((_v, key) => {
417
+ if (key.startsWith("order[")) keys.push(key);
418
+ });
419
+ keys.forEach((key) => url.searchParams.delete(key));
420
+ }
421
+
422
+ parseOrderParams(searchParams) {
423
+ const order = {};
424
+ searchParams.forEach((value, key) => {
425
+ const match = key.match(/^order\[(.+)\]$/);
426
+ if (match) order[match[1]] = value;
427
+ });
428
+ return order;
429
+ }
430
+
431
+ collectFilters() {
432
+ const filters = this.renderedFilters();
433
+
434
+ if (
435
+ this.hasMensaAddFilterOutlet &&
436
+ this.mensaAddFilterOutlet.selectedFilterColumn
437
+ ) {
438
+ const isMultiple = this.mensaAddFilterOutlet.isMultipleMode;
439
+ const filter = {
440
+ operator: this.mensaAddFilterOutlet.operator,
441
+ };
442
+ if (this.mensaAddFilterOutlet.operatorRequiresValue()) {
443
+ filter.value = isMultiple
444
+ ? this.mensaAddFilterOutlet.selectedValues
445
+ : this.mensaAddFilterOutlet.hasValueTarget
446
+ ? this.mensaAddFilterOutlet.valueTarget.value
447
+ : "";
448
+ }
449
+ filters[this.mensaAddFilterOutlet.selectedFilterColumn] = filter;
450
+ }
451
+
452
+ return filters;
453
+ }
454
+
455
+ renderedFilters() {
456
+ const filters = {};
457
+
458
+ this.element
459
+ .querySelectorAll('[data-controller~="mensa-filter-pill"]')
460
+ .forEach((pill) => {
461
+ const columnName = pill.getAttribute(
462
+ "data-mensa-filter-pill-column-name-value",
463
+ );
464
+ if (!columnName) return;
465
+
466
+ const operator = pill.getAttribute(
467
+ "data-mensa-filter-pill-operator-value",
468
+ );
469
+ const operatorWithoutValue =
470
+ pill.getAttribute(
471
+ "data-mensa-filter-pill-operator-without-value-value",
472
+ ) === "true";
473
+ const rawValue = pill.getAttribute(
474
+ "data-mensa-filter-pill-value-value",
475
+ );
476
+
477
+ if (operatorWithoutValue) {
478
+ filters[columnName] = { operator };
479
+ return;
480
+ }
481
+
482
+ let value;
483
+ try {
484
+ value = JSON.parse(rawValue);
485
+ } catch {
486
+ value = rawValue;
487
+ }
488
+
489
+ filters[columnName] = {
490
+ value,
491
+ operator,
492
+ };
493
+ });
494
+
495
+ return filters;
496
+ }
497
+
498
+ setSearchField(query) {
499
+ if (this.hasSearchInputTarget) {
500
+ this.searchInputTarget.value = query || "";
501
+ }
502
+ this._monitorResetButton();
503
+ }
504
+
505
+ setViewHighlight(view) {
506
+ if (!view) return;
507
+
508
+ const root = this.element.closest(".mensa-table");
509
+ if (!root) return;
510
+
511
+ // Update the views dropdown option checks
512
+ root.querySelectorAll('[data-mensa-views-target="view"]').forEach(
513
+ (el) => {
514
+ const linkView = el.getAttribute("data-view-id") || "";
515
+ const check = el.querySelector(
516
+ ".mensa-table__views__option-check",
517
+ );
518
+ if (check) {
519
+ check.classList.toggle(
520
+ "invisible",
521
+ view !== "" && linkView !== view,
522
+ );
523
+ }
524
+ },
525
+ );
526
+
527
+ // Update the trigger label
528
+ const triggerLabel = root.querySelector(
529
+ '[data-mensa-views-target="triggerLabel"]',
530
+ );
531
+ if (triggerLabel && view) {
532
+ const viewEl = root.querySelector(
533
+ `[data-mensa-views-target="view"][data-view-id="${view}"]`,
534
+ );
535
+ if (viewEl) {
536
+ const name =
537
+ viewEl.dataset.viewName ||
538
+ viewEl.querySelector("span")?.textContent?.trim();
539
+ if (name) triggerLabel.textContent = name;
540
+ }
541
+ }
542
+
543
+ this.updateSearchPlaceholder();
544
+ }
545
+
546
+ updateSearchPlaceholder() {}
547
+
548
+ _supportsColumnFilters() {
549
+ return (
550
+ this.hasMensaAddFilterOutlet &&
551
+ this.mensaAddFilterOutlet.filterListItemTargets.length > 0
552
+ );
553
+ }
554
+
555
+ // Clears all dirty-state localStorage keys so the view is clean after save.
556
+ clearPersistedDirtyState() {
557
+ this.writeStorage(this.filtersStorageKey, null);
558
+ this.writeStorage(this.searchStorageKey, null);
559
+ this.writeStorage(this.orderStorageKey, null);
560
+ this.writeStorage(`mensa:column_order:${this.tableNameValue}`, null);
561
+ this.writeStorage(`mensa:hidden_columns:${this.tableNameValue}`, null);
562
+ }
563
+
564
+ // Keep these for backward-compatibility with other controllers that look up
565
+ // the search input via helper methods.
566
+ searchInputElement() {
567
+ return this.hasSearchInputTarget ? this.searchInputTarget : null;
568
+ }
569
+
570
+ resetSearchButtonElement() {
571
+ return this.hasResetSearchButtonTarget
572
+ ? this.resetSearchButtonTarget
573
+ : null;
574
+ }
575
+
576
+ // --- Persistence ---
577
+
578
+ persistState(state) {
579
+ this.persistFilters(state.filters || {});
580
+ this.persistQuery(state.query || "");
581
+ this.persistView(state.view || "");
582
+ this.persistPage(state.page || "");
583
+ this.persistOrder(state.order || {});
584
+ }
585
+
586
+ persistFilters(filters) {
587
+ this.writeStorage(
588
+ this.filtersStorageKey,
589
+ Object.keys(filters).length > 0 ? JSON.stringify(filters) : null,
590
+ );
591
+ }
592
+
593
+ loadFilters() {
594
+ const raw = this.readStorage(this.filtersStorageKey);
595
+ if (!raw) return {};
596
+ try {
597
+ return JSON.parse(raw);
598
+ } catch (e) {
599
+ return {};
600
+ }
601
+ }
602
+
603
+ persistQuery(query) {
604
+ this.writeStorage(
605
+ this.searchStorageKey,
606
+ query && query.length > 0 ? query : null,
607
+ );
608
+ }
609
+
610
+ loadQuery() {
611
+ return this.readStorage(this.searchStorageKey) || "";
612
+ }
613
+
614
+ persistView(view) {
615
+ this.writeStorage(
616
+ this.viewStorageKey,
617
+ view && view.length > 0 ? view : null,
618
+ );
619
+ }
620
+
621
+ loadView() {
622
+ return this.readStorage(this.viewStorageKey) || "";
623
+ }
624
+
625
+ persistPage(page) {
626
+ this.writeStorage(
627
+ this.pageStorageKey,
628
+ page && page !== "1" ? page : null,
629
+ );
630
+ }
631
+
632
+ loadPage() {
633
+ return this.readStorage(this.pageStorageKey) || "";
634
+ }
635
+
636
+ persistOrder(order) {
637
+ this.writeStorage(
638
+ this.orderStorageKey,
639
+ order && Object.keys(order).length > 0
640
+ ? JSON.stringify(order)
641
+ : null,
642
+ );
643
+ }
644
+
645
+ loadOrder() {
646
+ const raw = this.readStorage(this.orderStorageKey);
647
+ if (!raw) return {};
648
+ try {
649
+ return JSON.parse(raw);
650
+ } catch (e) {
651
+ return {};
652
+ }
653
+ }
654
+
655
+ writeStorage(key, value) {
656
+ if (!this.hasStorage) return;
657
+ try {
658
+ if (value === null) {
659
+ window.localStorage.removeItem(key);
660
+ } else {
661
+ window.localStorage.setItem(key, value);
662
+ }
663
+ } catch (e) {}
664
+ }
665
+
666
+ readStorage(key) {
667
+ if (!this.hasStorage) return null;
668
+ try {
669
+ return window.localStorage.getItem(key);
670
+ } catch (e) {
671
+ return null;
672
+ }
673
+ }
674
+
675
+ get hasStorage() {
676
+ try {
677
+ return typeof window !== "undefined" && !!window.localStorage;
678
+ } catch (e) {
679
+ return false;
680
+ }
681
+ }
682
+
683
+ loadColumnOrder() {
684
+ try {
685
+ return (
686
+ JSON.parse(
687
+ this.readStorage(
688
+ `mensa:column_order:${this.tableNameValue}`,
689
+ ),
690
+ ) || []
691
+ );
692
+ } catch (e) {
693
+ return [];
694
+ }
695
+ }
696
+
697
+ loadHiddenColumns() {
698
+ try {
699
+ return (
700
+ JSON.parse(
701
+ this.readStorage(
702
+ `mensa:hidden_columns:${this.tableNameValue}`,
703
+ ),
704
+ ) || []
705
+ );
706
+ } catch (e) {
707
+ return [];
708
+ }
709
+ }
710
+
711
+ get filtersStorageKey() {
712
+ return `mensa:filters:${this.tableNameValue}`;
713
+ }
714
+ get searchStorageKey() {
715
+ return `mensa:search:${this.tableNameValue}`;
716
+ }
717
+ get viewStorageKey() {
718
+ return `mensa:view:${this.tableNameValue}`;
719
+ }
720
+ get pageStorageKey() {
721
+ return `mensa:page:${this.tableNameValue}`;
722
+ }
723
+ get orderStorageKey() {
724
+ return `mensa:order:${this.tableNameValue}`;
725
+ }
726
+
727
+ get ourUrl() {
728
+ return this.mensaTableOutlet.ourUrl;
729
+ }
730
+
731
+ // --- Private ---
732
+
733
+ _monitorResetButton() {
734
+ if (!this.hasSearchInputTarget) return;
735
+ const hasValue = this.searchInputTarget.value.length > 0;
736
+ if (this.hasResetSearchButtonTarget) {
737
+ this.resetSearchButtonTarget.classList.toggle("hidden", !hasValue);
738
+ }
739
+ }
740
+
741
+ _notifyUnsavedState() {
742
+ if (
743
+ this.hasMensaTableOutlet &&
744
+ typeof this.mensaTableOutlet.notifyUnsavedState === "function"
745
+ ) {
746
+ this.mensaTableOutlet.notifyUnsavedState();
747
+ }
748
+ }
749
+ }