layered-resource-rails 0.4.0 → 0.6.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 +14 -3
- data/CHANGELOG.md +30 -0
- data/README.md +36 -7
- data/app/controllers/layered/resource/controller.rb +6 -0
- data/app/views/layered/resource/resources/_row_actions.html.erb +19 -0
- data/app/views/layered/resource/resources/index.html.erb +13 -16
- data/lib/generators/layered/resource/views/views_generator.rb +2 -1
- data/lib/layered/resource/base.rb +45 -18
- data/lib/layered/resource/version.rb +1 -1
- metadata +94 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4560cdb00f90fd031ddb92e6a472e8b72e375c929864a36f013206f59abfa21a
|
|
4
|
+
data.tar.gz: b9b93f938d7448c751e9a78b66ff9f23ea174f72e6feec1c67752309bf3f8acb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e49ac83a4e70bcb16a51eee140652eb9331c856bd5664bad52955474cbb523ee2d78faf7146ef80aa66b78f2ae6e312ac9d733a099deb34dea4b0b2ccb6be76
|
|
7
|
+
data.tar.gz: 34c074eb474d6aa7aac0c0e9bc24f2feb334023ec6787fc11ea6d7a2c357147a72c258348244051fb60aeff0965b7be1cec0b4693994e52371b0ed96d06334f8
|
|
@@ -178,7 +178,7 @@ Inference by column type: `enum`/`belongs_to` -> multi-select (`_in`; `multiple:
|
|
|
178
178
|
|
|
179
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.
|
|
180
180
|
|
|
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. 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.
|
|
182
182
|
|
|
183
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.
|
|
184
184
|
|
|
@@ -309,7 +309,7 @@ When enabled:
|
|
|
309
309
|
|
|
310
310
|
- `scope(controller)` defaults to the controller's `policy_scope(model)` helper, so apps that override `pundit_user` (e.g. `current_account`) get the same identity used by `authorize`.
|
|
311
311
|
- The controller calls `authorize(@record)` after loading a member record (show/edit/update/destroy and any custom member action).
|
|
312
|
-
- The `New`/`Edit`/`Delete` actions in the default views (inline buttons on show, a per-row actions popover menu on index, in a column pinned to the right edge via `l_ui_table`'s `floating_actions:`) hide for users whose policy denies the action - the views call a `resource_can?(action, record = nil)` helper that ANDs the route-exposure flag with `policy(record).<action>?`.
|
|
312
|
+
- The `New`/`Edit`/`Delete` actions in the default views (inline buttons on show, a per-row actions popover menu on index, in a column pinned to the right edge via `l_ui_table`'s `floating_actions:`) hide for users whose policy denies the action - the views call a `resource_can?(action, record = nil)` helper that ANDs the route-exposure flag with `policy(record).<action>?`. A resource's own `_row_actions` partial replaces the index menu's checks, so it must call `resource_can?` itself (see Row actions).
|
|
313
313
|
|
|
314
314
|
`owned_by` composes with `use_pundit`: Pundit's `Policy::Scope#resolve` wins for the read filter; `owned_by` still drives owner assignment on create. Stack both when needed.
|
|
315
315
|
|
|
@@ -344,6 +344,17 @@ end
|
|
|
344
344
|
|
|
345
345
|
Drop a partial at `app/views/layered/<resource>/_introduction.html.erb` to render content above the search area on that resource's index page. The partial is rendered when present and skipped otherwise — no DSL, no configuration. It uses the same per-resource view path as ejected templates and column overrides.
|
|
346
346
|
|
|
347
|
+
## Row actions
|
|
348
|
+
|
|
349
|
+
Each index row's popover menu holds whatever the `_row_actions` partial renders for that record. The default renders `Edit`/`Delete`, gated by `resource_can?(action, record)`. For a menu that varies per record or per user (a role-dependent "Members"/"Settings"/"Leave", say), drop `app/views/layered/<resource>/_row_actions.html.erb` - **don't eject the whole index for this**; the override keeps search, filters, sort and pagination.
|
|
350
|
+
|
|
351
|
+
- Locals: `record`. Full view context: `current_user`, host path helpers, `layered_member_path`, `resource_can?`.
|
|
352
|
+
- Render items only (the index wraps them in the popover): class `l-ui-popover__menu-item` (`--danger` for destructive), `<hr class="l-ui-popover__menu-divider">` between groups, `data: { turbo_frame: "_top" }` on each. Use `button_to ..., method: :delete` for non-GET actions.
|
|
353
|
+
- Empty output for a row means no menu on that row.
|
|
354
|
+
- The override replaces the default's gating - wrap any kept Edit/Delete in `resource_can?` so `only:` and Pundit still apply.
|
|
355
|
+
- The index renders every row's menu before the table and adds the actions column only when at least one row has one - so an index-only route gets a column only if its partial renders items, and an ejected copy of the default adds no empty column.
|
|
356
|
+
- The partial is keyed by the route's resource name, so every route for that name (e.g. `/team_posts` and `/browse/team_posts`) shares it.
|
|
357
|
+
|
|
347
358
|
## Ejection
|
|
348
359
|
|
|
349
360
|
Take over presentation or controller logic without giving up the rest:
|
|
@@ -444,7 +455,7 @@ columns [
|
|
|
444
455
|
|
|
445
456
|
`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`.
|
|
446
457
|
|
|
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 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.
|
|
458
|
+
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.
|
|
448
459
|
|
|
449
460
|
## Common issues
|
|
450
461
|
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
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.6.0] - 2026-11-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- A resource can supply its own index row actions with a `_row_actions` partial at `app/views/layered/<resource>/_row_actions.html.erb`, rendered once per row with `record` as a local. It replaces the default `Edit`/`Delete` menu for that resource, keeping the index's search, filters, sorting and pagination, so menus that vary per record or per user no longer mean ejecting the whole index. A row whose partial renders nothing gets no menu, and the actions column appears only when at least one row on the page has one, so an index-only route can have row actions too. The default menu is now this partial too, and `rails g layered:resource:views` copies it
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- The index leaves out the actions column when no row on the page has a menu, such as a page where the policy denies every row. It used to show an empty column whenever edit or destroy was routed
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- A partial the default views render by bare name, such as `_search` or `_filters`, is now picked up from `app/views/layered/<resource>/` when overridden there. The lookup context could be built before the resource was known, so its prefixes left out the per-resource directory and the gem's own partial always won
|
|
18
|
+
|
|
19
|
+
## [0.5.0] - 2026-11-11
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- 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
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
|
|
32
|
+
- 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.
|
|
33
|
+
- 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
|
|
34
|
+
|
|
5
35
|
## [0.4.0] - 2026-10-04
|
|
6
36
|
|
|
7
37
|
### Added
|
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
|
|
|
@@ -133,7 +133,7 @@ That's it. You now have a full CRUD interface with search and pagination for `Po
|
|
|
133
133
|
|
|
134
134
|
The index table's primary column (`primary: true`, or the first column) links to each record's edit page - or, for read-only resources without an edit action, to its show page.
|
|
135
135
|
|
|
136
|
-
Row actions (`Edit`/`Delete
|
|
136
|
+
Row actions (`Edit`/`Delete`, or [your own](#row-actions)) live in a popover menu in the table's last column. That column is pinned to the right-hand edge of the table's scroll container, so the actions menu stays reachable when a wide table scrolls horizontally.
|
|
137
137
|
|
|
138
138
|
Each action also sets `@page_title` for use by the layout's `<title>` tag - `"Posts"` on index, `"New Post"` on new, the record's primary column value on show, and `"Edit <record label>"` on edit. Override after `super` in a custom action if you need something different.
|
|
139
139
|
|
|
@@ -291,7 +291,8 @@ When enabled:
|
|
|
291
291
|
`Edit`/`Delete` items in the index row's actions popover, and the
|
|
292
292
|
`Edit`/`Delete` buttons on the show page check
|
|
293
293
|
`policy(record).new?/update?/destroy?` so users only see actions they
|
|
294
|
-
can perform.
|
|
294
|
+
can perform. A resource's own [`_row_actions`](#row-actions) partial
|
|
295
|
+
replaces the row menu's checks, so it calls `resource_can?` itself.
|
|
295
296
|
|
|
296
297
|
> **`verify_authorized` / `verify_policy_scoped`**: if your
|
|
297
298
|
> `ApplicationController` runs Pundit's `after_action :verify_authorized`
|
|
@@ -386,7 +387,7 @@ class PostResource < Layered::Resource::Base
|
|
|
386
387
|
end
|
|
387
388
|
```
|
|
388
389
|
|
|
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.
|
|
390
|
+
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.
|
|
390
391
|
|
|
391
392
|
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.
|
|
392
393
|
|
|
@@ -562,7 +563,7 @@ class PostResource < Layered::Resource::Base
|
|
|
562
563
|
end
|
|
563
564
|
```
|
|
564
565
|
|
|
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:
|
|
566
|
+
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:
|
|
566
567
|
|
|
567
568
|
```ruby
|
|
568
569
|
class UserOptionsController < ApplicationController
|
|
@@ -684,6 +685,34 @@ To render an introduction above the search area on a resource's index page, drop
|
|
|
684
685
|
|
|
685
686
|
The partial sits inside the resource's view directory, so it follows the same per-resource override path as ejected views and column partials.
|
|
686
687
|
|
|
688
|
+
## Row actions
|
|
689
|
+
|
|
690
|
+
Each index row's actions menu holds whatever the `_row_actions` partial renders for that record. The gem's default renders `Edit` and `Delete`, each shown only when `resource_can?` allows it for the record. To give a resource its own menu, drop a partial at `app/views/layered/<resource>/_row_actions.html.erb`. It replaces the default for that resource only, and the index keeps its search, filters, sorting and pagination.
|
|
691
|
+
|
|
692
|
+
The partial renders once per row with `record` as a local, in the normal view context, so `current_user`, your app's path helpers, `layered_member_path` and `resource_can?` are all available. Render the menu items only; the index wraps them in the popover. A row whose partial renders nothing gets no menu.
|
|
693
|
+
|
|
694
|
+
```erb
|
|
695
|
+
<%# app/views/layered/accounts/_row_actions.html.erb %>
|
|
696
|
+
<% role = record.role_for(current_user) %>
|
|
697
|
+
<% if role.in?(%w[owner admin]) %>
|
|
698
|
+
<%= link_to "Members", account_members_path(record),
|
|
699
|
+
class: "l-ui-popover__menu-item", data: { turbo_frame: "_top" } %>
|
|
700
|
+
<% end %>
|
|
701
|
+
<% if role == "owner" %>
|
|
702
|
+
<%= link_to "Settings", edit_account_path(record),
|
|
703
|
+
class: "l-ui-popover__menu-item", data: { turbo_frame: "_top" } %>
|
|
704
|
+
<% end %>
|
|
705
|
+
<hr class="l-ui-popover__menu-divider">
|
|
706
|
+
<%= button_to "Leave", leave_account_path(record), method: :delete,
|
|
707
|
+
class: "l-ui-popover__menu-item l-ui-popover__menu-item--danger",
|
|
708
|
+
data: { turbo_confirm: "Leave this account?", turbo_frame: "_top" } %>
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
- **Item classes.** Give links and buttons `l-ui-popover__menu-item`, add `l-ui-popover__menu-item--danger` for destructive ones, and separate groups with `<hr class="l-ui-popover__menu-divider">`. Add `data: { turbo_frame: "_top" }` so the action leaves the index's Turbo frame.
|
|
712
|
+
- **Gating is yours.** Your partial replaces the default's `resource_can?` checks. Call `resource_can?(:edit, record)` / `resource_can?(:destroy, record)` around any built-in actions you keep, so they still respect the route's `only:` and your Pundit policy. Actions on your own routes are authorised by their own controllers, as usual.
|
|
713
|
+
- **When the column shows.** The actions column appears only when at least one row on the page has a menu. So an index-only route using the default gets none, while a resource whose own partial renders items gets the column on any route.
|
|
714
|
+
- **Ejected views.** `rails g layered:resource:views` copies the default `_row_actions.html.erb` along with the other templates; edit it in place.
|
|
715
|
+
|
|
687
716
|
## Record pickers
|
|
688
717
|
|
|
689
718
|
A field naming a `belongs_to`'s **foreign key** is a record picker, so it renders as a single-select [combobox](https://github.com/layered-ai-public/layered-ui-rails) - a type-ahead input whose selection becomes a removable token - over the associated records, rather than as the number the column happens to hold:
|
|
@@ -189,6 +189,12 @@ module Layered
|
|
|
189
189
|
@columns = @resource.columns
|
|
190
190
|
@layered_route_key = route_key
|
|
191
191
|
@layered_resource_name = @_route_entry[:resource_name].presence || route_key
|
|
192
|
+
# The lookup context may already exist (an earlier before_action can
|
|
193
|
+
# build it), holding prefixes from before the resource name was
|
|
194
|
+
# known. Partials rendered by bare name - `render "row_actions"` -
|
|
195
|
+
# search those prefixes, so refresh them or a per-resource override
|
|
196
|
+
# in app/views/layered/<name>/ is never found.
|
|
197
|
+
lookup_context.prefixes = _prefixes
|
|
192
198
|
@fields = @resource.resolved_fields
|
|
193
199
|
@crud_enabled = @fields.any?
|
|
194
200
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%# The items in an index row's actions menu, rendered once per record with
|
|
2
|
+
`record` as a local. The index wraps whatever this outputs in the row's
|
|
3
|
+
popover, and leaves the row without a menu when it outputs nothing.
|
|
4
|
+
Override per resource at app/views/layered/<resource>/_row_actions.html.erb. %>
|
|
5
|
+
<% can_edit = resource_can?(:edit, record) %>
|
|
6
|
+
<% if can_edit %>
|
|
7
|
+
<%= link_to "Edit", layered_member_path(record, action: :edit),
|
|
8
|
+
class: "l-ui-popover__menu-item",
|
|
9
|
+
data: { turbo_frame: "_top" } %>
|
|
10
|
+
<% end %>
|
|
11
|
+
<% if resource_can?(:destroy, record) %>
|
|
12
|
+
<% if can_edit %>
|
|
13
|
+
<hr class="l-ui-popover__menu-divider">
|
|
14
|
+
<% end %>
|
|
15
|
+
<%= button_to "Delete", layered_member_path(record),
|
|
16
|
+
method: :delete,
|
|
17
|
+
class: "l-ui-popover__menu-item l-ui-popover__menu-item--danger",
|
|
18
|
+
data: { turbo_confirm: "Are you sure?", turbo_frame: "_top" } %>
|
|
19
|
+
<% end %>
|
|
@@ -39,22 +39,19 @@
|
|
|
39
39
|
<%= render "filters", turbo_frame_name: turbo_frame_name %>
|
|
40
40
|
<% end %>
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
<%# Each row's menu holds whatever the _row_actions partial renders for it:
|
|
43
|
+
Edit and Delete by default, or a resource's own override. Rendering
|
|
44
|
+
them up front means the actions column only appears when at least one
|
|
45
|
+
row on the page has a menu - not on an index-only route, nor on a page
|
|
46
|
+
where the policy denies every row. %>
|
|
47
|
+
<% row_menus = @records.each_with_object({}.compare_by_identity) do |record, menus|
|
|
48
|
+
items = render("row_actions", record: record)
|
|
49
|
+
menus[record] = items if items.present?
|
|
50
|
+
end %>
|
|
51
|
+
<% actions_proc = if row_menus.any?
|
|
43
52
|
->(record) do
|
|
44
|
-
items = []
|
|
45
|
-
|
|
46
|
-
items << link_to("Edit", layered_member_path(record, action: :edit),
|
|
47
|
-
class: "l-ui-popover__menu-item",
|
|
48
|
-
data: { turbo_frame: "_top" })
|
|
49
|
-
end
|
|
50
|
-
if resource_can?(:destroy, record)
|
|
51
|
-
items << tag.hr(class: "l-ui-popover__menu-divider") if items.any?
|
|
52
|
-
items << button_to("Delete", layered_member_path(record),
|
|
53
|
-
method: :delete,
|
|
54
|
-
class: "l-ui-popover__menu-item l-ui-popover__menu-item--danger",
|
|
55
|
-
data: { turbo_confirm: "Are you sure?", turbo_frame: "_top" })
|
|
56
|
-
end
|
|
57
|
-
next if items.empty?
|
|
53
|
+
items = row_menus[record]
|
|
54
|
+
next unless items
|
|
58
55
|
|
|
59
56
|
l_ui_popover(align: :end) do |p|
|
|
60
57
|
p.trigger(class: "l-ui-button l-ui-button--outline l-ui-button--icon l-ui-button--small",
|
|
@@ -62,7 +59,7 @@
|
|
|
62
59
|
image_tag("layered_ui/icon_more.svg", alt: "",
|
|
63
60
|
class: "l-ui-icon l-ui-icon--sm", aria: { hidden: true })
|
|
64
61
|
end
|
|
65
|
-
tag.div(
|
|
62
|
+
tag.div(items, class: "l-ui-popover__menu")
|
|
66
63
|
end
|
|
67
64
|
end
|
|
68
65
|
end %>
|
|
@@ -16,7 +16,8 @@ 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
|
-
_search.html.erb _filters.html.erb _filter_control.html.erb
|
|
19
|
+
_search.html.erb _filters.html.erb _filter_control.html.erb
|
|
20
|
+
_row_actions.html.erb].freeze
|
|
20
21
|
|
|
21
22
|
def copy_views
|
|
22
23
|
VIEWS.each do |view|
|
|
@@ -401,24 +401,46 @@ module Layered
|
|
|
401
401
|
# `m`. The overrides only answer when the `auth_object` is a layered
|
|
402
402
|
# resource that references `m` — either as its own model, or via an
|
|
403
403
|
# association-walking search field. Every other caller falls through
|
|
404
|
-
# to the
|
|
405
|
-
#
|
|
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.
|
|
406
410
|
def patch_ransack(m)
|
|
407
411
|
return if m.instance_variable_get(:@_layered_resource_ransack_configured)
|
|
408
412
|
|
|
409
413
|
original_attributes = m.method(:ransackable_attributes)
|
|
410
414
|
original_associations = m.method(:ransackable_associations)
|
|
411
415
|
# Detect a host-defined override anywhere in the model's singleton
|
|
412
|
-
# ancestry
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
#
|
|
416
|
-
host_attributes_defined = original_attributes.owner
|
|
417
|
-
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
|
|
418
440
|
|
|
419
441
|
m.define_singleton_method(:ransackable_attributes) do |auth_object = nil|
|
|
420
442
|
unless auth_object.is_a?(Class) && auth_object < Layered::Resource::Base
|
|
421
|
-
next
|
|
443
|
+
next fallback_attributes.call(self, auth_object)
|
|
422
444
|
end
|
|
423
445
|
|
|
424
446
|
if auth_object.model == self
|
|
@@ -452,12 +474,7 @@ module Layered
|
|
|
452
474
|
declared = auth_object.association_search_fields
|
|
453
475
|
.select { |a| a[:klass] == self }
|
|
454
476
|
.map { |a| a[:attribute] }
|
|
455
|
-
|
|
456
|
-
base = host_attributes_defined ? original_attributes.call(auth_object) : []
|
|
457
|
-
base | declared
|
|
458
|
-
else
|
|
459
|
-
original_attributes.call(auth_object)
|
|
460
|
-
end
|
|
477
|
+
fallback_attributes.call(self, auth_object) | declared
|
|
461
478
|
end
|
|
462
479
|
end
|
|
463
480
|
|
|
@@ -468,16 +485,26 @@ module Layered
|
|
|
468
485
|
# rather than 500ing.
|
|
469
486
|
m.define_singleton_method(:ransackable_associations) do |auth_object = nil|
|
|
470
487
|
if auth_object.is_a?(Class) && auth_object < Layered::Resource::Base && auth_object.model == self
|
|
471
|
-
|
|
472
|
-
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] }
|
|
473
489
|
else
|
|
474
|
-
|
|
490
|
+
fallback_associations.call(self, auth_object)
|
|
475
491
|
end
|
|
476
492
|
end
|
|
477
493
|
|
|
478
494
|
m.instance_variable_set(:@_layered_resource_ransack_configured, true)
|
|
479
495
|
end
|
|
480
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
|
+
|
|
481
508
|
# Walks the resource class ancestry to find the first ancestor that
|
|
482
509
|
# has the given ivar set. Class-level ivars are not inherited in Ruby,
|
|
483
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.6.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
|
|
@@ -225,6 +297,7 @@ files:
|
|
|
225
297
|
- app/views/layered/resource/columns/_text.html.erb
|
|
226
298
|
- app/views/layered/resource/resources/_filter_control.html.erb
|
|
227
299
|
- app/views/layered/resource/resources/_filters.html.erb
|
|
300
|
+
- app/views/layered/resource/resources/_row_actions.html.erb
|
|
228
301
|
- app/views/layered/resource/resources/_search.html.erb
|
|
229
302
|
- app/views/layered/resource/resources/edit.html.erb
|
|
230
303
|
- app/views/layered/resource/resources/index.html.erb
|