hibiki_rails 0.6.0 → 0.7.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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +9 -0
  4. data/app/assets/javascripts/hibiki.js +20 -2
  5. data/lib/generators/hibiki/rails/css_variant.rb +25 -2
  6. data/lib/generators/hibiki/rails/form/USAGE +24 -0
  7. data/lib/generators/hibiki/rails/form/form_generator.rb +171 -0
  8. data/lib/generators/hibiki/rails/multiselect/USAGE +27 -0
  9. data/lib/generators/hibiki/rails/multiselect/multiselect_generator.rb +149 -0
  10. data/lib/generators/hibiki/rails/multiselect/templates/_multiselect.html.erb.tt +44 -0
  11. data/lib/generators/hibiki/rails/multiselect/templates/concern.rb.tt +87 -0
  12. data/lib/generators/hibiki/rails/multiselect/templates/join_migration.rb.tt +14 -0
  13. data/lib/generators/hibiki/rails/multiselect/templates/join_model.rb.tt +7 -0
  14. data/lib/generators/hibiki/rails/multiselect/templates/multiselect_component.rb.tt +65 -0
  15. data/lib/generators/hibiki/rails/multiselect_helpers.rb +190 -0
  16. data/lib/generators/hibiki/rails/multiselect_injections.rb +241 -0
  17. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row.rb.tt +1 -1
  18. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row_form.rb.tt +1 -1
  19. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +1 -1
  20. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +1 -1
  21. data/lib/generators/hibiki/rails/scaffold_phlex_helpers.rb +4 -2
  22. data/lib/generators/hibiki/rails/scaffold_post_install.rb +8 -12
  23. data/lib/generators/hibiki/rails/scaffold_shared_views.rb +13 -3
  24. data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +10 -3
  25. data/lib/hibiki/rails/reactive_form.rb +42 -0
  26. data/lib/hibiki/rails/version.rb +1 -1
  27. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d708875aa67bf5d9b9137ac8d9bb05cf23604db936011706d068ec0b7116429c
4
- data.tar.gz: 941b16f996127706d699e8812cf712b755c0f833f7a04c9e71912844421645ba
3
+ metadata.gz: eb224598ce236ad6d2f55b9648fbcb4644a478e1c86db2839dc3648207cd0605
4
+ data.tar.gz: 2c4c73dbc34338aca84fde08bf520e47dcc5920a7e4a6403ce9b4ee6d7ec62b6
5
5
  SHA512:
6
- metadata.gz: 74175d980c70f1b4ea1bb9ba593ef9940326eccfce6c783ceefdebfb2a0659706faa1040393759549366c5880ea23459740e0f3f280e64fb47a4bc32a371f69c
7
- data.tar.gz: c88a903f15b13516310beb32a7ed741942c49ae65823a6422fb36823400a9fea420ae08bd2fe047bc1d2e6ff3e38cef8f86c0665eeee6014e0f495c880ad90a4
6
+ metadata.gz: 5cace159e074313892f3736f5bed6220e1852fc71d85ee5b063af80c31471662f492d945ad57b5589543b54f43d2f4bac3dce3fdf8718fc0b8b1df18d0f5e756
7
+ data.tar.gz: 99a518942cc899684e94951d88fea9a08524c83327ae61cc5a924356b16485b33a14cc51fc9fcac3d8dc67d71f96c41ef5f8915d0b97349bc4ed0f74db3de556
data/CHANGELOG.md CHANGED
@@ -4,6 +4,76 @@ 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.7.0 — 2026-08-12
8
+
9
+ ### Added
10
+
11
+ **`hibiki:rails:multiselect` — a dropdown multi-select over a
12
+ `has_many :through`, layered onto a scaffolded resource.**
13
+
14
+ ```
15
+ bin/rails g hibiki:rails:multiselect Album Song Track
16
+ ```
17
+
18
+ Owner and target must exist and be migrated; the join model is generated when
19
+ missing (a `belongs_to` pair with `touch:` on the owner side — a join write IS
20
+ an owner change — and a unique index over the pair) and named off the owner's
21
+ reflection when the `has_many :through` is already declared, where the third
22
+ argument may be omitted. An existing join gets `touch: true` added to its
23
+ `belongs_to`.
24
+
25
+ The channel half is a generated concern (`AlbumsChannel::SongsMultiselect`
26
+ under `app/channels/concerns/`) that wraps `build_graph`, `list_locals` and
27
+ `edit` with `super` via a prepended module — one `include` line is the
28
+ channel's only edit, and the concern's public `toggle_song` / `search_songs`
29
+ methods are client-invocable actions like any other. The form object gains
30
+ `reactive_association :songs`.
31
+
32
+ The selection is owned by the graph, not the form markup: each checkbox sends
33
+ its own toggle carrying its checked state (a set, so two tabs converge), and
34
+ the submit payload never carries the ids. That is what keeps the built-in
35
+ searchable filter honest — narrowing the option list can never drop
36
+ selections it hides. Options are capped (`--limit`, default 50) with a
37
+ LIMIT+1 fetch whose extra row becomes the "type to narrow" hint — no COUNT
38
+ per keystroke. `--skip-search` drops the filter AND the cap, which would
39
+ strand the options it hides. `--label` overrides the inferred display column;
40
+ `--phlex` and `--css` are detected from what the scaffold left behind.
41
+
42
+ **A multiple `<select>`'s full selection now reaches the channel, and
43
+ `ReactiveForm` can carry a collection's ids.** Two halves shipped together:
44
+ the client collects every FormData entry of a `[]`-suffixed field name as an
45
+ array under the bare key (previously last-wins, so a multi-select submitted
46
+ only one option; non-`[]` duplicate keys stay last-wins on purpose — the
47
+ hidden-field checkbox convention depends on it), and
48
+ `reactive_association :tags` on a `ReactiveForm` defines a `tag_ids` signal
49
+ that hydrates from the record's ids reader and commits through the
50
+ association writer, casting each id via the target model's primary-key type.
51
+
52
+ **`hibiki:rails:form` — re-derive a resource's form from its validators.**
53
+ `bin/rails g hibiki:rails:form Book` reads the migrated schema and rewrites the
54
+ validator-shaped files only: the `ReactiveForm` (its `live_errors` clauses) and
55
+ the two form views (number-field bounds, requiredness). Everything else the
56
+ scaffold wrote is untouched, and Thor still asks per file before replacing
57
+ edits. `--skip-views` limits it to the form object; the view layer and css
58
+ variant are detected from the files a previous run left, with `--phlex` and
59
+ `--css` as overrides. An optional field list chooses order and subset, never
60
+ facts.
61
+
62
+ ### Changed
63
+
64
+ **Scaffolded partials pass a generic `extras: {}` hash down the
65
+ list → row → row form chain, in both view layers.** The extension point
66
+ add-on generators ride: merge locals into the broadcast under one key and
67
+ they arrive at the row form with no partial edits. Existing scaffolds keep
68
+ working untouched — a generator that needs the hash on pre-0.7.0 output
69
+ threads it in with a `compat` notice.
70
+
71
+ **The scaffold's empty-`live_errors` notice now recommends
72
+ `bin/rails g hibiki:rails:form Book`** — on its own line, with no retyped field
73
+ list — instead of re-running `scaffold_controller` with the full arguments. The
74
+ migration the scaffold wrote preserves the argument order, so once it has run
75
+ the schema answers both order and facts.
76
+
7
77
  ## 0.6.0 — 2026-08-10
8
78
 
9
79
  ### Added
data/README.md CHANGED
@@ -73,6 +73,9 @@ bin/rails g hibiki:rails:scaffold_controller Book
73
73
 
74
74
  # Same, but you pick the field order; everything else still comes from the model
75
75
  bin/rails g hibiki:rails:scaffold_controller Book title:string author:references
76
+
77
+ # After adding validators: re-derive the form and its views, touching nothing else
78
+ bin/rails g hibiki:rails:form Book
76
79
  ```
77
80
 
78
81
  Your plain `rails g scaffold` is untouched. The generated markup is styled to
@@ -91,6 +94,12 @@ Listing the fields yourself only chooses their order and which ones appear — t
91
94
  model still answers everything else, so the live validation, a number field's
92
95
  `min:`/`max:` and a `belongs_to`'s display label all survive the choice.
93
96
 
97
+ Because the live validation is derived from the model's validators, a scaffold
98
+ generated before its migration ran starts with none. `hibiki:rails:form` is the
99
+ catch-up: it rewrites only the `ReactiveForm` and the two form views from the
100
+ schema as it stands now, asks before replacing anything you edited, and takes
101
+ `--skip-views` to rewrite the form object alone.
102
+
94
103
  Your models are edited, not just read: the one being scaffolded gains the
95
104
  `after_commit` broadcast that makes writes from anywhere reach an open list, and
96
105
  each model a `belongs_to` points at gains the `has_many` half plus a ping of its
@@ -104,6 +104,24 @@ const controlValue = (control) => {
104
104
  return control.value
105
105
  }
106
106
 
107
+ // A trailing [] is a serialization artifact, not part of the attribute name:
108
+ // the channel payload is JSON, where arrays are native.
109
+ const payloadKey = (name) => (name.endsWith("[]") ? name.slice(0, -2) : name)
110
+
111
+ // A submitted form's contribution to the payload. A []-named field collects
112
+ // EVERY entry as an array under the bare key; other duplicate keys stay
113
+ // last-wins, which is what lets Rails' hidden-field checkbox convention
114
+ // submit "1" when checked and "0" when not.
115
+ const formPayload = (form) => {
116
+ const data = new FormData(form)
117
+ const payload = {}
118
+ for (const key of new Set(data.keys())) {
119
+ const all = data.getAll(key)
120
+ payload[payloadKey(key)] = key.endsWith("[]") ? all : all.at(-1)
121
+ }
122
+ return payload
123
+ }
124
+
107
125
  // The subclassable base: one channel subscription per controller element,
108
126
  // identified by a per-page-load cid (data-<identifier>-cid-value).
109
127
  export class ChannelController extends Controller {
@@ -577,9 +595,9 @@ export default class HibikiController extends ChannelController {
577
595
  ? JSON.parse(control.dataset.hibikiWith)
578
596
  : {}
579
597
  if (event.type === "submit") {
580
- Object.assign(payload, Object.fromEntries(new FormData(control)))
598
+ Object.assign(payload, formPayload(control))
581
599
  } else if (control.name && (event.type === "change" || event.type === "input")) {
582
- payload[control.name] = controlValue(control)
600
+ payload[payloadKey(control.name)] = controlValue(control)
583
601
  }
584
602
  // perform stamps `hbk` after this merge, so a field literally named hbk
585
603
  // loses to the seq rather than corrupting it.
@@ -99,7 +99,21 @@ module Hibiki
99
99
  counts: "ml-auto text-sm opacity-70",
100
100
  # my-, not mt-: the control renders above the list as well as below,
101
101
  # and the top copy needs the gap on its other side.
102
- pagination_nav: "join my-2 mx-auto flex justify-center w-fit"
102
+ pagination_nav: "join my-2 mx-auto flex justify-center w-fit",
103
+
104
+ # The multiselect dropdown (hibiki:rails:multiselect). One structure
105
+ # for every variant: DaisyUI's dropdown opens on :focus-within, the
106
+ # tailwind override hand-rolls the same rule with group-focus-within,
107
+ # and under --css=none nothing hides the panel so the list renders
108
+ # inline — unstyled, still functional.
109
+ dropdown: "dropdown w-full",
110
+ dropdown_trigger: "select w-full items-center",
111
+ dropdown_panel: "dropdown-content bg-base-100 rounded-box z-10 mt-1 w-full p-2 shadow-md",
112
+ option_list: "menu w-full max-h-60 flex-nowrap overflow-y-auto p-0",
113
+ option_label: "label cursor-pointer justify-start gap-2",
114
+ option_note: "p-2 opacity-60 italic",
115
+ checkbox_sm: "checkbox checkbox-sm",
116
+ filter_input: "input input-sm w-full mb-2"
103
117
  }.freeze
104
118
 
105
119
  # DaisyUI is a plugin over Tailwind, and the merge says so literally:
@@ -144,7 +158,16 @@ module Hibiki
144
158
  muted: "text-gray-500 italic",
145
159
  muted_inline: "text-gray-400",
146
160
  counts: "ml-auto text-sm text-gray-500",
147
- pagination_nav: "my-2 mx-auto flex justify-center w-fit -space-x-px rounded-md shadow-sm"
161
+ pagination_nav: "my-2 mx-auto flex justify-center w-fit -space-x-px rounded-md shadow-sm",
162
+ dropdown: "group relative w-full",
163
+ dropdown_trigger: "#{FIELD_FULL} flex cursor-pointer items-center",
164
+ dropdown_panel: "absolute inset-x-0 z-10 mt-1 hidden rounded-md border border-gray-200 " \
165
+ "bg-white p-2 shadow-lg group-focus-within:block",
166
+ option_list: "max-h-60 space-y-1 overflow-y-auto",
167
+ option_label: "flex cursor-pointer items-center gap-2 py-1 text-sm",
168
+ option_note: "p-2 text-gray-500 italic",
169
+ checkbox_sm: CHECKBOX,
170
+ filter_input: "#{FIELD_FULL} mb-2 text-sm"
148
171
  ).freeze
149
172
 
150
173
  # Every lookup misses, so every class argument is omitted entirely.
@@ -0,0 +1,24 @@
1
+ Description:
2
+ Regenerates a resource's ReactiveForm and its form views from the model's
3
+ schema and validators. Run it after adding or changing validators so
4
+ live_errors, number-field bounds and requiredness match the model again.
5
+
6
+ The model must exist and its table must be migrated. With no field list,
7
+ columns and their order come from the schema; pass fields explicitly only
8
+ to choose a different order or subset — the facts stay the schema's.
9
+
10
+ Only the validator-shaped files are rewritten; the rest of the scaffold's
11
+ output is untouched. Thor asks per file before replacing anything you have
12
+ edited. The view layer and css variant are detected from the files a
13
+ previous scaffold left; --phlex and --css override.
14
+
15
+ Example:
16
+ bin/rails generate hibiki:rails:form Book
17
+
18
+ This will rewrite:
19
+ app/forms/book_form.rb
20
+ app/views/books/_form.html.erb
21
+ app/views/books/_book_form.html.erb
22
+
23
+ Under --phlex, the views are app/views/books/{form,row_form}.rb instead.
24
+ Pass --skip-views to rewrite only app/forms/book_form.rb.
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/resource_helpers"
5
+ require_relative "../generator_helpers"
6
+ require_relative "../scaffold_helpers"
7
+ require_relative "../scaffold_view_helpers"
8
+ require_relative "../scaffold_phlex_helpers"
9
+ require_relative "../scaffold_post_install"
10
+ require_relative "../scaffold_shared_views"
11
+ require_relative "../scaffold_schema"
12
+ require_relative "../css_variant"
13
+
14
+ module Hibiki
15
+ module Rails
16
+ module Generators
17
+ # Re-derives the validator-shaped slice of a scaffolded resource: the
18
+ # ReactiveForm (live_errors) and the two form views (bounds,
19
+ # requiredness). Run it after adding or changing validators on the model;
20
+ # everything else the scaffold wrote is deliberately out of scope, so a
21
+ # customized index or controller is never put at risk over a validator.
22
+ #
23
+ # Always the introspection path: the model and its table must exist,
24
+ # because validators are the one thing this generator is for and only a
25
+ # live model can answer them. A field list is optional and chooses order
26
+ # and subset only — the facts stay the schema's, like the scaffold's own
27
+ # merge.
28
+ class FormGenerator < ::Rails::Generators::NamedBase
29
+ include ::Rails::Generators::ResourceHelpers
30
+ include GeneratorHelpers
31
+ include ScaffoldHelpers
32
+ include ScaffoldViewHelpers
33
+ include ScaffoldPhlexHelpers
34
+ include ScaffoldPostInstall
35
+ include ScaffoldSharedViews
36
+
37
+ # The scaffold's templates, reused wholesale — this generator re-emits
38
+ # a subset of that output and must never drift from it. Its own
39
+ # source_root stays beside its own USAGE (Base#usage_path resolves
40
+ # ../USAGE from source_root, and pointing it at the scaffold's tree
41
+ # would serve the scaffold's help text).
42
+ SCAFFOLD_TEMPLATES = File.expand_path("../scaffold_controller/templates", __dir__)
43
+
44
+ source_root File.expand_path("templates", __dir__)
45
+
46
+ desc "Regenerates a resource's ReactiveForm and form views from the " \
47
+ "model's schema and validators."
48
+
49
+ argument :attributes, type: :array, default: [], banner: "field:type field:type"
50
+
51
+ class_option :css, type: :string, enum: CssVariant::NAMES,
52
+ desc: "Markup variant for the form views (default: detect)"
53
+ class_option :skip_views, type: :boolean, default: false,
54
+ desc: "Rewrite only the form object — leave the form views alone"
55
+ class_option :phlex, type: :boolean,
56
+ desc: "Emit Phlex form components (default: detect from existing views)"
57
+
58
+ def check_phlex_wiring
59
+ warn_without_phlex_rails unless skip_views?
60
+ end
61
+
62
+ # Resolving the schema first means a missing model or an unmigrated
63
+ # table aborts before anything is written.
64
+ def resolve_schema
65
+ schema
66
+ @new_app_dirs = %w[app/forms].reject { exists?(it) }
67
+ end
68
+
69
+ def create_form
70
+ template "form.rb.tt", form_path
71
+ end
72
+
73
+ def create_views
74
+ return if skip_views?
75
+
76
+ if phlex?
77
+ template "views/form.rb.tt", view_path("form.rb")
78
+ template "views/row_form.rb.tt", view_path("row_form.rb")
79
+ else
80
+ template "views/_form.html.erb.tt", view_path("_form.html.erb")
81
+ template "views/_row_form.html.erb.tt", view_path("_#{row_form_partial}.html.erb")
82
+ end
83
+
84
+ create_form_shared_views
85
+ end
86
+
87
+ def post_install
88
+ restart_notice
89
+ shared_views_notice
90
+ skipped_notice
91
+ live_errors_notice
92
+ uniqueness_notice
93
+ end
94
+
95
+ private
96
+
97
+ # The same layering as the scaffold's, minus nothing: the css-variant
98
+ # fork holds only the page control today, but an app's own
99
+ # lib/templates override must keep winning first either way.
100
+ def source_paths
101
+ @source_paths ||= [*self.class.source_paths_for_search,
102
+ *phlex_source_paths,
103
+ File.join(SCAFFOLD_TEMPLATES, css_variant.to_s),
104
+ File.join(SCAFFOLD_TEMPLATES, "shared")]
105
+ end
106
+
107
+ def phlex_source_paths
108
+ return [] unless phlex?
109
+
110
+ [File.join(SCAFFOLD_TEMPLATES, "phlex", css_variant.to_s),
111
+ File.join(SCAFFOLD_TEMPLATES, "phlex", "shared")]
112
+ end
113
+
114
+ # Unlike the scaffold pair, the model is REQUIRED on both branches: an
115
+ # argument list here chooses order and subset, never facts, because
116
+ # facts with no validators behind them are exactly the output this
117
+ # generator exists to replace.
118
+ def schema
119
+ @schema ||= if attributes.any?
120
+ ScaffoldSchema.from_attributes(attributes, model: model_class)
121
+ else
122
+ ScaffoldSchema.from_model(model_class)
123
+ end
124
+ end
125
+
126
+ # Aborts rather than emitting a form with nothing derived — like the
127
+ # scaffold's own model_class, but with no field-list escape hatch.
128
+ def model_class
129
+ klass = class_name.safe_constantize
130
+ raise ::Rails::Generators::Error, missing_model_message unless klass
131
+
132
+ # Touch the schema here so an unmigrated table fails with our message
133
+ # rather than somewhere inside a template.
134
+ klass.tap(&:columns_hash)
135
+ rescue ::Rails::Generators::Error
136
+ raise
137
+ rescue StandardError
138
+ raise ::Rails::Generators::Error, missing_table_message
139
+ end
140
+
141
+ def missing_model_message
142
+ "No model #{class_name}. Generate the resource first:\n " \
143
+ "bin/rails g hibiki:rails:scaffold #{name} title:string"
144
+ end
145
+
146
+ def missing_table_message
147
+ "#{class_name} has no table yet. Run bin/rails db:migrate first"
148
+ end
149
+
150
+ def skip_views? = options[:skip_views]
151
+
152
+ # Which view layer a previous scaffold left behind. app/views/<res>/
153
+ # form.rb only ever comes from a --phlex run (the ERB layer's file is
154
+ # _form.html.erb), so its presence is the layer answer; --phlex and
155
+ # --no-phlex override for the odd case.
156
+ def phlex?
157
+ return @phlex if defined?(@phlex)
158
+
159
+ @phlex = options[:phlex].nil? ? exists?(view_path("form.rb")) : options[:phlex]
160
+ end
161
+
162
+ def css_variant
163
+ @css_variant ||= (options[:css] || CssVariant.detect(destination_root)).to_sym
164
+ end
165
+
166
+ def css(token) = CssVariant.token(css_variant, token)
167
+ def css? = css_variant != :none
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,27 @@
1
+ Description:
2
+ Adds a dropdown multi-select for a has_many :through association onto a
3
+ resource that hibiki:rails:scaffold_controller already generated.
4
+
5
+ Owner and Target must exist and be migrated. Join is required only when
6
+ the owner doesn't declare the association yet — the join model and its
7
+ migration are generated (belongs_to pair, touch: on the owner side, a
8
+ unique index over the pair), and the has_many :through is injected into
9
+ the owner.
10
+
11
+ The channel gains one `include`; everything else lives in a generated
12
+ concern under app/channels/concerns that wraps build_graph/list_locals
13
+ with super. The selection is owned by the graph (the form's <target>_ids
14
+ signal), so filtering the option list can never drop hidden selections.
15
+
16
+ The option list is capped (OPTIONS_LIMIT in the concern, --limit to
17
+ choose it); the cap is dropped under --skip-search, where it would strand
18
+ the options it hides. Scaffolds generated before 0.7.0 lack the extras:
19
+ hash the view rides on — it is threaded through their partials
20
+ automatically.
21
+
22
+ Examples:
23
+ bin/rails g hibiki:rails:multiselect Album Song Track
24
+
25
+ bin/rails g hibiki:rails:multiselect Album Song --skip-search
26
+
27
+ bin/rails g hibiki:rails:multiselect Album Song Track --label=name --limit=25
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/resource_helpers"
5
+ require "rails/generators/active_record"
6
+ require_relative "../generator_helpers"
7
+ require_relative "../scaffold_helpers"
8
+ require_relative "../scaffold_view_helpers"
9
+ require_relative "../scaffold_phlex_helpers"
10
+ require_relative "../scaffold_model_injection"
11
+ require_relative "../scaffold_schema"
12
+ require_relative "../css_variant"
13
+ require_relative "../multiselect_helpers"
14
+ require_relative "../multiselect_injections"
15
+
16
+ module Hibiki
17
+ module Rails
18
+ module Generators
19
+ # A dropdown multi-select over a has_many :through, added onto a
20
+ # resource hibiki:rails:scaffold_controller already generated: a channel
21
+ # concern that wraps the graph (the include line is the channel's only
22
+ # edit), a view partial or component riding the extras: hash, an ids
23
+ # signal on the ReactiveForm, and the join model when it doesn't exist
24
+ # yet.
25
+ #
26
+ # Owner and target must exist and be migrated; the join is the one model
27
+ # this generator may create.
28
+ class MultiselectGenerator < ::Rails::Generators::NamedBase
29
+ include ::Rails::Generators::ResourceHelpers
30
+ include ::ActiveRecord::Generators::Migration
31
+ include GeneratorHelpers
32
+ include ScaffoldHelpers
33
+ include ScaffoldViewHelpers
34
+ include ScaffoldPhlexHelpers
35
+ include ScaffoldModelInjection
36
+ include MultiselectHelpers
37
+ include MultiselectInjections
38
+
39
+ source_root File.expand_path("templates", __dir__)
40
+
41
+ desc "Adds a dropdown multi-select for a has_many :through onto a " \
42
+ "hibiki:rails scaffold, generating the join model if needed."
43
+
44
+ argument :target, type: :string, banner: "Target"
45
+ argument :join, type: :string, required: false, default: nil, banner: "[Join]"
46
+
47
+ class_option :css, type: :string, enum: CssVariant::NAMES,
48
+ desc: "Markup variant for the generated view (default: detect)"
49
+ class_option :skip_search, type: :boolean, default: false,
50
+ desc: "No filter input — also drops the option limit, " \
51
+ "which would strand options it hides"
52
+ class_option :limit, type: :numeric, default: 50,
53
+ desc: "How many options the dropdown offers at once"
54
+ class_option :label, type: :string,
55
+ desc: "The target column shown per option (default: infer)"
56
+ class_option :phlex, type: :boolean,
57
+ desc: "Emit a Phlex component (default: detect from the scaffold)"
58
+
59
+ # Everything that can refuse, before anything is written.
60
+ def preflight
61
+ owner_class
62
+ target_class
63
+ check_scaffolded!
64
+ label_column
65
+ join_class_name
66
+ @concerns_dir_new = !exists?("app/channels/concerns")
67
+ end
68
+
69
+ def create_join_model
70
+ return unless (@join_generated = join_missing?)
71
+
72
+ template "join_model.rb.tt", join_model_path
73
+ migration_template "join_migration.rb.tt", "db/migrate/create_#{join_table_name}.rb"
74
+ end
75
+
76
+ def wire_models
77
+ inject_owner_associations
78
+ inject_join_touch
79
+ end
80
+
81
+ def create_concern
82
+ template "concern.rb.tt", concern_path
83
+ end
84
+
85
+ def wire_channel
86
+ inject_channel_include
87
+ end
88
+
89
+ def wire_form
90
+ inject_form_association
91
+ end
92
+
93
+ def create_view
94
+ if phlex?
95
+ template "multiselect_component.rb.tt", view_path("#{multiselect_partial}.rb")
96
+ else
97
+ template "_multiselect.html.erb.tt", view_path("_#{multiselect_partial}.html.erb")
98
+ end
99
+ end
100
+
101
+ # BEFORE the render injection: the compat probe reads "extras" from the
102
+ # row form, and the render line would satisfy it vacuously.
103
+ def thread_extras
104
+ thread_extras_through_partials
105
+ end
106
+
107
+ def wire_views
108
+ inject_row_form_render
109
+ inject_row_display
110
+ end
111
+
112
+ def wire_preloads
113
+ inject_query_preload
114
+ inject_member_channel_preload
115
+ end
116
+
117
+ def post_install
118
+ if @join_generated
119
+ say_status :migrate, "#{join_class_name} was generated — run bin/rails db:migrate " \
120
+ "before using the multiselect.", :yellow
121
+ end
122
+ if @concerns_dir_new
123
+ say_status :restart, "app/channels/concerns is new. " \
124
+ "Please restart if the server is running.", :yellow
125
+ end
126
+ say_status :assoc, "using #{target_class_name}##{label_column} as the option label — " \
127
+ "pass --label=<column> if that's wrong", :blue
128
+ end
129
+
130
+ private
131
+
132
+ # The scaffold's own view layer, read from what it left on disk; an
133
+ # explicit --phlex wins.
134
+ def phlex?
135
+ return options[:phlex] unless options[:phlex].nil?
136
+
137
+ @phlex ||= exists?(view_path("row_form.rb"))
138
+ end
139
+
140
+ def css_variant
141
+ @css_variant ||= (options[:css] || CssVariant.detect(destination_root)).to_sym
142
+ end
143
+
144
+ def css(token) = CssVariant.token(css_variant, token)
145
+ def css? = css_variant != :none
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,44 @@
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 <%= target_plural %> multi-select. The graph owns the selection: each checkbox
6
+ sends its own toggle and the submit payload never carries the ids, so
7
+ options hidden by the filter are never dropped. %>
8
+ <%% ms = extras.fetch(:<%= extras_key %>, { options: [] }) %>
9
+ <div<%= css_attr(:label) %>><%= target_human_plural %></div>
10
+ <div<%= css_attr(:dropdown) %>>
11
+ <%%# tabindex, not a <button>: focus-within keeps the panel open while boxes
12
+ inside are clicked, and the morphed repaint keeps focus and caret. %>
13
+ <div tabindex="0" role="button" id="<%%= dom %>_<%= target_plural %>"<%= css_attr(:dropdown_trigger) %>>
14
+ <%%= pluralize(form.<%= ids_attr %>.size, "<%= target_human_singular.downcase %>") %> selected
15
+ </div>
16
+ <div tabindex="0"<%= css_attr(:dropdown_panel) %>>
17
+ <% if search? -%>
18
+ <%%# Narrows the option list only — the selection lives in the graph and
19
+ survives filtering. `on` debounces input events. %>
20
+ <%%= search_field_tag <%= wrapped_list(filter_field_args, indent: 25).lstrip %> %>
21
+ <% end -%>
22
+ <ul<%= css_attr(:option_list) %>>
23
+ <%% ms[:options].each do |text, id| %>
24
+ <li>
25
+ <%%= label_tag "#{dom}_<%= target_singular %>_#{id}"<%= css? ? %(, class: "#{css(:option_label)}") : "" %> do %>
26
+ <%%# Named "checked", NOT <%= ids_attr %>[]: the graph's copy of the
27
+ selection is authoritative, so the submit payload must not
28
+ carry a subset that depends on which options are visible. %>
29
+ <%%= check_box_tag <%= wrapped_list(option_checkbox_args, indent: 30).lstrip %> %>
30
+ <span><%%= text %></span>
31
+ <%% end %>
32
+ </li>
33
+ <%% end %>
34
+ <%% if ms[:options].empty? %>
35
+ <li<%= css_attr(:option_note) %>>No <%= target_human_plural.downcase %> match</li>
36
+ <%% end %>
37
+ <% if search? -%>
38
+ <%% if ms[:truncated] %>
39
+ <li<%= css_attr(:option_note) %>>Showing first <%%= ms[:options].size %> — type to narrow</li>
40
+ <%% end %>
41
+ <% end -%>
42
+ </ul>
43
+ </div>
44
+ </div>