mensa 0.4.0 → 0.6.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.zed/tasks.json +1 -1
  3. data/Gemfile +1 -0
  4. data/Gemfile.lock +12 -8
  5. data/README.md +30 -15
  6. data/app/components/mensa/add_filter/component.css +5 -3
  7. data/app/components/mensa/add_filter/component.html.erb +3 -3
  8. data/app/components/mensa/add_filter/component_controller.js +1 -0
  9. data/app/components/mensa/column_customizer/component.css +4 -4
  10. data/app/components/mensa/column_customizer/component.html.erb +4 -4
  11. data/app/components/mensa/column_customizer/component_controller.js +1 -1
  12. data/app/components/mensa/control_bar/component.css +23 -3
  13. data/app/components/mensa/control_bar/component.html.erb +13 -12
  14. data/app/components/mensa/empty_state/component.html.erb +1 -1
  15. data/app/components/mensa/empty_state/component.rb +1 -1
  16. data/app/components/mensa/filter_pill/component.html.erb +2 -2
  17. data/app/components/mensa/filter_pill_list/component.html.erb +2 -2
  18. data/app/components/mensa/header/component.html.erb +1 -1
  19. data/app/components/mensa/table/component.html.erb +8 -4
  20. data/app/components/mensa/table/component.rb +3 -3
  21. data/app/components/mensa/table/component_controller.js +104 -30
  22. data/app/components/mensa/view/component.html.erb +1 -1
  23. data/app/components/mensa/view/component.rb +1 -1
  24. data/app/components/mensa/views/component.html.erb +8 -8
  25. data/app/controllers/mensa/tables/exports_controller.rb +25 -5
  26. data/app/controllers/mensa/tables_controller.rb +8 -10
  27. data/app/helpers/mensa/application_helper.rb +2 -2
  28. data/app/jobs/mensa/export_job.rb +4 -0
  29. data/app/jobs/mensa/recurring_exports_job.rb +17 -0
  30. data/app/models/mensa/export.rb +54 -2
  31. data/app/tables/mensa/base.rb +55 -13
  32. data/app/tables/mensa/cell.rb +8 -4
  33. data/app/tables/mensa/column.rb +5 -0
  34. data/app/tables/mensa/config/column_dsl.rb +1 -0
  35. data/app/tables/mensa/config/dsl_logic.rb +19 -1
  36. data/app/tables/mensa/config/format_dsl.rb +7 -0
  37. data/app/tables/mensa/config/table_dsl.rb +2 -5
  38. data/app/tables/mensa/filter.rb +24 -1
  39. data/app/tables/mensa/format.rb +23 -0
  40. data/app/tables/mensa/scope.rb +5 -3
  41. data/app/views/mensa/exports/_dialog.html.erb +25 -2
  42. data/app/views/mensa/exports/_list.html.erb +15 -2
  43. data/app/views/mensa/tables/filters/show.turbo_stream.erb +4 -1
  44. data/app/views/mensa/tables/views/destroy.turbo_stream.erb +5 -1
  45. data/config/locales/en.yml +25 -0
  46. data/config/locales/nl.yml +24 -0
  47. data/config/routes.rb +1 -1
  48. data/db/migrate/20260612110000_add_repeat_to_mensa_exports.rb +8 -0
  49. data/lib/generators/mensa/templates/config/initializers/mensa.rb +29 -8
  50. data/lib/mensa/configuration.rb +64 -17
  51. data/lib/mensa/version.rb +1 -1
  52. data/lib/tasks/mensa_tasks.rake +7 -0
  53. metadata +6 -2
@@ -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,11 +372,18 @@ 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
+ );
378
+ const visibleIconClass =
379
+ this.eyeButtonTarget.dataset.iconVisibleClass || "";
380
+ const hiddenIconClass =
381
+ this.eyeButtonTarget.dataset.iconHiddenClass || "";
382
+
352
383
  // Replace innerHTML so FontAwesome's MutationObserver re-processes the new <i>
353
384
  this.eyeButtonTarget.innerHTML = hidden
354
- ? '<i class="fa-solid fa-eye"></i>'
355
- : '<i class="fa-solid fa-eye-slash"></i>';
385
+ ? `<i class="${visibleIconClass}"></i>`
386
+ : `<i class="${hiddenIconClass}"></i>`;
356
387
  }
357
388
  }
358
389
 
@@ -366,13 +397,18 @@ export default class TableComponentController extends ApplicationController {
366
397
  if (this.hasMensaFilterPillListOutlet) {
367
398
  return this.mensaFilterPillListOutlet.tableNameValue;
368
399
  }
369
- const el = this.element.querySelector("[data-mensa-filter-pill-list-table-name-value]");
400
+ const el = this.element.querySelector(
401
+ "[data-mensa-filter-pill-list-table-name-value]",
402
+ );
370
403
  return el?.dataset?.mensaFilterPillListTableNameValue || "";
371
404
  }
372
405
 
373
406
  _loadViewFiltersVisible() {
374
407
  try {
375
- return window.localStorage.getItem(this._viewFiltersStorageKey()) === "true";
408
+ return (
409
+ window.localStorage.getItem(this._viewFiltersStorageKey()) ===
410
+ "true"
411
+ );
376
412
  } catch (e) {
377
413
  return false;
378
414
  }
@@ -381,7 +417,10 @@ export default class TableComponentController extends ApplicationController {
381
417
  _saveViewFiltersVisible(visible) {
382
418
  try {
383
419
  if (visible) {
384
- window.localStorage.setItem(this._viewFiltersStorageKey(), "true");
420
+ window.localStorage.setItem(
421
+ this._viewFiltersStorageKey(),
422
+ "true",
423
+ );
385
424
  } else {
386
425
  window.localStorage.removeItem(this._viewFiltersStorageKey());
387
426
  }
@@ -432,13 +471,36 @@ export default class TableComponentController extends ApplicationController {
432
471
  }
433
472
  }
434
473
 
474
+ toggleExportRepeat() {
475
+ const dialog = this.exportDialogTarget;
476
+ const options = dialog.querySelector(
477
+ "[data-mensa-table-repeat-options]",
478
+ );
479
+ if (!options) return;
480
+
481
+ const mode = dialog.querySelector(
482
+ 'input[name="repeat_mode"]:checked',
483
+ )?.value;
484
+ options.hidden = mode !== "repeating";
485
+ }
486
+
435
487
  confirmExport(event) {
436
488
  event.preventDefault();
437
489
  if (!this.hasExportsUrlValue) return;
438
490
 
439
491
  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";
492
+ const scope =
493
+ dialog.querySelector('input[name="scope"]:checked')?.value || "all";
494
+ const exportFormat =
495
+ dialog.querySelector('input[name="export_format"]:checked')
496
+ ?.value || "csv_excel";
497
+ const repeatMode = dialog.querySelector(
498
+ 'input[name="repeat_mode"]:checked',
499
+ )?.value;
500
+ const repeat =
501
+ repeatMode === "repeating"
502
+ ? dialog.querySelector('select[name="repeat"]')?.value || ""
503
+ : "";
442
504
 
443
505
  const state = this.currentViewState();
444
506
  const nav = this.hasMensaFilterPillListOutlet
@@ -452,6 +514,7 @@ export default class TableComponentController extends ApplicationController {
452
514
  body: JSON.stringify({
453
515
  scope,
454
516
  export_format: exportFormat,
517
+ repeat,
455
518
  table_view_id: view,
456
519
  page: nav.page,
457
520
  query: state.query || nav.query,
@@ -464,11 +527,17 @@ export default class TableComponentController extends ApplicationController {
464
527
  }
465
528
 
466
529
  get ourUrl() {
467
- if (this.hasTurboFrameTarget && this.turboFrameTarget.getAttribute("src")) {
468
- return new URL(this.turboFrameTarget.getAttribute("src"));
530
+ if (
531
+ this.hasTurboFrameTarget &&
532
+ this.turboFrameTarget.getAttribute("src")
533
+ ) {
534
+ return new URL(
535
+ this.turboFrameTarget.getAttribute("src"),
536
+ window.location.origin,
537
+ );
469
538
  }
470
539
  if (this.hasTableUrlValue && this.tableUrlValue) {
471
- return new URL(this.tableUrlValue);
540
+ return new URL(this.tableUrlValue, window.location.origin);
472
541
  }
473
542
  return new URL(window.location.href);
474
543
  }
@@ -494,8 +563,12 @@ export default class TableComponentController extends ApplicationController {
494
563
  _updateSaveButtonMode() {
495
564
  if (!this.hasSaveSimpleTarget && !this.hasSaveSplitTarget) return;
496
565
  const isUserView = !!this._selectedUserViewId();
497
- this.saveSimpleTargets.forEach((t) => t.classList.toggle("hidden", isUserView));
498
- this.saveSplitTargets.forEach((t) => t.classList.toggle("hidden", !isUserView));
566
+ this.saveSimpleTargets.forEach((t) =>
567
+ t.classList.toggle("hidden", isUserView),
568
+ );
569
+ this.saveSplitTargets.forEach((t) =>
570
+ t.classList.toggle("hidden", !isUserView),
571
+ );
499
572
  }
500
573
 
501
574
  _selectedUserViewId() {
@@ -517,7 +590,8 @@ export default class TableComponentController extends ApplicationController {
517
590
  _openSaveDialog() {
518
591
  if (!this.hasSaveViewDialogTarget) return;
519
592
  if (this.hasSaveViewNameTarget) this.saveViewNameTarget.value = "";
520
- if (this.hasSaveViewDescriptionTarget) this.saveViewDescriptionTarget.value = "";
593
+ if (this.hasSaveViewDescriptionTarget)
594
+ this.saveViewDescriptionTarget.value = "";
521
595
  if (typeof this.saveViewDialogTarget.showModal === "function") {
522
596
  this.saveViewDialogTarget.showModal();
523
597
  } 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
@@ -11,26 +11,26 @@
11
11
  data-mensa-views-views-url-value="<%= helpers.mensa.table_views_path(table.name) %>">
12
12
  <button class="mensa-table__views__trigger" type="button" data-action="mensa-views#toggleDropdown" data-mensa-views-target="trigger" aria-haspopup="listbox">
13
13
  <span class="mensa-table__views__trigger-label" data-mensa-views-target="triggerLabel"><%= current_view_name %></span>
14
- <i class="fa-solid fa-sort mensa-table__views__trigger-icon"></i>
14
+ <i class="<%= Mensa.config.icons[:views_trigger] %> mensa-table__views__trigger-icon"></i>
15
15
  </button>
16
16
 
17
17
  <div class="mensa-table__views__dropdown hidden" data-mensa-views-target="dropdown">
18
18
  <ul role="listbox">
19
19
  <% table.all_views.each do |view| %>
20
20
  <% is_selected = view.id == table.table_view&.id || (view.id == :default && table.table_view.blank?) %>
21
- <% is_user_view = view.id.is_a?(String) && view.id.match?(/\A[0-9a-f-]{32,}\z/i) %>
21
+ <% is_user_view = view.is_a?(Mensa::TableView) %>
22
22
  <li class="mensa-table__views__option" role="option" data-view-id="<%= view.id %>" data-view-name="<%= view.name %>" data-mensa-views-target="view">
23
23
  <button class="mensa-table__views__option-btn" type="button" data-action="mensa-views#select" data-view-id="<%= view.id %>">
24
- <i class="mensa-table__views__option-check fa-solid fa-check<%= " invisible" unless is_selected %>"></i>
24
+ <i class="mensa-table__views__option-check <%= Mensa.config.icons[:views_option_selected] %><%= " invisible" unless is_selected %>"></i>
25
25
  <span><%= view.name %></span>
26
26
  </button>
27
27
  <% if is_user_view %>
28
28
  <button class="mensa-table__views__option-menu" type="button" data-action="mensa-views#toggleSubmenu" data-view-id="<%= view.id %>" title="View options">
29
- <i class="fa-solid fa-ellipsis"></i>
29
+ <i class="<%= Mensa.config.icons[:views_option_menu] %>"></i>
30
30
  </button>
31
31
  <% else %>
32
32
  <div class="mensa-table__views__option-system">
33
- <i class="fa-solid fa-ban"></i>
33
+ <i class="<%= Mensa.config.icons[:views_option_system] %>"></i>
34
34
  </div>
35
35
  <% end %>
36
36
  </li>
@@ -39,15 +39,15 @@
39
39
 
40
40
  <div class="mensa-table__views__submenu hidden" data-mensa-views-target="submenu">
41
41
  <button class="mensa-table__views__submenu-item" type="button" data-action="mensa-views#renameView">
42
- <i class="fa-solid fa-pencil"></i>
42
+ <i class="<%= Mensa.config.icons[:views_rename] %>"></i>
43
43
  <%= t(".rename_view", default: "Rename view") %>
44
44
  </button>
45
45
  <button class="mensa-table__views__submenu-item" type="button" data-action="mensa-views#duplicateView">
46
- <i class="fa-solid fa-copy"></i>
46
+ <i class="<%= Mensa.config.icons[:views_duplicate] %>"></i>
47
47
  <%= t(".duplicate_view", default: "Duplicate view") %>
48
48
  </button>
49
49
  <button class="mensa-table__views__submenu-item mensa-table__views__submenu-item--danger" type="button" data-action="mensa-views#deleteView">
50
- <i class="fa-solid fa-trash"></i>
50
+ <i class="<%= Mensa.config.icons[:views_delete] %>"></i>
51
51
  <%= t(".delete_view", default: "Delete view") %>
52
52
  </button>
53
53
  </div>
@@ -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,27 +1,25 @@
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 if params.key?(:filters)
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) if @view
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
24
- format.html # You shouldn't get here
21
+ format.turbo_stream # Used for filtering
22
+ format.html
25
23
  end
26
24
  end
27
25
  end
@@ -1,7 +1,7 @@
1
1
  module Mensa
2
2
  module ApplicationHelper
3
- def table(name, config = {}, **options)
4
- component = ::Mensa::Table::Component.new(name, config, **options)
3
+ def table(name, params: {}, **options)
4
+ component = ::Mensa::Table::Component.new(name, params: params, **options)
5
5
  component.original_view_context = self
6
6
  render(component)
7
7
  end
@@ -23,6 +23,7 @@ module Mensa
23
23
 
24
24
  data, filename, content_type = generate(table, export)
25
25
 
26
+ export.asset.purge if export.asset.attached?
26
27
  export.asset.attach(io: StringIO.new(data), filename: filename, content_type: content_type)
27
28
  finalize(export, status: "completed", filename: filename)
28
29
 
@@ -94,6 +95,9 @@ module Mensa
94
95
  def finalize(export, status:, filename: nil)
95
96
  attributes = {status: status}
96
97
  attributes[:filename] = filename if filename
98
+
99
+ attributes[:last_repeat_run_at] = Time.current if status == "completed" && export.repeat.present?
100
+
97
101
  export.update(attributes)
98
102
  # Refresh the export button badge (download count) and the downloads list
99
103
  # inside the export dialog for everyone viewing this table.
@@ -0,0 +1,17 @@
1
+ module Mensa
2
+ # Sweeps recurring exports and re-enqueues any whose next scheduled run is due.
3
+ # This job is intended to be invoked by a daily cron entry or scheduler.
4
+ class RecurringExportsJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform(reference_time = Time.current)
8
+ reference_time = reference_time.in_time_zone if reference_time.respond_to?(:in_time_zone)
9
+
10
+ Mensa::Export.repeating.find_each do |export|
11
+ next unless export.repeat_due?(reference_time)
12
+
13
+ Mensa::ExportJob.perform_later(export)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -7,17 +7,21 @@ module Mensa
7
7
  STATUSES = %w[pending processing completed failed].freeze
8
8
  FORMATS = %w[csv_excel plain_csv].freeze
9
9
  SCOPES = %w[all current_page].freeze
10
+ REPEATS = ["", "daily", "weekly", "monthly", "quarterly", "bi-yearly", "yearly"].freeze
10
11
 
11
12
  belongs_to :user, optional: true
12
13
  has_one_attached :asset
13
14
 
14
15
  validates :table_name, presence: true
15
16
  validates :status, inclusion: {in: STATUSES}
17
+ validates :repeat, inclusion: {in: REPEATS}
16
18
 
17
19
  scope :for_table, ->(table_name) { where(table_name: table_name.to_s) }
18
20
  scope :for_user, ->(user) { where(user_id: user.respond_to?(:id) ? user&.id : user) }
19
21
  scope :completed, -> { where(status: "completed") }
20
22
  scope :recent, -> { order(created_at: :desc) }
23
+ scope :repeating, -> { where.not(repeat: [nil, ""]) }
24
+ scope :with_downloadable_asset, -> { completed.joins(:asset_attachment).distinct }
21
25
 
22
26
  def completed?
23
27
  status == "completed"
@@ -35,15 +39,63 @@ module Mensa
35
39
  status == "pending"
36
40
  end
37
41
 
42
+ def next_repeat_run_at(from: nil)
43
+ return if repeat.blank?
44
+
45
+ anchor = from || last_repeat_run_at || created_at
46
+
47
+ case repeat
48
+ when "daily"
49
+ anchor + 1.day
50
+ when "weekly"
51
+ anchor + 1.week
52
+ when "monthly"
53
+ anchor.advance(months: 1)
54
+ when "quarterly"
55
+ anchor.advance(months: 3)
56
+ when "bi-yearly"
57
+ anchor.advance(months: 6)
58
+ when "yearly"
59
+ anchor.advance(years: 1)
60
+ end
61
+ end
62
+
63
+ def repeat_due?(reference_time = Time.current)
64
+ return false if repeat.blank? || pending? || processing?
65
+
66
+ next_run_at = next_repeat_run_at
67
+ next_run_at.present? && next_run_at <= reference_time
68
+ end
69
+
70
+ def repeating?
71
+ repeat.present?
72
+ end
73
+
74
+ def repeat_label
75
+ return if repeat.blank?
76
+
77
+ I18n.t(
78
+ "mensa.exports.repeats_with_interval",
79
+ default: "Repeats %{interval}",
80
+ interval: repeat_interval_label
81
+ )
82
+ end
83
+
84
+ def repeat_interval_label
85
+ return if repeat.blank?
86
+
87
+ I18n.t("mensa.exports.repeat_intervals.#{repeat}", default: repeat)
88
+ end
89
+
38
90
  # True once the asset is ready to be downloaded by the user.
39
91
  def downloadable?
40
92
  completed? && asset.attached?
41
93
  end
42
94
 
43
- # Number of completed (downloadable) exports for a table/user combination.
95
+ # Number of currently-downloadable exports for a table/user combination.
44
96
  # This is the number rendered in the export button badge.
45
97
  def self.completed_count(table_name, user)
46
- for_table(table_name).for_user(user).completed.count
98
+ for_table(table_name).for_user(user).with_downloadable_asset.count
47
99
  end
48
100
 
49
101
  # A stable, page-independent key identifying the exports of a table/user