From 46cb88137a2a71dbeb479a8e413b4d3252aa4450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Fri, 6 Nov 2026 11:54:38 +0100 Subject: [PATCH] derive_trails false, and a model name for records without to_s A trail class can now set `derive_trails false`: a view without a `breadcrumb` call then gets an empty trail, as with gretel. Subclasses inherit it. The verifier no longer counts crumbs only a derived trail would read as used in such a class, and Crumbly::Oracle lists no call as redundant there. A derived record level, or a derived parent record level, whose model has no to_s of its own is labelled "#{model_name.human} #{to_param}" ("Invoice 12") instead of "#". Co-Authored-By: Claude Opus 5.5 --- README.md | 23 ++++++++++++++++++--- docs/guide.html | 16 ++++++++++++--- docs/index.html | 2 +- docs/migration.html | 5 +++-- lib/crumbly/trail.rb | 25 ++++++++++++++++++++-- lib/crumbly/verifier.rb | 3 ++- lib/crumbly/view_helpers.rb | 3 ++- test/oracle_test.rb | 10 +++++++++ test/test_helper.rb | 41 +++++++++++++++++++++++++++++++++++++ test/trail_test.rb | 33 +++++++++++++++++++++++++++++ test/verify_test.rb | 12 +++++++++++ 11 files changed, 160 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index fac1aff..d403870 100644 --- a/README.md +++ b/README.md @@ -169,13 +169,14 @@ When a view doesn't call `breadcrumb`, Crumbly builds the trail from the action: | Action | Trail | |---|---| | `index` | the collection: your `posts` crumb if you wrote one, or else the model's plural name linked to the `index` route | -| `show` | the record: your `post` crumb, or else its `to_s` linked to the `show` route, under the collection | +| `show` | the record: your `post` crumb, or else its label linked to the `show` route, under the collection | | `new`, `create` | "New" under the collection | | `edit`, `update` | "Edit" under the record | The record is the one the controller has already loaded, `@post` in `PostsController`. Crumbly never runs a query to find it. "New" and "Edit" come from the `crumbly.new` and `crumbly.edit` translations, so you can translate -them. +them. A record's label is its `to_s`. A model without a `to_s` of its own gets its translated model name and id +instead ("Post 5"), not `#`. Nested routes work too. On `/admin/posts/5/comments`, the trail is Posts › Post 5 › Comments, and the post is the `@post` the controller loaded. If the controller didn't load it, Crumbly skips that level and keeps Posts. @@ -183,6 +184,19 @@ the `@post` the controller loaded. If the controller didn't load it, Crumbly ski To change how these trails are built, override the private methods `collection_crumb`, `record_crumb`, `new_record_crumb` or `edit_record_crumb` in your trail class. +To turn them off, set `derive_trails false` in the trail class. A view that doesn't call `breadcrumb` then has no +trail, as with gretel: `breadcrumb_trail` returns `[]` and `breadcrumb_json_ld` returns `nil`. + +```ruby +module Admin + class Breadcrumbs < Crumbly::Trail + derive_trails false + end +end +``` + +Subclasses inherit the setting, and `derive_trails true` turns it back on in one of them. + ### Which crumb wins Crumbly takes the first of these that applies: @@ -194,6 +208,8 @@ Crumbly takes the first of these that applies: 3. The trail Crumbly works out. Its parent levels follow the same rule: your crumb for the parent's model if there is one, or else a level Crumbly builds. +With `derive_trails false`, only the first applies. + ## Testing your trails Add one test: @@ -218,7 +234,7 @@ fails when: - a view or another crumb calls a crumb with the wrong number of arguments (calls spread over several lines, or with a splat, are skipped); - a crumb is never used. A crumb is used when another crumb, a view or a controller calls it, when a trail - Crumbly works out reads it, or when it is named after a model. + Crumbly works out reads it (unless its class sets `derive_trails false`), or when it is named after a model. A crumb defined twice in the same class is caught even earlier: the class raises `Crumbly::DuplicateCrumb` when it loads. @@ -296,6 +312,7 @@ It prints each line it left out or couldn't convert exactly, and with `--views`, Crumbly may now make unnecessary. It needs Prism 1.2 or later: Ruby 3.4 comes with it, and on Ruby 3.3 you add `gem "prism"` to the development group. While gretel is still installed on the migration branch, `Crumbly::Oracle` draws each trail your tests render with both gems and lists where the labels or URLs differ. +To keep gretel's empty trail on pages whose views name no crumb, set `derive_trails false` in the trail class. The [migration guide](https://levups.github.io/crumbly/migration.html) has every step: what the converter maps to what, what it leaves to you, the comparison in your tests, and the same walk-through for loaf and diff --git a/docs/guide.html b/docs/guide.html index 4f0aae5..dbe8c16 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -145,13 +145,14 @@

Trails Crumbly works out on its own

ActionTrail indexThe collection: your posts crumb if you wrote one, or else the model's plural name linked to the index route. - showThe record: your post crumb, or else its to_s linked to the show route, under the collection. + showThe record: your post crumb, or else its label linked to the show route, under the collection. new, create"New" under the collection. edit, update"Edit" under the record.

The record is the one the controller has already loaded, @post in PostsController. Crumbly never runs a query to find it, and without it show and edit get no trail. A controller with no index or show route still gets its level, without a link. Other actions get nothing.

Labels

+

A record's label is its to_s. A model without a to_s of its own gets its model name, translated by model_name.human, and its id instead: "Post 5", not #<Post:0x000...>.

"New" and "Edit" come from the crumbly.new and crumbly.edit translations:

# config/locales/es.yml
 es:
@@ -170,8 +171,16 @@ 

Overrides

end end

Nested routes

-

Under a nested route, each :<name>_id path parameter adds a parent level, outermost first. On /admin/posts/5/comments, the trail is Posts › Post 5 › Comments, and the post is the @post the controller loaded. Its level is your post crumb, or else its to_s linked to its show route.

+

Under a nested route, each :<name>_id path parameter adds a parent level, outermost first. On /admin/posts/5/comments, the trail is Posts › Post 5 › Comments, and the post is the @post the controller loaded. Its level is your post crumb, or else its label linked to its show route.

If the controller didn't load a parent, Crumbly doesn't query it. It skips that level and the crumb named after the nesting, and keeps the parent's collection: Posts › Comments.

+

Turning them off

+

To draw only the trails your views name, set derive_trails false in the trail class. A view that doesn't call breadcrumb then has no trail, as with gretel: breadcrumb_trail returns [] and breadcrumb_json_ld returns nil.

+
module Admin
+  class Breadcrumbs < Crumbly::Trail
+    derive_trails false
+  end
+end
+

Subclasses inherit the setting, and derive_trails true turns it back on in one of them. The breadcrumbs test no longer counts a crumb as used because a derived trail would read it, and Crumbly::Oracle lists no call as redundant.

@@ -184,6 +193,7 @@

Which crumb wins

  • Your crumb named after the controller: posts in Admin::PostsController#index. Under a nested route it is named after the whole nesting and gets the parent records, so /admin/orders/42/messages calls order_messages(order). On show and edit, it is the crumb named after the record's model (post).
  • The trail Crumbly works out. Its parent levels follow the same rule: your crumb for the parent's model if there is one, or else a level Crumbly builds.
  • +

    With derive_trails false, only the first applies.

    @@ -205,7 +215,7 @@

    Testing your trails

  • a crumb's parent is not a crumb, or a _path or _url it calls is not a route helper;
  • a breadcrumb :name in app/views or app/controllers names a crumb that no trail class defines;
  • a view or another crumb calls a crumb with the wrong number of arguments. gretel ignored extra arguments and passed nil for missing ones, but a method raises ArgumentError. Calls spread over several lines, or with a splat, are skipped;
  • -
  • a crumb is never used. A crumb is used when another crumb, a view or a controller calls it (breadcrumb :name, breadcrumb @record), when a trail Crumbly works out reads it, or when it is named after a model.
  • +
  • a crumb is never used. A crumb is used when another crumb, a view or a controller calls it (breadcrumb :name, breadcrumb @record), when a trail Crumbly works out reads it (unless its class sets derive_trails false), or when it is named after a model.
  • A crumb defined twice in the same class is caught even earlier: the class raises Crumbly::DuplicateCrumb when it loads.

    Outside Minitest, Crumbly.verify! runs the same checks. It raises Crumbly::InvalidTrail with every problem in one message, and returns the unused crumbs (["Admin::Breadcrumbs#drafts"]). Both accept trail_classes:, routes: and sources:, the directories it scans for breadcrumb calls.

    diff --git a/docs/index.html b/docs/index.html index 7154b25..995ba2e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -97,7 +97,7 @@

    Plain Ruby methods

    Trails you don't write

    -

    When a view names no crumb, the trail comes from the action, the routes and the record the controller loaded. Nested routes work too.

    +

    When a view names no crumb, the trail comes from the action, the routes and the record the controller loaded. Nested routes work too, and derive_trails false turns it off.

    Trails Crumbly works out

    diff --git a/docs/migration.html b/docs/migration.html index 75ffac8..247cb02 100644 --- a/docs/migration.html +++ b/docs/migration.html @@ -81,7 +81,7 @@

    2. Compare the trails

    Minitest.after_run do puts Crumbly::Oracle.differences.to_a, Crumbly::Oracle.redundant_calls.to_a end -

    differences lists each page whose trail changed, with gretel's and Crumbly's side by side. It includes pages with no breadcrumb call, which had no trail with gretel and get one now. gretel shows a crumb's extra links as levels of their own, and the comparison does the same with links. A call that gretel can't draw is listed too, with gretel's error. redundant_calls lists the breadcrumb calls you can delete, because the page gets the same trail without them. watch { ... } watches a block only. Parallel test workers each keep their own lists, and after_run in the parent process doesn't see them, so run the watched suite with PARALLEL_WORKERS=1.

    +

    differences lists each page whose trail changed, with gretel's and Crumbly's side by side. It includes pages with no breadcrumb call, which had no trail with gretel and get one now, unless the trail class sets derive_trails false. gretel shows a crumb's extra links as levels of their own, and the comparison does the same with links. A call that gretel can't draw is listed too, with gretel's error. redundant_calls lists the breadcrumb calls you can delete, because the page gets the same trail without them (none, with derive_trails false). watch { ... } watches a block only. Parallel test workers each keep their own lists, and after_run in the parent process doesn't see them, so run the watched suite with PARALLEL_WORKERS=1.

    For crumbs that no test renders, compare checks the calls you give it:

    class BreadcrumbsMigrationTest < ActionView::TestCase
       test "Crumbly draws the trails gretel drew" do
    @@ -94,6 +94,7 @@ 

    2. Compare the trails

    end

    3. Review the calls in your views

    The --views option of crumbly-convert lists the breadcrumb calls that Crumbly may now make unnecessary: :generic_form (or :new, :edit) in new and edit views, breadcrumb @order in a show view, and breadcrumb :orders in an index view. It doesn't delete anything. Remove a call once its page shows the same trail without it; for the pages your tests render, Crumbly::Oracle.redundant_calls tells you which ones. Area prefixes in crumb names (:admin_order) can go too, since the namespace keeps the areas apart.

    +

    To keep every trail explicit instead, as it was with gretel, set derive_trails false in the trail class and keep the calls: a page whose view names no crumb then has no trail.

    4. Remove gretel

    Remove gretel and config/breadcrumbs.rb, and render breadcrumb_trail in the layout where breadcrumbs was. The guide has a loop to start from. gretel's rendering options (style: :bootstrap5, the separator, link_current, semantic markup) become choices in your own markup, and its structured_data becomes breadcrumb_json_ld.

    Then add the breadcrumbs test. It catches the parents, route helpers and view calls that the conversion got wrong, and the crumbs that nothing uses any more.

    @@ -149,7 +150,7 @@

    From breadcrumbs_on_rails

    end
    <%# app/views/admin/orders/messages.html.erb %>
     <% breadcrumb :order_messages, @order %>
    -

    index and show find these crumbs on their own. Without orders and order, they would still get "Orders" and the order's to_s

    +

    index and show find these crumbs on their own. Without orders and order, they would still get "Orders" and the order's to_s, or "Order 42" if Order has none

    diff --git a/lib/crumbly/trail.rb b/lib/crumbly/trail.rb index b6aa9ed..f879604 100644 --- a/lib/crumbly/trail.rb +++ b/lib/crumbly/trail.rb @@ -37,6 +37,18 @@ def method_added(name) raise DuplicateCrumb, "#{self}##{name} is defined twice: the second definition would replace the first" end + def derive_trails(derive) + raise ArgumentError, "derive_trails takes true or false, not #{derive.inspect}" unless derive in true | false + + @derive_trails = derive + end + + def derive_trails? + return @derive_trails unless @derive_trails.nil? + + self == Trail || superclass.derive_trails? + end + def crumb?(name) trail_class = self while trail_class < Trail @@ -52,6 +64,8 @@ def resolve(helpers, target, *arguments) end def derive(helpers) + return [] unless derive_trails? + new(helpers).send(:derived_levels) end end @@ -196,7 +210,14 @@ def collection_crumb def record_crumb(record) return crumb_for(record) if crumb_name_for(record) - crumb(record.to_s, route_path(action: "show", id: record), parent: collection_crumb) + crumb(record_label(record), route_path(action: "show", id: record), parent: collection_crumb) + end + + # Kernel#to_s would label the level "#". + def record_label(record) + return record.to_s unless record.class.instance_method(:to_s).owner == Kernel && record.class.respond_to?(:model_name) + + "#{record.model_name.human} #{record.to_param}" end def new_record_crumb @@ -235,7 +256,7 @@ def parent_record_crumb(parents) return crumb_for(parent.record) if crumb_name_for(parent.record) name = parent.name.pluralize - crumb(parent.record.to_s, route_path(resources: name, action: "show", id: parent.id, **route_parent_ids(ancestors)), + crumb(record_label(parent.record), route_path(resources: name, action: "show", id: parent.id, **route_parent_ids(ancestors)), parent: nested_collection_crumb(name, ancestors)) end diff --git a/lib/crumbly/verifier.rb b/lib/crumbly/verifier.rb index 94f5c5b..38f1b08 100644 --- a/lib/crumbly/verifier.rb +++ b/lib/crumbly/verifier.rb @@ -165,8 +165,9 @@ def source_files end def dead_crumbs - reachable_names = called_names + derived_names + reachable_names_with_derivation = called_names + derived_names trail_classes.flat_map do |trail_class| + reachable_names = trail_class.derive_trails? ? reachable_names_with_derivation : called_names dead_names = crumb_names(trail_class).reject do |name| reachable_names.include?(name) || model_name?(name) end diff --git a/lib/crumbly/view_helpers.rb b/lib/crumbly/view_helpers.rb index d8cd241..4cc280d 100644 --- a/lib/crumbly/view_helpers.rb +++ b/lib/crumbly/view_helpers.rb @@ -2,7 +2,8 @@ module Crumbly module ViewHelpers - # The view names its crumb; a view that says nothing gets the trail derived from its controller and action. + # The view names its crumb; a view that says nothing gets the trail derived from its controller and action, + # or none under `derive_trails false`. def breadcrumb(target, *arguments) @crumbly_breadcrumb = [target, arguments] @crumbly_trail = nil diff --git a/test/oracle_test.rb b/test/oracle_test.rb index 5200085..e1498f5 100644 --- a/test/oracle_test.rb +++ b/test/oracle_test.rb @@ -72,6 +72,16 @@ class OracleWatchTest < IntegrationTestCase Crumbly::Oracle.redundant_calls.map(&:to_s) end + test "with derive_trails false, no call is redundant and a page without one matches gretel's empty trail" do + Crumbly::Oracle.watch do + get "/backstage/categories/3" + get "/backstage/posts" + end + + assert_empty Crumbly::Oracle.differences + assert_empty Crumbly::Oracle.redundant_calls + end + test "outside the block, nothing is compared" do Crumbly::Oracle.watch {} get "/admin/orders/42/messages" diff --git a/test/test_helper.rb b/test/test_helper.rb index 3335d0a..57c1a11 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -26,9 +26,17 @@ resources :replies, only: :index end end + resources :invoices, only: [:index, :show] do + resources :payments, only: :index + end get "reports/sales", to: "reports#sales", as: :sales_report end + namespace :backstage do + resources :categories, only: :show + resources :posts, only: :index + end + namespace :storefront do root "home#show" resources :categories, only: :show @@ -100,6 +108,9 @@ def to_s end end +class Invoice < Record +end + ALICE = Customer.new(id: 7, name: "Alice") ORDER = Order.new(id: 42, number: "2026-0042", customer: ALICE) BOOKS = Category.new(id: 1, name: "Books") @@ -107,6 +118,7 @@ def to_s NOVELS = Category.new(id: 3, name: "Novels", parent: FICTION) POST = Post.new(id: 5, title: "Hello") COMMENT = Comment.new(id: 9, body: "First!") +INVOICE = Invoice.new(id: 12) module Admin class Breadcrumbs < Crumbly::Trail @@ -249,6 +261,20 @@ def index end end + class InvoicesController < ApplicationController + def show + @invoice = INVOICE + render inline: OrdersController::TRAIL + end + end + + class PaymentsController < ApplicationController + def index + @invoice = INVOICE + render inline: OrdersController::TRAIL + end + end + class ReportsController < ApplicationController def sales render inline: "<% breadcrumb :sales_report %>#{OrdersController::TRAIL}" @@ -256,6 +282,21 @@ def sales end end +module Backstage + class Breadcrumbs < Admin::Breadcrumbs + derive_trails false + end + + class CategoriesController < Admin::CategoriesController + end + + class PostsController < ApplicationController + def index + render inline: "<%= breadcrumb_trail.inspect %> <%= breadcrumb_json_ld.inspect %>" + end + end +end + module Storefront class Breadcrumbs < Crumbly::Trail def categories diff --git a/test/trail_test.rb b/test/trail_test.rb index cd1b68c..ea67882 100644 --- a/test/trail_test.rb +++ b/test/trail_test.rb @@ -75,4 +75,37 @@ class TrailTest < IntegrationTestCase "Shipments|/admin/orders/42/shipments|current", "/admin/orders/42/shipments" end + + test "a record without its own to_s is named after its model and id" do + assert_trail "Home|/admin > Invoices|/admin/invoices > Invoice 12|/admin/invoices/12|current", "/admin/invoices/12" + assert_trail "Home|/admin > Invoices|/admin/invoices > Invoice 12|/admin/invoices/12 > " \ + "Payments|/admin/invoices/12/payments|current", + "/admin/invoices/12/payments" + end + + test "the model name of a record without its own to_s is translated" do + I18n.backend.store_translations(:en, activemodel: {models: {invoice: {one: "Bill", other: "Bills"}}}) + + assert_trail "Home|/admin > Bills|/admin/invoices > Bill 12|/admin/invoices/12|current", "/admin/invoices/12" + ensure + I18n.reload! + end + + test "with derive_trails false, a view that names no crumb has no trail, and one that does keeps it" do + assert_trail "[] nil", "/backstage/posts" + assert_trail "Home|/admin > Categories|/admin/categories > Books|/admin/categories/1 > " \ + "Fiction|/admin/categories/2 > Novels|/admin/categories/3|current", + "/backstage/categories/3" + end + + test "derive_trails is inherited, can be turned back on, and is not a crumb" do + assert Admin::Breadcrumbs.derive_trails? + assert_not Backstage::Breadcrumbs.derive_trails? + assert_not Class.new(Backstage::Breadcrumbs).derive_trails? + assert Class.new(Backstage::Breadcrumbs) { derive_trails true }.derive_trails? + assert_not Backstage::Breadcrumbs.crumb?(:derive_trails) + + error = assert_raises(ArgumentError) { Class.new(Crumbly::Trail) { derive_trails nil } } + assert_equal "derive_trails takes true or false, not nil", error.message + end end diff --git a/test/verify_test.rb b/test/verify_test.rb index 38805f6..ebd4738 100644 --- a/test/verify_test.rb +++ b/test/verify_test.rb @@ -49,6 +49,14 @@ def latest_orders end end + class Explicit < Crumbly::Trail + derive_trails false + + def orders + crumb "Orders", admin_orders_path + end + end + class CalledFromHelper < Crumbly::Trail def orders crumb "Orders", admin_orders_path @@ -151,6 +159,10 @@ class VerifyTest < ActiveSupport::TestCase assert_match "Dead crumbs, called by no crumb, view or controller: Checked::DeadCrumb#archived_orders", failure.message end + test "a crumb only a derived trail would read is dead when the trail class derives none" do + assert_equal ["Checked::Explicit#orders"], verify(Checked::Explicit) + end + test "a crumb is alive when a view, or a private helper of its trail, calls it" do with_views("admin/orders/archived.html.erb" => "<% breadcrumb :archived_orders %>") do |views| assert_equal [], verify(Checked::DeadCrumb, sources: [views])