hibiki_rails 0.5.1 → 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 +167 -0
- data/README.md +9 -0
- data/app/assets/javascripts/hibiki.js +20 -2
- data/lib/generators/hibiki/rails/css_variant.rb +34 -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/USAGE +0 -1
- data/lib/generators/hibiki/rails/scaffold_controller/USAGE +3 -5
- data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +18 -20
- data/lib/generators/hibiki/rails/scaffold_controller/templates/daisyui/views/_pagination.html.erb.tt +18 -12
- data/lib/generators/hibiki/rails/scaffold_controller/templates/none/views/_pagination.html.erb.tt +18 -12
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/daisyui/views/pagination.rb.tt +14 -9
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/none/views/pagination.rb.tt +14 -9
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/field_error.rb.tt +8 -3
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/form.rb.tt +2 -14
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/form_errors.rb.tt +29 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/list.rb.tt +17 -3
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row.rb.tt +3 -3
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row_form.rb.tt +2 -2
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/tailwind/views/pagination.rb.tt +14 -9
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/channel.rb.tt +3 -1
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/member_channel.rb.tt +7 -5
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt +7 -5
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_field_error.html.erb.tt +6 -1
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_form.html.erb.tt +1 -11
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_form_errors.html.erb.tt +24 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt +11 -3
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +6 -5
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +2 -2
- data/lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt +18 -12
- data/lib/generators/hibiki/rails/scaffold_helpers.rb +13 -6
- data/lib/generators/hibiki/rails/scaffold_model_injection.rb +6 -31
- data/lib/generators/hibiki/rails/scaffold_phlex_helpers.rb +9 -5
- data/lib/generators/hibiki/rails/scaffold_post_install.rb +12 -14
- data/lib/generators/hibiki/rails/scaffold_schema.rb +4 -11
- data/lib/generators/hibiki/rails/scaffold_shared_views.rb +86 -0
- data/lib/generators/hibiki/rails/scaffold_transport_stylesheet.rb +20 -8
- data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +10 -3
- data/lib/hibiki/rails/engine.rb +8 -0
- data/lib/hibiki/rails/reactive_form.rb +42 -0
- data/lib/hibiki/rails/swallowed_write_warning.rb +43 -0
- data/lib/hibiki/rails/version.rb +1 -1
- data/lib/hibiki/rails.rb +30 -0
- metadata +18 -4
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/row.rb.tt +0 -19
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,173 @@ 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
|
+
|
|
77
|
+
## 0.6.0 — 2026-08-10
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
|
|
81
|
+
**`Hibiki::Rails.record_equals` — a per-signal comparator for ActiveRecord
|
|
82
|
+
records.** `ActiveRecord#==` compares class and id only, so a stale record is
|
|
83
|
+
`==` to its edited reload and a signal write carrying the fresh one is silently
|
|
84
|
+
dropped. Pass the comparator per signal and the write goes through:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
state(:items, equals: Hibiki::Rails.record_equals) { fetch }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
It compares class + `attributes`, recurses through arrays, and falls back to
|
|
91
|
+
`==` for everything else. It needs hibiki 0.3.0's `equals:` (see the dependency
|
|
92
|
+
change below), which is honored at both of the graph's equality gates — the
|
|
93
|
+
write gate and the flush-time check — so a change that passes the comparator
|
|
94
|
+
actually reaches the page. This is opt-in sugar that makes the snapshot pattern
|
|
95
|
+
forgiving; "records stop at the boundary" stays the documented default, and a
|
|
96
|
+
comparator still cannot see an in-place mutation that never enters the write
|
|
97
|
+
path.
|
|
98
|
+
|
|
99
|
+
**A development-mode warning for the write that pattern swallows.** When a
|
|
100
|
+
`State` write is dropped by the default `==` but the old and new values'
|
|
101
|
+
`attributes` differ — the classic silent-stale-UI debugging session — the log
|
|
102
|
+
now says so and points at the docs. Development only (never test or
|
|
103
|
+
production), zero semantic change: the write is still dropped.
|
|
104
|
+
|
|
105
|
+
### Changed
|
|
106
|
+
|
|
107
|
+
**The scaffold's row projection is gone — records now cross the boundary as
|
|
108
|
+
frozen snapshots.** `app/models/book_row.rb` is no longer generated. The query
|
|
109
|
+
object's `rows` returns real records, hardened at the boundary —
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
def rows
|
|
113
|
+
@rows ||= window_scope.strict_loading.map { it.readonly!; it.freeze }
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
— and the channels' `rows`/`row` deriveds compare them with
|
|
118
|
+
`equals: Hibiki::Rails.record_equals`. What the `Data` projection's structural
|
|
119
|
+
`==` used to provide, the comparator provides; what it could never provide, the
|
|
120
|
+
freeze triple does: an attribute write raises `FrozenError`, `save` raises
|
|
121
|
+
`ActiveRecord::ReadOnlyRecord`, and an unpreloaded association walk raises
|
|
122
|
+
`ActiveRecord::StrictLoadingViolationError` instead of firing a lazy query off
|
|
123
|
+
the graph thread. Views print a `belongs_to` label as `book.author&.name`
|
|
124
|
+
directly, so **the scaffold no longer injects a `delegate` per `belongs_to`
|
|
125
|
+
into the model** — the model injection is the `after_commit` ping alone. The
|
|
126
|
+
member channel's fetch preloads its associations (`includes(...) +
|
|
127
|
+
strict_loading`) for the same reason.
|
|
128
|
+
|
|
129
|
+
Existing scaffolded apps keep working untouched: their `book_row.rb` and
|
|
130
|
+
delegates are app code, and the runtime reads none of it. Re-running a scaffold
|
|
131
|
+
with `--force` moves the resource over; the generator never deletes, so the
|
|
132
|
+
orphaned `*_row.rb` stays on disk for you to remove.
|
|
133
|
+
|
|
134
|
+
**The hibiki dependency floor is `~> 0.3`** (per-signal `equals:`, shipped in
|
|
135
|
+
hibiki 0.3.0). Everything 0.2 provided still holds; generated channels now rely
|
|
136
|
+
on the comparator being consulted at both equality gates.
|
|
137
|
+
|
|
138
|
+
**The page control and the field-error line are shared partials now.** Each
|
|
139
|
+
scaffold used to write its own copy per resource; both are presentation-only,
|
|
140
|
+
so they are emitted once per app instead — `app/views/shared/_pagination.html.erb`
|
|
141
|
+
and `_field_error.html.erb` (under `--phlex`: `Views::Shared::Pagination` and
|
|
142
|
+
`Views::Shared::FieldError` in `app/views/shared/`). Everything resource-shaped
|
|
143
|
+
reaches the page control as locals from the list partial. A second scaffold
|
|
144
|
+
finds the files present and leaves them; if they were generated under a
|
|
145
|
+
different `--css` style, a notice says so instead of silently restyling every
|
|
146
|
+
other resource's control.
|
|
147
|
+
|
|
148
|
+
**Pagination renders above the list as well as below**, so a long page starts
|
|
149
|
+
with a control in reach. Both copies live inside the re-rendered fragment and
|
|
150
|
+
carry distinct ids (`books_pagination_top` / `books_pagination`), so idiomorph
|
|
151
|
+
updates them in place.
|
|
152
|
+
|
|
153
|
+
Re-running a scaffold with `--force` moves the list over to the shared
|
|
154
|
+
partials and prints a notice naming the now-dead per-resource copies — the
|
|
155
|
+
generator never deletes them itself.
|
|
156
|
+
|
|
157
|
+
**The form's error summary is a shared partial too** —
|
|
158
|
+
`app/views/shared/_form_errors.html.erb` (under `--phlex`:
|
|
159
|
+
`Views::Shared::FormErrors`), rendered by every scaffolded full-page form. The
|
|
160
|
+
resource name comes off the record at render time, so one file serves every
|
|
161
|
+
scaffold. Under `--css=tailwind` and `--css=daisyui` the block is styled as a
|
|
162
|
+
red alert panel instead of the scaffold-stock `style="color: red"`, which
|
|
163
|
+
`--css=none` keeps.
|
|
164
|
+
|
|
165
|
+
### Fixed
|
|
166
|
+
|
|
167
|
+
**The `hibiki_busy.css` import lands directly below `@import "tailwindcss";`**
|
|
168
|
+
instead of at the end of the entry stylesheet — appending placed it after
|
|
169
|
+
`@plugin` lines, and CSS requires every `@import` before any other statement.
|
|
170
|
+
Also fixed: re-running a scaffold in a tailwindcss-rails app
|
|
171
|
+
(`app/assets/tailwind/application.css`) duplicated the import on every run,
|
|
172
|
+
because the idempotence probe checked the wrong path spelling.
|
|
173
|
+
|
|
7
174
|
## 0.5.1 — 2026-08-08
|
|
8
175
|
|
|
9
176
|
### Changed
|
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.
|
|
@@ -87,10 +87,33 @@ module Hibiki
|
|
|
87
87
|
spinner: "loading loading-spinner loading-xs",
|
|
88
88
|
warning_text: "text-warning",
|
|
89
89
|
error_text: "text-error text-sm mt-1",
|
|
90
|
+
# The full-form error summary. Plain Tailwind on purpose, so both
|
|
91
|
+
# styled variants share it via the merge — an addition (2026-08-09),
|
|
92
|
+
# not a transcription: the reference apps carried the scaffold-stock
|
|
93
|
+
# `style="color: red"` block, which --css=none still emits.
|
|
94
|
+
error_summary: "rounded-md border border-red-200 bg-red-50 p-4 mb-6",
|
|
95
|
+
error_summary_title: "text-sm font-semibold text-red-800",
|
|
96
|
+
error_summary_list: "mt-2 list-disc list-inside text-sm text-red-700",
|
|
90
97
|
muted: "opacity-60 italic",
|
|
91
98
|
muted_inline: "opacity-60",
|
|
92
99
|
counts: "ml-auto text-sm opacity-70",
|
|
93
|
-
|
|
100
|
+
# my-, not mt-: the control renders above the list as well as below,
|
|
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",
|
|
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"
|
|
94
117
|
}.freeze
|
|
95
118
|
|
|
96
119
|
# DaisyUI is a plugin over Tailwind, and the merge says so literally:
|
|
@@ -135,7 +158,16 @@ module Hibiki
|
|
|
135
158
|
muted: "text-gray-500 italic",
|
|
136
159
|
muted_inline: "text-gray-400",
|
|
137
160
|
counts: "ml-auto text-sm text-gray-500",
|
|
138
|
-
pagination_nav: "
|
|
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"
|
|
139
171
|
).freeze
|
|
140
172
|
|
|
141
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
|