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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +9 -0
- data/app/assets/javascripts/hibiki.js +20 -2
- data/lib/generators/hibiki/rails/css_variant.rb +25 -2
- data/lib/generators/hibiki/rails/form/USAGE +24 -0
- data/lib/generators/hibiki/rails/form/form_generator.rb +171 -0
- data/lib/generators/hibiki/rails/multiselect/USAGE +27 -0
- data/lib/generators/hibiki/rails/multiselect/multiselect_generator.rb +149 -0
- data/lib/generators/hibiki/rails/multiselect/templates/_multiselect.html.erb.tt +44 -0
- data/lib/generators/hibiki/rails/multiselect/templates/concern.rb.tt +87 -0
- data/lib/generators/hibiki/rails/multiselect/templates/join_migration.rb.tt +14 -0
- data/lib/generators/hibiki/rails/multiselect/templates/join_model.rb.tt +7 -0
- data/lib/generators/hibiki/rails/multiselect/templates/multiselect_component.rb.tt +65 -0
- data/lib/generators/hibiki/rails/multiselect_helpers.rb +190 -0
- data/lib/generators/hibiki/rails/multiselect_injections.rb +241 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row.rb.tt +1 -1
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row_form.rb.tt +1 -1
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +1 -1
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +1 -1
- data/lib/generators/hibiki/rails/scaffold_phlex_helpers.rb +4 -2
- data/lib/generators/hibiki/rails/scaffold_post_install.rb +8 -12
- data/lib/generators/hibiki/rails/scaffold_shared_views.rb +13 -3
- data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +10 -3
- data/lib/hibiki/rails/reactive_form.rb +42 -0
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb224598ce236ad6d2f55b9648fbcb4644a478e1c86db2839dc3648207cd0605
|
|
4
|
+
data.tar.gz: 2c4c73dbc34338aca84fde08bf520e47dcc5920a7e4a6403ce9b4ee6d7ec62b6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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>
|