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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b661448cbe2b84a20e26f5cd0bffaf2cf7be8c755f0f1b9c692f77e9f5798430
4
- data.tar.gz: b09c3b35032a6990ab08c17e424a147e9e667905a4f9e9378062d52583e213d6
3
+ metadata.gz: 8dc32112018a236bbd193028d2fe6a0c0650afd04cf8e6de5f26d84bed7de97a
4
+ data.tar.gz: 15fcf11847178df9f355279f7666b064a557ee5ebd0647f24373aeb638fb0131
5
5
  SHA512:
6
- metadata.gz: dc4013a0867b606341dd9af0135a82ab9e5a8c6b5f5cabd788eea48e0f3b604a708d31aa21c044f8639a5f2951242986b710d893b968246d03703359d562235b
7
- data.tar.gz: d407a4c966366e5b11d9db6eaf7e789dcd4e086ca028dad0b2bc9b69bc4ec8f6b110339e338894b51851aa8ace18f08ef2339ce9198cf7a9226dc48d11b8c592
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
- # ---- pre-extras scaffolds (before 0.7.0) -----------------------------------
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
- Parent and Child models must exist and be migrated, and Child must
12
- declare belongs_to :<parent>. An optional field list narrows or orders
13
- the child's form fields; the facts stay the schema's.
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
- if @position_migration
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
- "No model #{child_class_name}. hibiki:rails:nested needs the child model " \
57
- "to exist:\n bin/rails g model #{child_class_name} #{singular_name}:references " \
58
- "role:string\nMigrate, then re-run."
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
- child_class
96
- parent_reflection
97
- check_parent_wireable!
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
- "#{parent_reflection.foreign_key}#{" and #{position_column}" if ordered?} — " \
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
- # Always the introspection path — the child model is required — with
186
- # the scaffold's 3.6 merge when a field list narrows or orders it. The
187
- # parent's foreign key and the ordering column never become form
188
- # fields: the association assigns one, the parent form stamps the
189
- # other.
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: child_class)
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 == parent_reflection.foreign_key.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>