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.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/Gemfile +23 -0
- data/LICENSE +21 -0
- data/README.md +511 -0
- data/RELEASING.md +44 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/crud_components.css +35 -0
- data/app/views/crud_components/_action_button.html.erb +11 -0
- data/app/views/crud_components/_actions.html.erb +12 -0
- data/app/views/crud_components/_column_header.html.erb +24 -0
- data/app/views/crud_components/_column_picker.html.erb +66 -0
- data/app/views/crud_components/_filter.html.erb +34 -0
- data/app/views/crud_components/_form.html.erb +30 -0
- data/app/views/crud_components/_pager.html.erb +41 -0
- data/app/views/crud_components/_record.html.erb +15 -0
- data/app/views/crud_components/_row.html.erb +26 -0
- data/app/views/crud_components/_selection_action.html.erb +14 -0
- data/app/views/crud_components/_sort_link.html.erb +17 -0
- data/app/views/crud_components/_toolbar.html.erb +50 -0
- data/app/views/crud_components/fields/_asciidoc.html.erb +8 -0
- data/app/views/crud_components/fields/_association.html.erb +13 -0
- data/app/views/crud_components/fields/_association_list.html.erb +24 -0
- data/app/views/crud_components/fields/_attachment.html.erb +16 -0
- data/app/views/crud_components/fields/_attachment_thumb.html.erb +17 -0
- data/app/views/crud_components/fields/_boolean.html.erb +13 -0
- data/app/views/crud_components/fields/_date.html.erb +6 -0
- data/app/views/crud_components/fields/_datetime.html.erb +6 -0
- data/app/views/crud_components/fields/_email.html.erb +7 -0
- data/app/views/crud_components/fields/_enum.html.erb +14 -0
- data/app/views/crud_components/fields/_json.html.erb +10 -0
- data/app/views/crud_components/fields/_markdown.html.erb +9 -0
- data/app/views/crud_components/fields/_number.html.erb +8 -0
- data/app/views/crud_components/fields/_string.html.erb +8 -0
- data/app/views/crud_components/fields/_text.html.erb +9 -0
- data/app/views/crud_components/fields/_url.html.erb +11 -0
- data/app/views/crud_components/filters/_boolean.html.erb +12 -0
- data/app/views/crud_components/filters/_date_range.html.erb +11 -0
- data/app/views/crud_components/filters/_number_range.html.erb +13 -0
- data/app/views/crud_components/filters/_select.html.erb +8 -0
- data/app/views/crud_components/filters/_text.html.erb +5 -0
- data/app/views/crud_components/form_fields/_belongs_to.html.erb +3 -0
- data/app/views/crud_components/form_fields/_boolean.html.erb +12 -0
- data/app/views/crud_components/form_fields/_date.html.erb +2 -0
- data/app/views/crud_components/form_fields/_datetime.html.erb +2 -0
- data/app/views/crud_components/form_fields/_enum.html.erb +8 -0
- data/app/views/crud_components/form_fields/_file.html.erb +47 -0
- data/app/views/crud_components/form_fields/_habtm.html.erb +5 -0
- data/app/views/crud_components/form_fields/_number.html.erb +2 -0
- data/app/views/crud_components/form_fields/_string.html.erb +3 -0
- data/app/views/crud_components/form_fields/_text.html.erb +2 -0
- data/app/views/crud_components/layouts/_table.html.erb +143 -0
- data/config/locales/crud_components.de.yml +39 -0
- data/config/locales/crud_components.en.yml +40 -0
- data/crud_components.gemspec +48 -0
- data/docs/extending.md +308 -0
- data/docs/fields.md +442 -0
- data/docs/forms.md +253 -0
- data/docs/performance.md +90 -0
- data/docs/security.md +139 -0
- data/docs/views.md +405 -0
- data/lib/crud_components/action.rb +85 -0
- data/lib/crud_components/builder.rb +246 -0
- data/lib/crud_components/config.rb +128 -0
- data/lib/crud_components/dynamic_column.rb +68 -0
- data/lib/crud_components/engine.rb +25 -0
- data/lib/crud_components/errors.rb +9 -0
- data/lib/crud_components/fields/attachment_field.rb +22 -0
- data/lib/crud_components/fields/base.rb +260 -0
- data/lib/crud_components/fields/belongs_to_field.rb +91 -0
- data/lib/crud_components/fields/boolean_field.rb +31 -0
- data/lib/crud_components/fields/computed_field.rb +34 -0
- data/lib/crud_components/fields/date_field.rb +51 -0
- data/lib/crud_components/fields/dynamic_field.rb +44 -0
- data/lib/crud_components/fields/enum_field.rb +40 -0
- data/lib/crud_components/fields/has_many_field.rb +50 -0
- data/lib/crud_components/fields/json_field.rb +10 -0
- data/lib/crud_components/fields/numeric_field.rb +31 -0
- data/lib/crud_components/fields/path_field.rb +327 -0
- data/lib/crud_components/fields/string_field.rb +41 -0
- data/lib/crud_components/fields/text_field.rb +9 -0
- data/lib/crud_components/fieldset.rb +38 -0
- data/lib/crud_components/helpers.rb +259 -0
- data/lib/crud_components/like_spec.rb +113 -0
- data/lib/crud_components/markup.rb +36 -0
- data/lib/crud_components/model.rb +33 -0
- data/lib/crud_components/permission_context.rb +62 -0
- data/lib/crud_components/presenters/actions.rb +51 -0
- data/lib/crud_components/presenters/base.rb +95 -0
- data/lib/crud_components/presenters/cell_context.rb +28 -0
- data/lib/crud_components/presenters/cells.rb +160 -0
- data/lib/crud_components/presenters/collection.rb +498 -0
- data/lib/crud_components/presenters/column_selection.rb +91 -0
- data/lib/crud_components/presenters/filter.rb +38 -0
- data/lib/crud_components/presenters/form.rb +57 -0
- data/lib/crud_components/presenters/record.rb +57 -0
- data/lib/crud_components/query.rb +110 -0
- data/lib/crud_components/route_resolver.rb +123 -0
- data/lib/crud_components/structure.rb +343 -0
- data/lib/crud_components/version.rb +3 -0
- data/lib/crud_components/where_like.rb +13 -0
- data/lib/crud_components.rb +160 -0
- data/lib/generators/crud_components/install/install_generator.rb +43 -0
- data/lib/generators/crud_components/install/templates/crud_columns_controller.js +76 -0
- data/lib/generators/crud_components/install/templates/crud_filter_controller.js +32 -0
- data/lib/generators/crud_components/install/templates/crud_multiselect_controller.js +70 -0
- data/lib/generators/crud_components/install/templates/crud_select_controller.js +35 -0
- data/lib/generators/crud_components/install/templates/initializer.rb +56 -0
- data/lib/generators/crud_components/views/views_generator.rb +14 -0
- 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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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).
|