mensa 0.2.4 → 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 (122) 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/CHANGELOG.md +6 -0
  10. data/Gemfile.lock +155 -153
  11. data/Procfile +1 -1
  12. data/README.md +95 -60
  13. data/app/assets/stylesheets/mensa/application.css +14 -11
  14. data/app/components/mensa/add_filter/component.css +110 -5
  15. data/app/components/mensa/add_filter/component.html.slim +10 -12
  16. data/app/components/mensa/add_filter/component.rb +8 -2
  17. data/app/components/mensa/add_filter/component_controller.js +697 -85
  18. data/app/components/mensa/cell/component.css +9 -0
  19. data/app/components/mensa/column_customizer/component.css +40 -0
  20. data/app/components/mensa/column_customizer/component.html.slim +14 -0
  21. data/app/components/mensa/column_customizer/component.rb +13 -0
  22. data/app/components/mensa/column_customizer/component_controller.js +383 -0
  23. data/app/components/mensa/control_bar/component.css +127 -4
  24. data/app/components/mensa/control_bar/component.html.slim +41 -14
  25. data/app/components/mensa/control_bar/component.rb +2 -6
  26. data/app/components/mensa/empty_state/component.css +20 -0
  27. data/app/components/mensa/empty_state/component.html.slim +7 -0
  28. data/app/components/mensa/empty_state/component.rb +18 -0
  29. data/app/components/mensa/filter_pill/component.css +23 -0
  30. data/app/components/mensa/filter_pill/component.html.slim +9 -0
  31. data/app/components/mensa/filter_pill/component.rb +24 -0
  32. data/app/components/mensa/filter_pill/component_controller.js +52 -0
  33. data/app/components/mensa/filter_pill_list/component.css +63 -0
  34. data/app/components/mensa/filter_pill_list/component.html.slim +11 -0
  35. data/app/components/mensa/{filter_list → filter_pill_list}/component.rb +1 -1
  36. data/app/components/mensa/filter_pill_list/component_controller.js +749 -0
  37. data/app/components/mensa/header/component.css +41 -43
  38. data/app/components/mensa/header/component.html.slim +7 -7
  39. data/app/components/mensa/header/component.rb +1 -1
  40. data/app/components/mensa/row_action/component.html.slim +2 -2
  41. data/app/components/mensa/row_action/component.rb +1 -1
  42. data/app/components/mensa/search/component.css +68 -9
  43. data/app/components/mensa/search/component.html.slim +19 -15
  44. data/app/components/mensa/search/component.rb +1 -1
  45. data/app/components/mensa/search/component_controller.js +39 -49
  46. data/app/components/mensa/selection/component_controller.js +147 -0
  47. data/app/components/mensa/table/component.css +28 -0
  48. data/app/components/mensa/table/component.html.slim +9 -6
  49. data/app/components/mensa/table/component.rb +1 -0
  50. data/app/components/mensa/table/component_controller.js +524 -76
  51. data/app/components/mensa/table_row/component.css +6 -0
  52. data/app/components/mensa/table_row/component.html.slim +8 -3
  53. data/app/components/mensa/table_row/component.rb +1 -1
  54. data/app/components/mensa/view/component.css +97 -29
  55. data/app/components/mensa/view/component.html.slim +23 -10
  56. data/app/components/mensa/view/component.rb +5 -0
  57. data/app/components/mensa/views/component.css +106 -13
  58. data/app/components/mensa/views/component.html.slim +51 -17
  59. data/app/components/mensa/views/component_controller.js +245 -20
  60. data/app/controllers/mensa/application_controller.rb +1 -1
  61. data/app/controllers/mensa/tables/batch_actions_controller.rb +24 -0
  62. data/app/controllers/mensa/tables/exports_controller.rb +96 -0
  63. data/app/controllers/mensa/tables/filters_controller.rb +6 -2
  64. data/app/controllers/mensa/tables/views_controller.rb +108 -0
  65. data/app/controllers/mensa/tables_controller.rb +5 -14
  66. data/app/helpers/mensa/application_helper.rb +4 -1
  67. data/app/javascript/mensa/application.js +2 -2
  68. data/app/javascript/mensa/controllers/application_controller.js +5 -21
  69. data/app/javascript/mensa/controllers/index.js +16 -7
  70. data/app/jobs/mensa/export_job.rb +77 -85
  71. data/app/models/mensa/export.rb +93 -0
  72. data/app/tables/mensa/action.rb +3 -1
  73. data/app/tables/mensa/base.rb +103 -17
  74. data/app/tables/mensa/batch_action.rb +27 -0
  75. data/app/tables/mensa/cell.rb +21 -6
  76. data/app/tables/mensa/column.rb +30 -25
  77. data/app/tables/mensa/config/action_dsl.rb +1 -1
  78. data/app/tables/mensa/config/batch_dsl.rb +13 -0
  79. data/app/tables/mensa/config/column_dsl.rb +1 -0
  80. data/app/tables/mensa/config/dsl_logic.rb +8 -4
  81. data/app/tables/mensa/config/filter_dsl.rb +4 -1
  82. data/app/tables/mensa/config/render_dsl.rb +1 -1
  83. data/app/tables/mensa/config/table_dsl.rb +14 -4
  84. data/app/tables/mensa/config/view_dsl.rb +2 -0
  85. data/app/tables/mensa/config_readers.rb +34 -3
  86. data/app/tables/mensa/filter.rb +94 -14
  87. data/app/tables/mensa/row.rb +1 -1
  88. data/app/tables/mensa/scope.rb +25 -13
  89. data/app/views/mensa/exports/_badge.html.slim +5 -0
  90. data/app/views/mensa/exports/_dialog.html.slim +42 -0
  91. data/app/views/mensa/exports/_list.html.slim +29 -0
  92. data/app/views/mensa/tables/filters/show.turbo_stream.slim +34 -6
  93. data/app/views/mensa/tables/show.html.slim +2 -0
  94. data/app/views/mensa/tables/show.turbo_stream.slim +1 -1
  95. data/app/views/mensa/tables/views/create.turbo_stream.slim +11 -0
  96. data/app/views/mensa/tables/views/destroy.turbo_stream.slim +11 -0
  97. data/app/views/mensa/tables/views/update.turbo_stream.slim +11 -0
  98. data/bin/setup +1 -1
  99. data/config/locales/en.yml +45 -1
  100. data/config/locales/nl.yml +46 -1
  101. data/config/routes.rb +7 -0
  102. data/db/migrate/20260604120000_create_mensa_exports.rb +25 -0
  103. data/docs/columns.png +0 -0
  104. data/docs/export.png +0 -0
  105. data/docs/filters.png +0 -0
  106. data/docs/table.png +0 -0
  107. data/lib/generators/mensa/tailwind_config_generator.rb +3 -3
  108. data/lib/generators/mensa/templates/config/initializers/mensa.rb +1 -1
  109. data/lib/mensa/configuration.rb +35 -15
  110. data/lib/mensa/engine.rb +15 -10
  111. data/lib/mensa/version.rb +1 -1
  112. data/lib/mensa.rb +2 -2
  113. data/lib/tasks/mensa_tasks.rake +1 -1
  114. data/mensa.gemspec +3 -2
  115. data/mise.toml +8 -0
  116. data/package-lock.json +0 -7
  117. metadata +60 -15
  118. data/app/components/mensa/filter/component_controller.js +0 -12
  119. data/app/components/mensa/filter_list/component.css +0 -14
  120. data/app/components/mensa/filter_list/component.html.slim +0 -14
  121. data/app/components/mensa/filter_list/component_controller.js +0 -14
  122. /data/{rubocop.yml → .rubocop.yml} +0 -0
@@ -1,88 +1,700 @@
1
- import ApplicationController from 'mensa/controllers/application_controller'
1
+ import ApplicationController from "mensa/controllers/application_controller";
2
2
  // import { debounce } from '@entdec/satis'
3
- import { get } from '@rails/request.js'
3
+ import { get } from "@rails/request.js";
4
+
5
+ // Survives the turbo-stream re-render that destroys both the filter-pill-list
6
+ // and add-filter controller instances on every refreshFilters() call.
7
+ let _pendingMultiReopen = null;
4
8
 
5
9
  export default class AddFilterComponentController extends ApplicationController {
6
- static outlets = [
7
- "mensa-table"
8
- ]
9
- static targets = [
10
- 'filterList', // all filters
11
- 'filterListItem', // individual filters
12
- 'description', // contains the filter description in the "tab"
13
- 'valuePopover' // contains the filter-value
14
- ]
15
- static values = {
16
- supportsViews: Boolean
17
- }
18
-
19
- connect() {
20
- super.connect()
21
-
22
- // this.filterValueEntered = debounce(this.filterValueEntered, 500).bind(this)
23
- // this.filterValueEntered = this.filterValueEntered.bind(this)
24
- this.selectedFilterColumn = null
25
- }
26
-
27
- // Called when you click add-filter
28
- toggle(event) {
29
- this.filterListTarget.classList.toggle('hidden')
30
- }
31
-
32
- // Called when you selected a column
33
- openValuePopover(event) {
34
- let url = this.ourUrl
35
- url.pathname += `/filters/${this.selectedFilterColumn}`
36
- url.searchParams.append('target', this.valuePopoverTarget.id)
37
-
38
- get(url, {
39
- responseKind: 'turbo-stream'
40
- }).then(() => {
41
- this.valuePopoverTarget.classList.remove('hidden')
42
- })
43
- }
44
-
45
- // Called when you select a column from the "dropdown"
46
- selectColumn(event) {
47
- this.filterListItemTargets.forEach((lt) => {
48
- let check = lt.querySelector('.check')
49
- check.classList.add('hidden')
50
- })
51
- let check = event.target.closest('li').querySelector('.check')
52
- check.classList.remove('hidden')
53
- this.selectedFilterColumn = event.target.closest('li').getAttribute('data-filter-column-name')
54
-
55
- let label = event.target.closest('li').querySelector('.label')
56
- this.descriptionTarget.innerText = label.innerText + ': '
57
-
58
- this.toggle()
59
- this.openValuePopover()
60
- }
61
-
62
- // Called when you entered/selected a filter value
63
- filterValueEntered(event) {
64
- this.valuePopoverTarget.classList.add('hidden')
65
-
66
- let url = this.ourUrl
67
-
68
- let filters = url.searchParams.get('filters') || {}
69
- this.mensaTableOutlet.mensaFilterOutlets.forEach((filterOutlet) => {
70
- url.searchParams.append(`filters[${filterOutlet.columnNameValue}][value]`, filterOutlet.valueValue)
71
- url.searchParams.append(`filters[${filterOutlet.columnNameValue}][operator]`, filterOutlet.operatorValue)
72
- })
73
- // FIXME: Needs better way of getting value
74
- url.searchParams.append(`filters[${this.selectedFilterColumn}][value]`, event.target.value)
75
-
76
- get(url, {
77
- responseKind: 'turbo-stream'
78
- }).then(() => {
79
- // FIXME: There should be a better way to do this, possibly using
80
- // this.mensaTableOutlet.filterListTarget.addEventListener("turbo:after-stream-render", this.unhide.bind(this)) ?
81
- setTimeout(() => {
82
- this.mensaTableOutlet.filterListTarget.classList.remove('hidden')
83
- }, 50)
84
- })
85
- event.preventDefault()
86
- return false
87
- }
88
- }
10
+ static outlets = ["mensa-filter-pill-list"];
11
+ static targets = [
12
+ "filterList", // all filters
13
+ "filterListItem", // individual filters
14
+ "description", // contains the filter description in the "tab"
15
+ "valuePopover", // contains the filter-value
16
+ "value",
17
+ "valueOption", // individual value list items in the popover
18
+ "operatorOption", // individual operator list items
19
+ ];
20
+ static values = {
21
+ operatorLabels: Object,
22
+ supportsViews: Boolean,
23
+ };
24
+
25
+ connect() {
26
+ super.connect();
27
+
28
+ // this.filterValueEntered = debounce(this.filterValueEntered, 500).bind(this)
29
+ // this.filterValueEntered = this.filterValueEntered.bind(this)
30
+ this._selectedFilterColumn = null;
31
+ this._outsideClickHandler = null;
32
+ this._columnLabel = null;
33
+ this._pendingPill = null;
34
+
35
+ // If a multi-select toggle triggered a refreshFilters() that destroyed and
36
+ // recreated this controller, re-open the value popover with the saved state.
37
+ if (_pendingMultiReopen) {
38
+ const reopen = _pendingMultiReopen;
39
+ _pendingMultiReopen = null;
40
+ // Defer one macrotask so Stimulus has time to wire up outlets.
41
+ setTimeout(() => {
42
+ if (this.hasMensaFilterPillListOutlet) {
43
+ this.editColumn(
44
+ reopen.column,
45
+ reopen.values,
46
+ reopen.operator || "is",
47
+ null,
48
+ );
49
+ }
50
+ }, 0);
51
+ }
52
+
53
+ // Prevent arrow keys from scrolling the page while any popup is open.
54
+ // Bound once here (not on each showList/hideList) to avoid timing issues
55
+ // with turbo-stream re-renders destroying the controller mid-flow.
56
+ this._arrowPreventHandler = (event) => {
57
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
58
+ const listOpen =
59
+ this.hasFilterListTarget &&
60
+ !this.filterListTarget.classList.contains("hidden");
61
+ if (listOpen || this.isValuePopoverOpen) event.preventDefault();
62
+ };
63
+ document.addEventListener("keydown", this._arrowPreventHandler);
64
+ }
65
+
66
+ disconnect() {
67
+ this._unbindOutsideClick();
68
+ if (this._arrowPreventHandler) {
69
+ document.removeEventListener("keydown", this._arrowPreventHandler);
70
+ this._arrowPreventHandler = null;
71
+ }
72
+ }
73
+
74
+ // Called when you click add-filter (legacy — kept for backward compatibility)
75
+ toggle(event) {
76
+ this.filterListTarget.classList.toggle("hidden");
77
+ }
78
+
79
+ // Called by the + button — always shows all columns regardless of current filter
80
+ openAllColumns(event) {
81
+ this.filterColumns("");
82
+ this.showList(event?.currentTarget);
83
+ }
84
+
85
+ // Called by the filter-pill-list controller when the search input is focused
86
+ // or receives input, so the column list appears under the search bar.
87
+ // triggerEl: the element that triggered this (search input or + button), used
88
+ // to align the dropdown horizontally to where the user actually is.
89
+ showList(triggerEl = null) {
90
+ this._triggerEl = triggerEl;
91
+ this.filterListTarget.classList.remove("hidden");
92
+ this._positionListUnderSearchBar(triggerEl);
93
+ this._bindOutsideClick();
94
+ }
95
+
96
+ hideList() {
97
+ this.filterListItemTargets.forEach((i) =>
98
+ i.classList.remove("highlighted"),
99
+ );
100
+ this.filterListTarget.classList.add("hidden");
101
+ this._unbindOutsideClick();
102
+ }
103
+
104
+ // Filters the visible column items to those matching `query`.
105
+ filterColumns(query) {
106
+ const q = (query || "").toLowerCase();
107
+ this.filterListItemTargets.forEach((item) => {
108
+ const label =
109
+ item.querySelector(".label")?.textContent?.toLowerCase() || "";
110
+ item.classList.toggle("hidden", q.length > 0 && !label.includes(q));
111
+ });
112
+ }
113
+
114
+ // Filters the value option items in the open value popover to those matching `query`.
115
+ // Operator options are never hidden — they don't represent data values.
116
+ filterValues(query) {
117
+ const q = (query || "").toLowerCase();
118
+ this.valueOptionTargets.forEach((item) => {
119
+ const label = (
120
+ item.dataset.label ||
121
+ item.dataset.value ||
122
+ ""
123
+ ).toLowerCase();
124
+ item.classList.toggle("hidden", q.length > 0 && !label.includes(q));
125
+ });
126
+ }
127
+
128
+ get visibleColumnCount() {
129
+ return this.filterListItemTargets.filter(
130
+ (i) => !i.classList.contains("hidden"),
131
+ ).length;
132
+ }
133
+
134
+ get hasHighlightedColumn() {
135
+ return !!this.filterListItemTargets.find((i) =>
136
+ i.classList.contains("highlighted"),
137
+ );
138
+ }
139
+
140
+ confirmHighlightedColumn() {
141
+ const item = this.filterListItemTargets.find((i) =>
142
+ i.classList.contains("highlighted"),
143
+ );
144
+ if (item) item.click();
145
+ }
146
+
147
+ // Re-opens the value popover for an already-applied filter (clicked pill),
148
+ // pre-selected to its current value. Routes through the same popover/flow as
149
+ // adding a brand new filter, so selecting a value re-requests the table.
150
+ editColumn(columnName, value, operator, anchor) {
151
+ this.selectedFilterColumn = columnName;
152
+ this.editingValue = value;
153
+ this.editingOperator = operator || null;
154
+ this.anchorElement = anchor;
155
+
156
+ // Resolve the human column label from the filter list so _updateDescription works.
157
+ const item = this.filterListItemTargets.find(
158
+ (li) => li.dataset.filterColumnName === columnName,
159
+ );
160
+ this._columnLabel =
161
+ item?.querySelector(".label")?.innerText ?? columnName;
162
+
163
+ this.openValuePopover();
164
+ }
165
+
166
+ // Keyboard navigation: move highlight down/up in the column list
167
+ highlightNext() {
168
+ const items = this.filterListItemTargets.filter(
169
+ (i) => !i.classList.contains("hidden"),
170
+ );
171
+ if (!items.length) return;
172
+ const current = items.findIndex((i) =>
173
+ i.classList.contains("highlighted"),
174
+ );
175
+ const next = current < items.length - 1 ? current + 1 : 0;
176
+ items.forEach((i) => i.classList.remove("highlighted"));
177
+ items[next].classList.add("highlighted");
178
+ items[next].scrollIntoView({ block: "nearest" });
179
+ }
180
+
181
+ highlightPrev() {
182
+ const items = this.filterListItemTargets.filter(
183
+ (i) => !i.classList.contains("hidden"),
184
+ );
185
+ if (!items.length) return;
186
+ const current = items.findIndex((i) =>
187
+ i.classList.contains("highlighted"),
188
+ );
189
+ const prev = current > 0 ? current - 1 : items.length - 1;
190
+ items.forEach((i) => i.classList.remove("highlighted"));
191
+ items[prev].classList.add("highlighted");
192
+ items[prev].scrollIntoView({ block: "nearest" });
193
+ }
194
+
195
+ // Mouse hover on column list items — keeps highlight in sync with cursor
196
+ columnItemHovered(event) {
197
+ this.filterListItemTargets.forEach((i) =>
198
+ i.classList.remove("highlighted"),
199
+ );
200
+ event.currentTarget.classList.add("highlighted");
201
+ }
202
+
203
+ // Keyboard navigation: move highlight down/up in the value popover (values + operators)
204
+ highlightNextValue() {
205
+ const items = this._valuePopoverItems;
206
+ if (!items.length) return;
207
+ const current = items.findIndex((i) =>
208
+ i.classList.contains("highlighted"),
209
+ );
210
+ const next = current < items.length - 1 ? current + 1 : 0;
211
+ items.forEach((i) => i.classList.remove("highlighted"));
212
+ items[next].classList.add("highlighted");
213
+ items[next].scrollIntoView({ block: "nearest" });
214
+ }
215
+
216
+ highlightPrevValue() {
217
+ const items = this._valuePopoverItems;
218
+ if (!items.length) return;
219
+ const current = items.findIndex((i) =>
220
+ i.classList.contains("highlighted"),
221
+ );
222
+ const prev = current > 0 ? current - 1 : items.length - 1;
223
+ items.forEach((i) => i.classList.remove("highlighted"));
224
+ items[prev].classList.add("highlighted");
225
+ items[prev].scrollIntoView({ block: "nearest" });
226
+ }
227
+
228
+ // Mouse hover on value/operator items — keeps highlight in sync with cursor
229
+ highlightItem(event) {
230
+ this._valuePopoverItems.forEach((i) =>
231
+ i.classList.remove("highlighted"),
232
+ );
233
+ event.currentTarget.classList.add("highlighted");
234
+ }
235
+
236
+ // Confirm the highlighted (or pre-selected) item in the value popover via Enter
237
+ confirmHighlightedValue() {
238
+ let item = this._valuePopoverItems.find((i) =>
239
+ i.classList.contains("highlighted"),
240
+ );
241
+ // Fall back to pre-selected value option if nothing is keyboard-highlighted (single only)
242
+ if (!item && !this.isMultipleMode && this.hasValueOptionTarget) {
243
+ item = this.valueOptionTargets.find(
244
+ (opt) => opt.dataset.selected === "true",
245
+ );
246
+ }
247
+ if (!item) {
248
+ if (this.hasValueTarget && this.valueTarget.value) {
249
+ this._applyManualValue();
250
+ }
251
+ return;
252
+ }
253
+ if (
254
+ this.hasValueOptionTarget &&
255
+ this.valueOptionTargets.includes(item)
256
+ ) {
257
+ if (this.isMultipleMode) {
258
+ this._toggleValueItem(item);
259
+ } else {
260
+ this._selectValueItem(item);
261
+ }
262
+ } else {
263
+ item.click(); // operator item
264
+ }
265
+ }
266
+
267
+ // Click handler for custom value list items
268
+ selectValue(event) {
269
+ if (this.isMultipleMode) {
270
+ this._toggleValueItem(event.currentTarget);
271
+ } else {
272
+ this._selectValueItem(event.currentTarget);
273
+ }
274
+ }
275
+
276
+ manualValueChanged() {
277
+ this._updateDescription();
278
+ }
279
+
280
+ applyManualValue(event) {
281
+ event.preventDefault();
282
+ this._applyManualValue();
283
+ }
284
+
285
+ get isValuePopoverOpen() {
286
+ return (
287
+ this.hasValuePopoverTarget &&
288
+ !this.valuePopoverTarget.classList.contains("hidden")
289
+ );
290
+ }
291
+
292
+ get isMultipleMode() {
293
+ if (!this.hasValuePopoverTarget) return false;
294
+ return (
295
+ this.valuePopoverTarget.querySelector(
296
+ ".mensa-table__add_filter__popover_container__values",
297
+ )?.dataset.multiple === "true"
298
+ );
299
+ }
300
+
301
+ // Returns all selected values. Array for multi-select; scalar string for single.
302
+ get selectedValues() {
303
+ if (this.isMultipleMode) {
304
+ return this.hasValueOptionTarget
305
+ ? this.valueOptionTargets
306
+ .filter((opt) => opt.dataset.selected === "true")
307
+ .map((opt) => opt.dataset.value)
308
+ : [];
309
+ }
310
+ return this.hasValueTarget ? this.valueTarget.value : "";
311
+ }
312
+
313
+ get hasHighlightedValue() {
314
+ return this._valuePopoverItems.some((i) =>
315
+ i.classList.contains("highlighted"),
316
+ );
317
+ }
318
+
319
+ get _valuePopoverItems() {
320
+ return [
321
+ ...(this.hasValueOptionTarget ? this.valueOptionTargets : []),
322
+ ...this.operatorOptionTargets,
323
+ ].filter((i) => !i.classList.contains("hidden"));
324
+ }
325
+
326
+ // Called when you selected a column
327
+ openValuePopover(event) {
328
+ let url = this.mensaFilterPillListOutlet.ourUrl;
329
+ url.pathname += `/filters/${this.selectedFilterColumn}`;
330
+ url.searchParams.append("target", this.valuePopoverTarget.id);
331
+ if (Array.isArray(this.editingValue)) {
332
+ this.editingValue.forEach((v) =>
333
+ url.searchParams.append("value[]", v),
334
+ );
335
+ } else if (this.editingValue) {
336
+ url.searchParams.append("value", this.editingValue);
337
+ }
338
+ if (this.editingOperator)
339
+ url.searchParams.append("operator", this.editingOperator);
340
+
341
+ get(url, {
342
+ responseKind: "turbo-stream",
343
+ }).then(() => {
344
+ this.valuePopoverTarget.classList.remove("hidden");
345
+ this.positionPopover();
346
+ this._bindOutsideClick();
347
+ // Restore focus to the search input so keyboard navigation (↑↓ Enter)
348
+ // keeps working — especially after the popover is re-opened programmatically
349
+ // following a multi-select turbo-stream re-render.
350
+ const manualInput = this._manualValueInput;
351
+ if (manualInput) {
352
+ manualInput.focus({ preventScroll: true });
353
+ manualInput.select?.();
354
+ } else if (this.hasMensaFilterPillListOutlet) {
355
+ const input =
356
+ this.mensaFilterPillListOutlet.searchInputElement?.();
357
+ if (input) input.focus({ preventScroll: true });
358
+ }
359
+ });
360
+ }
361
+
362
+ // Position the value popover below the search container. Uses fixed positioning
363
+ // with viewport coordinates so it escapes any ancestor overflow clipping.
364
+ positionPopover() {
365
+ const container =
366
+ this.element.closest(".mensa-table__search-container") ||
367
+ this.element.closest(".mensa-table__search-bar");
368
+
369
+ if (container) {
370
+ const containerRect = container.getBoundingClientRect();
371
+ this.valuePopoverTarget.style.top = `${containerRect.bottom + 4}px`;
372
+ }
373
+
374
+ if (this._pendingPill) {
375
+ // New filter: place popover at the right edge of the pending pill,
376
+ // directly below where the text cursor sits in the search input.
377
+ const rect = this._pendingPill.getBoundingClientRect();
378
+ this.valuePopoverTarget.style.left = `${rect.right}px`;
379
+ } else if (this.anchorElement) {
380
+ const anchor = this.anchorElement.getBoundingClientRect();
381
+ this.valuePopoverTarget.style.left = `${anchor.left}px`;
382
+ } else {
383
+ const ref = this._triggerEl || this.element;
384
+ const r = ref.getBoundingClientRect();
385
+ this.valuePopoverTarget.style.left = `${r.left}px`;
386
+ }
387
+ }
388
+
389
+ // Called when you select a column from the "dropdown"
390
+ selectColumn(event) {
391
+ this.filterListItemTargets.forEach((lt) => {
392
+ let check = lt.querySelector(".check");
393
+ check.classList.add("hidden");
394
+ });
395
+ let check = event.target.closest("li").querySelector(".check");
396
+ check.classList.remove("hidden");
397
+ this.selectedFilterColumn = event.target
398
+ .closest("li")
399
+ .getAttribute("data-filter-column-name");
400
+ this.anchorElement = null;
401
+
402
+ let label = event.target.closest("li").querySelector(".label");
403
+ this._columnLabel = label.innerText;
404
+ if (this.hasDescriptionTarget)
405
+ this.descriptionTarget.innerText = `${this._columnLabel} is`;
406
+
407
+ this._showPendingPill(this._columnLabel);
408
+ this.hideList();
409
+ this.openValuePopover();
410
+ }
411
+
412
+ // Called when an operator list item is clicked
413
+ selectOperator(event) {
414
+ const selected = event.currentTarget;
415
+ this.operatorOptionTargets.forEach((opt) => {
416
+ const check = opt.querySelector(
417
+ ".mensa-table__add_filter__popover_container__operator__check",
418
+ );
419
+ if (opt === selected) {
420
+ opt.dataset.selected = "true";
421
+ check?.classList.remove("invisible");
422
+ } else {
423
+ delete opt.dataset.selected;
424
+ check?.classList.add("invisible");
425
+ }
426
+ });
427
+
428
+ const requiresValue = this.operatorRequiresValue(
429
+ selected.dataset.operator,
430
+ );
431
+ if (!requiresValue) {
432
+ if (this.hasValueTarget) this.valueTarget.value = "";
433
+ if (this.hasValueOptionTarget) {
434
+ this.valueOptionTargets.forEach((opt) => {
435
+ delete opt.dataset.selected;
436
+ const check = opt.querySelector(
437
+ ".mensa-table__add_filter__popover_container__value__check",
438
+ );
439
+ check?.classList.add("invisible");
440
+ });
441
+ }
442
+ }
443
+
444
+ // Always reflect the new operator in the description immediately.
445
+ this._updateDescription();
446
+
447
+ // Apply to the table immediately when a value is already selected.
448
+ if (!requiresValue) {
449
+ this.mensaFilterPillListOutlet.refreshFilters();
450
+ } else if (this.isMultipleMode) {
451
+ if (this.selectedValues.length > 0) {
452
+ _pendingMultiReopen = {
453
+ column: this._selectedFilterColumn,
454
+ values: this.selectedValues,
455
+ operator: this.operator,
456
+ };
457
+ this.mensaFilterPillListOutlet.refreshFilters();
458
+ }
459
+ } else if (this.hasValueTarget && this.valueTarget.value) {
460
+ this.mensaFilterPillListOutlet.refreshFilters();
461
+ }
462
+ }
463
+
464
+ // Returns the currently selected operator, defaulting to "is"
465
+ get operator() {
466
+ if (!this.hasOperatorOptionTarget) return "is";
467
+ const selected = this.operatorOptionTargets.find(
468
+ (opt) => opt.dataset.selected === "true",
469
+ );
470
+ return selected?.dataset.operator ?? "is";
471
+ }
472
+
473
+ operatorRequiresValue(operator = this.operator) {
474
+ const selected = this.operatorOptionTargets.find(
475
+ (opt) => opt.dataset.operator === operator,
476
+ );
477
+ return selected ? selected.dataset.requiresValue !== "false" : true;
478
+ }
479
+
480
+ // Called via Escape — close the value popover.
481
+ closeValuePopover() {
482
+ this._closePopover();
483
+ }
484
+
485
+ // Called by the Clear link.
486
+ reset(event) {
487
+ if (this.hasDescriptionTarget)
488
+ this.descriptionTarget.innerText = "Add filter";
489
+ this.selectedFilterColumn = null;
490
+ this._columnLabel = null;
491
+ this._removePendingPill();
492
+ this._closePopover();
493
+ }
494
+
495
+ get selectedFilterColumn() {
496
+ return this._selectedFilterColumn;
497
+ }
498
+
499
+ set selectedFilterColumn(value) {
500
+ this._selectedFilterColumn = value;
501
+ }
502
+
503
+ // --- private ---
504
+
505
+ get operatorLabel() {
506
+ return this.operatorLabelsValue?.[this.operator] ?? this.operator;
507
+ }
508
+
509
+ _updateDescription() {
510
+ if (!this._columnLabel) return;
511
+
512
+ let valueLabel;
513
+ if (this.isMultipleMode) {
514
+ const labels = this.hasValueOptionTarget
515
+ ? this.valueOptionTargets
516
+ .filter((opt) => opt.dataset.selected === "true")
517
+ .map((opt) => opt.dataset.label || opt.dataset.value)
518
+ : [];
519
+ valueLabel = labels.length > 0 ? labels.join(", ") : null;
520
+ } else {
521
+ const value = this.hasValueTarget ? this.valueTarget.value : "";
522
+ if (value) {
523
+ const option = this.hasValueOptionTarget
524
+ ? this.valueOptionTargets.find(
525
+ (opt) => opt.dataset.value === value,
526
+ )
527
+ : null;
528
+ valueLabel = option?.dataset.label || value;
529
+ }
530
+ }
531
+
532
+ const text = valueLabel
533
+ ? `${this._columnLabel} ${this.operatorLabel} ${valueLabel}`
534
+ : `${this._columnLabel} ${this.operatorLabel}`;
535
+
536
+ if (this.hasDescriptionTarget) {
537
+ this.descriptionTarget.innerText = text;
538
+ }
539
+ this._updatePendingPill(valueLabel);
540
+ }
541
+
542
+ _toggleValueItem(item) {
543
+ const isSelected = item.dataset.selected === "true";
544
+ const newState = !isSelected;
545
+ item.dataset.selected = newState ? "true" : "";
546
+ const checkbox = item.querySelector(
547
+ ".mensa-table__add_filter__checkbox",
548
+ );
549
+ if (checkbox)
550
+ checkbox.classList.toggle(
551
+ "mensa-table__add_filter__checkbox--checked",
552
+ newState,
553
+ );
554
+ this._updateDescription();
555
+ // Stash reopen state before the turbo-stream destroys both this controller
556
+ // and the filter-pill-list controller. The new add-filter instance reads
557
+ // this in connect() and re-opens the popover after outlets are wired.
558
+ _pendingMultiReopen = {
559
+ column: this._selectedFilterColumn,
560
+ values: this.selectedValues,
561
+ operator: this.operator,
562
+ };
563
+ this.mensaFilterPillListOutlet.refreshFilters();
564
+ }
565
+
566
+ _selectValueItem(item) {
567
+ const value = item.dataset.value;
568
+ if (this.hasValueTarget) this.valueTarget.value = value;
569
+ if (this.hasValueOptionTarget) {
570
+ this.valueOptionTargets.forEach((opt) => {
571
+ const check = opt.querySelector(
572
+ ".mensa-table__add_filter__popover_container__value__check",
573
+ );
574
+ const isSelected = opt.dataset.value === value;
575
+ opt.dataset.selected = isSelected ? "true" : "";
576
+ check?.classList.toggle("invisible", !isSelected);
577
+ });
578
+ }
579
+ this._updateDescription();
580
+ this._removePendingPill();
581
+ this.mensaFilterPillListOutlet.refreshFilters();
582
+ }
583
+
584
+ _applyManualValue() {
585
+ if (!this.hasValueTarget || !this.valueTarget.value) return;
586
+ this._updateDescription();
587
+ this._removePendingPill();
588
+ this.mensaFilterPillListOutlet.refreshFilters();
589
+ }
590
+
591
+ // Position the column list below the search container, aligned to the trigger
592
+ // element (search input or + button). Uses fixed positioning with viewport
593
+ // coordinates so it escapes any ancestor overflow clipping.
594
+ _positionListUnderSearchBar(triggerEl = null) {
595
+ const container =
596
+ this.element.closest(".mensa-table__search-container") ||
597
+ this.element.closest(".mensa-table__search-bar");
598
+ if (!container) return;
599
+ const containerRect = container.getBoundingClientRect();
600
+
601
+ this.filterListTarget.style.top = `${containerRect.bottom + 4}px`;
602
+ this.filterListTarget.style.minWidth = "16rem";
603
+
604
+ const anchor = triggerEl || this.element;
605
+ const anchorRect = anchor.getBoundingClientRect();
606
+ this.filterListTarget.style.left = `${anchorRect.left}px`;
607
+ }
608
+
609
+ _closePopover() {
610
+ this._valuePopoverItems.forEach((i) =>
611
+ i.classList.remove("highlighted"),
612
+ );
613
+ this.editingValue = null;
614
+ this.editingOperator = null;
615
+ this.anchorElement = null;
616
+ this._removePendingPill();
617
+ this.valuePopoverTarget.classList.add("hidden");
618
+ this.valuePopoverTarget.style.left = "";
619
+ this.valuePopoverTarget.style.top = "";
620
+ this._unbindOutsideClick();
621
+ }
622
+
623
+ _showPendingPill(columnLabel) {
624
+ this._removePendingPill();
625
+ const pill = document.createElement("div");
626
+ pill.className = "mensa-filter-pill mensa-filter-pill--pending";
627
+ pill.innerHTML = `<div class="mensa-filter-pill__chip mensa-filter-pill__chip--pending"><span class="mensa-filter-pill__column">${this._escapeHtml(columnLabel)}</span><span class="mensa-filter-pill__operator">${this._escapeHtml(this.operatorLabel)}</span></div>`;
628
+ this.element.insertAdjacentElement("beforebegin", pill);
629
+ this._pendingPill = pill;
630
+ }
631
+
632
+ _updatePendingPill(valueLabel = null) {
633
+ if (!this._pendingPill) return;
634
+
635
+ const operator = this._pendingPill.querySelector(
636
+ ".mensa-filter-pill__operator",
637
+ );
638
+ if (operator) operator.textContent = this.operatorLabel;
639
+
640
+ let value = this._pendingPill.querySelector(
641
+ ".mensa-filter-pill__value",
642
+ );
643
+ if (valueLabel) {
644
+ if (!value) {
645
+ value = document.createElement("span");
646
+ value.className = "mensa-filter-pill__value";
647
+ this._pendingPill
648
+ .querySelector(".mensa-filter-pill__chip")
649
+ ?.appendChild(value);
650
+ }
651
+ value.textContent = valueLabel;
652
+ } else if (value) {
653
+ value.remove();
654
+ }
655
+ }
656
+
657
+ _removePendingPill() {
658
+ if (this._pendingPill) {
659
+ this._pendingPill.remove();
660
+ this._pendingPill = null;
661
+ }
662
+ }
663
+
664
+ _escapeHtml(str) {
665
+ return String(str)
666
+ .replace(/&/g, "&amp;")
667
+ .replace(/</g, "&lt;")
668
+ .replace(/>/g, "&gt;")
669
+ .replace(/"/g, "&quot;");
670
+ }
671
+
672
+ get _manualValueInput() {
673
+ if (!this.hasValueTarget) return null;
674
+ const input = this.valueTarget;
675
+ return input.matches('input:not([type="hidden"])') ? input : null;
676
+ }
677
+
678
+ _bindOutsideClick() {
679
+ this._unbindOutsideClick();
680
+ this._outsideClickHandler = (event) => {
681
+ if (this.element.contains(event.target)) return;
682
+ // Keep open if user clicks within the search bar (so typing still works)
683
+ const searchBar = this.element.closest(".mensa-table__search-bar");
684
+ if (searchBar && searchBar.contains(event.target)) return;
685
+ this.hideList();
686
+ this._closePopover();
687
+ };
688
+ // Defer so the current click that opened the popover doesn't immediately close it
689
+ setTimeout(() => {
690
+ document.addEventListener("click", this._outsideClickHandler);
691
+ }, 0);
692
+ }
693
+
694
+ _unbindOutsideClick() {
695
+ if (this._outsideClickHandler) {
696
+ document.removeEventListener("click", this._outsideClickHandler);
697
+ this._outsideClickHandler = null;
698
+ }
699
+ }
700
+ }