hibiki_rails 0.9.0 → 0.10.0
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/CHANGELOG.md +76 -0
- data/lib/generators/hibiki/rails/css_variant.rb +29 -2
- data/lib/generators/hibiki/rails/extras_threading.rb +55 -0
- data/lib/generators/hibiki/rails/multiselect/multiselect_generator.rb +2 -0
- data/lib/generators/hibiki/rails/multiselect_injections.rb +2 -39
- data/lib/generators/hibiki/rails/nested/USAGE +9 -4
- data/lib/generators/hibiki/rails/nested/nested_generator.rb +23 -4
- data/lib/generators/hibiki/rails/nested_helpers.rb +68 -15
- data/lib/generators/hibiki/rails/upload_field/USAGE +46 -0
- data/lib/generators/hibiki/rails/upload_field/templates/_upload.html.erb.tt +38 -0
- data/lib/generators/hibiki/rails/upload_field/templates/_upload_many.html.erb.tt +47 -0
- data/lib/generators/hibiki/rails/upload_field/templates/concern.rb.tt +133 -0
- data/lib/generators/hibiki/rails/upload_field/templates/concern_many.rb.tt +160 -0
- data/lib/generators/hibiki/rails/upload_field/templates/upload_component.rb.tt +56 -0
- data/lib/generators/hibiki/rails/upload_field/templates/upload_field_controller.js.tt +41 -0
- data/lib/generators/hibiki/rails/upload_field/templates/upload_many_component.rb.tt +64 -0
- data/lib/generators/hibiki/rails/upload_field/upload_field_generator.rb +200 -0
- data/lib/generators/hibiki/rails/upload_field_helpers.rb +219 -0
- data/lib/generators/hibiki/rails/upload_field_injections.rb +501 -0
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8dc32112018a236bbd193028d2fe6a0c0650afd04cf8e6de5f26d84bed7de97a
|
|
4
|
+
data.tar.gz: 15fcf11847178df9f355279f7666b064a557ee5ebd0647f24373aeb638fb0131
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf0c15e56804599427b2c9ee2e323118ed2dc6decb1862c1765544535313cf49587997375370cfa335db4b2a32cfb8b33dae6f81ffa8870cf73c99af418cc098
|
|
7
|
+
data.tar.gz: 7dd41bb7197cc96a93a9b878ab41ee02b33a18830ecdb8f8926193529cc2c890ca6336eaca1c9eb71f0963262251d07be77895dda03cfd954ae4483a7968fdf6
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,82 @@ The gem and the npm package are released in lockstep and share these version
|
|
|
4
4
|
numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
|
|
5
5
|
so importmap and bundler apps always resolve identical client code.
|
|
6
6
|
|
|
7
|
+
## 0.10.0 — 2026-08-22
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
**`hibiki:rails:upload_field` — a `has_one_attached` upload on both edit
|
|
12
|
+
surfaces.** `bin/rails g hibiki:rails:upload_field Album cover` extends a
|
|
13
|
+
hibiki:rails scaffold: the classic form gains a direct-upload file field plus
|
|
14
|
+
a remove checkbox (a virtual `remove_cover` attribute whose purge yields to a
|
|
15
|
+
same-submit upload), and the inline channel form gains a nameless file input
|
|
16
|
+
that direct-uploads on change and hands only the signed blob id to the graph.
|
|
17
|
+
The pending state lives in the channel (a generated concern under
|
|
18
|
+
`app/channels/concerns`, one `include` in the channel), keyed by the form's
|
|
19
|
+
dom, so it survives repaints and the edit row and inline create form stay
|
|
20
|
+
independent; attach/purge happens after a successful commit. Both channel
|
|
21
|
+
queries are preloaded with `with_attached_cover`, `:cover`/`:remove_cover`
|
|
22
|
+
join the permitted params, and one shared
|
|
23
|
+
`app/javascript/controllers/upload_field_controller.js` is emitted per app —
|
|
24
|
+
parameterized by Stimulus values, so a second attachment reuses it. One
|
|
25
|
+
attachment per run; `has_one_attached` only.
|
|
26
|
+
|
|
27
|
+
**`--accept` — non-image formats.** `--accept=pdf,doc` narrows both file
|
|
28
|
+
inputs (image by default; tokens `image`, `audio`, `video`, `pdf`, `csv`,
|
|
29
|
+
`text`, `doc`, `xls`, `ppt`, plus any raw `type/subtype` or `.ext` passed
|
|
30
|
+
through; an unknown token refuses before anything is written). Every display
|
|
31
|
+
site — the inline form, the classic form, and the row on the index and show
|
|
32
|
+
page — branches per blob at render time on `variable?`: an image gets its
|
|
33
|
+
variant thumbnail, anything else its filename and size, with a
|
|
34
|
+
`rails_blob_path` link on the row. That is one template for every accept
|
|
35
|
+
list, so mixed lists work and a wrong-typed file past the advisory `accept`
|
|
36
|
+
no longer breaks the index at `variant`. The image_processing notice fires
|
|
37
|
+
only when an image type is accepted. The accept list stays advisory — the
|
|
38
|
+
app owns any server-side content-type validation.
|
|
39
|
+
|
|
40
|
+
**`--many` — `has_many_attached` galleries.** `bin/rails g
|
|
41
|
+
hibiki:rails:upload_field Album photos --many` (implied when the model
|
|
42
|
+
already declares `has_many_attached :photos`; `--many` against an existing
|
|
43
|
+
`has_one_attached` refuses) generates the gallery shape on both surfaces.
|
|
44
|
+
The inline form's input takes several files — the shared
|
|
45
|
+
`upload_field_controller.js` now uploads one file after another, so a
|
|
46
|
+
gallery's pending list keeps the pick order; a single-file input behaves
|
|
47
|
+
as before, and a many-mode run refreshes a copy from before the loop. Each
|
|
48
|
+
attached file carries a graph-owned ✕ (toggle: ✕ marks, Undo unmarks) and
|
|
49
|
+
each pending file its own badge with a ✕ to drop it; the pending state per
|
|
50
|
+
form dom is `{ adds:, removes: }`, applied after a successful commit by
|
|
51
|
+
APPENDING through `attach` and purging the marked ids — the collection is
|
|
52
|
+
never assigned, which since Rails 7.1 replaces it. The classic form gets
|
|
53
|
+
the same append/remove split as two virtual model attributes,
|
|
54
|
+
`add_photos` (a `multiple` direct-upload field, attached in `before_save`)
|
|
55
|
+
and `remove_photo_ids` (a checkbox per attached file, purged in
|
|
56
|
+
`after_save`), riding `params.expect` as a braced group. The row and show
|
|
57
|
+
page render the gallery with the same per-blob thumbnail-or-filename
|
|
58
|
+
branch, so `--many --accept=pdf` is a document list. Two css tokens
|
|
59
|
+
(`gallery_item`, `upload_status_list`).
|
|
60
|
+
|
|
61
|
+
The packaged client did not change — the emitted Stimulus controller imports
|
|
62
|
+
`@rails/activestorage` from the app's own dependencies.
|
|
63
|
+
|
|
64
|
+
## 0.9.1 — 2026-08-18
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
**`hibiki:rails:nested` creates a missing child model from the field list.**
|
|
69
|
+
`bin/rails g hibiki:rails:nested Song Credit role:string position:integer`
|
|
70
|
+
no longer refuses when `Credit` doesn't exist: the generator invokes
|
|
71
|
+
`active_record:model` with `song:references` prepended (kept where you put it
|
|
72
|
+
if you typed it yourself), then wires the edge off the argument list exactly
|
|
73
|
+
as if the field list had narrowed an existing schema. Run
|
|
74
|
+
`bin/rails db:migrate` before using the fieldset. A created child is never
|
|
75
|
+
ordered silently — put `position` in the field list, or pass
|
|
76
|
+
`--position=COLUMN` and the column joins the create-table migration. With an
|
|
77
|
+
existing child model nothing changes, and a missing model with no field list
|
|
78
|
+
still refuses, now suggesting the inline syntax.
|
|
79
|
+
|
|
80
|
+
The runtime and the packaged client did not change; the npm 0.9.1 exists only
|
|
81
|
+
to keep the lockstep rule.
|
|
82
|
+
|
|
7
83
|
## 0.9.0 — 2026-08-17
|
|
8
84
|
|
|
9
85
|
### Added
|
|
@@ -121,7 +121,25 @@ module Hibiki
|
|
|
121
121
|
fieldset_legend: "fieldset-legend px-2",
|
|
122
122
|
nested_row: "border border-base-300 rounded-box p-3 mb-2 space-y-2",
|
|
123
123
|
btn_ghost_sm: "btn btn-sm btn-ghost",
|
|
124
|
-
btn_outline_sm: "btn btn-sm btn-outline"
|
|
124
|
+
btn_outline_sm: "btn btn-sm btn-outline",
|
|
125
|
+
|
|
126
|
+
# The upload field (hibiki:rails:upload_field): the attachment row
|
|
127
|
+
# in both forms, its thumbnail, the non-image filename link on the
|
|
128
|
+
# row, and the pending badge on its own full-width line under the
|
|
129
|
+
# controls (the row wraps, so a long filename never pushes Remove
|
|
130
|
+
# off-screen). upload_row, upload_status and thumb are plain
|
|
131
|
+
# utilities, so the tailwind variant inherits them.
|
|
132
|
+
upload_row: "flex flex-wrap items-center gap-3",
|
|
133
|
+
upload_status: "w-full",
|
|
134
|
+
# --many: one chip per attached file (thumb + ✕) and the pending
|
|
135
|
+
# list, which wraps like the row.
|
|
136
|
+
gallery_item: "inline-flex items-center gap-1",
|
|
137
|
+
upload_status_list: "w-full flex flex-wrap gap-2",
|
|
138
|
+
thumb: "rounded",
|
|
139
|
+
file_link: "link link-hover",
|
|
140
|
+
file_input: "file-input w-full",
|
|
141
|
+
file_input_sm: "file-input file-input-sm",
|
|
142
|
+
badge_info: "badge badge-info"
|
|
125
143
|
}.freeze
|
|
126
144
|
|
|
127
145
|
# DaisyUI is a plugin over Tailwind, and the merge says so literally:
|
|
@@ -179,7 +197,16 @@ module Hibiki
|
|
|
179
197
|
fieldset_legend: "px-2 text-sm font-medium text-gray-700",
|
|
180
198
|
nested_row: "rounded-md border border-gray-200 p-3 mb-2 space-y-2",
|
|
181
199
|
btn_ghost_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1"),
|
|
182
|
-
btn_outline_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1")
|
|
200
|
+
btn_outline_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1"),
|
|
201
|
+
file_link: "text-indigo-600 hover:underline",
|
|
202
|
+
file_input: "block w-full text-sm text-gray-700 file:mr-3 file:rounded-md " \
|
|
203
|
+
"file:border-0 file:bg-gray-100 file:px-3 file:py-2 file:text-sm " \
|
|
204
|
+
"file:font-medium file:text-gray-700 hover:file:bg-gray-200",
|
|
205
|
+
file_input_sm: "block text-xs text-gray-700 file:mr-2 file:rounded-md " \
|
|
206
|
+
"file:border-0 file:bg-gray-100 file:px-2 file:py-1 file:text-xs " \
|
|
207
|
+
"file:font-medium file:text-gray-700 hover:file:bg-gray-200",
|
|
208
|
+
badge_info: "inline-flex items-center rounded-full bg-sky-100 px-2 py-1 " \
|
|
209
|
+
"text-xs font-medium text-sky-800"
|
|
183
210
|
).freeze
|
|
184
211
|
|
|
185
212
|
# Every lookup misses, so every class argument is omitted entirely.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
module Rails
|
|
5
|
+
module Generators
|
|
6
|
+
# Threading the extras: hash through a pre-0.7.0 scaffold's partial
|
|
7
|
+
# chain. Scaffolds since 0.7.0 emit it themselves (list -> row ->
|
|
8
|
+
# row_form, untouched), so on those this is a no-op probe; older
|
|
9
|
+
# output gets the header/keyword plus the two render calls retrofitted,
|
|
10
|
+
# anchored on text every scaffold emitted verbatim.
|
|
11
|
+
#
|
|
12
|
+
# Shared by every add-on generator that rides extras:
|
|
13
|
+
# (hibiki:rails:multiselect, hibiki:rails:upload_field). Depends on the
|
|
14
|
+
# generator for view_path/row_partial/row_form_partial/phlex?.
|
|
15
|
+
module ExtrasThreading
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def thread_extras_through_partials
|
|
19
|
+
return if wired?(extras_probe_path, "extras")
|
|
20
|
+
|
|
21
|
+
say_status :compat, "pre-0.7 scaffold — threading extras: through the partials", :yellow
|
|
22
|
+
phlex? ? thread_extras_phlex : thread_extras_erb
|
|
23
|
+
return if wired?(extras_probe_path, "extras")
|
|
24
|
+
|
|
25
|
+
say_status :warn, "could not thread extras: automatically. Pass an extras: {} local " \
|
|
26
|
+
"from the list partial down to #{row_form_partial} yourself.", :yellow
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def extras_probe_path = phlex? ? view_path("row_form.rb") : view_path("_#{row_form_partial}.html.erb")
|
|
30
|
+
|
|
31
|
+
def thread_extras_erb
|
|
32
|
+
[view_path("_list.html.erb"), view_path("_#{row_partial}.html.erb"),
|
|
33
|
+
view_path("_#{row_form_partial}.html.erb")].each do |path|
|
|
34
|
+
# /m: the row form's locals header wraps onto a second line.
|
|
35
|
+
gsub_file path, /^(<%# locals: \(.*?)\) -%>/m, '\1, extras: {}) -%>', verbose: false
|
|
36
|
+
end
|
|
37
|
+
# `form: form` appears exactly once per file, inside the one render
|
|
38
|
+
# call that must pass extras on.
|
|
39
|
+
gsub_file view_path("_list.html.erb"), "form: form", "form: form, extras: extras"
|
|
40
|
+
gsub_file view_path("_#{row_partial}.html.erb"), "form: form", "form: form, extras: extras"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def thread_extras_phlex
|
|
44
|
+
%w[list.rb row.rb row_form.rb].each do |basename|
|
|
45
|
+
path = view_path(basename)
|
|
46
|
+
gsub_file path, /(def initialize\([^)]*)\)/, '\1, extras: {})', verbose: false
|
|
47
|
+
gsub_file path, /^(\s*)@form = form$/, "\\1@form = form\n\\1@extras = extras", verbose: false
|
|
48
|
+
end
|
|
49
|
+
gsub_file view_path("list.rb"), "form: @form", "form: @form, extras: @extras"
|
|
50
|
+
gsub_file view_path("row.rb"), "form: @form", "form: @form, extras: @extras"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -10,6 +10,7 @@ require_relative "../scaffold_phlex_helpers"
|
|
|
10
10
|
require_relative "../scaffold_model_injection"
|
|
11
11
|
require_relative "../scaffold_schema"
|
|
12
12
|
require_relative "../css_variant"
|
|
13
|
+
require_relative "../extras_threading"
|
|
13
14
|
require_relative "../multiselect_helpers"
|
|
14
15
|
require_relative "../multiselect_injections"
|
|
15
16
|
|
|
@@ -33,6 +34,7 @@ module Hibiki
|
|
|
33
34
|
include ScaffoldViewHelpers
|
|
34
35
|
include ScaffoldPhlexHelpers
|
|
35
36
|
include ScaffoldModelInjection
|
|
37
|
+
include ExtrasThreading
|
|
36
38
|
include MultiselectHelpers
|
|
37
39
|
include MultiselectInjections
|
|
38
40
|
|
|
@@ -201,45 +201,8 @@ module Hibiki
|
|
|
201
201
|
RUBY
|
|
202
202
|
end
|
|
203
203
|
|
|
204
|
-
#
|
|
205
|
-
|
|
206
|
-
# Older scaffold output has no extras: hash to ride on, so it is
|
|
207
|
-
# threaded through here — the header/keyword plus the two render
|
|
208
|
-
# calls, anchored on text every scaffold emitted verbatim.
|
|
209
|
-
def thread_extras_through_partials
|
|
210
|
-
return if wired?(extras_probe_path, "extras")
|
|
211
|
-
|
|
212
|
-
say_status :compat, "pre-0.7 scaffold — threading extras: through the partials", :yellow
|
|
213
|
-
phlex? ? thread_extras_phlex : thread_extras_erb
|
|
214
|
-
return if wired?(extras_probe_path, "extras")
|
|
215
|
-
|
|
216
|
-
say_status :warn, "could not thread extras: automatically. Pass an extras: {} local " \
|
|
217
|
-
"from the list partial down to #{row_form_partial} yourself.", :yellow
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def extras_probe_path = phlex? ? view_path("row_form.rb") : view_path("_#{row_form_partial}.html.erb")
|
|
221
|
-
|
|
222
|
-
def thread_extras_erb
|
|
223
|
-
[view_path("_list.html.erb"), view_path("_#{row_partial}.html.erb"),
|
|
224
|
-
view_path("_#{row_form_partial}.html.erb")].each do |path|
|
|
225
|
-
# /m: the row form's locals header wraps onto a second line.
|
|
226
|
-
gsub_file path, /^(<%# locals: \(.*?)\) -%>/m, '\1, extras: {}) -%>', verbose: false
|
|
227
|
-
end
|
|
228
|
-
# `form: form` appears exactly once per file, inside the one render
|
|
229
|
-
# call that must pass extras on.
|
|
230
|
-
gsub_file view_path("_list.html.erb"), "form: form", "form: form, extras: extras"
|
|
231
|
-
gsub_file view_path("_#{row_partial}.html.erb"), "form: form", "form: form, extras: extras"
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def thread_extras_phlex
|
|
235
|
-
%w[list.rb row.rb row_form.rb].each do |basename|
|
|
236
|
-
path = view_path(basename)
|
|
237
|
-
gsub_file path, /(def initialize\([^)]*)\)/, '\1, extras: {})', verbose: false
|
|
238
|
-
gsub_file path, /^(\s*)@form = form$/, "\\1@form = form\n\\1@extras = extras", verbose: false
|
|
239
|
-
end
|
|
240
|
-
gsub_file view_path("list.rb"), "form: @form", "form: @form, extras: @extras"
|
|
241
|
-
gsub_file view_path("row.rb"), "form: @form", "form: @form, extras: @extras"
|
|
242
|
-
end
|
|
204
|
+
# The pre-0.7.0 extras threading moved to ExtrasThreading (shared with
|
|
205
|
+
# hibiki:rails:upload_field).
|
|
243
206
|
end
|
|
244
207
|
end
|
|
245
208
|
end
|
|
@@ -8,9 +8,11 @@ Description:
|
|
|
8
8
|
fallback in the full-page form, and the controller's params.expect
|
|
9
9
|
double-array group.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
the
|
|
11
|
+
When the Child model exists (migrated, with belongs_to :<parent>), an
|
|
12
|
+
optional field list narrows or orders its form fields; the facts stay
|
|
13
|
+
the schema's. When it doesn't, the field list creates it — model and
|
|
14
|
+
migration via active_record:model, <parent>:references prepended for
|
|
15
|
+
you. Run bin/rails db:migrate before using the fieldset.
|
|
14
16
|
|
|
15
17
|
Depth is composition: run once per edge. A run whose Parent is itself a
|
|
16
18
|
nested child (its _<parent>_fields partial exists) nests into that
|
|
@@ -20,7 +22,8 @@ Description:
|
|
|
20
22
|
the fieldset ordered — the has_many gains an order scope, the parent
|
|
21
23
|
form stamps positions from array order, and rows get ↑/↓ buttons. The
|
|
22
24
|
named column is added by migration when missing; --skip-position opts
|
|
23
|
-
out entirely.
|
|
25
|
+
out entirely. A created child is never ordered silently: put position
|
|
26
|
+
in the field list, or pass --position.
|
|
24
27
|
|
|
25
28
|
Examples:
|
|
26
29
|
bin/rails g hibiki:rails:nested Song Credit
|
|
@@ -28,3 +31,5 @@ Examples:
|
|
|
28
31
|
bin/rails g hibiki:rails:nested Credit Contribution
|
|
29
32
|
|
|
30
33
|
bin/rails g hibiki:rails:nested Song Credit artist:references role:string --position=rank
|
|
34
|
+
|
|
35
|
+
bin/rails g hibiki:rails:nested Song Credit role:string position:integer
|
|
@@ -22,6 +22,8 @@ module Hibiki
|
|
|
22
22
|
# generic Hibiki::Rails::NestedActions), reactive_nested on the parent
|
|
23
23
|
# form, the model wiring, the classic fields_for fallback in the
|
|
24
24
|
# full-page form, and the controller's params.expect double-array group.
|
|
25
|
+
# A missing child model is created from the field list (model +
|
|
26
|
+
# migration via active_record:model, the parent reference prepended).
|
|
25
27
|
#
|
|
26
28
|
# Depth is composition — run once per edge. A run whose parent is
|
|
27
29
|
# itself a nested child injects into the parent's own fields partial
|
|
@@ -62,6 +64,14 @@ module Hibiki
|
|
|
62
64
|
warn_without_phlex_rails
|
|
63
65
|
end
|
|
64
66
|
|
|
67
|
+
def create_child_model
|
|
68
|
+
return unless create_child?
|
|
69
|
+
|
|
70
|
+
# to_argv, never GeneratedAttribute#to_s: `title:string!` round-trips
|
|
71
|
+
# through to_s as "title:string{null}", which .parse then rejects.
|
|
72
|
+
invoke "active_record:model", child_model_argv
|
|
73
|
+
end
|
|
74
|
+
|
|
65
75
|
def create_child_form
|
|
66
76
|
template "child_form.rb.tt", child_form_path
|
|
67
77
|
end
|
|
@@ -111,10 +121,7 @@ module Hibiki
|
|
|
111
121
|
end
|
|
112
122
|
|
|
113
123
|
def post_install
|
|
114
|
-
|
|
115
|
-
say_status :migrate, "#{child_table_name}.#{position_column} was added — " \
|
|
116
|
-
"run bin/rails db:migrate before using the fieldset.", :yellow
|
|
117
|
-
end
|
|
124
|
+
migrate_notice
|
|
118
125
|
schema.belongs_tos.each do |column|
|
|
119
126
|
say_status :assoc, "using #{column.association_class_name}##{column.label_column} as " \
|
|
120
127
|
"the #{column.human_name.downcase} option label — edit " \
|
|
@@ -124,6 +131,18 @@ module Hibiki
|
|
|
124
131
|
|
|
125
132
|
private
|
|
126
133
|
|
|
134
|
+
# Never both: a created child's ordering column rides its
|
|
135
|
+
# create-table migration.
|
|
136
|
+
def migrate_notice
|
|
137
|
+
if create_child?
|
|
138
|
+
say_status :migrate, "#{child_class_name} was generated — " \
|
|
139
|
+
"run bin/rails db:migrate before using the fieldset.", :yellow
|
|
140
|
+
elsif @position_migration
|
|
141
|
+
say_status :migrate, "#{child_table_name}.#{position_column} was added — " \
|
|
142
|
+
"run bin/rails db:migrate before using the fieldset.", :yellow
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
127
146
|
# The scaffold's own view layer, read from what it left on disk; an
|
|
128
147
|
# explicit --phlex wins. Deep mode reads it off the parent's fields
|
|
129
148
|
# partial instead of the row form.
|
|
@@ -36,8 +36,19 @@ module Hibiki
|
|
|
36
36
|
|
|
37
37
|
def child_form_class_name = "#{child_class_name}Form"
|
|
38
38
|
def child_form_path = File.join("app/forms", "#{child_class_name.underscore}_form.rb")
|
|
39
|
+
def child_model_path = File.join("app/models", "#{child_class_name.underscore}.rb")
|
|
39
40
|
def child_table_name = child_class_name.tableize.tr("/", "_")
|
|
40
41
|
|
|
42
|
+
# Create mode: no constant, no model file, and a field list to build
|
|
43
|
+
# from. Memoized before the invoke writes the file — later calls must
|
|
44
|
+
# keep the preflight answer.
|
|
45
|
+
def create_child?
|
|
46
|
+
return @create_child if defined?(@create_child)
|
|
47
|
+
|
|
48
|
+
@create_child = attributes.any? && child_class_name.safe_constantize.nil? &&
|
|
49
|
+
!exists?(child_model_path)
|
|
50
|
+
end
|
|
51
|
+
|
|
41
52
|
def child_class
|
|
42
53
|
@child_class ||= begin
|
|
43
54
|
klass = child_class_name.safe_constantize
|
|
@@ -53,9 +64,13 @@ module Hibiki
|
|
|
53
64
|
end
|
|
54
65
|
|
|
55
66
|
def missing_child_message
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
67
|
+
if exists?(child_model_path)
|
|
68
|
+
"#{child_class_name} has no table yet. Run bin/rails db:migrate first."
|
|
69
|
+
else
|
|
70
|
+
"No model #{child_class_name}. Pass its fields and the generator creates it " \
|
|
71
|
+
"(#{singular_name}:references is added for you):\n " \
|
|
72
|
+
"bin/rails g hibiki:rails:nested #{name} #{child} role:string"
|
|
73
|
+
end
|
|
59
74
|
end
|
|
60
75
|
|
|
61
76
|
# The child's belongs_to back to the parent. Required: the has_many's
|
|
@@ -92,9 +107,14 @@ module Hibiki
|
|
|
92
107
|
|
|
93
108
|
# Everything that can refuse, before anything is written.
|
|
94
109
|
def check_wireable!
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
110
|
+
if create_child?
|
|
111
|
+
check_parent_wireable!
|
|
112
|
+
check_creatable!
|
|
113
|
+
else
|
|
114
|
+
child_class
|
|
115
|
+
parent_reflection
|
|
116
|
+
check_parent_wireable!
|
|
117
|
+
end
|
|
98
118
|
check_editable_columns!
|
|
99
119
|
end
|
|
100
120
|
|
|
@@ -119,10 +139,21 @@ module Hibiki
|
|
|
119
139
|
|
|
120
140
|
raise ::Rails::Generators::Error,
|
|
121
141
|
"#{child_class_name} has no editable columns beyond " \
|
|
122
|
-
"#{
|
|
142
|
+
"#{child_foreign_key}#{" and #{position_column}" if ordered?} — " \
|
|
123
143
|
"nothing to build a fieldset from."
|
|
124
144
|
end
|
|
125
145
|
|
|
146
|
+
# A bare <parent>_id column generates no belongs_to, and the has_many's
|
|
147
|
+
# inverse_of and accepts_nested_attributes_for both stand on it.
|
|
148
|
+
def check_creatable!
|
|
149
|
+
fk = "#{singular_name}_id"
|
|
150
|
+
return if attributes.none? { !it.reference? && it.name == fk }
|
|
151
|
+
|
|
152
|
+
raise ::Rails::Generators::Error,
|
|
153
|
+
"Spell the parent column #{singular_name}:references — a bare #{fk} " \
|
|
154
|
+
"generates no belongs_to, and the nested wiring needs one."
|
|
155
|
+
end
|
|
156
|
+
|
|
126
157
|
# ---- the owning resource ---------------------------------------------
|
|
127
158
|
#
|
|
128
159
|
# Root mode: the parent itself. Deep mode: derived from where the
|
|
@@ -182,15 +213,36 @@ module Hibiki
|
|
|
182
213
|
|
|
183
214
|
# ---- the child schema ------------------------------------------------
|
|
184
215
|
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
216
|
+
# Introspection when the child model exists (the scaffold's 3.6 merge
|
|
217
|
+
# when a field list narrows or orders it); the argument-only path when
|
|
218
|
+
# this run is creating the model — the fresh constant is not loadable
|
|
219
|
+
# in-process, so nothing may read it. The parent's foreign key and the
|
|
220
|
+
# ordering column never become form fields: the association assigns
|
|
221
|
+
# one, the parent form stamps the other.
|
|
222
|
+
|
|
223
|
+
def introspectable_child = create_child? ? nil : child_class
|
|
224
|
+
|
|
225
|
+
# Argument-derived in create mode: there is no reflection to ask yet.
|
|
226
|
+
def child_foreign_key
|
|
227
|
+
@child_foreign_key ||= create_child? ? "#{singular_name}_id" : parent_reflection.foreign_key.to_s
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# What create_child_model hands to active_record:model: the parent
|
|
231
|
+
# reference prepended unless the field list already spells it, the
|
|
232
|
+
# --position column appended when ordering asked for one the list
|
|
233
|
+
# doesn't carry.
|
|
234
|
+
def child_model_argv
|
|
235
|
+
parent_ref = ("#{singular_name}:references" unless parent_ref_argument?)
|
|
236
|
+
position_arg = ("#{position_column}:integer" if ordered? && options[:position].present? &&
|
|
237
|
+
attributes.none? { it.name == position_column })
|
|
238
|
+
[child_class_name, *parent_ref, *attributes.map { to_argv(it) }, *position_arg]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def parent_ref_argument? = attributes.any? { it.reference? && it.name == singular_name }
|
|
190
242
|
|
|
191
243
|
def full_schema
|
|
192
244
|
@full_schema ||= if attributes.any?
|
|
193
|
-
ScaffoldSchema.from_attributes(attributes, model:
|
|
245
|
+
ScaffoldSchema.from_attributes(attributes, model: introspectable_child)
|
|
194
246
|
else
|
|
195
247
|
ScaffoldSchema.from_model(child_class)
|
|
196
248
|
end
|
|
@@ -206,7 +258,7 @@ module Hibiki
|
|
|
206
258
|
|
|
207
259
|
def form_excluded?(column)
|
|
208
260
|
return true if column.belongs_to? && column.association_name == singular_name.to_sym
|
|
209
|
-
return true if column.name.to_s ==
|
|
261
|
+
return true if column.name.to_s == child_foreign_key
|
|
210
262
|
|
|
211
263
|
ordered? && column.name.to_s == position_column
|
|
212
264
|
end
|
|
@@ -218,6 +270,7 @@ module Hibiki
|
|
|
218
270
|
|
|
219
271
|
@ordered = if options[:skip_position] then false
|
|
220
272
|
elsif options[:position].present? then true
|
|
273
|
+
elsif create_child? then attributes.any? { it.name == "position" }
|
|
221
274
|
else child_class.column_names.include?("position")
|
|
222
275
|
end
|
|
223
276
|
end
|
|
@@ -225,7 +278,7 @@ module Hibiki
|
|
|
225
278
|
def position_column = @position_column ||= options[:position].presence || "position"
|
|
226
279
|
|
|
227
280
|
def position_migration_needed?
|
|
228
|
-
ordered? && !child_class.column_names.include?(position_column)
|
|
281
|
+
ordered? && !create_child? && !child_class.column_names.include?(position_column)
|
|
229
282
|
end
|
|
230
283
|
|
|
231
284
|
# ---- template refs ---------------------------------------------------
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Adds a has_one_attached upload onto a resource that
|
|
3
|
+
hibiki:rails:scaffold_controller already generated, on both edit
|
|
4
|
+
surfaces. One attachment per run.
|
|
5
|
+
|
|
6
|
+
The classic form gains a direct-upload file field plus a remove
|
|
7
|
+
checkbox (a virtual remove_<attachment> attribute on the model, whose
|
|
8
|
+
purge yields to a same-submit upload). The inline channel form gains a
|
|
9
|
+
nameless file input that direct-uploads on change and hands only the
|
|
10
|
+
signed blob id to the graph — the pending state lives there, keyed by
|
|
11
|
+
the form's dom, so it survives repaints and the edit row and inline
|
|
12
|
+
create form stay independent.
|
|
13
|
+
|
|
14
|
+
The channel gains one `include`; everything else lives in a generated
|
|
15
|
+
concern under app/channels/concerns that wraps the lifecycle with
|
|
16
|
+
super. Both channel queries are preloaded with with_attached_<name>,
|
|
17
|
+
and one shared upload_field_controller.js is emitted per app.
|
|
18
|
+
|
|
19
|
+
--accept=pdf,doc narrows the file inputs (image by default; tokens
|
|
20
|
+
image, audio, video, pdf, csv, text, doc, xls, ppt, or a raw
|
|
21
|
+
type/subtype or .ext). Images render as thumbnails, anything else as
|
|
22
|
+
its filename and size — decided per blob at render time, so mixed
|
|
23
|
+
lists work and a wrong-typed file never breaks the page.
|
|
24
|
+
|
|
25
|
+
--many generates a has_many_attached gallery instead (implied when the
|
|
26
|
+
model already declares one): the inline input takes several files, each
|
|
27
|
+
attached file gets a graph-owned ✕ mark (toggle; purged on save) and
|
|
28
|
+
each pending file its own badge. The classic form appends through a
|
|
29
|
+
`multiple` field and purges checked remove_<singular>_ids — both
|
|
30
|
+
virtual attributes on the model, so the collection is never assigned
|
|
31
|
+
(which would replace it). --many against an existing has_one_attached
|
|
32
|
+
refuses.
|
|
33
|
+
|
|
34
|
+
Prerequisites (warned about, never installed): the Active Storage
|
|
35
|
+
tables, the image_processing gem for thumbnails (when images are
|
|
36
|
+
accepted), and ActiveStorage.start() in the JS entrypoint for the
|
|
37
|
+
classic form.
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
bin/rails g hibiki:rails:upload_field Album cover
|
|
41
|
+
|
|
42
|
+
bin/rails g hibiki:rails:upload_field Report document --accept=pdf,doc
|
|
43
|
+
|
|
44
|
+
bin/rails g hibiki:rails:upload_field Album photos --many
|
|
45
|
+
|
|
46
|
+
bin/rails g hibiki:rails:upload_field Album cover --css=tailwind --phlex
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<%#- ESCAPING: <%% emits runtime ERB into the generated view; a bare <% is
|
|
2
|
+
consumed here at generation time. #{...} inside an escaped tag is runtime
|
|
3
|
+
Ruby and passes through untouched. -%>
|
|
4
|
+
<%%# locals: (form:, dom:, extras: {}) -%>
|
|
5
|
+
<%%# The <%= attachment_name %> upload. The file input carries NO name on purpose: the save
|
|
6
|
+
submit serializes the form with FormData and a File can't ride the
|
|
7
|
+
channel — the input direct-uploads on change and hands the signed id to
|
|
8
|
+
<%= set_action %>; the badges are the graph echoing that state back. %>
|
|
9
|
+
<%% pending = extras.fetch(:<%= extras_key %>, {})[dom] %>
|
|
10
|
+
<%% attachment = form.record&.<%= attachment_reader %> %>
|
|
11
|
+
<%%= label_tag "#{dom}_<%= attachment_name %>", "<%= attachment_human %>"<%= arg_css(:label) %> %>
|
|
12
|
+
<div<%= css_attr(:upload_row) %>>
|
|
13
|
+
<%%# variable? — an image gets a thumbnail, anything else its filename. %>
|
|
14
|
+
<%% if attachment && !pending&.dig(:remove) %>
|
|
15
|
+
<%% if attachment.blob.variable? %>
|
|
16
|
+
<%%= image_tag attachment.blob.variant(resize_to_limit: [ 48, 48 ])<%= arg_css(:thumb) %> %>
|
|
17
|
+
<%% else %>
|
|
18
|
+
<span<%= css_attr(:muted_inline) %>><%%= attachment.blob.filename %> (<%%= number_to_human_size(attachment.blob.byte_size) %>)</span>
|
|
19
|
+
<%% end %>
|
|
20
|
+
<%% end %>
|
|
21
|
+
<%%= tag.input type: "file", accept: "<%= accept_attr %>", id: "#{dom}_<%= attachment_name %>"<%= arg_css(:file_input_sm) %>,
|
|
22
|
+
data: { controller: "upload-field",
|
|
23
|
+
upload_field_url_value: rails_direct_uploads_path,
|
|
24
|
+
upload_field_action_value: "<%= set_action %>",
|
|
25
|
+
upload_field_dom_value: dom,
|
|
26
|
+
action: "change->upload-field#upload" } %>
|
|
27
|
+
<%% if !pending&.dig(:remove) && (attachment || pending) %>
|
|
28
|
+
<%%= tag.button("Remove", type: "button"<%= arg_css(:btn_ghost_sm) %>,
|
|
29
|
+
**on(:<%= remove_action %>, with: { dom: dom })) %>
|
|
30
|
+
<%% end %>
|
|
31
|
+
<div<%= css_attr(:upload_status) %>>
|
|
32
|
+
<%% if pending&.dig(:remove) %>
|
|
33
|
+
<span<%= css_attr(:badge_warning) %>><%= attachment_name %> removed on save</span>
|
|
34
|
+
<%% elsif pending %>
|
|
35
|
+
<span<%= css_attr(:badge_info) %>><%%= pending[:filename].truncate(12) %> — attaches on save</span>
|
|
36
|
+
<%% end %>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<%#- ESCAPING: <%% emits runtime ERB into the generated view; a bare <% is
|
|
2
|
+
consumed here at generation time. #{...} inside an escaped tag is runtime
|
|
3
|
+
Ruby and passes through untouched. -%>
|
|
4
|
+
<%%# locals: (form:, dom:, extras: {}) -%>
|
|
5
|
+
<%%# The <%= attachment_name %> gallery. The file input carries NO name on purpose: the save
|
|
6
|
+
submit serializes the form with FormData and a File can't ride the
|
|
7
|
+
channel — the input direct-uploads each pick on change and hands the
|
|
8
|
+
signed ids to <%= add_action %>; the badges and ✕ marks are the graph
|
|
9
|
+
echoing that state back. %>
|
|
10
|
+
<%% pending = extras.fetch(:<%= extras_key %>, {})[dom] || { adds: [], removes: [] } %>
|
|
11
|
+
<%% attachments = form.record&.<%= attachment_reader %> || [] %>
|
|
12
|
+
<%%= label_tag "#{dom}_<%= attachment_name %>", "<%= attachment_human %>"<%= arg_css(:label) %> %>
|
|
13
|
+
<div<%= css_attr(:upload_row) %>>
|
|
14
|
+
<%% attachments.each do |attachment| %>
|
|
15
|
+
<span<%= css_attr(:gallery_item) %>>
|
|
16
|
+
<%%# variable? — an image gets a thumbnail, anything else its filename. %>
|
|
17
|
+
<%% if attachment.blob.variable? %>
|
|
18
|
+
<%%= image_tag attachment.blob.variant(resize_to_limit: [ 48, 48 ])<%= arg_css(:thumb) %> %>
|
|
19
|
+
<%% else %>
|
|
20
|
+
<span<%= css_attr(:muted_inline) %>><%%= attachment.blob.filename %> (<%%= number_to_human_size(attachment.blob.byte_size) %>)</span>
|
|
21
|
+
<%% end %>
|
|
22
|
+
<%% if pending[:removes].include?(attachment.id) %>
|
|
23
|
+
<span<%= css_attr(:badge_warning) %>>removed on save</span>
|
|
24
|
+
<%%= tag.button("Undo", type: "button"<%= arg_css(:btn_ghost_sm) %>,
|
|
25
|
+
**on(:<%= remove_action %>, with: { dom: dom, attachment_id: attachment.id })) %>
|
|
26
|
+
<%% else %>
|
|
27
|
+
<%%= tag.button("✕", type: "button", aria: { label: "Remove" }<%= arg_css(:btn_ghost_sm) %>,
|
|
28
|
+
**on(:<%= remove_action %>, with: { dom: dom, attachment_id: attachment.id })) %>
|
|
29
|
+
<%% end %>
|
|
30
|
+
</span>
|
|
31
|
+
<%% end %>
|
|
32
|
+
<%%= tag.input type: "file", multiple: true, accept: "<%= accept_attr %>", id: "#{dom}_<%= attachment_name %>"<%= arg_css(:file_input_sm) %>,
|
|
33
|
+
data: { controller: "upload-field",
|
|
34
|
+
upload_field_url_value: rails_direct_uploads_path,
|
|
35
|
+
upload_field_action_value: "<%= add_action %>",
|
|
36
|
+
upload_field_dom_value: dom,
|
|
37
|
+
action: "change->upload-field#upload" } %>
|
|
38
|
+
<div<%= css_attr(:upload_status_list) %>>
|
|
39
|
+
<%% pending[:adds].each do |add| %>
|
|
40
|
+
<span<%= css_attr(:gallery_item) %>>
|
|
41
|
+
<span<%= css_attr(:badge_info) %>><%%= add[:filename].truncate(12) %> — attaches on save</span>
|
|
42
|
+
<%%= tag.button("✕", type: "button", aria: { label: "Drop" }<%= arg_css(:btn_ghost_sm) %>,
|
|
43
|
+
**on(:<%= remove_action %>, with: { dom: dom, signed_id: add[:signed_id] })) %>
|
|
44
|
+
</span>
|
|
45
|
+
<%% end %>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|