mensa 0.2.5 → 0.2.6

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +6 -2
  3. data/.devcontainer/compose.yaml +1 -1
  4. data/.devcontainer/devcontainer.json +31 -29
  5. data/.devcontainer/postCreate.sh +8 -0
  6. data/.devcontainer/postStart.sh +9 -0
  7. data/.gitignore +3 -1
  8. data/.zed/tasks.json +12 -0
  9. data/Gemfile.lock +155 -153
  10. data/Procfile +1 -1
  11. data/README.md +85 -60
  12. data/app/assets/stylesheets/mensa/application.css +14 -11
  13. data/app/components/mensa/add_filter/component.css +110 -5
  14. data/app/components/mensa/add_filter/component.html.slim +10 -12
  15. data/app/components/mensa/add_filter/component.rb +7 -1
  16. data/app/components/mensa/add_filter/component_controller.js +697 -83
  17. data/app/components/mensa/cell/component.css +9 -0
  18. data/app/components/mensa/column_customizer/component.css +40 -0
  19. data/app/components/mensa/column_customizer/component.html.slim +14 -0
  20. data/app/components/mensa/column_customizer/component.rb +13 -0
  21. data/app/components/mensa/column_customizer/component_controller.js +383 -0
  22. data/app/components/mensa/control_bar/component.css +127 -4
  23. data/app/components/mensa/control_bar/component.html.slim +41 -14
  24. data/app/components/mensa/control_bar/component.rb +0 -4
  25. data/app/components/mensa/empty_state/component.css +20 -0
  26. data/app/components/mensa/empty_state/component.html.slim +7 -0
  27. data/app/components/mensa/empty_state/component.rb +18 -0
  28. data/app/components/mensa/filter_pill/component.css +23 -0
  29. data/app/components/mensa/filter_pill/component.html.slim +9 -6
  30. data/app/components/mensa/filter_pill/component.rb +9 -0
  31. data/app/components/mensa/filter_pill/component_controller.js +50 -10
  32. data/app/components/mensa/filter_pill_list/component.css +58 -9
  33. data/app/components/mensa/filter_pill_list/component.html.slim +11 -8
  34. data/app/components/mensa/filter_pill_list/component_controller.js +747 -48
  35. data/app/components/mensa/header/component.css +41 -43
  36. data/app/components/mensa/header/component.html.slim +7 -7
  37. data/app/components/mensa/row_action/component.html.slim +2 -2
  38. data/app/components/mensa/search/component.css +68 -9
  39. data/app/components/mensa/search/component.html.slim +19 -15
  40. data/app/components/mensa/search/component_controller.js +39 -49
  41. data/app/components/mensa/selection/component_controller.js +147 -0
  42. data/app/components/mensa/table/component.css +28 -0
  43. data/app/components/mensa/table/component.html.slim +9 -6
  44. data/app/components/mensa/table/component.rb +1 -0
  45. data/app/components/mensa/table/component_controller.js +524 -88
  46. data/app/components/mensa/table_row/component.css +6 -0
  47. data/app/components/mensa/table_row/component.html.slim +8 -3
  48. data/app/components/mensa/view/component.css +97 -29
  49. data/app/components/mensa/view/component.html.slim +23 -10
  50. data/app/components/mensa/view/component.rb +5 -0
  51. data/app/components/mensa/views/component.css +106 -13
  52. data/app/components/mensa/views/component.html.slim +51 -17
  53. data/app/components/mensa/views/component_controller.js +245 -20
  54. data/app/controllers/mensa/tables/batch_actions_controller.rb +24 -0
  55. data/app/controllers/mensa/tables/exports_controller.rb +96 -0
  56. data/app/controllers/mensa/tables/filters_controller.rb +4 -1
  57. data/app/controllers/mensa/tables/views_controller.rb +108 -0
  58. data/app/controllers/mensa/tables_controller.rb +3 -6
  59. data/app/helpers/mensa/application_helper.rb +4 -0
  60. data/app/javascript/mensa/application.js +2 -2
  61. data/app/javascript/mensa/controllers/index.js +13 -4
  62. data/app/jobs/mensa/export_job.rb +77 -84
  63. data/app/models/mensa/export.rb +93 -0
  64. data/app/tables/mensa/base.rb +103 -12
  65. data/app/tables/mensa/batch_action.rb +27 -0
  66. data/app/tables/mensa/cell.rb +15 -0
  67. data/app/tables/mensa/column.rb +15 -2
  68. data/app/tables/mensa/config/batch_dsl.rb +13 -0
  69. data/app/tables/mensa/config/column_dsl.rb +1 -0
  70. data/app/tables/mensa/config/filter_dsl.rb +4 -1
  71. data/app/tables/mensa/config/render_dsl.rb +1 -1
  72. data/app/tables/mensa/config/table_dsl.rb +12 -5
  73. data/app/tables/mensa/config/view_dsl.rb +2 -0
  74. data/app/tables/mensa/config_readers.rb +20 -1
  75. data/app/tables/mensa/filter.rb +86 -3
  76. data/app/tables/mensa/scope.rb +24 -12
  77. data/app/views/mensa/exports/_badge.html.slim +5 -0
  78. data/app/views/mensa/exports/_dialog.html.slim +42 -0
  79. data/app/views/mensa/exports/_list.html.slim +29 -0
  80. data/app/views/mensa/tables/filters/show.turbo_stream.slim +35 -8
  81. data/app/views/mensa/tables/views/create.turbo_stream.slim +11 -0
  82. data/app/views/mensa/tables/views/destroy.turbo_stream.slim +11 -0
  83. data/app/views/mensa/tables/views/update.turbo_stream.slim +11 -0
  84. data/config/locales/en.yml +44 -0
  85. data/config/locales/nl.yml +45 -0
  86. data/config/routes.rb +7 -0
  87. data/db/migrate/20260604120000_create_mensa_exports.rb +25 -0
  88. data/docs/columns.png +0 -0
  89. data/docs/export.png +0 -0
  90. data/docs/filters.png +0 -0
  91. data/docs/table.png +0 -0
  92. data/lib/mensa/configuration.rb +33 -12
  93. data/lib/mensa/engine.rb +7 -2
  94. data/lib/mensa/version.rb +1 -1
  95. data/mensa.gemspec +2 -1
  96. data/mise.toml +8 -0
  97. data/package-lock.json +0 -7
  98. metadata +50 -8
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mensa
4
+ module Tables
5
+ class BatchActionsController < ::ApplicationController
6
+ def create
7
+ table_name = params[:table_id]
8
+ batch_action_name = params[:batch_action_name]&.to_sym
9
+ record_ids = Array(params[:record_ids])
10
+
11
+ table = Mensa.for_name(table_name)
12
+ table.original_view_context = helpers
13
+
14
+ batch_action = table.batch_actions.find { |a| a.name == batch_action_name }
15
+ return head :not_found unless batch_action
16
+
17
+ records = table.model.where(id: record_ids).to_a
18
+ batch_action.process.call(records)
19
+
20
+ redirect_back_or_to helpers.mensa.table_path(table_name)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,96 @@
1
+ module Mensa
2
+ module Tables
3
+ # Lists a user's available downloads for a table and creates new export
4
+ # requests. Generating the CSV happens asynchronously in Mensa::ExportJob;
5
+ # both the export button badge and the downloads list are refreshed via
6
+ # Turbo streams once the job completes.
7
+ class ExportsController < ::ApplicationController
8
+ # Returns the current downloads list for the table, used to refresh the
9
+ # contents of the export dialog when it is opened.
10
+ def index
11
+ respond_to do |format|
12
+ format.turbo_stream { render turbo_stream: list_stream }
13
+ format.html { render partial: "mensa/exports/list", locals: list_locals }
14
+ end
15
+ end
16
+
17
+ # Creates a new export for the current user and enqueues the job that
18
+ # generates and attaches the CSV.
19
+ def create
20
+ export = Mensa::Export.new(
21
+ table_name: params[:table_id],
22
+ table_view_id: params[:table_view_id].presence,
23
+ user: current_mensa_user,
24
+ format: params[:export_format].to_s.presence_in(Mensa::Export::FORMATS) || "csv_excel",
25
+ scope: params[:scope].to_s.presence_in(Mensa::Export::SCOPES) || "all",
26
+ config: params.permit(:query, :page, order: {}, filters: {}).to_h,
27
+ status: "pending"
28
+ )
29
+
30
+ if export.save
31
+ Mensa::ExportJob.perform_later(export)
32
+
33
+ respond_to do |format|
34
+ format.turbo_stream { render turbo_stream: [list_stream, badge_stream] }
35
+ format.json { render json: {id: export.id}, status: :created }
36
+ end
37
+ else
38
+ respond_to do |format|
39
+ format.turbo_stream { head :unprocessable_entity }
40
+ format.json { render json: {errors: export.errors.full_messages}, status: :unprocessable_entity }
41
+ end
42
+ end
43
+ end
44
+
45
+ # Streams the generated CSV and then removes the export, purging the
46
+ # attached asset. Downloads are single-use: routing them through the
47
+ # controller (instead of a direct Active Storage link) gives us a hook to
48
+ # delete the Mensa::Export record and free the stored file afterwards.
49
+ def download
50
+ export = exports.find(params[:id])
51
+ return head :not_found unless export.downloadable?
52
+
53
+ data = export.asset.download
54
+ filename = export.asset.filename.to_s.presence || export.filename.presence || "#{export.table_name}_export.csv"
55
+ content_type = export.asset.content_type.presence || "text/csv"
56
+
57
+ send_data data, filename: filename, type: content_type, disposition: "attachment"
58
+
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.
62
+ begin
63
+ export.destroy
64
+ Mensa::Export.broadcast_refresh(export.table_name, export.user)
65
+ rescue => e
66
+ Mensa.config.logger&.warn("Mensa::Export cleanup failed for #{export.id}: #{e.class}: #{e.message}")
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def exports
73
+ @exports ||= Mensa::Export.for_table(params[:table_id]).for_user(current_mensa_user).recent
74
+ end
75
+
76
+ def list_stream
77
+ turbo_stream.replace(Mensa::Export.list_dom_id(params[:table_id], current_mensa_user),
78
+ partial: "mensa/exports/list", locals: list_locals)
79
+ end
80
+
81
+ def badge_stream
82
+ turbo_stream.replace(Mensa::Export.badge_dom_id(params[:table_id], current_mensa_user),
83
+ partial: "mensa/exports/badge",
84
+ locals: {table_name: params[:table_id], user: current_mensa_user})
85
+ end
86
+
87
+ def list_locals
88
+ {table_name: params[:table_id], user: current_mensa_user, exports: exports}
89
+ end
90
+
91
+ def current_mensa_user
92
+ Current.user if defined?(Current) && Current.respond_to?(:user)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -16,10 +16,13 @@ module Mensa
16
16
 
17
17
  # Returns the filter information on the column-name
18
18
  def show
19
- config = {}.merge(params.permit(:id, :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: {}).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])
23
+ @operator = params[:operator].presence || "is"
24
+ @multiple = @column.filter.multiple?
25
+ @values = Array(params[:value]).flatten.compact
23
26
  respond_to do |format|
24
27
  format.turbo_stream
25
28
  format.html
@@ -0,0 +1,108 @@
1
+ module Mensa
2
+ module Tables
3
+ class ViewsController < ::ApplicationController
4
+ # Persists the current filters, ordering and search query as a named
5
+ # custom view, owned by the current user. Without a current user there is
6
+ # nobody to own the view, so the request is rejected (and the UI hides the
7
+ # save button in that case).
8
+ def create
9
+ user = current_mensa_user
10
+ return head(:forbidden) if user.blank?
11
+
12
+ view = Mensa::TableView.new(
13
+ table_name: params[:table_id],
14
+ name: params[:name],
15
+ description: params[:description],
16
+ config: view_config,
17
+ user: user
18
+ )
19
+
20
+ if view.save
21
+ respond_to do |format|
22
+ format.turbo_stream do
23
+ # Re-use the same turbo_frame_id that the client sent so the
24
+ # generated element IDs match what is already in the DOM.
25
+ table_config = view.config
26
+ .deep_transform_keys(&:to_sym)
27
+ .merge(turbo_frame_id: params[:turbo_frame_id])
28
+
29
+ @table = Mensa.for_name(params[:table_id], table_config)
30
+ @table.request = request
31
+ @table.original_view_context = helpers
32
+ @table.table_view = view
33
+ end
34
+ format.json { render json: {id: view.id, name: view.name}, status: :created }
35
+ end
36
+ else
37
+ render json: {errors: view.errors.full_messages}, status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ def update
42
+ user = current_mensa_user
43
+ return head(:forbidden) if user.blank?
44
+
45
+ view = Mensa::TableView.find_by(table_name: params[:table_id], id: params[:id], user: user)
46
+ return head(:not_found) if view.blank?
47
+
48
+ # When only renaming, preserve the existing config rather than overwriting
49
+ # with an empty hash from view_config.
50
+ new_config = view_config
51
+ update_attrs = {config: new_config.present? ? new_config : view.config}
52
+ update_attrs[:name] = params[:name] if params[:name].present?
53
+
54
+ if view.update(update_attrs)
55
+ respond_to do |format|
56
+ format.turbo_stream do
57
+ table_config = view.config
58
+ .deep_transform_keys(&:to_sym)
59
+ .merge(turbo_frame_id: params[:turbo_frame_id])
60
+
61
+ @table = Mensa.for_name(params[:table_id], table_config)
62
+ @table.request = request
63
+ @table.original_view_context = helpers
64
+ @table.table_view = view
65
+ end
66
+ format.json { render json: {id: view.id, name: view.name} }
67
+ end
68
+ else
69
+ render json: {errors: view.errors.full_messages}, status: :unprocessable_entity
70
+ end
71
+ end
72
+
73
+ def destroy
74
+ user = current_mensa_user
75
+ return head(:forbidden) if user.blank?
76
+
77
+ view = Mensa::TableView.find_by(table_name: params[:table_id], id: params[:id], user: user)
78
+ return head(:not_found) if view.blank?
79
+
80
+ view.destroy
81
+
82
+ respond_to do |format|
83
+ format.turbo_stream do
84
+ @table = Mensa.for_name(params[:table_id], {turbo_frame_id: params[:turbo_frame_id]})
85
+ @table.request = request
86
+ @table.original_view_context = helpers
87
+ end
88
+ format.json { head :no_content }
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ # The view configuration mirrors the query parameters the table reads on
95
+ # show (query, ordering and filters), so a saved view can be replayed by
96
+ # merging this hash back into the request params.
97
+ def view_config
98
+ config = params.permit(:query, order: {}, column_order: [], hidden_columns: []).to_h
99
+ config[:filters] = params[:filters]&.to_unsafe_h || {}
100
+ config
101
+ end
102
+
103
+ def current_mensa_user
104
+ Current.user if defined?(Current) && Current.respond_to?(:user)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -7,11 +7,12 @@ module Mensa
7
7
  if params[:table_view_id]
8
8
  @view = Mensa::TableView.find_by(table_name: params[:id], id: params[:table_view_id])
9
9
  @view ||= @table.system_views.find { |v| v.id == params[:table_view_id].to_sym }
10
- config = @view&.config
10
+ config = @view&.config&.deep_transform_keys(&:to_sym)
11
11
  end
12
12
 
13
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: {}, filters: {}).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] || {}
15
16
 
16
17
  @table = Mensa.for_name(params[:id], config)
17
18
  @table.request = request
@@ -21,10 +22,6 @@ module Mensa
21
22
  respond_to do |format|
22
23
  format.turbo_stream # Used for filterering
23
24
  format.html
24
- format.xlsx do
25
- Mensa::ExportJob.perform_later(current_user, params[:id])
26
- head :ok
27
- end
28
25
  end
29
26
  end
30
27
  end
@@ -1,4 +1,8 @@
1
1
  module Mensa
2
2
  module ApplicationHelper
3
+ def table(name, config = {}, **options)
4
+ options[:original_view_context] = self
5
+ render(::Mensa::Table::Component.new(name, config, **options))
6
+ end
3
7
  end
4
8
  end
@@ -1,2 +1,2 @@
1
- import "@hotwired/turbo-rails"
2
- import "mensa/controllers"
1
+ import "@hotwired/turbo-rails";
2
+ import "mensa/controllers";
@@ -1,4 +1,4 @@
1
- import { application } from "mensa/controllers/application"
1
+ import { application } from "mensa/controllers/application";
2
2
 
3
3
  // import AddFilterComponentController from 'components/add_filter/component_controller';
4
4
  // application.register('mensa-add-filter', AddFilterComponentController)
@@ -10,17 +10,26 @@ import FilterPillComponentController from "mensa/components/filter_pill/componen
10
10
  application.register("mensa-filter-pill", FilterPillComponentController);
11
11
 
12
12
  import FilterPillListComponentController from "mensa/components/filter_pill_list/component_controller";
13
- application.register("mensa-filter-pill-list", FilterPillListComponentController);
13
+ application.register(
14
+ "mensa-filter-pill-list",
15
+ FilterPillListComponentController,
16
+ );
14
17
 
15
18
  import SearchComponentController from "mensa/components/search/component_controller";
16
19
  application.register("mensa-search", SearchComponentController);
17
20
 
18
- import TableComponentController from 'mensa/components/table/component_controller'
21
+ import TableComponentController from "mensa/components/table/component_controller";
19
22
  application.register("mensa-table", TableComponentController);
20
23
 
21
- import ViewsComponentController from 'mensa/components/views/component_controller'
24
+ import ViewsComponentController from "mensa/components/views/component_controller";
22
25
  application.register("mensa-views", ViewsComponentController);
23
26
 
27
+ import SelectionComponentController from "mensa/components/selection/component_controller";
28
+ application.register("mensa-selection", SelectionComponentController);
29
+
30
+ import ColumnCustomizerController from "mensa/components/column_customizer/component_controller";
31
+ application.register("mensa-column-customizer", ColumnCustomizerController);
32
+
24
33
  // Eager load all controllers defined in the import map under controllers/**/*_controller
25
34
  // import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
26
35
  // eagerLoadControllersFrom("controllers", application)
@@ -1,110 +1,103 @@
1
+ require "csv"
1
2
  require "securerandom"
3
+ require "stringio"
2
4
 
3
5
  module Mensa
6
+ # Generates the CSV for a Mensa::Export, attaches it to the export's +asset+
7
+ # and broadcasts the refreshed export button badge and downloads list so the
8
+ # requesting user sees the finished download appear without reloading.
4
9
  class ExportJob < ApplicationJob
5
10
  queue_as :default
6
11
 
7
- # export_started: lambda do |user_id, table_name|
8
- # end,
9
- # export_complete: lambda do |user_id, table_name, attachment|
10
- # end
12
+ def perform(export)
13
+ return unless export
11
14
 
12
- def perform(user, table_name)
13
- table = Mensa.for_name(table_name)
14
- return unless table.exportable?
15
+ export.update(status: "processing")
16
+ Mensa.config.callbacks[:export_started]&.call(export)
15
17
 
16
- context = Mensa.config.callbacks[:export_started].call(user, table_name)
17
-
18
- styles = []
19
- default_style = {b: true, bg_color: "3B82F6", fg_color: "FFFFFF", border: {style: :thin, color: "000000"}, sz: 8,
20
- alignment: {vertical: :bottom, horizontal: :left}}
18
+ table = build_table(export)
19
+ unless table.exportable?
20
+ finalize(export, status: "failed")
21
+ return
22
+ end
21
23
 
22
- p = Axlsx::Package.new
23
- p.use_shared_strings = true
24
+ data, filename, content_type = generate(table, export)
24
25
 
25
- wb = p.workbook
26
+ export.asset.attach(io: StringIO.new(data), filename: filename, content_type: content_type)
27
+ finalize(export, status: "completed", filename: filename)
26
28
 
27
- wb.styles.add_style({format_code: "@", alignment: {horizontal: :left, vertical: :top, wrap_text: true}})
28
- nowrap_text = wb.styles.add_style({format_code: "@", alignment: {horizontal: :left, vertical: :top, wrap_text: false}})
29
- wb.styles.add_style format_code: "#"
30
- wb.styles.add_style format_code: "dddd, d mmmm yyyy hh:mm:ss"
31
- wb.styles.add_style format_code: "dddd, d mmmm yyyy"
29
+ Mensa.config.callbacks[:export_complete]&.call(export)
30
+ rescue => e
31
+ Mensa.config.logger&.error("Mensa::ExportJob failed for export #{export_id}: #{e.class}: #{e.message}")
32
+ finalize(export, status: "failed") if export
33
+ raise
34
+ end
32
35
 
33
- # custom_styles = ActionTable.config.format_config.each_with_object({}) { |(key, value), hash| hash[key] = value[:xlsx_style] if value.key?(:xlsx_style) }
36
+ private
34
37
 
35
- wb.add_worksheet(name: table_name.first(31)) do |sheet|
36
- column_widths = []
37
- # TODO: Separate display columns for export?
38
- table.display_columns.map.with_index do |column, index|
39
- styles[index] = sheet.styles.add_style(default_style) # .merge(column.export_style || {}))
40
- # width = column.export_style.delete(:width)
41
- # column_widths[index] = width if width
42
- end
38
+ # Rebuilds the table the export was requested for, layering the view
39
+ # configuration (if any) underneath the captured request configuration
40
+ # (filters, query, ordering, page) so the generated data matches what the
41
+ # user saw when they requested the export.
42
+ def build_table(export)
43
+ config = {}
43
44
 
44
- first_row = sheet.add_row(table.display_columns.map { |c| c.human_name }, style: styles, height: 28)
45
-
46
- first_cell = first_row.first
47
- last_row = first_row
48
-
49
- table.export_rows.each do |row|
50
- # next if row.reject { |column, value| value.nil? || value == '' }.blank?
51
- #
52
- # row_types = :string #row.map(&:second)
53
- # row_values = row.map(&:third)
54
- # options = row.map(&:last)
55
- #
56
- # row_styles = options.map.with_index { |option, i|
57
- # type = row_types[i]
58
- # value = row_values[i]
59
- # export_style = option.dig(:export, :xlsx_style)
60
- #
61
- # if export_style.present?
62
- # wb.styles.add_style(export_style)
63
- # elsif custom_styles[type].present?
64
- # wb.styles.add_style(custom_styles[type])
65
- # elsif [:integer, :float, :decimal, :number].include?(type)
66
- # number_format
67
- # elsif [:datetime, :time, :timestamp].include?(type)
68
- # datetime_format
69
- # elsif type == :date
70
- # date_format
71
- # elsif value.is_a?(Axlsx::RichText) || type == :text
72
- # wrap_text
73
- # else
74
- # nowrap_text
75
- # end
76
- # }
77
- values = table.display_columns.map { |column| row.value(column) }
78
- row_styles = table.display_columns.map { |column| nowrap_text }
79
- last_row = sheet.add_row(values, style: row_styles)
80
- end
45
+ if export.table_view_id.present?
46
+ view = Mensa::TableView.find_by(table_name: export.table_name, id: export.table_view_id)
47
+ config = view.config.deep_symbolize_keys if view&.config
48
+ end
81
49
 
82
- last_cell = last_row.last
50
+ config = config.merge((export.config.compact_blank || {}).deep_symbolize_keys)
51
+ table = Mensa.for_name(export.table_name, config)
52
+ table.request = ActionDispatch::Request.new({})
53
+ table.request.set_header("action_dispatch.request.query_parameters", {"page" => config[:page]})
54
+ table
55
+ end
83
56
 
84
- sheet.column_widths(*column_widths)
85
- sheet.auto_filter = Axlsx.cell_range([first_cell, last_cell], false)
57
+ def generate(table, export)
58
+ io = StringIO.new
59
+ # A UTF-8 BOM makes spreadsheet programs such as Excel detect the encoding
60
+ # correctly. The "plain" CSV variant omits it for maximum compatibility
61
+ # with programmatic consumers.
62
+ io.write("\uFEFF") if export.format == "csv_excel"
63
+
64
+ csv = CSV.new(io)
65
+ csv << table.display_columns.map(&:name)
66
+ export_rows(table, export).each do |row|
67
+ csv << table.display_columns.map { |column| Mensa::Cell.new(row: row, column: column).render(:csv) }
86
68
  end
69
+ io.rewind
70
+ data = io.read
71
+
72
+ base_filename = "#{export.table_name}_export_#{export.created_at.strftime("%Y-%m-%d-%H%M%S")}"
87
73
 
88
- base_filename = "#{table_name}_export_#{Time.current.strftime("%Y-%m-%d-%H:%M:%S")}"
89
- enc = nil
90
- password = nil
91
74
  if table.export_with_password?
75
+ require "zip"
92
76
  password = SecureRandom.hex(6)
93
- enc = Zip::TraditionalEncrypter.new(password)
94
- end
95
- stringio = Zip::OutputStream.write_buffer(encrypter: enc) do |zio|
96
- zio.put_next_entry("#{base_filename}.xlsx")
97
- zio.write p.to_stream.read
77
+ encrypter = Zip::TraditionalEncrypter.new(password)
78
+ zip_io = Zip::OutputStream.write_buffer(encrypter: encrypter) do |zio|
79
+ zio.put_next_entry("#{base_filename}.csv")
80
+ zio.write data
81
+ end
82
+ zip_io.rewind
83
+ [zip_io.read, "#{base_filename}.zip", "application/zip"]
84
+ else
85
+ [data, "#{base_filename}.csv", "text/csv"]
98
86
  end
99
- stringio.rewind
87
+ end
100
88
 
101
- attachment = {io: stringio,
102
- content_type: "application/zip", filename: "#{base_filename}.zip"}
103
- if password.present?
104
- attachment[:password] = password
105
- end
89
+ def export_rows(table, export)
90
+ scope = (export.scope == "current_page") ? table.paged_scope : table.selected_scope
91
+ scope.map { |row| Mensa::Row.new(table, row) }
92
+ end
106
93
 
107
- Mensa.config.callbacks[:export_completed].call(user, table_name, context, attachment)
94
+ def finalize(export, status:, filename: nil)
95
+ attributes = {status: status}
96
+ attributes[:filename] = filename if filename
97
+ export.update(attributes)
98
+ # Refresh the export button badge (download count) and the downloads list
99
+ # inside the export dialog for everyone viewing this table.
100
+ Mensa::Export.broadcast_refresh(export.table_name, export.user)
108
101
  end
109
102
  end
110
103
  end
@@ -0,0 +1,93 @@
1
+ module Mensa
2
+ # An export request for a Mensa table. Each export captures the table it was
3
+ # generated for, the view (if any), the requesting user and the request
4
+ # configuration (filters/query/order/page) needed to rebuild the data. Once
5
+ # processed by Mensa::ExportJob the generated CSV is stored in +asset+.
6
+ class Export < ApplicationRecord
7
+ STATUSES = %w[pending processing completed failed].freeze
8
+ FORMATS = %w[csv_excel plain_csv].freeze
9
+ SCOPES = %w[all current_page].freeze
10
+
11
+ belongs_to :user, optional: true
12
+ has_one_attached :asset
13
+
14
+ validates :table_name, presence: true
15
+ validates :status, inclusion: {in: STATUSES}
16
+
17
+ scope :for_table, ->(table_name) { where(table_name: table_name.to_s) }
18
+ scope :for_user, ->(user) { where(user_id: user.respond_to?(:id) ? user&.id : user) }
19
+ scope :completed, -> { where(status: "completed") }
20
+ scope :recent, -> { order(created_at: :desc) }
21
+
22
+ def completed?
23
+ status == "completed"
24
+ end
25
+
26
+ def failed?
27
+ status == "failed"
28
+ end
29
+
30
+ def processing?
31
+ status == "processing"
32
+ end
33
+
34
+ def pending?
35
+ status == "pending"
36
+ end
37
+
38
+ # True once the asset is ready to be downloaded by the user.
39
+ def downloadable?
40
+ completed? && asset.attached?
41
+ end
42
+
43
+ # Number of completed (downloadable) exports for a table/user combination.
44
+ # This is the number rendered in the export button badge.
45
+ def self.completed_count(table_name, user)
46
+ for_table(table_name).for_user(user).completed.count
47
+ end
48
+
49
+ # A stable, page-independent key identifying the exports of a table/user
50
+ # combination, used for Turbo stream names and DOM ids so background jobs
51
+ # can target them after completion.
52
+ def self.token(table_name, user)
53
+ user_key = user.respond_to?(:id) ? user&.id : user
54
+ [table_name.to_s, user_key || "anonymous"].join("-").parameterize
55
+ end
56
+
57
+ def self.stream_name(table_name, user)
58
+ "mensa-exports-#{token(table_name, user)}"
59
+ end
60
+
61
+ def self.badge_dom_id(table_name, user)
62
+ "mensa-export-badge-#{token(table_name, user)}"
63
+ end
64
+
65
+ def self.list_dom_id(table_name, user)
66
+ "mensa-export-list-#{token(table_name, user)}"
67
+ end
68
+
69
+ # Re-renders the export button badge and downloads list for everyone
70
+ # subscribed to this table/user's export stream. Best-effort: a missing
71
+ # Action Cable backend (or other broadcast failure) must never break the
72
+ # caller (job completion, download cleanup, ...).
73
+ def self.broadcast_refresh(table_name, user)
74
+ stream = stream_name(table_name, user)
75
+
76
+ Turbo::StreamsChannel.broadcast_replace_to(
77
+ stream,
78
+ target: badge_dom_id(table_name, user),
79
+ partial: "mensa/exports/badge",
80
+ locals: {table_name: table_name, user: user}
81
+ )
82
+
83
+ Turbo::StreamsChannel.broadcast_replace_to(
84
+ stream,
85
+ target: list_dom_id(table_name, user),
86
+ partial: "mensa/exports/list",
87
+ locals: {table_name: table_name, user: user, exports: for_table(table_name).for_user(user).recent}
88
+ )
89
+ rescue => e
90
+ Mensa.config.logger&.warn("Mensa::Export broadcast failed: #{e.class}: #{e.message}")
91
+ end
92
+ end
93
+ end