mensa 0.2.4 → 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 (122) 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/CHANGELOG.md +6 -0
  10. data/Gemfile.lock +155 -153
  11. data/Procfile +1 -1
  12. data/README.md +95 -60
  13. data/app/assets/stylesheets/mensa/application.css +14 -11
  14. data/app/components/mensa/add_filter/component.css +110 -5
  15. data/app/components/mensa/add_filter/component.html.slim +10 -12
  16. data/app/components/mensa/add_filter/component.rb +8 -2
  17. data/app/components/mensa/add_filter/component_controller.js +697 -85
  18. data/app/components/mensa/cell/component.css +9 -0
  19. data/app/components/mensa/column_customizer/component.css +40 -0
  20. data/app/components/mensa/column_customizer/component.html.slim +14 -0
  21. data/app/components/mensa/column_customizer/component.rb +13 -0
  22. data/app/components/mensa/column_customizer/component_controller.js +383 -0
  23. data/app/components/mensa/control_bar/component.css +127 -4
  24. data/app/components/mensa/control_bar/component.html.slim +41 -14
  25. data/app/components/mensa/control_bar/component.rb +2 -6
  26. data/app/components/mensa/empty_state/component.css +20 -0
  27. data/app/components/mensa/empty_state/component.html.slim +7 -0
  28. data/app/components/mensa/empty_state/component.rb +18 -0
  29. data/app/components/mensa/filter_pill/component.css +23 -0
  30. data/app/components/mensa/filter_pill/component.html.slim +9 -0
  31. data/app/components/mensa/filter_pill/component.rb +24 -0
  32. data/app/components/mensa/filter_pill/component_controller.js +52 -0
  33. data/app/components/mensa/filter_pill_list/component.css +63 -0
  34. data/app/components/mensa/filter_pill_list/component.html.slim +11 -0
  35. data/app/components/mensa/{filter_list → filter_pill_list}/component.rb +1 -1
  36. data/app/components/mensa/filter_pill_list/component_controller.js +749 -0
  37. data/app/components/mensa/header/component.css +41 -43
  38. data/app/components/mensa/header/component.html.slim +7 -7
  39. data/app/components/mensa/header/component.rb +1 -1
  40. data/app/components/mensa/row_action/component.html.slim +2 -2
  41. data/app/components/mensa/row_action/component.rb +1 -1
  42. data/app/components/mensa/search/component.css +68 -9
  43. data/app/components/mensa/search/component.html.slim +19 -15
  44. data/app/components/mensa/search/component.rb +1 -1
  45. data/app/components/mensa/search/component_controller.js +39 -49
  46. data/app/components/mensa/selection/component_controller.js +147 -0
  47. data/app/components/mensa/table/component.css +28 -0
  48. data/app/components/mensa/table/component.html.slim +9 -6
  49. data/app/components/mensa/table/component.rb +1 -0
  50. data/app/components/mensa/table/component_controller.js +524 -76
  51. data/app/components/mensa/table_row/component.css +6 -0
  52. data/app/components/mensa/table_row/component.html.slim +8 -3
  53. data/app/components/mensa/table_row/component.rb +1 -1
  54. data/app/components/mensa/view/component.css +97 -29
  55. data/app/components/mensa/view/component.html.slim +23 -10
  56. data/app/components/mensa/view/component.rb +5 -0
  57. data/app/components/mensa/views/component.css +106 -13
  58. data/app/components/mensa/views/component.html.slim +51 -17
  59. data/app/components/mensa/views/component_controller.js +245 -20
  60. data/app/controllers/mensa/application_controller.rb +1 -1
  61. data/app/controllers/mensa/tables/batch_actions_controller.rb +24 -0
  62. data/app/controllers/mensa/tables/exports_controller.rb +96 -0
  63. data/app/controllers/mensa/tables/filters_controller.rb +6 -2
  64. data/app/controllers/mensa/tables/views_controller.rb +108 -0
  65. data/app/controllers/mensa/tables_controller.rb +5 -14
  66. data/app/helpers/mensa/application_helper.rb +4 -1
  67. data/app/javascript/mensa/application.js +2 -2
  68. data/app/javascript/mensa/controllers/application_controller.js +5 -21
  69. data/app/javascript/mensa/controllers/index.js +16 -7
  70. data/app/jobs/mensa/export_job.rb +77 -85
  71. data/app/models/mensa/export.rb +93 -0
  72. data/app/tables/mensa/action.rb +3 -1
  73. data/app/tables/mensa/base.rb +103 -17
  74. data/app/tables/mensa/batch_action.rb +27 -0
  75. data/app/tables/mensa/cell.rb +21 -6
  76. data/app/tables/mensa/column.rb +30 -25
  77. data/app/tables/mensa/config/action_dsl.rb +1 -1
  78. data/app/tables/mensa/config/batch_dsl.rb +13 -0
  79. data/app/tables/mensa/config/column_dsl.rb +1 -0
  80. data/app/tables/mensa/config/dsl_logic.rb +8 -4
  81. data/app/tables/mensa/config/filter_dsl.rb +4 -1
  82. data/app/tables/mensa/config/render_dsl.rb +1 -1
  83. data/app/tables/mensa/config/table_dsl.rb +14 -4
  84. data/app/tables/mensa/config/view_dsl.rb +2 -0
  85. data/app/tables/mensa/config_readers.rb +34 -3
  86. data/app/tables/mensa/filter.rb +94 -14
  87. data/app/tables/mensa/row.rb +1 -1
  88. data/app/tables/mensa/scope.rb +25 -13
  89. data/app/views/mensa/exports/_badge.html.slim +5 -0
  90. data/app/views/mensa/exports/_dialog.html.slim +42 -0
  91. data/app/views/mensa/exports/_list.html.slim +29 -0
  92. data/app/views/mensa/tables/filters/show.turbo_stream.slim +34 -6
  93. data/app/views/mensa/tables/show.html.slim +2 -0
  94. data/app/views/mensa/tables/show.turbo_stream.slim +1 -1
  95. data/app/views/mensa/tables/views/create.turbo_stream.slim +11 -0
  96. data/app/views/mensa/tables/views/destroy.turbo_stream.slim +11 -0
  97. data/app/views/mensa/tables/views/update.turbo_stream.slim +11 -0
  98. data/bin/setup +1 -1
  99. data/config/locales/en.yml +45 -1
  100. data/config/locales/nl.yml +46 -1
  101. data/config/routes.rb +7 -0
  102. data/db/migrate/20260604120000_create_mensa_exports.rb +25 -0
  103. data/docs/columns.png +0 -0
  104. data/docs/export.png +0 -0
  105. data/docs/filters.png +0 -0
  106. data/docs/table.png +0 -0
  107. data/lib/generators/mensa/tailwind_config_generator.rb +3 -3
  108. data/lib/generators/mensa/templates/config/initializers/mensa.rb +1 -1
  109. data/lib/mensa/configuration.rb +35 -15
  110. data/lib/mensa/engine.rb +15 -10
  111. data/lib/mensa/version.rb +1 -1
  112. data/lib/mensa.rb +2 -2
  113. data/lib/tasks/mensa_tasks.rake +1 -1
  114. data/mensa.gemspec +3 -2
  115. data/mise.toml +8 -0
  116. data/package-lock.json +0 -7
  117. metadata +60 -15
  118. data/app/components/mensa/filter/component_controller.js +0 -12
  119. data/app/components/mensa/filter_list/component.css +0 -14
  120. data/app/components/mensa/filter_list/component.html.slim +0 -14
  121. data/app/components/mensa/filter_list/component_controller.js +0 -14
  122. /data/{rubocop.yml → .rubocop.yml} +0 -0
@@ -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
@@ -5,7 +5,8 @@ module Mensa
5
5
 
6
6
  class Filter
7
7
  include ActiveModel::Model
8
- attr_accessor :value
8
+
9
+ attr_accessor :column, :value
9
10
  end
10
11
 
11
12
  def index
@@ -15,10 +16,13 @@ module Mensa
15
16
 
16
17
  # Returns the filter information on the column-name
17
18
  def show
18
- 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)
19
20
  @table = Mensa.for_name(params[:table_id], config)
20
21
  @table.original_view_context = helpers
21
22
  @column = @table.column(params[:id])
23
+ @operator = params[:operator].presence || "is"
24
+ @multiple = @column.filter.multiple?
25
+ @values = Array(params[:value]).flatten.compact
22
26
  respond_to do |format|
23
27
  format.turbo_stream
24
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
@@ -1,7 +1,5 @@
1
1
  module Mensa
2
- class TablesController < ::ApplicationController
3
- layout :decide_layout
4
-
2
+ class TablesController < ApplicationController
5
3
  def show
6
4
  @table = Mensa.for_name(params[:id])
7
5
 
@@ -9,11 +7,12 @@ module Mensa
9
7
  if params[:table_view_id]
10
8
  @view = Mensa::TableView.find_by(table_name: params[:id], id: params[:table_view_id])
11
9
  @view ||= @table.system_views.find { |v| v.id == params[:table_view_id].to_sym }
12
- config = @view&.config
10
+ config = @view&.config&.deep_transform_keys(&:to_sym)
13
11
  end
14
12
 
15
13
  config = config.merge(params.permit!.to_h)
16
- 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] || {}
17
16
 
18
17
  @table = Mensa.for_name(params[:id], config)
19
18
  @table.request = request
@@ -21,17 +20,9 @@ module Mensa
21
20
  @table.original_view_context = helpers
22
21
 
23
22
  respond_to do |format|
24
- format.turbo_stream
23
+ format.turbo_stream # Used for filterering
25
24
  format.html
26
- format.xlsx do
27
- Mensa::ExportJob.perform_later(current_user, params[:id])
28
- head 200
29
- end
30
25
  end
31
26
  end
32
-
33
- def decide_layout
34
- false if params[:turbo_frame_id]
35
- end
36
27
  end
37
28
  end
@@ -1,5 +1,8 @@
1
1
  module Mensa
2
2
  module ApplicationHelper
3
-
3
+ def table(name, config = {}, **options)
4
+ options[:original_view_context] = self
5
+ render(::Mensa::Table::Component.new(name, config, **options))
6
+ end
4
7
  end
5
8
  end
@@ -1,2 +1,2 @@
1
- import "@hotwired/turbo-rails"
2
- import "mensa/controllers"
1
+ import "@hotwired/turbo-rails";
2
+ import "mensa/controllers";
@@ -2,15 +2,15 @@
2
2
  import { Controller } from '@hotwired/stimulus'
3
3
 
4
4
  export default class ApplicationController extends Controller {
5
- connect () {
5
+ connect() {
6
6
  this.element[this.identifier] = this
7
7
  }
8
8
 
9
- getController (element, identifier) {
9
+ getController(element, identifier) {
10
10
  return this.application.getControllerForElementAndIdentifier(element, identifier)
11
11
  }
12
12
 
13
- triggerEvent (el, name, data) {
13
+ triggerEvent(el, name, data) {
14
14
  let event
15
15
  if (typeof window.CustomEvent === 'function') {
16
16
  event = new CustomEvent(name, { detail: data, cancelable: true, bubbles: true })
@@ -21,14 +21,14 @@ export default class ApplicationController extends Controller {
21
21
  el.dispatchEvent(event)
22
22
  }
23
23
 
24
- elementScrolled (element) {
24
+ elementScrolled(element) {
25
25
  if (element.scrollHeight - Math.round(element.scrollTop) === element.clientHeight) {
26
26
  return true
27
27
  }
28
28
  return false
29
29
  }
30
30
 
31
- debouncedHover (element, timeout, handler) {
31
+ debouncedHover(element, timeout, handler) {
32
32
  var timeoutId = null
33
33
  element.addEventListener(marker, 'mouseover', function () {
34
34
  timeoutId = setTimeout(handler, timeout)
@@ -38,20 +38,4 @@ export default class ApplicationController extends Controller {
38
38
  clearTimeout(timeoutId)
39
39
  })
40
40
  }
41
-
42
- get ourUrl () {
43
- // This requires the mensaTableOutlet
44
- let turboFrame = this.mensaTableOutlet?.turboFrameTarget
45
- if(!turboFrame) {
46
- turboFrame = this.turboFrameTarget
47
- }
48
- let url
49
-
50
- if (turboFrame && turboFrame.getAttribute('src')) {
51
- url = new URL(turboFrame.getAttribute('src'))
52
- } else {
53
- url = new URL(window.location.href)
54
- }
55
- return url
56
- }
57
41
  }
@@ -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)
@@ -6,21 +6,30 @@ import { application } from "mensa/controllers/application"
6
6
  import AddFilterComponentController from "mensa/components/add_filter/component_controller";
7
7
  application.register("mensa-add-filter", AddFilterComponentController);
8
8
 
9
- import FilterComponentController from "mensa/components/filter/component_controller";
10
- application.register("mensa-filter", FilterComponentController);
9
+ import FilterPillComponentController from "mensa/components/filter_pill/component_controller";
10
+ application.register("mensa-filter-pill", FilterPillComponentController);
11
11
 
12
- import FilterListComponentController from "mensa/components/filter_list/component_controller";
13
- application.register("mensa-filter-list", FilterListComponentController);
12
+ import FilterPillListComponentController from "mensa/components/filter_pill_list/component_controller";
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,111 +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
- wrap_text = 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
- number_format = wb.styles.add_style format_code: '#'
30
- datetime_format = wb.styles.add_style format_code: "dddd, d mmmm yyyy hh:mm:ss"
31
- date_format = 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) }
34
- custom_style = {}
36
+ private
35
37
 
36
- wb.add_worksheet(name: table_name.first(31)) do |sheet|
37
- column_widths = []
38
- # TODO: Separate display columns for export?
39
- table.display_columns.map.with_index do |column, index|
40
- styles[index] = sheet.styles.add_style(default_style) #.merge(column.export_style || {}))
41
- # width = column.export_style.delete(:width)
42
- # column_widths[index] = width if width
43
- 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 = {}
44
44
 
45
- first_row = sheet.add_row(table.display_columns.map { |c| c.human_name }, style: styles, height: 28)
46
-
47
- first_cell = first_row.first
48
- last_row = first_row
49
-
50
- table.export_rows.each do |row|
51
- # next if row.reject { |column, value| value.nil? || value == '' }.blank?
52
- #
53
- # row_types = :string #row.map(&:second)
54
- # row_values = row.map(&:third)
55
- # options = row.map(&:last)
56
- #
57
- # row_styles = options.map.with_index { |option, i|
58
- # type = row_types[i]
59
- # value = row_values[i]
60
- # export_style = option.dig(:export, :xlsx_style)
61
- #
62
- # if export_style.present?
63
- # wb.styles.add_style(export_style)
64
- # elsif custom_styles[type].present?
65
- # wb.styles.add_style(custom_styles[type])
66
- # elsif [:integer, :float, :decimal, :number].include?(type)
67
- # number_format
68
- # elsif [:datetime, :time, :timestamp].include?(type)
69
- # datetime_format
70
- # elsif type == :date
71
- # date_format
72
- # elsif value.is_a?(Axlsx::RichText) || type == :text
73
- # wrap_text
74
- # else
75
- # nowrap_text
76
- # end
77
- # }
78
- values = table.display_columns.map { |column| row.value(column) }
79
- row_styles = table.display_columns.map { |column| nowrap_text }
80
- last_row = sheet.add_row(values, style: row_styles)
81
- 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
82
49
 
83
- 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
84
56
 
85
- sheet.column_widths(*column_widths)
86
- 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) }
87
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")}"
88
73
 
89
- base_filename = "#{table_name}_export_#{Time.current.strftime("%Y-%m-%d-%H:%M:%S")}"
90
- enc = nil
91
- password = nil
92
74
  if table.export_with_password?
75
+ require "zip"
93
76
  password = SecureRandom.hex(6)
94
- enc = Zip::TraditionalEncrypter.new(password)
95
- end
96
- stringio = Zip::OutputStream.write_buffer(encrypter: enc) do |zio|
97
- zio.put_next_entry("#{base_filename}.xlsx")
98
- 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"]
99
86
  end
100
- stringio.rewind
87
+ end
101
88
 
102
- attachment = { io: stringio,
103
- content_type: 'application/zip', filename: "#{base_filename}.zip" }
104
- if password.present?
105
- attachment[:password] = password
106
- 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
107
93
 
108
- 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)
109
101
  end
110
102
  end
111
103
  end