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.
- checksums.yaml +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +101 -8
- data/app/helpers/hot_glue_helper.rb +32 -0
- data/lib/generators/hot_glue/csv_export_install_generator.rb +100 -0
- data/lib/generators/hot_glue/fields/association_field.rb +4 -0
- data/lib/generators/hot_glue/fields/field.rb +4 -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 +14 -3
- data/lib/generators/hot_glue/scaffold_generator.rb +135 -19
- data/lib/generators/hot_glue/templates/controller.rb.erb +52 -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
hot-glue (0.
|
|
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.
|
|
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
|
-
|
|
2171
|
-
|
|
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
|
|
2527
|
-
|
|
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
|
|
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
|
+
' ▲'
|
|
37
|
+
elsif active && direction == 'desc'
|
|
38
|
+
' ▼'
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|