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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +76 -2
- data/lib/generators/hot_glue/csv_export_install_generator.rb +100 -0
- data/lib/generators/hot_glue/layout_strategy/base.rb +1 -0
- data/lib/generators/hot_glue/layout_strategy/bootstrap.rb +4 -0
- data/lib/generators/hot_glue/layout_strategy/hot_glue.rb +4 -0
- data/lib/generators/hot_glue/layout_strategy/tailwind.rb +1 -0
- data/lib/generators/hot_glue/markup_templates/erb.rb +6 -2
- data/lib/generators/hot_glue/scaffold_generator.rb +90 -19
- data/lib/generators/hot_glue/templates/controller.rb.erb +50 -6
- data/lib/generators/hot_glue/templates/csv_export/_failed.erb +5 -0
- data/lib/generators/hot_glue/templates/csv_export/_pending.erb +6 -0
- data/lib/generators/hot_glue/templates/csv_export/_ready.erb +6 -0
- data/lib/generators/hot_glue/templates/csv_export/auto_download_controller.js +11 -0
- data/lib/generators/hot_glue/templates/csv_export/build_csv_job.rb +36 -0
- data/lib/generators/hot_glue/templates/csv_export/csv_constructor.rb +68 -0
- data/lib/generators/hot_glue/templates/csv_export/csv_request.rb +10 -0
- data/lib/generators/hot_glue/templates/csv_export/csv_requests_controller.rb +8 -0
- data/lib/generators/hot_glue/templates/csv_export/destroy_csv_request_job.rb +5 -0
- data/lib/generators/hot_glue/templates/erb/_export_button.erb +9 -0
- data/lib/generators/hot_glue/templates/erb/_list.erb +12 -2
- data/lib/hotglue/version.rb +1 -1
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b7fb9cf9f9337373e86732ff6db9b2904de11b9d00450f78d44235820e9bdba
|
|
4
|
+
data.tar.gz: 7213c6c5680a3390559944d2e390b913269d9fee5c016db358f5ac64f6e7c1b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 759e7b6343a5ed0457b13cdc039732ada8187ebaf27e9da04cf8203130861210bd5dccb50795712fe43ae68e7e51f8eeb2a6afb55d3cfcf2a45533d65bee20a7
|
|
7
|
+
data.tar.gz: 841e4b699b8d5f8b416618a768fc58f9748c6b1e1c1e90337a92e55ced89893b872a4ef883e16bcd6c555685caa42e68e8348b295a001d40503e2ae92dd1a7df
|
data/Gemfile.lock
CHANGED
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
|
-
|
|
2180
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,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,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
|
-
|
|
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
|
-
|
|
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 %>
|
data/lib/hotglue/version.rb
CHANGED
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.
|
|
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-
|
|
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
|