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 +4 -4
- data/.claude/skills/layered-resource-rails/SKILL.md +4 -3
- data/CHANGELOG.md +23 -0
- data/README.md +32 -12
- data/app/controllers/layered/resource/controller.rb +6 -6
- data/app/controllers/layered/resource/internal/breadcrumbs.rb +10 -7
- data/app/controllers/layered/resource/internal/columns.rb +8 -4
- data/app/controllers/layered/resource/internal/routing.rb +1 -1
- data/lib/layered/resource/base.rb +79 -18
- data/lib/layered/resource/version.rb +1 -1
- metadata +93 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd456a360f63f94c4c38e833251b2d4b22a20c7df76b2b78612d9baf55135e9c
|
|
4
|
+
data.tar.gz: 783f255e36a42959f5565862cf2354d5a57cb1ec5e2d98fbf281477613446c43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
49
|
-
- Ransack ~> 4.
|
|
50
|
-
- Pagy ~> 43.
|
|
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).
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
`
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
371
|
-
#
|
|
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
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
382
|
-
host_attributes_defined = original_attributes.owner
|
|
383
|
-
host_associations_defined = original_associations.owner
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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.
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|