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.
- checksums.yaml +4 -4
- data/.devcontainer/Dockerfile +6 -2
- data/.devcontainer/compose.yaml +1 -1
- data/.devcontainer/devcontainer.json +31 -29
- data/.devcontainer/postCreate.sh +8 -0
- data/.devcontainer/postStart.sh +9 -0
- data/.gitignore +3 -1
- data/.zed/tasks.json +12 -0
- data/Gemfile.lock +155 -153
- data/Procfile +1 -1
- data/README.md +85 -60
- data/app/assets/stylesheets/mensa/application.css +14 -11
- data/app/components/mensa/add_filter/component.css +110 -5
- data/app/components/mensa/add_filter/component.html.slim +10 -12
- data/app/components/mensa/add_filter/component.rb +7 -1
- data/app/components/mensa/add_filter/component_controller.js +697 -83
- data/app/components/mensa/cell/component.css +9 -0
- data/app/components/mensa/column_customizer/component.css +40 -0
- data/app/components/mensa/column_customizer/component.html.slim +14 -0
- data/app/components/mensa/column_customizer/component.rb +13 -0
- data/app/components/mensa/column_customizer/component_controller.js +383 -0
- data/app/components/mensa/control_bar/component.css +127 -4
- data/app/components/mensa/control_bar/component.html.slim +41 -14
- data/app/components/mensa/control_bar/component.rb +0 -4
- data/app/components/mensa/empty_state/component.css +20 -0
- data/app/components/mensa/empty_state/component.html.slim +7 -0
- data/app/components/mensa/empty_state/component.rb +18 -0
- data/app/components/mensa/filter_pill/component.css +23 -0
- data/app/components/mensa/filter_pill/component.html.slim +9 -6
- data/app/components/mensa/filter_pill/component.rb +9 -0
- data/app/components/mensa/filter_pill/component_controller.js +50 -10
- data/app/components/mensa/filter_pill_list/component.css +58 -9
- data/app/components/mensa/filter_pill_list/component.html.slim +11 -8
- data/app/components/mensa/filter_pill_list/component_controller.js +747 -48
- data/app/components/mensa/header/component.css +41 -43
- data/app/components/mensa/header/component.html.slim +7 -7
- data/app/components/mensa/row_action/component.html.slim +2 -2
- data/app/components/mensa/search/component.css +68 -9
- data/app/components/mensa/search/component.html.slim +19 -15
- data/app/components/mensa/search/component_controller.js +39 -49
- data/app/components/mensa/selection/component_controller.js +147 -0
- data/app/components/mensa/table/component.css +28 -0
- data/app/components/mensa/table/component.html.slim +9 -6
- data/app/components/mensa/table/component.rb +1 -0
- data/app/components/mensa/table/component_controller.js +524 -88
- data/app/components/mensa/table_row/component.css +6 -0
- data/app/components/mensa/table_row/component.html.slim +8 -3
- data/app/components/mensa/view/component.css +97 -29
- data/app/components/mensa/view/component.html.slim +23 -10
- data/app/components/mensa/view/component.rb +5 -0
- data/app/components/mensa/views/component.css +106 -13
- data/app/components/mensa/views/component.html.slim +51 -17
- data/app/components/mensa/views/component_controller.js +245 -20
- data/app/controllers/mensa/tables/batch_actions_controller.rb +24 -0
- data/app/controllers/mensa/tables/exports_controller.rb +96 -0
- data/app/controllers/mensa/tables/filters_controller.rb +4 -1
- data/app/controllers/mensa/tables/views_controller.rb +108 -0
- data/app/controllers/mensa/tables_controller.rb +3 -6
- data/app/helpers/mensa/application_helper.rb +4 -0
- data/app/javascript/mensa/application.js +2 -2
- data/app/javascript/mensa/controllers/index.js +13 -4
- data/app/jobs/mensa/export_job.rb +77 -84
- data/app/models/mensa/export.rb +93 -0
- data/app/tables/mensa/base.rb +103 -12
- data/app/tables/mensa/batch_action.rb +27 -0
- data/app/tables/mensa/cell.rb +15 -0
- data/app/tables/mensa/column.rb +15 -2
- data/app/tables/mensa/config/batch_dsl.rb +13 -0
- data/app/tables/mensa/config/column_dsl.rb +1 -0
- data/app/tables/mensa/config/filter_dsl.rb +4 -1
- data/app/tables/mensa/config/render_dsl.rb +1 -1
- data/app/tables/mensa/config/table_dsl.rb +12 -5
- data/app/tables/mensa/config/view_dsl.rb +2 -0
- data/app/tables/mensa/config_readers.rb +20 -1
- data/app/tables/mensa/filter.rb +86 -3
- data/app/tables/mensa/scope.rb +24 -12
- data/app/views/mensa/exports/_badge.html.slim +5 -0
- data/app/views/mensa/exports/_dialog.html.slim +42 -0
- data/app/views/mensa/exports/_list.html.slim +29 -0
- data/app/views/mensa/tables/filters/show.turbo_stream.slim +35 -8
- data/app/views/mensa/tables/views/create.turbo_stream.slim +11 -0
- data/app/views/mensa/tables/views/destroy.turbo_stream.slim +11 -0
- data/app/views/mensa/tables/views/update.turbo_stream.slim +11 -0
- data/config/locales/en.yml +44 -0
- data/config/locales/nl.yml +45 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20260604120000_create_mensa_exports.rb +25 -0
- data/docs/columns.png +0 -0
- data/docs/export.png +0 -0
- data/docs/filters.png +0 -0
- data/docs/table.png +0 -0
- data/lib/mensa/configuration.rb +33 -12
- data/lib/mensa/engine.rb +7 -2
- data/lib/mensa/version.rb +1 -1
- data/mensa.gemspec +2 -1
- data/mise.toml +8 -0
- data/package-lock.json +0 -7
- 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: {},
|
|
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,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(
|
|
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
|
|
21
|
+
import TableComponentController from "mensa/components/table/component_controller";
|
|
19
22
|
application.register("mensa-table", TableComponentController);
|
|
20
23
|
|
|
21
|
-
import ViewsComponentController from
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
# export_complete: lambda do |user_id, table_name, attachment|
|
|
10
|
-
# end
|
|
12
|
+
def perform(export)
|
|
13
|
+
return unless export
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return unless table.exportable?
|
|
15
|
+
export.update(status: "processing")
|
|
16
|
+
Mensa.config.callbacks[:export_started]&.call(export)
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
table = build_table(export)
|
|
19
|
+
unless table.exportable?
|
|
20
|
+
finalize(export, status: "failed")
|
|
21
|
+
return
|
|
22
|
+
end
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
p.use_shared_strings = true
|
|
24
|
+
data, filename, content_type = generate(table, export)
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
export.asset.attach(io: StringIO.new(data), filename: filename, content_type: content_type)
|
|
27
|
+
finalize(export, status: "completed", filename: filename)
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
+
private
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
87
|
+
end
|
|
100
88
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|