hot-glue 0.8 → 0.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 555bc8c92663c9e6797856debce6569ef5532f1d8f0536d862f1226b08b3e297
4
- data.tar.gz: 2cc566882dc10083d857ebf8e254a126cf2842cde280509ff56ce37a86c4766e
3
+ metadata.gz: 4b7fb9cf9f9337373e86732ff6db9b2904de11b9d00450f78d44235820e9bdba
4
+ data.tar.gz: 7213c6c5680a3390559944d2e390b913269d9fee5c016db358f5ac64f6e7c1b8
5
5
  SHA512:
6
- metadata.gz: 9592d760df6d8b79e41fdd71bb8e8a89f62745056c512b56cf1d31bed0174e9169146531febdec68dfb7ca2017e26e048f52055718ab04279e444c2742016482
7
- data.tar.gz: c4e5ab6b7289ba6ac225eb4f40267bd2bb5b0163b1dc029ba43b55161552255ae4e541028a89aa4b988f6a83ea8123da7102216d4afe52a226dead4ca0e71c98
6
+ metadata.gz: 759e7b6343a5ed0457b13cdc039732ada8187ebaf27e9da04cf8203130861210bd5dccb50795712fe43ae68e7e51f8eeb2a6afb55d3cfcf2a45533d65bee20a7
7
+ data.tar.gz: 841e4b699b8d5f8b416618a768fc58f9748c6b1e1c1e90337a92e55ced89893b872a4ef883e16bcd6c555685caa42e68e8348b295a001d40503e2ae92dd1a7df
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hot-glue (0.7.9)
4
+ hot-glue (0.9)
5
5
  ffaker (~> 2.16)
6
6
  rails (> 5.1)
7
7
 
data/README.md CHANGED
@@ -2009,6 +2009,74 @@ List of field names (separated by commas)
2009
2009
  Leave off (do not specify) to sort by all sort-eligible fields
2010
2010
 
2011
2011
 
2012
+ ## CSV / Excel Export
2013
+ A scaffold can export its list (respecting whatever search filters and sort order the user currently has applied) as a CSV or Excel file. Small result sets download immediately; large result sets are built in background job and the browser is notified via Turbo Streams when the file is ready, then the download starts automatically.
2014
+
2015
+ ### One-time setup
2016
+
2017
+ Run this once per app, **before** generating any scaffold with `--csv`:
2018
+
2019
+ ```
2020
+ bin/rails generate hot_glue:csv_export_install
2021
+ ```
2022
+
2023
+ This copies the shared pieces every exporting controller depends on (`CsvConstructor`, the `CsvRequest` model + migration, `BuildCsvJob`, `DestroyCsvRequestJob`, `CsvRequestsController`, the Turbo Stream partials, and an `auto-download` Stimulus controller), and prints a checklist to finish:
2024
+
2025
+ 1. Add gems:
2026
+ ```ruby
2027
+ gem "caxlsx" # .xlsx / Excel export
2028
+ gem "csv" # required on Ruby 3.4+ (csv is no longer in the default gemset)
2029
+ ```
2030
+
2031
+ 2. Install Active Storage (the export file is stored as an attachment):
2032
+ ```
2033
+ bin/rails active_storage:install
2034
+ ```
2035
+
2036
+ 3. Add routes — the global download route once:
2037
+ ```ruby
2038
+ resources :csv_requests, only: [] do
2039
+ member { get :download }
2040
+ end
2041
+ ```
2042
+ ...then, for **each** scaffold you build with `--csv`, add an export route to
2043
+ that resource:
2044
+ ```ruby
2045
+ resources :things do
2046
+ collection { post :export }
2047
+ end
2048
+ ```
2049
+
2050
+ 4. **Action Cable must be cross-process.** A large export runs in a background job and, on completion, broadcasts a Turbo Stream telling the browser the file is ready. The job and the web server are separate processes in any real deployment (and in dev too, if you run a separate worker), so the Action Cable adapter must be shared across processes — `solid_cable` or `redis` both work. The `async` adapter (Rails' dev default) only works in-process and will make the export appear to hang forever on "Preparing...". If you use the redis adapter, note Action Cable requires `redis < 6`:
2051
+
2052
+ ```ruby
2053
+ gem "redis", "~> 5.0"
2054
+ ```
2055
+
2056
+ ### `--csv` (default: false)
2057
+ Builds the exporter into this controller: an `EXPORTABLE_FIELDS` constant, a `self.load_all_<plural>_query` class method (shared with `CsvConstructor` so an export always reflects the same search+sort logic as the interactive index), an `export` action, and an Export dropdown button (CSV / Excel) above the list. Opt in per scaffold with `--csv`.
2058
+
2059
+ ### `--csv-fields=name,desc,age`
2060
+ Comma-separated whitelist of which columns are written to the export file, and in what order. Defaults to every field passed to `--include`. Raises at generation time if you list a field that isn't in `--include`. `id` is never exportable, whitelisted or not.
2061
+
2062
+ `--csv` cannot be combined with `--no-list` — there's no list to export — and raises at generation time if you try.
2063
+
2064
+ ### How it behaves at runtime
2065
+ - **Quick mode** (≤ 100 matching rows): the file is built in-process and streamed back immediately as an attachment — no job, no polling.
2066
+
2067
+ - **Background mode** (> 100 matching rows): a `CsvRequest` row is created, a `BuildCsvJob` is queued, and the page shows a "Preparing your export…" panel. When the job finishes it attaches the file (via Active Storage) and broadcasts a Turbo Stream that replaces that panel with a download link, which the `auto-download` Stimulus controller clicks automatically — no second click required. The `CsvRequest` (and its attached file) are destroyed automatically ~5 minutes after download.
2068
+
2069
+ ### `--csv` with `--pundit`
2070
+ The export respects your Pundit scopes. `--csv` generates a `self.csv_export_scope(owner)` class method (`Pundit.policy_scope!(owner, Model)`) that `CsvConstructor` calls whenever it's present, for both quick and background exports:
2071
+ - Quick (synchronous) exports run inside the request, so the scope is the same `policy_scope(...)` the interactive index already uses.
2072
+ - Background exports run in a separate job process with no request/session, so the `export` action stores `pundit_user` on the `CsvRequest` (`owner`, the same nullable polymorphic column mentioned below) at creation time, and the job recomputes `Pundit.policy_scope!(owner, Model)` from that stored value when it runs.
2073
+
2074
+ ### Known limitations
2075
+ - Scaffolds generated without `--gd` and without `--pundit` (auth-scoped some other way, e.g. `current_user.things`) correctly scope the interactive index to `current_user`, but a *background* export's `CsvConstructor` call doesn't yet receive that scope — it falls back to the model's default. To be addressed once there's a dummy app with real login to build and test it against. (`--pundit` scoping is handled — see above.)
2076
+
2077
+ - The `export` route helper only handles top-level and namespaced resources today, not `nested_set`-style nested routes.
2078
+
2079
+
2012
2080
  ## Attachments
2013
2081
 
2014
2082
  #### `--attachments=` Long form syntax with 1st and 2nd parameters
@@ -2176,8 +2244,10 @@ For Pagy version 9 or below
2176
2244
  4. add `include Pagy::Frontend` to ApplicationHelper
2177
2245
 
2178
2246
  For Pagy version 43 (there was a version jump)
2179
- *NOT YET COMPATIBLE WITH PAGY 43*
2180
- TODO: implement pagy 43
2247
+ Include pagy in your code (usually application_controller.rb)
2248
+ `include Pagy::Method`
2249
+
2250
+ Breaking changes bewteen Pagy version 9 and version 42 force you to rebuild everything (every view) when upgrading Pagy. Hot Glue now detects which version of Pagy is installed and outputs the syntax for that version.
2181
2251
 
2182
2252
  ## "Thing" Label
2183
2253
 
@@ -2498,6 +2568,10 @@ These automatic pickups for partials are detected at build time. This means that
2498
2568
 
2499
2569
  # VERSION HISTORY
2500
2570
 
2571
+ #### 2026-09-05 - v0.9
2572
+ - CSV / Excel export: `--csv` (default false) and `--csv-fields=`. New `hot_glue:csv_export_install` generator sets up the shared `CsvConstructor`/`CsvRequest`/`BuildCsvJob` infrastructure. Quick (in-process) and background (job + Turbo Stream + auto-download) modes; the data output respects that you have sorted and searched too. Respects `--pundit` policy scopes on both quick and background exports. Raises if combined with `--no-list`. See "CSV / Excel Export" above for full docs.
2573
+
2574
+
2501
2575
  #### 2026-08-30 - v0.8
2502
2576
  - `--sortable` (true of flagged; false otherwise; does not take an argument)
2503
2577
  To add sorting to your columns add `--sortable` to your build
@@ -0,0 +1,100 @@
1
+
2
+
3
+ module HotGlue
4
+ class CsvExportInstallGenerator < Rails::Generators::Base
5
+ source_root File.expand_path('templates', __dir__)
6
+
7
+ def filepath_prefix
8
+ # todo: inject the context
9
+ 'spec/dummy/' if $INTERNAL_SPECS
10
+ end
11
+
12
+ def initialize(*args) #:nodoc:
13
+ super
14
+
15
+ # global service object, model, jobs, controller & views shared by every
16
+ # scaffold's CSV/Excel exporter (built per-controller with the --csv flag)
17
+ copy_file "csv_export/csv_constructor.rb", "#{filepath_prefix}app/services/csv_constructor.rb"
18
+ copy_file "csv_export/csv_request.rb", "#{filepath_prefix}app/models/csv_request.rb"
19
+ copy_file "csv_export/build_csv_job.rb", "#{filepath_prefix}app/jobs/build_csv_job.rb"
20
+ copy_file "csv_export/destroy_csv_request_job.rb", "#{filepath_prefix}app/jobs/destroy_csv_request_job.rb"
21
+ copy_file "csv_export/csv_requests_controller.rb", "#{filepath_prefix}app/controllers/csv_requests_controller.rb"
22
+
23
+ copy_file "csv_export/_pending.erb", "#{filepath_prefix}app/views/csv_requests/_pending.erb"
24
+ copy_file "csv_export/_ready.erb", "#{filepath_prefix}app/views/csv_requests/_ready.erb"
25
+ copy_file "csv_export/_failed.erb", "#{filepath_prefix}app/views/csv_requests/_failed.erb"
26
+
27
+ # Stimulus controller that auto-triggers the download when the background
28
+ # export's "ready" Turbo Stream arrives (registers it in the manifest,
29
+ # then overwrites the stub with the real implementation)
30
+ system("./bin/rails generate stimulus AutoDownload")
31
+ copy_file "csv_export/auto_download_controller.js", "#{filepath_prefix}app/javascript/controllers/auto_download_controller.js"
32
+
33
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
34
+ migration_version = ActiveRecord::Migration.current_version
35
+ create_file "#{filepath_prefix}db/migrate/#{timestamp}_create_csv_requests.rb", <<~RUBY
36
+ class CreateCsvRequests < ActiveRecord::Migration[#{migration_version}]
37
+ def change
38
+ create_table :csv_requests do |t|
39
+ t.string :controller_name, null: false
40
+ t.string :format, null: false, default: "csv"
41
+ t.jsonb :query_params, null: false, default: {}
42
+ t.string :status, null: false, default: "pending"
43
+ t.string :owner_type
44
+ t.bigint :owner_id
45
+ t.text :error_message
46
+ t.datetime :downloaded_at
47
+
48
+ t.timestamps
49
+ end
50
+ add_index :csv_requests, [:owner_type, :owner_id]
51
+ end
52
+ end
53
+ RUBY
54
+
55
+ puts <<~MSG
56
+
57
+ ============================================================
58
+ HOT GLUE --> CSV/Excel export installed.
59
+
60
+ Finish setup with these one-time steps:
61
+
62
+ 1. Add these gems to your Gemfile:
63
+ gem "caxlsx" # .xlsx / Excel export
64
+ gem "csv" # required on Ruby 3.4+ (csv is no longer default)
65
+
66
+ 2. Install Active Storage (the export file is stored as an attachment):
67
+ bin/rails active_storage:install
68
+
69
+ 3. Run migrations:
70
+ bin/rails db:migrate
71
+
72
+ 4. Add routes to config/routes.rb:
73
+
74
+ a) the global download route (once):
75
+ resources :csv_requests, only: [] do
76
+ member { get :download }
77
+ end
78
+
79
+ b) for EACH scaffold you build with --csv, add an export
80
+ collection route to that resource, e.g. for Things:
81
+ resources :things do
82
+ collection { post :export }
83
+ end
84
+
85
+ 5. IMPORTANT — Action Cable must be CROSS-PROCESS.
86
+ When an export is too large it runs in a background job and, on
87
+ completion, broadcasts a Turbo Stream to the browser. The job and the
88
+ web server are separate processes in any real deployment (and in dev
89
+ if you run a separate worker), so your Action Cable adapter must be
90
+ shared across processes -- solid_cable, or redis. The `async` adapter
91
+ (Rails' dev default) only works in-process and will make the export
92
+ appear to hang on "Preparing...". If you use the redis adapter, note
93
+ that Action Cable requires the redis gem < 6:
94
+ gem "redis", "~> 5.0"
95
+ ============================================================
96
+
97
+ MSG
98
+ end
99
+ end
100
+ end
@@ -24,6 +24,7 @@ module LayoutStrategy
24
24
  (col_width/(builder.columns.count)).to_i
25
25
  end
26
26
  def list_classes; ""; end
27
+ def top_button_row_classes; ""; end
27
28
  def magic_button_classes; ""; end
28
29
  def row_classes; ""; end
29
30
  def row_heading_classes; ""; end
@@ -54,6 +54,10 @@ class LayoutStrategy::Bootstrap < LayoutStrategy::Base
54
54
  "row hg-row"
55
55
  end
56
56
 
57
+ def top_button_row_classes
58
+ "d-flex justify-content-between align-items-start"
59
+ end
60
+
57
61
  def page_end
58
62
  '</div> </div>'
59
63
  end
@@ -35,6 +35,10 @@ class LayoutStrategy::HotGlue < LayoutStrategy::Base
35
35
  "scaffold-list"
36
36
  end
37
37
 
38
+ def top_button_row_classes
39
+ "scaffold-button-row"
40
+ end
41
+
38
42
  def row_classes
39
43
  "scaffold-row"
40
44
  end
@@ -19,6 +19,7 @@ class LayoutStrategy::Tailwind < LayoutStrategy::Base
19
19
  end
20
20
 
21
21
  def list_classes; "overflow-x-auto w-full"; end
22
+ def top_button_row_classes; "flex justify-between items-start"; end
22
23
  def row_classes; "grid grid-cols-4 gap-x-16 py-5 px-4 text-sm text-gray-700 border-b border-gray-200 dark:border-gray-700"; end
23
24
  def row_heading_classes; "grid grid-cols-4 gap-x-16 p-4 text-sm font-medium text-gray-900 bg-gray-100 border-t border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 dark:text-white"; end
24
25
  def page_begin; '<div class="overflow-hidden min-w-max"> '; end
@@ -13,7 +13,8 @@ module HotGlue
13
13
  :form_path, :layout_object, :search_clear_button, :search_autosearch,
14
14
  :stimmify, :stimmify_camel, :hidden_create, :hidden_update, :invisible_create,
15
15
  :invisible_update, :plural, :phantom_search, :pagination_style,
16
- :namespace, :controller_build_folder, :sortable, :sortable_fields
16
+ :namespace, :controller_build_folder, :sortable, :sortable_fields,
17
+ :csv, :csv_fields
17
18
 
18
19
 
19
20
  def initialize(singular:, singular_class: ,
@@ -28,11 +29,14 @@ module HotGlue
28
29
  form_path: , stimmify: , stimmify_camel:, hidden_create:, hidden_update: ,
29
30
  invisible_create:, invisible_update: , plural: , phantom_search:,
30
31
  pagination_style:, namespace: nil, controller_build_folder: nil,
31
- sortable: false, sortable_fields: [] )
32
+ sortable: false, sortable_fields: [],
33
+ csv: true, csv_fields: [] )
32
34
 
33
35
 
34
36
  @sortable = sortable
35
37
  @sortable_fields = sortable_fields
38
+ @csv = csv
39
+ @csv_fields = csv_fields
36
40
  @form_path = form_path
37
41
  @search = search
38
42
  @search_fields = search_fields
@@ -35,7 +35,8 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
35
35
  :stimmify, :stimmify_camel, :hidden_create, :hidden_update,
36
36
  :invisible_create, :invisible_update, :phantom_create_params,
37
37
  :phantom_update_params, :lazy, :back_link_to_parent, :polymorphic_parents,
38
- :sortable, :sortable_fields
38
+ :sortable, :sortable_fields,
39
+ :csv, :csv_fields
39
40
 
40
41
  # important: using an attr_accessor called :namespace indirectly causes a conflict with Rails class_name method
41
42
  # so we use namespace_value instead
@@ -149,6 +150,11 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
149
150
  class_option :sortable, type: :boolean, default: false
150
151
  class_option :sort_fields, default: nil # comma separated whitelist; defaults to all sortable-type fields
151
152
 
153
+ # CSV/EXCEL EXPORT OPTIONS
154
+ # requires a one-time `rails generate hot_glue:csv_export_install`
155
+ class_option :csv, type: :boolean, default: false # build the CSV/Excel exporter into this controller
156
+ class_option :csv_fields, default: nil # comma separated whitelist; defaults to all included fields
157
+
152
158
 
153
159
 
154
160
  def initialize(*meta_args)
@@ -778,6 +784,27 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
778
784
  @sortable_fields = []
779
785
  end
780
786
 
787
+ @csv = options['csv']
788
+
789
+ if @csv && @no_list
790
+ raise "--csv cannot be combined with --no-list: there is no list to export."
791
+ end
792
+
793
+ if @csv
794
+ if options['csv_fields']
795
+ @csv_fields = options['csv_fields'].split(',').collect(&:to_sym)
796
+ @csv_fields.each do |field|
797
+ if !@columns_map[field]
798
+ raise "You specified a csv field for #{field} but that field is not in the list of columns"
799
+ end
800
+ end
801
+ else
802
+ @csv_fields = @columns
803
+ end
804
+ else
805
+ @csv_fields = []
806
+ end
807
+
781
808
 
782
809
  @columns_map.each do |key, field|
783
810
  if field.is_a?(AssociationField)
@@ -956,7 +983,9 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
956
983
  namespace: @namespace,
957
984
  controller_build_folder: @controller_build_folder,
958
985
  sortable: @sortable,
959
- sortable_fields: @sortable_fields
986
+ sortable_fields: @sortable_fields,
987
+ csv: @csv,
988
+ csv_fields: @csv_fields
960
989
  )
961
990
  elsif @markup == "slim"
962
991
  raise(HotGlue::Error, "SLIM IS NOT IMPLEMENTED")
@@ -1854,6 +1883,10 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
1854
1883
  res << '_lazy_list'
1855
1884
  end
1856
1885
 
1886
+ if @csv && !@no_list
1887
+ res << '_export_button'
1888
+ end
1889
+
1857
1890
  res
1858
1891
  end
1859
1892
 
@@ -2037,36 +2070,70 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
2037
2070
  end
2038
2071
 
2039
2072
  def load_all_code
2040
- # the inner method definition of the load_all_* method
2073
+ # the inner method definition of the load_all_* method (non-CSV path):
2074
+ # query-building followed immediately by pagination (unchanged behavior)
2075
+ load_all_query_code + load_all_pagination_code
2076
+ end
2077
+
2078
+ def load_all_query_class_method_code
2079
+ # the same query-building code but rewritten to use LOCAL variables so it
2080
+ # can live in the class-level load_all_<plural>_query method that the CSV
2081
+ # exporter shares with the controller. (no pagination — the exporter needs
2082
+ # the full result set; pagination stays in the instance load_all_ method)
2083
+ #
2084
+ # For non-god controllers the base relation depends on controller context
2085
+ # (current_user, policy_scope, etc.) which does not exist in a class method,
2086
+ # so it is injected as `scope`. God controllers have a context-free base
2087
+ # (Model.all) and keep it inline.
2088
+ # Pundit's `policy_scope` is a controller-instance method (it depends on
2089
+ # pundit_user), so even a --gd controller needs its scope injected from
2090
+ # the instance when --pundit is enabled -- only a non-pundit --gd
2091
+ # controller can compute its scope inline in the class method.
2092
+ base = (@god && !pundit) ? nil : "scope"
2093
+ load_all_query_code(base_override: base).gsub("@#{plural}", plural).gsub("@q", "q")
2094
+ end
2095
+
2096
+ def load_all_base_scope
2097
+ # the base relation expression used by the instance load_all_ method; for
2098
+ # non-god controllers it is passed into the class-level query method as
2099
+ # `scope:` (it may reference current_user / policy_scope, which only exist
2100
+ # in controller-instance context)
2101
+ if pundit
2102
+ "policy_scope(#{ object_scope })#{record_scope}"
2103
+ elsif !@self_auth
2104
+ "#{ object_scope.gsub("@",'') }#{record_scope}#{ n_plus_one_includes }#{".all" if n_plus_one_includes.blank? && record_scope.blank? }"
2105
+ elsif @nested_set[0] && @nested_set[0][:optional]
2106
+ "#{ class_name }.#{record_scope}.all"
2107
+ else
2108
+ "#{ class_name }.#{record_scope}.where(id: #{ auth_object.gsub("@",'') }.id)#{ n_plus_one_includes }"
2109
+ end
2110
+ end
2111
+
2112
+ def load_all_query_code(base_override: nil)
2113
+ # the query-building portion of load_all (search where-clauses, phantom
2114
+ # search, and sort) — WITHOUT pagination. When base_override is given (the
2115
+ # class-level query method), the base relation is that expression (e.g. an
2116
+ # injected `scope`) instead of the controller-context object scope.
2041
2117
  res = +""
2042
2118
  if @search_fields
2043
- res << @search_fields.collect{ |field|
2119
+ search_field_assignments = @search_fields.collect{ |field|
2044
2120
  if !@columns_map[field.to_sym].load_all_query_statement.empty?
2045
2121
  @columns_map[field.to_sym].load_all_query_statement
2046
2122
  end
2047
- }.compact.join("\n" + spaces(4)) + "\n"
2123
+ }.compact
2124
+ res << spaces(4) + search_field_assignments.join("\n" + spaces(4)) + "\n" if search_field_assignments.any?
2048
2125
  end
2049
2126
 
2050
- if pundit
2127
+ if base_override
2128
+ res << spaces(4) + "@#{ plural_name } = #{ base_override }"
2129
+ res << "\n"
2130
+ elsif pundit
2051
2131
  res << " @#{ plural_name } = policy_scope(#{ object_scope })#{record_scope}\n"
2052
2132
  else
2053
2133
  if !@self_auth
2054
2134
 
2055
2135
  res << spaces(4) + "@#{ plural_name } = #{ object_scope.gsub("@",'') }#{record_scope}#{ n_plus_one_includes }#{".all" if n_plus_one_includes.blank? && record_scope.blank? }"
2056
2136
 
2057
- if @search_fields
2058
- res << @search_fields.collect{ |field|
2059
- wqs = @columns_map[field.to_sym].where_query_statement
2060
- if !wqs.empty?
2061
- "\n" + spaces(4) + "@#{ plural_name } = @#{ plural_name }#{ wqs } if #{field}_query"
2062
- end
2063
- }.compact.join
2064
- end
2065
-
2066
-
2067
-
2068
- # res << "\n @#{plural} = @#{plural}.page(params[:page])#{ '.per(per)' if @paginate_per_page_selector }"
2069
-
2070
2137
  elsif @nested_set[0] && @nested_set[0][:optional]
2071
2138
  res << "@#{ plural_name } = #{ class_name }.#{record_scope}.all"
2072
2139
  else
@@ -2113,7 +2180,11 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
2113
2180
  res << " @#{plural} = @#{plural}.order(params[:sort] => params[:direction].to_sym)\n"
2114
2181
  res << " end"
2115
2182
  end
2183
+ res
2184
+ end
2116
2185
 
2186
+ def load_all_pagination_code
2187
+ res = +""
2117
2188
  if @pagination_style == "kaminari"
2118
2189
  res << " @#{plural} = @#{plural}.page(params[:page])#{ ".per(per)" if @paginate_per_page_selector }"
2119
2190
  elsif @pagination_style == "will_paginate"
@@ -7,7 +7,8 @@ class <%= controller_class_name %> < <%= controller_descends_from %>
7
7
  # rubocop:enable Layout/LineLength <% end %>
8
8
 
9
9
  helper :hot_glue
10
- include HotGlue::ControllerHelper
10
+ include HotGlue::ControllerHelper<% if @csv %>
11
+ extend HotGlue::ControllerHelper<% end %>
11
12
  <%= @code_in_controller.gsub(";", "\n") %>
12
13
 
13
14
  <% unless @god %>before_action :<%= "authenticate_" + @auth_identifier.split(".")[0] + "!" %><% end %><% if any_nested? %>
@@ -78,15 +79,32 @@ class <%= controller_class_name %> < <%= controller_descends_from %>
78
79
  end<% end %>
79
80
  <% unless @no_list %>
80
81
  <% if @sortable %> SORTABLE_FIELDS = %w[<%= @sortable_fields.join(' ') %>].freeze
82
+ <% end %><% if @csv %> EXPORTABLE_FIELDS = %w[<%= @csv_fields.join(' ') %>].freeze
81
83
  <% end %>
82
- def load_all_<%= plural %><% if @search == "set" %>
84
+ <% if @csv %> def self.load_all_<%= plural %>_query(params:<% if !@god || @pundit %>, scope: <%= class_name %>.all<% end %>)
85
+ q = params[:q]<% if @search == "set" %> || <%= search_default %><% end %>
86
+ <%= load_all_query_class_method_code %>
87
+
88
+ [q, <%= plural %>]
89
+ end
90
+ <% if @pundit %>
91
+ def self.csv_export_scope(owner)
92
+ Pundit.policy_scope!(owner, <%= class_name %>)
93
+ end
94
+ <% end %>
95
+ def load_all_<%= plural %>
96
+ @q, @<%= plural %> = self.class.load_all_<%= plural %>_query(params: params<% if !@god || @pundit %>, scope: <%= load_all_base_scope %><% end %>)
97
+ <%= load_all_pagination_code %>
98
+ end<% else %> def load_all_<%= plural %><% if @search == "set" %>
83
99
  @q = params[:q] || <%= search_default %> <% end %>
84
100
  <%= load_all_code %>
85
- end
101
+ end<% end %>
86
102
 
87
103
  def index
88
104
  load_all_<%= plural %><% if @search_fields %>
89
- <%= @search_fields.collect{|field_name| @columns_map[field_name.to_sym].code_to_reset_match_if_search_is_blank}.compact.join(" \n") %><% end %>
105
+ <%= @search_fields.collect{|field_name| @columns_map[field_name.to_sym].code_to_reset_match_if_search_is_blank}.compact.join(" \n") %><% end %><% if @csv %>
106
+
107
+ @pending_csv_request = CsvRequest.find_by(id: flash[:pending_csv_request_id])<% end %>
90
108
  <% if @pundit %><% if @pundit && !@pundit_policy_override %>
91
109
  authorize @<%= plural_name %><% elsif @pundit && @pundit_policy_override %>
92
110
  skip_authorization
@@ -96,7 +114,29 @@ class <%= controller_class_name %> < <%= controller_descends_from %>
96
114
  flash[:alert] = 'You are not authorized to perform this action.'
97
115
  render 'layouts/error'<% end %>
98
116
  end<% end %>
99
-
117
+ <% if @csv %>
118
+ def export
119
+ format_type = %w[csv xlsx].include?(params[:format_type]) ? params[:format_type] : "csv"
120
+ constructor = CsvConstructor.new(controller_name: "<%= controller_class_name %>", params: params<% if @pundit %>, owner: pundit_user<% end %>)
121
+
122
+ if constructor.count <= 100
123
+ send_data constructor.build(format_type),
124
+ filename: "<%= plural %>.#{format_type}",
125
+ type: mime_for_export(format_type),
126
+ disposition: "attachment"
127
+ else
128
+ csv_request = CsvRequest.create!(
129
+ controller_name: "<%= controller_class_name %>",
130
+ format: format_type,
131
+ query_params: params.slice(:sort, :direction, :q).to_unsafe_h<% if @pundit %>,
132
+ owner: pundit_user<% end %>
133
+ )
134
+ BuildCsvJob.perform_later(csv_request.id)
135
+ flash[:pending_csv_request_id] = csv_request.id
136
+ redirect_back fallback_location: <%= path_helper_plural %>
137
+ end
138
+ end
139
+ <% end %>
100
140
  <% if create_action %> def new<% if @object_owner_sym %>
101
141
  @<%= singular_name %> = <%= class_name %>.new<% if eval("#{class_name}.reflect_on_association(:#{@object_owner_sym})").class == ActiveRecord::Reflection::BelongsToReflection %>(<%= @object_owner_sym %>: <%= @object_owner_eval %>)<% end %><% elsif @object_owner_optional && any_nested? %>
102
142
  @<%= singular_name %> = <%= class_name %>.new({}.merge(<%= @nested_set.last[:singular] %> ? {<%= @object_owner_sym %>: <%= @object_owner_eval %>} : {}))<% else %>
@@ -324,6 +364,10 @@ class <%= controller_class_name %> < <%= controller_descends_from %>
324
364
  def namespace
325
365
  <% if @namespace %>'<%= @namespace %>/'<% else %><% end %>
326
366
  end
327
- end
367
+ <% if @csv %>
368
+ def mime_for_export(format_type)
369
+ format_type == "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv"
370
+ end
371
+ <% end %>end
328
372
 
329
373
 
@@ -0,0 +1,5 @@
1
+ <%= turbo_frame_tag "csv_request_#{csv_request.id}" do %>
2
+ <div class="alert alert-danger">
3
+ Your export failed<%= ": #{csv_request.error_message}" if csv_request.error_message.present? %>.
4
+ </div>
5
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%= turbo_stream_from csv_request %>
2
+ <%= turbo_frame_tag "csv_request_#{csv_request.id}" do %>
3
+ <div class="alert alert-info">
4
+ Preparing your <%= csv_request.format.upcase %> export&hellip;
5
+ </div>
6
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%= turbo_frame_tag "csv_request_#{csv_request.id}" do %>
2
+ <div class="alert alert-success">
3
+ Your <%= csv_request.format.upcase %> export is ready.
4
+ <%= link_to "Download", download_csv_request_path(csv_request), class: "btn btn-sm btn-primary", data: { turbo: false, controller: "auto-download" } %>
5
+ </div>
6
+ <% end %>
@@ -0,0 +1,11 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Attached to the CSV/Excel export "Download" link that arrives via a Turbo
4
+ // Stream broadcast when a background export finishes. On connect it clicks the
5
+ // link once, so the file (served with Content-Disposition: attachment)
6
+ // downloads automatically without the user having to click.
7
+ export default class extends Controller {
8
+ connect() {
9
+ this.element.click()
10
+ }
11
+ }
@@ -0,0 +1,36 @@
1
+ class BuildCsvJob < ApplicationJob
2
+ def perform(csv_request_id)
3
+ csv_request = CsvRequest.find(csv_request_id)
4
+ csv_request.update!(status: "processing")
5
+
6
+ constructor = CsvConstructor.new(
7
+ controller_name: csv_request.controller_name,
8
+ params: csv_request.query_params_as_params,
9
+ owner: csv_request.owner
10
+ )
11
+ content = constructor.build(csv_request.format)
12
+
13
+ csv_request.file.attach(
14
+ io: StringIO.new(content),
15
+ filename: "export-#{csv_request.id}.#{csv_request.format}",
16
+ content_type: csv_request.format == "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv"
17
+ )
18
+ csv_request.update!(status: "completed")
19
+
20
+ csv_request.broadcast_replace_to(
21
+ csv_request,
22
+ target: "csv_request_#{csv_request.id}",
23
+ partial: "csv_requests/ready",
24
+ locals: { csv_request: csv_request }
25
+ )
26
+ rescue => e
27
+ csv_request&.update!(status: "failed", error_message: e.message)
28
+ csv_request&.broadcast_replace_to(
29
+ csv_request,
30
+ target: "csv_request_#{csv_request.id}",
31
+ partial: "csv_requests/failed",
32
+ locals: { csv_request: csv_request }
33
+ )
34
+ raise
35
+ end
36
+ end
@@ -0,0 +1,68 @@
1
+ require "csv"
2
+
3
+ class CsvConstructor
4
+ def initialize(controller_name:, params:, owner: nil)
5
+ @controller_class = controller_name.to_s.constantize
6
+ @params = params
7
+ @owner = owner
8
+ end
9
+
10
+ def relation
11
+ @relation ||= begin
12
+ plural = @controller_class.name.underscore.sub(/_controller$/, "")
13
+ _, relation = if @controller_class.respond_to?(:csv_export_scope)
14
+ @controller_class.public_send("load_all_#{plural}_query", params: @params, scope: @controller_class.csv_export_scope(@owner))
15
+ else
16
+ @controller_class.public_send("load_all_#{plural}_query", params: @params)
17
+ end
18
+ relation
19
+ end
20
+ end
21
+
22
+ def count
23
+ relation.count
24
+ end
25
+
26
+ def columns
27
+ @columns ||= @controller_class::EXPORTABLE_FIELDS
28
+ end
29
+
30
+ def build(format)
31
+ format == "xlsx" ? build_xlsx : build_csv
32
+ end
33
+
34
+ private
35
+
36
+ def build_csv
37
+ CSV.generate do |csv|
38
+ csv << columns.map(&:humanize)
39
+ relation.find_each { |record| csv << columns.map { |c| format_value(record, c) } }
40
+ end
41
+ end
42
+
43
+ def build_xlsx
44
+ package = Axlsx::Package.new
45
+ package.workbook.add_worksheet(name: "Export") do |sheet|
46
+ sheet.add_row columns.map(&:humanize)
47
+ relation.find_each { |record| sheet.add_row columns.map { |c| format_value(record, c) } }
48
+ end
49
+ package.to_stream.read
50
+ end
51
+
52
+ def format_value(record, column)
53
+ value = record[column]
54
+ if time_column?(column) && value.respond_to?(:strftime)
55
+ value.strftime("%H:%M:%S")
56
+ else
57
+ value
58
+ end
59
+ end
60
+
61
+ def time_only_columns
62
+ @time_only_columns ||= relation.klass.columns_hash.select { |_, c| c.type == :time }.keys
63
+ end
64
+
65
+ def time_column?(column)
66
+ time_only_columns.include?(column.to_s)
67
+ end
68
+ end
@@ -0,0 +1,10 @@
1
+ class CsvRequest < ApplicationRecord
2
+ has_one_attached :file, dependent: :purge_later
3
+ belongs_to :owner, polymorphic: true, optional: true
4
+
5
+ validates :status, inclusion: { in: %w[pending processing completed failed] }
6
+
7
+ def query_params_as_params
8
+ ActionController::Parameters.new(query_params)
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ class CsvRequestsController < ApplicationController
2
+ def download
3
+ csv_request = CsvRequest.find(params[:id])
4
+ csv_request.update!(downloaded_at: Time.current)
5
+ DestroyCsvRequestJob.set(wait: 5.minutes).perform_later(csv_request.id)
6
+ redirect_to rails_blob_path(csv_request.file, disposition: "attachment")
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ class DestroyCsvRequestJob < ApplicationJob
2
+ def perform(csv_request_id)
3
+ CsvRequest.find_by(id: csv_request_id)&.destroy
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ <div class="dropdown d-inline-block">
2
+ <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
3
+ Export
4
+ </button>
5
+ <ul class="dropdown-menu">
6
+ <li><\%= button_to "as CSV", export_<%= "#{@namespace}_" if @namespace %><%= plural %>_path(request.query_parameters.merge(format_type: "csv")), class: "dropdown-item", form: { data: { turbo: false } } %></li>
7
+ <li><\%= button_to "as Excel", export_<%= "#{@namespace}_" if @namespace %><%= plural %>_path(request.query_parameters.merge(format_type: "xlsx")), class: "dropdown-item", form: { data: { turbo: false } } %></li>
8
+ </ul>
9
+ </div>
@@ -9,14 +9,24 @@
9
9
 
10
10
  <% if @new_button_position == 'above' %>
11
11
  <% unless @no_create %>
12
- <%= '<%= render partial: "' + ((@namespace+"/" if @namespace) || "") +
12
+ <% if @csv %> <div class="<%= @layout_strategy.top_button_row_classes %>">
13
+ <% end %> <%= '<%= render partial: "' + ((@namespace+"/" if @namespace) || "") +
13
14
  @controller_build_folder +
14
15
  "/new_button\", locals: {
15
16
  #{@nested_set.collect{|arg| arg[:singular] + ": " + arg[:singular]}.join(",\n ")} }" +
16
17
  ' %\>'.gsub('\\',"") %>
17
- <br />
18
+ <% if @csv %> <\%= render partial: "<%= namespace_with_trailing_dash %><%= @controller_build_folder %>/export_button" %>
19
+ </div>
20
+ <% end %> <br />
18
21
  <% end %>
19
22
  <% end %>
23
+ <% if @csv %>
24
+ <div id="export-status">
25
+ <\% if @pending_csv_request %>
26
+ <\%= render partial: "csv_requests/pending", locals: { csv_request: @pending_csv_request } %>
27
+ <\% end %>
28
+ </div>
29
+ <% end %>
20
30
 
21
31
  <% unless @no_list %>
22
32
  <% unless @no_list_heading %>
@@ -1,5 +1,5 @@
1
1
  module HotGlue
2
2
  class Version
3
- CURRENT = '0.8'
3
+ CURRENT = '0.9'
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hot-glue
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.8'
4
+ version: '0.9'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Fleetwood-Boldt
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-30 00:00:00.000000000 Z
11
+ date: 2026-09-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -63,6 +63,7 @@ files:
63
63
  - config/database.yml
64
64
  - config/hot_glue.yml
65
65
  - db/schema.rb
66
+ - lib/generators/hot_glue/csv_export_install_generator.rb
66
67
  - lib/generators/hot_glue/default_config_loader.rb
67
68
  - lib/generators/hot_glue/direct_upload_install_generator.rb
68
69
  - lib/generators/hot_glue/dropzone_install_generator.rb
@@ -99,7 +100,17 @@ files:
99
100
  - lib/generators/hot_glue/templates/capybara_login.rb
100
101
  - lib/generators/hot_glue/templates/computer_code.jpg
101
102
  - lib/generators/hot_glue/templates/controller.rb.erb
103
+ - lib/generators/hot_glue/templates/csv_export/_failed.erb
104
+ - lib/generators/hot_glue/templates/csv_export/_pending.erb
105
+ - lib/generators/hot_glue/templates/csv_export/_ready.erb
106
+ - lib/generators/hot_glue/templates/csv_export/auto_download_controller.js
107
+ - lib/generators/hot_glue/templates/csv_export/build_csv_job.rb
108
+ - lib/generators/hot_glue/templates/csv_export/csv_constructor.rb
109
+ - lib/generators/hot_glue/templates/csv_export/csv_request.rb
110
+ - lib/generators/hot_glue/templates/csv_export/csv_requests_controller.rb
111
+ - lib/generators/hot_glue/templates/csv_export/destroy_csv_request_job.rb
102
112
  - lib/generators/hot_glue/templates/erb/_edit.erb
113
+ - lib/generators/hot_glue/templates/erb/_export_button.erb
103
114
  - lib/generators/hot_glue/templates/erb/_flash_notices.erb
104
115
  - lib/generators/hot_glue/templates/erb/_form.erb
105
116
  - lib/generators/hot_glue/templates/erb/_lazy_list.erb