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.
- checksums.yaml +4 -4
- data/.zed/tasks.json +1 -1
- data/Gemfile +1 -0
- data/Gemfile.lock +12 -8
- data/README.md +30 -15
- data/app/components/mensa/add_filter/component.css +5 -3
- data/app/components/mensa/add_filter/component.html.erb +3 -3
- data/app/components/mensa/add_filter/component_controller.js +1 -0
- data/app/components/mensa/column_customizer/component.css +4 -4
- data/app/components/mensa/column_customizer/component.html.erb +4 -4
- data/app/components/mensa/column_customizer/component_controller.js +1 -1
- data/app/components/mensa/control_bar/component.css +23 -3
- data/app/components/mensa/control_bar/component.html.erb +13 -12
- data/app/components/mensa/empty_state/component.html.erb +1 -1
- data/app/components/mensa/empty_state/component.rb +1 -1
- data/app/components/mensa/filter_pill/component.html.erb +2 -2
- data/app/components/mensa/filter_pill_list/component.html.erb +2 -2
- data/app/components/mensa/header/component.html.erb +1 -1
- data/app/components/mensa/table/component.html.erb +8 -4
- data/app/components/mensa/table/component.rb +3 -3
- data/app/components/mensa/table/component_controller.js +104 -30
- data/app/components/mensa/view/component.html.erb +1 -1
- data/app/components/mensa/view/component.rb +1 -1
- data/app/components/mensa/views/component.html.erb +8 -8
- data/app/controllers/mensa/tables/exports_controller.rb +25 -5
- data/app/controllers/mensa/tables_controller.rb +8 -10
- data/app/helpers/mensa/application_helper.rb +2 -2
- data/app/jobs/mensa/export_job.rb +4 -0
- data/app/jobs/mensa/recurring_exports_job.rb +17 -0
- data/app/models/mensa/export.rb +54 -2
- data/app/tables/mensa/base.rb +55 -13
- data/app/tables/mensa/cell.rb +8 -4
- data/app/tables/mensa/column.rb +5 -0
- data/app/tables/mensa/config/column_dsl.rb +1 -0
- data/app/tables/mensa/config/dsl_logic.rb +19 -1
- data/app/tables/mensa/config/format_dsl.rb +7 -0
- data/app/tables/mensa/config/table_dsl.rb +2 -5
- data/app/tables/mensa/filter.rb +24 -1
- data/app/tables/mensa/format.rb +23 -0
- data/app/tables/mensa/scope.rb +5 -3
- data/app/views/mensa/exports/_dialog.html.erb +25 -2
- data/app/views/mensa/exports/_list.html.erb +15 -2
- data/app/views/mensa/tables/filters/show.turbo_stream.erb +4 -1
- data/app/views/mensa/tables/views/destroy.turbo_stream.erb +5 -1
- data/config/locales/en.yml +25 -0
- data/config/locales/nl.yml +24 -0
- data/config/routes.rb +1 -1
- data/db/migrate/20260612110000_add_repeat_to_mensa_exports.rb +8 -0
- data/lib/generators/mensa/templates/config/initializers/mensa.rb +29 -8
- data/lib/mensa/configuration.rb +64 -17
- data/lib/mensa/version.rb +1 -1
- data/lib/tasks/mensa_tasks.rake +7 -0
- 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 (
|
|
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:
|
|
102
|
-
view:
|
|
103
|
-
order:
|
|
104
|
-
page:
|
|
104
|
+
query: outlet.loadQuery(),
|
|
105
|
+
view: outlet.loadView(),
|
|
106
|
+
order: outlet.loadOrder(),
|
|
107
|
+
page: outlet.loadPage(),
|
|
105
108
|
};
|
|
106
|
-
this.turboFrameTarget.setAttribute(
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
253
|
+
const name = this.hasSaveViewNameTarget
|
|
254
|
+
? this.saveViewNameTarget.value.trim()
|
|
255
|
+
: "";
|
|
243
256
|
if (!name) {
|
|
244
|
-
if (this.hasSaveViewNameTarget)
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
?
|
|
355
|
-
:
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
441
|
-
|
|
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 (
|
|
468
|
-
|
|
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) =>
|
|
498
|
-
|
|
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)
|
|
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
|
|
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 %>
|
|
@@ -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="
|
|
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.
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
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.
|
|
64
|
-
|
|
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
|
-
|
|
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 ||=
|
|
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
|
|
24
|
-
format.html
|
|
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,
|
|
4
|
-
component = ::Mensa::Table::Component.new(name,
|
|
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
|
data/app/models/mensa/export.rb
CHANGED
|
@@ -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
|
|
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).
|
|
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
|