layered-resource-rails 0.3.1 → 0.5.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: 8bc9f9c8cd7248c93dcfe75e5af6c62e73f15e12f7a6a2092b213bbf3c5ffaf3
4
- data.tar.gz: bc8992afbc303af01d63f9a0d86e01e3141e5cff9b47b4f6d61db7b7d68c560a
3
+ metadata.gz: dd456a360f63f94c4c38e833251b2d4b22a20c7df76b2b78612d9baf55135e9c
4
+ data.tar.gz: 783f255e36a42959f5565862cf2354d5a57cb1ec5e2d98fbf281477613446c43
5
5
  SHA512:
6
- metadata.gz: 91a5a65b3a8b3ac9108046f040e9b38d5ad58e79cd457343cea5d25cedf8c37a16d4077fcaf7d12f5a7da0ecbfa07e293c88169342048f82bc508df47d1606f1
7
- data.tar.gz: eaf791793476475fc62b310fe841c186fb23561302aad6e26a06fe0f6f4c975de37e32f1fdc4360af9b3f1eb1ad41a48a34587af9939e5a72603ebed08246ede
6
+ metadata.gz: e29f1a82da384c20cf1d2b99793fedd5079d129a3767cd0c18ba81a55b41b4cf7621c9d0528d992239dc75480607ec0702bb6c70f2562660bb5a063d4faa33d6
7
+ data.tar.gz: 6b144d42bb808d4ef5cab32ed5119ec6a976046e4dd82b9d3fac221c2aacfd2015985f0428f1011493a2bd46289e75876b44bce62e1c69b2f22cb42f0c2e1ff7
@@ -108,6 +108,7 @@ end
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"` |
111
+ | `lookup_attribute :uid` | Attribute a record is looked up by from its URL `:id` (`find_by!(uid:)` within `scope`, so out of scope is still a 404), and that the gem's links put there - primary column, row actions, `link:` columns, parent breadcrumbs. Defaults to `:id` (`scope.find` + `to_param`, so FriendlyId still works). Override `self.find_record(controller, id)` / `self.record_param(record)` for anything else |
111
112
  | `default_sort attribute:, direction:` | Default sort order for the index |
112
113
  | `per_page n` | Pagination size (default 15) |
113
114
  | `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 |
@@ -117,7 +118,7 @@ The trail always ends with the current page (collection name on `index`, record
117
118
  ### Column options
118
119
 
119
120
  - `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)
120
- - `primary: true` - marks the cell that links to the record's edit (or show) page (defaults to first column)
121
+ - `primary: true` - marks the cell that links to the record's edit (or show) page (defaults to first column). Add `link: false` to leave it unlinked - for a `render:` proc that links somewhere else itself (e.g. a conversation's own page rather than its edit form)
121
122
  - `label: "Custom"` - overrides the humanised attribute name
122
123
  - `sortable:` - whether the header renders a sort link. **Defaults to `false` for any attribute that isn't a real DB column** (virtual attributes, delegated association values), because a Ransack sort link on one 500s when clicked. Pass `sortable: true` to opt back in - you then own the associated model's `ransackable_attributes` (see [Associations](#associations))
123
124
  - `link: :route_key` - wraps the column's rendered value in a link to a nested route (e.g. `:users_posts`); composes with `as:` (pair with `as: :badge` for a badge link)
@@ -177,7 +178,7 @@ Inference by column type: `enum`/`belongs_to` -> multi-select (`_in`; `multiple:
177
178
 
178
179
  **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.
179
180
 
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.
181
+ **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. It searches outside any resource, so the model needs its own `ransackable_attributes` covering the `search:` fields. 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.
181
182
 
182
183
  **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.
183
184
 
@@ -443,7 +444,7 @@ columns [
443
444
 
444
445
  `as: :badge, rounded: true` gives the rounded count pill; `link:` (optional) makes it link to the children's nested index. If the parent doesn't already carry a counter-cache column, add the migration and `counter_cache: true` rather than reaching for `.size`.
445
446
 
446
- To make an association searchable, add a Ransack-walk-shaped entry to `search_fields` - e.g. `search_fields [:title, :user_name]` resolves `:user_name` against `belongs_to :user` + `users.name` and joins into the association. The gem scopes the Ransack allowlists per resource: it only responds when the resource class is the auth object (on both the parent and the associated model), so host-app Ransack config is preserved. A walked search field is also *sortable* (`q[s]=user_name asc` orders by `users.name`) because Ransack derives its sort allowlist from the search allowlist. Cross-model sort/filter on associations *not* declared in `search_fields` remains off; to opt in, define `ransackable_associations` on the parent model yourself and allowlist the attributes on the child model - the gem detects the host-defined override and unions with it. The search box placeholder labels a walk as "<association> <attribute>" via each model's `human_attribute_name` - translate `activerecord.attributes.<model>.<attr>` to rename a half, or set `search_placeholder` on the resource to replace the whole string.
447
+ To make an association searchable, add a Ransack-walk-shaped entry to `search_fields` - e.g. `search_fields [:title, :user_name]` resolves `:user_name` against `belongs_to :user` + `users.name` and joins into the association. The gem scopes the Ransack allowlists per resource: it only responds when the resource class is the auth object (on both the parent and the associated model), so host-app Ransack config is preserved. A host-app `Model.ransack` call outside a resource still needs the model's own allowlist: with none defined, the gem's patch raises a "needs explicitly allowlisted" error, as Ransack does for an unconfigured model, rather than allowlisting everything. A walked search field is also *sortable* (`q[s]=user_name asc` orders by `users.name`) because Ransack derives its sort allowlist from the search allowlist. Cross-model sort/filter on associations *not* declared in `search_fields` remains off; to opt in, define `ransackable_associations` on the parent model yourself and allowlist the attributes on the child model - the gem detects the host-defined override and unions with it. The search box placeholder labels a walk as "<association> <attribute>" via each model's `human_attribute_name` - translate `activerecord.attributes.<model>.<attr>` to rename a half, or set `search_placeholder` on the resource to replace the whole string.
447
448
 
448
449
  ## Common issues
449
450
 
data/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
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.5.0] - 2026-11-11
6
+
7
+ ### Fixed
8
+
9
+ - The "needs explicitly allowlisted" error raised for a configured model with no allowlist of its own now names the class being searched. An STI subclass of a configured model was reported as its parent
10
+
11
+ ### Changed
12
+
13
+ - Refresh the development/test bundle to Rails 8.1.4, layered-ui-rails 0.28.1, Pagy 43.8.1, concurrent-ruby 1.3.8, SQLite 2.10.2, and compatible transitive updates.
14
+ - Raise runtime and development dependency minimums to the versions verified by the suite, retaining their current major-version ranges. Ruby >= 3.3 and Rails ~> 8.0 support are unchanged; Rails keeps its existing minimum so Rails 8.0 apps remain supported.
15
+
16
+ ### Security
17
+
18
+ - Require Ransack >= 4.4.4 within the existing 4.x range, including the sort-link reflected XSS and open redirect fix for GHSA-58rx-96gf-w92x.
19
+ - A model the gem configures for Ransack no longer becomes fully searchable by the rest of the app. The scoped `ransackable_attributes`/`ransackable_associations` fell through to Ransack's builtin for any other caller. Because the override counted as an explicit definition, Ransack logged a deprecation warning and allowlisted every column and association, including fields like `encrypted_password`. Where the host app hasn't defined its own allowlist, another layered resource now gets an empty list, and any other caller gets the same "needs explicitly allowlisted" error Ransack raises for an unconfigured model. A host app that searched such a model outside a resource, and relied on the accidental widening, now needs to define `ransackable_attributes`/`ransackable_associations` on it
20
+
21
+ ## [0.4.0] - 2026-10-04
22
+
23
+ ### Added
24
+
25
+ - `lookup_attribute :uid` on a resource looks its records up by an attribute other than `id` - an unguessable token, a slug - with `find_by!` inside `scope`, so a record outside it is still a 404. The gem's own links (the primary column, row actions, `link:` columns into a nested resource, and parent breadcrumbs) put the same attribute in the URL. The default stays `:id`, with `scope.find` and `to_param` as before. `find_record(controller, id)` and `record_param(record)` are the override points behind it
26
+ - `link: false` on a column leaves the primary column unlinked, for a `render:` proc that links somewhere other than the edit form itself. Before, the gem wrapped such a proc's link in its own
27
+
5
28
  ## [0.3.1] - 2026-10-04
6
29
 
7
30
  ### Security
data/README.md CHANGED
@@ -45,9 +45,9 @@ 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.27
49
- - Ransack ~> 4.0
50
- - Pagy ~> 43.2
48
+ - [layered-ui-rails](https://github.com/layered-ai-public/layered-ui-rails) ~> 0.28, >= 0.28.1
49
+ - Ransack ~> 4.4, >= 4.4.4
50
+ - Pagy ~> 43.8, >= 43.8.1
51
51
 
52
52
  ## Getting started
53
53
 
@@ -205,6 +205,18 @@ end
205
205
 
206
206
  When the attribute has no value, the label falls back to the first of `name`/`title`/`label`/`email` that does, then to the model's own `to_s` if it defines one, and finally to `"Post #12"` — never a bare `#<Post:0x...>`.
207
207
 
208
+ **Lookup attribute:** records are looked up by the `:id` in their URL, the Rails way: member links use `to_param` and the lookup is `scope.find`. Declare `lookup_attribute` when records are addressed by something else - an unguessable token, say, so ids can't be enumerated:
209
+
210
+ ```ruby
211
+ class ConversationResource < Layered::Resource::Base
212
+ model Conversation
213
+
214
+ lookup_attribute :uid # /conversations/Xk3…/edit, found with find_by!(uid:)
215
+ end
216
+ ```
217
+
218
+ Every member action (`show`, `edit`, `update`, `destroy`, and custom `member` routes) finds the record by it within `scope`, so one outside the scope is still a 404. The gem's own links - the primary column, the row actions, `link:` columns into a nested resource, and parent breadcrumbs - put the same attribute in the URL. For a lookup the attribute can't express, override `self.find_record(controller, id)` (and `self.record_param(record)`, which produces the URL value).
219
+
208
220
  **Custom scope (e.g. tenant isolation):**
209
221
 
210
222
  ```ruby
@@ -374,7 +386,7 @@ class PostResource < Layered::Resource::Base
374
386
  end
375
387
  ```
376
388
 
377
- The Ransack allowlists are scoped to the resource: the association and the associated model's attribute are only ransackable when this resource is the one searching, so nothing else gains access to the associated model and any host-app Ransack config is preserved.
389
+ The Ransack allowlists are scoped to the resource: the association and the associated model's attribute are only ransackable when this resource is the one searching, so nothing else gains access to the associated model and any host-app Ransack config is preserved. A model the gem configures isn't opened up for anything else either: other Ransack calls still go through the model's own `ransackable_attributes`/`ransackable_associations`, and if it doesn't define them, the gem raises a "needs explicitly allowlisted" error, as Ransack does for a model with no allowlist.
378
390
 
379
391
  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.
380
392
 
@@ -550,7 +562,7 @@ class PostResource < Layered::Resource::Base
550
562
  end
551
563
  ```
552
564
 
553
- Give `url:` as a callable so it resolves per request in the view, where route helpers are available. The endpoint is an ordinary action in your app — the gem never routes it — so it's authorised however any index is:
565
+ Give `url:` as a callable so it resolves per request in the view, where route helpers are available. The endpoint is an ordinary action in your app — the gem never routes it — so it's authorised however any index is. It searches with Ransack outside any resource, so the model it searches needs its own `ransackable_attributes` covering the `search:` fields:
554
566
 
555
567
  ```ruby
556
568
  class UserOptionsController < ApplicationController
@@ -639,6 +651,15 @@ column a `render:` proc that produces the value.
639
651
 
640
652
  A column with a `render:` proc is exempt - the proc decides what to call, and `attribute:` is then just the header and sort key.
641
653
 
654
+ The primary column links to the record's edit page (or its show page when the resource isn't editable). Pass `link: false` to leave it unlinked - when its `render:` proc links somewhere more useful itself, say:
655
+
656
+ ```ruby
657
+ columns [
658
+ { attribute: :name, primary: true, link: false,
659
+ render: ->(record, view) { view.link_to(record.name, view.conversation_path(record)) } }
660
+ ]
661
+ ```
662
+
642
663
  ### Sortable headers
643
664
 
644
665
  A column header renders a sort link only when the attribute is sortable. This defaults to `true` for real DB columns and `false` for anything else - virtual attributes and delegated association values - because Ransack can't sort those without the associated model allowlisting the underlying field, and the sort link would 500 when clicked. Set `sortable: true` on the column to opt back in; you're then responsible for that model's `ransackable_attributes` (see [Associations](#associations)).
@@ -917,17 +938,16 @@ cd test/dummy && bin/rails db:setup && bin/dev
917
938
 
918
939
  ### Deploying the dummy app
919
940
 
920
- The dummy app can be deployed with [Kamal](https://kamal-deploy.org). Set the required environment variables and deploy from `test/dummy`:
941
+ The dummy app can be deployed with [Kamal](https://kamal-deploy.org). Copy `.env.deploy.example` to `.env.deploy` (gitignored), fill in `KAMAL_DEPLOY_IP`, `KAMAL_DEPLOY_DOMAIN`, and `KAMAL_SSH_KEY`, then run `bin/deploy`:
921
942
 
922
943
  ```bash
923
- cd test/dummy
924
- export KAMAL_DEPLOY_IP=<server-ip>
925
- export KAMAL_DEPLOY_DOMAIN=<domain>
926
- export KAMAL_SSH_KEY=<path-to-ssh-key>
927
- kamal deploy
944
+ cp .env.deploy.example .env.deploy
945
+ bin/deploy setup # first run on a new server
946
+ bin/deploy # kamal deploy
947
+ bin/deploy logs # any other kamal subcommand passes straight through
928
948
  ```
929
949
 
930
- `KAMAL_DEPLOY_DOMAIN` defaults to `layered-resource-rails.layered.ai`. `SECRET_KEY_BASE` is read from `test/dummy/.kamal/secrets`, which is gitignored - create it locally before the first deploy.
950
+ `SECRET_KEY_BASE` is read from `test/dummy/.kamal/secrets`, which is gitignored - create it locally before the first deploy.
931
951
 
932
952
  ## Contributing
933
953
 
@@ -63,7 +63,7 @@ module Layered
63
63
  end
64
64
 
65
65
  def show
66
- @record = @resource.scope(self).find(params[:id])
66
+ @record = @resource.find_record(self, params[:id])
67
67
  authorize_layered_record(@record)
68
68
  @page_title = layered_record_label(@record)
69
69
  end
@@ -89,14 +89,14 @@ module Layered
89
89
  end
90
90
 
91
91
  def edit
92
- @record = @resource.scope(self).find(params[:id])
92
+ @record = @resource.find_record(self, params[:id])
93
93
  authorize_layered_record(@record)
94
94
  @form_url = layered_member_path(@record)
95
95
  @page_title = "Edit #{layered_record_label(@record)}"
96
96
  end
97
97
 
98
98
  def update
99
- @record = @resource.scope(self).find(params[:id])
99
+ @record = @resource.find_record(self, params[:id])
100
100
  authorize_layered_record(@record)
101
101
  if @record.update(layered_resource_params)
102
102
  redirect_to @resource.after_save_path(self, @record),
@@ -109,7 +109,7 @@ module Layered
109
109
  end
110
110
 
111
111
  def destroy
112
- @record = @resource.scope(self).find(params[:id])
112
+ @record = @resource.find_record(self, params[:id])
113
113
  authorize_layered_record(@record)
114
114
  redirect_path = @resource.after_save_path(self, @record)
115
115
  model_name = @resource.model.model_name.human
@@ -217,7 +217,7 @@ module Layered
217
217
 
218
218
  # For custom member actions declared in a `layered_resources` block,
219
219
  # populate @record from params[:id] so action bodies don't have to
220
- # repeat `@resource.scope(self).find(params[:id])`. Skip this with
220
+ # repeat `@resource.find_record(self, params[:id])`. Skip this with
221
221
  # `skip_before_action :load_layered_member_record, only: [:foo]` if
222
222
  # the action doesn't need the record (or shouldn't 404 on a missing
223
223
  # one).
@@ -228,7 +228,7 @@ module Layered
228
228
  member_actions = @_route_entry[:member_actions] || []
229
229
  return unless member_actions.include?(action_name.to_sym)
230
230
 
231
- @record = @resource.scope(self).find(params[:id])
231
+ @record = @resource.find_record(self, params[:id])
232
232
  authorize_layered_record(@record)
233
233
  end
234
234
 
@@ -35,9 +35,10 @@ module Layered
35
35
  # parent that lives in the same namespace.
36
36
  collection_key = parent_collection_keys[key]
37
37
  collection_entry = collection_key && Layered::Resource::Routing.lookup(collection_key)
38
+ parent_resource = collection_entry && collection_entry[:resource].constantize
38
39
  model_class =
39
- if collection_entry
40
- collection_entry[:resource].constantize.model
40
+ if parent_resource
41
+ parent_resource.model
41
42
  elsif current_namespace
42
43
  "#{current_namespace}::#{model_name.classify}".safe_constantize ||
43
44
  model_name.classify.safe_constantize
@@ -67,11 +68,13 @@ module Layered
67
68
 
68
69
  # Add the specific record breadcrumb, linked to its own show
69
70
  # page when the parent resource has one - a crumb that isn't
70
- # the current page shouldn't be a dead end.
71
- record = model_class.find_by(id: params[key])
71
+ # the current page shouldn't be a dead end. The param holds
72
+ # whatever the parent resource addresses its records by.
73
+ lookup_attribute = parent_resource ? parent_resource.lookup_attribute : :id
74
+ record = model_class.find_by(lookup_attribute => params[key])
72
75
  if record
73
76
  label = record.try(:name) || record.try(:title) || "#{model_class.model_name.human} ##{record.id}"
74
- crumbs << { label: label, path: layered_parent_record_path(collection_entry, collection_key, ancestor_args, record) }
77
+ crumbs << { label: label, path: layered_parent_record_path(collection_entry, parent_resource, collection_key, ancestor_args, record) }
75
78
  end
76
79
 
77
80
  crumbs
@@ -82,7 +85,7 @@ module Layered
82
85
  # The parent record's show path, or nil when the parent isn't a
83
86
  # layered resource, doesn't route :show, or can't be addressed
84
87
  # from here.
85
- def layered_parent_record_path(collection_entry, collection_key, ancestor_args, record)
88
+ def layered_parent_record_path(collection_entry, parent_resource, collection_key, ancestor_args, record)
86
89
  return nil unless collection_entry && ancestor_args
87
90
  return nil unless collection_entry[:actions].include?(:show)
88
91
 
@@ -90,7 +93,7 @@ module Layered
90
93
  helper = :"#{collection_key.to_s.singularize}_path"
91
94
  return nil unless rs.url_helpers.method_defined?(helper)
92
95
 
93
- rs.url_helpers.send(helper, default_url_options.merge(ancestor_args).merge(id: record.to_param))
96
+ rs.url_helpers.send(helper, default_url_options.merge(ancestor_args).merge(id: parent_resource.record_param(record)))
94
97
  end
95
98
  end
96
99
  end
@@ -154,7 +154,8 @@ module Layered
154
154
  # and only falls back to show for read-only resources that still
155
155
  # expose a detail page. The "primary" column is the one marked
156
156
  # primary: true (or the first column if none is). Columns that already
157
- # declare a custom link: are left alone.
157
+ # declare a custom link: are left alone, and `link: false` leaves the
158
+ # column unlinked - for a `render:` proc that links somewhere else.
158
159
  def apply_primary_column_link
159
160
  singular = @layered_route_key.singularize
160
161
  helper = if @resource_can_edit
@@ -172,13 +173,13 @@ module Layered
172
173
 
173
174
  @columns = @columns.each_with_index.map do |col, i|
174
175
  next col unless i == primary_index
175
- next col if col[:link]
176
+ next col if col.key?(:link)
176
177
 
177
178
  inner_render = col[:render]
178
179
  col.merge(
179
180
  render: ->(record) {
180
181
  value = inner_render.call(record)
181
- view.link_to value, routes_proxy.send(helper, record), data: { turbo_frame: "_top" }
182
+ view.link_to value, routes_proxy.send(helper, @resource.record_param(record)), data: { turbo_frame: "_top" }
182
183
  }
183
184
  )
184
185
  end
@@ -224,7 +225,10 @@ module Layered
224
225
  value = inner_render.call(record)
225
226
  args = opts.dup
226
227
  ancestor_params.each { |p| args[p] = record.public_send(p) }
227
- args[immediate_parent] = record.id
228
+ # The parent is this resource's record, so it goes in the URL
229
+ # the way this resource's own links put it - its id unless
230
+ # the resource declares a `lookup_attribute`.
231
+ args[immediate_parent] = @resource.lookup_attribute == :id ? record.id : @resource.record_param(record)
228
232
  path = rs.url_helpers.send(path_helper, args)
229
233
  view.link_to value, path, data: { turbo_frame: "_top" }
230
234
  }
@@ -52,7 +52,7 @@ module Layered
52
52
  "No #{action || 'member'} route registered for #{@layered_route_key}. " \
53
53
  "Include the matching action in only:."
54
54
  end
55
- layered_routes.send(helper, record)
55
+ layered_routes.send(helper, @resource.record_param(record))
56
56
  end
57
57
  end
58
58
  end
@@ -126,6 +126,40 @@ module Layered
126
126
  Layered::Resource.record_label(record, attribute: label_attribute)
127
127
  end
128
128
 
129
+ # The attribute a record is looked up by from the `:id` in its URL,
130
+ # and that the gem's own member links put there. Defaults to `:id`,
131
+ # which keeps Rails' own behaviour: links use `to_param` and the
132
+ # lookup is `scope.find`, so a gem that overrides both (FriendlyId,
133
+ # say) still works. Declare it when records are addressed by
134
+ # something else - an unguessable token, a slug:
135
+ #
136
+ # lookup_attribute :uid
137
+ def lookup_attribute(value = nil)
138
+ if value
139
+ @lookup_attribute = value.to_sym
140
+ else
141
+ inherited_attribute(:@lookup_attribute) || :id
142
+ end
143
+ end
144
+
145
+ # The record named by `id`, within `scope` - raising
146
+ # ActiveRecord::RecordNotFound (a 404) when it is outside it. Every
147
+ # member action looks its record up through here, so override it for
148
+ # a lookup `lookup_attribute` can't express.
149
+ def find_record(controller, id)
150
+ relation = scope(controller)
151
+ return relation.find(id) if lookup_attribute == :id
152
+
153
+ relation.find_by!(lookup_attribute => id)
154
+ end
155
+
156
+ # What a member URL carries for `record`: the inverse of `find_record`.
157
+ def record_param(record)
158
+ return record.to_param if lookup_attribute == :id
159
+
160
+ record.public_send(lookup_attribute).to_s
161
+ end
162
+
129
163
  # Declares structured filter controls for the index table. Each entry
130
164
  # is either a bare attribute (control + Ransack predicate inferred from
131
165
  # the column type, enum, or association) or an attribute with an
@@ -367,24 +401,46 @@ module Layered
367
401
  # `m`. The overrides only answer when the `auth_object` is a layered
368
402
  # resource that references `m` — either as its own model, or via an
369
403
  # association-walking search field. Every other caller falls through
370
- # to the methods captured below (whether host-defined or the framework
371
- # default), so any allowlist the host app has set up is preserved.
404
+ # to the host's own allowlist when it has one. When it doesn't, a
405
+ # layered resource gets nothing and anyone else gets the error Ransack
406
+ # raises for an unconfigured model. Delegating to Ransack's builtin
407
+ # instead would be deprecated, and since these overrides count as an
408
+ # explicit definition it would allowlist every column — so patching a
409
+ # model must not widen what the rest of the app can search.
372
410
  def patch_ransack(m)
373
411
  return if m.instance_variable_get(:@_layered_resource_ransack_configured)
374
412
 
375
413
  original_attributes = m.method(:ransackable_attributes)
376
414
  original_associations = m.method(:ransackable_associations)
377
415
  # Detect a host-defined override anywhere in the model's singleton
378
- # ancestry (Post, ApplicationRecord, ActiveRecord::Base, etc.).
379
- # Ransack supplies the default via a regular Module mixin, so its
380
- # `owner` is a Module but not a Class; an explicit `def self.x` in
381
- # any host class produces a singleton-class owner, which is a Class.
382
- host_attributes_defined = original_attributes.owner.is_a?(Class)
383
- host_associations_defined = original_associations.owner.is_a?(Class)
416
+ # ancestry: a `def self.x` in Post or ApplicationRecord, or a module
417
+ # brought in with `extend` or a concern's `class_methods`. Only
418
+ # Ransack's own default (the module it extends ActiveRecord::Base
419
+ # with) counts as "no allowlist".
420
+ host_attributes_defined = original_attributes.owner != ::Ransack::Adapters::ActiveRecord::Base
421
+ host_associations_defined = original_associations.owner != ::Ransack::Adapters::ActiveRecord::Base
422
+ fallback_attributes = ->(klass, auth_object) do
423
+ if host_attributes_defined
424
+ original_attributes.call(auth_object)
425
+ elsif auth_object.is_a?(Class) && auth_object < Layered::Resource::Base
426
+ []
427
+ else
428
+ Layered::Resource::Base.send(:raise_unconfigured_ransack, klass, :ransackable_attributes)
429
+ end
430
+ end
431
+ fallback_associations = ->(klass, auth_object) do
432
+ if host_associations_defined
433
+ original_associations.call(auth_object)
434
+ elsif auth_object.is_a?(Class) && auth_object < Layered::Resource::Base
435
+ []
436
+ else
437
+ Layered::Resource::Base.send(:raise_unconfigured_ransack, klass, :ransackable_associations)
438
+ end
439
+ end
384
440
 
385
441
  m.define_singleton_method(:ransackable_attributes) do |auth_object = nil|
386
442
  unless auth_object.is_a?(Class) && auth_object < Layered::Resource::Base
387
- next original_attributes.call(auth_object)
443
+ next fallback_attributes.call(self, auth_object)
388
444
  end
389
445
 
390
446
  if auth_object.model == self
@@ -418,12 +474,7 @@ module Layered
418
474
  declared = auth_object.association_search_fields
419
475
  .select { |a| a[:klass] == self }
420
476
  .map { |a| a[:attribute] }
421
- if declared.any?
422
- base = host_attributes_defined ? original_attributes.call(auth_object) : []
423
- base | declared
424
- else
425
- original_attributes.call(auth_object)
426
- end
477
+ fallback_attributes.call(self, auth_object) | declared
427
478
  end
428
479
  end
429
480
 
@@ -434,16 +485,26 @@ module Layered
434
485
  # rather than 500ing.
435
486
  m.define_singleton_method(:ransackable_associations) do |auth_object = nil|
436
487
  if auth_object.is_a?(Class) && auth_object < Layered::Resource::Base && auth_object.model == self
437
- base = host_associations_defined ? original_associations.call(auth_object) : []
438
- base | auth_object.association_search_fields.map { |a| a[:association] }
488
+ fallback_associations.call(self, auth_object) | auth_object.association_search_fields.map { |a| a[:association] }
439
489
  else
440
- original_associations.call(auth_object)
490
+ fallback_associations.call(self, auth_object)
441
491
  end
442
492
  end
443
493
 
444
494
  m.instance_variable_set(:@_layered_resource_ransack_configured, true)
445
495
  end
446
496
 
497
+ # Mirrors Ransack 4's refusal to search a model that hasn't
498
+ # allowlisted anything, for callers outside the gem.
499
+ def raise_unconfigured_ransack(m, method)
500
+ raise <<~MESSAGE.squish
501
+ Ransack needs #{m.name} #{method.to_s.delete_prefix("ransackable_")} explicitly
502
+ allowlisted as searchable. Define `#{m.name}.#{method}(auth_object = nil)`,
503
+ leaving out anything you don't want searchable. layered-resource-rails
504
+ only allowlists for its own resources.
505
+ MESSAGE
506
+ end
507
+
447
508
  # Walks the resource class ancestry to find the first ancestor that
448
509
  # has the given ivar set. Class-level ivars are not inherited in Ruby,
449
510
  # so we explicitly walk to give subclasses access to a parent's
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Resource
3
- VERSION = "0.3.1"
3
+ VERSION = "0.5.0"
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.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -13,44 +13,62 @@ dependencies:
13
13
  name: concurrent-ruby
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
16
19
  - - ">="
17
20
  - !ruby/object:Gem::Version
18
- version: '1.0'
21
+ version: 1.3.8
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '1.3'
23
29
  - - ">="
24
30
  - !ruby/object:Gem::Version
25
- version: '1.0'
31
+ version: 1.3.8
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: layered-ui-rails
28
34
  requirement: !ruby/object:Gem::Requirement
29
35
  requirements:
30
36
  - - "~>"
31
37
  - !ruby/object:Gem::Version
32
- version: '0.27'
38
+ version: '0.28'
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: 0.28.1
33
42
  type: :runtime
34
43
  prerelease: false
35
44
  version_requirements: !ruby/object:Gem::Requirement
36
45
  requirements:
37
46
  - - "~>"
38
47
  - !ruby/object:Gem::Version
39
- version: '0.27'
48
+ version: '0.28'
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: 0.28.1
40
52
  - !ruby/object:Gem::Dependency
41
53
  name: pagy
42
54
  requirement: !ruby/object:Gem::Requirement
43
55
  requirements:
44
56
  - - "~>"
45
57
  - !ruby/object:Gem::Version
46
- version: '43.2'
58
+ version: '43.8'
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 43.8.1
47
62
  type: :runtime
48
63
  prerelease: false
49
64
  version_requirements: !ruby/object:Gem::Requirement
50
65
  requirements:
51
66
  - - "~>"
52
67
  - !ruby/object:Gem::Version
53
- version: '43.2'
68
+ version: '43.8'
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 43.8.1
54
72
  - !ruby/object:Gem::Dependency
55
73
  name: rails
56
74
  requirement: !ruby/object:Gem::Requirement
@@ -71,14 +89,20 @@ dependencies:
71
89
  requirements:
72
90
  - - "~>"
73
91
  - !ruby/object:Gem::Version
74
- version: '4.0'
92
+ version: '4.4'
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 4.4.4
75
96
  type: :runtime
76
97
  prerelease: false
77
98
  version_requirements: !ruby/object:Gem::Requirement
78
99
  requirements:
79
100
  - - "~>"
80
101
  - !ruby/object:Gem::Version
81
- version: '4.0'
102
+ version: '4.4'
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: 4.4.4
82
106
  - !ruby/object:Gem::Dependency
83
107
  name: devise
84
108
  requirement: !ruby/object:Gem::Requirement
@@ -86,6 +110,9 @@ dependencies:
86
110
  - - "~>"
87
111
  - !ruby/object:Gem::Version
88
112
  version: '5.0'
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: 5.0.4
89
116
  type: :development
90
117
  prerelease: false
91
118
  version_requirements: !ruby/object:Gem::Requirement
@@ -93,90 +120,129 @@ dependencies:
93
120
  - - "~>"
94
121
  - !ruby/object:Gem::Version
95
122
  version: '5.0'
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: 5.0.4
96
126
  - !ruby/object:Gem::Dependency
97
127
  name: importmap-rails
98
128
  requirement: !ruby/object:Gem::Requirement
99
129
  requirements:
100
130
  - - "~>"
101
131
  - !ruby/object:Gem::Version
102
- version: '2.0'
132
+ version: '2.2'
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 2.2.3
103
136
  type: :development
104
137
  prerelease: false
105
138
  version_requirements: !ruby/object:Gem::Requirement
106
139
  requirements:
107
140
  - - "~>"
108
141
  - !ruby/object:Gem::Version
109
- version: '2.0'
142
+ version: '2.2'
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: 2.2.3
110
146
  - !ruby/object:Gem::Dependency
111
147
  name: propshaft
112
148
  requirement: !ruby/object:Gem::Requirement
113
149
  requirements:
114
150
  - - "~>"
115
151
  - !ruby/object:Gem::Version
116
- version: '1.0'
152
+ version: '1.3'
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: 1.3.2
117
156
  type: :development
118
157
  prerelease: false
119
158
  version_requirements: !ruby/object:Gem::Requirement
120
159
  requirements:
121
160
  - - "~>"
122
161
  - !ruby/object:Gem::Version
123
- version: '1.0'
162
+ version: '1.3'
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: 1.3.2
124
166
  - !ruby/object:Gem::Dependency
125
167
  name: pundit
126
168
  requirement: !ruby/object:Gem::Requirement
127
169
  requirements:
128
170
  - - "~>"
129
171
  - !ruby/object:Gem::Version
130
- version: '2.4'
172
+ version: '2.5'
173
+ - - ">="
174
+ - !ruby/object:Gem::Version
175
+ version: 2.5.2
131
176
  type: :development
132
177
  prerelease: false
133
178
  version_requirements: !ruby/object:Gem::Requirement
134
179
  requirements:
135
180
  - - "~>"
136
181
  - !ruby/object:Gem::Version
137
- version: '2.4'
182
+ version: '2.5'
183
+ - - ">="
184
+ - !ruby/object:Gem::Version
185
+ version: 2.5.2
138
186
  - !ruby/object:Gem::Dependency
139
187
  name: puma
140
188
  requirement: !ruby/object:Gem::Requirement
141
189
  requirements:
142
190
  - - "~>"
143
191
  - !ruby/object:Gem::Version
144
- version: '7.0'
192
+ version: '7.2'
193
+ - - ">="
194
+ - !ruby/object:Gem::Version
195
+ version: 7.2.1
145
196
  type: :development
146
197
  prerelease: false
147
198
  version_requirements: !ruby/object:Gem::Requirement
148
199
  requirements:
149
200
  - - "~>"
150
201
  - !ruby/object:Gem::Version
151
- version: '7.0'
202
+ version: '7.2'
203
+ - - ">="
204
+ - !ruby/object:Gem::Version
205
+ version: 7.2.1
152
206
  - !ruby/object:Gem::Dependency
153
207
  name: sqlite3
154
208
  requirement: !ruby/object:Gem::Requirement
155
209
  requirements:
156
210
  - - "~>"
157
211
  - !ruby/object:Gem::Version
158
- version: '2.0'
212
+ version: '2.10'
213
+ - - ">="
214
+ - !ruby/object:Gem::Version
215
+ version: 2.10.2
159
216
  type: :development
160
217
  prerelease: false
161
218
  version_requirements: !ruby/object:Gem::Requirement
162
219
  requirements:
163
220
  - - "~>"
164
221
  - !ruby/object:Gem::Version
165
- version: '2.0'
222
+ version: '2.10'
223
+ - - ">="
224
+ - !ruby/object:Gem::Version
225
+ version: 2.10.2
166
226
  - !ruby/object:Gem::Dependency
167
227
  name: stimulus-rails
168
228
  requirement: !ruby/object:Gem::Requirement
169
229
  requirements:
170
230
  - - "~>"
171
231
  - !ruby/object:Gem::Version
172
- version: '1.0'
232
+ version: '1.3'
233
+ - - ">="
234
+ - !ruby/object:Gem::Version
235
+ version: 1.3.4
173
236
  type: :development
174
237
  prerelease: false
175
238
  version_requirements: !ruby/object:Gem::Requirement
176
239
  requirements:
177
240
  - - "~>"
178
241
  - !ruby/object:Gem::Version
179
- version: '1.0'
242
+ version: '1.3'
243
+ - - ">="
244
+ - !ruby/object:Gem::Version
245
+ version: 1.3.4
180
246
  - !ruby/object:Gem::Dependency
181
247
  name: turbo-rails
182
248
  requirement: !ruby/object:Gem::Requirement
@@ -184,6 +250,9 @@ dependencies:
184
250
  - - "~>"
185
251
  - !ruby/object:Gem::Version
186
252
  version: '2.0'
253
+ - - ">="
254
+ - !ruby/object:Gem::Version
255
+ version: 2.0.23
187
256
  type: :development
188
257
  prerelease: false
189
258
  version_requirements: !ruby/object:Gem::Requirement
@@ -191,6 +260,9 @@ dependencies:
191
260
  - - "~>"
192
261
  - !ruby/object:Gem::Version
193
262
  version: '2.0'
263
+ - - ">="
264
+ - !ruby/object:Gem::Version
265
+ version: 2.0.23
194
266
  description: A convention-over-configuration CRUD engine for Rails 8+, built on the
195
267
  layered-ui-rails gem. Declare a resource class, mount it in routes.rb, and get index,
196
268
  new/create, edit/update, and destroy actions with clean default views, Ransack-powered