layered-resource-rails 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83a0686237527aabd89c6fa83f98c8a2bbab1a02deff48528e8e5baed7f8b874
4
- data.tar.gz: 744e768aff2d5c624c9a243ccfbc9d92cf80de3a559fec9c320c89d51e7e197e
3
+ metadata.gz: bfe32848fe680ff7bcbafa8c72d1d7c46a83fb48c3a440411eae26f5fe766cb3
4
+ data.tar.gz: 9c47bbcff2783c5423cc1d0d7360d27934ed18debbb3bedda72af65b8995b312
5
5
  SHA512:
6
- metadata.gz: ebe053f334e9ad89c835add54f1c60bf5c8c863cb175ec42932e7d9cf0809eef73d65646a96505da64cd9062207dc722eb672d4662cad612ca70259eecac9c60
7
- data.tar.gz: a148f47dc69bbd3fb799bd1b8b49d2b4bf90dbc3854e2c75fec7dd88dd5c8efcb8e99ec7af434d6c2b76370d2243ca3eb9f7d512113aeba3ae46effe8bc76194
6
+ metadata.gz: 5b79478c63e11f9cd96af0df6aa89ed46e8f133ac90c2f05842d0a2f19cec7156faf70aa9f75c803a4c7655b20e771c0e11c0d6d95c2bba927582d1a30b9116b
7
+ data.tar.gz: 013feae5be9a1c9e717434411d7f9d2de288cff2669400ae14477a27cdc104098a0d78d45584366da675be3892dec955fd3ba6104a1f1a3f3e0903fc00518c7c
@@ -2,7 +2,7 @@
2
2
  name: layered-resource-rails
3
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
4
  license: Apache-2.0
5
- compatibility: Requires Ruby on Rails >= 8.0, layered-ui-rails ~> 0.9, ransack ~> 4.0, pagy ~> 43.2
5
+ compatibility: Requires Ruby on Rails >= 8.0, layered-ui-rails ~> 0.27, ransack ~> 4.0, pagy ~> 43.2
6
6
  metadata:
7
7
  author: layered.ai
8
8
  version: "1.0"
@@ -39,6 +39,8 @@ This produces:
39
39
  - `app/layered_resources/post_resource.rb` with `columns` and `fields` derived from the attributes
40
40
  - `layered_resources :posts` appended to `config/routes.rb`
41
41
 
42
+ A `references` attribute (e.g. `speaker:references`) becomes a `:speaker_id` field - inferred as a combobox of the associated records - but is left out of `columns`, since a raw foreign key is rarely the column an index wants. Polymorphic references are left out of both: setting one takes a `_type` too, so write that field yourself.
43
+
42
44
  Useful flags:
43
45
 
44
46
  - `--skip-model` - the model already exists
@@ -102,7 +104,7 @@ end
102
104
  | `model Post` | The ActiveRecord class this resource manages |
103
105
  | `columns [...]` | Index table columns. Each entry is `{ attribute:, label:, primary:, link:, render: }` |
104
106
  | `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 |
107
+ | `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. The box searches as the term is typed (debounced, into the index Turbo frame) with a clear button built into the field - no Search or Clear buttons. Typed searches `replace` history rather than pushing; the caret survives the re-render and the result count is announced |
106
108
  | `search_placeholder "..."` | Replaces the search box placeholder. Default derives from `search_fields` via `human_attribute_name`, so `activerecord.attributes.<model>.<attr>` i18n renames flow through (association walks resolve each half against its own model) |
107
109
  | `filters :a, :b, c: {...}` | Structured filter controls on the index — an "Add filter" popover plus removable tags. Control + Ransack predicate inferred per column; trailing hash overrides per attribute. See [Filters](#filters) |
108
110
  | `label_attribute :title` | Attribute a record is labelled by (page titles, row action menus, another resource's picker). Defaults to the `primary:` column, else the first. Falls back through `name`/`title`/`label`/`email`, then the model's own `to_s`, then `"Post #12"` |
@@ -110,6 +112,8 @@ end
110
112
  | `per_page n` | Pagination size (default 15) |
111
113
  | `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
114
 
115
+ The trail always ends with the current page (collection name on `index`, record label on `show`, `New`/`Edit` on the forms), and only that crumb carries `aria-current="page"`. Parent record crumbs link to their show page when the parent resource routes `:show`.
116
+
113
117
  ### Column options
114
118
 
115
119
  - `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)
@@ -159,7 +163,7 @@ Field `as:` follows Rails' `form_with` field helpers - `:text`, `:checkbox`, `:d
159
163
 
160
164
  ### Filters
161
165
 
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).
166
+ `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 (the one-shot `fo` param marks which tag's popover renders open). The filter controls are plain links and forms with no JavaScript; only the search box's type-to-search is scripted. Because the filters are hidden fields inside the search form, clearing the term client-side keeps them.
163
167
 
164
168
  ```ruby
165
169
  filters :status, # enum -> multi-select of its values (status_in)
@@ -181,7 +185,7 @@ Override per attribute with a trailing hash: `as:` (force control type: `:select
181
185
 
182
186
  **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
187
 
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.
188
+ 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. The search box is the `_search` partial, which calls `l_ui_search_form` in block mode with `l_ui_search_control`. Both take `live: true` (opt-in in layered-ui-rails 0.27 - it is not inferred from `turbo_frame:`, and the control defaults to `live: false` on its own), and the form passes `count: @pagy&.count` so results are announced.
185
189
 
186
190
  ## Route DSL
187
191
 
@@ -194,10 +198,14 @@ layered_resources :posts, except: [:destroy] # everything but
194
198
  layered_resources :posts, controller: "posts" # use a custom controller
195
199
  layered_resources :posts, resource: "Admin::PostResource" # explicit resource class
196
200
  layered_resources :posts, namespace: "Admin" # derives Admin::PostResource and Admin::ResourcesController
201
+ layered_resources :posts, layout: "manage" # render inside app/views/layouts/manage.html.erb
202
+ layered_resources :posts, layout: false # render with no layout
197
203
  ```
198
204
 
199
205
  Incoherent `only:` combos raise at boot time - e.g. `:new` without `:create`, or `:edit` without `:update`.
200
206
 
207
+ `layout:` is the hook for putting a resource on a host-app layout **without ejecting a controller** - reach for it before generating one just to write a `layout` line. It applies to every action on that route, and each route decides independently, so the same resource can be plain under `/posts` and wrapped under `/manage/posts`. A `layout` declared in an ejected controller replaces the hook and wins. Anything but a String, Symbol, or `false` raises at boot.
208
+
201
209
  ### Nested routes
202
210
 
203
211
  ```ruby
@@ -344,6 +352,8 @@ rails g layered:resource:views posts # copies index/show/new/edit ERB int
344
352
  rails g layered:resource:controller posts # generates a controller subclass for custom actions
345
353
  ```
346
354
 
355
+ Pass the **plural** name: a singular one is pluralised (`talk` → `TalksController`) because `layered_resources` routes under the plural and `controller:` must match. Don't eject a controller just to set a layout - use the route's `layout:` option.
356
+
347
357
  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
358
 
349
359
  To outgrow the gem entirely: drop the inheritance, write a plain Rails controller, swap `layered_resources :posts` for `resources :posts` in routes.
@@ -440,6 +450,7 @@ To make an association searchable, add a Ransack-walk-shaped entry to `search_fi
440
450
  - **`NoMethodError: undefined method 'l_ui_table'`** - the host app hasn't installed `layered-ui-rails`. Run `bin/rails generate layered:ui:install`.
441
451
  - **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
452
  - **`only:` validation error at boot** - `:new` requires `:create`, `:edit` requires `:update`. Adjust the action list.
453
+ - **`<Name>Resource declares column :foo, but <Model> has no public method by that name`** - a column names something the model doesn't publicly answer to (a typo, or a private method - cells are read with `public_send`). Define or `delegate` a public method, fix the attribute name, or give the column a `render:` proc (procs are exempt from the check).
443
454
  - **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
455
 
445
456
  ## Further reference
data/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.3.0] - 2026-09-21
6
+
7
+ ### Changed
8
+
9
+ - The index search box searches as the term is typed. The **Search** and **Clear** buttons are gone: the box submits into the index Turbo frame as the field changes, debounced so a typed word is one request rather than one per letter, and the field carries its own clear button - shown only once there is something to clear, and reachable by keyboard with Escape clearing the field too. A typed search `replace`s the history entry rather than pushing one, so Back leaves the index instead of replaying the term letter by letter; the term still lives in the URL, so a search stays shareable and survives a reload, and sort links, pagination and filter chips still advance.
10
+ - With no button press to signal a change, the number of results is now announced to screen readers after each search (`count:` is passed from `@pagy`), the caret is preserved across the frame re-render so a word is never interrupted mid-letter, and the box is a labelled `search` landmark. The form still submits on Enter and works without JavaScript.
11
+ - The two search branches on the index - one for resources with `filters`, one without - are now a single `_search.html.erb` partial, ejected by `rails g layered:resource:views` along with the rest. Because the active filters are hidden fields inside the search form, clearing the term keeps them, which is what `layered_search_clear_path` used to compute; that helper is still there for an ejected view that wants the same link.
12
+ - Requires `layered-ui-rails` ~> 0.27, which is where the type-to-search control (`live:`, `count:`, the in-field clear button) landed.
13
+
14
+ ### Fixed
15
+
16
+ - The breadcrumb trail ends with the page it is on - the collection name on `index`, the record's label on `show`, **New** or **Edit** on the form pages - and only that last crumb is marked `aria-current="page"`. The trail used to stop at the parent record, and because `l_ui_breadcrumb_item` marks any unlinked crumb as the current page, a nested index pointed a screen reader at its parent while `show` marked nothing at all. Crumbs now go through a `layered_breadcrumb_item` helper that lets only the current one through unlinked, and a parent record links to its own show page when its resource routes `:show`, so the middle of a trail is no longer a dead end.
17
+
18
+ ## [0.2.0] - 2026-09-10
19
+
20
+ ### Routing and controllers
21
+
22
+ - `layered_resources :posts, layout: "manage"` renders a resource's pages inside one of the host app's layouts, and `layout: false` renders them with no layout at all. The option belongs to the route rather than the resource, so the same resource can be plain under `/posts` and wrapped in admin chrome under `/manage/posts`. Previously the only way to put a resource on an app layout was to eject a controller purely to write one `layout` line. A `layout` declared in an ejected controller still wins; a route declaring no layout resolves exactly as before, landing on the host's `ApplicationController` layout.
23
+
24
+ ### Columns
25
+
26
+ - A column naming a method the model has no public answer for now raises before the table renders, naming the resource, the attribute, and the model, and suggesting the `delegate`. It used to surface as a bare `NoMethodError` from inside a column partial. Columns with a `render:` proc are exempt — the proc decides what to call, and `attribute:` is then only the header and sort key.
27
+
28
+ ### Generators
29
+
30
+ - `rails g layered:resource talk speaker:references` now emits a `:speaker_id` field, which infers a combobox of the associated records. References stay out of `columns` (a raw foreign key is rarely the column an index wants), but excluding them from `fields` too left the generated form with no way to set the association — and since `belongs_to` is required by default, every create failed with "Speaker must exist". Polymorphic references stay out of both: setting one takes a `_type` as well.
31
+ - `rails g layered:resource:controller talk` now generates `TalksController` and advises `layered_resources :talks, controller: "talks"`. It used to generate `talk_controller.rb` and advise `layered_resources :talk`, which moved the collection from `/talks` to `/talk` and broke every path helper pointing at it.
32
+
5
33
  ## [0.1.0] - 2026-08-30
6
34
 
7
35
  Initial release.
data/README.md CHANGED
@@ -45,7 +45,7 @@ curl -fsSL https://raw.githubusercontent.com/layered-ai-public/layered-resource-
45
45
  ## Requirements
46
46
 
47
47
  - Ruby on Rails >= 8.0
48
- - [layered-ui-rails](https://github.com/layered-ai-public/layered-ui-rails) ~> 0.25 (>= 0.25.1)
48
+ - [layered-ui-rails](https://github.com/layered-ai-public/layered-ui-rails) ~> 0.27
49
49
  - Ransack ~> 4.0
50
50
  - Pagy ~> 43.2
51
51
 
@@ -73,6 +73,8 @@ rails g layered:resource:scaffold post title:string body:text
73
73
 
74
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
75
 
76
+ A `references` attribute (e.g. `speaker:references`) becomes a `:speaker_id` field - which infers a combobox of the associated records - but is left out of `columns`, since a raw foreign key is rarely the column you want on an index. Polymorphic references are left out of both: setting one takes a `_type` as well, so it needs a field you write yourself.
77
+
76
78
  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
79
 
78
80
  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.
@@ -163,6 +165,15 @@ layered_resources :posts, except: [:destroy]
163
165
 
164
166
  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
167
 
168
+ **Layout:** the gem's controller renders inside your `ApplicationController`'s layout. Point a route at a different one - an admin chrome, say - without ejecting a controller:
169
+
170
+ ```ruby
171
+ layered_resources :posts, layout: "manage" # app/views/layouts/manage.html.erb
172
+ layered_resources :posts, layout: false # no layout at all
173
+ ```
174
+
175
+ The option covers every action on that route, and each route decides independently, so the same resource can be plain under `/posts` and wrapped under `/manage/posts`. An ejected controller that declares its own `layout` wins over the route option.
176
+
166
177
  **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
178
 
168
179
  ```ruby
@@ -175,6 +186,8 @@ end
175
186
 
176
187
  Nested routes prepend it to the derived parent trail (e.g. Home / Users / Alice). Pass `nil` as the path to render unlinked text.
177
188
 
189
+ Every trail ends with the page you're on - the collection name on `index`, the record's label on `show`, `New` or `Edit` on the form pages - and that last crumb is the only one marked `aria-current="page"`. Parent crumbs never are, whether or not they link: a parent record links to its own show page when its resource routes `:show`, and renders as plain text when it doesn't.
190
+
178
191
  **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
192
 
180
193
  ```ruby
@@ -365,6 +378,14 @@ The Ransack allowlists are scoped to the resource: the association and the assoc
365
378
 
366
379
  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
380
 
381
+ ### How the search box behaves
382
+
383
+ There is no Search button to press. The box searches as the term is typed, debounced so a typed word is one request rather than one per letter, and the results re-render inside the index's Turbo frame. The field carries its own clear button, which appears once there is something to clear; Escape in the field clears it too.
384
+
385
+ Each search **replaces** the history entry rather than pushing one, so Back leaves the index instead of replaying the term letter by letter. The term still lives in the URL, so a search remains shareable and survives a reload — and sort links, pagination and filter chips still push history as before.
386
+
387
+ The parts that matter for accessibility are handled for you: the caret stays put across the re-render, so a word is never interrupted mid-letter; the number of results is announced to screen readers after each search, since with no button press there is otherwise nothing to say the page changed; and the box is a labelled `search` landmark. The form still submits on Enter and works without JavaScript.
388
+
368
389
  ### Search placeholder
369
390
 
370
391
  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]`:
@@ -465,7 +486,7 @@ A counter-cache column reads straight off the parent row, so the index renders i
465
486
 
466
487
  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
488
 
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.
489
+ 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). The filter controls themselves are plain links and forms, with no JavaScript involved; only the search box's type-to-search is scripted. 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
490
 
470
491
  Declare `filters` with a list of attributes. The control and predicate are inferred from each column:
471
492
 
@@ -579,7 +600,7 @@ filters :created_at,
579
600
 
580
601
  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
602
 
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`.
603
+ 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 search box is the `_search` partial, the bar is `_filters`, and each control is `_filter_control`.
583
604
 
584
605
  ## Column rendering
585
606
 
@@ -607,6 +628,17 @@ rails g layered:resource:column priority_badge # scaffold a brand-new type
607
628
 
608
629
  A custom partial receives `record`, `value`, and `options` (the column hash) as locals - read keys like `:variants` or `:format` straight off `options`.
609
630
 
631
+ A column's `attribute:` has to be something the model publicly answers to - a DB column, an association, an `attribute`, a delegated method, or any public method you define (the default renderer reads the cell with `public_send`). Naming one it doesn't have raises before the table renders, telling you which resource and which attribute:
632
+
633
+ ```
634
+ PostResource declares column :author_name, but Post has no public method by that
635
+ name. Add it to Post (`delegate :author_name, to: :<association>` for an
636
+ associated model's attribute), correct the column's attribute:, or give the
637
+ column a `render:` proc that produces the value.
638
+ ```
639
+
640
+ A column with a `render:` proc is exempt - the proc decides what to call, and `attribute:` is then just the header and sort key.
641
+
610
642
  ### Sortable headers
611
643
 
612
644
  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)).
@@ -851,7 +883,7 @@ This copies the gem's actual `index`, `show`, `new`, and `edit` templates into `
851
883
  rails g layered:resource:controller posts
852
884
  ```
853
885
 
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.
886
+ 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. Pass a singular name and it's pluralised (`talk` generates `TalksController`): `layered_resources` declares its routes under the plural name and `controller:` has to match, so a `talk_controller.rb` would only be reachable by moving the collection from `/talks` to `/talk`.
855
887
 
856
888
  If you outgrow the gem entirely, drop the inheritance and write a plain Rails controller:
857
889
 
@@ -25,6 +25,12 @@ module Layered
25
25
  helper Layered::Ui::PagyHelper
26
26
  helper Layered::Ui::BreadcrumbsHelper
27
27
  helper Layered::Resource::FiltersHelper
28
+ helper Layered::Resource::BreadcrumbsHelper
29
+
30
+ # Hooks the route's `layout:` option in. Returning nil (the common
31
+ # case) falls through to Rails' normal layout resolution, so routes
32
+ # without the option are unaffected.
33
+ layout :layered_resource_layout
28
34
 
29
35
  before_action :load_layered_resource
30
36
  before_action :load_layered_member_record
@@ -195,6 +201,20 @@ module Layered
195
201
  @resource_can_show = resource_actions.include?(:show)
196
202
  end
197
203
 
204
+ # The layout named by `layered_resources :posts, layout: "manage"`,
205
+ # or nil when the route declared none — which Rails reads as "resolve
206
+ # the layout the usual way", landing on the host's
207
+ # ApplicationController layout. `layout: false` renders bare. Nil is
208
+ # also what an ejected controller sees before `load_layered_resource`
209
+ # has run (e.g. rendering an error page), which is the right default
210
+ # there too.
211
+ def layered_resource_layout
212
+ layout = @_route_entry && @_route_entry[:layout]
213
+ return nil if layout.nil?
214
+
215
+ layout == false ? false : layout.to_s
216
+ end
217
+
198
218
  # For custom member actions declared in a `layered_resources` block,
199
219
  # populate @record from params[:id] so action bodies don't have to
200
220
  # repeat `@resource.scope(self).find(params[:id])`. Skip this with
@@ -48,32 +48,50 @@ module Layered
48
48
 
49
49
  crumbs = []
50
50
 
51
- # Link to the parent's layered index if a route exists
51
+ # The parent's own ancestors, filled in from the current
52
+ # request - needed by both of the parent's path helpers.
52
53
  if collection_entry
53
54
  rs = collection_entry[:routes] || Rails.application.routes
55
+ ancestor_args = collection_entry[:parent_params].index_with { |p| params[p] }
56
+ ancestor_args = nil unless ancestor_args.values.all?(&:present?)
57
+ end
58
+
59
+ # Link to the parent's layered index if a route exists
60
+ if collection_entry && ancestor_args
54
61
  helper = :"#{collection_key}_path"
55
62
  if rs.url_helpers.method_defined?(helper)
56
- ancestor_args = collection_entry[:parent_params].each_with_object({}) do |p, h|
57
- h[p] = params[p]
58
- end
59
- if ancestor_args.values.all?(&:present?)
60
- path = rs.url_helpers.send(helper, default_url_options.merge(ancestor_args))
61
- crumbs << { label: model_class.model_name.human.pluralize, path: path }
62
- end
63
+ path = rs.url_helpers.send(helper, default_url_options.merge(ancestor_args))
64
+ crumbs << { label: model_class.model_name.human.pluralize, path: path }
63
65
  end
64
66
  end
65
67
 
66
- # Add the specific record breadcrumb
68
+ # Add the specific record breadcrumb, linked to its own show
69
+ # page when the parent resource has one - a crumb that isn't
70
+ # the current page shouldn't be a dead end.
67
71
  record = model_class.find_by(id: params[key])
68
72
  if record
69
73
  label = record.try(:name) || record.try(:title) || "#{model_class.model_name.human} ##{record.id}"
70
- crumbs << { label: label, path: nil }
74
+ crumbs << { label: label, path: layered_parent_record_path(collection_entry, collection_key, ancestor_args, record) }
71
75
  end
72
76
 
73
77
  crumbs
74
78
  end
75
79
  end
76
80
  end
81
+
82
+ # The parent record's show path, or nil when the parent isn't a
83
+ # layered resource, doesn't route :show, or can't be addressed
84
+ # from here.
85
+ def layered_parent_record_path(collection_entry, collection_key, ancestor_args, record)
86
+ return nil unless collection_entry && ancestor_args
87
+ return nil unless collection_entry[:actions].include?(:show)
88
+
89
+ rs = collection_entry[:routes] || Rails.application.routes
90
+ helper = :"#{collection_key.to_s.singularize}_path"
91
+ return nil unless rs.url_helpers.method_defined?(helper)
92
+
93
+ rs.url_helpers.send(helper, default_url_options.merge(ancestor_args).merge(id: record.to_param))
94
+ end
77
95
  end
78
96
  end
79
97
  end
@@ -13,12 +13,42 @@ module Layered
13
13
  # that doesn't already have one, then wraps columns with a `link:`
14
14
  # option in a link to the named route.
15
15
  def decorate_columns
16
+ validate_column_attributes!
16
17
  apply_column_sortability
17
18
  apply_column_renderers
18
19
  apply_column_links
19
20
  apply_primary_column_link if action_name == "index"
20
21
  end
21
22
 
23
+ # `columns` is declarative, so a column naming a method the model
24
+ # doesn't have is checkable before anything renders. Without this the
25
+ # miss surfaces as a bare NoMethodError raised from inside a column
26
+ # partial, naming neither the resource nor the column. Columns with a
27
+ # `render:` proc are exempt: the proc decides what to call, and
28
+ # `attribute` is then just a header/sort key.
29
+ def validate_column_attributes!
30
+ model = @resource.model
31
+ # Attribute readers are defined lazily, so ask for them before
32
+ # asking whether they exist.
33
+ model.define_attribute_methods
34
+
35
+ # Public only: the default renderer reads the cell with
36
+ # `record.public_send`, so a private method is as unrenderable as
37
+ # a missing one.
38
+ missing = @columns.reject { |col| col[:render] }
39
+ .filter_map { |col| col[:attribute] }
40
+ .reject { |attr| model.method_defined?(attr) }
41
+ return if missing.empty?
42
+
43
+ raise ArgumentError,
44
+ "#{@resource.name} declares column#{'s' if missing.size > 1} " \
45
+ "#{missing.map(&:inspect).join(', ')}, but #{model.name} has no public method " \
46
+ "by that name. " \
47
+ "Add it to #{model.name} (`delegate :#{missing.first}, to: :<association>` for " \
48
+ "an associated model's attribute), correct the column's attribute:, or give the " \
49
+ "column a `render:` proc that produces the value."
50
+ end
51
+
22
52
  # Marks columns sortable: false unless they map to a real DB column.
23
53
  # Virtual / association-derived columns (e.g. :user_name on Post) can't
24
54
  # be sorted by Ransack without the associated model also having
@@ -0,0 +1,19 @@
1
+ module Layered
2
+ module Resource
3
+ # Renders one crumb of a layered resource's breadcrumb trail.
4
+ #
5
+ # `l_ui_breadcrumb_item` marks every unlinked crumb as
6
+ # aria-current="page", which is only correct for the crumb that *is* the
7
+ # current page. A derived trail routinely carries unlinked crumbs that
8
+ # aren't - a parent record with no show route, an unlinked
9
+ # `root_breadcrumb` - so only the crumb a view flags as `current:` is
10
+ # passed through unlinked; the rest render as plain, uncurrent text.
11
+ module BreadcrumbsHelper
12
+ def layered_breadcrumb_item(label, path = nil, current: false)
13
+ return l_ui_breadcrumb_item(label, current ? nil : path) if path || current
14
+
15
+ tag.li(tag.span(label), class: "l-ui-breadcrumbs__item")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -235,8 +235,12 @@ module Layered
235
235
  @resource.search_fields.map(&:to_s).join("_or_") + "_cont"
236
236
  end
237
237
 
238
- # Clear link for the search box that drops only the search term,
239
- # keeping active filters and sort.
238
+ # A path that drops only the search term, keeping active filters and sort.
239
+ #
240
+ # The search box no longer needs this - it clears client-side by
241
+ # submitting its own form with an empty field, and the filters are hidden
242
+ # fields inside that form, so they ride along. Kept for an ejected view or
243
+ # a host app that wants the same "clear just the term" link.
240
244
  def layered_search_clear_path
241
245
  layered_filter_path_with(layered_filter_query_params.except(layered_search_field_key))
242
246
  end
@@ -0,0 +1,24 @@
1
+ <%# The search box. Searching happens as the term is typed (live: true), so
2
+ there is no Search button to press, and the field carries its own clear
3
+ button. live: needs the turbo_frame: to land the response in, and count:
4
+ so the size of the result set is announced once it has.
5
+
6
+ Any active filter and sort params ride along as hidden fields, so a search
7
+ composes with them rather than replacing them - which is also what makes
8
+ clearing the term client-side keep the filters: an empty term is submitted
9
+ through this same form. %>
10
+ <div class="l-ui-mt-4">
11
+ <%= l_ui_search_form(@q,
12
+ url: layered_collection_path,
13
+ label: "Search #{@model.model_name.human.pluralize.downcase}",
14
+ live: true,
15
+ count: @pagy&.count,
16
+ turbo_frame: turbo_frame_name) do |f| %>
17
+ <%= layered_filter_hidden_fields(except: layered_search_field_key) if layered_filters.any? %>
18
+ <div class="l-ui-search-inline">
19
+ <%= l_ui_search_control(f, layered_search_field_key,
20
+ placeholder: @resource.search_placeholder,
21
+ live: true) %>
22
+ </div>
23
+ <% end %>
24
+ </div>
@@ -2,9 +2,10 @@
2
2
  <div>
3
3
  <%= l_ui_breadcrumbs do %>
4
4
  <% layered_breadcrumbs.each do |crumb| %>
5
- <%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
5
+ <%= layered_breadcrumb_item crumb[:label], crumb[:path] %>
6
6
  <% end %>
7
- <%= l_ui_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
7
+ <%= layered_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
8
+ <%= layered_breadcrumb_item "Edit", current: true %>
8
9
  <% end %>
9
10
 
10
11
  <h1 class="l-ui-heading">Edit <%= @model.model_name.human.downcase %></h1>
@@ -2,11 +2,14 @@
2
2
 
3
3
  <div class="l-ui-spread">
4
4
  <div>
5
+ <%# The trail ends on this page, so it carries aria-current - the
6
+ parent crumbs before it never do, linked or not. %>
5
7
  <% if layered_breadcrumbs.any? %>
6
8
  <%= l_ui_breadcrumbs do %>
7
9
  <% layered_breadcrumbs.each do |crumb| %>
8
- <%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
10
+ <%= layered_breadcrumb_item crumb[:label], crumb[:path] %>
9
11
  <% end %>
12
+ <%= layered_breadcrumb_item @model.model_name.human.pluralize, current: true %>
10
13
  <% end %>
11
14
  <% end %>
12
15
 
@@ -29,32 +32,7 @@
29
32
 
30
33
  <%= turbo_frame_tag turbo_frame_name, data: { turbo_action: "advance" } do %>
31
34
  <% if @resource.search_fields.any? %>
32
- <div class="l-ui-mt-4">
33
- <% if layered_filters.any? %>
34
- <%# With filters declared, the search form must round-trip the active
35
- filter and sort q params as hidden fields so its submit composes
36
- with them; Clear drops only the search term. %>
37
- <%= l_ui_search_form(@q, url: layered_collection_path, turbo_frame: turbo_frame_name) do |f| %>
38
- <%= layered_filter_hidden_fields(except: layered_search_field_key) %>
39
- <%= f.label layered_search_field_key, "Search", class: "l-ui-sr-only" %>
40
- <div class="l-ui-search-inline">
41
- <%= f.text_field layered_search_field_key,
42
- class: "l-ui-form__field", placeholder: @resource.search_placeholder %>
43
- <%= f.submit "Search", class: "l-ui-button l-ui-button--primary" %>
44
- <%= link_to "Clear", layered_search_clear_path,
45
- class: "l-ui-button l-ui-button--outline",
46
- data: { turbo_frame: turbo_frame_name, turbo_action: "advance" } %>
47
- </div>
48
- <% end %>
49
- <% else %>
50
- <%= l_ui_search_form(@q,
51
- url: layered_collection_path,
52
- fields: @resource.search_fields,
53
- placeholder: @resource.search_placeholder,
54
- clear: true,
55
- turbo_frame: turbo_frame_name) %>
56
- <% end %>
57
- </div>
35
+ <%= render "search", turbo_frame_name: turbo_frame_name %>
58
36
  <% end %>
59
37
 
60
38
  <% if layered_filters.any? %>
@@ -2,9 +2,10 @@
2
2
  <div>
3
3
  <%= l_ui_breadcrumbs do %>
4
4
  <% layered_breadcrumbs.each do |crumb| %>
5
- <%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
5
+ <%= layered_breadcrumb_item crumb[:label], crumb[:path] %>
6
6
  <% end %>
7
- <%= l_ui_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
7
+ <%= layered_breadcrumb_item @model.model_name.human.pluralize, layered_collection_path %>
8
+ <%= layered_breadcrumb_item "New", current: true %>
8
9
  <% end %>
9
10
 
10
11
  <h1 class="l-ui-heading">New <%= @model.model_name.human.downcase %></h1>
@@ -6,11 +6,12 @@
6
6
  <% if layered_breadcrumbs.any? || index_path %>
7
7
  <%= l_ui_breadcrumbs do %>
8
8
  <% layered_breadcrumbs.each do |crumb| %>
9
- <%= l_ui_breadcrumb_item crumb[:label], crumb[:path] %>
9
+ <%= layered_breadcrumb_item crumb[:label], crumb[:path] %>
10
10
  <% end %>
11
11
  <% if index_path %>
12
- <%= l_ui_breadcrumb_item @model.model_name.human.pluralize, index_path %>
12
+ <%= layered_breadcrumb_item @model.model_name.human.pluralize, index_path %>
13
13
  <% end %>
14
+ <%= layered_breadcrumb_item record_label, current: true %>
14
15
  <% end %>
15
16
  <% end %>
16
17
 
@@ -28,6 +28,14 @@ module Layered
28
28
  template "controller.rb.tt", path
29
29
  end
30
30
 
31
+ def note_pluralisation
32
+ return unless pluralised?
33
+
34
+ say ""
35
+ say "Named the controller #{file_name} (not #{given_name}): layered_resources " \
36
+ "declares its routes under the plural name, and controller: has to match."
37
+ end
38
+
31
39
  def show_routing_instructions
32
40
  say ""
33
41
  say "Point the route at the new controller:"
@@ -45,8 +53,26 @@ module Layered
45
53
 
46
54
  private
47
55
 
56
+ # `layered_resources` declares its routes under the plural name, and
57
+ # `controller:` has to name the controller's own path, so a singular
58
+ # argument is pluralised. Generating `talk_controller.rb` and telling
59
+ # the user to write `layered_resources :talk, controller: "talk"`
60
+ # would move the collection from /talks to /talk and break every
61
+ # path helper already pointing at it.
48
62
  def file_name
49
- @_file_name ||= super.sub(/_?controller$/i, "")
63
+ @_file_name ||= given_name.pluralize
64
+ end
65
+
66
+ # The name as typed, minus any `_controller` suffix. `super` here
67
+ # would re-enter the override, so read NamedBase's own reader.
68
+ def given_name
69
+ @_given_name ||= Rails::Generators::NamedBase
70
+ .instance_method(:file_name).bind_call(self)
71
+ .sub(/_?controller$/i, "")
72
+ end
73
+
74
+ def pluralised?
75
+ given_name != file_name
50
76
  end
51
77
  end
52
78
  end
@@ -44,12 +44,22 @@ module Layered
44
44
  singular_name.camelize
45
45
  end
46
46
 
47
+ # `speaker_id` is a poor index column - the table wants the
48
+ # speaker's label, which is the consumer's call - so references are
49
+ # left out of `columns`.
47
50
  def column_attributes
48
51
  attributes.reject { |a| a.reference? || a.password_digest? }
49
52
  end
50
53
 
54
+ # References belong in `fields` as their foreign key: `belongs_to` is
55
+ # required by default, so a form without the FK cannot create a
56
+ # record. A `:<name>_id` field infers a combobox of the associated
57
+ # records (see `Layered::Resource::Base#infer_association_field`).
58
+ # Polymorphic references stay out: setting one needs a `_type` as
59
+ # well, and there is no single class whose records could fill a
60
+ # picker.
51
61
  def field_attributes
52
- column_attributes
62
+ attributes.reject { |a| a.password_digest? || (a.reference? && a.polymorphic?) }
53
63
  end
54
64
 
55
65
  def field_as(attr)
@@ -12,7 +12,7 @@ class <%= resource_class_name %>Resource < Layered::Resource::Base
12
12
 
13
13
  fields [
14
14
  <% field_attributes.each_with_index do |attr, i| -%>
15
- { attribute: :<%= attr.name %><%= field_as(attr) %> }<%= "," unless i == field_attributes.length - 1 %>
15
+ { attribute: :<%= attr.column_name %><%= field_as(attr) %> }<%= "," unless i == field_attributes.length - 1 %>
16
16
  <% end -%>
17
17
  ]
18
18
  <% end -%>
@@ -16,7 +16,7 @@ module Layered
16
16
  desc "Copy the gem's view templates into app/views/layered/<name>/ for full customisation."
17
17
 
18
18
  VIEWS = %w[index.html.erb show.html.erb new.html.erb edit.html.erb
19
- _filters.html.erb _filter_control.html.erb].freeze
19
+ _search.html.erb _filters.html.erb _filter_control.html.erb].freeze
20
20
 
21
21
  def copy_views
22
22
  VIEWS.each do |view|
@@ -6,11 +6,12 @@ module Layered
6
6
  @registry = Concurrent::Map.new
7
7
 
8
8
  class << self
9
- def register(route_key, resource_class_name, actions: [], routes: nil, parent_params: [], parent_collection_keys: {}, resource_name: nil, member_actions: [], collection_actions: [])
9
+ def register(route_key, resource_class_name, actions: [], routes: nil, parent_params: [], parent_collection_keys: {}, resource_name: nil, member_actions: [], collection_actions: [], layout: nil)
10
10
  @registry[route_key.to_s] = {
11
11
  resource: resource_class_name.to_s,
12
12
  actions: actions,
13
13
  routes: routes,
14
+ layout: layout,
14
15
  parent_params: parent_params,
15
16
  parent_collection_keys: parent_collection_keys,
16
17
  resource_name: resource_name.to_s,
@@ -82,7 +83,13 @@ module Layered
82
83
  end
83
84
  end
84
85
 
85
- def layered_resources(resource_name, resource: nil, controller: nil, namespace: nil, only: RESOURCE_ACTIONS, except: nil, **options, &block)
86
+ # `layout:` renders the resource's pages inside one of the host app's
87
+ # layouts (`layout: "manage"` → `app/views/layouts/manage.html.erb`),
88
+ # or without one (`layout: false`). Without it the controller keeps
89
+ # Rails' normal resolution, which lands on the host's
90
+ # `ApplicationController` layout. An explicit `layout` declaration in
91
+ # an ejected controller replaces the hook and wins over this option.
92
+ def layered_resources(resource_name, resource: nil, controller: nil, namespace: nil, layout: nil, only: RESOURCE_ACTIONS, except: nil, **options, &block)
86
93
  # When called inside `resources :foo do ... end` (or `resource :foo do`),
87
94
  # Rails has set up a resource_scope but hasn't pushed the parent's
88
95
  # path into @scope. Push it ourselves via scope(path:) and recurse.
@@ -107,7 +114,7 @@ module Layered
107
114
  begin
108
115
  layered_resources(resource_name,
109
116
  resource: resource, controller: controller, namespace: namespace,
110
- only: only, except: except, **options, &block)
117
+ layout: layout, only: only, except: except, **options, &block)
111
118
  ensure
112
119
  @scope.frame[:as] = saved_as
113
120
  end
@@ -124,6 +131,13 @@ module Layered
124
131
  # `scope path: "foo", module: "foo"` and pass `namespace:` here.
125
132
  namespace = namespace.to_s.presence
126
133
 
134
+ unless layout.nil? || layout == false || layout.is_a?(String) || layout.is_a?(Symbol)
135
+ raise ArgumentError,
136
+ "layered_resources :#{resource_name} got layout: #{layout.inspect}. " \
137
+ "Pass a layout name (String or Symbol, resolved under app/views/layouts) " \
138
+ "or `false` to render without a layout."
139
+ end
140
+
127
141
  resource_class_name = resource ||
128
142
  (namespace ? "#{namespace}::#{resource_name.to_s.classify}Resource" : "#{resource_name.to_s.classify}Resource")
129
143
  route_key = resource_name.to_s
@@ -282,7 +296,8 @@ module Layered
282
296
  parent_collection_keys: parent_collection_keys,
283
297
  resource_name: route_key,
284
298
  member_actions: custom_member.map { |a| a[:action] },
285
- collection_actions: custom_collection.map { |a| a[:action] })
299
+ collection_actions: custom_collection.map { |a| a[:action] },
300
+ layout: layout)
286
301
 
287
302
  route_defaults = (options[:defaults] || {}).merge(
288
303
  _layered_resource_route_key: as_base
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Resource
3
- VERSION = "0.1.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-resource-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai
@@ -29,20 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.25'
33
- - - ">="
34
- - !ruby/object:Gem::Version
35
- version: 0.25.1
32
+ version: '0.27'
36
33
  type: :runtime
37
34
  prerelease: false
38
35
  version_requirements: !ruby/object:Gem::Requirement
39
36
  requirements:
40
37
  - - "~>"
41
38
  - !ruby/object:Gem::Version
42
- version: '0.25'
43
- - - ">="
44
- - !ruby/object:Gem::Version
45
- version: 0.25.1
39
+ version: '0.27'
46
40
  - !ruby/object:Gem::Dependency
47
41
  name: pagy
48
42
  requirement: !ruby/object:Gem::Requirement
@@ -223,6 +217,7 @@ files:
223
217
  - app/controllers/layered/resource/internal/columns.rb
224
218
  - app/controllers/layered/resource/internal/routing.rb
225
219
  - app/controllers/layered/resource/resources_controller.rb
220
+ - app/helpers/layered/resource/breadcrumbs_helper.rb
226
221
  - app/helpers/layered/resource/filters_helper.rb
227
222
  - app/views/layered/resource/columns/_badge.html.erb
228
223
  - app/views/layered/resource/columns/_boolean.html.erb
@@ -230,6 +225,7 @@ files:
230
225
  - app/views/layered/resource/columns/_text.html.erb
231
226
  - app/views/layered/resource/resources/_filter_control.html.erb
232
227
  - app/views/layered/resource/resources/_filters.html.erb
228
+ - app/views/layered/resource/resources/_search.html.erb
233
229
  - app/views/layered/resource/resources/edit.html.erb
234
230
  - app/views/layered/resource/resources/index.html.erb
235
231
  - app/views/layered/resource/resources/new.html.erb