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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 83a0686237527aabd89c6fa83f98c8a2bbab1a02deff48528e8e5baed7f8b874
4
+ data.tar.gz: 744e768aff2d5c624c9a243ccfbc9d92cf80de3a559fec9c320c89d51e7e197e
5
+ SHA512:
6
+ metadata.gz: ebe053f334e9ad89c835add54f1c60bf5c8c863cb175ec42932e7d9cf0809eef73d65646a96505da64cd9062207dc722eb672d4662cad612ca70259eecac9c60
7
+ data.tar.gz: a148f47dc69bbd3fb799bd1b8b49d2b4bf90dbc3854e2c75fec7dd88dd5c8efcb8e99ec7af434d6c2b76370d2243ca3eb9f7d512113aeba3ae46effe8bc76194
@@ -0,0 +1,449 @@
1
+ ---
2
+ name: layered-resource-rails
3
+ description: Installs, configures, and builds with the layered-resource-rails gem - a Rails 8+ engine providing convention-over-configuration CRUD scaffolding with search, sort, and pagination. Use when adding layered-resource-rails to a Rails app, defining resource classes, mounting `layered_resources` routes, ejecting views or controllers, or troubleshooting setup.
4
+ license: Apache-2.0
5
+ compatibility: Requires Ruby on Rails >= 8.0, layered-ui-rails ~> 0.9, ransack ~> 4.0, pagy ~> 43.2
6
+ metadata:
7
+ author: layered.ai
8
+ version: "1.0"
9
+ source: https://github.com/layered-ai-public/layered-resource-rails
10
+ ---
11
+
12
+ # layered-resource-rails
13
+
14
+ A Rails 8+ engine that scaffolds CRUD UIs from a single resource class and a single route. Built on top of [layered-ui-rails](https://github.com/layered-ai-public/layered-ui-rails), Ransack, and Pagy.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ bundle add layered-resource-rails
20
+ ```
21
+
22
+ The gem depends on `layered-ui-rails` for its UI. If the host app hasn't installed it yet, run its install generator first:
23
+
24
+ ```bash
25
+ bin/rails generate layered:ui:install
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ The fastest path is the scaffold generator. It invokes Rails' built-in `model` generator, writes a resource class, and appends a route:
31
+
32
+ ```bash
33
+ rails g layered:resource:scaffold post title:string body:text
34
+ ```
35
+
36
+ This produces:
37
+
38
+ - `db/migrate/<timestamp>_create_posts.rb` and `app/models/post.rb` (via `rails g model`)
39
+ - `app/layered_resources/post_resource.rb` with `columns` and `fields` derived from the attributes
40
+ - `layered_resources :posts` appended to `config/routes.rb`
41
+
42
+ Useful flags:
43
+
44
+ - `--skip-model` - the model already exists
45
+ - `--actions index show` - emits `only: [:index, :show]`
46
+ - `--except destroy` - emits `except: [:destroy]`
47
+ - `--controller` - also eject a controller and wire it into the route
48
+ - `--views` - also eject the templates upfront
49
+
50
+ Views are intentionally not generated by default - the gem's defaults render until ejected.
51
+
52
+ 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`. It writes `app/layered_resources/post_resource.rb` and appends `layered_resources :posts` to `config/routes.rb`. Pass `--skip-route` to skip the route line.
53
+
54
+ ## What you get
55
+
56
+ For `layered_resources :posts` with all actions enabled:
57
+
58
+ | Route | Action |
59
+ |-------------------------|---------|
60
+ | `GET /posts` | index |
61
+ | `GET /posts/:id` | show |
62
+ | `GET /posts/new` | new |
63
+ | `POST /posts` | create |
64
+ | `GET /posts/:id/edit` | edit |
65
+ | `PATCH /posts/:id` | update |
66
+ | `DELETE /posts/:id` | destroy |
67
+
68
+ The index table's primary column links to each record's **edit** page automatically (or its show page for read-only resources that have `:show` but not `:edit`).
69
+
70
+ The default `show` view is intentionally a blank canvas - the gem does not auto-render an attribute list (generated detail pages are low-value and always need customizing). Since the index links titles to edit, `:show` is only worth keeping if you'll build a real detail view (eject it with `rails g layered:resource:views` and fill in the template). Otherwise advise `except: [:show]` to drop the route.
71
+
72
+ `@page_title` is set automatically per action: pluralized model name on index, `"New <Model>"` on new, the record's primary column value on show, and `"Edit <record label>"` on edit. The layered-ui-rails layout reads it for `<title>`.
73
+
74
+ ## Resource DSL
75
+
76
+ Resource classes live in `app/layered_resources/` and inherit from `Layered::Resource::Base`:
77
+
78
+ ```ruby
79
+ class PostResource < Layered::Resource::Base
80
+ model Post
81
+
82
+ columns [
83
+ { attribute: :title, primary: true },
84
+ { attribute: :status },
85
+ { attribute: :created_at, label: "Published" }
86
+ ]
87
+
88
+ fields [
89
+ { attribute: :title },
90
+ { attribute: :body, as: :text },
91
+ { attribute: :status }
92
+ ]
93
+
94
+ search_fields [:title, :body]
95
+ default_sort attribute: :created_at, direction: :desc
96
+ per_page 25
97
+ end
98
+ ```
99
+
100
+ | Method | Purpose |
101
+ |---|---|
102
+ | `model Post` | The ActiveRecord class this resource manages |
103
+ | `columns [...]` | Index table columns. Each entry is `{ attribute:, label:, primary:, link:, render: }` |
104
+ | `fields [...]` | Form fields for new/edit. Omit to disable CRUD forms |
105
+ | `search_fields [...]` | Ransack attributes the index search box matches against. Association-walking entries like `:user_name` (for `belongs_to :user` + `users.name`) join into the association |
106
+ | `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) |
107
+ | `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) |
108
+ | `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"` |
109
+ | `default_sort attribute:, direction:` | Default sort order for the index |
110
+ | `per_page n` | Pagination size (default 15) |
111
+ | `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 |
112
+
113
+ ### Column options
114
+
115
+ - `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)
116
+ - `primary: true` - marks the cell that links to the record's edit (or show) page (defaults to first column)
117
+ - `label: "Custom"` - overrides the humanised attribute name
118
+ - `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))
119
+ - `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)
120
+ - `render: ->(record) { ... }` - custom cell renderer. The proc is a plain Ruby closure invoked with `.call` (not `instance_exec`), so `self` inside it is the lexical scope where you wrote it - the resource class body, which has no view helpers. **To use a view helper (`l_ui_format_datetime`, `link_to`, `tag.*`, etc.), take the view context as a second arg:** `render: ->(record, view) { view.l_ui_format_datetime(record.created_at) }`. Arity `>= 2` (or variadic/optional, e.g. `->(record, view = nil)`) trips the view-injection branch; an arity-1 proc that calls a view helper raises `NoMethodError ... for class YourResource`. Before reaching for a proc at all: the **default renderer already strftime-formats datetime columns** (and dispatches `as:` partials), so a plain `{ attribute: :created_at, label: "Created" }` renders the timestamp readably with no proc needed.
121
+
122
+ ### Column rendering
123
+
124
+ Cells render through a partial picked by `as:`, or inferred from the model's column type (`boolean` → `:boolean`, `date`/`datetime`/`time`/`timestamp` → `:datetime`, everything else including virtual attributes → `:text`). A `render:` proc bypasses partials entirely. Lookup order is per-resource (`app/views/layered/<resource>/columns/_<type>.html.erb`) → host-wide (`app/views/layered/resource/columns/_<type>.html.erb`) → gem built-in, so an ejected partial overrides the default for one resource or the whole app.
125
+
126
+ Partial locals are `record`, `value` (`record.public_send(attribute)`), and `options` — **`options` is the column hash itself**. That's the extension point: any extra key on a column is readable by its partial, which is how the per-type options work.
127
+
128
+ | `as:` | Options it reads |
129
+ |---|---|
130
+ | `:text` | — (strftime-formats a value that responds to `strftime`) |
131
+ | `:datetime` | `format:` (strftime string, default `"%-d %b %Y %H:%M"`); nil-safe |
132
+ | `:boolean` | `true_label:` (default `"✓"`), `false_label:` (default `"✗"`) |
133
+ | `:badge` | `variants:`, `rounded:` |
134
+
135
+ Since `:datetime` and `:boolean` are already the defaults for their column types, `as:` mostly earns its place for `:badge`, or to pass `format:` / `true_label:`.
136
+
137
+ `variants:` maps the cell's value to a layered-ui badge modifier, defaulting to `:default` (grey) for any value not listed — including `nil`:
138
+
139
+ ```ruby
140
+ { attribute: :lock_status, label: "Status", as: :badge,
141
+ variants: { Locked: :danger, Active: :success } }
142
+ ```
143
+
144
+ Keys are matched as `value.to_s.to_sym`, so they must match the rendered string exactly: a `lock_status` returning `"Locked"` needs the key `Locked:`, and `:locked` silently falls through to grey. Variant names are layered-ui's badge modifiers — see that skill's `references/CSS.md`. Pair with `rounded: true` for a count pill (see [Counts of nested resources](#counts-of-nested-resources)). A badge column is usually backed by a **model method**, not a DB column, so it's non-sortable by default (see `sortable:` above).
145
+
146
+ ```bash
147
+ rails g layered:resource:column badge # eject the built-in host-wide
148
+ rails g layered:resource:column badge users # eject for one resource only
149
+ rails g layered:resource:column priority_badge # scaffold a new as: type
150
+ ```
151
+
152
+ A scaffolded partial ships with the locals contract in a comment, ready to fill in.
153
+
154
+ ### Field types
155
+
156
+ Field `as:` follows Rails' `form_with` field helpers - `:text`, `:checkbox`, `:date`, `:datetime`, `:select`, etc. A field is automatically marked required if its model has an unconditional presence validator on that attribute.
157
+
158
+ **Record pickers.** A field naming a `belongs_to`'s foreign key (`user_id`) infers `as: :combobox, multiple: false` - a type-ahead token select over the associated records. Default options are `klass.all` labelled by the first present of `name`/`title`/`label`/`email` (else `"User #12"`), resolved per request via a callable. It posts the plain foreign key, so the write path is unchanged. Required comes from the association's `optional:` (via `belongs_to_required_by_default`), not from a presence validator on the column - `belongs_to` validates the *association*, and with an `if:` Rails attaches for its own reasons. Polymorphic associations are skipped (no single class to fill the picker). `as:` opts out; `collection:` keeps the control and replaces the options - use it to scope, order, or label by a specific resource (`-> { User.editors.map { |u| [UserResource.record_label(u), u.id] } }`), since the default does *not* consult the associated model's own resource (a model can have several). Other combobox options (`multiple:`, `url:`, `min_chars:`, `create:`/`create_name:`, `reorder:`, `text:`) pass through to `l_ui_combobox`. Requires layered-ui-rails ~> 0.25, which is where the `:combobox` field type landed.
159
+
160
+ ### Filters
161
+
162
+ `filters` declares structured index controls complementing the single free-text `search_fields` box. The UI: an **Add filter** button opens a popover listing the declared filters; picking one adds it as an unset **tag** at the end of the row (the `f[]` param tracks added tags and their order for as long as they're shown) with its controls popover already open, ready to take a value; pressing the tag's label reopens the popover, and its ✕ removes it. Short single-choice filters apply instantly via links; ranges/text/multi-selects/comboboxes apply via a small GET form. Every filter is a Ransack predicate in the URL, so filters compose with search, sort, and pagination — the search form and each filter form round-trip the other `q` params (and `f[]` entries) as hidden fields (no JavaScript; the one-shot `fo` param marks which tag's popover renders open).
163
+
164
+ ```ruby
165
+ filters :status, # enum -> multi-select of its values (status_in)
166
+ :featured, # boolean -> Yes / No (featured_eq)
167
+ :created_at, # date -> from / to range (created_at_gteq / _lteq)
168
+ :comments_count, # integer -> number range (comments_count_gteq / _lteq)
169
+ :user # belongs_to -> multi-select (user_id_in)
170
+ ```
171
+
172
+ Inference by column type: `enum`/`belongs_to` -> multi-select (`_in`; `multiple: false` gives a single-choice `_eq`); `boolean` -> Yes/No (`_eq`); `date`/`datetime` -> date range (`_gteq`+`_lteq`); numeric -> number range; `string`/`text` -> "contains" (`_cont`), or a multi-select when a `collection:` or `url:` is given. A `belongs_to` filter keys on the **foreign key** (`user_id`) so it never joins — no association-walk setup needed; its default options are `klass.all` labelled by the first present of `name`/`title`/`label`/`email`.
173
+
174
+ **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.
175
+
176
+ **Remote options.** `url:` fetches the options from an endpoint as the user types instead of rendering a collection — such a filter is always a combobox (nothing to render or count). Give it as a callable (`-> { user_options_path }`) so it resolves per request in the view, where route helpers exist. The endpoint is an ordinary host-app action including `Layered::Ui::ComboboxOptions` and returning `l_ui_combobox_options(scope, label:, search:)` — the gem never routes it, so it's authorised however any index is. Since the browser has no collection to look a label up in, an active remote filter's values are labelled server-side from the records (same `name`/`title`/`label`/`email` fallbacks), so the tag reads "User: Alice" not "User: 12"; a `url:` over a plain column has no records and its values label themselves. `min_chars:` and `text:` pass through too; the write-side options (`create:`, `create_name:`, `reorder:`) deliberately don't — a filter picks among values that already exist.
177
+
178
+ **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.
179
+
180
+ Override per attribute with a trailing hash: `as:` (force control type: `:select`, `:combobox`, `:boolean`, `:string`, `:range`, `:date_range`), `collection:` (select options — array, `[label, value]` pairs, or a callable resolved per request, returning either form or records), `multiple:` (defaults to true for select-types; false gives single-choice `_eq`), `url:`/`min_chars:`/`text:` (remote options; `Layered::Resource::Base::COMBOBOX_FILTER_OPTIONS`), `label:`, `pinned:` (tag always shown — never in the add menu, no remove ✕; Clear resets the value but the tag stays), `default:` (value applied when the request has no state for the filter — scalar, `{ from:, to: }` for ranges, array for `multiple:`, or a callable). The add-filter button only renders while unpinned filters remain. Clearing a defaulted filter writes an explicit blank (`q[status_eq]=`) so the default doesn't re-apply. Filtered attributes are added to the resource's Ransack allowlist; un-shown/un-searched/un-filtered attributes stay un-queryable and stray `q[...]` params are ignored, not raised.
181
+
182
+ **The predicate set is closed.** Each control type maps to a fixed predicate (`:select`/`:combobox` → `_in`/`_eq`, `:boolean` → `_eq`, `:string` → `_cont`, ranges → `_gteq`+`_lteq`) and there is no `predicate:` option, so predicates Ransack can otherwise express (`_not_null`, `_matches`) aren't reachable through the DSL. In particular a **"is this set / unset" filter on a nullable timestamp** (a `locked_at`-style column) has no inferred control: `as: :boolean` emits `locked_at_eq=true`, which casts against a datetime column and matches nothing. Back the flag with a real boolean column the write path maintains, or eject the filter partials and emit the predicate yourself. Filtered attributes *are* allowlisted, so `q[locked_at_not_null]=1` works hand-typed in the URL — it just has no UI control.
183
+
184
+ The bar renders inside the index Turbo frame between search box and table, built from `l_ui_popover` and the `_filters`/`_filter_control` partials — eject with `rails g layered:resource:views` to customise.
185
+
186
+ ## Route DSL
187
+
188
+ Mount in `config/routes.rb`:
189
+
190
+ ```ruby
191
+ layered_resources :posts # all CRUD actions
192
+ layered_resources :posts, only: [:index] # read-only
193
+ layered_resources :posts, except: [:destroy] # everything but delete
194
+ layered_resources :posts, controller: "posts" # use a custom controller
195
+ layered_resources :posts, resource: "Admin::PostResource" # explicit resource class
196
+ layered_resources :posts, namespace: "Admin" # derives Admin::PostResource and Admin::ResourcesController
197
+ ```
198
+
199
+ Incoherent `only:` combos raise at boot time - e.g. `:new` without `:create`, or `:edit` without `:update`.
200
+
201
+ ### Nested routes
202
+
203
+ ```ruby
204
+ layered_resources :users
205
+
206
+ # Either form produces /users/:user_id/posts with Rails-standard helper
207
+ # names (user_posts_path, new_user_post_path, edit_user_post_path, …):
208
+ resources :users, only: [] do
209
+ layered_resources :posts
210
+ end
211
+ # …or, equivalently…
212
+ scope "users/:user_id" do
213
+ layered_resources :posts
214
+ end
215
+ ```
216
+
217
+ Helper names follow the standard Rails nested-resources convention, so `polymorphic_path([@user, :posts])`, `link_to "Edit", [@user, @post]`, `url_for([@user, @post, :comments])` etc. all resolve. The `link:` column option uses the same name (`link: :user_posts`, `link: :user_post_comments`).
218
+
219
+ **Never add `as:` to a surrounding `scope`.** `layered_resources` composes its own helper names from the path segments (`scope path: "manage"` → `manage_posts_path`, `new_manage_post_path`, …), so `as:` is unnecessary. A value matching the path is absorbed silently; a disagreeing one (`as: "admin"`) is ignored with a boot-time warning.
220
+
221
+ Resolve the parent in `scope`:
222
+
223
+ ```ruby
224
+ class PostResource < Layered::Resource::Base
225
+ model Post
226
+
227
+ def self.scope(controller)
228
+ if controller.params[:user_id].present?
229
+ User.find(controller.params[:user_id]).posts
230
+ else
231
+ Post.all
232
+ end
233
+ end
234
+ end
235
+ ```
236
+
237
+ ## Override points
238
+
239
+ Override these class methods on the resource (not the controller) to change behaviour:
240
+
241
+ ```ruby
242
+ class PostResource < Layered::Resource::Base
243
+ model Post
244
+
245
+ # Restrict the base scope (e.g. tenant isolation)
246
+ def self.scope(controller)
247
+ controller.current_team.posts
248
+ end
249
+
250
+ # Customise how new records are instantiated
251
+ def self.build_record(controller)
252
+ scope(controller).build(author: controller.current_user)
253
+ end
254
+
255
+ # Customise the redirect target after create/update/destroy
256
+ def self.after_save_path(controller, record)
257
+ controller.main_app.post_path(record)
258
+ end
259
+ end
260
+ ```
261
+
262
+ Note: when `current_user` (or whichever accessor a hand-rolled `scope` reads) is `nil`, return `model.none` rather than `model.all` - otherwise unauthenticated requests see the full table. The `owned_by` shorthand below raises loudly instead, surfacing missing auth wiring.
263
+
264
+ ## Ownership shorthand
265
+
266
+ `owned_by` collapses the two most common scope/build_record overrides into one declaration:
267
+
268
+ ```ruby
269
+ class QuoteResource < Layered::Resource::Base
270
+ model Quote
271
+
272
+ owned_by :user # default via :current_user
273
+ # owned_by :account, via: :current_account
274
+ end
275
+ ```
276
+
277
+ It rewires `scope(controller) = model.where(association => controller.public_send(via))` and assigns the owner in `build_record`. Pure data filter - it does not enforce per-action authorisation.
278
+
279
+ When `via` returns nil, `owned_by` raises `Layered::Resource::MissingOwnerError` so a missing `before_action :authenticate_user!` surfaces immediately instead of every page silently 404ing. Pass `allow_nil: true` to opt into public-with-scope behaviour (returns `model.none` and assigns nil on create):
280
+
281
+ ```ruby
282
+ owned_by :user, allow_nil: true
283
+ ```
284
+
285
+ When combined with `use_pundit`, the nil-owner check is skipped — Pundit's policy gate (`policy.create?`) is the authoritative auth check.
286
+
287
+ ## Authorisation (Pundit)
288
+
289
+ The gem ships first-class Pundit support as opt-in. Add `use_pundit` to a resource:
290
+
291
+ ```ruby
292
+ class PostResource < Layered::Resource::Base
293
+ model Post
294
+
295
+ use_pundit
296
+ end
297
+ ```
298
+
299
+ When enabled:
300
+
301
+ - `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`.
302
+ - The controller calls `authorize(@record)` after loading a member record (show/edit/update/destroy and any custom member action).
303
+ - 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>?`.
304
+
305
+ `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.
306
+
307
+ For CanCan, plain POROs, or bespoke policies, override `scope` directly (and add `before_action :authorize_*` callbacks in an ejected controller). `use_pundit` is just a convenience for the Pundit case.
308
+
309
+ In ejected views, prefer `resource_can?(:update, @record)` over the raw `@resource_can_update` ivar when you want per-record policy gating; the ivar still works for route-exposure-only checks.
310
+
311
+ ## Inheritance / variants
312
+
313
+ Subclasses inherit `model`, `columns`, `fields`, `search_fields`, `search_placeholder`, `label_attribute`, `default_sort`, `per_page`, and `root_breadcrumb`. Override only what differs:
314
+
315
+ ```ruby
316
+ # app/layered_resources/admin/post_resource.rb
317
+ class Admin::PostResource < PostResource
318
+ columns [
319
+ { attribute: :title, primary: true },
320
+ { attribute: :author_name, label: "Author" },
321
+ { attribute: :pinned }
322
+ ]
323
+ end
324
+ ```
325
+
326
+ ```ruby
327
+ # config/routes.rb
328
+ layered_resources :posts
329
+ namespace :admin do
330
+ layered_resources :posts, resource: "Admin::PostResource"
331
+ end
332
+ ```
333
+
334
+ ## Index introduction
335
+
336
+ 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.
337
+
338
+ ## Ejection
339
+
340
+ Take over presentation or controller logic without giving up the rest:
341
+
342
+ ```bash
343
+ rails g layered:resource:views posts # copies index/show/new/edit ERB into app/views/layered/posts/
344
+ rails g layered:resource:controller posts # generates a controller subclass for custom actions
345
+ ```
346
+
347
+ The controller's `_prefixes` is overridden so `app/views/layered/<plural>/` overrides win automatically - no extra wiring. Delete any individual ejected template to fall back to the gem default.
348
+
349
+ To outgrow the gem entirely: drop the inheritance, write a plain Rails controller, swap `layered_resources :posts` for `resources :posts` in routes.
350
+
351
+ ## Authentication
352
+
353
+ `Layered::Resource::ResourcesController` inherits from the host app's `ApplicationController`, so any `before_action` declared there (e.g. Devise's `authenticate_user!`) already protects every layered resource request - no extra configuration needed.
354
+
355
+ ### Engines: route to your engine's `ApplicationController`
356
+
357
+ For an engine with its own `ApplicationController` (running its own `authorize`/`authenticate` chain), define a sibling controller and include the gem's concern:
358
+
359
+ ```ruby
360
+ # app/controllers/layered/assistant/resources_controller.rb
361
+ class Layered::Assistant::ResourcesController < Layered::Assistant::ApplicationController
362
+ include Layered::Resource::Controller
363
+ end
364
+ ```
365
+
366
+ Then pass `namespace:` so `layered_resources` derives both the resource class and the controller from one option:
367
+
368
+ ```ruby
369
+ scope path: "/assistant", module: "layered/assistant" do
370
+ layered_resources :skills, namespace: "Layered::Assistant"
371
+ end
372
+ ```
373
+
374
+ This resolves to `resource: "Layered::Assistant::SkillResource"` and routes to `Layered::Assistant::ResourcesController` automatically — no per-route `resource:`/`controller:` plumbing.
375
+
376
+ `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.
377
+
378
+ ## Flash messages (i18n)
379
+
380
+ Flash strings live under `layered.resource.flash.*` in `config/locales/en.yml`:
381
+
382
+ | Key | Trigger |
383
+ |---|---|
384
+ | `created` / `updated` / `deleted` | Successful create/update/destroy |
385
+ | `not_deleted` | `record.destroy` returned false |
386
+ | `dependent_records` | `destroy` rescued `ActiveRecord::InvalidForeignKey` or `ActiveRecord::DeleteRestrictionError` |
387
+
388
+ Override per-locale in the host app's `config/locales/<lang>.yml`. The `%{model}` interpolation is the humanised model name.
389
+
390
+ `destroy` rescues FK and restrict-dependent violations and redirects to the index with the `dependent_records` flash instead of returning a 500.
391
+
392
+ ## Show is intentionally minimal
393
+
394
+ The default show view renders the primary column as a heading with `Edit` and `Delete` buttons - nothing else. It does not iterate over `columns` (those are designed for index table cells, with no per-user gating, and would leak everything declared for the index). For a real detail page, eject with `rails g layered:resource:views <name>` and edit the show template directly against `@record`.
395
+
396
+ ## Associations
397
+
398
+ Resources are independent - each model gets its own resource class. To surface association data on an index, expose a method on the model and reference it as a virtual column attribute:
399
+
400
+ ```ruby
401
+ class Post < ApplicationRecord
402
+ belongs_to :user
403
+ delegate :name, to: :user, prefix: true, allow_nil: true # post.user_name
404
+ end
405
+ ```
406
+
407
+ ```ruby
408
+ columns [
409
+ { attribute: :title, primary: true },
410
+ { attribute: :user_name, label: "Author" }
411
+ ]
412
+ ```
413
+
414
+ ### Counts of nested resources
415
+
416
+ To show how many children a record has, back the count with a **Rails counter cache**, not a virtual `record.children.size` column. A virtual size column issues a `COUNT` query per row (an N+1 on the index) and can't be sorted or searched; a counter-cache column reads off the parent row in the index's single query and sorts via `q[s]=<count> asc` like any other attribute.
417
+
418
+ Add `counter_cache: true` to the child's `belongs_to` and a backing integer column on the parent, then point the column at that real attribute. **Render the count as a rounded badge** so it reads as a count rather than a value:
419
+
420
+ ```ruby
421
+ class Comment < ApplicationRecord
422
+ belongs_to :post, counter_cache: true # maintains posts.comments_count
423
+ end
424
+ # add_column :posts, :comments_count, :integer, default: 0, null: false
425
+ ```
426
+
427
+ ```ruby
428
+ columns [
429
+ { attribute: :title, primary: true },
430
+ { attribute: :comments_count, label: "Comments", as: :badge, rounded: true, link: :post_comments }
431
+ ]
432
+ ```
433
+
434
+ `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`.
435
+
436
+ 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.
437
+
438
+ ## Common issues
439
+
440
+ - **`NoMethodError: undefined method 'l_ui_table'`** - the host app hasn't installed `layered-ui-rails`. Run `bin/rails generate layered:ui:install`.
441
+ - **Search/sort returns empty** - the attribute isn't in `search_fields`, or Ransack's `ransackable_attributes` on the model excludes it. The resource patches Ransack only when itself is the auth object; verify nothing in the host app removes the attribute unconditionally.
442
+ - **`only:` validation error at boot** - `:new` requires `:create`, `:edit` requires `:update`. Adjust the action list.
443
+ - **Ejected view not picked up** - the controller looks under `app/views/layered/<plural_name>/`, where `<plural_name>` is the symbol passed to `layered_resources` (ignoring Rails namespaces). The generator mirrors this; if you've moved files manually, match that path.
444
+
445
+ ## Further reference
446
+
447
+ - README: https://github.com/layered-ai-public/layered-resource-rails
448
+ - Live demo: https://layered-resource-rails.layered.ai
449
+ - Underlying UI gem: https://github.com/layered-ai-public/layered-ui-rails
data/AGENTS.md ADDED
@@ -0,0 +1,36 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to AI agents when working with code in this repository.
4
+
5
+ A Rails 8+ engine providing CRUD scaffolding. Consumer apps declare a `Layered::Resource::Base` subclass in `app/layered_resources/` and a `layered_resources :name` route. README is the user-facing API reference.
6
+
7
+ ## Commands
8
+
9
+ ```bash
10
+ bundle exec rake test # full suite
11
+ bundle exec rake test TEST=test/integration/layered_resource_crud_test.rb
12
+ cd test/dummy && bin/dev # manual exploration
13
+ ```
14
+
15
+ ## Architecture
16
+
17
+ Three load-bearing pieces:
18
+
19
+ - **`Layered::Resource::Base`** (`lib/layered/resource/base.rb`) - DSL (`model`, `columns`, `fields`, `search_fields`, `default_sort`, `per_page`) plus override points (`scope`, `build_record`, `after_save_path`). `inherited_attribute` walks the ancestor chain manually because class ivars aren't Ruby-inherited - this enables resource subclassing. `configure_ransack` patches `ransackable_attributes`/`ransackable_associations` on the model but **only responds when called with the resource class as `auth_object`**; other callers fall through to the original methods, preserving host-app config.
20
+
21
+ - **`Layered::Resource::Routing`** (`lib/layered/resource/routing.rb`) - the `layered_resources` route DSL plus a process-wide `Concurrent::Map` registry. Each route bakes a `_layered_resource_route_key` default into `path_parameters` so the controller can look up the right resource. Parses surrounding `scope` paths at registration time for nested-route support. Raises early on incoherent `only:` combos (e.g. `:new` without `:create`) - keep those guards in sync if adding actions.
22
+
23
+ - **`Layered::Resource::ResourcesController`** (`app/controllers/layered/resource/resources_controller.rb`) - inherits from the **host app's** `ApplicationController`, so its `before_action`s (e.g. Devise) apply automatically. `load_layered_resource` reads the route key, looks up the resource, and sets the `@can_*` action flags. `_prefixes` is overridden so `app/views/layered/<name>/` overrides win - this is what makes `rails g layered:resource:views` ejection work.
24
+
25
+ Engine (`lib/layered/resource/engine.rb`) autoloads `app/layered_resources`, mixes `Routing` into `Mapper`, includes `Pagy::Method`, and prepends the engine view path.
26
+
27
+ ## Notes
28
+
29
+ - Tests in `test/integration/` run against `test/dummy/` (a real Rails app with `Post` and `User` models, plus `PostResource` and `UserResource`). Integration tests are the contract for controller/routing/DSL changes.
30
+ - `attribute_required?` treats a field as required only when the presence validator is unconditional - don't tighten without considering conditional-validation forms.
31
+ - `concurrent-ruby` is depended on solely for the routing registry's `Concurrent::Map`.
32
+
33
+ ## Conventions
34
+
35
+ - Titles: capitalise first word only (e.g. "This title")
36
+ - Document new DSL surface in three places: the README (user-facing API reference), an integration test under `test/integration/` exercising it against the dummy app, and `.claude/skills/layered-resource-rails/SKILL.md`. When a new DSL option needs a runnable example, wire it into the dummy app to show it working.
data/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
4
+
5
+ ## [0.1.0] - 2026-08-30
6
+
7
+ Initial release.
8
+
9
+ ### Resource DSL
10
+
11
+ - `Layered::Resource::Base` DSL: `model`, `columns`, `fields`, `search_fields`, `default_sort`, `per_page`.
12
+ - `label_attribute :title` — the attribute a record is labelled by wherever the gem names one (the `show`/`edit` page titles, a row's actions menu, and its options in another resource's picker). Defaults to the primary column. A record whose labelling attribute is blank falls back through `name`/`title`/`label`/`email`, then the model's own `to_s` when it defines one, then `"Post #12"`.
13
+ - Resource inheritance for namespaced variants (e.g. `Admin::PostResource`), with `inherited_attribute` walking the ancestor chain so subclasses pick up their parent's declarations.
14
+ - Escape hatches: `scope`, `build_record`, and `after_save_path`.
15
+
16
+ ### Routing and controllers
17
+
18
+ - `layered_resources` route helper with full CRUD, plus `only:`/`except:` to restrict actions. Incoherent combinations (`:new` without `:create`) raise at route-definition time rather than 404ing later.
19
+ - `layered_resources :foo, namespace: "Foo::Bar"` derives the resource class as `Foo::Bar::FooResource` and routes to `Foo::Bar::ResourcesController` when one is defined — the supported path for mounting resources inside an engine.
20
+ - `Layered::Resource::Controller` concern. An engine can define its own `<Namespace>::ResourcesController` inheriting from its own `ApplicationController` and `include Layered::Resource::Controller`, keeping auth/authorize `before_action`s wired correctly.
21
+ - Auth inherited from the host app's `ApplicationController`, so its `before_action`s (Devise and friends) apply with no extra configuration.
22
+ - `owned_by` scopes a resource to its owner, and raises `Layered::Resource::MissingOwnerError` when `via` returns nil so auth misconfiguration surfaces loudly instead of silently 404ing every request. Pass `allow_nil: true` to opt into public-with-scope behaviour. (No-op under `use_pundit`, where Pundit handles the policy gate.)
23
+ - `destroy` rescues `ActiveRecord::InvalidForeignKey` and `ActiveRecord::DeleteRestrictionError`, redirecting to the index with a flash rather than raising a 500.
24
+
25
+ ### Index: search, sort, filters, pagination
26
+
27
+ - Index search, sort, and pagination via Ransack and Pagy.
28
+ - `filters :status, :created_at, user: { multiple: true }` — structured index filters rendered as an "Add filter" popover plus removable chips. Picking a filter adds an unset chip to the end of the row; its popover holds the controls, its ✕ removes it. Control and Ransack predicate are inferred from the column type (enum/`belongs_to` → multi-select via `_in`, with `multiple: false` for single-choice `_eq`; boolean → Yes/No; date/datetime → date range; numeric → number range; string → contains), and are overridable per attribute with `as:`/`collection:`/`multiple:`/`label:`/`pinned:`/`default:`. Pinned filters render as always-shown chips (no ✕, never in the add menu — which disappears entirely once every filter is pinned); a default applies when the request carries no state for the filter, and clearing writes an explicit blank so it does not re-apply. Filters, search, and sort round-trip each other as hidden fields, so they compose in the URL with no JavaScript.
29
+ - A select-type filter with more than `Layered::Resource.filter_combobox_threshold` (10) options renders as a type-ahead combobox rather than a checkbox list (or, single-choice, a menu of instant-apply links), so a `belongs_to` filter over a large table stays usable. The count is taken per request, after a `collection:` callable resolves, so the control follows the data; declaring `as: :select` or `as: :combobox` pins it either way.
30
+ - Filters accept `url:` (plus `min_chars:` and `text:`), fetching their options from an endpoint as the user types rather than rendering a collection up front. Such a filter is always a combobox. Give `url:` as a callable so it resolves per request in the view (`-> { user_options_path }`); the endpoint is an ordinary host-app action including `Layered::Ui::ComboboxOptions`, so it is authorised however any index is. An active remote filter's values are labelled server-side from the records, so its tag reads "User: Alice" rather than "User: 12".
31
+
32
+ ### Forms
33
+
34
+ - Record pickers: a field naming a `belongs_to`'s foreign key (`user_id`) renders as a single-select combobox over the associated records — a type-ahead input whose selection becomes a removable token — rather than as the raw number the column holds. Options default to `klass.all`, labelled the way a `belongs_to` filter's are and resolved per request; the picker posts the plain foreign key, so the write path is unchanged. `as:` opts out, `collection:` replaces the options, and every other combobox option passes through to `l_ui_combobox`. Polymorphic associations are skipped, having no single class to fill a picker.
35
+ - A field on a `belongs_to`'s foreign key takes its required flag from the association's `optional:` rather than from a presence validator on the column, since `belongs_to` validates the presence of the *association* under an `if:` that ActiveRecord attaches for its own reasons.
36
+ - A field's `permit:` is strong-parameters configuration read by `permitted_params`, and is dropped before the field reaches the form layer. The form helper passes any key it does not recognise through to the field's input, where a stray `permit` would render as an HTML attribute on a `select` or text input — and raise outright on a `combobox`, whose helper takes named options only.
37
+
38
+ ### Generators and i18n
39
+
40
+ - `layered:resource:scaffold`, `layered:resource`, `layered:resource:views`, `layered:resource:controller`, and `layered:resource:column` generators, plus `layered:resource:install_agent_skill` for the bundled agent skill.
41
+ - Flash messages are i18n-backed (`config/locales/en.yml`, key `layered.resource.flash.*`), overridable per-locale in the host app.
42
+
43
+ ### Requirements
44
+
45
+ - Rails ~> 8.0, Ruby >= 3.3, and layered-ui-rails ~> 0.25 (>= 0.25.1, which is where the popover overflow fix the filter comboboxes depend on landed).
data/CLA.md ADDED
@@ -0,0 +1,10 @@
1
+ # Contributor License Agreement
2
+
3
+ This agreement allows LAYERED AI LIMITED (UK company number: 17056830) to
4
+ use your contributions in both open source and commercial offerings.
5
+
6
+ - You retain copyright
7
+ - You grant LAYERED AI LIMITED a perpetual, worldwide license to use,
8
+ modify, sublicense, and distribute your contribution
9
+
10
+ This agreement does not restrict your own use of the contribution.