hot-glue 0.7.9 → 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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +2 -2
  3. data/README.md +101 -8
  4. data/app/helpers/hot_glue_helper.rb +32 -0
  5. data/lib/generators/hot_glue/csv_export_install_generator.rb +100 -0
  6. data/lib/generators/hot_glue/fields/association_field.rb +4 -0
  7. data/lib/generators/hot_glue/fields/field.rb +4 -0
  8. data/lib/generators/hot_glue/layout_strategy/base.rb +1 -0
  9. data/lib/generators/hot_glue/layout_strategy/bootstrap.rb +4 -0
  10. data/lib/generators/hot_glue/layout_strategy/hot_glue.rb +4 -0
  11. data/lib/generators/hot_glue/layout_strategy/tailwind.rb +1 -0
  12. data/lib/generators/hot_glue/markup_templates/erb.rb +14 -3
  13. data/lib/generators/hot_glue/scaffold_generator.rb +135 -19
  14. data/lib/generators/hot_glue/templates/controller.rb.erb +52 -6
  15. data/lib/generators/hot_glue/templates/csv_export/_failed.erb +5 -0
  16. data/lib/generators/hot_glue/templates/csv_export/_pending.erb +6 -0
  17. data/lib/generators/hot_glue/templates/csv_export/_ready.erb +6 -0
  18. data/lib/generators/hot_glue/templates/csv_export/auto_download_controller.js +11 -0
  19. data/lib/generators/hot_glue/templates/csv_export/build_csv_job.rb +36 -0
  20. data/lib/generators/hot_glue/templates/csv_export/csv_constructor.rb +68 -0
  21. data/lib/generators/hot_glue/templates/csv_export/csv_request.rb +10 -0
  22. data/lib/generators/hot_glue/templates/csv_export/csv_requests_controller.rb +8 -0
  23. data/lib/generators/hot_glue/templates/csv_export/destroy_csv_request_job.rb +5 -0
  24. data/lib/generators/hot_glue/templates/erb/_export_button.erb +9 -0
  25. data/lib/generators/hot_glue/templates/erb/_list.erb +12 -2
  26. data/lib/hotglue/version.rb +1 -1
  27. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76c922f98d4c719eebde65cdc53c37d2c8be4c77ef56790bc749332f3d65eefc
4
- data.tar.gz: 32b2ae025eae1edd8f75a702c00efb9cad09797c72f9af56343c881d0850d9b9
3
+ metadata.gz: 4b7fb9cf9f9337373e86732ff6db9b2904de11b9d00450f78d44235820e9bdba
4
+ data.tar.gz: 7213c6c5680a3390559944d2e390b913269d9fee5c016db358f5ac64f6e7c1b8
5
5
  SHA512:
6
- metadata.gz: 4cb18544f2114c1633fd2c00ccfe8beecaed890a0e6fdfec5cd0bf6ebb536ef9a80d8cf586b4d5bf9baf6097afc8149380d77f85df04465b01872239f09033b7
7
- data.tar.gz: 180a70ce42a44d9bf0f6919e689762dad6589530746e51e98d6b18fe8568d3c52f699859e715b2a3045c9240813ad6537a39a0f2416f8d7e8739da3e2e2d606e
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.8)
4
+ hot-glue (0.9)
5
5
  ffaker (~> 2.16)
6
6
  rails (> 5.1)
7
7
 
@@ -134,7 +134,7 @@ GEM
134
134
  net-protocol
135
135
  net-pop (0.1.2)
136
136
  net-protocol
137
- net-protocol (0.2.2)
137
+ net-protocol (0.3.0)
138
138
  timeout
139
139
  net-smtp (0.5.1)
140
140
  net-protocol
data/README.md CHANGED
@@ -1096,8 +1096,6 @@ The effect will be that the fields will be stacked together into nicely fit colu
1096
1096
 
1097
1097
  **If your customer is used to Excel, this feature will confuse them.**
1098
1098
 
1099
- Also, this feature will **probably not** be supported by the SORTING (not yet implemented). (You will be forced to choose between the two which I think makes sense.)
1100
-
1101
1099
  The layout builder works from right-to-left and starts with 12, the number of Bootstrap's columns.
1102
1100
 
1103
1101
  It reserves 2 columns for the default buttons. Then +1 additional column for **each magic button** you have specified.
@@ -2000,6 +1998,85 @@ _Notice that Stimulus requires object registration when used in a Node environme
2000
1998
  Instead, use the generator and the registry will be updated for you automatically.
2001
1999
 
2002
2000
 
2001
+ ## Sorting
2002
+
2003
+ #### `--sortable` (true of flagged; false otherwise; does not take an argument)
2004
+ To add sorting to your columns add `--sortable` to your build
2005
+ sortable fields are integers, floats, string, text, datetime, time, dates excluding anything that is a foreign key, not UUIDs
2006
+
2007
+ #### `--sort-fields=`
2008
+ List of field names (separated by commas)
2009
+ Leave off (do not specify) to sort by all sort-eligible fields
2010
+
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
+
2003
2080
  ## Attachments
2004
2081
 
2005
2082
  #### `--attachments=` Long form syntax with 1st and 2nd parameters
@@ -2167,8 +2244,10 @@ For Pagy version 9 or below
2167
2244
  4. add `include Pagy::Frontend` to ApplicationHelper
2168
2245
 
2169
2246
  For Pagy version 43 (there was a version jump)
2170
- *NOT YET COMPATIBLE WITH PAGY 43*
2171
- 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.
2172
2251
 
2173
2252
  ## "Thing" Label
2174
2253
 
@@ -2489,6 +2568,19 @@ These automatic pickups for partials are detected at build time. This means that
2489
2568
 
2490
2569
  # VERSION HISTORY
2491
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
+
2575
+ #### 2026-08-30 - v0.8
2576
+ - `--sortable` (true of flagged; false otherwise; does not take an argument)
2577
+ To add sorting to your columns add `--sortable` to your build
2578
+ sortable fields are integers, floats, string, text, datetime, time, dates excluding anything that is a foreign key, not UUIDs
2579
+ - `--sort-fields=`
2580
+ List of field names (separated by commas)
2581
+ Leave off (do not specify) to sort by all sort-eligible fields
2582
+
2583
+
2492
2584
  #### 2026-08-29 - v0.7.9
2493
2585
  -  Render float fields as type="number" step="any" for HTML5 validation; float fields should not be correctly validated on input
2494
2586
 
@@ -2523,12 +2615,13 @@ for example, if we have a `thing` that can belong (via parent_id and parent_type
2523
2615
 
2524
2616
  Hot glue wil convert the spaces to commas when writing the controller code.
2525
2617
 
2526
- A child controller to with a polymorphic parent:
2527
- - This special case assume that that the parent being build is not actually a polymoprh, it is a real object, but its children have foreign keys to it which are polymorphic.
2618
+ A child controller with a polymorphic parent:
2619
+
2620
+ - This special case assumes that that the parent being build is not actually a polymoprh, it is a real object, but its children have foreign keys to it which are polymorphic.
2528
2621
 
2529
- `--nested=abc(parent) `
2622
+ `--nested=abc(parent)`
2530
2623
 
2531
- Example: In my data model, targets have a polymorphic parent (parent_id and parent_type) and can belong to either Companies or Schools. Here, we are building the Companies view with a child to Targets, but notice for these targets we are using polymorphism and also using a controller prefix, so tha this child controller will be built as CompanyTargets. In the companies build, we downnest `company_targets(targets)` (`company_targets` is the name of the child controller, but it is acting on an object called `targets`, as seen in the downnest specification.)
2624
+ Example: In my data model, targets have a polymorphic parent (parent_id and parent_type) and can belong to either Companies or Schools. Here, we are building the Companies view with a child to Targets, but notice for these targets we are using polymorphism and also using a controller prefix, so that this child controller will be built as CompanyTargets. In the companies build, we downnest `company_targets(targets)` (`company_targets` is the name of the child controller, but it is acting on an object called `targets`, as seen in the downnest specification.)
2532
2625
 
2533
2626
  ```
2534
2627
  bin/rails generate hot_glue:scaffold Company --namespace='account_dashboard' --nested='account' --downnest='company_targets(targets)'
@@ -10,4 +10,36 @@ module HotGlueHelper
10
10
  def enum_to_collection_select(hash)
11
11
  hash.collect{|k,v| KVObject.new(key: k, value: v)}
12
12
  end
13
+
14
+ def sort_link(field, label)
15
+ field = field.to_s
16
+ active = params[:sort] == field
17
+ direction = active ? params[:direction] : nil
18
+
19
+ next_direction =
20
+ if !active
21
+ 'asc'
22
+ elsif direction == 'asc'
23
+ 'desc'
24
+ else
25
+ nil
26
+ end
27
+
28
+ new_params = request.query_parameters.except('sort', 'direction', 'page')
29
+ if next_direction
30
+ new_params['sort'] = field
31
+ new_params['direction'] = next_direction
32
+ end
33
+
34
+ arrow =
35
+ if active && direction == 'asc'
36
+ '&nbsp;&#9650;'
37
+ elsif active && direction == 'desc'
38
+ '&nbsp;&#9660;'
39
+ else
40
+ ''
41
+ end
42
+
43
+ link_to (label + arrow).html_safe, "#{request.path}?#{new_params.to_query}".chomp('?'), style: 'white-space: nowrap', 'data-turbo-action': 'advance'
44
+ end
13
45
  end
@@ -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
@@ -33,6 +33,10 @@ class AssociationField < Field
33
33
 
34
34
  end
35
35
 
36
+ def sortable?
37
+ false
38
+ end
39
+
36
40
  def assoc_name
37
41
  assoc
38
42
  end
@@ -52,6 +52,10 @@ class Field
52
52
  @name
53
53
  end
54
54
 
55
+ def sortable?
56
+ true
57
+ end
58
+
55
59
  def form_field_output
56
60
  raise "superclass must implement"
57
61
  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
16
+ :namespace, :controller_build_folder, :sortable, :sortable_fields,
17
+ :csv, :csv_fields
17
18
 
18
19
 
19
20
  def initialize(singular:, singular_class: ,
@@ -27,9 +28,15 @@ module HotGlue
27
28
  search_clear_button:, search_autosearch:, layout_object:,
28
29
  form_path: , stimmify: , stimmify_camel:, hidden_create:, hidden_update: ,
29
30
  invisible_create:, invisible_update: , plural: , phantom_search:,
30
- pagination_style:, namespace: nil, controller_build_folder: nil )
31
+ pagination_style:, namespace: nil, controller_build_folder: nil,
32
+ sortable: false, sortable_fields: [],
33
+ csv: true, csv_fields: [] )
31
34
 
32
35
 
36
+ @sortable = sortable
37
+ @sortable_fields = sortable_fields
38
+ @csv = csv
39
+ @csv_fields = csv_fields
33
40
  @form_path = form_path
34
41
  @search = search
35
42
  @search_fields = search_fields
@@ -106,7 +113,11 @@ module HotGlue
106
113
  "<div class='#{layout_strategy.column_classes_for_column_headings(size)} hg-heading-row heading--#{singular}--#{column.join("-")}' " + col_style + ">" +
107
114
  column.map(&:to_s).map{|col_name|
108
115
  unless col_name.starts_with?("**")
109
- the_output = "#{col_name.humanize}"
116
+ if @sortable && @sortable_fields.collect(&:to_s).include?(col_name)
117
+ the_output = "<%= sort_link(:#{col_name}, '#{col_name.humanize}') %>"
118
+ else
119
+ the_output = "#{col_name.humanize}"
120
+ end
110
121
  else
111
122
  the_output = ""
112
123
  end
@@ -34,7 +34,9 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
34
34
  :search_clear_button, :search_autosearch, :include_object_names,
35
35
  :stimmify, :stimmify_camel, :hidden_create, :hidden_update,
36
36
  :invisible_create, :invisible_update, :phantom_create_params,
37
- :phantom_update_params, :lazy, :back_link_to_parent, :polymorphic_parents
37
+ :phantom_update_params, :lazy, :back_link_to_parent, :polymorphic_parents,
38
+ :sortable, :sortable_fields,
39
+ :csv, :csv_fields
38
40
 
39
41
  # important: using an attr_accessor called :namespace indirectly causes a conflict with Rails class_name method
40
42
  # so we use namespace_value instead
@@ -144,6 +146,15 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
144
146
 
145
147
  class_option :phantom_search, default: nil
146
148
 
149
+ # SORT OPTIONS
150
+ class_option :sortable, type: :boolean, default: false
151
+ class_option :sort_fields, default: nil # comma separated whitelist; defaults to all sortable-type fields
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
+
147
158
 
148
159
 
149
160
  def initialize(*meta_args)
@@ -752,6 +763,48 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
752
763
  @columns_map[col] = this_column_object.field
753
764
  end
754
765
 
766
+ @sortable = options['sortable']
767
+
768
+ if @sortable
769
+ if options['sort_fields']
770
+ @sortable_fields = options['sort_fields'].split(',').collect(&:to_sym)
771
+ @sortable_fields.each do |field|
772
+ if !@columns_map[field]
773
+ raise "You specified a sort field for #{field} but that field is not in the list of columns"
774
+ elsif !@columns_map[field].sortable?
775
+ raise "You specified a sort field for #{field} but that field type cannot be sorted (e.g. an association or a UUID)"
776
+ end
777
+ end
778
+ else
779
+ @sortable_fields = @columns_map.select { |col, field| field.sortable? }.keys
780
+ end
781
+
782
+ warn_about_unindexed_sortable_fields
783
+ else
784
+ @sortable_fields = []
785
+ end
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
+
755
808
 
756
809
  @columns_map.each do |key, field|
757
810
  if field.is_a?(AssociationField)
@@ -928,7 +981,11 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
928
981
  phantom_search: @phantom_search,
929
982
  pagination_style: @pagination_style,
930
983
  namespace: @namespace,
931
- controller_build_folder: @controller_build_folder
984
+ controller_build_folder: @controller_build_folder,
985
+ sortable: @sortable,
986
+ sortable_fields: @sortable_fields,
987
+ csv: @csv,
988
+ csv_fields: @csv_fields
932
989
  )
933
990
  elsif @markup == "slim"
934
991
  raise(HotGlue::Error, "SLIM IS NOT IMPLEMENTED")
@@ -1826,6 +1883,10 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
1826
1883
  res << '_lazy_list'
1827
1884
  end
1828
1885
 
1886
+ if @csv && !@no_list
1887
+ res << '_export_button'
1888
+ end
1889
+
1829
1890
  res
1830
1891
  end
1831
1892
 
@@ -1997,37 +2058,82 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
1997
2058
  end
1998
2059
 
1999
2060
 
2061
+ def warn_about_unindexed_sortable_fields
2062
+ indexed_columns = @the_object.connection.indexes(@the_object.table_name).collect { |index| index.columns.first }
2063
+ unindexed = @sortable_fields.reject { |field| indexed_columns.include?(field.to_s) }
2064
+ return if unindexed.empty?
2065
+
2066
+ puts "********************"
2067
+ puts "WARNING: The following fields are sortable but have no database index, which may cause slow queries as the table grows: #{unindexed.join(', ')}"
2068
+ puts "Consider adding indexes, e.g.: " + unindexed.collect { |field| "add_index :#{plural}, :#{field}" }.join("; ")
2069
+ puts "********************"
2070
+ end
2071
+
2000
2072
  def load_all_code
2001
- # 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.
2002
2117
  res = +""
2003
2118
  if @search_fields
2004
- res << @search_fields.collect{ |field|
2119
+ search_field_assignments = @search_fields.collect{ |field|
2005
2120
  if !@columns_map[field.to_sym].load_all_query_statement.empty?
2006
2121
  @columns_map[field.to_sym].load_all_query_statement
2007
2122
  end
2008
- }.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?
2009
2125
  end
2010
2126
 
2011
- if pundit
2127
+ if base_override
2128
+ res << spaces(4) + "@#{ plural_name } = #{ base_override }"
2129
+ res << "\n"
2130
+ elsif pundit
2012
2131
  res << " @#{ plural_name } = policy_scope(#{ object_scope })#{record_scope}\n"
2013
2132
  else
2014
2133
  if !@self_auth
2015
2134
 
2016
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? }"
2017
2136
 
2018
- if @search_fields
2019
- res << @search_fields.collect{ |field|
2020
- wqs = @columns_map[field.to_sym].where_query_statement
2021
- if !wqs.empty?
2022
- "\n" + spaces(4) + "@#{ plural_name } = @#{ plural_name }#{ wqs } if #{field}_query"
2023
- end
2024
- }.compact.join
2025
- end
2026
-
2027
-
2028
-
2029
- # res << "\n @#{plural} = @#{plural}.page(params[:page])#{ '.per(per)' if @paginate_per_page_selector }"
2030
-
2031
2137
  elsif @nested_set[0] && @nested_set[0][:optional]
2032
2138
  res << "@#{ plural_name } = #{ class_name }.#{record_scope}.all"
2033
2139
  else
@@ -2068,7 +2174,17 @@ class HotGlue::ScaffoldGenerator < Erb::Generators::ScaffoldGenerator
2068
2174
  res << "\n"
2069
2175
  end
2070
2176
 
2177
+ if @sortable
2178
+ res << "\n"
2179
+ res << " if SORTABLE_FIELDS.include?(params[:sort]) && %w[asc desc].include?(params[:direction])\n"
2180
+ res << " @#{plural} = @#{plural}.order(params[:sort] => params[:direction].to_sym)\n"
2181
+ res << " end"
2182
+ end
2183
+ res
2184
+ end
2071
2185
 
2186
+ def load_all_pagination_code
2187
+ res = +""
2072
2188
  if @pagination_style == "kaminari"
2073
2189
  res << " @#{plural} = @#{plural}.page(params[:page])#{ ".per(per)" if @paginate_per_page_selector }"
2074
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? %>
@@ -77,14 +78,33 @@ class <%= controller_class_name %> < <%= controller_descends_from %>
77
78
  params[:per] || 10
78
79
  end<% end %>
79
80
  <% unless @no_list %>
80
- def load_all_<%= plural %><% if @search == "set" %>
81
+ <% if @sortable %> SORTABLE_FIELDS = %w[<%= @sortable_fields.join(' ') %>].freeze
82
+ <% end %><% if @csv %> EXPORTABLE_FIELDS = %w[<%= @csv_fields.join(' ') %>].freeze
83
+ <% end %>
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" %>
81
99
  @q = params[:q] || <%= search_default %> <% end %>
82
100
  <%= load_all_code %>
83
- end
101
+ end<% end %>
84
102
 
85
103
  def index
86
104
  load_all_<%= plural %><% if @search_fields %>
87
- <%= @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 %>
88
108
  <% if @pundit %><% if @pundit && !@pundit_policy_override %>
89
109
  authorize @<%= plural_name %><% elsif @pundit && @pundit_policy_override %>
90
110
  skip_authorization
@@ -94,7 +114,29 @@ class <%= controller_class_name %> < <%= controller_descends_from %>
94
114
  flash[:alert] = 'You are not authorized to perform this action.'
95
115
  render 'layouts/error'<% end %>
96
116
  end<% end %>
97
-
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 %>
98
140
  <% if create_action %> def new<% if @object_owner_sym %>
99
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? %>
100
142
  @<%= singular_name %> = <%= class_name %>.new({}.merge(<%= @nested_set.last[:singular] %> ? {<%= @object_owner_sym %>: <%= @object_owner_eval %>} : {}))<% else %>
@@ -322,6 +364,10 @@ class <%= controller_class_name %> < <%= controller_descends_from %>
322
364
  def namespace
323
365
  <% if @namespace %>'<%= @namespace %>/'<% else %><% end %>
324
366
  end
325
- 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
326
372
 
327
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.7.9'
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.7.9
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-29 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