hibiki_rails 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 514349fcd04fc813cc4833144b4355c56958e9055dda33aefbdc547517d73bfd
4
- data.tar.gz: 1a9729ad926d72f9bdfb1b4411544893f59c3ebb40d2dd552165bed1299218e5
3
+ metadata.gz: 6b163c0d20f8fcca4839e8cfcae98316cae9b8d01bd4f81eece18e02a9ae39be
4
+ data.tar.gz: bf61ca3db0cd1ad769a7f5ee931a06362124841e4d62ba0bd55856d892511b85
5
5
  SHA512:
6
- metadata.gz: eb8e0207e6b1281f7085e7258a9ed189f836b0427ea3172acf3711b118f80f1a0a8fa97474d5f4ff4deaf69ef0bec8d6083f3b09a3ee1178cb836335453f3d76
7
- data.tar.gz: 1f4c370144b71c7baea0b3960d69d761b562f6590f281b079f1bf9a26173e7d83c415e262924030e02e4d5bd62876eab7b62c2a12b8a145298ea1d60ff2a5480
6
+ metadata.gz: 317d9a267de7853075cc35f089099f5f5fc89060afa2db3afd298d8b955f5dd441fdd90d1d2780be561280c6a9e514f827901112ede534a6dd82f06cf763e9a3
7
+ data.tar.gz: 2623fa07782f9d5264592c4bc3e977cb229967b1810f1b05c121bd16dbbc546335c420fd855751ae0dda71e6a148b648623a010344dbec21283f764f76d9f813
data/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ 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.14.0 — 2026-09-07
8
+
9
+ ### Changed
10
+
11
+ **The infinite-scroll Load-more control has a degraded path.** Under
12
+ `--infinite-scroll` the scaffold emitted one button that answered both a
13
+ click and the `visible` sentinel event, and gave it no `fallback:` on
14
+ purpose: a sentinel that navigates on a dead socket would make scrolling
15
+ navigate. Without JavaScript the button rendered and did nothing. The control
16
+ is now two elements. The wrapper (`books_load_more`, the same id) is the
17
+ sentinel and carries only `visible`. A link inside it is the click path, with
18
+ `fallback: true` and a real href: the query already reads `?page=N` as a
19
+ growing window, so a degraded click shows every window up to N, and the
20
+ link's fragment lands on the last row the reader had already seen. The JS
21
+ client is unchanged.
22
+
23
+ - Both view layers. The Phlex component uses a bare `a`, so no `LinkTo`
24
+ include; `page_url` is now emitted in both pagination modes.
25
+ - Regenerating an app changes only `_list.html.erb` / `list.rb`. A system
26
+ test that clicked `#books_load_more button` now clicks the `a`.
27
+ - Docs: the fallback contract in [The JS client] gains the rule — never give
28
+ a `visible` control a `fallback:`; split it as the scaffold does.
29
+
30
+ ## 0.13.0 — 2026-09-07
31
+
32
+ ### Changed
33
+
34
+ **The scaffold's query object lives in `app/queries`.** `hibiki:rails:scaffold`
35
+ and `scaffold_controller` write `app/queries/book_query.rb` where they wrote
36
+ `app/models/book_query.rb`. The constant is unchanged. `app/queries` is the
37
+ directory the query-object gems (ARQO, querifier, query_delegator) already
38
+ generate into, so a query one of them writes for the same model meets ours as
39
+ a file conflict instead of a second `BookQuery` that Rails loads first. The
40
+ move costs no extra restart: the scaffold already creates `app/forms`, and one
41
+ restart picks up both.
42
+
43
+ - Apps scaffolded earlier need nothing. Move the file with `git mv` when
44
+ convenient; `hibiki:rails:multiselect` and `upload_field` find it in either
45
+ place.
46
+ - A `--force` re-run writes `app/queries`. Rails autoloads `app/models` first,
47
+ so the post-install output asks you to delete the old file.
48
+
7
49
  ## 0.12.0 — 2026-09-06
8
50
 
9
51
  ### Added
data/README.md CHANGED
@@ -112,8 +112,8 @@ once per app, shared by every generated resource, and wired into your stylesheet
112
112
  or layout automatically — the post-install output says which, or gives you the
113
113
  line to add when it cannot tell.
114
114
 
115
- Restart the server afterwards: `app/forms/` is new, and Rails works out its
116
- autoload paths at boot.
115
+ Restart the server afterwards: `app/forms/` and `app/queries/` are new, and
116
+ Rails works out its autoload paths at boot.
117
117
 
118
118
  ### Render the reactive component
119
119
 
@@ -98,7 +98,7 @@ module Hibiki
98
98
 
99
99
  # Rows are strict_loading, so the display line raises without these.
100
100
  def inject_query_preload
101
- inject_includes query_path,
101
+ inject_includes existing_query_path,
102
102
  with_existing: /matched_scope\.includes\(([^)]*)\)/,
103
103
  without: "apply_sort(matched_scope)",
104
104
  wrapped: "apply_sort(matched_scope.includes(:#{association_name}))"
@@ -18,7 +18,7 @@ Example:
18
18
  This will create:
19
19
  db/migrate/XXXXXXXXXXXX_create_books.rb
20
20
  app/models/book.rb
21
- app/models/book_query.rb
21
+ app/queries/book_query.rb
22
22
  app/channels/books_channel.rb
23
23
  app/channels/book_channel.rb
24
24
  app/forms/book_form.rb
@@ -28,7 +28,8 @@ Example:
28
28
  And add to config/routes.rb:
29
29
  resources :books
30
30
 
31
- Restart the server afterwards if it is running — app/forms is new.
31
+ Restart the server afterwards if it is running — app/forms and
32
+ app/queries are new.
32
33
 
33
34
  Pass --phlex to emit Phlex components under app/views/books/*.rb instead of
34
35
  ERB templates. The view layer is the only thing it changes; it needs the
@@ -17,7 +17,7 @@ Example:
17
17
  bin/rails generate hibiki:rails:scaffold_controller Book
18
18
 
19
19
  This will create:
20
- app/models/book_query.rb
20
+ app/queries/book_query.rb
21
21
  app/channels/books_channel.rb
22
22
  app/channels/book_channel.rb
23
23
  app/forms/book_form.rb
@@ -86,7 +86,7 @@ module Hibiki
86
86
  # Captured BEFORE anything is written, so post_install can tell which
87
87
  # app/* directories are new — Rails computes autoload paths from that
88
88
  # glob at boot, and a new one needs a restart.
89
- @new_app_dirs = %w[app/forms app/channels app/models app/views].reject { exists?(it) }
89
+ @new_app_dirs = %w[app/forms app/queries app/channels app/models app/views].reject { exists?(it) }
90
90
  end
91
91
 
92
92
  # One file per app, not per resource, and the only thing this generator
@@ -61,10 +61,11 @@ class <%= view_class_name(:list) %> < Views::Base
61
61
  pages = <%= query_class_name %>.page_numbers(page: @page, page_count: @page_count)
62
62
  <%= render_call(:pagination, ["page: @page", "page_count: @page_count", "pages: pages", %(anchor: "##{list_dom_id}"), "id: id", "url: page_url"], indent: 4, shared: true) %>
63
63
  end
64
+ <% end -%>
64
65
 
65
- # Real pagination hrefs carrying the current query state — the channel
66
- # re-renders them with url_params fresh, so the fallback URLs stay
67
- # truthful as the live state moves.
66
+ # Real hrefs for the <%= infinite? ? "Load-more link" : "page links" %>, carrying the current query
67
+ # state — the channel re-renders them with url_params fresh, so the
68
+ # fallback URLs stay truthful as the live state moves.
68
69
  def page_url
69
70
  ->(n) do
70
71
  query = @url_params.except(:page)
@@ -72,19 +73,22 @@ class <%= view_class_name(:list) %> < Views::Base
72
73
  <%= index_helper(type: :path) %>(**query)
73
74
  end
74
75
  end
75
- <% end -%>
76
76
  <% if infinite? -%>
77
77
 
78
- # `visible` is a pseudo-event backed by an IntersectionObserver, so this
79
- # button doubles as the infinite-scroll sentinel. The client stamps
80
- # data-hibiki-busy on it while the next page is in flight.
78
+ # `visible` is a pseudo-event backed by an IntersectionObserver: the wrapper
79
+ # is the scroll sentinel, the link inside it is the click path and its href
80
+ # the fallback. A sentinel must never fall back — scrolling into view on a
81
+ # dead socket would navigate.
81
82
  def load_more
82
- div<%= arg_list(%(id: "#{load_more_dom_id}"), *css_args(:load_more_wrap)) %> do
83
- button<%= arg_list(%(type: "button"), *css_args(:btn_wide), "**on(:go_to_page, event: %i[click visible], with: { page: @page + 1 })", indent: 13) %> do
83
+ div<%= arg_list(%(id: "#{load_more_dom_id}"), *css_args(:load_more_wrap), "**on(:go_to_page, event: :visible, with: { page: @page + 1 })", indent: 8) %> do
84
+ # The STRING "false", not the boolean — Phlex omits false-valued attributes.
85
+ a<%= arg_list("href: load_more_href", *css_args(:btn_wide), "data: { turbo: \"false\" }.merge(on(:go_to_page, with: { page: @page + 1 },\n#{' ' * 42}fallback: true)[:data])", indent: 8) %> do
84
86
  plain "Load more"
85
87
  span<%= arg_list(*css_args(:muted_inline)) %> { " · #{@remaining} remaining" }
86
88
  end
87
89
  end
88
90
  end
91
+
92
+ def load_more_href = "#{page_url.(@page + 1)}#<%= row_dom_id_prefix %>_#{@<%= controller_file_name %>.last.id}"
89
93
  <% end -%>
90
94
  end
@@ -3,16 +3,14 @@
3
3
  <%%# locals: (<%= list_locals_signature %>) -%>
4
4
  <%%# The island's re-rendered fragment. Its root id is the broadcast target —
5
5
  deliberately not the island root, which carries the stream source. %>
6
- <% unless infinite? -%>
7
- <%%# Real pagination hrefs carrying the current query state — the channel
8
- re-renders them with url_params fresh, so the fallback URLs stay
9
- truthful as the live state moves. %>
6
+ <%%# Real hrefs for the <%= infinite? ? "Load-more link" : "page links" %>, carrying the current query
7
+ state — the channel re-renders them with url_params fresh, so the
8
+ fallback URLs stay truthful as the live state moves. %>
10
9
  <%% page_url = ->(n) do
11
10
  query = url_params.except(:page)
12
11
  query[:page] = n unless n == 1
13
12
  <%= index_helper(type: :path) %>(**query)
14
13
  end %>
15
- <% end -%>
16
14
  <div id="<%= list_dom_id %>"<%= css_attr(:list) %>>
17
15
  <% if inline_create? -%>
18
16
  <%%# The inline create form — only the channel ever renders it (the New
@@ -44,20 +42,25 @@
44
42
  <%% end %>
45
43
 
46
44
  <% if infinite? -%>
47
- <%%# `visible` is a pseudo-event backed by an IntersectionObserver, so this
48
- button doubles as the infinite-scroll sentinel. The client stamps
49
- data-hibiki-busy on it while the next page is in flight. %>
45
+ <%%# `visible` is a pseudo-event backed by an IntersectionObserver: the wrapper
46
+ is the scroll sentinel, the link inside it is the click path and its href
47
+ the fallback. A sentinel must never fall back — scrolling into view on a
48
+ dead socket would navigate. %>
50
49
  <%% if remaining.positive? %>
51
- <div id="<%= load_more_dom_id %>"<%= css_attr(:load_more_wrap) %>>
52
- <%%= tag.button(type: "button",
50
+ <%%= tag.div(id: "<%= load_more_dom_id %>",
53
51
  <% if css? -%>
54
- class: "<%= css(:btn_wide) %>",
52
+ class: "<%= css(:load_more_wrap) %>",
55
53
  <% end -%>
56
- **on(:go_to_page, event: %i[click visible],
57
- with: { page: page + 1 })) do %>
54
+ **on(:go_to_page, event: :visible, with: { page: page + 1 })) do %>
55
+ <%%= link_to "#{page_url.(page + 1)}#<%= row_dom_id_prefix %>_#{<%= controller_file_name %>.last.id}",
56
+ <% if css? -%>
57
+ class: "<%= css(:btn_wide) %>",
58
+ <% end -%>
59
+ data: { turbo: false }.merge(on(:go_to_page, with: { page: page + 1 },
60
+ fallback: true)[:data]) do %>
58
61
  Load more<span<%= css_attr(:muted_inline) %>>&nbsp;·&nbsp;<%%= remaining %> remaining</span>
59
62
  <%% end %>
60
- </div>
63
+ <%% end %>
61
64
  <%% end %>
62
65
  <% else -%>
63
66
  <%%= render "shared/pagination", page: page, page_count: page_count, pages: pages,
@@ -49,19 +49,26 @@ module Hibiki
49
49
  def scaffold_controller_class_name = "#{controller_class_name}Controller"
50
50
 
51
51
  # ---- destinations ----------------------------------------------------
52
- #
53
- # The query object goes in app/models, NOT a new app/queries: Rails
54
- # computes autoload paths from the app/* glob at boot, so a new
55
- # top-level directory is not autoloadable until a server restart.
56
- # app/forms already costs one restart; two would be gratuitous.
57
52
 
58
53
  def view_dir = File.join("app/views", controller_file_path)
59
54
  def collection_channel_path = File.join("app/channels", "#{controller_file_path}_channel.rb")
60
55
  def member_channel_path = File.join("app/channels", *class_path, "#{file_name}_channel.rb")
61
56
  def model_path = File.join("app/models", *class_path, "#{file_name}.rb")
62
- def query_path = File.join("app/models", *class_path, "#{file_name}_query.rb")
57
+ def query_path = File.join("app/queries", *class_path, "#{file_name}_query.rb")
63
58
  def form_path = File.join("app/forms", *class_path, "#{file_name}_form.rb")
64
59
 
60
+ # Scaffolds before 0.13.0 wrote the query object to app/models. The
61
+ # add-on generators read existing_query_path so their injections land
62
+ # in the file the app loads; the scaffold itself always writes query_path.
63
+ def legacy_query_path = File.join("app/models", *class_path, "#{file_name}_query.rb")
64
+
65
+ def existing_query_path
66
+ return query_path if File.exist?(File.join(destination_root, query_path))
67
+ return legacy_query_path if File.exist?(File.join(destination_root, legacy_query_path))
68
+
69
+ query_path
70
+ end
71
+
65
72
  def scaffold_controller_path
66
73
  File.join("app/controllers", "#{controller_file_path}_controller.rb")
67
74
  end
@@ -23,6 +23,7 @@ module Hibiki
23
23
  # stylesheet, a leftover file. None of them is about the resource.
24
24
  def app_notices
25
25
  restart_notice
26
+ stale_query_notice
26
27
  # These live with the code that chose their branch, like
27
28
  # parent_notices — the outcomes are those modules' vocabulary.
28
29
  stylesheet_notice
@@ -49,6 +50,16 @@ module Hibiki
49
50
  "Please restart if the server is running.", :yellow
50
51
  end
51
52
 
53
+ # Scaffolds before 0.13.0 wrote the query object to app/models, which
54
+ # autoloads ahead of app/queries: until the old copy goes, it is the one
55
+ # the app loads. A generator never deletes.
56
+ def stale_query_notice
57
+ return unless exists?(legacy_query_path)
58
+
59
+ say_status :stale, "#{legacy_query_path} has moved to #{query_path}. " \
60
+ "Please delete the old file.", :yellow
61
+ end
62
+
52
63
  def rebuild_css_notice
53
64
  return unless css?
54
65
 
@@ -103,13 +103,14 @@ module Hibiki
103
103
  # Rows are strict_loading and frozen, so the thumbnail raises without
104
104
  # these — on the index AND on show-page repaints (the member channel).
105
105
  def inject_query_preload
106
- return if wired?(query_path, /\.#{with_attached}\b/)
106
+ path = existing_query_path
107
+ return if wired?(path, /\.#{with_attached}\b/)
107
108
 
108
- unless wired?(query_path, QUERY_WINDOW_SCOPE)
109
- return manual_wiring(query_path, " # preload the attachment:\n .#{with_attached}")
109
+ unless wired?(path, QUERY_WINDOW_SCOPE)
110
+ return manual_wiring(path, " # preload the attachment:\n .#{with_attached}")
110
111
  end
111
112
 
112
- gsub_file(query_path, QUERY_WINDOW_SCOPE) do |match|
113
+ gsub_file(path, QUERY_WINDOW_SCOPE) do |match|
113
114
  head, scope, tail = match.match(QUERY_WINDOW_SCOPE).captures
114
115
  "#{head}#{scope}.#{with_attached}#{tail}"
115
116
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.12.0"
5
+ VERSION = "0.14.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hibiki_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska