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