layered-resource-rails 0.1.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/skills/layered-resource-rails/SKILL.md +449 -0
  3. data/AGENTS.md +36 -0
  4. data/CHANGELOG.md +45 -0
  5. data/CLA.md +10 -0
  6. data/LICENSE +201 -0
  7. data/NOTICE +7 -0
  8. data/README.md +912 -0
  9. data/Rakefile +23 -0
  10. data/TRADEMARK.md +31 -0
  11. data/app/controllers/layered/resource/controller.rb +284 -0
  12. data/app/controllers/layered/resource/internal/breadcrumbs.rb +80 -0
  13. data/app/controllers/layered/resource/internal/columns.rb +207 -0
  14. data/app/controllers/layered/resource/internal/routing.rb +60 -0
  15. data/app/controllers/layered/resource/resources_controller.rb +20 -0
  16. data/app/helpers/layered/resource/filters_helper.rb +321 -0
  17. data/app/views/layered/resource/columns/_badge.html.erb +2 -0
  18. data/app/views/layered/resource/columns/_boolean.html.erb +1 -0
  19. data/app/views/layered/resource/columns/_datetime.html.erb +1 -0
  20. data/app/views/layered/resource/columns/_text.html.erb +1 -0
  21. data/app/views/layered/resource/resources/_filter_control.html.erb +87 -0
  22. data/app/views/layered/resource/resources/_filters.html.erb +60 -0
  23. data/app/views/layered/resource/resources/edit.html.erb +16 -0
  24. data/app/views/layered/resource/resources/index.html.erb +106 -0
  25. data/app/views/layered/resource/resources/new.html.erb +16 -0
  26. data/app/views/layered/resource/resources/show.html.erb +34 -0
  27. data/config/locales/en.yml +9 -0
  28. data/lib/generators/layered/resource/column/column_generator.rb +63 -0
  29. data/lib/generators/layered/resource/controller/controller_generator.rb +54 -0
  30. data/lib/generators/layered/resource/controller/templates/controller.rb.tt +31 -0
  31. data/lib/generators/layered/resource/install_agent_skill_generator.rb +26 -0
  32. data/lib/generators/layered/resource/resource_generator.rb +63 -0
  33. data/lib/generators/layered/resource/scaffold/scaffold_generator.rb +94 -0
  34. data/lib/generators/layered/resource/templates/resource.rb.tt +19 -0
  35. data/lib/generators/layered/resource/views/views_generator.rb +49 -0
  36. data/lib/layered/resource/base.rb +625 -0
  37. data/lib/layered/resource/engine.rb +33 -0
  38. data/lib/layered/resource/routing.rb +366 -0
  39. data/lib/layered/resource/version.rb +5 -0
  40. data/lib/layered/resource.rb +61 -0
  41. data/lib/layered-resource-rails.rb +1 -0
  42. metadata +299 -0
data/README.md ADDED
@@ -0,0 +1,912 @@
1
+ # layered-resource-rails
2
+
3
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
4
+ [![Website](https://img.shields.io/badge/Website-layered.ai-purple)](https://www.layered.ai/)
5
+ [![Demo](https://img.shields.io/badge/Demo-layered--resource--rails.layered.ai-purple)](https://layered-resource-rails.layered.ai/)
6
+ [![GitHub](https://img.shields.io/badge/GitHub-layered--resource--rails-black)](https://github.com/layered-ai-public/layered-resource-rails)
7
+ [![Discord](https://img.shields.io/badge/Discord-join-5865F2)](https://discord.gg/aCGqz9Bx)
8
+ [![YouTube](https://img.shields.io/badge/YouTube-subscribe-FF0000)](https://www.youtube.com/@UseLayeredAi)
9
+ [![X](https://img.shields.io/badge/X-follow-000000)](https://x.com/UseLayeredAi)
10
+ [![LinkedIn](https://img.shields.io/badge/LinkedIn-follow-0A66C2)](https://www.linkedin.com/company/uselayeredai/)
11
+
12
+ An open source, Rails 8+ engine that provides convention-over-configuration CRUD scaffolding. Define a resource class and a single route, and you get index, show, new/create, edit/update, and destroy actions with search and pagination. Built on top of [layered-ui-rails](https://github.com/layered-ai-public/layered-ui-rails), Ransack, and Pagy.
13
+
14
+ ## Why use it
15
+
16
+ Most Rails apps need an admin area, an internal dashboard, or a "list and edit some records" screen long before they need anything bespoke. `layered-resource-rails` gets you there in a few lines, then stays out of your way as your needs grow:
17
+
18
+ - **Skip the boilerplate.** Declare your columns, fields, and search - get index, show, forms, search, sort, and pagination for free. No scaffold to maintain, no half-finished admin gem to fight.
19
+ - **Looks right out of the box.** Tables, forms, and pagination come pre-styled via [layered-ui-rails](https://github.com/layered-ai-public/layered-ui-rails) with WCAG 2.2 AA compliance and dark mode included.
20
+ - **Override only what you need.** Swap a single view partial, subclass the controller for a custom scope or redirect, or generate plain ERB to take full control - without rewriting the rest.
21
+ - **Eject cleanly if you outgrow it.** Generate a standard Rails controller and views, drop the gem, and you're left with idiomatic Rails. No lock-in, no hidden coupling.
22
+
23
+ ## Agent skill
24
+
25
+ An [agent skill](https://agentskills.io) is included so AI coding agents can work with `layered-resource-rails` in your project. Once installed, the agent can handle the full setup - just ask it to add `layered-resource-rails` to your app and it will install the gem, scaffold resources, and wire up routes.
26
+
27
+ **Project install** - scoped to a single repo, available to all contributors:
28
+
29
+ ```bash
30
+ bin/rails generate layered:resource:install_agent_skill
31
+ ```
32
+
33
+ **Global install** - available across all your projects:
34
+
35
+ ```bash
36
+ ./install-skill.sh
37
+ ```
38
+
39
+ Or install remotely without cloning the repo:
40
+
41
+ ```bash
42
+ curl -fsSL https://raw.githubusercontent.com/layered-ai-public/layered-resource-rails/main/install-skill.sh | sh
43
+ ```
44
+
45
+ ## Requirements
46
+
47
+ - Ruby on Rails >= 8.0
48
+ - [layered-ui-rails](https://github.com/layered-ai-public/layered-ui-rails) ~> 0.25 (>= 0.25.1)
49
+ - Ransack ~> 4.0
50
+ - Pagy ~> 43.2
51
+
52
+ ## Getting started
53
+
54
+ Add to your Gemfile and install:
55
+
56
+ ```bash
57
+ bundle add layered-resource-rails
58
+ ```
59
+
60
+ `layered-resource-rails` depends on `layered-ui-rails` for its UI. If you haven't already set it up, run its install generator:
61
+
62
+ ```bash
63
+ bin/rails generate layered:ui:install
64
+ ```
65
+
66
+ ## Quick start
67
+
68
+ The fastest path: scaffold the model, migration, resource, and route in one shot.
69
+
70
+ ```
71
+ rails g layered:resource:scaffold post title:string body:text
72
+ ```
73
+
74
+ This invokes Rails' built-in `model` generator (so you get the migration and model), writes `app/layered_resources/post_resource.rb` with `columns` and `fields` derived from the attributes, and appends `layered_resources :posts` to `config/routes.rb`. Views are intentionally not generated - the gem's defaults render until you eject them with `rails g layered:resource:views posts`.
75
+
76
+ Pass `--skip-model` if the model already exists. Restrict which CRUD actions get routed with `--actions index show` (emits `only:`) or `--except destroy` (emits `except:`). Pass `--controller` to also eject a controller and wire it into the route, or `--views` to eject the templates upfront.
77
+
78
+ If the model already exists and you just want the resource class plus its route, use `rails g layered:resource post title:string body:text` instead. It writes `app/layered_resources/post_resource.rb` and appends `layered_resources :posts` - pass `--skip-route` to skip the route line.
79
+
80
+ The rest of this section walks through what the generator produces.
81
+
82
+ ### 1. Define a resource
83
+
84
+ Create a file at `app/layered_resources/post_resource.rb`:
85
+
86
+ ```ruby
87
+ class PostResource < Layered::Resource::Base
88
+ model Post
89
+
90
+ columns [
91
+ { attribute: :title, primary: true },
92
+ { attribute: :status },
93
+ { attribute: :created_at, label: "Published" }
94
+ ]
95
+
96
+ search_fields [:title]
97
+
98
+ default_sort attribute: :created_at, direction: :desc
99
+
100
+ fields [
101
+ { attribute: :title },
102
+ { attribute: :body, as: :text },
103
+ { attribute: :status }
104
+ ]
105
+ end
106
+ ```
107
+
108
+ ### 2. Add the route
109
+
110
+ In `config/routes.rb`:
111
+
112
+ ```ruby
113
+ Rails.application.routes.draw do
114
+ layered_resources :posts
115
+ end
116
+ ```
117
+
118
+ That's it. You now have a full CRUD interface with search and pagination for `Post`.
119
+
120
+ ### What you get
121
+
122
+ | Route | Action | Description |
123
+ |-------------------------|---------|------------------|
124
+ | `GET /posts` | index | Paginated table |
125
+ | `GET /posts/:id` | show | Post detail page |
126
+ | `GET /posts/new` | new | New post form |
127
+ | `POST /posts` | create | Create post |
128
+ | `GET /posts/:id/edit` | edit | Edit post form |
129
+ | `PATCH /posts/:id` | update | Update post |
130
+ | `DELETE /posts/:id` | destroy | Delete post |
131
+
132
+ 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.
133
+
134
+ Row actions (`Edit`/`Delete`) 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.
135
+
136
+ 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.
137
+
138
+ ## Options
139
+
140
+ **Read-only (no forms):** omit `fields` and restrict routes:
141
+
142
+ ```ruby
143
+ class PostResource < Layered::Resource::Base
144
+ model Post
145
+
146
+ columns [
147
+ { attribute: :title, primary: true },
148
+ { attribute: :created_at, label: "Published" }
149
+ ]
150
+ end
151
+ ```
152
+
153
+ ```ruby
154
+ layered_resources :posts, only: [:index]
155
+ ```
156
+
157
+ **Restrict actions** with `only:` or `except:`:
158
+
159
+ ```ruby
160
+ layered_resources :posts, only: [:index, :show, :edit, :update]
161
+ layered_resources :posts, except: [:destroy]
162
+ ```
163
+
164
+ The default `show` view is intentionally a blank canvas - the gem doesn't auto-render an attribute list, since generated detail pages tend to be low-value and always need customizing. The index links each record's title to its **edit** page, not its show page, so `show` is only worth keeping if you're going to build a real detail view (eject it with `rails g layered:resource:views` and fill in the template). If you're not, pass `except: [:show]` to drop the route.
165
+
166
+ **Root breadcrumb:** top-level resources render no breadcrumb trail by default. Declare a static first crumb — typically a link back to the host app's dashboard:
167
+
168
+ ```ruby
169
+ class PostResource < Layered::Resource::Base
170
+ model Post
171
+
172
+ root_breadcrumb "Home", "/"
173
+ end
174
+ ```
175
+
176
+ Nested routes prepend it to the derived parent trail (e.g. Home / Users / Alice). Pass `nil` as the path to render unlinked text.
177
+
178
+ **Record label:** a record is labelled by its **primary column** (the one marked `primary: true`, else the first) wherever the gem has to name it: the `show` and `edit` page titles, a row's actions menu, and its options in another resource's picker. Declare `label_attribute` when that column isn't the record's name — a `primary:` column rendered by a `render:` proc, say:
179
+
180
+ ```ruby
181
+ class PostResource < Layered::Resource::Base
182
+ model Post
183
+
184
+ columns [
185
+ { attribute: :headline, primary: true, render: ->(record, view) { view.tag.strong(record.headline) } },
186
+ { attribute: :created_at }
187
+ ]
188
+
189
+ label_attribute :title
190
+ end
191
+ ```
192
+
193
+ 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...>`.
194
+
195
+ **Custom scope (e.g. tenant isolation):**
196
+
197
+ ```ruby
198
+ class PostResource < Layered::Resource::Base
199
+ model Post
200
+
201
+ # ...columns, fields, etc.
202
+
203
+ def self.scope(controller)
204
+ controller.current_team.posts
205
+ end
206
+ end
207
+ ```
208
+
209
+ ## Ownership
210
+
211
+ For the common case where every record belongs to the signed-in user (or
212
+ tenant), use the `owned_by` shorthand:
213
+
214
+ ```ruby
215
+ class QuoteResource < Layered::Resource::Base
216
+ model Quote
217
+
218
+ owned_by :user # via :current_user (default)
219
+ # owned_by :account, via: :current_account
220
+ end
221
+ ```
222
+
223
+ `owned_by` is purely behavioural - it expands to:
224
+
225
+ 1. `scope(controller)` returns `Quote.where(user: controller.current_user)`,
226
+ so the index, show, edit, etc. only see records the user owns.
227
+ 2. `build_record(controller)` assigns the owner on new records, so `create`
228
+ stamps `user_id` automatically.
229
+ 3. When `controller.current_user` is `nil`, `owned_by` raises
230
+ `Layered::Resource::MissingOwnerError` so a missing `authenticate_user!`
231
+ surfaces immediately instead of every page silently 404ing. Pass
232
+ `allow_nil: true` for genuinely public-with-scope behaviour (returns
233
+ `Quote.none` and assigns nil on create). With `use_pundit`, the policy
234
+ gate (`policy.create?`) decides — `owned_by` lets nil through.
235
+
236
+ `owned_by` is a fact about the data, not a gate on what users can do. For
237
+ per-action authorisation ("can this user edit *this* record?") use
238
+ `use_pundit` or override `scope` yourself.
239
+
240
+ ## Authorisation
241
+
242
+ `scope(controller)` is the universal seam - any read filter (Pundit,
243
+ CanCan, a plain PORO) goes there. The gem also ships first-class Pundit
244
+ support via opt-in:
245
+
246
+ ```ruby
247
+ class PostResource < Layered::Resource::Base
248
+ model Post
249
+
250
+ use_pundit
251
+ end
252
+ ```
253
+
254
+ When enabled:
255
+
256
+ - `scope(controller)` defaults to the controller's `policy_scope(model)` helper -
257
+ the index reads through `Policy::Scope#resolve`. This routes through
258
+ Pundit's `pundit_user`, so apps that authorize as `current_account` (or
259
+ any other identity) get the same context here as in `authorize` calls.
260
+ - Every action that loads or builds a record (`new`, `create`, `show`,
261
+ `edit`, `update`, `destroy`, plus any custom member action declared in
262
+ a `layered_resources` block) calls `authorize(@record)` automatically.
263
+ Pundit raises `Pundit::NotAuthorizedError` on denial; handle it in
264
+ your `ApplicationController` as you would for any Pundit-backed app.
265
+ - Action buttons hide automatically: the `New` link on the index, the
266
+ `Edit`/`Delete` items in the index row's actions popover, and the
267
+ `Edit`/`Delete` buttons on the show page check
268
+ `policy(record).new?/update?/destroy?` so users only see actions they
269
+ can perform.
270
+
271
+ > **`verify_authorized` / `verify_policy_scoped`**: if your
272
+ > `ApplicationController` runs Pundit's `after_action :verify_authorized`
273
+ > (or `:verify_policy_scoped`) globally, layered resources that don't opt
274
+ > into `use_pundit` will raise `AuthorizationNotPerformed` because the
275
+ > controller never calls `authorize` for them. Either skip those checks
276
+ > for the layered controller (`skip_after_action :verify_authorized,
277
+ > if: -> { is_a?(Layered::Resource::ResourcesController) }`) or scope the
278
+ > hardening to the controllers you actually want it on.
279
+
280
+ `owned_by` composes with `use_pundit`: Pundit owns the read filter
281
+ (`Policy::Scope` wins over the owner-where), and `owned_by` still drives
282
+ owner assignment on `create`. Stack them when the policy needs to express
283
+ more than ownership:
284
+
285
+ ```ruby
286
+ class PostResource < Layered::Resource::Base
287
+ model Post
288
+
289
+ use_pundit
290
+ owned_by :user
291
+ end
292
+ ```
293
+
294
+ CanCan, plain POROs, and bespoke policies are still supported - just
295
+ override `scope` (and add per-action `before_action :authorize_*` callbacks
296
+ in an ejected controller). Use `use_pundit` when Pundit is the right fit
297
+ and you want the integration without writing it.
298
+
299
+ ### Writing policies
300
+
301
+ Two conventions worth knowing:
302
+
303
+ - **Compare ids, not associations.** Inside policy methods write
304
+ `record.user_id == user.id` rather than `record.user == user`. The id
305
+ comparison avoids loading the association and works on unsaved records.
306
+ - **The policy queried matches the route, not the model being mutated.**
307
+ For a custom member action declared on `layered_resources :questions`
308
+ (e.g. `POST /questions/:id/submit_answer`), `@record` is a `Question`
309
+ and the gem calls `QuestionPolicy#submit_answer?` — even if the action
310
+ ultimately creates an `Answer`. Put the predicate on the policy for the
311
+ resource the route lives on.
312
+
313
+ ### Per-record gating in custom views
314
+
315
+ Inside ejected views, the `resource_can?(action, record = nil)` helper
316
+ returns `true` only when both the route exposes the action and (when
317
+ Pundit is enabled) the policy permits it for the given record. Use it
318
+ wherever you'd previously read `@resource_can_*`:
319
+
320
+ ```erb
321
+ <% if resource_can?(:update, @record) %>
322
+ <%= link_to "Edit", edit_post_path(@record) %>
323
+ <% end %>
324
+ ```
325
+
326
+ The raw `@resource_can_*` ivars remain available for route-only checks
327
+ (no per-record policy lookup).
328
+
329
+ ## Associations
330
+
331
+ Resources are independent - each model gets its own resource class. To surface association data on an index, add a virtual column whose `attribute:` is a method on the model. For `Post belongs_to :user`, expose `user.name` by delegating on the model:
332
+
333
+ ```ruby
334
+ class Post < ApplicationRecord
335
+ belongs_to :user
336
+ delegate :name, to: :user, prefix: true, allow_nil: true # post.user_name
337
+ end
338
+ ```
339
+
340
+ ```ruby
341
+ class PostResource < Layered::Resource::Base
342
+ model Post
343
+
344
+ columns [
345
+ { attribute: :title, primary: true },
346
+ { attribute: :user_name, label: "Author" },
347
+ { attribute: :created_at, label: "Published" }
348
+ ]
349
+ end
350
+ ```
351
+
352
+ ### Searching across associations
353
+
354
+ `search_fields` accepts association-walking entries in Ransack's `<association>_<attribute>` form. An entry that isn't a column on the resource's own model but matches an association plus a column on the associated model (e.g. `:user_name` for `belongs_to :user` and `users.name`) makes the index search box join into the association:
355
+
356
+ ```ruby
357
+ class PostResource < Layered::Resource::Base
358
+ model Post
359
+
360
+ search_fields [:title, :body, :user_name] # user_name searches users.name
361
+ end
362
+ ```
363
+
364
+ 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.
365
+
366
+ 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.
367
+
368
+ ### Search placeholder
369
+
370
+ The index search box's placeholder is derived from `search_fields` via `human_attribute_name`, so attribute renames declared in the standard Rails i18n location flow through automatically. Given `search_fields [:title, :user_sid]`:
371
+
372
+ ```yaml
373
+ # config/locales/en.yml
374
+ en:
375
+ activerecord:
376
+ attributes:
377
+ user:
378
+ sid: Identifier
379
+ ```
380
+
381
+ produces "Search by title, user identifier". Association walks are labelled `<association> <attribute>`, each half resolved against its own model's human names (so translating `post.user` changes the "user" half too).
382
+
383
+ To replace the derived text wholesale, declare `search_placeholder`:
384
+
385
+ ```ruby
386
+ class PostResource < Layered::Resource::Base
387
+ model Post
388
+
389
+ search_fields [:title, :body, :user_sid]
390
+
391
+ search_placeholder "Search by title, body or author identifier"
392
+ end
393
+ ```
394
+
395
+ ### Nested routes
396
+
397
+ To scope posts to a user (`/users/:user_id/posts`), nest the route inside a Rails `resources :users do` block or an explicit `scope "users/:user_id"`. Either form produces the standard Rails nested-resources helper names (`user_posts_path`, `user_post_path`, `new_user_post_path`, …) — `polymorphic_path([@user, :posts])` and `link_to "Edit", [@user, @post]` resolve to them with no extra wiring:
398
+
399
+ ```ruby
400
+ # config/routes.rb
401
+ layered_resources :users
402
+
403
+ resources :users, only: [] do
404
+ layered_resources :posts
405
+ end
406
+ # …or, equivalently…
407
+ scope "users/:user_id" do
408
+ layered_resources :posts
409
+ end
410
+ ```
411
+
412
+ > **Don't add `as:` to a surrounding `scope`.** `layered_resources` derives its own helper names from the path segments (`scope path: "manage"` → `manage_posts_path`, `new_manage_post_path`, …), so an `as:` is unnecessary — and a value that disagrees with the path is ignored with a warning.
413
+
414
+ Resolve the parent in the resource's `scope`:
415
+
416
+ ```ruby
417
+ class PostResource < Layered::Resource::Base
418
+ model Post
419
+
420
+ # ...columns, fields, etc.
421
+
422
+ def self.scope(controller)
423
+ if controller.params[:user_id].present?
424
+ User.find(controller.params[:user_id]).posts
425
+ else
426
+ Post.all
427
+ end
428
+ end
429
+ end
430
+ ```
431
+
432
+ ### Linking columns to a nested index
433
+
434
+ A column on the parent can link to its children's index using `link:` with the nested route's key:
435
+
436
+ ```ruby
437
+ class UserResource < Layered::Resource::Base
438
+ model User
439
+
440
+ columns [
441
+ { attribute: :name, primary: true },
442
+ { attribute: :email },
443
+ { attribute: :posts_count, label: "Posts", as: :badge, rounded: true, link: :user_posts }
444
+ ]
445
+ end
446
+ ```
447
+
448
+ The `posts_count` cell on each user row links to `/users/:id/posts`. `link:` wraps the column's normal rendering in a link, so it composes with `as:` — pair it with `as: :badge` if you want a badge link.
449
+
450
+ **Use a counter cache for child counts, not a virtual `child.size` column.** Render the count as a rounded badge (`as: :badge, rounded: true`) so it reads as a count, not a value. Point the column at a real counter-cache attribute and add `counter_cache: true` to the child's `belongs_to`:
451
+
452
+ ```ruby
453
+ class Post < ApplicationRecord
454
+ belongs_to :user, counter_cache: true # maintains users.posts_count
455
+ end
456
+ ```
457
+
458
+ ```ruby
459
+ # add_column :users, :posts_count, :integer, default: 0, null: false
460
+ ```
461
+
462
+ A counter-cache column reads straight off the parent row, so the index renders in one query. A virtual column backed by `user.posts.size` issues a `COUNT` per row (an N+1) and can't be sorted or searched. Once `posts_count` is a real column it sorts via `q[s]=posts_count asc` like any other.
463
+
464
+ ## Filters
465
+
466
+ Where `search_fields` gives a single free-text box, `filters` adds structured controls for narrowing the index by specific attributes. The UI follows the "add filter" pattern: an **Add filter** button opens a popover listing the declared filters; picking one adds it as a **tag** with its controls popover already open, ready to take a value; pressing the tag's label reopens the popover, and its ✕ removes it. Booleans and short single-choice selects apply instantly on click; multi-selects, comboboxes, ranges, and text filters have an Apply button.
467
+
468
+ Under the hood every filter is a Ransack predicate in the query string, so filters compose with search, sort, and pagination — all coexist in the URL and survive each other's submits (the search form and each filter form round-trip the other params as hidden fields; no JavaScript involved). The lightweight `f[]` param records which tags were added and in what order — new tags join the end of the row (after any pinned ones) and stay put when set; a tag's ✕ removes its entry.
469
+
470
+ Declare `filters` with a list of attributes. The control and predicate are inferred from each column:
471
+
472
+ ```ruby
473
+ class PostResource < Layered::Resource::Base
474
+ model Post
475
+
476
+ filters :status, # enum column -> multi-select of its values (status_in)
477
+ :featured, # boolean column -> Yes / No (featured_eq)
478
+ :created_at, # date/datetime -> from / to date range (created_at_gteq / _lteq)
479
+ :comments_count, # integer/decimal -> from / to number range (comments_count_gteq / _lteq)
480
+ :user # belongs_to -> multi-select of associated records (user_id_in)
481
+ end
482
+ ```
483
+
484
+ | Column type | Control | Ransack predicate |
485
+ | --- | --- | --- |
486
+ | `enum` | multi-select of the enum's values | `_in` (or `_eq` with `multiple: false`) |
487
+ | `boolean` | Yes / No | `_eq` |
488
+ | `date` / `datetime` | from–to date range | `_gteq` + `_lteq` |
489
+ | `integer` / `decimal` / `float` | from–to number range | `_gteq` + `_lteq` |
490
+ | `belongs_to` | multi-select of associated records | `<foreign_key>_in` (or `_eq`) |
491
+ | `string` / `text` | text "contains" (multi-select if a `collection:` is given) | `_cont` (or `_in` / `_eq`) |
492
+
493
+ Select-type filters default to **multi-select**: filtering via the `_in` predicate. Pass `multiple: false` for a single-choice select via `_eq`.
494
+
495
+ A `belongs_to` filter keys on the **foreign-key column** (`user_id`), so it never joins — no association-walk setup is needed, unlike `search_fields`. By default its options are `klass.all`, labelled by the first present of `name`/`title`/`label`/`email`; pass a `collection:` to scope, order, or label differently. As with search, every filtered attribute is added to the resource's Ransack allowlist; attributes that are neither shown, searched, nor filtered stay un-queryable and any `q[...]` referencing them is silently ignored rather than raising.
496
+
497
+ ### Long option lists
498
+
499
+ How a select-type filter renders depends on how many options it turns out to have — a checkbox list of every user is no way to pick one:
500
+
501
+ | Options | Multi-select | Single-choice |
502
+ | --- | --- | --- |
503
+ | up to 10 | checkbox list, Apply button | list of links, applying instantly on click |
504
+ | more than 10 | [combobox](https://github.com/layered-ai-public/layered-ui-rails) — type-ahead, selections become removable tokens | single-select combobox, Apply button |
505
+
506
+ The count is taken per request (a `collection:` callable resolves first), so a filter follows its data rather than a guess made when the resource was written. The threshold is global:
507
+
508
+ ```ruby
509
+ # config/initializers/layered_resource.rb
510
+ Layered::Resource.filter_combobox_threshold = 25
511
+ ```
512
+
513
+ Declaring `as:` pins the control and opts out of the switch entirely — `as: :select` keeps the plain list however long it gets, `as: :combobox` uses the combobox however short:
514
+
515
+ ```ruby
516
+ filters status: { as: :combobox }, # always the type-ahead
517
+ user: { as: :select } # always the checkbox list
518
+ ```
519
+
520
+ ### Remote filter options
521
+
522
+ Past a few thousand records, rendering the options at all is the problem. Point a filter at an endpoint with `url:` and its options are fetched as the user types instead — the filter is then always a combobox, since there is no collection to render or count:
523
+
524
+ ```ruby
525
+ class PostResource < Layered::Resource::Base
526
+ model Post
527
+
528
+ filters user: { url: -> { user_options_path }, min_chars: 2 }
529
+ end
530
+ ```
531
+
532
+ 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:
533
+
534
+ ```ruby
535
+ class UserOptionsController < ApplicationController
536
+ include Layered::Ui::ComboboxOptions
537
+
538
+ def index
539
+ render json: l_ui_combobox_options(policy_scope(User), label: :name, search: [:name, :email])
540
+ end
541
+ end
542
+ ```
543
+
544
+ A remote combobox has no collection in the browser to look a label up in, so an active filter's current values are labelled server-side from the records themselves (the same `name`/`title`/`label`/`email` fallbacks) — the tag reads "User: Alice", not "User: 12". A `url:` on a plain column has no records to read, so its values label themselves.
545
+
546
+ > **Label lookups are not scoped.** Whatever scoping the `url:` endpoint applies covers the options it *serves*; it does not cover the labels. Current values arrive in the query string, and the gem labels them with an unscoped `klass.where(id: ...)` — so hand-editing `?q[user_id_in][]=` to an id outside the endpoint's scope still renders that record's label in the tag and the combobox token. This is the same position a `belongs_to` filter's default `klass.all` options take, and it discloses one label at a time; treat it as a reason not to rely on a filter's option scoping to keep an association's names or e-mail addresses private.
547
+
548
+ `min_chars:` and `text:` pass through to `l_ui_combobox` alongside `url:`. The write-side combobox options (`create:`, `create_name:`, `reorder:`) don't: a filter picks among values that already exist.
549
+
550
+ ### Overriding the inference
551
+
552
+ Pass an options hash per attribute to override what's inferred:
553
+
554
+ ```ruby
555
+ filters :created_at,
556
+ status: { collection: %w[draft live] }, # override the select options
557
+ user: { multiple: false, # single-choice (user_id_eq) instead of multi
558
+ collection: -> { User.active } }, # scope the options per request
559
+ title: { as: :string, label: "Headline" } # force a "contains" text filter
560
+ ```
561
+
562
+ Recognised keys:
563
+
564
+ - `as:` — force a control type (`:select`, `:combobox`, `:boolean`, `:string`, `:range`, `:date_range`). Naming `:select` or `:combobox` also pins the control against the [option-count switch](#long-option-lists).
565
+ - `collection:` — options for a select: an array of values, an array of `[label, value]` pairs, or a callable resolved per request (returning either form, or records). A `collection:` on a plain string column promotes it to a select.
566
+ - `multiple:` — `true` (the default for select-type filters) filters via the `_in` predicate; `false` gives a single-choice `_eq`.
567
+ - `url:`, `min_chars:`, `text:` — [remote options](#remote-filter-options), fetched from an endpoint as the user types.
568
+ - `label:` — override the filter's name (defaults to `human_attribute_name`, so i18n flows through).
569
+ - `pinned:` — always show the tag. Pinned tags never appear in the add-filter menu and have no remove ✕ (their popover's Clear resets the value; the tag stays).
570
+ - `default:` — value applied when the request carries none of the filter's params: a scalar, `{ from:, to: }` for ranges, an array for `multiple:`, or a callable resolved per request (e.g. `-> { { from: 7.days.ago.to_date } }`).
571
+
572
+ ### Pinned tags and defaults
573
+
574
+ ```ruby
575
+ filters :created_at,
576
+ status: { pinned: true, default: Post.statuses[:published] },
577
+ user: { pinned: true }
578
+ ```
579
+
580
+ Pinned filters render as tags from the start, so the common ones are one click away instead of two; the **Add filter** button only renders while there are unpinned filters left to add (pin everything and it disappears). A `default:` applies whenever the request carries no state for that filter — the tag shows it as active and every link and form round-trips it explicitly from then on. Clearing a defaulted filter writes an explicit blank (`q[status_eq]=`) rather than dropping the param, so the default doesn't immediately re-apply.
581
+
582
+ The filter bar renders inside the index's Turbo frame between the search box and the table; eject the views (`rails g layered:resource:views`) to customise placement — the bar is the `_filters` partial, and each control is `_filter_control`.
583
+
584
+ ## Column rendering
585
+
586
+ Each column on the index table is rendered through a partial. By default the gem picks one based on the model's column type (`text` for strings, `datetime` for timestamps, etc.), but you can pin a column to a specific renderer with `as:`:
587
+
588
+ ```ruby
589
+ columns [
590
+ { attribute: :title, primary: true },
591
+ { attribute: :status, as: :badge, variants: { published: :success, draft: :warning } },
592
+ { attribute: :priority, as: :badge, rounded: true },
593
+ { attribute: :created_at, as: :datetime, format: "%Y-%m-%d" },
594
+ { attribute: :pinned, as: :boolean, true_label: "Yes", false_label: "No" }
595
+ ]
596
+ ```
597
+
598
+ The built-in column types are `:text`, `:datetime`, `:badge`, and `:boolean`. Lookup order is per-resource → host-wide → gem default, so any partial you place at `app/views/layered/<resource>/columns/_<type>.html.erb` overrides the gem's built-in for that resource only, and one at `app/views/layered/resource/columns/_<type>.html.erb` overrides it host-wide.
599
+
600
+ Use the column generator to eject a built-in or scaffold a new one:
601
+
602
+ ```bash
603
+ rails g layered:resource:column badge # eject the built-in host-wide
604
+ rails g layered:resource:column badge posts # eject scoped to PostResource
605
+ rails g layered:resource:column priority_badge # scaffold a brand-new type
606
+ ```
607
+
608
+ A custom partial receives `record`, `value`, and `options` (the column hash) as locals - read keys like `:variants` or `:format` straight off `options`.
609
+
610
+ ### Sortable headers
611
+
612
+ 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)).
613
+
614
+ ```ruby
615
+ columns [
616
+ { attribute: :title, primary: true },
617
+ { attribute: :user_name, label: "Author", sortable: true }
618
+ ]
619
+ ```
620
+
621
+ ## Index introduction
622
+
623
+ To render an introduction above the search area on a resource's index page, drop a partial at `app/views/layered/<resource>/_introduction.html.erb`. It's rendered when present and skipped otherwise — no DSL or configuration needed.
624
+
625
+ ```erb
626
+ <%# app/views/layered/posts/_introduction.html.erb %>
627
+ <div class="l-ui-mt-4">
628
+ <p>Browse the latest posts. Use search and sort to find what you're looking for.</p>
629
+ </div>
630
+ ```
631
+
632
+ The partial sits inside the resource's view directory, so it follows the same per-resource override path as ejected views and column partials.
633
+
634
+ ## Record pickers
635
+
636
+ 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:
637
+
638
+ ```ruby
639
+ fields [
640
+ { attribute: :title },
641
+ { attribute: :user_id, label: "Author" } # belongs_to :user -> author picker
642
+ ]
643
+ ```
644
+
645
+ The default options are `klass.all`, labelled the same way a `belongs_to` filter's are (the first present of `name`/`title`/`label`/`email`, else `"User #12"`), and resolved per request rather than once at boot. The picker posts the plain foreign key (`post[user_id]`), so nothing else in the write path changes.
646
+
647
+ Two things follow from the association rather than the column:
648
+
649
+ - **Required.** A `belongs_to` validates the presence of the *association*, not of the foreign key, so the picker's required flag comes from the association's own `optional:` (resolved as ActiveRecord resolves it, via `belongs_to_required_by_default`) instead of from a presence validator on the column.
650
+ - **Polymorphic associations are skipped.** There is no single class whose records could fill the picker, so the field falls back to its column type.
651
+
652
+ Override with `as:` to opt out of the control entirely, or `collection:` to keep it and replace the options - to scope them, order them, or label them by a particular resource:
653
+
654
+ ```ruby
655
+ fields [
656
+ { attribute: :user_id, as: :select, collection: -> { User.pluck(:name, :id) } }, # plain <select>
657
+ { attribute: :editor_id, collection: -> { User.editors.map { |u| [UserResource.record_label(u), u.id] } } }
658
+ ]
659
+ ```
660
+
661
+ Note that the default labelling deliberately does **not** consult the associated model's own resource for its `label_attribute`: a model can have several resources (a plain one and an admin variant, say), so there is no single resource to ask. Name one in a `collection:` when you want its labelling.
662
+
663
+ Every other combobox option - `multiple:`, `url:`, `min_chars:`, `create:`/`create_name:`, `reorder:`, `text:` - passes straight through to `l_ui_combobox`.
664
+
665
+ ## Strong parameters for nested or array fields
666
+
667
+ By default each entry in `fields` is permitted as a scalar. To allow an array (e.g. `has_many_attached`) or a nested hash (e.g. `accepts_nested_attributes_for`), set `permit:` on the field:
668
+
669
+ ```ruby
670
+ fields [
671
+ { attribute: :title },
672
+ { attribute: :documents, as: :file, permit: [] }, # array of files
673
+ { attribute: :address_attributes, permit: [:street, :city, :zip] } # nested hash
674
+ ]
675
+ ```
676
+
677
+ `permit: []` produces `params.permit(documents: [])`; `permit: [:street, :city]` produces `params.permit(address_attributes: [:street, :city])`.
678
+
679
+ ## Custom member and collection routes
680
+
681
+ To add non-CRUD actions (e.g. `POST /posts/:id/approve`, `POST /posts/bulk_archive`), pass a block to `layered_resources` with the same `member`/`collection` DSL Rails' `resources` uses. A block requires `controller:` because the action implementation has to live somewhere - generate a controller subclass with `rails g layered:resource:controller posts` and point the route at it:
682
+
683
+ ```ruby
684
+ layered_resources :posts, controller: "posts" do
685
+ member do
686
+ post :approve
687
+ end
688
+
689
+ collection do
690
+ get :bulk_archive
691
+ post :bulk_destroy
692
+ end
693
+ end
694
+ ```
695
+
696
+ ```ruby
697
+ class PostsController < Layered::Resource::ResourcesController
698
+ def approve
699
+ @record.update!(approved: true)
700
+ redirect_to layered_member_path(@record), notice: "Post approved"
701
+ end
702
+
703
+ def bulk_archive
704
+ # render a confirmation page, run the archive, etc.
705
+ end
706
+ end
707
+ ```
708
+
709
+ Inside the controller, `@resource` is the resource class and the standard Rails `before_action`s from `ApplicationController` (e.g. `authenticate_user!`) still apply.
710
+
711
+ **Auto-loaded `@record` (member actions only).** Custom member actions get `@record` set from `params[:id]` via `@resource.scope(self).find` before the action runs. This does not extend to collection actions — those have no `:id` and `@record` stays `nil`, so do any lookups yourself. Opt a member action out of the auto-load with `skip_before_action :load_layered_member_record, only: [:foo]` if it shouldn't 404 on a missing record (or shouldn't pay for the lookup).
712
+
713
+ Path helpers available inside actions:
714
+
715
+ - `layered_collection_path` — index path for the current resource. Raises if `:index` isn't routed.
716
+ - `layered_member_path(record)` — show/update/destroy path for the current resource. Raises if no member route is registered.
717
+ - `layered_routes.<helper>_path(...)` — any registered route, with parent params filled in from the current request.
718
+
719
+ ## Variants via inheritance
720
+
721
+ For variants that warrant their own URL - typically a separate admin area - declare a subclass and register it on its own route. The subclass inherits `model`, `columns`, `fields`, `search_fields`, `search_placeholder`, `default_sort`, `per_page`, and `root_breadcrumb` from the parent and overrides only what differs:
722
+
723
+ ```ruby
724
+ # app/layered_resources/admin/post_resource.rb
725
+ class Admin::PostResource < PostResource
726
+ columns [
727
+ { attribute: :title, primary: true },
728
+ { attribute: :status },
729
+ { attribute: :author_name, label: "Author" },
730
+ { attribute: :created_at, label: "Published" }
731
+ ]
732
+
733
+ fields [
734
+ { attribute: :title },
735
+ { attribute: :body, as: :text },
736
+ { attribute: :status },
737
+ { attribute: :pinned, as: :checkbox }
738
+ ]
739
+ end
740
+ ```
741
+
742
+ ```ruby
743
+ # config/routes.rb
744
+ layered_resources :posts
745
+ namespace :admin do
746
+ layered_resources :posts, resource: "Admin::PostResource"
747
+ end
748
+ ```
749
+
750
+ `search_fields` and `model` aren't redeclared - they're inherited from `PostResource`.
751
+
752
+ ## Authentication
753
+
754
+ `Layered::Resource::ResourcesController` inherits from your app's `ApplicationController`, so any `before_action` you've declared there (e.g. Devise's `authenticate_user!`) already protects every layered resource request.
755
+
756
+ ### Engines: route to your engine's `ApplicationController`
757
+
758
+ For an engine with its own `ApplicationController` (running its own `authorize`/`authenticate` chain), define a sibling controller and include the gem's concern:
759
+
760
+ ```ruby
761
+ # app/controllers/layered/assistant/resources_controller.rb
762
+ class Layered::Assistant::ResourcesController < Layered::Assistant::ApplicationController
763
+ include Layered::Resource::Controller
764
+ end
765
+ ```
766
+
767
+ Then pass `namespace:` so `layered_resources` derives both the resource class and the controller from one option:
768
+
769
+ ```ruby
770
+ # config/routes.rb (or your engine's routes)
771
+ scope path: "/assistant", module: "layered/assistant" do
772
+ layered_resources :skills, namespace: "Layered::Assistant"
773
+ end
774
+ ```
775
+
776
+ This resolves to `resource: "Layered::Assistant::SkillResource"` and routes to `Layered::Assistant::ResourcesController` automatically — no per-route `resource:`/`controller:` plumbing.
777
+
778
+ `namespace:` is explicit-only. Avoid wrapping in a `namespace :foo` block: Rails composes URL helpers differently inside one (e.g. `foo_new_post_path` instead of `new_foo_post_path`), and the gem-shipped views call the latter form. Use `scope path:`/`module:` as above to get the path/module without the `:as` prefix.
779
+
780
+ ## Flash messages
781
+
782
+ Flash strings come from i18n. The gem ships English defaults under `layered.resource.flash.*`:
783
+
784
+ | Key | Trigger |
785
+ |---|---|
786
+ | `created` | `create` succeeded |
787
+ | `updated` | `update` succeeded |
788
+ | `deleted` | `destroy` succeeded |
789
+ | `not_deleted` | `record.destroy` returned false |
790
+ | `dependent_records` | `destroy` rescued `ActiveRecord::InvalidForeignKey` or `ActiveRecord::DeleteRestrictionError` (the gem catches these so dependent-record violations redirect with a flash instead of 500ing) |
791
+
792
+ Override per-locale by adding the same keys in your host app's `config/locales/<lang>.yml`:
793
+
794
+ ```yaml
795
+ en:
796
+ layered:
797
+ resource:
798
+ flash:
799
+ created: "%{model} added successfully"
800
+ dependent_records: "Can't delete %{model} — it still has linked records."
801
+ ```
802
+
803
+ The `%{model}` interpolation is `model.model_name.human`, so it picks up any `activerecord.models.<key>` translations you've already defined.
804
+
805
+ ## Show is intentionally minimal
806
+
807
+ The default show view is a heading with `Edit` and `Delete` buttons - it
808
+ doesn't iterate over `columns` because columns are designed for table cells
809
+ and have no per-user gating. If a column is configured for the index it
810
+ would otherwise leak in full on show. When you want a real detail page,
811
+ eject views with `rails g layered:resource:views <name>` and write the show
812
+ template against `@record` directly.
813
+
814
+ ## Escape hatching
815
+
816
+ The gem is designed so you can start fully managed and progressively take over control if you outgrow the defaults.
817
+
818
+ **Override the scope or redirect target** directly in the resource class:
819
+
820
+ ```ruby
821
+ class PostResource < Layered::Resource::Base
822
+ model Post
823
+
824
+ # ...columns, fields, etc.
825
+
826
+ def self.scope(controller)
827
+ controller.current_team.posts
828
+ end
829
+
830
+ def self.build_record(controller)
831
+ scope(controller).build(author: controller.current_user)
832
+ end
833
+
834
+ def self.after_save_path(controller, record)
835
+ controller.main_app.post_path(record)
836
+ end
837
+ end
838
+ ```
839
+
840
+ **Eject views** when you need full control over presentation:
841
+
842
+ ```
843
+ rails g layered:resource:views posts
844
+ ```
845
+
846
+ This copies the gem's actual `index`, `show`, `new`, and `edit` templates into `app/views/layered/posts/` - fully populated, working ERB you can edit immediately. Delete any of them to fall back to the gem default; keep the rest to override only what you need.
847
+
848
+ **Override the controller.** Use the generator to create one in the right place:
849
+
850
+ ```
851
+ rails g layered:resource:controller posts
852
+ ```
853
+
854
+ This gives you a controller that inherits from the base - override any of the standard CRUD actions and call `super` when you only want to tweak behaviour.
855
+
856
+ If you outgrow the gem entirely, drop the inheritance and write a plain Rails controller:
857
+
858
+ ```ruby
859
+ class PostsController < ApplicationController
860
+ def index
861
+ @posts = Post.all
862
+ end
863
+
864
+ # ...
865
+ end
866
+ ```
867
+
868
+ ```ruby
869
+ # swap the route
870
+ resources :posts
871
+ ```
872
+
873
+ ## Documentation
874
+
875
+ A live demo of the dummy app is deployed at **[https://layered-resource-rails.layered.ai](https://layered-resource-rails.layered.ai)**.
876
+
877
+ You can also run it locally to explore:
878
+
879
+ ```bash
880
+ git clone https://github.com/layered-ai-public/layered-resource-rails.git
881
+ cd layered-resource-rails
882
+ bundle install
883
+ cd test/dummy && bin/rails db:setup && bin/dev
884
+ ```
885
+
886
+ ### Deploying the dummy app
887
+
888
+ The dummy app can be deployed with [Kamal](https://kamal-deploy.org). Set the required environment variables and deploy from `test/dummy`:
889
+
890
+ ```bash
891
+ cd test/dummy
892
+ export KAMAL_DEPLOY_IP=<server-ip>
893
+ export KAMAL_DEPLOY_DOMAIN=<domain>
894
+ export KAMAL_SSH_KEY=<path-to-ssh-key>
895
+ kamal deploy
896
+ ```
897
+
898
+ `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.
899
+
900
+ ## Contributing
901
+
902
+ This project is still in its early days. We welcome issues, feedback, and ideas - they genuinely help shape the direction of the project. That said, we're holding off on accepting pull requests for now to stay focused on getting the foundations right. Thank you for your patience and interest. See [CLA.md](CLA.md) for the full policy.
903
+
904
+ ## License
905
+
906
+ Released under the [Apache 2.0 License](LICENSE).
907
+
908
+ Copyright 2026 LAYERED AI LIMITED (UK company number: 17056830). See [NOTICE](NOTICE) for attribution details.
909
+
910
+ ## Trademarks
911
+
912
+ The source code is fully open, but the layered.ai name, logo, and brand assets are trademarks of LAYERED AI LIMITED. The Apache 2.0 license does not grant rights to use the layered.ai branding. Forks and redistributions must use a distinct name. See [TRADEMARK.md](TRADEMARK.md) for the full policy.