mensa 0.6.11 → 0.6.12

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/.shipkit.yml +2 -0
  3. data/CHANGELOG.md +18 -0
  4. data/Gemfile +4 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +32 -0
  7. data/app/components/mensa/column_customizer/component.html.erb +1 -0
  8. data/app/components/mensa/column_customizer/component_controller.js +8 -3
  9. data/app/components/mensa/control_bar/component.html.erb +2 -2
  10. data/app/components/mensa/filter_pill_list/component.html.erb +1 -0
  11. data/app/components/mensa/filter_pill_list/component_controller.js +15 -9
  12. data/app/components/mensa/table/component.html.erb +3 -3
  13. data/app/components/mensa/table/component_controller.js +9 -5
  14. data/app/components/mensa/view/component.html.erb +1 -1
  15. data/app/components/mensa/views/component.html.erb +1 -1
  16. data/app/components/mensa/views/component_controller.js +2 -2
  17. data/app/controllers/mensa/tables/batch_actions_controller.rb +1 -1
  18. data/app/controllers/mensa/tables/exports_controller.rb +31 -12
  19. data/app/controllers/mensa/tables/filters_controller.rb +2 -2
  20. data/app/controllers/mensa/tables/views_controller.rb +8 -3
  21. data/app/javascript/mensa/controllers/application_controller.js +8 -0
  22. data/app/jobs/mensa/export_job.rb +5 -5
  23. data/app/models/mensa/export.rb +42 -25
  24. data/app/tables/mensa/base.rb +21 -1
  25. data/app/tables/mensa/column.rb +1 -1
  26. data/app/tables/mensa/scope.rb +21 -2
  27. data/app/tables/mensa/table_params.rb +44 -0
  28. data/app/views/mensa/exports/_badge.html.erb +3 -2
  29. data/app/views/mensa/exports/_dialog.html.erb +2 -2
  30. data/app/views/mensa/exports/_list.html.erb +1 -1
  31. data/lib/mensa/configuration.rb +24 -1
  32. data/lib/mensa/version.rb +1 -1
  33. metadata +4 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 595e1c13d2966b2cf3991e4f145bf408655431ca50a388d47c5fc8532d534635
4
- data.tar.gz: 06a44600cbf0f6cdf14f0cdc8dfd3e4cb31ca0749b0cfbbb9017295bcea6ec9a
3
+ metadata.gz: 72a11ca0b316e616b3705f54523e4fe6f6ae408f7e767f9a130db02d1afc088f
4
+ data.tar.gz: ae13275d6ead1adece9e27ea3f82143c3dc88bb1aa7f443ad8aaf8dca251eff1
5
5
  SHA512:
6
- metadata.gz: 7873d0830a89a852aae065431b35312ab71d00284330bf6b20a7aacf64250f3480c28478955a86b6c5e90b2882be7d1de3d3df1ab3e0a8ee16e04834ddb9f258
7
- data.tar.gz: f50ca9748a2fe082d257d79339f2408bd36dfca6bbd4dda32b184fefc5beee6570280eb1f289028d4c36ed7586a5cd6f56d80b87476992800621a68cdaa67adf
6
+ metadata.gz: 1a5f0d0e105762e8542af697c8bc1af5816ac2d628b9ba1e22bab262828ddf47ffbfe5e8f8a9d03ca78990caa8cf6b313748199eaa2f2eae0658cf6a2ad0c8f0
7
+ data.tar.gz: 8ba1ab12c0c48b03f4b425cc933614bbc5880fed150fbf70f2e32e39dd90307eca0a530c2cf43e81450708ba094fcab9dfe6dd81258b2cb77a72818f4f48fed9
data/.shipkit.yml ADDED
@@ -0,0 +1,2 @@
1
+ ---
2
+ previous_version: 0.6.11
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.12
4
+
5
+ - Tables built with `params` (`table :name, params: {...}`) now work in filters,
6
+ views, batch actions and exports. The params are sent to these endpoints as
7
+ `params[...]` query parameters and stored on `Mensa::Export#config`, so
8
+ `Mensa::ExportJob` (and recurring exports) rebuild the table with them.
9
+ Host apps can remove their FiltersController, ExportsController and
10
+ Table::Component patches for this.
11
+ - The export list, badge and Turbo stream are scoped per table params, so
12
+ exports of e.g. two different queries on the same table are no longer mixed.
13
+ - Browser state (filters, search, order, column order, hidden columns) is
14
+ stored per table params. Unknown column names in `column_order`,
15
+ `hidden_columns`, `order` and `filters` are ignored instead of raising.
16
+ - The export completed callback is now `export_completed`, as documented in the
17
+ generated initializer. The old `export_complete` name still works. Unknown
18
+ callback names are logged as a warning.
19
+ - `selected_scope` only selects `id` when the relation has an `id` column.
20
+
3
21
  ## 0.2.4
4
22
 
5
23
  - Renames the data column on table_views to config
data/Gemfile CHANGED
@@ -22,3 +22,7 @@ group :development, :test do
22
22
  gem "ruby-lsp", require: false
23
23
  gem "ruby-lsp-rails", require: false
24
24
  end
25
+
26
+ source "https://rubygems.pkg.github.com/entdec" do
27
+ gem "shipkit"
28
+ end
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mensa (0.6.10)
4
+ mensa (0.6.11)
5
5
  csv
6
6
  importmap-rails
7
7
  pagy (>= 43)
@@ -366,6 +366,11 @@ GEM
366
366
  yaml (0.4.0)
367
367
  zeitwerk (2.8.2)
368
368
 
369
+ GEM
370
+ remote: https://rubygems.pkg.github.com/entdec/
371
+ specs:
372
+ shipkit (0.1.2)
373
+
369
374
  PLATFORMS
370
375
  aarch64-linux-gnu
371
376
  aarch64-linux-musl
@@ -391,8 +396,9 @@ DEPENDENCIES
391
396
  ruby-lsp
392
397
  ruby-lsp-rails
393
398
  selenium-webdriver (~> 4.17)
399
+ shipkit!
394
400
  sprockets-rails
395
401
  standard
396
402
 
397
403
  BUNDLED WITH
398
- 2.6.2
404
+ 2.6.9
data/README.md CHANGED
@@ -186,12 +186,44 @@ Next you can run the generator to generate a table:
186
186
  $ rails g mensa:table:generate <model_name>
187
187
  ```
188
188
 
189
+ ### Table params
190
+
191
+ A table can be built with extra params, for example when its scope or columns
192
+ depend on a record:
193
+
194
+ ```erb
195
+ <%= table :query_results, params: {query_id: @query.id} %>
196
+ ```
197
+
198
+ They are available as `params` inside the table (e.g. `params[:query_id]`) and
199
+ are sent along whenever Mensa rebuilds the table: paging, filters, views, batch
200
+ actions and exports (including recurring exports, which store them on the
201
+ `Mensa::Export`). Exports and the state the browser remembers (filters, order,
202
+ columns) are kept per table *and* params.
203
+
189
204
  ### Exports
190
205
 
191
206
  Exporting is built into the table's control bar. Clicking the export button opens
192
207
  a dialog that lists the user's previous downloads and lets them request a new
193
208
  export (scope and CSV format).
194
209
 
210
+ #### Export callbacks
211
+
212
+ Configure callbacks in your Mensa initializer to act on exports, for example to
213
+ email the user once their download is ready:
214
+
215
+ ```ruby
216
+ Mensa.setup do |config|
217
+ config.callbacks = {
218
+ export_started: ->(export) {},
219
+ export_completed: ->(export) { ExportMailer.with(export: export).ready.deliver_later }
220
+ }
221
+ end
222
+ ```
223
+
224
+ `export_completed` was called `export_complete` before 0.6.12; that name still
225
+ works. Unknown callback names are logged as a warning.
226
+
195
227
  #### Repeating exports
196
228
 
197
229
  The user can choose to export a table on a regular basis (daily, weekly, monthly, quarterly, bi-yearly, yearly).
@@ -1,6 +1,7 @@
1
1
  <div class="relative"
2
2
  data-controller="mensa-column-customizer"
3
3
  data-mensa-column-customizer-table-name-value="<%= table.name %>"
4
+ data-mensa-column-customizer-storage-key-value="<%= table.storage_key %>"
4
5
  data-mensa-column-customizer-turbo-frame-id-value="<%= table.table_id %>"
5
6
  data-mensa-column-customizer-mensa-table-outlet="#table-<%= table.table_id %>">
6
7
  <button class="mensa-table__control_bar__button" type="button" data-action="click->mensa-column-customizer#toggle">
@@ -3,7 +3,7 @@ import ApplicationController from "mensa/controllers/application_controller";
3
3
  export default class ColumnCustomizerController extends ApplicationController {
4
4
  static outlets = ["mensa-table"];
5
5
  static targets = ["popover", "columnRow"];
6
- static values = { turboFrameId: String, tableName: String };
6
+ static values = { turboFrameId: String, tableName: String, storageKey: String };
7
7
 
8
8
  connect() {
9
9
  super.connect();
@@ -340,12 +340,17 @@ export default class ColumnCustomizerController extends ApplicationController {
340
340
  if (view) url.searchParams.set("table_view_id", view);
341
341
  }
342
342
 
343
+ // Tables built with different params keep separate browser state.
344
+ get storageName() {
345
+ return this.storageKeyValue || this.tableNameValue;
346
+ }
347
+
343
348
  get _columnOrderKey() {
344
- return `mensa:column_order:${this.tableNameValue}`;
349
+ return `mensa:column_order:${this.storageName}`;
345
350
  }
346
351
 
347
352
  get _hiddenColumnsKey() {
348
- return `mensa:hidden_columns:${this.tableNameValue}`;
353
+ return `mensa:hidden_columns:${this.storageName}`;
349
354
  }
350
355
 
351
356
  _writeStorage(key, value) {
@@ -45,13 +45,13 @@
45
45
  <% if table.exportable? %>
46
46
  <button class="mensa-table__control_bar__button relative" type="button" data-action="mensa-table#export">
47
47
  <i data-mensa-table-target="exportIcon" class="<%= Mensa.config.icons[:control_bar_export] %>"></i>
48
- <%= helpers.render partial: "mensa/exports/badge", locals: {table_name: table.name, user: table.current_user} %>
48
+ <%= helpers.render partial: "mensa/exports/badge", locals: {table_name: table.name, user: table.current_user, table_params: table.params} %>
49
49
  </button>
50
50
  <% end %>
51
51
  </div>
52
52
 
53
53
  <% if table.exportable? %>
54
- <%= helpers.turbo_stream_from Mensa::Export.stream_name(table.name, table.current_user) %>
54
+ <%= helpers.turbo_stream_from Mensa::Export.stream_name(table.name, table.current_user, params: table.params) %>
55
55
  <%= helpers.render partial: "mensa/exports/dialog", locals: {table: table} %>
56
56
  <% end %>
57
57
  </div>
@@ -4,6 +4,7 @@
4
4
  data-controller="mensa-filter-pill-list"
5
5
  data-mensa-table-target="filterList"
6
6
  data-mensa-filter-pill-list-table-name-value="<%= table.name %>"
7
+ data-mensa-filter-pill-list-storage-key-value="<%= table.storage_key %>"
7
8
  data-mensa-filter-pill-list-mensa-table-outlet=".mensa-table#table-<%= table.table_id %>"
8
9
  data-mensa-filter-pill-list-mensa-filter-pill-outlet=".mensa-filter-pill"
9
10
  data-mensa-filter-pill-list-mensa-add-filter-outlet="[data-controller=mensa-add-filter]">
@@ -8,6 +8,7 @@ export default class FilterPillListComponentController extends ApplicationContro
8
8
  static values = {
9
9
  supportsViews: Boolean,
10
10
  tableName: String,
11
+ storageKey: String,
11
12
  };
12
13
 
13
14
  connect() {
@@ -567,8 +568,8 @@ export default class FilterPillListComponentController extends ApplicationContro
567
568
  this.writeStorage(this.filtersStorageKey, null);
568
569
  this.writeStorage(this.searchStorageKey, null);
569
570
  this.writeStorage(this.orderStorageKey, null);
570
- this.writeStorage(`mensa:column_order:${this.tableNameValue}`, null);
571
- this.writeStorage(`mensa:hidden_columns:${this.tableNameValue}`, null);
571
+ this.writeStorage(`mensa:column_order:${this.storageName}`, null);
572
+ this.writeStorage(`mensa:hidden_columns:${this.storageName}`, null);
572
573
  }
573
574
 
574
575
  // Keep these for backward-compatibility with other controllers that look up
@@ -698,7 +699,7 @@ export default class FilterPillListComponentController extends ApplicationContro
698
699
  return (
699
700
  JSON.parse(
700
701
  this.readStorage(
701
- `mensa:column_order:${this.tableNameValue}`,
702
+ `mensa:column_order:${this.storageName}`,
702
703
  ),
703
704
  ) || []
704
705
  );
@@ -712,7 +713,7 @@ export default class FilterPillListComponentController extends ApplicationContro
712
713
  return (
713
714
  JSON.parse(
714
715
  this.readStorage(
715
- `mensa:hidden_columns:${this.tableNameValue}`,
716
+ `mensa:hidden_columns:${this.storageName}`,
716
717
  ),
717
718
  ) || []
718
719
  );
@@ -721,20 +722,25 @@ export default class FilterPillListComponentController extends ApplicationContro
721
722
  }
722
723
  }
723
724
 
725
+ // Tables built with different params keep separate browser state.
726
+ get storageName() {
727
+ return this.storageKeyValue || this.tableNameValue;
728
+ }
729
+
724
730
  get filtersStorageKey() {
725
- return `mensa:filters:${this.tableNameValue}`;
731
+ return `mensa:filters:${this.storageName}`;
726
732
  }
727
733
  get searchStorageKey() {
728
- return `mensa:search:${this.tableNameValue}`;
734
+ return `mensa:search:${this.storageName}`;
729
735
  }
730
736
  get viewStorageKey() {
731
- return `mensa:view:${this.tableNameValue}`;
737
+ return `mensa:view:${this.storageName}`;
732
738
  }
733
739
  get pageStorageKey() {
734
- return `mensa:page:${this.tableNameValue}`;
740
+ return `mensa:page:${this.storageName}`;
735
741
  }
736
742
  get orderStorageKey() {
737
- return `mensa:order:${this.tableNameValue}`;
743
+ return `mensa:order:${this.storageName}`;
738
744
  }
739
745
 
740
746
  get ourUrl() {
@@ -2,9 +2,9 @@
2
2
  id="table-<%= table.table_id %>"
3
3
  data-mensa-table-supports-views-value="<%= table.views? %>"
4
4
  data-mensa-table-table-url-value="<%= table.path(turbo_frame_id: table.table_id, user_params: params) %>"
5
- data-mensa-table-save-view-url-value="<%= helpers.mensa.table_views_path(table.name) %>"
6
- data-mensa-table-views-url-value="<%= helpers.mensa.table_views_path(table.name) %>"
7
- data-mensa-table-exports-url-value="<%= helpers.mensa.table_exports_path(table.name) %>"
5
+ data-mensa-table-save-view-url-value="<%= table.path_with_params(helpers.mensa.table_views_path(table.name)) %>"
6
+ data-mensa-table-views-url-value="<%= table.path_with_params(helpers.mensa.table_views_path(table.name)) %>"
7
+ data-mensa-table-exports-url-value="<%= table.path_with_params(helpers.mensa.table_exports_path(table.name)) %>"
8
8
  data-controller="mensa-table"
9
9
  data-action="click@document->mensa-table#saveDropdownOutsideHandler"
10
10
  data-mensa-table-mensa-filter-pill-outlet="[data-controller='mensa-filter-pill']"
@@ -255,7 +255,7 @@ export default class TableComponentController extends ApplicationController {
255
255
  const state = this.currentViewState();
256
256
  this.hideSaveReset();
257
257
 
258
- const response = await patch(`${this.saveViewUrlValue}/${viewId}`, {
258
+ const response = await patch(this.memberUrl(this.saveViewUrlValue, viewId), {
259
259
  body: JSON.stringify({
260
260
  query: state.query,
261
261
  filters: state.filters,
@@ -439,17 +439,21 @@ export default class TableComponentController extends ApplicationController {
439
439
  // --- View filter visibility persistence ---
440
440
 
441
441
  _viewFiltersStorageKey() {
442
- return `mensa:view-filters-visible:${this._tableName()}`;
442
+ return `mensa:view-filters-visible:${this._storageName()}`;
443
443
  }
444
444
 
445
- _tableName() {
445
+ _storageName() {
446
446
  if (this.hasMensaFilterPillListOutlet) {
447
- return this.mensaFilterPillListOutlet.tableNameValue;
447
+ return this.mensaFilterPillListOutlet.storageName;
448
448
  }
449
449
  const el = this.element.querySelector(
450
450
  "[data-mensa-filter-pill-list-table-name-value]",
451
451
  );
452
- return el?.dataset?.mensaFilterPillListTableNameValue || "";
452
+ return (
453
+ el?.dataset?.mensaFilterPillListStorageKeyValue ||
454
+ el?.dataset?.mensaFilterPillListTableNameValue ||
455
+ ""
456
+ );
453
457
  }
454
458
 
455
459
  _loadViewFiltersVisible() {
@@ -2,7 +2,7 @@
2
2
  <div class="overflow-y-auto relative"
3
3
  data-mensa-table-target="scrollContainer"
4
4
  data-controller="mensa-selection"
5
- data-mensa-selection-batch-url-value="<%= table.batch_actions? ? helpers.mensa.table_batch_actions_path(table.name) : "" %>">
5
+ data-mensa-selection-batch-url-value="<%= table.batch_actions? ? table.path_with_params(helpers.mensa.table_batch_actions_path(table.name)) : "" %>">
6
6
  <% if table.batch_actions? %>
7
7
  <div class="mensa-batch-bar hidden" data-mensa-selection-target="batchBar">
8
8
  <div class="mensa-batch-bar__content">
@@ -8,7 +8,7 @@
8
8
  data-controller="mensa-views"
9
9
  data-mensa-views-mensa-filter-pill-list-outlet="#mensa-filter-pill-list-<%= table.table_id %>"
10
10
  data-mensa-views-table-id-value="<%= table.table_id %>"
11
- data-mensa-views-views-url-value="<%= helpers.mensa.table_views_path(table.name) %>">
11
+ data-mensa-views-views-url-value="<%= table.path_with_params(helpers.mensa.table_views_path(table.name)) %>">
12
12
  <button class="mensa-table__views__trigger" type="button" data-action="mensa-views#toggleDropdown" data-mensa-views-target="trigger" aria-haspopup="listbox">
13
13
  <span class="mensa-table__views__trigger-label" data-mensa-views-target="triggerLabel"><%= current_view_name %></span>
14
14
  <i class="<%= Mensa.config.icons[:views_trigger] %> mensa-table__views__trigger-icon"></i>
@@ -120,7 +120,7 @@ export default class ViewsComponentController extends ApplicationController {
120
120
  }
121
121
 
122
122
  const turboFrameId = this._turboFrameId();
123
- const response = await patch(`${this.viewsUrlValue}/${viewId}`, {
123
+ const response = await patch(this.memberUrl(this.viewsUrlValue, viewId), {
124
124
  body: JSON.stringify({ name: newName, turbo_frame_id: turboFrameId }),
125
125
  contentType: "application/json",
126
126
  responseKind: "turbo-stream",
@@ -177,7 +177,7 @@ export default class ViewsComponentController extends ApplicationController {
177
177
  if (!confirm(`Delete "${viewName}"?`)) return;
178
178
 
179
179
  const turboFrameId = this._turboFrameId();
180
- await destroy(`${this.viewsUrlValue}/${viewId}`, {
180
+ await destroy(this.memberUrl(this.viewsUrlValue, viewId), {
181
181
  body: JSON.stringify({ turbo_frame_id: turboFrameId }),
182
182
  contentType: "application/json",
183
183
  responseKind: "turbo-stream",
@@ -8,7 +8,7 @@ module Mensa
8
8
  batch_action_name = params[:batch_action_name]&.to_sym
9
9
  record_ids = Array(params[:record_ids])
10
10
 
11
- table = Mensa.for_name(table_name)
11
+ table = Mensa.for_name(table_name, params: Mensa::TableParams.from_request(params))
12
12
  table.original_view_context = helpers
13
13
 
14
14
  batch_action = table.batch_actions.find { |a| a.name == batch_action_name }
@@ -4,6 +4,10 @@ module Mensa
4
4
  # requests. Generating the CSV happens asynchronously in Mensa::ExportJob;
5
5
  # both the export button badge and the downloads list are refreshed via
6
6
  # Turbo streams once the job completes.
7
+ #
8
+ # Tables built with extra params send them along as params[...] in the
9
+ # exports URL. They are stored on the export so Mensa::ExportJob can
10
+ # rebuild the table, and they scope the downloads list and badge.
7
11
  class ExportsController < ::ApplicationController
8
12
  # Returns the current downloads list for the table, used to refresh the
9
13
  # contents of the export dialog when it is opened.
@@ -46,9 +50,10 @@ module Mensa
46
50
  # Deletes an export from the current user's download list. The attached
47
51
  # asset is purged automatically when the record is destroyed.
48
52
  def destroy
49
- export = exports.find(params[:id])
53
+ export = user_exports.find(params[:id])
54
+ @table_params = export.table_params
50
55
  export.destroy
51
- Mensa::Export.broadcast_refresh(export.table_name, export.user)
56
+ Mensa::Export.broadcast_refresh(export.table_name, export.user, params: export.table_params)
52
57
 
53
58
  respond_to do |format|
54
59
  format.turbo_stream { render turbo_stream: [list_stream, badge_stream] }
@@ -62,7 +67,7 @@ module Mensa
62
67
  # controller (instead of a direct Active Storage link) gives us a hook to
63
68
  # delete the Mensa::Export record and free the stored file afterwards.
64
69
  def download
65
- export = exports.find(params[:id])
70
+ export = user_exports.find(params[:id])
66
71
  return head :not_found unless export.downloadable?
67
72
 
68
73
  data = export.asset.download
@@ -77,11 +82,10 @@ module Mensa
77
82
  begin
78
83
  if export.repeating?
79
84
  export.asset.purge_later
80
- Mensa::Export.broadcast_refresh(export.table_name, export.user)
81
85
  else
82
86
  export.destroy
83
- Mensa::Export.broadcast_refresh(export.table_name, export.user)
84
87
  end
88
+ Mensa::Export.broadcast_refresh(export.table_name, export.user, params: export.table_params)
85
89
  rescue => e
86
90
  Mensa.config.logger&.warn("Mensa::Export cleanup failed for #{export.id}: #{e.class}: #{e.message}")
87
91
  end
@@ -89,35 +93,50 @@ module Mensa
89
93
 
90
94
  private
91
95
 
96
+ # All of the current user's exports of this table, regardless of
97
+ # params. Download and delete links don't carry the params.
98
+ def user_exports
99
+ Mensa::Export.where(table_name: params[:table_id]).for_user(current_mensa_user)
100
+ end
101
+
102
+ # The exports listed for the table as built with the current params.
92
103
  def exports
93
- @exports ||= Mensa::Export.for_table(params[:table_id]).for_user(current_mensa_user).recent
104
+ @exports ||= Mensa::Export.for_table(params[:table_id], params: table_params).for_user(current_mensa_user).recent
105
+ end
106
+
107
+ def table_params
108
+ @table_params ||= Mensa::TableParams.from_request(params)
94
109
  end
95
110
 
96
111
  def list_stream
97
- turbo_stream.replace(Mensa::Export.list_dom_id(params[:table_id], current_mensa_user),
112
+ turbo_stream.replace(Mensa::Export.list_dom_id(params[:table_id], current_mensa_user, params: table_params),
98
113
  partial: "mensa/exports/list", locals: list_locals)
99
114
  end
100
115
 
101
116
  def badge_stream
102
- turbo_stream.replace(Mensa::Export.badge_dom_id(params[:table_id], current_mensa_user),
117
+ turbo_stream.replace(Mensa::Export.badge_dom_id(params[:table_id], current_mensa_user, params: table_params),
103
118
  partial: "mensa/exports/badge",
104
- locals: {table_name: params[:table_id], user: current_mensa_user})
119
+ locals: {table_name: params[:table_id], user: current_mensa_user, table_params: table_params})
105
120
  end
106
121
 
107
122
  def list_locals
108
- {table_name: params[:table_id], user: current_mensa_user, exports: exports}
123
+ {table_name: params[:table_id], user: current_mensa_user, table_params: table_params, exports: exports}
109
124
  end
110
125
 
111
126
  def export_config
112
- params.permit(
127
+ config = params.permit(
113
128
  :query,
114
129
  :page,
115
130
  :table_view_id,
116
131
  order: {},
117
132
  filters: {},
118
133
  column_order: [],
119
- hidden_columns: []
134
+ hidden_columns: [],
135
+ params: {}
120
136
  ).to_h
137
+ config.delete(:params)
138
+ config[:params] = table_params if table_params.present?
139
+ config
121
140
  end
122
141
 
123
142
  def current_mensa_user
@@ -10,13 +10,13 @@ module Mensa
10
10
  end
11
11
 
12
12
  def index
13
- config = {}.merge(params.permit(:id, :page, :table_id, :target, :table_view_id, :turbo_frame_id, order: {}, filters: {}).to_h)
13
+ config = {}.merge(params.permit(:id, :page, :table_id, :target, :table_view_id, :turbo_frame_id, order: {}, filters: {}, params: {}).to_h)
14
14
  @table = Mensa.for_name(params[:table_id], config)
15
15
  end
16
16
 
17
17
  # Returns the filter information on the column-name
18
18
  def show
19
- config = {}.merge(params.permit(:id, :value, :operator, :page, :table_id, :target, :table_view_id, :turbo_frame_id, order: {}, filters: {}).to_h)
19
+ config = {}.merge(params.permit(:id, :value, :operator, :page, :table_id, :target, :table_view_id, :turbo_frame_id, order: {}, filters: {}, params: {}).to_h)
20
20
  @table = Mensa.for_name(params[:table_id], config)
21
21
  @table.original_view_context = helpers
22
22
  @column = @table.column(params[:id])
@@ -24,7 +24,7 @@ module Mensa
24
24
  # generated element IDs match what is already in the DOM.
25
25
  table_config = view.config
26
26
  .deep_transform_keys(&:to_sym)
27
- .merge(turbo_frame_id: params[:turbo_frame_id])
27
+ .merge(turbo_frame_id: params[:turbo_frame_id], params: table_params)
28
28
 
29
29
  @table = Mensa.for_name(params[:table_id], table_config)
30
30
  @table.request = request
@@ -56,7 +56,7 @@ module Mensa
56
56
  format.turbo_stream do
57
57
  table_config = view.config
58
58
  .deep_transform_keys(&:to_sym)
59
- .merge(turbo_frame_id: params[:turbo_frame_id])
59
+ .merge(turbo_frame_id: params[:turbo_frame_id], params: table_params)
60
60
 
61
61
  @table = Mensa.for_name(params[:table_id], table_config)
62
62
  @table.request = request
@@ -81,7 +81,7 @@ module Mensa
81
81
 
82
82
  respond_to do |format|
83
83
  format.turbo_stream do
84
- @table = Mensa.for_name(params[:table_id], {turbo_frame_id: params[:turbo_frame_id]})
84
+ @table = Mensa.for_name(params[:table_id], {turbo_frame_id: params[:turbo_frame_id], params: table_params})
85
85
  @table.request = request
86
86
  @table.original_view_context = helpers
87
87
  end
@@ -100,6 +100,11 @@ module Mensa
100
100
  config
101
101
  end
102
102
 
103
+ # The params the table was built with, sent along in the views URL.
104
+ def table_params
105
+ Mensa::TableParams.from_request(params)
106
+ end
107
+
103
108
  def current_mensa_user
104
109
  Current.user if defined?(Current) && Current.respond_to?(:user)
105
110
  end
@@ -6,6 +6,14 @@ export default class ApplicationController extends Controller {
6
6
  this.element[this.identifier] = this
7
7
  }
8
8
 
9
+ // Appends a member id to a collection URL while keeping its query string
10
+ // (e.g. the table's params[...]) intact.
11
+ memberUrl(collectionUrl, id) {
12
+ const url = new URL(collectionUrl, window.location.origin)
13
+ url.pathname += `/${encodeURIComponent(id)}`
14
+ return url.toString()
15
+ }
16
+
9
17
  getController(element, identifier) {
10
18
  return this.application.getControllerForElementAndIdentifier(element, identifier)
11
19
  }
@@ -13,7 +13,7 @@ module Mensa
13
13
  return unless export
14
14
 
15
15
  export.update(status: "processing")
16
- Mensa.config.callbacks[:export_started]&.call(export)
16
+ Mensa.config.run_callback(:export_started, export)
17
17
 
18
18
  table = build_table(export)
19
19
  unless table.exportable?
@@ -27,7 +27,7 @@ module Mensa
27
27
  export.asset.attach(io: tempfile, filename: filename, content_type: content_type)
28
28
  finalize(export, status: "completed", filename: filename)
29
29
 
30
- Mensa.config.callbacks[:export_complete]&.call(export)
30
+ Mensa.config.run_callback(:export_completed, export)
31
31
  rescue => e
32
32
  Mensa.config.logger&.error("Mensa::ExportJob failed for export #{export&.id}: #{e.class}: #{e.message}")
33
33
  finalize(export, status: "failed") if export
@@ -40,8 +40,8 @@ module Mensa
40
40
 
41
41
  # Rebuilds the table the export was requested for, layering the view
42
42
  # configuration (if any) underneath the captured request configuration
43
- # (filters, query, ordering, page) so the generated data matches what the
44
- # user saw when they requested the export.
43
+ # (params, filters, query, ordering, page) so the generated data matches
44
+ # what the user saw when they requested the export.
45
45
  def build_table(export)
46
46
  config = {}
47
47
 
@@ -131,7 +131,7 @@ module Mensa
131
131
  export.update(attributes)
132
132
  # Refresh the export button badge (download count) and the downloads list
133
133
  # inside the export dialog for everyone viewing this table.
134
- Mensa::Export.broadcast_refresh(export.table_name, export.user)
134
+ Mensa::Export.broadcast_refresh(export.table_name, export.user, params: export.table_params)
135
135
  end
136
136
  end
137
137
  end
@@ -16,7 +16,19 @@ module Mensa
16
16
  validates :status, inclusion: {in: STATUSES}
17
17
  validates :repeat, inclusion: {in: REPEATS}
18
18
 
19
- scope :for_table, ->(table_name) { where(table_name: table_name.to_s) }
19
+ # Exports of a table built with the given params (see Mensa::TableParams).
20
+ # Exports of the same table with different params are kept apart.
21
+ scope :for_table, ->(table_name, params: nil) {
22
+ relation = where(table_name: table_name.to_s)
23
+ table_params = Mensa::TableParams.normalize(params)
24
+ config_params = "#{quoted_table_name}.config -> 'params'"
25
+
26
+ if table_params.blank?
27
+ relation.where("COALESCE(#{config_params}, '{}'::jsonb) = '{}'::jsonb")
28
+ else
29
+ relation.where("#{config_params} = ?::jsonb", table_params.to_json)
30
+ end
31
+ }
20
32
  scope :for_user, ->(user) { where(user_id: user.respond_to?(:id) ? user&.id : user) }
21
33
  scope :completed, -> { where(status: "completed") }
22
34
  scope :recent, -> { order(created_at: :desc) }
@@ -39,6 +51,11 @@ module Mensa
39
51
  status == "pending"
40
52
  end
41
53
 
54
+ # The params the table was built with when this export was requested.
55
+ def table_params
56
+ (config || {}).stringify_keys["params"].presence || {}
57
+ end
58
+
42
59
  def next_repeat_run_at(from: nil)
43
60
  return if repeat.blank?
44
61
 
@@ -92,51 +109,51 @@ module Mensa
92
109
  completed? && asset.attached?
93
110
  end
94
111
 
95
- # Number of currently-downloadable exports for a table/user combination.
96
- # This is the number rendered in the export button badge.
97
- def self.completed_count(table_name, user)
98
- for_table(table_name).for_user(user).with_downloadable_asset.count
112
+ # Number of currently-downloadable exports for a table/user/params
113
+ # combination. This is the number rendered in the export button badge.
114
+ def self.completed_count(table_name, user, params: nil)
115
+ for_table(table_name, params: params).for_user(user).with_downloadable_asset.count
99
116
  end
100
117
 
101
- # A stable, page-independent key identifying the exports of a table/user
102
- # combination, used for Turbo stream names and DOM ids so background jobs
103
- # can target them after completion.
104
- def self.token(table_name, user)
118
+ # A stable, page-independent key identifying the exports of a
119
+ # table/user/params combination, used for Turbo stream names and DOM ids
120
+ # so background jobs can target them after completion.
121
+ def self.token(table_name, user, params: nil)
105
122
  user_key = user.respond_to?(:id) ? user&.id : user
106
- [table_name.to_s, user_key || "anonymous"].join("-").parameterize
123
+ [table_name.to_s, user_key || "anonymous", Mensa::TableParams.digest(params)].compact.join("-").parameterize
107
124
  end
108
125
 
109
- def self.stream_name(table_name, user)
110
- "mensa-exports-#{token(table_name, user)}"
126
+ def self.stream_name(table_name, user, params: nil)
127
+ "mensa-exports-#{token(table_name, user, params: params)}"
111
128
  end
112
129
 
113
- def self.badge_dom_id(table_name, user)
114
- "mensa-export-badge-#{token(table_name, user)}"
130
+ def self.badge_dom_id(table_name, user, params: nil)
131
+ "mensa-export-badge-#{token(table_name, user, params: params)}"
115
132
  end
116
133
 
117
- def self.list_dom_id(table_name, user)
118
- "mensa-export-list-#{token(table_name, user)}"
134
+ def self.list_dom_id(table_name, user, params: nil)
135
+ "mensa-export-list-#{token(table_name, user, params: params)}"
119
136
  end
120
137
 
121
138
  # Re-renders the export button badge and downloads list for everyone
122
- # subscribed to this table/user's export stream. Best-effort: a missing
123
- # Action Cable backend (or other broadcast failure) must never break the
124
- # caller (job completion, download cleanup, ...).
125
- def self.broadcast_refresh(table_name, user)
126
- stream = stream_name(table_name, user)
139
+ # subscribed to this table/user/params export stream. Best-effort: a
140
+ # missing Action Cable backend (or other broadcast failure) must never
141
+ # break the caller (job completion, download cleanup, ...).
142
+ def self.broadcast_refresh(table_name, user, params: nil)
143
+ stream = stream_name(table_name, user, params: params)
127
144
 
128
145
  Turbo::StreamsChannel.broadcast_replace_to(
129
146
  stream,
130
- target: badge_dom_id(table_name, user),
147
+ target: badge_dom_id(table_name, user, params: params),
131
148
  partial: "mensa/exports/badge",
132
- locals: {table_name: table_name, user: user}
149
+ locals: {table_name: table_name, user: user, table_params: params}
133
150
  )
134
151
 
135
152
  Turbo::StreamsChannel.broadcast_replace_to(
136
153
  stream,
137
- target: list_dom_id(table_name, user),
154
+ target: list_dom_id(table_name, user, params: params),
138
155
  partial: "mensa/exports/list",
139
- locals: {table_name: table_name, user: user, exports: for_table(table_name).for_user(user).recent}
156
+ locals: {table_name: table_name, user: user, table_params: params, exports: for_table(table_name, params: params).for_user(user).recent}
140
157
  )
141
158
  rescue => e
142
159
  Mensa.config.logger&.warn("Mensa::Export broadcast failed: #{e.class}: #{e.message}")
@@ -25,9 +25,11 @@ module Mensa
25
25
  @params = (@config[:params].presence || {}).deep_symbolize_keys
26
26
  @config[:params] = @params
27
27
 
28
+ # Hidden columns can come from browser state saved for different params,
29
+ # so skip names that aren't columns of this table.
28
30
  current_hidden_columns&.each do |column_name|
29
31
  c = columns.find { |c| c.name == column_name.to_sym }
30
- c.config[:visible] = false
32
+ c.config[:visible] = false if c
31
33
  end
32
34
  end
33
35
 
@@ -40,6 +42,12 @@ module Mensa
40
42
  # are absent from any URL-supplied column_order. Always append them so
41
43
  # that columns and selected_scope include their attributes.
42
44
  all_keys = (config[:columns]&.keys || []).map(&:to_sym)
45
+ # A column_order can come from browser state saved for different params
46
+ # (with different columns), so drop names that aren't columns here.
47
+ if all_keys.any?
48
+ order &= all_keys
49
+ order = all_keys if order.empty?
50
+ end
43
51
  internal_keys = all_keys.select { |key| config.dig(:columns, key, :internal) }
44
52
  (order | internal_keys)
45
53
  end
@@ -140,6 +148,18 @@ module Mensa
140
148
  query.present? ? "#{path}?#{query}" : path
141
149
  end
142
150
 
151
+ # Appends this table's params to a Mensa endpoint path, so the endpoint
152
+ # can rebuild the table (see Mensa::TableParams).
153
+ def path_with_params(path)
154
+ Mensa::TableParams.append_to(path, params)
155
+ end
156
+
157
+ # Key under which the browser persists this table's state (filters, order,
158
+ # column order, ...). Tables built with different params get separate state.
159
+ def storage_key
160
+ [name, Mensa::TableParams.digest(params)].compact.join(":")
161
+ end
162
+
143
163
  def all_views
144
164
  views = system_views
145
165
  views += TableView.where(table_name: name).where(user: [nil, current_user])
@@ -74,7 +74,7 @@ module Mensa
74
74
  @attribute_for_condition = if config[:attribute].present?
75
75
  Arel.sql(raw_attribute)
76
76
  elsif table.model.column_names.include? name.to_s
77
- Arel.sql("\"#{table.model.table_name}\".\"#{name}\"")
77
+ Arel.sql("#{table.model.connection.quote_table_name(table.model.table_name)}.#{table.model.connection.quote_column_name(name)}")
78
78
  end
79
79
  end
80
80
 
@@ -47,7 +47,7 @@ module Mensa
47
47
  ensure_internal_columns_for_joined_associations
48
48
 
49
49
  @selected_scope = ordered_scope
50
- @selected_scope = @selected_scope.select([:id] + columns.filter_map(&:attribute))
50
+ @selected_scope = @selected_scope.select((id_column? ? [:id] : []) + columns.filter_map(&:attribute))
51
51
 
52
52
  @selected_scope
53
53
  end
@@ -76,6 +76,16 @@ module Mensa
76
76
 
77
77
  private
78
78
 
79
+ # Relations without an id column (e.g. a read-only model backed by a
80
+ # subquery) can't select it.
81
+ def id_column?
82
+ relation = ordered_scope
83
+ klass = relation.respond_to?(:klass) ? relation.klass : model
84
+ klass.column_names.include?("id")
85
+ rescue ActiveRecord::ActiveRecordError
86
+ false
87
+ end
88
+
79
89
  def adjacent_record(record, step:)
80
90
  records = ordered_scope.to_a
81
91
  index = records.find_index { |candidate| same_record?(candidate, record) }
@@ -110,11 +120,20 @@ module Mensa
110
120
  result = current_order_provided? ? (current_order || {}) : (config[:order] || {})
111
121
  result = result.symbolize_keys.compact_blank.transform_values(&:to_sym)
112
122
  result.filter_map { |k, v|
113
- attribute = column(k)&.attribute_for_condition || k
123
+ col = column(k)
124
+ # Skip orders on unknown names, e.g. from browser state saved for
125
+ # different params.
126
+ next unless col || model_attribute?(k)
127
+
128
+ attribute = col&.attribute_for_condition || k
114
129
  "#{attribute} #{v} NULLS LAST"
115
130
  }.join(", ")
116
131
  end
117
132
 
133
+ def model_attribute?(name)
134
+ model.respond_to?(:column_names) && model.column_names.include?(name.to_s)
135
+ end
136
+
118
137
  # Builds an order hash for URL generation. Merges current order with overrides;
119
138
  # nil values become "" so they appear in the URL as order[col]= (which tells
120
139
  # the server the user explicitly cleared that column's sort direction).
@@ -0,0 +1,44 @@
1
+ require "digest"
2
+
3
+ module Mensa
4
+ # Helpers for the extra +params+ a table can be built with
5
+ # (`Mensa::Table::Component.new("name", params: {...})`). They travel to
6
+ # Mensa's endpoints as `params[...]` query parameters, the same way
7
+ # Mensa::Base#path sends them, so normalizing through that query-string
8
+ # round trip gives exactly the hash the controllers receive.
9
+ module TableParams
10
+ module_function
11
+
12
+ # Returns the params as a string-keyed hash with string values, or {} when
13
+ # blank. Accepts plain hashes and ActionController::Parameters.
14
+ def normalize(params)
15
+ params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
16
+ return {} if params.blank?
17
+
18
+ Rack::Utils.parse_nested_query({params: params}.to_query)["params"] || {}
19
+ end
20
+
21
+ # The permitted `params` sent along with a request to a Mensa endpoint.
22
+ def from_request(request_params)
23
+ normalize(request_params.slice(:params).permit(params: {}).to_h[:params])
24
+ end
25
+
26
+ # Query string (`params[key]=value`) to append to Mensa endpoint URLs.
27
+ def to_query(params)
28
+ normalized = normalize(params)
29
+ normalized.present? ? {params: normalized}.to_query : ""
30
+ end
31
+
32
+ # Appends the params to +path+ as `params[...]` query parameters.
33
+ def append_to(path, params)
34
+ query = to_query(params)
35
+ query.present? ? "#{path}?#{query}" : path
36
+ end
37
+
38
+ # Short, stable digest identifying a set of params, or nil when blank.
39
+ def digest(params)
40
+ query = to_query(params)
41
+ Digest::SHA256.hexdigest(query)[0, 12] if query.present?
42
+ end
43
+ end
44
+ end
@@ -1,5 +1,6 @@
1
- <% count = Mensa::Export.completed_count(table_name, user) %>
2
- <span id="<%= Mensa::Export.badge_dom_id(table_name, user) %>"
1
+ <% table_params = local_assigns[:table_params] %>
2
+ <% count = Mensa::Export.completed_count(table_name, user, params: table_params) %>
3
+ <span id="<%= Mensa::Export.badge_dom_id(table_name, user, params: table_params) %>"
3
4
  class="mensa-table__control_bar__badge <%= 'hidden' unless count.positive? %>"
4
5
  aria-hidden="<%= count.positive? ? 'false' : 'true' %>">
5
6
  <%= count %>
@@ -1,5 +1,5 @@
1
1
  <% user = table.current_user %>
2
- <% exports = Mensa::Export.for_table(table.name).for_user(user).recent %>
2
+ <% exports = Mensa::Export.for_table(table.name, params: table.params).for_user(user).recent %>
3
3
  <dialog class="mensa-table__export-dialog" data-mensa-table-target="exportDialog" data-action="click->mensa-table#exportDialogBackdrop">
4
4
  <div class="mensa-table__export-dialog__panel">
5
5
  <header class="mensa-table__export-dialog__header">
@@ -10,7 +10,7 @@
10
10
  </header>
11
11
 
12
12
  <div class="mensa-table__export-dialog__body">
13
- <%= render partial: "mensa/exports/list", locals: {table_name: table.name, user: user, exports: exports} %>
13
+ <%= render partial: "mensa/exports/list", locals: {table_name: table.name, user: user, table_params: table.params, exports: exports} %>
14
14
 
15
15
  <form class="mensa-table__export-dialog__form" data-action="submit->mensa-table#confirmExport">
16
16
  <fieldset class="mensa-table__export-dialog__fieldset">
@@ -1,4 +1,4 @@
1
- <div id="<%= Mensa::Export.list_dom_id(table_name, user) %>" class="mensa-table__export-dialog__downloads">
1
+ <div id="<%= Mensa::Export.list_dom_id(table_name, user, params: local_assigns[:table_params]) %>" class="mensa-table__export-dialog__downloads">
2
2
  <h3 class="mensa-table__export-dialog__section-title"><%= t("mensa.exports.available_downloads", default: "Available downloads") %></h3>
3
3
  <% if exports.blank? %>
4
4
  <p class="mensa-table__export-dialog__empty"><%= t("mensa.exports.empty", default: "You have no downloads yet. Create one below.") %></p>
@@ -120,15 +120,20 @@ module Mensa
120
120
  end,
121
121
  # Called with the Mensa::Export once the CSV has been generated and
122
122
  # attached (export.asset). Use this to e.g. notify or email the user.
123
+ # (Before 0.6.12 this was called export_complete; that name still works.)
123
124
  #
124
125
  # UserMailer.with(
125
126
  # user: User.find(export.user_id),
126
127
  # export: export,
127
128
  # ).export_email.deliver_later
128
- export_complete: lambda do |export|
129
+ export_completed: lambda do |export|
129
130
  end
130
131
  }
131
132
 
133
+ CALLBACKS = %i[export_started export_completed].freeze
134
+ # Older names that are still called alongside the current one.
135
+ CALLBACK_ALIASES = {export_completed: %i[export_complete]}.freeze
136
+
132
137
  # :front or :back (default)
133
138
  option :row_actions_position, default: :back
134
139
  # It's either :basic or :fuzzy, for fuzzy search you need to have `pg_trgm` extension installed
@@ -137,6 +142,24 @@ module Mensa
137
142
  def initialize
138
143
  set_defaults!
139
144
  end
145
+
146
+ def callbacks=(callbacks)
147
+ known = CALLBACKS + CALLBACK_ALIASES.values.flatten
148
+ unknown = callbacks.to_h.keys.map(&:to_sym) - known
149
+ if unknown.any?
150
+ logger&.warn("Mensa: ignoring unknown callback(s) #{unknown.join(", ")}; known callbacks are #{CALLBACKS.join(", ")}")
151
+ end
152
+
153
+ @callbacks = callbacks
154
+ end
155
+
156
+ # Calls the callback registered under +name+ (or one of its older names).
157
+ def run_callback(name, *args)
158
+ [name, *CALLBACK_ALIASES.fetch(name, [])].each do |key|
159
+ callback = callbacks && (callbacks[key] || callbacks[key.to_s])
160
+ callback&.call(*args)
161
+ end
162
+ end
140
163
  end
141
164
 
142
165
  module Configurable
data/lib/mensa/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Mensa
2
- VERSION = "0.6.11"
2
+ VERSION = "0.6.12"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mensa
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.11
4
+ version: 0.6.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom de Grunt
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-21 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -168,6 +168,7 @@ files:
168
168
  - ".gitignore"
169
169
  - ".prettierrc.json"
170
170
  - ".rubocop.yml"
171
+ - ".shipkit.yml"
171
172
  - ".vscode/settings.json"
172
173
  - ".vscode/tasks.json"
173
174
  - ".zed/tasks.json"
@@ -277,6 +278,7 @@ files:
277
278
  - app/tables/mensa/scope.rb
278
279
  - app/tables/mensa/search.rb
279
280
  - app/tables/mensa/system_view.rb
281
+ - app/tables/mensa/table_params.rb
280
282
  - app/views/mensa/exports/_badge.html.erb
281
283
  - app/views/mensa/exports/_dialog.html.erb
282
284
  - app/views/mensa/exports/_list.html.erb