layered-resource-rails 0.2.0 → 0.3.1

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: '0429425d0767f40488539bbaef1d13ef629598c52a40592ecc914d9196a6f3e7'
4
- data.tar.gz: a45581d887a80412baaeab41ffc0e0310eaaeb1bd495cc11576c12d957424416
3
+ metadata.gz: 8bc9f9c8cd7248c93dcfe75e5af6c62e73f15e12f7a6a2092b213bbf3c5ffaf3
4
+ data.tar.gz: bc8992afbc303af01d63f9a0d86e01e3141e5cff9b47b4f6d61db7b7d68c560a
5
5
  SHA512:
6
- metadata.gz: 7571835a600e4d34659fcfedcd6573c2593a3653fdd23f8c80fa823ea7014b3226078492b8eb217a2247ccbf500d266d77495939f07b65d5178ffed3c53e3196
7
- data.tar.gz: 9e980c9c01d37611a40c6172007932e4f62ac1e58702d2a8490c5c902e920985e72a099db7a31d7b890e2a82b779806718a2e416ba32898bccebf7d5679724a0
6
+ metadata.gz: 91a5a65b3a8b3ac9108046f040e9b38d5ad58e79cd457343cea5d25cedf8c37a16d4077fcaf7d12f5a7da0ecbfa07e293c88169342048f82bc508df47d1606f1
7
+ data.tar.gz: eaf791793476475fc62b310fe841c186fb23561302aad6e26a06fe0f6f4c975de37e32f1fdc4360af9b3f1eb1ad41a48a34587af9939e5a72603ebed08246ede
@@ -2,7 +2,7 @@
2
2
  name: layered-resource-rails
3
3
  description: Installs, configures, and builds with the layered-resource-rails gem - a Rails 8+ engine providing convention-over-configuration CRUD scaffolding with search, sort, and pagination. Use when adding layered-resource-rails to a Rails app, defining resource classes, mounting `layered_resources` routes, ejecting views or controllers, or troubleshooting setup.
4
4
  license: Apache-2.0
5
- compatibility: Requires Ruby on Rails >= 8.0, layered-ui-rails ~> 0.9, ransack ~> 4.0, pagy ~> 43.2
5
+ compatibility: Requires Ruby on Rails >= 8.0, layered-ui-rails ~> 0.27, ransack ~> 4.0, pagy ~> 43.2
6
6
  metadata:
7
7
  author: layered.ai
8
8
  version: "1.0"
@@ -104,7 +104,7 @@ end
104
104
  | `model Post` | The ActiveRecord class this resource manages |
105
105
  | `columns [...]` | Index table columns. Each entry is `{ attribute:, label:, primary:, link:, render: }` |
106
106
  | `fields [...]` | Form fields for new/edit. Omit to disable CRUD forms |
107
- | `search_fields [...]` | Ransack attributes the index search box matches against. Association-walking entries like `:user_name` (for `belongs_to :user` + `users.name`) join into the association |
107
+ | `search_fields [...]` | Ransack attributes the index search box matches against. Association-walking entries like `:user_name` (for `belongs_to :user` + `users.name`) join into the association. The box searches as the term is typed (debounced, into the index Turbo frame) with a clear button built into the field - no Search or Clear buttons. Typed searches `replace` history rather than pushing; the caret survives the re-render and the result count is announced |
108
108
  | `search_placeholder "..."` | Replaces the search box placeholder. Default derives from `search_fields` via `human_attribute_name`, so `activerecord.attributes.<model>.<attr>` i18n renames flow through (association walks resolve each half against its own model) |
109
109
  | `filters :a, :b, c: {...}` | Structured filter controls on the index — an "Add filter" popover plus removable tags. Control + Ransack predicate inferred per column; trailing hash overrides per attribute. See [Filters](#filters) |
110
110
  | `label_attribute :title` | Attribute a record is labelled by (page titles, row action menus, another resource's picker). Defaults to the `primary:` column, else the first. Falls back through `name`/`title`/`label`/`email`, then the model's own `to_s`, then `"Post #12"` |
@@ -112,6 +112,8 @@ end
112
112
  | `per_page n` | Pagination size (default 15) |
113
113
  | `root_breadcrumb "Home", "/"` | Static first crumb in the breadcrumb trail (e.g. back to the host app's dashboard). Without it, top-level resources render no trail; nested routes prepend it to the derived parent trail |
114
114
 
115
+ The trail always ends with the current page (collection name on `index`, record label on `show`, `New`/`Edit` on the forms), and only that crumb carries `aria-current="page"`. Parent record crumbs link to their show page when the parent resource routes `:show`.
116
+
115
117
  ### Column options
116
118
 
117
119
  - `as: :type` - pins the cell to a column partial instead of the type-inferred default. Built in: `:text`, `:datetime`, `:badge`, `:boolean`; an unrecognised type raises `ArgumentError` at render time rather than rendering an empty cell. See [Column rendering](#column-rendering)
@@ -161,7 +163,7 @@ Field `as:` follows Rails' `form_with` field helpers - `:text`, `:checkbox`, `:d
161
163
 
162
164
  ### Filters
163
165
 
164
- `filters` declares structured index controls complementing the single free-text `search_fields` box. The UI: an **Add filter** button opens a popover listing the declared filters; picking one adds it as an unset **tag** at the end of the row (the `f[]` param tracks added tags and their order for as long as they're shown) with its controls popover already open, ready to take a value; pressing the tag's label reopens the popover, and its ✕ removes it. Short single-choice filters apply instantly via links; ranges/text/multi-selects/comboboxes apply via a small GET form. Every filter is a Ransack predicate in the URL, so filters compose with search, sort, and pagination — the search form and each filter form round-trip the other `q` params (and `f[]` entries) as hidden fields (no JavaScript; the one-shot `fo` param marks which tag's popover renders open).
166
+ `filters` declares structured index controls complementing the single free-text `search_fields` box. The UI: an **Add filter** button opens a popover listing the declared filters; picking one adds it as an unset **tag** at the end of the row (the `f[]` param tracks added tags and their order for as long as they're shown) with its controls popover already open, ready to take a value; pressing the tag's label reopens the popover, and its ✕ removes it. Short single-choice filters apply instantly via links; ranges/text/multi-selects/comboboxes apply via a small GET form. Every filter is a Ransack predicate in the URL, so filters compose with search, sort, and pagination — the search form and each filter form round-trip the other `q` params (and `f[]` entries) as hidden fields (the one-shot `fo` param marks which tag's popover renders open). The filter controls are plain links and forms with no JavaScript; only the search box's type-to-search is scripted. Because the filters are hidden fields inside the search form, clearing the term client-side keeps them.
165
167
 
166
168
  ```ruby
167
169
  filters :status, # enum -> multi-select of its values (status_in)
@@ -175,7 +177,7 @@ Inference by column type: `enum`/`belongs_to` -> multi-select (`_in`; `multiple:
175
177
 
176
178
  **Long option lists switch control.** A select-type filter renders as the plain list (multi-select: checkboxes + Apply; single-choice: instant-apply links) up to `Layered::Resource.filter_combobox_threshold` options (10), and as a type-ahead **combobox** past it — a checkbox list of every user is no way to pick one. A single-choice combobox posts a scalar (no `[]`), so `_eq` gets the value rather than an array. The count is taken per request, after a `collection:` callable resolves, so the control follows the data; the decision lives in `layered_filter_control` (the view helper), not in `resolved_filters`, for exactly that reason. Declaring `as:` pins the control and opts out: `as: :select` keeps the list however long, `as: :combobox` uses the combobox however short.
177
179
 
178
- **Remote options.** `url:` fetches the options from an endpoint as the user types instead of rendering a collection — such a filter is always a combobox (nothing to render or count). Give it as a callable (`-> { user_options_path }`) so it resolves per request in the view, where route helpers exist. The endpoint is an ordinary host-app action including `Layered::Ui::ComboboxOptions` and returning `l_ui_combobox_options(scope, label:, search:)` — the gem never routes it, so it's authorised however any index is. Since the browser has no collection to look a label up in, an active remote filter's values are labelled server-side from the records (same `name`/`title`/`label`/`email` fallbacks), so the tag reads "User: Alice" not "User: 12"; a `url:` over a plain column has no records and its values label themselves. `min_chars:` and `text:` pass through too; the write-side options (`create:`, `create_name:`, `reorder:`) deliberately don't — a filter picks among values that already exist.
180
+ **Remote options.** `url:` fetches the options from an endpoint as the user types instead of rendering a collection — such a filter is always a combobox (nothing to render or count). Give it as a callable (`-> { user_options_path }`) so it resolves per request in the view, where route helpers exist. The endpoint is an ordinary host-app action including `Layered::Ui::ComboboxOptions` and returning `l_ui_combobox_options(scope, label:, search:)` — the gem never routes it, so it's authorised however any index is. Since the browser has no collection to look a label up in, an active remote filter's values are labelled server-side from the records (same `name`/`title`/`label`/`email` fallbacks), so the tag reads "User: Alice" not "User: 12"; a `url:` over a plain column has no records and its values label themselves. The label lookup is confined to records the resource's `scope(controller)` references (via the foreign key), so a hand-edited id outside it - e.g. another tenant's user - labels itself instead of leaking a name; the cost is that an in-scope option with no matching records also shows its raw id. `min_chars:` and `text:` pass through too; the write-side options (`create:`, `create_name:`, `reorder:`) deliberately don't — a filter picks among values that already exist.
179
181
 
180
182
  **The blank a combobox posts.** `l_ui_combobox` emits a leading blank hidden input so clearing every token submits an empty collection rather than omitting the key. Ransack prunes blanks from an `_in` array itself, and `layered_filter_query_params` prunes them too — an array left with nothing becomes an explicit `""`, the same shape the Clear link writes, so the tag reads as inactive *and* a `default:` doesn't immediately re-apply.
181
183
 
@@ -183,7 +185,7 @@ Override per attribute with a trailing hash: `as:` (force control type: `:select
183
185
 
184
186
  **The predicate set is closed.** Each control type maps to a fixed predicate (`:select`/`:combobox` → `_in`/`_eq`, `:boolean` → `_eq`, `:string` → `_cont`, ranges → `_gteq`+`_lteq`) and there is no `predicate:` option, so predicates Ransack can otherwise express (`_not_null`, `_matches`) aren't reachable through the DSL. In particular a **"is this set / unset" filter on a nullable timestamp** (a `locked_at`-style column) has no inferred control: `as: :boolean` emits `locked_at_eq=true`, which casts against a datetime column and matches nothing. Back the flag with a real boolean column the write path maintains, or eject the filter partials and emit the predicate yourself. Filtered attributes *are* allowlisted, so `q[locked_at_not_null]=1` works hand-typed in the URL — it just has no UI control.
185
187
 
186
- The bar renders inside the index Turbo frame between search box and table, built from `l_ui_popover` and the `_filters`/`_filter_control` partials — eject with `rails g layered:resource:views` to customise.
188
+ The bar renders inside the index Turbo frame between search box and table, built from `l_ui_popover` and the `_filters`/`_filter_control` partials — eject with `rails g layered:resource:views` to customise. The search box is the `_search` partial, which calls `l_ui_search_form` in block mode with `l_ui_search_control`. Both take `live: true` (opt-in in layered-ui-rails 0.27 - it is not inferred from `turbo_frame:`, and the control defaults to `live: false` on its own), and the form passes `count: @pagy&.count` so results are announced.
187
189
 
188
190
  ## Route DSL
189
191
 
data/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.3.1] - 2026-10-04
6
+
7
+ ### Security
8
+
9
+ - A remote (`url:`) `belongs_to` filter no longer labels records outside the resource's `scope`. Its current values come from the query string, and the labels for the tag and combobox tokens were looked up with an unscoped `klass.where(id: ...)`, so hand-editing the id (`?q[user_id_in][]=`) revealed the name or e-mail of any record, including another tenant's. The lookup now only reads records the resource's `scope(controller)` references through the foreign key. Any other value shows its raw id instead.
10
+
11
+ ## [0.3.0] - 2026-09-21
12
+
13
+ ### Changed
14
+
15
+ - The index search box searches as the term is typed. The **Search** and **Clear** buttons are gone: the box submits into the index Turbo frame as the field changes, debounced so a typed word is one request rather than one per letter, and the field carries its own clear button - shown only once there is something to clear, and reachable by keyboard with Escape clearing the field too. A typed search `replace`s the history entry rather than pushing one, so Back leaves the index instead of replaying the term letter by letter; the term still lives in the URL, so a search stays shareable and survives a reload, and sort links, pagination and filter chips still advance.
16
+ - With no button press to signal a change, the number of results is now announced to screen readers after each search (`count:` is passed from `@pagy`), the caret is preserved across the frame re-render so a word is never interrupted mid-letter, and the box is a labelled `search` landmark. The form still submits on Enter and works without JavaScript.
17
+ - The two search branches on the index - one for resources with `filters`, one without - are now a single `_search.html.erb` partial, ejected by `rails g layered:resource:views` along with the rest. Because the active filters are hidden fields inside the search form, clearing the term keeps them, which is what `layered_search_clear_path` used to compute; that helper is still there for an ejected view that wants the same link.
18
+ - Requires `layered-ui-rails` ~> 0.27, which is where the type-to-search control (`live:`, `count:`, the in-field clear button) landed.
19
+
20
+ ### Fixed
21
+
22
+ - The breadcrumb trail ends with the page it is on - the collection name on `index`, the record's label on `show`, **New** or **Edit** on the form pages - and only that last crumb is marked `aria-current="page"`. The trail used to stop at the parent record, and because `l_ui_breadcrumb_item` marks any unlinked crumb as the current page, a nested index pointed a screen reader at its parent while `show` marked nothing at all. Crumbs now go through a `layered_breadcrumb_item` helper that lets only the current one through unlinked, and a parent record links to its own show page when its resource routes `:show`, so the middle of a trail is no longer a dead end.
23
+
5
24
  ## [0.2.0] - 2026-09-10
6
25
 
7
26
  ### Routing and controllers
data/README.md CHANGED
@@ -45,7 +45,7 @@ curl -fsSL https://raw.githubusercontent.com/layered-ai-public/layered-resource-
45
45
  ## Requirements
46
46
 
47
47
  - Ruby on Rails >= 8.0
48
- - [layered-ui-rails](https://github.com/layered-ai-public/layered-ui-rails) ~> 0.25 (>= 0.25.1)
48
+ - [layered-ui-rails](https://github.com/layered-ai-public/layered-ui-rails) ~> 0.27
49
49
  - Ransack ~> 4.0
50
50
  - Pagy ~> 43.2
51
51
 
@@ -186,6 +186,8 @@ end
186
186
 
187
187
  Nested routes prepend it to the derived parent trail (e.g. Home / Users / Alice). Pass `nil` as the path to render unlinked text.
188
188
 
189
+ Every trail ends with the page you're on - the collection name on `index`, the record's label on `show`, `New` or `Edit` on the form pages - and that last crumb is the only one marked `aria-current="page"`. Parent crumbs never are, whether or not they link: a parent record links to its own show page when its resource routes `:show`, and renders as plain text when it doesn't.
190
+
189
191
  **Record label:** a record is labelled by its **primary column** (the one marked `primary: true`, else the first) wherever the gem has to name it: the `show` and `edit` page titles, a row's actions menu, and its options in another resource's picker. Declare `label_attribute` when that column isn't the record's name — a `primary:` column rendered by a `render:` proc, say:
190
192
 
191
193
  ```ruby
@@ -376,6 +378,14 @@ The Ransack allowlists are scoped to the resource: the association and the assoc
376
378
 
377
379
  A walked search field is also sortable — `q[s]=user_name asc` orders the index by `users.name` — because Ransack derives its sort allowlist from the search allowlist. Associations not declared in `search_fields` stay unsearchable and unsortable.
378
380
 
381
+ ### How the search box behaves
382
+
383
+ There is no Search button to press. The box searches as the term is typed, debounced so a typed word is one request rather than one per letter, and the results re-render inside the index's Turbo frame. The field carries its own clear button, which appears once there is something to clear; Escape in the field clears it too.
384
+
385
+ Each search **replaces** the history entry rather than pushing one, so Back leaves the index instead of replaying the term letter by letter. The term still lives in the URL, so a search remains shareable and survives a reload — and sort links, pagination and filter chips still push history as before.
386
+
387
+ The parts that matter for accessibility are handled for you: the caret stays put across the re-render, so a word is never interrupted mid-letter; the number of results is announced to screen readers after each search, since with no button press there is otherwise nothing to say the page changed; and the box is a labelled `search` landmark. The form still submits on Enter and works without JavaScript.
388
+
379
389
  ### Search placeholder
380
390
 
381
391
  The index search box's placeholder is derived from `search_fields` via `human_attribute_name`, so attribute renames declared in the standard Rails i18n location flow through automatically. Given `search_fields [:title, :user_sid]`:
@@ -476,7 +486,7 @@ A counter-cache column reads straight off the parent row, so the index renders i
476
486
 
477
487
  Where `search_fields` gives a single free-text box, `filters` adds structured controls for narrowing the index by specific attributes. The UI follows the "add filter" pattern: an **Add filter** button opens a popover listing the declared filters; picking one adds it as a **tag** with its controls popover already open, ready to take a value; pressing the tag's label reopens the popover, and its ✕ removes it. Booleans and short single-choice selects apply instantly on click; multi-selects, comboboxes, ranges, and text filters have an Apply button.
478
488
 
479
- Under the hood every filter is a Ransack predicate in the query string, so filters compose with search, sort, and pagination — all coexist in the URL and survive each other's submits (the search form and each filter form round-trip the other params as hidden fields; no JavaScript involved). The lightweight `f[]` param records which tags were added and in what order — new tags join the end of the row (after any pinned ones) and stay put when set; a tag's ✕ removes its entry.
489
+ Under the hood every filter is a Ransack predicate in the query string, so filters compose with search, sort, and pagination — all coexist in the URL and survive each other's submits (the search form and each filter form round-trip the other params as hidden fields). The filter controls themselves are plain links and forms, with no JavaScript involved; only the search box's type-to-search is scripted. The lightweight `f[]` param records which tags were added and in what order — new tags join the end of the row (after any pinned ones) and stay put when set; a tag's ✕ removes its entry.
480
490
 
481
491
  Declare `filters` with a list of attributes. The control and predicate are inferred from each column:
482
492
 
@@ -554,7 +564,7 @@ end
554
564
 
555
565
  A remote combobox has no collection in the browser to look a label up in, so an active filter's current values are labelled server-side from the records themselves (the same `name`/`title`/`label`/`email` fallbacks) — the tag reads "User: Alice", not "User: 12". A `url:` on a plain column has no records to read, so its values label themselves.
556
566
 
557
- > **Label lookups are not scoped.** Whatever scoping the `url:` endpoint applies covers the options it *serves*; it does not cover the labels. Current values arrive in the query string, and the gem labels them with an unscoped `klass.where(id: ...)` — so hand-editing `?q[user_id_in][]=` to an id outside the endpoint's scope still renders that record's label in the tag and the combobox token. This is the same position a `belongs_to` filter's default `klass.all` options take, and it discloses one label at a time; treat it as a reason not to rely on a filter's option scoping to keep an association's names or e-mail addresses private.
567
+ Current values arrive in the query string, so they can be hand-edited to any id — and the gem can't see whatever scoping the `url:` endpoint applies. Labels are therefore only read for records the resource's own `scope` references (users with a post the viewer can see, say); any other value labels itself. A guessed `?q[user_id_in][]=` for another tenant's user shows "User: 42", not their name. The flip side is that a value from the endpoint with no matching records in scope also shows its id, next to an empty table. (A `belongs_to` filter *without* `url:` renders `klass.all` as its options — pass a scoped `collection:` if those labels are private.)
558
568
 
559
569
  `min_chars:` and `text:` pass through to `l_ui_combobox` alongside `url:`. The write-side combobox options (`create:`, `create_name:`, `reorder:`) don't: a filter picks among values that already exist.
560
570
 
@@ -590,7 +600,7 @@ filters :created_at,
590
600
 
591
601
  Pinned filters render as tags from the start, so the common ones are one click away instead of two; the **Add filter** button only renders while there are unpinned filters left to add (pin everything and it disappears). A `default:` applies whenever the request carries no state for that filter — the tag shows it as active and every link and form round-trips it explicitly from then on. Clearing a defaulted filter writes an explicit blank (`q[status_eq]=`) rather than dropping the param, so the default doesn't immediately re-apply.
592
602
 
593
- The filter bar renders inside the index's Turbo frame between the search box and the table; eject the views (`rails g layered:resource:views`) to customise placement — the bar is the `_filters` partial, and each control is `_filter_control`.
603
+ The filter bar renders inside the index's Turbo frame between the search box and the table; eject the views (`rails g layered:resource:views`) to customise placement — the search box is the `_search` partial, the bar is `_filters`, and each control is `_filter_control`.
594
604
 
595
605
  ## Column rendering
596
606
 
@@ -25,6 +25,7 @@ module Layered
25
25
  helper Layered::Ui::PagyHelper
26
26
  helper Layered::Ui::BreadcrumbsHelper
27
27
  helper Layered::Resource::FiltersHelper
28
+ helper Layered::Resource::BreadcrumbsHelper
28
29
 
29
30
  # Hooks the route's `layout:` option in. Returning nil (the common
30
31
  # case) falls through to Rails' normal layout resolution, so routes
@@ -48,32 +48,50 @@ module Layered
48
48
 
49
49
  crumbs = []
50
50
 
51
- # Link to the parent's layered index if a route exists
51
+ # The parent's own ancestors, filled in from the current
52
+ # request - needed by both of the parent's path helpers.
52
53
  if collection_entry
53
54
  rs = collection_entry[:routes] || Rails.application.routes
55
+ ancestor_args = collection_entry[:parent_params].index_with { |p| params[p] }
56
+ ancestor_args = nil unless ancestor_args.values.all?(&:present?)
57
+ end
58
+
59
+ # Link to the parent's layered index if a route exists
60
+ if collection_entry && ancestor_args
54
61
  helper = :"#{collection_key}_path"
55
62
  if rs.url_helpers.method_defined?(helper)
56
- ancestor_args = collection_entry[:parent_params].each_with_object({}) do |p, h|
57
- h[p] = params[p]
58
- end
59
- if ancestor_args.values.all?(&:present?)
60
- path = rs.url_helpers.send(helper, default_url_options.merge(ancestor_args))
61
- crumbs << { label: model_class.model_name.human.pluralize, path: path }
62
- end
63
+ path = rs.url_helpers.send(helper, default_url_options.merge(ancestor_args))
64
+ crumbs << { label: model_class.model_name.human.pluralize, path: path }
63
65
  end
64
66
  end
65
67
 
66
- # Add the specific record breadcrumb
68
+ # Add the specific record breadcrumb, linked to its own show
69
+ # page when the parent resource has one - a crumb that isn't
70
+ # the current page shouldn't be a dead end.
67
71
  record = model_class.find_by(id: params[key])
68
72
  if record
69
73
  label = record.try(:name) || record.try(:title) || "#{model_class.model_name.human} ##{record.id}"
70
- crumbs << { label: label, path: nil }
74
+ crumbs << { label: label, path: layered_parent_record_path(collection_entry, collection_key, ancestor_args, record) }
71
75
  end
72
76
 
73
77
  crumbs
74
78
  end
75
79
  end
76
80
  end
81
+
82
+ # The parent record's show path, or nil when the parent isn't a
83
+ # layered resource, doesn't route :show, or can't be addressed
84
+ # from here.
85
+ def layered_parent_record_path(collection_entry, collection_key, ancestor_args, record)
86
+ return nil unless collection_entry && ancestor_args
87
+ return nil unless collection_entry[:actions].include?(:show)
88
+
89
+ rs = collection_entry[:routes] || Rails.application.routes
90
+ helper = :"#{collection_key.to_s.singularize}_path"
91
+ return nil unless rs.url_helpers.method_defined?(helper)
92
+
93
+ rs.url_helpers.send(helper, default_url_options.merge(ancestor_args).merge(id: record.to_param))
94
+ end
77
95
  end
78
96
  end
79
97
  end
@@ -0,0 +1,19 @@
1
+ module Layered
2
+ module Resource
3
+ # Renders one crumb of a layered resource's breadcrumb trail.
4
+ #
5
+ # `l_ui_breadcrumb_item` marks every unlinked crumb as
6
+ # aria-current="page", which is only correct for the crumb that *is* the
7
+ # current page. A derived trail routinely carries unlinked crumbs that
8
+ # aren't - a parent record with no show route, an unlinked
9
+ # `root_breadcrumb` - so only the crumb a view flags as `current:` is
10
+ # passed through unlinked; the rest render as plain, uncurrent text.
11
+ module BreadcrumbsHelper
12
+ def layered_breadcrumb_item(label, path = nil, current: false)
13
+ return l_ui_breadcrumb_item(label, current ? nil : path) if path || current
14
+
15
+ tag.li(tag.span(label), class: "l-ui-breadcrumbs__item")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -235,8 +235,12 @@ module Layered
235
235
  @resource.search_fields.map(&:to_s).join("_or_") + "_cont"
236
236
  end
237
237
 
238
- # Clear link for the search box that drops only the search term,
239
- # keeping active filters and sort.
238
+ # A path that drops only the search term, keeping active filters and sort.
239
+ #
240
+ # The search box no longer needs this - it clears client-side by
241
+ # submitting its own form with an empty field, and the filters are hidden
242
+ # fields inside that form, so they ride along. Kept for an ejected view or
243
+ # a host app that wants the same "clear just the term" link.
240
244
  def layered_search_clear_path
241
245
  layered_filter_path_with(layered_filter_query_params.except(layered_search_field_key))
242
246
  end
@@ -282,15 +286,23 @@ module Layered
282
286
 
283
287
  # Value => label for the filter's currently-set values. A remote filter
284
288
  # has no collection to read them from, so an association's are looked up
285
- # by id; anything else (a remote filter over a plain column) resolves to
289
+ # by key; anything else (a remote filter over a plain column) resolves to
286
290
  # nothing and the value stands in for its own label.
291
+ #
292
+ # The values come straight from the query string, and the gem can't see
293
+ # whatever scoping the `url:` endpoint applies, so the lookup is confined
294
+ # to records the resource's own `scope` references: a hand-edited id for,
295
+ # say, another tenant's user labels itself rather than disclosing that
296
+ # user's name.
287
297
  def layered_filter_option_labels(filter, values)
288
298
  if filter[:url].present?
289
299
  reflection = filter[:reflection]
290
300
  return {} if reflection.nil?
291
301
 
292
- reflection.klass.where(id: values)
293
- .to_h { |record| [record.id.to_s, layered_filter_record_label(record)] }
302
+ primary_key = reflection.association_primary_key
303
+ referenced = @resource.scope(controller).unscope(:order).select(reflection.foreign_key)
304
+ reflection.klass.where(primary_key => values).where(primary_key => referenced)
305
+ .to_h { |record| [record.public_send(primary_key).to_s, layered_filter_record_label(record)] }
294
306
  else
295
307
  layered_filter_collection(filter).to_h { |label, value| [value, label] }
296
308
  end
@@ -0,0 +1,24 @@
1
+ <%# The search box. Searching happens as the term is typed (live: true), so
2
+ there is no Search button to press, and the field carries its own clear
3
+ button. live: needs the turbo_frame: to land the response in, and count:
4
+ so the size of the result set is announced once it has.
5
+
6
+ Any active filter and sort params ride along as hidden fields, so a search
7
+ composes with them rather than replacing them - which is also what makes
8
+ clearing the term client-side keep the filters: an empty term is submitted
9
+ through this same form. %>
10
+ <div class="l-ui-mt-4">
11
+ <%= l_ui_search_form(@q,
12
+ url: layered_collection_path,
13
+ label: "Search #{@model.model_name.human.pluralize.downcase}",
14
+ live: true,
15
+ count: @pagy&.count,
16
+ turbo_frame: turbo_frame_name) do |f| %>
17
+ <%= layered_filter_hidden_fields(except: layered_search_field_key) if layered_filters.any? %>
18
+ <div class="l-ui-search-inline">
19
+ <%= l_ui_search_control(f, layered_search_field_key,
20
+ placeholder: @resource.search_placeholder,
21
+ live: true) %>
22
+ </div>
23
+ <% end %>
24
+ </div>
@@ -2,9 +2,10 @@
2
2
  <div>
3
3
  <%= l_ui_breadcrumbs do %>
4
4
  <% layered_breadcrumbs.each do |crumb| %>
5
- <%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
5
+ <%= layered_breadcrumb_item crumb[:label], crumb[:path] %>
6
6
  <% end %>
7
- <%= l_ui_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
7
+ <%= layered_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
8
+ <%= layered_breadcrumb_item "Edit", current: true %>
8
9
  <% end %>
9
10
 
10
11
  <h1 class="l-ui-heading">Edit <%= @model.model_name.human.downcase %></h1>
@@ -2,11 +2,14 @@
2
2
 
3
3
  <div class="l-ui-spread">
4
4
  <div>
5
+ <%# The trail ends on this page, so it carries aria-current - the
6
+ parent crumbs before it never do, linked or not. %>
5
7
  <% if layered_breadcrumbs.any? %>
6
8
  <%= l_ui_breadcrumbs do %>
7
9
  <% layered_breadcrumbs.each do |crumb| %>
8
- <%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
10
+ <%= layered_breadcrumb_item crumb[:label], crumb[:path] %>
9
11
  <% end %>
12
+ <%= layered_breadcrumb_item @model.model_name.human.pluralize, current: true %>
10
13
  <% end %>
11
14
  <% end %>
12
15
 
@@ -29,32 +32,7 @@
29
32
 
30
33
  <%= turbo_frame_tag turbo_frame_name, data: { turbo_action: "advance" } do %>
31
34
  <% if @resource.search_fields.any? %>
32
- <div class="l-ui-mt-4">
33
- <% if layered_filters.any? %>
34
- <%# With filters declared, the search form must round-trip the active
35
- filter and sort q params as hidden fields so its submit composes
36
- with them; Clear drops only the search term. %>
37
- <%= l_ui_search_form(@q, url: layered_collection_path, turbo_frame: turbo_frame_name) do |f| %>
38
- <%= layered_filter_hidden_fields(except: layered_search_field_key) %>
39
- <%= f.label layered_search_field_key, "Search", class: "l-ui-sr-only" %>
40
- <div class="l-ui-search-inline">
41
- <%= f.text_field layered_search_field_key,
42
- class: "l-ui-form__field", placeholder: @resource.search_placeholder %>
43
- <%= f.submit "Search", class: "l-ui-button l-ui-button--primary" %>
44
- <%= link_to "Clear", layered_search_clear_path,
45
- class: "l-ui-button l-ui-button--outline",
46
- data: { turbo_frame: turbo_frame_name, turbo_action: "advance" } %>
47
- </div>
48
- <% end %>
49
- <% else %>
50
- <%= l_ui_search_form(@q,
51
- url: layered_collection_path,
52
- fields: @resource.search_fields,
53
- placeholder: @resource.search_placeholder,
54
- clear: true,
55
- turbo_frame: turbo_frame_name) %>
56
- <% end %>
57
- </div>
35
+ <%= render "search", turbo_frame_name: turbo_frame_name %>
58
36
  <% end %>
59
37
 
60
38
  <% if layered_filters.any? %>
@@ -2,9 +2,10 @@
2
2
  <div>
3
3
  <%= l_ui_breadcrumbs do %>
4
4
  <% layered_breadcrumbs.each do |crumb| %>
5
- <%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
5
+ <%= layered_breadcrumb_item crumb[:label], crumb[:path] %>
6
6
  <% end %>
7
- <%= l_ui_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
7
+ <%= layered_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
8
+ <%= layered_breadcrumb_item "New", current: true %>
8
9
  <% end %>
9
10
 
10
11
  <h1 class="l-ui-heading">New <%= @model.model_name.human.downcase %></h1>
@@ -6,11 +6,12 @@
6
6
  <% if layered_breadcrumbs.any? || index_path %>
7
7
  <%= l_ui_breadcrumbs do %>
8
8
  <% layered_breadcrumbs.each do |crumb| %>
9
- <%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
9
+ <%= layered_breadcrumb_item crumb[:label], crumb[:path] %>
10
10
  <% end %>
11
11
  <% if index_path %>
12
- <%= l_ui_breadcrumb_item @model.model_name.human.pluralize, index_path %>
12
+ <%= layered_breadcrumb_item @model.model_name.human.pluralize, index_path %>
13
13
  <% end %>
14
+ <%= layered_breadcrumb_item record_label, current: true %>
14
15
  <% end %>
15
16
  <% end %>
16
17
 
@@ -16,7 +16,7 @@ module Layered
16
16
  desc "Copy the gem's view templates into app/views/layered/<name>/ for full customisation."
17
17
 
18
18
  VIEWS = %w[index.html.erb show.html.erb new.html.erb edit.html.erb
19
- _filters.html.erb _filter_control.html.erb].freeze
19
+ _search.html.erb _filters.html.erb _filter_control.html.erb].freeze
20
20
 
21
21
  def copy_views
22
22
  VIEWS.each do |view|
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Resource
3
- VERSION = "0.2.0"
3
+ VERSION = "0.3.1"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-resource-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -29,20 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.25'
33
- - - ">="
34
- - !ruby/object:Gem::Version
35
- version: 0.25.1
32
+ version: '0.27'
36
33
  type: :runtime
37
34
  prerelease: false
38
35
  version_requirements: !ruby/object:Gem::Requirement
39
36
  requirements:
40
37
  - - "~>"
41
38
  - !ruby/object:Gem::Version
42
- version: '0.25'
43
- - - ">="
44
- - !ruby/object:Gem::Version
45
- version: 0.25.1
39
+ version: '0.27'
46
40
  - !ruby/object:Gem::Dependency
47
41
  name: pagy
48
42
  requirement: !ruby/object:Gem::Requirement
@@ -223,6 +217,7 @@ files:
223
217
  - app/controllers/layered/resource/internal/columns.rb
224
218
  - app/controllers/layered/resource/internal/routing.rb
225
219
  - app/controllers/layered/resource/resources_controller.rb
220
+ - app/helpers/layered/resource/breadcrumbs_helper.rb
226
221
  - app/helpers/layered/resource/filters_helper.rb
227
222
  - app/views/layered/resource/columns/_badge.html.erb
228
223
  - app/views/layered/resource/columns/_boolean.html.erb
@@ -230,6 +225,7 @@ files:
230
225
  - app/views/layered/resource/columns/_text.html.erb
231
226
  - app/views/layered/resource/resources/_filter_control.html.erb
232
227
  - app/views/layered/resource/resources/_filters.html.erb
228
+ - app/views/layered/resource/resources/_search.html.erb
233
229
  - app/views/layered/resource/resources/edit.html.erb
234
230
  - app/views/layered/resource/resources/index.html.erb
235
231
  - app/views/layered/resource/resources/new.html.erb
@@ -292,7 +288,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
292
288
  - !ruby/object:Gem::Version
293
289
  version: '0'
294
290
  requirements: []
295
- rubygems_version: 4.0.6
291
+ rubygems_version: 3.6.9
296
292
  specification_version: 4
297
293
  summary: Convention-over-configuration CRUD for Rails 8+ with Ransack and Pagy built
298
294
  in.