mensa 0.3.4 → 0.5.0

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.zed/tasks.json +1 -1
  3. data/Gemfile.lock +1 -4
  4. data/README.md +26 -14
  5. data/app/components/mensa/add_filter/component.html.erb +1 -1
  6. data/app/components/mensa/column_customizer/component_controller.js +1 -1
  7. data/app/components/mensa/control_bar/component.css +23 -3
  8. data/app/components/mensa/control_bar/component.html.erb +5 -5
  9. data/app/components/mensa/empty_state/component.rb +1 -1
  10. data/app/components/mensa/filter_pill/component.css +17 -3
  11. data/app/components/mensa/table/component.html.erb +1 -1
  12. data/app/components/mensa/table/component.rb +3 -3
  13. data/app/components/mensa/table/component_controller.js +97 -28
  14. data/app/components/mensa/view/component.html.erb +1 -1
  15. data/app/components/mensa/view/component.rb +1 -1
  16. data/app/controllers/mensa/tables/exports_controller.rb +25 -5
  17. data/app/controllers/mensa/tables_controller.rb +7 -9
  18. data/app/helpers/mensa/application_helper.rb +2 -2
  19. data/app/jobs/mensa/export_job.rb +4 -0
  20. data/app/jobs/mensa/recurring_exports_job.rb +17 -0
  21. data/app/models/mensa/export.rb +54 -2
  22. data/app/tables/mensa/base.rb +52 -12
  23. data/app/tables/mensa/column.rb +16 -6
  24. data/app/tables/mensa/config/dsl_logic.rb +2 -2
  25. data/app/tables/mensa/config/table_dsl.rb +2 -0
  26. data/app/tables/mensa/filter.rb +8 -1
  27. data/app/tables/mensa/scope.rb +14 -13
  28. data/app/tables/mensa/search.rb +70 -0
  29. data/app/views/mensa/exports/_dialog.html.erb +23 -0
  30. data/app/views/mensa/exports/_list.html.erb +13 -0
  31. data/app/views/mensa/tables/show.html.erb +0 -2
  32. data/app/views/mensa/tables/standard_error.html.erb +9 -0
  33. data/config/locales/en.yml +22 -0
  34. data/config/locales/nl.yml +21 -0
  35. data/config/routes.rb +1 -1
  36. data/db/migrate/20260612110000_add_repeat_to_mensa_exports.rb +8 -0
  37. data/lib/mensa/configuration.rb +6 -7
  38. data/lib/mensa/engine.rb +0 -1
  39. data/lib/mensa/version.rb +1 -1
  40. data/lib/tasks/mensa_tasks.rake +7 -0
  41. data/mensa.gemspec +0 -1
  42. metadata +6 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 194b34ee31bebddc3962cc66e5c789e5369f828eb6087f67f72b54aba97f65d8
4
- data.tar.gz: 1ead92c4d986f5e69c3d5745fcee27541f8205bf0106bd25f757b7603cdc117f
3
+ metadata.gz: 5ad9a06fc13439d733db95cc8d3c0142247291057d8a5c84fce0f090f8c96a63
4
+ data.tar.gz: fef0dcdcaf8b21e038af8baa90f3509016372f30998a7bdd0cb94c3050a19444
5
5
  SHA512:
6
- metadata.gz: f5c078859d1c6214b8b65ab36fd077a2c36e2f5b50a69efd0dc20621d6373977be10ed543bc68add8b81338b4322903c3c305a15a0bd47c8ea906d77953c2a9d
7
- data.tar.gz: 6e68ccdd3ffea1ef460876f61a9d624a8d7de198addad7c712bfac67eac1cc08fbe0874221ba76f241ce5d47c8dec0b09c7950f058865ee232ca540868bea95d
6
+ metadata.gz: e86a46aab67adf045e5939e9d307732e7752ccc50f3bae26b67dc9176ecb00508405b9b8c9c146fd7f0eb88a71f2aeba7f9f49fc943e3b8bb6910cbd1858e2d9
7
+ data.tar.gz: 89e4d11db3e400215fdbce8cd5b031577a8c254cb92fc37f99c7ebbb726609c7d5e70c7bfb143a529f7ae6e9d5290d1bf4df314b6ca4e65384ebb20198f038dd
data/.zed/tasks.json CHANGED
@@ -1,7 +1,7 @@
1
1
  [
2
2
  {
3
3
  "label": "Rails server",
4
- "command": "bin/rails server -p 3000 -b 0.0.0.0",
4
+ "command": "pkill -f 'puma' && bin/rails server -p 3000 -b 0.0.0.0",
5
5
  "tags": ["rails"]
6
6
  },
7
7
  {
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mensa (0.3.3)
4
+ mensa (0.4.0)
5
5
  csv
6
6
  importmap-rails
7
7
  pagy (>= 43)
@@ -10,7 +10,6 @@ PATH
10
10
  rubyzip (>= 1.2.2)
11
11
  stimulus-rails
12
12
  tailwindcss-rails (~> 3.3)
13
- textacular (>= 5)
14
13
  turbo-rails
15
14
  view_component (~> 3.11)
16
15
 
@@ -337,8 +336,6 @@ GEM
337
336
  tailwindcss-ruby (3.4.19-arm64-darwin)
338
337
  tailwindcss-ruby (3.4.19-x86_64-darwin)
339
338
  tailwindcss-ruby (3.4.19-x86_64-linux)
340
- textacular (5.7.0)
341
- activerecord (>= 5.0)
342
339
  thor (1.5.0)
343
340
  timeout (0.6.1)
344
341
  tsort (0.2.0)
data/README.md CHANGED
@@ -9,21 +9,19 @@ Due to search, it only works with postgresql at the moment.
9
9
  ![export](./docs/export.png)
10
10
 
11
11
  Features:
12
-
13
- - [x] very fast
14
- - [x] row-links
15
- - [x] sorting
16
- - [x] filtering of multiple columns
12
+ - [x] Very fast
13
+ - [x] Row-links
14
+ - [x] Sorting
15
+ - [x] Filtering of multiple columns
17
16
  - [X] Hide filter icon in case there are no filters
18
- - [X] column ordering
19
- - [X] editing of existing filters
20
- - [X] view selection and exports per view
21
- - [X] multiple selection of rows and batch processing
22
- - [x] tables without headers (and without most of the above)
23
-
24
- Todo/Fixme:
25
- - [ ] exports can be mailed - daily/weekly/monthly/quarterly/bi-yearly/yearly (time configurable)
26
- - [ ] Search only works on table text columns
17
+ - [X] Column ordering
18
+ - [X] Editing of existing filters
19
+ - [X] View selection and exports per view
20
+ - [X] Multiple selection of rows and batch processing
21
+ - [x] Tables without headers (and without most of the above)
22
+ - [X] Search works on all table columns
23
+ - [X] Exports can be scheduled to run recurring (daily/weekly/monthly/quarterly/bi-yearly/yearly)
24
+ You will have to bring your own mailer, see configuration for details.
27
25
 
28
26
  Nice to haves:
29
27
 
@@ -191,6 +189,20 @@ Exporting is built into the table's control bar. Clicking the export button open
191
189
  a dialog that lists the user's previous downloads and lets them request a new
192
190
  export (scope and CSV format).
193
191
 
192
+ #### Repeating exports
193
+
194
+ The user can choose to export a table on a regular basis (daily, weekly, monthly, quarterly, bi-yearly, yearly).
195
+
196
+ When the user selects a repeating export, the table will be exported automatically on the specified schedule.
197
+
198
+ For this to work you need to have a cron job which runs daily.
199
+ When using `sidekiq-cron` or `goodjob` the `RecurringExportsJob` needs to be scheduled to run daily.
200
+
201
+ If you're just using cron, you can add the following to your crontab:
202
+ ```
203
+ 0 0 * * * rails runner "Mensa::RecurringExportsJob.perform_later"
204
+ ```
205
+
194
206
  ## Contributing
195
207
 
196
208
  ```
@@ -4,7 +4,7 @@
4
4
  data-mensa-add-filter-operator-labels-value="<%= operator_labels.to_json %>">
5
5
  <button class="mensa-table__add_filter__trigger"
6
6
  type="button"
7
- title="<%= t("mensa.add_filter.add", default: "Add filter") %>"
7
+ title="<%= t("mensa.add_filter.add") %>"
8
8
  data-action="mensa-add-filter#openAllColumns">
9
9
  <i class="fa-solid fa-circle-plus"></i>
10
10
  </button>
@@ -273,7 +273,7 @@ export default class ColumnCustomizerController extends ApplicationController {
273
273
  if (!urlStr) return false;
274
274
 
275
275
  try {
276
- const url = new URL(urlStr);
276
+ const url = new URL(urlStr, window.location.origin);
277
277
 
278
278
  const toDelete = [];
279
279
  url.searchParams.forEach((_, key) => {
@@ -62,19 +62,31 @@
62
62
  }
63
63
 
64
64
  &__item {
65
- @apply flex shrink-0 items-center justify-between gap-3 px-3 py-2.5;
65
+ @apply flex shrink-0 items-center gap-3 px-3 py-2.5;
66
+ }
67
+
68
+ &__delete-form {
69
+ @apply shrink-0;
70
+ }
71
+
72
+ &__delete {
73
+ @apply inline-flex h-9 w-9 items-center justify-center rounded-md text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-red-600 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-red-500/30 transition-colors cursor-pointer;
66
74
  }
67
75
 
68
76
  &__item-info {
69
- @apply flex flex-col min-w-0;
77
+ @apply flex flex-col min-w-0 flex-1;
70
78
  }
71
79
 
72
80
  &__item-name {
73
81
  @apply text-sm font-medium text-gray-900 dark:text-gray-100 truncate;
74
82
  }
75
83
 
84
+ &__item-repeat {
85
+ @apply text-xs font-normal text-gray-500 dark:text-gray-400;
86
+ }
87
+
76
88
  &__item-meta {
77
- @apply text-xs text-gray-500 dark:text-gray-400;
89
+ @apply text-xs font-normal text-gray-400 dark:text-gray-500;
78
90
  }
79
91
 
80
92
  &__download {
@@ -109,6 +121,14 @@
109
121
  }
110
122
  }
111
123
 
124
+ &__repeat-options {
125
+ @apply pl-6;
126
+ }
127
+
128
+ &__select {
129
+ @apply w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-opacity-20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100;
130
+ }
131
+
112
132
  &__actions {
113
133
  @apply flex justify-end gap-2 pt-1;
114
134
  }
@@ -11,28 +11,28 @@
11
11
  <% if table.current_user && table.supports_custom_views? %>
12
12
  <div class="relative">
13
13
  <button class="mensa-table__control_bar__button hidden" type="button" data-mensa-table-target="saveSimple" data-action="mensa-table#saveAsNewView">
14
- <%= t(".save", default: "Save") %>
14
+ <%= t("mensa.save") %>
15
15
  </button>
16
16
  <button class="mensa-table__control_bar__button hidden" type="button" data-mensa-table-target="saveSplit" data-action="mensa-table#toggleSaveDropdown">
17
- <%= t(".save", default: "Save") %>
17
+ <%= t("mensa.save") %>
18
18
  <i class="fa-solid fa-chevron-down text-xs"></i>
19
19
  </button>
20
20
  <ul class="mensa-table__control_bar__save-dropdown hidden" data-mensa-table-target="saveDropdown">
21
21
  <li>
22
22
  <button class="mensa-table__control_bar__save-dropdown-item" type="button" data-action="mensa-table#updateCurrentViewAction">
23
- <%= t(".update_view", default: "Update view") %>
23
+ <%= t("mensa.update_view") %>
24
24
  </button>
25
25
  </li>
26
26
  <li>
27
27
  <button class="mensa-table__control_bar__save-dropdown-item" type="button" data-action="mensa-table#saveAsNewView">
28
- <%= t(".save_as_new_view", default: "Save as new view") %>
28
+ <%= t("mensa.save_as_new_view") %>
29
29
  </button>
30
30
  </li>
31
31
  </ul>
32
32
  </div>
33
33
  <% else %>
34
34
  <button class="mensa-table__control_bar__button" type="button" data-action="mensa-table#saveAsNewView">
35
- <%= t(".save", default: "Save") %>
35
+ <%= t("mensa.save") %>
36
36
  </button>
37
37
  <% end %>
38
38
  </div>
@@ -11,7 +11,7 @@ module Mensa
11
11
 
12
12
  # "orders", "users", etc. — used inside the translated heading.
13
13
  def model_name_plural
14
- table.model.model_name.human.pluralize.downcase
14
+ table.model.model_name.human(count: 2).downcase
15
15
  end
16
16
  end
17
17
  end
@@ -1,8 +1,13 @@
1
1
  .mensa-filter-pill {
2
- @apply inline-flex items-stretch text-xs;
2
+ @apply inline-flex items-stretch text-xs rounded-md overflow-hidden;
3
3
 
4
4
  &__chip {
5
- @apply flex items-center gap-1 pl-2 pr-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded-l-md cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors;
5
+ @apply flex items-center gap-1 pl-2 pr-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded-md cursor-pointer transition-all;
6
+ }
7
+
8
+ &:hover &__chip,
9
+ &:focus-within &__chip {
10
+ @apply bg-gray-200 dark:bg-gray-600 rounded-r-none;
6
11
  }
7
12
 
8
13
  &__column {
@@ -18,6 +23,15 @@
18
23
  }
19
24
 
20
25
  &__remove {
21
- @apply inline-flex items-center justify-center px-1.5 text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-r-md hover:text-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 dark:hover:text-gray-200 cursor-pointer transition-colors;
26
+ @apply inline-flex items-center justify-center w-0 px-0 text-gray-400 bg-gray-100 dark:bg-gray-700 cursor-pointer overflow-hidden opacity-0 -translate-x-1 pointer-events-none transition-all duration-200;
27
+ }
28
+
29
+ &:hover &__remove,
30
+ &:focus-within &__remove {
31
+ @apply w-7 px-1.5 opacity-100 translate-x-0 pointer-events-auto;
32
+ }
33
+
34
+ &__remove:hover {
35
+ @apply text-gray-700 bg-gray-200 dark:bg-gray-600 dark:text-gray-200;
22
36
  }
23
37
  }
@@ -1,7 +1,7 @@
1
1
  <div class="mensa-table"
2
2
  id="table-<%= table.table_id %>"
3
3
  data-mensa-table-supports-views-value="<%= table.supports_views? %>"
4
- data-mensa-table-table-url-value="<%= helpers.mensa.table_url(table.name, {turbo_frame_id: table.table_id}.merge(params)) %>"
4
+ data-mensa-table-table-url-value="<%= table.path(turbo_frame_id: table.table_id, user_params: params) %>"
5
5
  data-mensa-table-save-view-url-value="<%= helpers.mensa.table_views_path(table.name) %>"
6
6
  data-mensa-table-views-url-value="<%= helpers.mensa.table_views_path(table.name) %>"
7
7
  data-mensa-table-exports-url-value="<%= helpers.mensa.table_exports_path(table.name) %>"
@@ -8,11 +8,11 @@ module Mensa
8
8
  attr_reader :table
9
9
  attr_reader :params
10
10
 
11
- def initialize(table_name, config = {}, **options)
12
- @table = Mensa.for_name(table_name, config)
11
+ def initialize(table_name, params: {}, **options)
12
+ @params = params
13
+ @table = Mensa.for_name(table_name, {params: params})
13
14
  @table.original_view_context = options[:original_view_context]
14
15
  @table.component = self
15
- @params = options[:params] || {}
16
16
  end
17
17
  end
18
18
  end
@@ -43,7 +43,10 @@ export default class TableComponentController extends ApplicationController {
43
43
 
44
44
  // Close save dropdown when clicking outside
45
45
  this._saveDropdownOutsideHandler = (e) => {
46
- if (this.hasSaveDropdownTarget && !this.saveDropdownTarget.classList.contains("hidden")) {
46
+ if (
47
+ this.hasSaveDropdownTarget &&
48
+ !this.saveDropdownTarget.classList.contains("hidden")
49
+ ) {
47
50
  const saveArea = this.saveDropdownTarget.closest(".relative");
48
51
  if (saveArea && !saveArea.contains(e.target)) {
49
52
  this.saveDropdownTarget.classList.add("hidden");
@@ -98,12 +101,15 @@ export default class TableComponentController extends ApplicationController {
98
101
  const outlet = this.mensaFilterPillListOutlet;
99
102
  const state = {
100
103
  filters: outlet.loadFilters(),
101
- query: outlet.loadQuery(),
102
- view: outlet.loadView(),
103
- order: outlet.loadOrder(),
104
- page: outlet.loadPage(),
104
+ query: outlet.loadQuery(),
105
+ view: outlet.loadView(),
106
+ order: outlet.loadOrder(),
107
+ page: outlet.loadPage(),
105
108
  };
106
- this.turboFrameTarget.setAttribute("src", outlet.buildUrl(state).toString());
109
+ this.turboFrameTarget.setAttribute(
110
+ "src",
111
+ outlet.buildUrl(state).toString(),
112
+ );
107
113
  } else if (this.hasTableUrlValue) {
108
114
  this.turboFrameTarget.setAttribute("src", this.tableUrlValue);
109
115
  }
@@ -135,7 +141,8 @@ export default class TableComponentController extends ApplicationController {
135
141
  cancelFiltersAndSearch(event) {
136
142
  if (event) event.preventDefault();
137
143
  this.hideSaveReset();
138
- if (this.hasSaveDropdownTarget) this.saveDropdownTarget.classList.add("hidden");
144
+ if (this.hasSaveDropdownTarget)
145
+ this.saveDropdownTarget.classList.add("hidden");
139
146
 
140
147
  if (this.hasMensaFilterPillListOutlet) {
141
148
  this.mensaFilterPillListOutlet.clearFiltersAndSearch();
@@ -164,14 +171,16 @@ export default class TableComponentController extends ApplicationController {
164
171
  // "Save as new view" — always opens the name dialog
165
172
  saveAsNewView(event) {
166
173
  if (event) event.preventDefault();
167
- if (this.hasSaveDropdownTarget) this.saveDropdownTarget.classList.add("hidden");
174
+ if (this.hasSaveDropdownTarget)
175
+ this.saveDropdownTarget.classList.add("hidden");
168
176
  this._openSaveDialog();
169
177
  }
170
178
 
171
179
  // "Update view" — updates the currently selected user-owned view in place
172
180
  async updateCurrentViewAction(event) {
173
181
  if (event) event.preventDefault();
174
- if (this.hasSaveDropdownTarget) this.saveDropdownTarget.classList.add("hidden");
182
+ if (this.hasSaveDropdownTarget)
183
+ this.saveDropdownTarget.classList.add("hidden");
175
184
 
176
185
  const viewId = this._selectedUserViewId();
177
186
  if (!viewId) {
@@ -204,7 +213,9 @@ export default class TableComponentController extends ApplicationController {
204
213
  order: state.order,
205
214
  column_order: state.column_order,
206
215
  hidden_columns: state.hidden_columns,
207
- turbo_frame_id: this.hasTurboFrameTarget ? this.turboFrameTarget.id : null,
216
+ turbo_frame_id: this.hasTurboFrameTarget
217
+ ? this.turboFrameTarget.id
218
+ : null,
208
219
  }),
209
220
  contentType: "application/json",
210
221
  responseKind: "turbo-stream",
@@ -239,9 +250,12 @@ export default class TableComponentController extends ApplicationController {
239
250
  async confirmSaveView(event) {
240
251
  event.preventDefault();
241
252
 
242
- const name = this.hasSaveViewNameTarget ? this.saveViewNameTarget.value.trim() : "";
253
+ const name = this.hasSaveViewNameTarget
254
+ ? this.saveViewNameTarget.value.trim()
255
+ : "";
243
256
  if (!name) {
244
- if (this.hasSaveViewNameTarget) this.saveViewNameTarget.reportValidity();
257
+ if (this.hasSaveViewNameTarget)
258
+ this.saveViewNameTarget.reportValidity();
245
259
  return;
246
260
  }
247
261
 
@@ -260,7 +274,9 @@ export default class TableComponentController extends ApplicationController {
260
274
  order: state.order,
261
275
  column_order: state.column_order,
262
276
  hidden_columns: state.hidden_columns,
263
- turbo_frame_id: this.hasTurboFrameTarget ? this.turboFrameTarget.id : null,
277
+ turbo_frame_id: this.hasTurboFrameTarget
278
+ ? this.turboFrameTarget.id
279
+ : null,
264
280
  }),
265
281
  contentType: "application/json",
266
282
  responseKind: "turbo-stream",
@@ -307,7 +323,10 @@ export default class TableComponentController extends ApplicationController {
307
323
  const hasViewFilters = this._hasViewFilterPills();
308
324
  if (hasViewFilters) {
309
325
  const visible = this._loadViewFiltersVisible();
310
- this.element.classList.toggle("mensa-table--view-filters-hidden", !visible);
326
+ this.element.classList.toggle(
327
+ "mensa-table--view-filters-hidden",
328
+ !visible,
329
+ );
311
330
  } else {
312
331
  this.element.classList.remove("mensa-table--view-filters-hidden");
313
332
  }
@@ -328,13 +347,18 @@ export default class TableComponentController extends ApplicationController {
328
347
 
329
348
  toggleViewFilters(event) {
330
349
  if (event) event.preventDefault();
331
- const nowHidden = this.element.classList.toggle("mensa-table--view-filters-hidden");
350
+ const nowHidden = this.element.classList.toggle(
351
+ "mensa-table--view-filters-hidden",
352
+ );
332
353
  this._saveViewFiltersVisible(!nowHidden);
333
354
  this._updateEyeButton();
334
355
  }
335
356
 
336
357
  _hasViewFilterPills() {
337
- return this.element.querySelectorAll('[data-view-filter="true"]').length > 0;
358
+ return (
359
+ this.element.querySelectorAll('[data-view-filter="true"]').length >
360
+ 0
361
+ );
338
362
  }
339
363
 
340
364
  // Only updates button visibility and icon — never touches the hidden class.
@@ -348,7 +372,9 @@ export default class TableComponentController extends ApplicationController {
348
372
  this.eyeButtonTarget.classList.toggle("hidden", !hasViewFilters);
349
373
 
350
374
  if (hasViewFilters) {
351
- const hidden = this.element.classList.contains("mensa-table--view-filters-hidden");
375
+ const hidden = this.element.classList.contains(
376
+ "mensa-table--view-filters-hidden",
377
+ );
352
378
  // Replace innerHTML so FontAwesome's MutationObserver re-processes the new <i>
353
379
  this.eyeButtonTarget.innerHTML = hidden
354
380
  ? '<i class="fa-solid fa-eye"></i>'
@@ -366,13 +392,18 @@ export default class TableComponentController extends ApplicationController {
366
392
  if (this.hasMensaFilterPillListOutlet) {
367
393
  return this.mensaFilterPillListOutlet.tableNameValue;
368
394
  }
369
- const el = this.element.querySelector("[data-mensa-filter-pill-list-table-name-value]");
395
+ const el = this.element.querySelector(
396
+ "[data-mensa-filter-pill-list-table-name-value]",
397
+ );
370
398
  return el?.dataset?.mensaFilterPillListTableNameValue || "";
371
399
  }
372
400
 
373
401
  _loadViewFiltersVisible() {
374
402
  try {
375
- return window.localStorage.getItem(this._viewFiltersStorageKey()) === "true";
403
+ return (
404
+ window.localStorage.getItem(this._viewFiltersStorageKey()) ===
405
+ "true"
406
+ );
376
407
  } catch (e) {
377
408
  return false;
378
409
  }
@@ -381,7 +412,10 @@ export default class TableComponentController extends ApplicationController {
381
412
  _saveViewFiltersVisible(visible) {
382
413
  try {
383
414
  if (visible) {
384
- window.localStorage.setItem(this._viewFiltersStorageKey(), "true");
415
+ window.localStorage.setItem(
416
+ this._viewFiltersStorageKey(),
417
+ "true",
418
+ );
385
419
  } else {
386
420
  window.localStorage.removeItem(this._viewFiltersStorageKey());
387
421
  }
@@ -432,13 +466,36 @@ export default class TableComponentController extends ApplicationController {
432
466
  }
433
467
  }
434
468
 
469
+ toggleExportRepeat() {
470
+ const dialog = this.exportDialogTarget;
471
+ const options = dialog.querySelector(
472
+ "[data-mensa-table-repeat-options]",
473
+ );
474
+ if (!options) return;
475
+
476
+ const mode = dialog.querySelector(
477
+ 'input[name="repeat_mode"]:checked',
478
+ )?.value;
479
+ options.hidden = mode !== "repeating";
480
+ }
481
+
435
482
  confirmExport(event) {
436
483
  event.preventDefault();
437
484
  if (!this.hasExportsUrlValue) return;
438
485
 
439
486
  const dialog = this.exportDialogTarget;
440
- const scope = dialog.querySelector('input[name="scope"]:checked')?.value || "all";
441
- const exportFormat = dialog.querySelector('input[name="export_format"]:checked')?.value || "csv_excel";
487
+ const scope =
488
+ dialog.querySelector('input[name="scope"]:checked')?.value || "all";
489
+ const exportFormat =
490
+ dialog.querySelector('input[name="export_format"]:checked')
491
+ ?.value || "csv_excel";
492
+ const repeatMode = dialog.querySelector(
493
+ 'input[name="repeat_mode"]:checked',
494
+ )?.value;
495
+ const repeat =
496
+ repeatMode === "repeating"
497
+ ? dialog.querySelector('select[name="repeat"]')?.value || ""
498
+ : "";
442
499
 
443
500
  const state = this.currentViewState();
444
501
  const nav = this.hasMensaFilterPillListOutlet
@@ -452,6 +509,7 @@ export default class TableComponentController extends ApplicationController {
452
509
  body: JSON.stringify({
453
510
  scope,
454
511
  export_format: exportFormat,
512
+ repeat,
455
513
  table_view_id: view,
456
514
  page: nav.page,
457
515
  query: state.query || nav.query,
@@ -464,11 +522,17 @@ export default class TableComponentController extends ApplicationController {
464
522
  }
465
523
 
466
524
  get ourUrl() {
467
- if (this.hasTurboFrameTarget && this.turboFrameTarget.getAttribute("src")) {
468
- return new URL(this.turboFrameTarget.getAttribute("src"));
525
+ if (
526
+ this.hasTurboFrameTarget &&
527
+ this.turboFrameTarget.getAttribute("src")
528
+ ) {
529
+ return new URL(
530
+ this.turboFrameTarget.getAttribute("src"),
531
+ window.location.origin,
532
+ );
469
533
  }
470
534
  if (this.hasTableUrlValue && this.tableUrlValue) {
471
- return new URL(this.tableUrlValue);
535
+ return new URL(this.tableUrlValue, window.location.origin);
472
536
  }
473
537
  return new URL(window.location.href);
474
538
  }
@@ -494,8 +558,12 @@ export default class TableComponentController extends ApplicationController {
494
558
  _updateSaveButtonMode() {
495
559
  if (!this.hasSaveSimpleTarget && !this.hasSaveSplitTarget) return;
496
560
  const isUserView = !!this._selectedUserViewId();
497
- this.saveSimpleTargets.forEach((t) => t.classList.toggle("hidden", isUserView));
498
- this.saveSplitTargets.forEach((t) => t.classList.toggle("hidden", !isUserView));
561
+ this.saveSimpleTargets.forEach((t) =>
562
+ t.classList.toggle("hidden", isUserView),
563
+ );
564
+ this.saveSplitTargets.forEach((t) =>
565
+ t.classList.toggle("hidden", !isUserView),
566
+ );
499
567
  }
500
568
 
501
569
  _selectedUserViewId() {
@@ -517,7 +585,8 @@ export default class TableComponentController extends ApplicationController {
517
585
  _openSaveDialog() {
518
586
  if (!this.hasSaveViewDialogTarget) return;
519
587
  if (this.hasSaveViewNameTarget) this.saveViewNameTarget.value = "";
520
- if (this.hasSaveViewDescriptionTarget) this.saveViewDescriptionTarget.value = "";
588
+ if (this.hasSaveViewDescriptionTarget)
589
+ this.saveViewDescriptionTarget.value = "";
521
590
  if (typeof this.saveViewDialogTarget.showModal === "function") {
522
591
  this.saveViewDialogTarget.showModal();
523
592
  } else {
@@ -27,7 +27,7 @@
27
27
  </th>
28
28
  <% end %>
29
29
  <% if table.actions? && Mensa.config.row_actions_position == :front %>
30
- <th>Actions</th>
30
+ <th><%= t("mensa.actions") %></th>
31
31
  <% end %>
32
32
  <%= render(Mensa::Header::Component.with_collection(table.display_columns, table: table)) %>
33
33
  <% if table.actions? && Mensa.config.row_actions_position == :back %>
@@ -14,7 +14,7 @@ module Mensa
14
14
 
15
15
  # "orders", "users", etc. — used in the paging info line.
16
16
  def model_name_plural
17
- table.model.model_name.human.pluralize.downcase
17
+ table.model.model_name.human(count: 2)
18
18
  end
19
19
  end
20
20
  end
@@ -23,6 +23,7 @@ module Mensa
23
23
  user: current_mensa_user,
24
24
  format: params[:export_format].to_s.presence_in(Mensa::Export::FORMATS) || "csv_excel",
25
25
  scope: params[:scope].to_s.presence_in(Mensa::Export::SCOPES) || "all",
26
+ repeat: params[:repeat].to_s.presence_in(Mensa::Export::REPEATS) || "",
26
27
  config: params.permit(:query, :page, order: {}, filters: {}).to_h,
27
28
  status: "pending"
28
29
  )
@@ -42,6 +43,20 @@ module Mensa
42
43
  end
43
44
  end
44
45
 
46
+ # Deletes an export from the current user's download list. The attached
47
+ # asset is purged automatically when the record is destroyed.
48
+ def destroy
49
+ export = exports.find(params[:id])
50
+ export.destroy
51
+ Mensa::Export.broadcast_refresh(export.table_name, export.user)
52
+
53
+ respond_to do |format|
54
+ format.turbo_stream { render turbo_stream: [list_stream, badge_stream] }
55
+ format.json { head :no_content }
56
+ format.html { redirect_back fallback_location: mensa.table_exports_path(params[:table_id]) }
57
+ end
58
+ end
59
+
45
60
  # Streams the generated CSV and then removes the export, purging the
46
61
  # attached asset. Downloads are single-use: routing them through the
47
62
  # controller (instead of a direct Active Storage link) gives us a hook to
@@ -56,12 +71,17 @@ module Mensa
56
71
 
57
72
  send_data data, filename: filename, type: content_type, disposition: "attachment"
58
73
 
59
- # Clean up after a successful send. `has_one_attached :asset` purges the
60
- # blob when the record is destroyed (dependent: :purge_later). Never let
61
- # cleanup failures break the download itself.
74
+ # One-off exports are single-use and are deleted after download.
75
+ # Repeating exports stay in place and can be removed explicitly via the
76
+ # trash action.
62
77
  begin
63
- export.destroy
64
- Mensa::Export.broadcast_refresh(export.table_name, export.user)
78
+ if export.repeating?
79
+ export.asset.purge_later
80
+ Mensa::Export.broadcast_refresh(export.table_name, export.user)
81
+ else
82
+ export.destroy
83
+ Mensa::Export.broadcast_refresh(export.table_name, export.user)
84
+ end
65
85
  rescue => e
66
86
  Mensa.config.logger&.warn("Mensa::Export cleanup failed for #{export.id}: #{e.class}: #{e.message}")
67
87
  end
@@ -1,26 +1,24 @@
1
1
  module Mensa
2
2
  class TablesController < ApplicationController
3
3
  def show
4
- @table = Mensa.for_name(params[:id])
4
+ config = params.permit(:format, :query, :id, :page, :table_view_id, :turbo_frame_id, order: {}, column_order: [], hidden_columns: [], params: {}).to_h
5
+ config[:filters] = params[:filters]&.to_unsafe_h || {}
6
+ config[:params] = params[:params]&.to_unsafe_h || {}
5
7
 
6
- config = {}
7
8
  if params[:table_view_id]
9
+ view_lookup_table = Mensa.for_name(params[:id], config)
8
10
  @view = Mensa::TableView.find_by(table_name: params[:id], id: params[:table_view_id])
9
- @view ||= @table.system_views.find { |v| v.id == params[:table_view_id].to_sym }
10
- config = @view&.config&.deep_transform_keys(&:to_sym)
11
+ @view ||= view_lookup_table.system_views.find { |v| v.id == params[:table_view_id].to_sym }
12
+ config = (@view&.config&.deep_transform_keys(&:to_sym) || {}).merge(config)
11
13
  end
12
14
 
13
- config = config.merge(params.permit!.to_h)
14
- config = config.merge(params.permit(:format, :query, :id, :page, :table_view_id, :turbo_frame_id, order: {}, column_order: [], hidden_columns: []).to_h)
15
- config[:filters] = params[:filters]&.to_unsafe_h || config[:filters] || {}
16
-
17
15
  @table = Mensa.for_name(params[:id], config)
18
16
  @table.request = request
19
17
  @table.table_view = @view
20
18
  @table.original_view_context = helpers
21
19
 
22
20
  respond_to do |format|
23
- format.turbo_stream # Used for filterering
21
+ format.turbo_stream # Used for filtering
24
22
  format.html
25
23
  end
26
24
  end