mensa 0.4.0 → 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.
- checksums.yaml +4 -4
- data/.zed/tasks.json +1 -1
- data/Gemfile.lock +1 -1
- data/README.md +26 -14
- data/app/components/mensa/add_filter/component.html.erb +1 -1
- 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 +5 -5
- data/app/components/mensa/empty_state/component.rb +1 -1
- data/app/components/mensa/table/component.html.erb +1 -1
- data/app/components/mensa/table/component.rb +3 -3
- data/app/components/mensa/table/component_controller.js +97 -28
- data/app/components/mensa/view/component.html.erb +1 -1
- data/app/components/mensa/view/component.rb +1 -1
- 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 +51 -11
- data/app/tables/mensa/config/table_dsl.rb +1 -0
- data/app/tables/mensa/scope.rb +5 -3
- data/app/views/mensa/exports/_dialog.html.erb +23 -0
- data/app/views/mensa/exports/_list.html.erb +13 -0
- data/app/views/mensa/tables/standard_error.html.erb +9 -0
- data/config/locales/en.yml +21 -0
- data/config/locales/nl.yml +20 -0
- data/config/routes.rb +1 -1
- data/db/migrate/20260612110000_add_repeat_to_mensa_exports.rb +8 -0
- data/lib/mensa/configuration.rb +5 -0
- data/lib/mensa/version.rb +1 -1
- data/lib/tasks/mensa_tasks.rake +7 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ad9a06fc13439d733db95cc8d3c0142247291057d8a5c84fce0f090f8c96a63
|
|
4
|
+
data.tar.gz: fef0dcdcaf8b21e038af8baa90f3509016372f30998a7bdd0cb94c3050a19444
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e86a46aab67adf045e5939e9d307732e7752ccc50f3bae26b67dc9176ecb00508405b9b8c9c146fd7f0eb88a71f2aeba7f9f49fc943e3b8bb6910cbd1858e2d9
|
|
7
|
+
data.tar.gz: 89e4d11db3e400215fdbce8cd5b031577a8c254cb92fc37f99c7ebbb726609c7d5e70c7bfb143a529f7ae6e9d5290d1bf4df314b6ca4e65384ebb20198f038dd
|
data/.zed/tasks.json
CHANGED
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -9,21 +9,19 @@ Due to search, it only works with postgresql at the moment.
|
|
|
9
9
|

|
|
10
10
|
|
|
11
11
|
Features:
|
|
12
|
-
|
|
13
|
-
- [x]
|
|
14
|
-
- [x]
|
|
15
|
-
- [x]
|
|
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]
|
|
19
|
-
- [X]
|
|
20
|
-
- [X]
|
|
21
|
-
- [X]
|
|
22
|
-
- [x]
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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"
|
|
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
|
|
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-
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
35
|
+
<%= t("mensa.save") %>
|
|
36
36
|
</button>
|
|
37
37
|
<% end %>
|
|
38
38
|
</div>
|
|
@@ -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="<%=
|
|
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,
|
|
12
|
-
@
|
|
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 (
|
|
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,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(
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
441
|
-
|
|
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 (
|
|
468
|
-
|
|
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) =>
|
|
498
|
-
|
|
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)
|
|
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
|
|
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 %>
|
|
@@ -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 || {}
|
|
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
|
data/app/tables/mensa/base.rb
CHANGED
|
@@ -8,8 +8,8 @@ module Mensa
|
|
|
8
8
|
|
|
9
9
|
attr_writer :original_view_context
|
|
10
10
|
attr_accessor :component, :name, :table_view, :request
|
|
11
|
-
attr_reader :params
|
|
12
11
|
|
|
12
|
+
config_reader :params
|
|
13
13
|
config_reader :model
|
|
14
14
|
config_reader :scope
|
|
15
15
|
config_reader :link, call: false
|
|
@@ -22,12 +22,12 @@ module Mensa
|
|
|
22
22
|
config_reader :export_with_password?
|
|
23
23
|
|
|
24
24
|
def initialize(config = {})
|
|
25
|
-
|
|
26
|
-
@config = self.class.definition.merge(
|
|
25
|
+
normalized_config = config.to_h.deep_symbolize_keys
|
|
26
|
+
@config = self.class.definition.merge(normalized_config)
|
|
27
|
+
@params = (@config[:params].presence || {}).deep_symbolize_keys
|
|
28
|
+
@config[:params] = @params
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
params[:hidden_columns]&.each do |column_name|
|
|
30
|
+
current_hidden_columns&.each do |column_name|
|
|
31
31
|
c = columns.find { |c| c.name == column_name.to_sym }
|
|
32
32
|
c.config[:visible] = false
|
|
33
33
|
end
|
|
@@ -48,12 +48,17 @@ module Mensa
|
|
|
48
48
|
|
|
49
49
|
# Returns all columns
|
|
50
50
|
def columns
|
|
51
|
+
ensure_internal_columns_for_joined_associations
|
|
51
52
|
@columns ||= column_order.map { |column_name| Mensa::Column.new(column_name, config: config.dig(:columns, column_name), table: self) }
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
# Returns a column by name
|
|
55
56
|
# @param [String] name
|
|
56
57
|
def column(name)
|
|
58
|
+
found_column = columns.find { |c| c.name == name.to_sym }
|
|
59
|
+
return found_column if found_column || @internal_columns_ensured
|
|
60
|
+
|
|
61
|
+
@columns = nil
|
|
57
62
|
columns.find { |c| c.name == name.to_sym }
|
|
58
63
|
end
|
|
59
64
|
|
|
@@ -117,16 +122,19 @@ module Mensa
|
|
|
117
122
|
end
|
|
118
123
|
|
|
119
124
|
# Returns the current path with configuration
|
|
120
|
-
def path(order: {}, turbo_frame_id:
|
|
125
|
+
def path(order: {}, turbo_frame_id: current_turbo_frame_id, table_view_id: current_table_view_id, column_order: current_column_order, hidden_columns: current_hidden_columns, user_params: nil)
|
|
121
126
|
# FIXME: if someone doesn't use as: :mensa in the routes, it breaks
|
|
122
|
-
original_view_context.mensa.table_path(
|
|
123
|
-
|
|
127
|
+
path = original_view_context.mensa.table_path(name)
|
|
128
|
+
query = {
|
|
129
|
+
params: user_params || params,
|
|
124
130
|
order: order_hash(order),
|
|
125
131
|
turbo_frame_id: turbo_frame_id,
|
|
126
132
|
table_view_id: table_view_id,
|
|
127
133
|
column_order: column_order,
|
|
128
134
|
hidden_columns: hidden_columns
|
|
129
|
-
|
|
135
|
+
}.compact.to_query
|
|
136
|
+
|
|
137
|
+
query.present? ? "#{path}?#{query}" : path
|
|
130
138
|
end
|
|
131
139
|
|
|
132
140
|
def all_views
|
|
@@ -146,21 +154,53 @@ module Mensa
|
|
|
146
154
|
def table_id
|
|
147
155
|
return @table_id if @table_id
|
|
148
156
|
|
|
149
|
-
@table_id =
|
|
157
|
+
@table_id = current_turbo_frame_id || "#{name.to_s.gsub("/", "__")}-#{SecureRandom.base36}"
|
|
150
158
|
end
|
|
151
159
|
|
|
152
160
|
def original_view_context
|
|
153
161
|
@original_view_context || component.original_view_context
|
|
154
162
|
end
|
|
155
163
|
|
|
164
|
+
def current_query
|
|
165
|
+
config[:query]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def current_order
|
|
169
|
+
config[:order]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def current_order_provided?
|
|
173
|
+
config.key?(:order)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def current_table_view_id
|
|
177
|
+
config[:table_view_id]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def current_column_order
|
|
181
|
+
config[:column_order]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def current_hidden_columns
|
|
185
|
+
config[:hidden_columns]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def current_turbo_frame_id
|
|
189
|
+
config[:turbo_frame_id]
|
|
190
|
+
end
|
|
191
|
+
|
|
156
192
|
private
|
|
157
193
|
|
|
158
194
|
def ensure_internal_columns_for_joined_associations
|
|
195
|
+
return if @internal_columns_ensured
|
|
196
|
+
|
|
159
197
|
config[:columns] ||= {}
|
|
160
198
|
|
|
161
199
|
auto_internal_column_names.each do |column_name|
|
|
162
200
|
config[:columns][column_name] ||= {internal: true, filter: false}
|
|
163
201
|
end
|
|
202
|
+
|
|
203
|
+
@internal_columns_ensured = true
|
|
164
204
|
end
|
|
165
205
|
|
|
166
206
|
def auto_internal_column_names
|
data/app/tables/mensa/scope.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Mensa
|
|
|
14
14
|
return @filtered_scope if @filtered_scope
|
|
15
15
|
|
|
16
16
|
@filtered_scope = scope
|
|
17
|
-
@filtered_scope = search(@filtered_scope,
|
|
17
|
+
@filtered_scope = search(@filtered_scope, current_query) if current_query.present?
|
|
18
18
|
|
|
19
19
|
# Use inject
|
|
20
20
|
active_filters.each do |filter|
|
|
@@ -44,6 +44,8 @@ module Mensa
|
|
|
44
44
|
def selected_scope
|
|
45
45
|
return @selected_scope if @selected_scope
|
|
46
46
|
|
|
47
|
+
ensure_internal_columns_for_joined_associations
|
|
48
|
+
|
|
47
49
|
@selected_scope = ordered_scope
|
|
48
50
|
@selected_scope = @selected_scope.select([:id] + columns.filter_map(&:attribute))
|
|
49
51
|
|
|
@@ -72,7 +74,7 @@ module Mensa
|
|
|
72
74
|
# (even with blank values), use only those — blank means "explicitly no sort".
|
|
73
75
|
# Falls back to the view/config default only when no order params were sent.
|
|
74
76
|
def effective_order
|
|
75
|
-
result =
|
|
77
|
+
result = current_order_provided? ? (current_order || {}) : (config[:order] || {})
|
|
76
78
|
result = result.symbolize_keys.compact_blank.transform_values(&:to_sym)
|
|
77
79
|
result.transform_keys { |column_name| column(column_name).attribute_for_condition }
|
|
78
80
|
end
|
|
@@ -81,7 +83,7 @@ module Mensa
|
|
|
81
83
|
# nil values become "" so they appear in the URL as order[col]= (which tells
|
|
82
84
|
# the server the user explicitly cleared that column's sort direction).
|
|
83
85
|
def order_hash(new_params = {})
|
|
84
|
-
base =
|
|
86
|
+
base = current_order&.symbolize_keys || config[:order]&.symbolize_keys || {}
|
|
85
87
|
merged = base.merge(new_params.symbolize_keys)
|
|
86
88
|
merged.transform_values { |v| v.nil? ? "" : v.to_sym }
|
|
87
89
|
end
|
|
@@ -37,6 +37,29 @@
|
|
|
37
37
|
</label>
|
|
38
38
|
</fieldset>
|
|
39
39
|
|
|
40
|
+
<fieldset class="mensa-table__export-dialog__fieldset">
|
|
41
|
+
<legend class="mensa-table__export-dialog__section-title"><%= t("mensa.exports.repeat_section", default: "Repeat") %></legend>
|
|
42
|
+
<label class="mensa-table__export-dialog__option">
|
|
43
|
+
<input type="radio" name="repeat_mode" value="once" checked data-action="change->mensa-table#toggleExportRepeat">
|
|
44
|
+
<span><%= t("mensa.exports.repeat_once", default: "Only once") %></span>
|
|
45
|
+
</label>
|
|
46
|
+
<label class="mensa-table__export-dialog__option">
|
|
47
|
+
<input type="radio" name="repeat_mode" value="repeating" data-action="change->mensa-table#toggleExportRepeat">
|
|
48
|
+
<span><%= t("mensa.exports.repeat_repeating", default: "Repeating") %></span>
|
|
49
|
+
</label>
|
|
50
|
+
|
|
51
|
+
<div class="mensa-table__export-dialog__repeat-options" hidden data-mensa-table-repeat-options>
|
|
52
|
+
<select name="repeat" class="mensa-table__export-dialog__select">
|
|
53
|
+
<option value="daily"><%= t("mensa.exports.repeat_intervals.daily", default: "daily") %></option>
|
|
54
|
+
<option value="weekly"><%= t("mensa.exports.repeat_intervals.weekly", default: "weekly") %></option>
|
|
55
|
+
<option value="monthly"><%= t("mensa.exports.repeat_intervals.monthly", default: "monthly") %></option>
|
|
56
|
+
<option value="quarterly"><%= t("mensa.exports.repeat_intervals.quarterly", default: "quarterly") %></option>
|
|
57
|
+
<option value="bi-yearly"><%= t("mensa.exports.repeat_intervals.bi-yearly", default: "bi-yearly") %></option>
|
|
58
|
+
<option value="yearly"><%= t("mensa.exports.repeat_intervals.yearly", default: "yearly") %></option>
|
|
59
|
+
</select>
|
|
60
|
+
</div>
|
|
61
|
+
</fieldset>
|
|
62
|
+
|
|
40
63
|
<div class="mensa-table__export-dialog__actions">
|
|
41
64
|
<button class="mensa-table__export-dialog__button mensa-table__export-dialog__button--secondary" type="button" data-action="mensa-table#cancelExport">
|
|
42
65
|
<%= t("mensa.exports.cancel", default: "Cancel") %>
|
|
@@ -6,8 +6,19 @@
|
|
|
6
6
|
<ul class="mensa-table__export-dialog__list">
|
|
7
7
|
<% exports.each do |export| %>
|
|
8
8
|
<li class="mensa-table__export-dialog__item">
|
|
9
|
+
<%= button_to mensa.table_export_path(export.table_name, export),
|
|
10
|
+
method: :delete,
|
|
11
|
+
class: "mensa-table__export-dialog__delete",
|
|
12
|
+
form_class: "mensa-table__export-dialog__delete-form",
|
|
13
|
+
aria: {label: t("mensa.exports.delete", default: "Delete export") } do %>
|
|
14
|
+
<i class="fa-solid fa-trash"></i>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
9
17
|
<div class="mensa-table__export-dialog__item-info">
|
|
10
18
|
<span class="mensa-table__export-dialog__item-name"><%= export.filename.presence || t("mensa.exports.item_name", default: "%{table} export", table: export.table_name.to_s.humanize) %></span>
|
|
19
|
+
<% if export.repeating? %>
|
|
20
|
+
<span class="mensa-table__export-dialog__item-repeat"><%= export.repeat_label %></span>
|
|
21
|
+
<% end %>
|
|
11
22
|
<span class="mensa-table__export-dialog__item-meta"><%= export.created_at.strftime("%Y-%m-%d %H:%M") %></span>
|
|
12
23
|
</div>
|
|
13
24
|
<div class="mensa-table__export-dialog__item-action">
|
|
@@ -18,6 +29,8 @@
|
|
|
18
29
|
<% end %>
|
|
19
30
|
<% elsif export.failed? %>
|
|
20
31
|
<span class="mensa-table__export-dialog__status mensa-table__export-dialog__status--failed"><%= t("mensa.exports.failed", default: "Failed") %></span>
|
|
32
|
+
<% elsif export.completed? %>
|
|
33
|
+
<span class="mensa-table__export-dialog__status mensa-table__export-dialog__status--pending"><%= t("mensa.exports.waiting", default: "Waiting") %></span>
|
|
21
34
|
<% else %>
|
|
22
35
|
<span class="mensa-table__export-dialog__status mensa-table__export-dialog__status--pending">
|
|
23
36
|
<i class="fa-solid fa-spinner fa-spin"></i>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<turbo-frame id="<%= @frame_id %>">
|
|
2
|
+
<div class="mensa-empty-state">
|
|
3
|
+
<div class="mensa-empty-state__icon">
|
|
4
|
+
<i class="fa-solid fa-circle-exclamation"></i>
|
|
5
|
+
</div>
|
|
6
|
+
<h3 class="mensa-empty-state__title">Could not load table</h3>
|
|
7
|
+
<p class="mensa-empty-state__subtitle"><%= @error_message %></p>
|
|
8
|
+
</div>
|
|
9
|
+
</turbo-frame>
|
data/config/locales/en.yml
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
en:
|
|
2
2
|
mensa:
|
|
3
|
+
actions: Actions
|
|
4
|
+
save: Save
|
|
5
|
+
update_view: Update view
|
|
6
|
+
save_view: Save as new view
|
|
7
|
+
save_as_new_view: Save as new view
|
|
3
8
|
operators:
|
|
4
9
|
is: is
|
|
5
10
|
isnt: isn't
|
|
@@ -12,6 +17,7 @@ en:
|
|
|
12
17
|
lteq: less than or equal to
|
|
13
18
|
is_empty: is empty
|
|
14
19
|
add_filter:
|
|
20
|
+
add: Add filter
|
|
15
21
|
component:
|
|
16
22
|
add_filter: Add filter
|
|
17
23
|
filter_pill_list:
|
|
@@ -20,6 +26,7 @@ en:
|
|
|
20
26
|
search_only: Search
|
|
21
27
|
search:
|
|
22
28
|
component:
|
|
29
|
+
search: Search and filter
|
|
23
30
|
search_in: Search in %{view}
|
|
24
31
|
cancel: Cancel
|
|
25
32
|
save: Save
|
|
@@ -35,8 +42,22 @@ en:
|
|
|
35
42
|
available_downloads: Available downloads
|
|
36
43
|
empty: You have no downloads yet. Create one below.
|
|
37
44
|
item_name: "%{table} export"
|
|
45
|
+
repeats_with_interval: "Repeats %{interval}"
|
|
46
|
+
repeat_section: Repeat
|
|
47
|
+
repeat: Repeat
|
|
48
|
+
repeat_once: "Only once"
|
|
49
|
+
repeat_repeating: Repeating
|
|
50
|
+
repeat_intervals:
|
|
51
|
+
daily: daily
|
|
52
|
+
weekly: weekly
|
|
53
|
+
monthly: monthly
|
|
54
|
+
quarterly: quarterly
|
|
55
|
+
bi-yearly: bi-yearly
|
|
56
|
+
yearly: yearly
|
|
38
57
|
download: Download
|
|
58
|
+
delete: Delete export
|
|
39
59
|
processing: Preparing…
|
|
60
|
+
waiting: Waiting
|
|
40
61
|
failed: Failed
|
|
41
62
|
new_export: New export
|
|
42
63
|
scope_all: All records (matching current filters)
|
data/config/locales/nl.yml
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
nl:
|
|
2
2
|
mensa:
|
|
3
|
+
actions: Acties
|
|
4
|
+
save: Bewaar
|
|
5
|
+
update_view: Bewaar weergave
|
|
6
|
+
save_view: Bewaar als nieuwe weergave
|
|
7
|
+
save_as_new_view: Bewaar als nieuwe weergave
|
|
3
8
|
operators:
|
|
4
9
|
is: is
|
|
5
10
|
isnt: is niet
|
|
@@ -12,6 +17,7 @@ nl:
|
|
|
12
17
|
lteq: kleiner dan of gelijk aan
|
|
13
18
|
is_empty: is leeg
|
|
14
19
|
add_filter:
|
|
20
|
+
add: Filter toevoegen
|
|
15
21
|
component:
|
|
16
22
|
add_filter: Filter toevoegen
|
|
17
23
|
filter_pill_list:
|
|
@@ -36,8 +42,22 @@ nl:
|
|
|
36
42
|
available_downloads: Beschikbare downloads
|
|
37
43
|
empty: Je hebt nog geen downloads. Maak er hieronder een aan.
|
|
38
44
|
item_name: "%{table} export"
|
|
45
|
+
repeats_with_interval: "Herhaalt %{interval}"
|
|
46
|
+
repeat_section: Herhalen
|
|
47
|
+
repeat: Herhalen
|
|
48
|
+
repeat_once: "Eenmalig"
|
|
49
|
+
repeat_repeating: Herhalend
|
|
50
|
+
repeat_intervals:
|
|
51
|
+
daily: dagelijks
|
|
52
|
+
weekly: wekelijks
|
|
53
|
+
monthly: maandelijks
|
|
54
|
+
quarterly: per kwartaal
|
|
55
|
+
bi-yearly: halfjaarlijks
|
|
56
|
+
yearly: jaarlijks
|
|
39
57
|
download: Downloaden
|
|
58
|
+
delete: Export verwijderen
|
|
40
59
|
processing: Bezig…
|
|
60
|
+
waiting: Wachtend
|
|
41
61
|
failed: Mislukt
|
|
42
62
|
new_export: Nieuwe export
|
|
43
63
|
scope_all: Alle records (volgens huidige filters)
|
data/config/routes.rb
CHANGED
|
@@ -4,7 +4,7 @@ Mensa::Engine.routes.draw do
|
|
|
4
4
|
resources :filters
|
|
5
5
|
resources :views, only: [:create, :update, :destroy]
|
|
6
6
|
resources :batch_actions, only: [:create]
|
|
7
|
-
resources :exports, only: [:index, :create] do
|
|
7
|
+
resources :exports, only: [:index, :create, :destroy] do
|
|
8
8
|
member do
|
|
9
9
|
get :download
|
|
10
10
|
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddRepeatToMensaExports < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :mensa_exports, :repeat, :string, null: false, default: ""
|
|
4
|
+
add_column :mensa_exports, :last_repeat_run_at, :datetime
|
|
5
|
+
add_index :mensa_exports, :repeat
|
|
6
|
+
add_index :mensa_exports, :last_repeat_run_at
|
|
7
|
+
end
|
|
8
|
+
end
|
data/lib/mensa/configuration.rb
CHANGED
|
@@ -72,6 +72,11 @@ module Mensa
|
|
|
72
72
|
end,
|
|
73
73
|
# Called with the Mensa::Export once the CSV has been generated and
|
|
74
74
|
# attached (export.asset). Use this to e.g. notify or email the user.
|
|
75
|
+
#
|
|
76
|
+
# UserMailer.with(
|
|
77
|
+
# user: User.find(export.user_id),
|
|
78
|
+
# export: export,
|
|
79
|
+
# ).export_email.deliver_later
|
|
75
80
|
export_complete: lambda do |export|
|
|
76
81
|
end
|
|
77
82
|
}
|
data/lib/mensa/version.rb
CHANGED
data/lib/tasks/mensa_tasks.rake
CHANGED
|
@@ -5,6 +5,13 @@ namespace :mensa do
|
|
|
5
5
|
Rails::Generators.invoke("mensa:tailwind_config", ["--force"])
|
|
6
6
|
end
|
|
7
7
|
end
|
|
8
|
+
|
|
9
|
+
namespace :exports do
|
|
10
|
+
desc "Run recurring exports that are due"
|
|
11
|
+
task recurring: :environment do
|
|
12
|
+
Mensa::RecurringExportsJob.perform_now
|
|
13
|
+
end
|
|
14
|
+
end
|
|
8
15
|
end
|
|
9
16
|
|
|
10
17
|
if Rake::Task.task_defined?("tailwindcss:build")
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mensa
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom de Grunt
|
|
@@ -247,6 +247,7 @@ files:
|
|
|
247
247
|
- app/javascript/mensa/controllers/link_controller.js
|
|
248
248
|
- app/jobs/mensa/application_job.rb
|
|
249
249
|
- app/jobs/mensa/export_job.rb
|
|
250
|
+
- app/jobs/mensa/recurring_exports_job.rb
|
|
250
251
|
- app/models/concerns/.keep
|
|
251
252
|
- app/models/mensa/application_record.rb
|
|
252
253
|
- app/models/mensa/export.rb
|
|
@@ -276,6 +277,7 @@ files:
|
|
|
276
277
|
- app/views/mensa/tables/filters/show.turbo_stream.erb
|
|
277
278
|
- app/views/mensa/tables/show.html.erb
|
|
278
279
|
- app/views/mensa/tables/show.turbo_stream.erb
|
|
280
|
+
- app/views/mensa/tables/standard_error.html.erb
|
|
279
281
|
- app/views/mensa/tables/views/create.turbo_stream.erb
|
|
280
282
|
- app/views/mensa/tables/views/destroy.turbo_stream.erb
|
|
281
283
|
- app/views/mensa/tables/views/update.turbo_stream.erb
|
|
@@ -290,6 +292,7 @@ files:
|
|
|
290
292
|
- db/migrate/20240201184752_create_mensa_table_views.rb
|
|
291
293
|
- db/migrate/20251112143558_add_description_to_table_view.rb
|
|
292
294
|
- db/migrate/20260604120000_create_mensa_exports.rb
|
|
295
|
+
- db/migrate/20260612110000_add_repeat_to_mensa_exports.rb
|
|
293
296
|
- docs/columns.png
|
|
294
297
|
- docs/export.png
|
|
295
298
|
- docs/filters.png
|