crud_components 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 (110) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +23 -0
  4. data/LICENSE +21 -0
  5. data/README.md +511 -0
  6. data/RELEASING.md +44 -0
  7. data/Rakefile +12 -0
  8. data/app/assets/stylesheets/crud_components.css +35 -0
  9. data/app/views/crud_components/_action_button.html.erb +11 -0
  10. data/app/views/crud_components/_actions.html.erb +12 -0
  11. data/app/views/crud_components/_column_header.html.erb +24 -0
  12. data/app/views/crud_components/_column_picker.html.erb +66 -0
  13. data/app/views/crud_components/_filter.html.erb +34 -0
  14. data/app/views/crud_components/_form.html.erb +30 -0
  15. data/app/views/crud_components/_pager.html.erb +41 -0
  16. data/app/views/crud_components/_record.html.erb +15 -0
  17. data/app/views/crud_components/_row.html.erb +26 -0
  18. data/app/views/crud_components/_selection_action.html.erb +14 -0
  19. data/app/views/crud_components/_sort_link.html.erb +17 -0
  20. data/app/views/crud_components/_toolbar.html.erb +50 -0
  21. data/app/views/crud_components/fields/_asciidoc.html.erb +8 -0
  22. data/app/views/crud_components/fields/_association.html.erb +13 -0
  23. data/app/views/crud_components/fields/_association_list.html.erb +24 -0
  24. data/app/views/crud_components/fields/_attachment.html.erb +16 -0
  25. data/app/views/crud_components/fields/_attachment_thumb.html.erb +17 -0
  26. data/app/views/crud_components/fields/_boolean.html.erb +13 -0
  27. data/app/views/crud_components/fields/_date.html.erb +6 -0
  28. data/app/views/crud_components/fields/_datetime.html.erb +6 -0
  29. data/app/views/crud_components/fields/_email.html.erb +7 -0
  30. data/app/views/crud_components/fields/_enum.html.erb +14 -0
  31. data/app/views/crud_components/fields/_json.html.erb +10 -0
  32. data/app/views/crud_components/fields/_markdown.html.erb +9 -0
  33. data/app/views/crud_components/fields/_number.html.erb +8 -0
  34. data/app/views/crud_components/fields/_string.html.erb +8 -0
  35. data/app/views/crud_components/fields/_text.html.erb +9 -0
  36. data/app/views/crud_components/fields/_url.html.erb +11 -0
  37. data/app/views/crud_components/filters/_boolean.html.erb +12 -0
  38. data/app/views/crud_components/filters/_date_range.html.erb +11 -0
  39. data/app/views/crud_components/filters/_number_range.html.erb +13 -0
  40. data/app/views/crud_components/filters/_select.html.erb +8 -0
  41. data/app/views/crud_components/filters/_text.html.erb +5 -0
  42. data/app/views/crud_components/form_fields/_belongs_to.html.erb +3 -0
  43. data/app/views/crud_components/form_fields/_boolean.html.erb +12 -0
  44. data/app/views/crud_components/form_fields/_date.html.erb +2 -0
  45. data/app/views/crud_components/form_fields/_datetime.html.erb +2 -0
  46. data/app/views/crud_components/form_fields/_enum.html.erb +8 -0
  47. data/app/views/crud_components/form_fields/_file.html.erb +47 -0
  48. data/app/views/crud_components/form_fields/_habtm.html.erb +5 -0
  49. data/app/views/crud_components/form_fields/_number.html.erb +2 -0
  50. data/app/views/crud_components/form_fields/_string.html.erb +3 -0
  51. data/app/views/crud_components/form_fields/_text.html.erb +2 -0
  52. data/app/views/crud_components/layouts/_table.html.erb +143 -0
  53. data/config/locales/crud_components.de.yml +39 -0
  54. data/config/locales/crud_components.en.yml +40 -0
  55. data/crud_components.gemspec +48 -0
  56. data/docs/extending.md +308 -0
  57. data/docs/fields.md +442 -0
  58. data/docs/forms.md +253 -0
  59. data/docs/performance.md +90 -0
  60. data/docs/security.md +139 -0
  61. data/docs/views.md +405 -0
  62. data/lib/crud_components/action.rb +85 -0
  63. data/lib/crud_components/builder.rb +246 -0
  64. data/lib/crud_components/config.rb +128 -0
  65. data/lib/crud_components/dynamic_column.rb +68 -0
  66. data/lib/crud_components/engine.rb +25 -0
  67. data/lib/crud_components/errors.rb +9 -0
  68. data/lib/crud_components/fields/attachment_field.rb +22 -0
  69. data/lib/crud_components/fields/base.rb +260 -0
  70. data/lib/crud_components/fields/belongs_to_field.rb +91 -0
  71. data/lib/crud_components/fields/boolean_field.rb +31 -0
  72. data/lib/crud_components/fields/computed_field.rb +34 -0
  73. data/lib/crud_components/fields/date_field.rb +51 -0
  74. data/lib/crud_components/fields/dynamic_field.rb +44 -0
  75. data/lib/crud_components/fields/enum_field.rb +40 -0
  76. data/lib/crud_components/fields/has_many_field.rb +50 -0
  77. data/lib/crud_components/fields/json_field.rb +10 -0
  78. data/lib/crud_components/fields/numeric_field.rb +31 -0
  79. data/lib/crud_components/fields/path_field.rb +327 -0
  80. data/lib/crud_components/fields/string_field.rb +41 -0
  81. data/lib/crud_components/fields/text_field.rb +9 -0
  82. data/lib/crud_components/fieldset.rb +38 -0
  83. data/lib/crud_components/helpers.rb +259 -0
  84. data/lib/crud_components/like_spec.rb +113 -0
  85. data/lib/crud_components/markup.rb +36 -0
  86. data/lib/crud_components/model.rb +33 -0
  87. data/lib/crud_components/permission_context.rb +62 -0
  88. data/lib/crud_components/presenters/actions.rb +51 -0
  89. data/lib/crud_components/presenters/base.rb +95 -0
  90. data/lib/crud_components/presenters/cell_context.rb +28 -0
  91. data/lib/crud_components/presenters/cells.rb +160 -0
  92. data/lib/crud_components/presenters/collection.rb +498 -0
  93. data/lib/crud_components/presenters/column_selection.rb +91 -0
  94. data/lib/crud_components/presenters/filter.rb +38 -0
  95. data/lib/crud_components/presenters/form.rb +57 -0
  96. data/lib/crud_components/presenters/record.rb +57 -0
  97. data/lib/crud_components/query.rb +110 -0
  98. data/lib/crud_components/route_resolver.rb +123 -0
  99. data/lib/crud_components/structure.rb +343 -0
  100. data/lib/crud_components/version.rb +3 -0
  101. data/lib/crud_components/where_like.rb +13 -0
  102. data/lib/crud_components.rb +160 -0
  103. data/lib/generators/crud_components/install/install_generator.rb +43 -0
  104. data/lib/generators/crud_components/install/templates/crud_columns_controller.js +76 -0
  105. data/lib/generators/crud_components/install/templates/crud_filter_controller.js +32 -0
  106. data/lib/generators/crud_components/install/templates/crud_multiselect_controller.js +70 -0
  107. data/lib/generators/crud_components/install/templates/crud_select_controller.js +35 -0
  108. data/lib/generators/crud_components/install/templates/initializer.rb +56 -0
  109. data/lib/generators/crud_components/views/views_generator.rb +14 -0
  110. metadata +209 -0
data/docs/fields.md ADDED
@@ -0,0 +1,442 @@
1
+ # Fields & rendering
2
+
3
+ > See it running: **[live demo](https://crud-components.zelenin.de)** — every field type and renderer has a page.
4
+
5
+ Everything in a `crud_structure` is, ultimately, about fields: what they are, how they
6
+ render, how they filter and sort. This is the reference for that. For the one-page
7
+ summary read the [combination table](../README.md#the-combination-table) in the README
8
+ first; this doc is the per-flavor depth behind it.
9
+
10
+ Running example: the bookstore from the [README](../README.md#the-running-example).
11
+
12
+ ## You rarely declare fields
13
+
14
+ ![A zero-config model (not even `include CrudComponents::Model`): the table, columns, search, filters and the auto-derived habtm "Books" column are all inferred from the schema](screenshots/zero-config-table.png)
15
+
16
+ All columns, enums and associations are already fields — derived from what Rails knows.
17
+ Declare an `attribute` only to *improve* one:
18
+
19
+ ```ruby
20
+ attribute :price, as: :number, unit: '€', digits: 2 # renderer + options
21
+ attribute :internal_notes, if: :manage # column-level permission
22
+ attribute :token, filter: false # opt a derived field out of filtering
23
+ ```
24
+
25
+ `if:` gates a column's visibility on a `can?` symbol or a lambda — hidden everywhere
26
+ (table, record, forms, `?q=`) for users who fail the check. See
27
+ [permissions](security.md#permissions).
28
+
29
+ `attributes` (plural) applies shared options to several fields at once:
30
+
31
+ ```ruby
32
+ attributes :participants, :owner, if: :manage
33
+ ```
34
+
35
+ The field universe is always *all* derived columns/associations plus declared computed
36
+ fields. `attribute` never adds or removes a column from a table — that is exclusively
37
+ the job of [fieldsets](views.md#fieldsets).
38
+
39
+ ## Renderers
40
+
41
+ ![A record view (crud_record) as a definition list: each value rendered type-aware — currency, a boolean check, a genre badge, pretty-printed JSON, and association links](screenshots/record.png)
42
+
43
+ Every field has a derived renderer. Name one explicitly with `as:` to override it, and
44
+ pass renderer options inline:
45
+
46
+ ```ruby
47
+ attribute :price, as: :number, unit: '€', digits: 2
48
+ attribute :blurb, as: :markdown
49
+ attribute :rating, as: :stars # a custom renderer, see Extending
50
+ ```
51
+
52
+ `as:` is the field's renderer ("present this as a …"), reading like simple_form's
53
+ `f.input :price, as: :string`. (It's distinct from `crud_collection`'s `layout:`, which
54
+ picks the whole-collection arrangement — field renderer vs. component layout.)
55
+
56
+ For one-off markup, a block that takes the record renders the cell inline — no named
57
+ renderer needed:
58
+
59
+ ```ruby
60
+ attribute(:badge) { |record| tag.span(record.status, class: 'badge') }
61
+ ```
62
+
63
+ The block is the inline custom-markup form; the `render` facet (below) is the same thing
64
+ inside a facet block. See [Custom markup](#custom-markup) for how blocks run.
65
+
66
+ Built-in renderers:
67
+
68
+ * `:text` — truncates in a collection, keeps line breaks on a record page.
69
+ * `:number` — `unit:` (suffix) and `digits:` (decimal places).
70
+ * `:date` — localized.
71
+ * `:datetime` — localized.
72
+ * `:boolean` — ✓/✗ icon; nil shows `—`.
73
+ * `:enum` — i18n'd badge; nil shows `—`.
74
+ * `:association` — nil-safe link via the target's `label`.
75
+ * `:association_list` — "a, b +n more" links.
76
+ * `:attachment` — supports `has_one_attached` / `has_many_attached`: each file is drawn by content type — an image inline, a previewable file (e.g. PDF) as a preview, anything else as an icon + filename download link. Sized by surface; a has_many set renders as a row.
77
+ * `:json` — pretty-printed `<pre>`, syntax-highlighted when [rouge](https://github.com/rouge-ruby/rouge) is present (optional — no rouge, no colors, no error).
78
+ * `:markdown` — needs one of [commonmarker](https://github.com/gjtorikian/commonmarker), [redcarpet](https://github.com/vmg/redcarpet) or [kramdown](https://github.com/gettalong/kramdown) in your bundle; **raises at boot** if none is present.
79
+ * `:asciidoc` — needs [asciidoctor](https://github.com/asciidoctor/asciidoctor); **raises at boot** if absent.
80
+ * `:email` — a `mailto:` link.
81
+ * `:url` — an http(s) value as a link (a non-URL stays plain text).
82
+
83
+ **Smart links by name.** A string column named `email` (or `*_email`) renders as `:email`,
84
+ and one named `url`, `website`, `link` or `homepage` renders as `:url`, with no
85
+ configuration. The trigger is the *column name*, never the value — a `description` that
86
+ happens to contain a URL is left alone — so it stays predictable and safe. `as:` overrides
87
+ either way. [Path columns](#path-columns) apply the same rule to their target name, so
88
+ `authors.email` shows a list of `mailto:` links.
89
+
90
+ **Renderers are surface-aware.** Each receives `surface:` (`:collection` or `:record`)
91
+ and adapts: `:text` truncates in a collection but keeps line breaks on a record,
92
+ `:attachment` shrinks to a thumbnail in table cells, `:json` truncates its `<pre>` in
93
+ collections.
94
+
95
+ To add your own renderer, see [Extending](extending.md#add-a-field-renderer).
96
+
97
+ ## Computed fields
98
+
99
+ A name that is not a column, enum or association falls back to a **public model
100
+ method**, rendered by its value type — no ceremony:
101
+
102
+ ```ruby
103
+ def shop_margin = price - purchase_price
104
+ # `shop_margin` is already a usable, display-only field
105
+ ```
106
+
107
+ A name that is *nothing* (no column/enum/association/method) and has no `render` facet
108
+ raises at boot, telling you to add one.
109
+
110
+ ### Custom markup
111
+
112
+ For custom HTML, a block that takes the record is the shortest form:
113
+
114
+ ```ruby
115
+ attribute(:cover) { |book| image_tag book.cover.variant(:large), class: 'rounded' }
116
+ ```
117
+
118
+ Blocks are **stored** in the model but **executed in the view context at render time** —
119
+ which is why `image_tag`, `link_to`, route helpers, `t` and your app's own helpers all
120
+ work inside them even though the block lives in a model file.
121
+
122
+ > **The view-context rule.** Presentation blocks (`render`, `label`, action path
123
+ > blocks) are `instance_exec`'d in the view with the record as the sole argument. Inside
124
+ > such a block `self` is the view — so call model methods *on the record argument*, not
125
+ > on `self`. Local variables captured by the closure are available; instance variables
126
+ > of the surrounding class body are not.
127
+
128
+ Customizing how a field renders costs nothing else: a string column with a custom
129
+ `render` block **keeps** its derived filter and sort. Overrides are per facet.
130
+
131
+ ## Facets
132
+
133
+ When a field needs more than rendering, its facets live together in one block:
134
+
135
+ ```ruby
136
+ attribute :author_names do
137
+ render { |book| book.authors.map(&:name).to_sentence }
138
+ filter authors: :name
139
+ sort { |scope, dir| scope.left_joins(:authors).order('authors.name' => dir) }
140
+ end
141
+ ```
142
+
143
+ | Facet | Takes | Effect |
144
+ | ----------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ |
145
+ | `render { \|record\| … }` | a block (markup) | overrides the rendered cell. Named renderers are `as:`'s job; this facet is block-only |
146
+ | `filter spec` / `filter { \|scope, value\| … }` | a positional spec or block | overrides/adds the filter. `filter false` switches a derived filter off |
147
+ | `sort :column` / `sort { \|scope, dir\| … }` | an own-column symbol or block | overrides/adds the sort (`dir` is guaranteed `:asc`/`:desc`). `sort false` switches it off |
148
+
149
+ Why filter/sort are opt-in for computed fields: **filtering and sorting run in SQL**, so
150
+ they stay correct on large tables and under pagination. A Ruby-computed value has no SQL
151
+ meaning until a facet tells the gem how to express it.
152
+
153
+ > **Query-block contract.** `filter`/`sort`/`search_in` blocks receive `(scope, value)`
154
+ > (or `(scope, dir)` for sort) and return a relation. There is no view context at query
155
+ > time; the scope arrives extended with `where_like` (below).
156
+
157
+ ## Dynamic columns
158
+
159
+ Some columns aren't part of the model at all — user-defined properties kept in a
160
+ separate store (a definitions + values pair, a JSONB blob, a remote API). They are
161
+ per-account, per-request data, so they don't belong in the model's `crud_structure`
162
+ (which is built once per class and shared by every request). Instead you build a
163
+ `CrudComponents::DynamicColumn` per request and pass the set to `crud_collection` via
164
+ `extra_columns:` — the model stays untouched, the column rides alongside the declared
165
+ ones:
166
+
167
+ ```ruby
168
+ # however your custom properties are stored, you adapt them to columns:
169
+ columns = current_account.custom_properties.map do |prop|
170
+ CrudComponents::DynamicColumn.new(
171
+ prop.key, # the column name (→ ?sort=, ?cols=)
172
+ label: prop.label, as: prop.renderer, # any built-in renderer: :number, :date, …
173
+ if: -> { can?(:read, prop) }, # same gate as a field's if:
174
+ preload: ->(records) { # one batch-load per page — no N+1
175
+ PropertyValue.where(definition: prop, subject: records).index_by(&:subject_id)
176
+ }
177
+ ) { |record, loaded| loaded[record.id]&.value } # the value resolver
178
+ end
179
+
180
+ crud_collection @books, extra_columns: columns
181
+ ```
182
+
183
+ The block is the **value resolver**: `|record|` or `|record, loaded|`, where `loaded`
184
+ is whatever `preload:` returned. It returns a plain value that the `as:` renderer (or,
185
+ with no `as:`, the value's type, exactly like a [computed field](#computed-fields))
186
+ displays. `preload:` runs once over the page's rows so a whole table costs one fetch,
187
+ not one per row.
188
+
189
+ A dynamic column is **display-only** until you give it the query facets — the same
190
+ `filter:`/`sort:` blocks the DSL takes, supplied as keyword arguments. Give them only
191
+ when the data is reachable in SQL; without them the column never reaches the query
192
+ layer, which keeps the [whitelist](security.md) tight:
193
+
194
+ ```ruby
195
+ CrudComponents::DynamicColumn.new(:priority, as: :number,
196
+ preload: ->(records) { … },
197
+ filter: ->(scope, value) {
198
+ # `where_like` escapes the user's %/_ and builds the ILIKE for you — never
199
+ # hand-write `where("value LIKE ?", "%#{value}%")`. The block's own `scope`
200
+ # already carries `#where_like`; for a subquery on another model use the
201
+ # module function on that relation:
202
+ matches = CrudComponents.where_like(PropertyValue.where(definition: prop), :value, value)
203
+ scope.where(id: matches.select(:subject_id))
204
+ },
205
+ sort: ->(scope, dir) { scope.order(Arel.sql("(#{subquery_for(prop)}) #{dir}")) }
206
+ ) { |record, loaded| loaded[record.id]&.value }
207
+ ```
208
+
209
+ `if:` follows the same rules as a declared field's: a denied column is absent from the
210
+ table, the filter row, sorting and `?cols=` — everywhere. See the column picker in
211
+ [views.md](views.md#column-picker) for letting users choose which of these they see, and
212
+ the `/custom_fields` page in `test/dummy` for a full worked example (string, number,
213
+ boolean and date flavors, all filtering and sorting).
214
+
215
+ `crud_record` takes `extra_columns:` too, so the same user-defined properties show as extra
216
+ rows on a detail view — batch-loaded on the single record.
217
+
218
+ ### Custom headers and column actions
219
+
220
+ A dynamic column often *is* a domain object — a mail, a resource, a property — so its
221
+ header naturally wants a **link** to that object and its own **bulk actions** ("Send to
222
+ selected", "Activate for all"). Two keyword arguments put those right in the `<th>`:
223
+
224
+ ![A collection table whose Shelf, Weight, Signed and Acquired columns each have a linked header and a "Tag selected" bulk button in the header, with a row-selection checkbox column — the per-column actions act on the ticked rows](screenshots/column-headers.png)
225
+
226
+ ```ruby
227
+ CrudComponents::DynamicColumn.new(:mail_42,
228
+ label: 'Welcome mail',
229
+ header: -> { link_to mail.name, mail }, # an HTML-safe String, or a view-context block
230
+ header_actions: [ # the same Action API as row/collection actions
231
+ CrudComponents::Action.new(:send_selected, on: :selection, icon: 'send', method: :post) { send_path(mail) },
232
+ CrudComponents::Action.new(:send_all, on: :collection, icon: 'send-fill', method: :post) { send_all_path(mail) }
233
+ ],
234
+ preload: ->(records) { … }) { |record, loaded| loaded[record.id] }
235
+ ```
236
+
237
+ * **`header:`** replaces the column's plain `human_name` in the header. A **String** is
238
+ rendered as-is — mark it `html_safe` if it carries markup. A **block** is `instance_exec`ed
239
+ in the view, so it may call `link_to` and any URL helper. When you set a header the column's
240
+ sort link is dropped (a column with its own header is usually display-only anyway); omit
241
+ `header:` to keep the default `human_name` + sort behavior. With `header_actions:` but no
242
+ `header:`, the normal title (sortable link or plain name) is kept and the actions appended.
243
+ * **`header_actions:`** is a list of plain `CrudComponents::Action`s, rendered in the header
244
+ with the same icons/titles/`confirm:` as everywhere else. Each action's path block closes
245
+ over the column's object (`mail` above). The action's **`on:`** decides how it acts and renders:
246
+ * **`on: :selection`** — acts on the **ticked rows** × this column's object. It submits the
247
+ same shared select-form the toolbar's bulk actions use, so the checked `selected[]` slugs
248
+ ride along to your endpoint. Declaring one **makes the collection selectable** (the checkbox
249
+ column appears) automatically — no extra wiring. Resolve them server-side with
250
+ `CrudComponents.selected(scope, params)`, exactly like a toolbar selection action.
251
+ * **`on: :collection`** (or `:row`) — a plain, selection-independent link/button (a non-GET
252
+ method renders as a CSRF-safe `button_to` form). Use it for "do this for *all* rows of this
253
+ column", where the selection is irrelevant.
254
+
255
+ Permissions here can only **show or hide** a header action (via `if:` — which also closes over
256
+ `mail`, e.g. `if: -> { can?(:send, mail) }`); *which* rows a `:selection` action ultimately
257
+ touches is chosen in the browser, so enforce per-record authorization in your controller when
258
+ the request arrives.
259
+
260
+ These are not specific to `DynamicColumn` — a declared `attribute :status, header_actions: […]`
261
+ takes the same options. Everything works in the non-grouped and grouped (`group_by:`) layouts,
262
+ and plays with the column picker (a hidden column simply renders no header). The
263
+ `/column_headers` page in `test/dummy` is a full worked example. This is what lets a
264
+ participants × mails / × resources **matrix** live entirely in `crud_collection` — one
265
+ `DynamicColumn` per mail/resource, its controls in its own header — instead of a hand-built
266
+ controls strip above the table.
267
+
268
+ ## Path columns
269
+
270
+ A field name with a **dot** reaches through associations: `publisher.name`,
271
+ `publisher.founded_on`, `authors.email`. The leading segments are associations on the
272
+ model; the last is an attribute (or method) on the target. Use them anywhere a field name
273
+ goes — a fieldset, `picked_columns:`, `?cols=` — so they show up in the [column picker](views.md#column-picker)
274
+ like any other column. No block needed; it's the declarative shortcut for what you'd
275
+ otherwise write as a computed field with a `render` + `filter` + `sort`:
276
+
277
+ ```ruby
278
+ fieldset :index, %i[title publisher.name authors.email]
279
+ ```
280
+
281
+ - A **single-valued** path (belongs_to / has_one) **delegates to the target model's own
282
+ field** for that attribute — `publisher.founded_on` renders, filters and sorts exactly
283
+ like Publisher's `founded_on` does: a date cell, a **date-range** filter, an ORDER BY the
284
+ date. `publisher.price` keeps the target's `unit:`/`digits:`; a `publisher.status` enum gets
285
+ the target's **select** filter and humanized badge. The path needn't repeat any of it —
286
+ declare it once on the target model, reuse it through every association.
287
+ - When the leaf attribute **is the target's label field** (`publisher.name`), the cell renders
288
+ a **link to that record** — the model's [icon](#identity-label-identify_by-search_in-icon)
289
+ then a link to its show page — so a path column doubles as a jump-to-the-object.
290
+ - A **list** path (has_many / habtm) renders the values joined — `authors.email` shows
291
+ every author's email (each linkified, since `email` is a smart-rendered name) — and is
292
+ **filterable** (a contains-match through the join, via the [search spec](#the-search-spec))
293
+ but not sortable by default (no single value to order by; add a `sort` facet if you have a
294
+ meaningful aggregate).
295
+
296
+ The association is eager-loaded automatically, so a path column costs one query per page,
297
+ not one per row.
298
+
299
+ **Override > target field > default.** Anything the path inherits from the target field can
300
+ be overridden on the path column itself — `as:` (or a `render`/`filter`/`sort` facet) wins,
301
+ then the target field's behaviour, then the inferred default. So
302
+ `attribute(:"publisher.price", unit: '$')` re-bases just the unit; `attribute(:"publisher.name", as: :string)` opts the label column out of the link.
303
+
304
+ **Two limits.** The chain may be at most `config.max_path_depth` associations deep (default
305
+ 3 — a guard rail against runaway joins; raise it if you need deeper). And it may cross **at
306
+ most one to-many** association: chain belongs_to/has_one freely, but a second has_many/habtm
307
+ would fan the list out into a meaningless list-of-lists. So `authors.publisher.name`
308
+ (habtm → one) is fine; `authors.books.title` (habtm → many) raises at resolve time. Both
309
+ limits report a clear `DefinitionError`.
310
+
311
+ `if:`, `label:` and facet overrides work as on any field — declare the path with
312
+ `attribute(:"authors.email", if: :manage)` to gate it, or give it a block to override how it
313
+ renders, filters or sorts.
314
+
315
+ ## The search spec
316
+
317
+ One declarative mini-language for "case-insensitive contains across these columns,
318
+ joining as needed" — shared by `filter` (passed positionally) and `search_in`:
319
+
320
+ ```ruby
321
+ filter :title # own column
322
+ filter :title, :subtitle # several own columns, OR-combined
323
+ filter authors: %i[name email] # join, explicit columns
324
+ filter user: { address: %i[street town] } # nested joins, explicit columns
325
+ filter :publisher # join, DELEGATE to Publisher's search_in
326
+ filter :title, { authors: :name } # mixed
327
+ ```
328
+
329
+ The **delegation form** — an association name *without* columns — means "search it the
330
+ way that model defines being searched" (its `search_in`). It is the idiomatic style and
331
+ stays correct as the target model's definition evolves.
332
+
333
+ The gem turns a spec into `left_joins` plus parameterized, wildcard-escaped `ILIKE`
334
+ (via `sanitize_sql_like` with an explicit `\` escape char, so `%`, `_` and `\` are all
335
+ literal). A spec contains only column/association names you wrote — **no SQL strings**,
336
+ nothing to sanitize. A joined match is `DISTINCT`; an own-column spec is not (no join to
337
+ dedupe). Delegation cycles are guarded (max 5 delegation hops) and raise rather than
338
+ stack-overflow.
339
+
340
+ ### The escape hatch
341
+
342
+ A block is the escape hatch for genuinely custom logic; the scope it receives carries
343
+ the same machinery, so you keep the safe pit of success without `sanitize_sql_like`:
344
+
345
+ ```ruby
346
+ filter do |scope, value|
347
+ scope.where(active: true).where_like({ authors: :name }, value)
348
+ end
349
+ ```
350
+
351
+ `where_like(spec, value)` is available on every scope handed to a filter/search block.
352
+ Raw SQL in a block is possible — and then explicitly your responsibility.
353
+
354
+ ## Identity: `label`, `identify_by`, `search_in`, `icon`
355
+
356
+ ```ruby
357
+ label :title # method or block; default: name → title → first string column → "Book #42"
358
+ identify_by :slug # default: :id
359
+ search_in :title, :subtitle, :publisher # default: own string/text columns
360
+ icon 'book' # default: guessed from the model name (config.model_icons), else none
361
+ ```
362
+
363
+ - **`label`** — the record's display name: links, select options, record headings.
364
+ Block form: `label { |book| "#{book.title} (#{book.published_on&.year})" }`. With no
365
+ string column at all it falls back to `"Book #42"` (`model_name.human` + ` #` + id).
366
+ When the label reaches into associations, declare them with `preload:` so they're
367
+ eager-loaded wherever this model is shown — `label :full_title, preload: %i[publisher]`
368
+ ([Performance](performance.md#eager-loading-render-dependencies)).
369
+ - **`identify_by`** — the column URL params use to identify a record of this model. With
370
+ `identify_by :slug`, a filter URL reads `?publisher=tor-books` and resolves via
371
+ `Publisher.where(slug: …)`.
372
+ - **`search_in`** — the model's text identity: what `?q=` searches, what the belongs_to
373
+ text-filter fallback matches, and what delegated specs (`filter :publisher`) expand to.
374
+ - **`icon`** — a Bootstrap-icon name (no `bi-` prefix — paired with `config.css.icon_prefix`,
375
+ swap the whole library there) that badges the model wherever it appears: column-picker
376
+ groups, association links, path-column cells. Undeclared, it's guessed from the model name
377
+ via `config.model_icons` (`User → person`, `Publisher → building`, …); an unmapped model
378
+ with no declaration shows no icon (set `config.model_fallback_icon` to badge every model).
379
+ Reach it in your own views with `crud_model_icon(record_or_class)` (the `<i>` tag) or
380
+ `crud_model_icon_name(…)` (just the name).
381
+
382
+ ### Identity composes through associations
383
+
384
+ These three are not just for the model's own pages — they define how **other** models
385
+ render, link and filter it through their associations:
386
+
387
+ ```ruby
388
+ class Publisher < ApplicationRecord
389
+ include CrudComponents::Model
390
+ crud_structure do
391
+ label :name
392
+ identify_by :slug
393
+ search_in :name
394
+ end
395
+ end
396
+ ```
397
+
398
+ Every model with a `belongs_to :publisher` now gets, for free: a column rendering the
399
+ publisher's name as a link (or a muted placeholder when nil), a filter valued by slug,
400
+ and — wherever a spec says `:publisher` — text search through the publisher's
401
+ name. Declared once, where Publisher lives; correct everywhere it appears. This is the
402
+ gem's central idea: per-model declarations composed over the association graph.
403
+
404
+ ### Re-titling an association column
405
+
406
+ A `belongs_to`/`has_many` column links the associated record using the
407
+ **target's** `label`. To title it differently *for this column* — keeping the same
408
+ nil-safe link and route resolution — pass a `label:` callable that receives the record.
409
+ For example, a `Review`'s `book` column, re-titled to include the publisher:
410
+
411
+ ```ruby
412
+ attribute :book, label: ->(book) { "#{book.title} (#{book.publisher.name})" }
413
+ ```
414
+
415
+ The same `Book` reads as just `The Hobbit` on its own pages and in most columns, but here
416
+ it shows `The Hobbit (Tor Books)` — without dropping to a full `render` block that has to
417
+ rebuild the link by hand. (When the callable reaches into the target like this, pair it
418
+ with `preload: %i[publisher]` — [Performance](performance.md#eager-loading-render-dependencies).)
419
+
420
+ ## Field flavors in depth
421
+
422
+ | Flavor | Renderer | Filter | Sort | Notes |
423
+ | ------------------------- | -------------------------------------------- | -------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ |
424
+ | string column | text | `ILIKE %v%` (escaped) | yes | |
425
+ | text column | truncated / line-breaks on record | `ILIKE %v%` | yes | |
426
+ | numeric column | number (`as: :number` for `unit:`/`digits:`) | `_geq`/`_leq` range + `?f=v` exact | yes | non-finite (`NaN`/`Inf`) ignored |
427
+ | date / datetime | localized | from–to range + exact day | yes | datetime ranges whole-day-inclusive |
428
+ | boolean | ✓/✗ icon (nil `—`), click-to-filter | any/yes/no select | yes | accepts `t/f/1/0/yes/no/on/off`; nullable column adds a "not set" (IS NULL) choice |
429
+ | enum | i18n'd badge (nil `—`), click-to-filter | select of keys | yes | values validated against the enum; nullable column adds a "not set" (IS NULL) choice |
430
+ | json | `<pre>` (rouge if present) | — | — | not form-editable in v1 |
431
+ | Active Storage attachment | image / preview / icon by content type | — | — | form shows current; keep/add/remove via signed_ids |
432
+ | `belongs_to` | nil-safe link via target `label` | select (≤ `select_limit`) / text over target `search_in` | v2 | resolves by `identify_by` |
433
+ | `has_many` / habtm | "a, b +n more" links | opt-in `filter` facet | no | "+n more" links to nested/filtered index |
434
+ | public method | by value type | — | — | needs a facet to filter/sort |
435
+ | `render` block | block output | — | — | facets add filter/sort |
436
+
437
+ Click-to-filter: in a collection, an enum badge and a boolean icon link to set their own
438
+ column's filter (respecting the fieldset whitelist and `param_prefix`). The inline
439
+ filter row uses compact controls; the standalone `crud_filter` form uses full-size ones.
440
+
441
+ See also: [Views & fieldsets](views.md) · [Forms](forms.md) · [Security](security.md) ·
442
+ [Extending](extending.md).