hibiki_rails 0.5.1 → 0.6.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/lib/generators/hibiki/rails/css_variant.rb +11 -2
  4. data/lib/generators/hibiki/rails/scaffold/USAGE +0 -1
  5. data/lib/generators/hibiki/rails/scaffold_controller/USAGE +3 -5
  6. data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +18 -20
  7. data/lib/generators/hibiki/rails/scaffold_controller/templates/daisyui/views/_pagination.html.erb.tt +18 -12
  8. data/lib/generators/hibiki/rails/scaffold_controller/templates/none/views/_pagination.html.erb.tt +18 -12
  9. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/daisyui/views/pagination.rb.tt +14 -9
  10. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/none/views/pagination.rb.tt +14 -9
  11. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/field_error.rb.tt +8 -3
  12. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/form.rb.tt +2 -14
  13. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/form_errors.rb.tt +29 -0
  14. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/list.rb.tt +17 -3
  15. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row.rb.tt +2 -2
  16. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row_form.rb.tt +1 -1
  17. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/tailwind/views/pagination.rb.tt +14 -9
  18. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/channel.rb.tt +3 -1
  19. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/member_channel.rb.tt +7 -5
  20. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt +7 -5
  21. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_field_error.html.erb.tt +6 -1
  22. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_form.html.erb.tt +1 -11
  23. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_form_errors.html.erb.tt +24 -0
  24. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt +11 -3
  25. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +5 -4
  26. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +1 -1
  27. data/lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt +18 -12
  28. data/lib/generators/hibiki/rails/scaffold_helpers.rb +13 -6
  29. data/lib/generators/hibiki/rails/scaffold_model_injection.rb +6 -31
  30. data/lib/generators/hibiki/rails/scaffold_phlex_helpers.rb +5 -3
  31. data/lib/generators/hibiki/rails/scaffold_post_install.rb +4 -2
  32. data/lib/generators/hibiki/rails/scaffold_schema.rb +4 -11
  33. data/lib/generators/hibiki/rails/scaffold_shared_views.rb +76 -0
  34. data/lib/generators/hibiki/rails/scaffold_transport_stylesheet.rb +20 -8
  35. data/lib/hibiki/rails/engine.rb +8 -0
  36. data/lib/hibiki/rails/swallowed_write_warning.rb +43 -0
  37. data/lib/hibiki/rails/version.rb +1 -1
  38. data/lib/hibiki/rails.rb +30 -0
  39. metadata +7 -4
  40. 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: cd126c85e743e5ec9c89e54a1ffaba4cb04e6b6b0ef89868d60023168a0936a7
4
- data.tar.gz: 00b4dfa1606348df1039cf5b69d1129c170fd418966b3db120964b71eba1e1c6
3
+ metadata.gz: d708875aa67bf5d9b9137ac8d9bb05cf23604db936011706d068ec0b7116429c
4
+ data.tar.gz: 941b16f996127706d699e8812cf712b755c0f833f7a04c9e71912844421645ba
5
5
  SHA512:
6
- metadata.gz: 3c1e7d75c946f79cc898c20c2c3a8457ba78c152963f8b8155dd70a26308c138b31acc4b7a732e037d6bc28e933fb542a509d2dabee2e03602727d7e5e81b6d6
7
- data.tar.gz: 6edf4361a4edbda81b933167cb6a7e08030cb50aac694c601a22c2210036eab5c56b101a449d3ea174a5485347e7c9c6e1cc86f9da123cbfe02e0e1ed34f41dd
6
+ metadata.gz: 74175d980c70f1b4ea1bb9ba593ef9940326eccfce6c783ceefdebfb2a0659706faa1040393759549366c5880ea23459740e0f3f280e64fb47a4bc32a371f69c
7
+ data.tar.gz: c88a903f15b13516310beb32a7ed741942c49ae65823a6422fb36823400a9fea420ae08bd2fe047bc1d2e6ff3e38cef8f86c0665eeee6014e0f495c880ad90a4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,103 @@ 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.6.0 — 2026-08-10
8
+
9
+ ### Added
10
+
11
+ **`Hibiki::Rails.record_equals` — a per-signal comparator for ActiveRecord
12
+ records.** `ActiveRecord#==` compares class and id only, so a stale record is
13
+ `==` to its edited reload and a signal write carrying the fresh one is silently
14
+ dropped. Pass the comparator per signal and the write goes through:
15
+
16
+ ```ruby
17
+ state(:items, equals: Hibiki::Rails.record_equals) { fetch }
18
+ ```
19
+
20
+ It compares class + `attributes`, recurses through arrays, and falls back to
21
+ `==` for everything else. It needs hibiki 0.3.0's `equals:` (see the dependency
22
+ change below), which is honored at both of the graph's equality gates — the
23
+ write gate and the flush-time check — so a change that passes the comparator
24
+ actually reaches the page. This is opt-in sugar that makes the snapshot pattern
25
+ forgiving; "records stop at the boundary" stays the documented default, and a
26
+ comparator still cannot see an in-place mutation that never enters the write
27
+ path.
28
+
29
+ **A development-mode warning for the write that pattern swallows.** When a
30
+ `State` write is dropped by the default `==` but the old and new values'
31
+ `attributes` differ — the classic silent-stale-UI debugging session — the log
32
+ now says so and points at the docs. Development only (never test or
33
+ production), zero semantic change: the write is still dropped.
34
+
35
+ ### Changed
36
+
37
+ **The scaffold's row projection is gone — records now cross the boundary as
38
+ frozen snapshots.** `app/models/book_row.rb` is no longer generated. The query
39
+ object's `rows` returns real records, hardened at the boundary —
40
+
41
+ ```ruby
42
+ def rows
43
+ @rows ||= window_scope.strict_loading.map { it.readonly!; it.freeze }
44
+ end
45
+ ```
46
+
47
+ — and the channels' `rows`/`row` deriveds compare them with
48
+ `equals: Hibiki::Rails.record_equals`. What the `Data` projection's structural
49
+ `==` used to provide, the comparator provides; what it could never provide, the
50
+ freeze triple does: an attribute write raises `FrozenError`, `save` raises
51
+ `ActiveRecord::ReadOnlyRecord`, and an unpreloaded association walk raises
52
+ `ActiveRecord::StrictLoadingViolationError` instead of firing a lazy query off
53
+ the graph thread. Views print a `belongs_to` label as `book.author&.name`
54
+ directly, so **the scaffold no longer injects a `delegate` per `belongs_to`
55
+ into the model** — the model injection is the `after_commit` ping alone. The
56
+ member channel's fetch preloads its associations (`includes(...) +
57
+ strict_loading`) for the same reason.
58
+
59
+ Existing scaffolded apps keep working untouched: their `book_row.rb` and
60
+ delegates are app code, and the runtime reads none of it. Re-running a scaffold
61
+ with `--force` moves the resource over; the generator never deletes, so the
62
+ orphaned `*_row.rb` stays on disk for you to remove.
63
+
64
+ **The hibiki dependency floor is `~> 0.3`** (per-signal `equals:`, shipped in
65
+ hibiki 0.3.0). Everything 0.2 provided still holds; generated channels now rely
66
+ on the comparator being consulted at both equality gates.
67
+
68
+ **The page control and the field-error line are shared partials now.** Each
69
+ scaffold used to write its own copy per resource; both are presentation-only,
70
+ so they are emitted once per app instead — `app/views/shared/_pagination.html.erb`
71
+ and `_field_error.html.erb` (under `--phlex`: `Views::Shared::Pagination` and
72
+ `Views::Shared::FieldError` in `app/views/shared/`). Everything resource-shaped
73
+ reaches the page control as locals from the list partial. A second scaffold
74
+ finds the files present and leaves them; if they were generated under a
75
+ different `--css` style, a notice says so instead of silently restyling every
76
+ other resource's control.
77
+
78
+ **Pagination renders above the list as well as below**, so a long page starts
79
+ with a control in reach. Both copies live inside the re-rendered fragment and
80
+ carry distinct ids (`books_pagination_top` / `books_pagination`), so idiomorph
81
+ updates them in place.
82
+
83
+ Re-running a scaffold with `--force` moves the list over to the shared
84
+ partials and prints a notice naming the now-dead per-resource copies — the
85
+ generator never deletes them itself.
86
+
87
+ **The form's error summary is a shared partial too** —
88
+ `app/views/shared/_form_errors.html.erb` (under `--phlex`:
89
+ `Views::Shared::FormErrors`), rendered by every scaffolded full-page form. The
90
+ resource name comes off the record at render time, so one file serves every
91
+ scaffold. Under `--css=tailwind` and `--css=daisyui` the block is styled as a
92
+ red alert panel instead of the scaffold-stock `style="color: red"`, which
93
+ `--css=none` keeps.
94
+
95
+ ### Fixed
96
+
97
+ **The `hibiki_busy.css` import lands directly below `@import "tailwindcss";`**
98
+ instead of at the end of the entry stylesheet — appending placed it after
99
+ `@plugin` lines, and CSS requires every `@import` before any other statement.
100
+ Also fixed: re-running a scaffold in a tailwindcss-rails app
101
+ (`app/assets/tailwind/application.css`) duplicated the import on every run,
102
+ because the idempotence probe checked the wrong path spelling.
103
+
7
104
  ## 0.5.1 — 2026-08-08
8
105
 
9
106
  ### Changed
@@ -87,10 +87,19 @@ 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
- pagination_nav: "join mt-2 mx-auto flex justify-center w-fit"
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"
94
103
  }.freeze
95
104
 
96
105
  # DaisyUI is a plugin over Tailwind, and the merge says so literally:
@@ -135,7 +144,7 @@ module Hibiki
135
144
  muted: "text-gray-500 italic",
136
145
  muted_inline: "text-gray-400",
137
146
  counts: "ml-auto text-sm text-gray-500",
138
- pagination_nav: "mt-2 mx-auto flex justify-center w-fit -space-x-px rounded-md shadow-sm"
147
+ pagination_nav: "my-2 mx-auto flex justify-center w-fit -space-x-px rounded-md shadow-sm"
139
148
  ).freeze
140
149
 
141
150
  # Every lookup misses, so every class argument is omitted entirely.
@@ -19,7 +19,6 @@ Example:
19
19
  db/migrate/XXXXXXXXXXXX_create_books.rb
20
20
  app/models/book.rb
21
21
  app/models/book_query.rb
22
- app/models/book_row.rb
23
22
  app/channels/books_channel.rb
24
23
  app/channels/book_channel.rb
25
24
  app/forms/book_form.rb
@@ -9,17 +9,15 @@ Description:
9
9
  reactive, edits happen in place, and changes from anywhere update every open
10
10
  list.
11
11
 
12
- Two models are MODIFIED. This one gets a delegate for each belongs_to and
13
- an after_commit broadcast. The related models get the missing has_many
14
- association and a ping so changes are reflected here. Existing declarations
15
- are left alone.
12
+ Two models are MODIFIED. This one gets an after_commit broadcast. The
13
+ related models get the missing has_many association and a ping so changes
14
+ are reflected here. Existing declarations are left alone.
16
15
 
17
16
  Example:
18
17
  bin/rails generate hibiki:rails:scaffold_controller Book
19
18
 
20
19
  This will create:
21
20
  app/models/book_query.rb
22
- app/models/book_row.rb
23
21
  app/channels/books_channel.rb
24
22
  app/channels/book_channel.rb
25
23
  app/forms/book_form.rb
@@ -10,6 +10,7 @@ require_relative "../scaffold_parent_injection"
10
10
  require_relative "../scaffold_parent_notices"
11
11
  require_relative "../scaffold_phlex_helpers"
12
12
  require_relative "../scaffold_post_install"
13
+ require_relative "../scaffold_shared_views"
13
14
  require_relative "../scaffold_transport_stylesheet"
14
15
  require_relative "../scaffold_schema"
15
16
  require_relative "../css_variant"
@@ -36,6 +37,7 @@ module Hibiki
36
37
  include ScaffoldParentInjection
37
38
  include ScaffoldParentNotices
38
39
  include ScaffoldPostInstall
40
+ include ScaffoldSharedViews
39
41
  include ScaffoldTransportStylesheet
40
42
 
41
43
  TEMPLATE_ROOT = File.expand_path("templates", __dir__)
@@ -100,7 +102,6 @@ module Hibiki
100
102
 
101
103
  def create_models
102
104
  template "query.rb.tt", query_path
103
- template "row.rb.tt", row_path
104
105
  end
105
106
 
106
107
  def create_form
@@ -112,27 +113,26 @@ module Hibiki
112
113
  end
113
114
 
114
115
  def create_views
115
- return create_phlex_views if phlex?
116
-
117
- %w[index show new edit _form _list _controls _field_error].each do |view|
118
- template "views/#{view}.html.erb.tt", view_path("#{view}.html.erb")
116
+ if phlex?
117
+ create_phlex_views
118
+ else
119
+ %w[index show new edit _form _list _controls].each do |view|
120
+ template "views/#{view}.html.erb.tt", view_path("#{view}.html.erb")
121
+ end
122
+
123
+ template "views/_row.html.erb.tt", view_path("_#{row_partial}.html.erb")
124
+ template "views/_row_form.html.erb.tt", view_path("_#{row_form_partial}.html.erb")
119
125
  end
120
126
 
121
- template "views/_row.html.erb.tt", view_path("_#{row_partial}.html.erb")
122
- template "views/_row_form.html.erb.tt", view_path("_#{row_form_partial}.html.erb")
123
-
124
- # Under --infinite-scroll the sentinel is inlined in _list and there
125
- # is no page control at all — not an empty one.
126
- template "views/_pagination.html.erb.tt", view_path("_pagination.html.erb") unless infinite?
127
+ # The page control and the field-error line, once per app — see
128
+ # ScaffoldSharedViews.
129
+ create_shared_views
127
130
  end
128
131
 
129
132
  # The model being scaffolded — one of two files this generator modifies
130
- # that the app already owned.
131
- #
132
- # It is not optional, and not only about the ping: the row partial
133
- # prints an association's LABEL, and the show page hands that partial a
134
- # live record — so without the delegate the show page raises on arrival.
135
- # Once we are editing the model anyway, the ping belongs here too.
133
+ # that the app already owned. It gets the after_commit ping, which is
134
+ # what makes another tab, another user, the plain controller and a
135
+ # console write all reach an open list.
136
136
  #
137
137
  # The alternative — printing it and hoping — fails SILENTLY: everything
138
138
  # works in one tab, and cross-tab plus out-of-band writes simply never
@@ -181,11 +181,9 @@ module Hibiki
181
181
  #
182
182
  # Private, because a public method on a Thor generator is a command.
183
183
  def create_phlex_views
184
- %w[index show new edit form list row row_form controls field_error].each do |view|
184
+ %w[index show new edit form list row row_form controls].each do |view|
185
185
  template "views/#{view}.rb.tt", view_path("#{view}.rb")
186
186
  end
187
-
188
- template "views/pagination.rb.tt", view_path("pagination.rb") unless infinite?
189
187
  end
190
188
 
191
189
  # Ordered so an app's own lib/templates/... override wins first, then
@@ -1,16 +1,22 @@
1
1
  <%#- The ONE component with a per-variant template. DaisyUI expresses a page
2
2
  item as two tokens (`join-item btn`), so each of the six variants below can
3
- repeat them inline — which is exactly what plain Tailwind cannot do. -%>
4
- <%%# locals: (page: 1, page_count: 1) -%>
5
- <%%# The numbered page control, inside the replaced fragment so it re-renders
6
- with the list. The links are anchors to the list, so a click also does the
7
- browser's own fragment jump; data-turbo="false" keeps Turbo Drive out.
8
- `on` returns a { data: } hash — merge, don't splat, or the anchor stops
9
- scrolling. %>
3
+ repeat them inline — which is exactly what plain Tailwind cannot do.
4
+
5
+ Emitted ONCE per app into app/views/shared/, so nothing here may name the
6
+ resource: everything resource-shaped (the wrapper id, the anchor target,
7
+ the page-number window) arrives as a local from the list partial. The
8
+ `--css=` marker below is how a later scaffold run detects a variant
9
+ mismatch — keep it. -%>
10
+ <%%# locals: (page: 1, page_count: 1, pages: [], anchor:, id:) -%>
11
+ <%%# Shared page control (--css=<%= css_variant %>), rendered above and below
12
+ every scaffolded list. The links are anchors to the list, so a click also
13
+ does the browser's own fragment jump; data-turbo="false" keeps Turbo Drive
14
+ out. `on` returns a { data: } hash — merge, don't splat, or the anchor
15
+ stops scrolling. %>
10
16
  <%% page_data = ->(n) { { turbo: false }.merge(on(:go_to_page, with: { page: n })[:data]) } %>
11
17
  <%%# The wrapper always renders (so idiomorph has a node to pair with) and
12
18
  stays bare — layout classes here would leave dead space when empty. %>
13
- <div id="<%= pagination_dom_id %>">
19
+ <div id="<%%= id %>">
14
20
  <%% if page_count > 1 %>
15
21
  <nav<%= css_attr(:pagination_nav) %> aria-label="Pagination">
16
22
  <%%# Prev/next go inert at the ends rather than disappearing, so the
@@ -18,17 +24,17 @@
18
24
  <%% if page == 1 %>
19
25
  <span class="join-item btn btn-disabled" aria-disabled="true">«</span>
20
26
  <%% else %>
21
- <%%= link_to "«", "#<%= list_dom_id %>", class: "join-item btn", rel: "prev",
27
+ <%%= link_to "«", anchor, class: "join-item btn", rel: "prev",
22
28
  "aria-label": "Previous page", data: page_data.(page - 1) %>
23
29
  <%% end %>
24
30
 
25
- <%% <%= query_class_name %>.page_numbers(page: page, page_count: page_count).each do |n| %>
31
+ <%% pages.each do |n| %>
26
32
  <%% if n.nil? %>
27
33
  <span class="join-item btn btn-disabled" aria-hidden="true">…</span>
28
34
  <%% elsif n == page %>
29
35
  <span class="join-item btn btn-active" aria-current="page"><%%= n %></span>
30
36
  <%% else %>
31
- <%%= link_to n, "#<%= list_dom_id %>", class: "join-item btn",
37
+ <%%= link_to n, anchor, class: "join-item btn",
32
38
  "aria-label": "Page #{n}", data: page_data.(n) %>
33
39
  <%% end %>
34
40
  <%% end %>
@@ -36,7 +42,7 @@
36
42
  <%% if page == page_count %>
37
43
  <span class="join-item btn btn-disabled" aria-disabled="true">»</span>
38
44
  <%% else %>
39
- <%%= link_to "»", "#<%= list_dom_id %>", class: "join-item btn", rel: "next",
45
+ <%%= link_to "»", anchor, class: "join-item btn", rel: "next",
40
46
  "aria-label": "Next page", data: page_data.(page + 1) %>
41
47
  <%% end %>
42
48
  </nav>
@@ -3,16 +3,22 @@
3
3
  no stylesheet there is nothing to carry that signal, so the current page
4
4
  has to change ELEMENT — <strong> rather than a styled <span> — and the
5
5
  inert prev/next markers become plain text. A class-token map cannot
6
- express a change of tag name. -%>
7
- <%%# locals: (page: 1, page_count: 1) -%>
8
- <%%# The numbered page control, inside the replaced fragment so it re-renders
9
- with the list. The links are anchors to the list, so a click also does the
10
- browser's own fragment jump; data-turbo="false" keeps Turbo Drive out.
11
- `on` returns a { data: } hash — merge, don't splat, or the anchor stops
12
- scrolling. %>
6
+ express a change of tag name.
7
+
8
+ Emitted ONCE per app into app/views/shared/, so nothing here may name the
9
+ resource: everything resource-shaped (the wrapper id, the anchor target,
10
+ the page-number window) arrives as a local from the list partial. The
11
+ `--css=` marker below is how a later scaffold run detects a variant
12
+ mismatch — keep it. -%>
13
+ <%%# locals: (page: 1, page_count: 1, pages: [], anchor:, id:) -%>
14
+ <%%# Shared page control (--css=<%= css_variant %>), rendered above and below
15
+ every scaffolded list. The links are anchors to the list, so a click also
16
+ does the browser's own fragment jump; data-turbo="false" keeps Turbo Drive
17
+ out. `on` returns a { data: } hash — merge, don't splat, or the anchor
18
+ stops scrolling. %>
13
19
  <%% page_data = ->(n) { { turbo: false }.merge(on(:go_to_page, with: { page: n })[:data]) } %>
14
20
  <%%# The wrapper always renders, so idiomorph always has a node to pair with. %>
15
- <div id="<%= pagination_dom_id %>">
21
+ <div id="<%%= id %>">
16
22
  <%% if page_count > 1 %>
17
23
  <nav aria-label="Pagination">
18
24
  <%%# Prev/next go inert at the ends rather than disappearing, so the
@@ -20,24 +26,24 @@
20
26
  <%% if page == 1 %>
21
27
  <span aria-disabled="true">«</span>
22
28
  <%% else %>
23
- <%%= link_to "«", "#<%= list_dom_id %>", rel: "prev",
29
+ <%%= link_to "«", anchor, rel: "prev",
24
30
  "aria-label": "Previous page", data: page_data.(page - 1) %>
25
31
  <%% end %>
26
32
 
27
- <%% <%= query_class_name %>.page_numbers(page: page, page_count: page_count).each do |n| %>
33
+ <%% pages.each do |n| %>
28
34
  <%% if n.nil? %>
29
35
  <span aria-hidden="true">…</span>
30
36
  <%% elsif n == page %>
31
37
  <strong aria-current="page"><%%= n %></strong>
32
38
  <%% else %>
33
- <%%= link_to n, "#<%= list_dom_id %>", "aria-label": "Page #{n}", data: page_data.(n) %>
39
+ <%%= link_to n, anchor, "aria-label": "Page #{n}", data: page_data.(n) %>
34
40
  <%% end %>
35
41
  <%% end %>
36
42
 
37
43
  <%% if page == page_count %>
38
44
  <span aria-disabled="true">»</span>
39
45
  <%% else %>
40
- <%%= link_to "»", "#<%= list_dom_id %>", rel: "next",
46
+ <%%= link_to "»", anchor, rel: "next",
41
47
  "aria-label": "Next page", data: page_data.(page + 1) %>
42
48
  <%% end %>
43
49
  </nav>
@@ -1,18 +1,23 @@
1
- <%#- ESCAPING: none — plain Ruby out, every <% is generation time. -%>
1
+ <%#- ESCAPING: none — plain Ruby out, every <% is generation time. Emitted ONCE
2
+ per app into app/views/shared/, so nothing here may name the resource:
3
+ everything resource-shaped arrives as a keyword from the list component.
4
+ The `--css=` marker in the class comment is how a later scaffold run
5
+ detects a variant mismatch — keep it. -%>
2
6
  # frozen_string_literal: true
3
7
 
4
- # The numbered page control, inside the replaced fragment so it re-renders
5
- # with the list. The links are anchors to the list, so a click also does the
6
- # browser's own fragment jump; data-turbo="false" keeps Turbo Drive out.
7
- class <%= view_class_name(:pagination) %> < Views::Base
8
+ # Shared page control (--css=<%= css_variant %>), rendered above and below
9
+ # every scaffolded list. The links are anchors to the list, so a click also
10
+ # does the browser's own fragment jump; data-turbo="false" keeps Turbo Drive
11
+ # out.
12
+ class Views::Shared::Pagination < Views::Base
8
13
  <%= phlex_includes(:link_to, hibiki: true) %>
9
14
 
10
- <%= initializer_source([%w[page 1], %w[page_count 1]]) %>
15
+ <%= initializer_source([%w[page 1], %w[page_count 1], %w[pages []], ["anchor"], ["id"]]) %>
11
16
 
12
17
  def view_template
13
18
  # The wrapper always renders (so idiomorph has a node to pair with) and
14
19
  # stays bare — layout classes here would leave dead space when empty.
15
- div(id: "<%= pagination_dom_id %>") do
20
+ div(id: @id) do
16
21
  nav<%= arg_list(*css_args(:pagination_nav), %(aria_label: "Pagination")) %> { items } if @page_count > 1
17
22
  end
18
23
  end
@@ -25,7 +30,7 @@ class <%= view_class_name(:pagination) %> < Views::Base
25
30
  @page == 1 ? inert("«") : step("«", @page - 1, rel: "prev", label: "Previous page")
26
31
  whitespace
27
32
 
28
- <%= query_class_name %>.page_numbers(page: @page, page_count: @page_count).each do |n|
33
+ @pages.each do |n|
29
34
  if n.nil?
30
35
  span(class: "join-item btn btn-disabled", aria_hidden: "true") { "…" }
31
36
  elsif n == @page
@@ -48,7 +53,7 @@ class <%= view_class_name(:pagination) %> < Views::Base
48
53
  # The STRING "false", not the boolean — Phlex omits false-valued attributes.
49
54
  # And merge, don't splat: the anchor needs its own data attribute too.
50
55
  def step(text, n, label:, rel: nil)
51
- link_to text, "#<%= list_dom_id %>", class: "join-item btn", rel:, "aria-label": label,
56
+ link_to text, @anchor, class: "join-item btn", rel:, "aria-label": label,
52
57
  data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n })[:data])
53
58
  end
54
59
  end
@@ -1,20 +1,25 @@
1
- <%#- ESCAPING: none — plain Ruby out, every <% is generation time. -%>
1
+ <%#- ESCAPING: none — plain Ruby out, every <% is generation time. Emitted ONCE
2
+ per app into app/views/shared/, so nothing here may name the resource:
3
+ everything resource-shaped arrives as a keyword from the list component.
4
+ The `--css=` marker in the class comment is how a later scaffold run
5
+ detects a variant mismatch — keep it. -%>
2
6
  # frozen_string_literal: true
3
7
 
4
- # The numbered page control, inside the replaced fragment so it re-renders
5
- # with the list. The links are anchors to the list, so a click also does the
6
- # browser's own fragment jump; data-turbo="false" keeps Turbo Drive out.
8
+ # Shared page control (--css=<%= css_variant %>), rendered above and below
9
+ # every scaffolded list. The links are anchors to the list, so a click also
10
+ # does the browser's own fragment jump; data-turbo="false" keeps Turbo Drive
11
+ # out.
7
12
  #
8
13
  # Unstyled, so the current page changes ELEMENT (strong) — there is no class
9
14
  # to carry the signal.
10
- class <%= view_class_name(:pagination) %> < Views::Base
15
+ class Views::Shared::Pagination < Views::Base
11
16
  <%= phlex_includes(:link_to, hibiki: true) %>
12
17
 
13
- <%= initializer_source([%w[page 1], %w[page_count 1]]) %>
18
+ <%= initializer_source([%w[page 1], %w[page_count 1], %w[pages []], ["anchor"], ["id"]]) %>
14
19
 
15
20
  def view_template
16
21
  # The wrapper always renders, so idiomorph always has a node to pair with.
17
- div(id: "<%= pagination_dom_id %>") do
22
+ div(id: @id) do
18
23
  nav(aria_label: "Pagination") { items } if @page_count > 1
19
24
  end
20
25
  end
@@ -27,7 +32,7 @@ class <%= view_class_name(:pagination) %> < Views::Base
27
32
  @page == 1 ? inert("«") : step("«", @page - 1, rel: "prev", label: "Previous page")
28
33
  whitespace
29
34
 
30
- <%= query_class_name %>.page_numbers(page: @page, page_count: @page_count).each do |n|
35
+ @pages.each do |n|
31
36
  if n.nil?
32
37
  span(aria_hidden: "true") { "…" }
33
38
  elsif n == @page
@@ -50,7 +55,7 @@ class <%= view_class_name(:pagination) %> < Views::Base
50
55
  # The STRING "false", not the boolean — Phlex omits false-valued attributes.
51
56
  # And merge, don't splat: the anchor needs its own data attribute too.
52
57
  def step(text, n, label:, rel: nil)
53
- link_to text, "#<%= list_dom_id %>", rel:, "aria-label": label,
58
+ link_to text, @anchor, rel:, "aria-label": label,
54
59
  data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n })[:data])
55
60
  end
56
61
  end
@@ -1,10 +1,15 @@
1
1
  <%#- ESCAPING: a Phlex component is plain Ruby, so there is no runtime ERB here
2
2
  and nothing to double-escape. Every <% in this tree is generation time,
3
- which is the one genuine simplification the port buys. -%>
3
+ which is the one genuine simplification the port buys.
4
+
5
+ Emitted ONCE per app into app/views/shared/. The `--css=` marker in the
6
+ class comment is how a later scaffold run detects a variant mismatch —
7
+ keep it. -%>
4
8
  # frozen_string_literal: true
5
9
 
6
- # One field's live validation message, or nothing at all.
7
- class <%= view_class_name(:field_error) %> < Views::Base
10
+ # Shared field-error line (--css=<%= css_variant %>): one field's live
11
+ # validation message, or nothing at all.
12
+ class Views::Shared::FieldError < Views::Base
8
13
  <%= initializer_source([%w[message nil]]) %>
9
14
 
10
15
  def view_template
@@ -9,13 +9,13 @@
9
9
  # Each belongs_to collection arrives as a keyword, never an ivar.
10
10
  <% end -%>
11
11
  class <%= view_class_name(:form) %> < Views::Base
12
- <%= phlex_includes(:form_with, :pluralize) %>
12
+ <%= phlex_includes(:form_with) %>
13
13
 
14
14
  <%= initializer_source([[singular_name, nil], *schema.belongs_tos.map { [it.association_name.to_s.pluralize, "[]"] }]) %>
15
15
 
16
16
  def view_template
17
17
  form_with<%= arg_list("model: @#{singular_name}", *css_args(:form)) %> do |form|
18
- errors
18
+ <%= render_call(:form_errors, ["record: @#{singular_name}"], indent: 6, shared: true) %>
19
19
  fields(form)
20
20
 
21
21
  div { form.submit<%= css? ? %( class: "#{css(:btn_primary)}") : "" %> }
@@ -24,18 +24,6 @@ class <%= view_class_name(:form) %> < Views::Base
24
24
 
25
25
  private
26
26
 
27
- def errors
28
- return if @<%= singular_name %>.errors.empty?
29
-
30
- div(style: "color: red") do
31
- h2 { "#{pluralize(@<%= singular_name %>.errors.count, 'error')} prohibited this <%= human_name.downcase %> from being saved:" }
32
-
33
- ul do
34
- @<%= singular_name %>.errors.each { |error| li { error.full_message } }
35
- end
36
- end
37
- end
38
-
39
27
  def fields(form)
40
28
  div<%= arg_list(*css_args(:fieldset)) %> do
41
29
  <% schema.columns.each_with_index do |column, index| -%>
@@ -0,0 +1,29 @@
1
+ <%#- ESCAPING: a Phlex component is plain Ruby, so there is no runtime ERB here
2
+ and nothing to double-escape.
3
+
4
+ Emitted ONCE per app into app/views/shared/. The `--css=` marker in the
5
+ class comment is how a later scaffold run detects a variant mismatch —
6
+ keep it. Under --css=none the body is the scaffold-stock red block; the
7
+ styled variants dress the same structure. -%>
8
+ # frozen_string_literal: true
9
+
10
+ # Shared error summary (--css=<%= css_variant %>): every message that stopped
11
+ # the record from saving, or nothing at all. The resource name comes off the
12
+ # record, so one component serves every scaffold.
13
+ class Views::Shared::FormErrors < Views::Base
14
+ <%= phlex_includes(:pluralize) %>
15
+
16
+ <%= initializer_source([%w[record]]) %>
17
+
18
+ def view_template
19
+ return if @record.errors.empty?
20
+
21
+ div<%= css? ? arg_list(*css_args(:error_summary)) : %[(style: "color: red")] %> do
22
+ h2<%= arg_list(*css_args(:error_summary_title)) %> { "#{pluralize(@record.errors.count, 'error')} prohibited this #{@record.model_name.human.downcase} from being saved:" }
23
+
24
+ ul<%= arg_list(*css_args(:error_summary_list)) %> do
25
+ @record.errors.each { |error| li { error.full_message } }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -15,6 +15,13 @@ class <%= view_class_name(:list) %> < Views::Base
15
15
 
16
16
  def view_template
17
17
  div<%= arg_list(%(id: "#{list_dom_id}"), *css_args(:list)) %> do
18
+ <% unless infinite? -%>
19
+ # Both page-control copies live inside the replaced fragment on purpose,
20
+ # so they re-render with the list; their distinct ids keep idiomorph
21
+ # from rebuilding them.
22
+ pagination("<%= pagination_dom_id %>_top")
23
+
24
+ <% end -%>
18
25
  @<%= controller_file_name %>.each do |<%= singular_name %>|
19
26
  <%= render_call(:row, row_render_args(singular_name), indent: 8) %>
20
27
  end
@@ -26,12 +33,19 @@ class <%= view_class_name(:list) %> < Views::Base
26
33
  <% if infinite? -%>
27
34
  load_more if @remaining.positive?
28
35
  <% else -%>
29
- # Inside the replaced fragment on purpose, so it re-renders with the
30
- # list. Stable child ids keep idiomorph from rebuilding it.
31
- <%= render_call(:pagination, ["page: @page", "page_count: @page_count"], indent: 6) %>
36
+ pagination("<%= pagination_dom_id %>")
32
37
  <% end -%>
33
38
  end
34
39
  end
40
+ <% unless infinite? -%>
41
+
42
+ private
43
+
44
+ def pagination(id)
45
+ pages = <%= query_class_name %>.page_numbers(page: @page, page_count: @page_count)
46
+ <%= render_call(:pagination, ["page: @page", "page_count: @page_count", "pages: pages", %(anchor: "##{list_dom_id}"), "id: id"], indent: 4, shared: true) %>
47
+ end
48
+ <% end -%>
35
49
  <% if infinite? -%>
36
50
 
37
51
  private
@@ -6,8 +6,8 @@
6
6
 
7
7
  # One row, in either of its two modes.
8
8
  #
9
- # `@<%= singular_name %>` is duck-typed: the row projection from the index,
10
- # the model from the show page. Rendered by the channel through
9
+ # `@<%= singular_name %>` is a record either way: a frozen read-only snapshot
10
+ # from the channel, live from the show page. Rendered by the channel through
11
11
  # ApplicationController.render, which has no controller context — everything
12
12
  # this component branches on is a keyword argument instead.
13
13
  class <%= view_class_name(:row) %> < Views::Base
@@ -57,7 +57,7 @@ class <%= view_class_name(:row_form) %> < Views::Base
57
57
  <% else -%>
58
58
  <%= column.tag_field_helper %> <%= wrapped_list(row_field_args(column), indent: column.tag_field_helper.length + 5).lstrip %>
59
59
  <% end -%>
60
- <%= render_call(:field_error, [%(message: #{form_ref}.error_for(:#{column.name}))], indent: 4) %>
60
+ <%= render_call(:field_error, [%(message: #{form_ref}.error_for(:#{column.name}))], indent: 4, shared: true) %>
61
61
  <% end -%>
62
62
  end
63
63