crud_components 0.1.0 → 0.2.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +15 -11
  4. data/RELEASING.md +14 -9
  5. data/app/views/crud_components/_filter.html.erb +20 -0
  6. data/app/views/crud_components/filters/_date.html.erb +5 -0
  7. data/app/views/crud_components/filters/_number.html.erb +6 -0
  8. data/app/views/crud_components/filters/_presence.html.erb +13 -0
  9. data/config/locales/crud_components.de.yml +7 -0
  10. data/config/locales/crud_components.en.yml +7 -0
  11. data/crud_components.gemspec +1 -0
  12. data/docs/fields.md +25 -53
  13. data/docs/filtering.md +167 -0
  14. data/docs/performance.md +1 -1
  15. data/docs/security.md +9 -7
  16. data/docs/views.md +8 -2
  17. data/lib/crud_components/builder.rb +3 -3
  18. data/lib/crud_components/dynamic_column.rb +11 -3
  19. data/lib/crud_components/fields/attachment_field.rb +23 -1
  20. data/lib/crud_components/fields/base.rb +71 -11
  21. data/lib/crud_components/fields/belongs_to_field.rb +38 -11
  22. data/lib/crud_components/fields/boolean_field.rb +2 -2
  23. data/lib/crud_components/fields/date_field.rb +2 -2
  24. data/lib/crud_components/fields/enum_field.rb +4 -4
  25. data/lib/crud_components/fields/has_many_field.rb +22 -2
  26. data/lib/crud_components/fields/numeric_field.rb +2 -2
  27. data/lib/crud_components/fields/path_field.rb +6 -6
  28. data/lib/crud_components/fields/string_field.rb +3 -3
  29. data/lib/crud_components/helpers.rb +20 -6
  30. data/lib/crud_components/like_spec.rb +33 -34
  31. data/lib/crud_components/presenters/collection.rb +17 -8
  32. data/lib/crud_components/presenters/filter.rb +27 -3
  33. data/lib/crud_components/query.rb +45 -6
  34. data/lib/crud_components/structure.rb +12 -2
  35. data/lib/crud_components/typed_filter.rb +113 -0
  36. data/lib/crud_components/version.rb +1 -1
  37. data/lib/crud_components.rb +8 -0
  38. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 300e08f93a98c2af440d843de88c42796428591c842cf037a6969c76f0bbfce6
4
- data.tar.gz: 65b817f9e2663d64159142b157b6326887f52f5bf93e684294d1288cfb4c5693
3
+ metadata.gz: 3039812d78a4d8677f871959497a18eb9f2236627a84a0d046b72a520c0ba34c
4
+ data.tar.gz: 70b36787f0e28e2f51af0cfdf3361dc860fcdfb048d90365ddf4ebf128759372
5
5
  SHA512:
6
- metadata.gz: 1ad9372ab031db1343c6afccf5f2f8525e593e00e23da8ee3b9a868b2e11a0dbbb5971dea96d348d2689517de69ea0da1aa9f9e589fa64dc825767f66717c72d
7
- data.tar.gz: 4b5f31a8f4e4b77a3fcef9ca7a27ce31ee4e254c071c5710b1b9816e1d59d5412db40551ffba641b3e50a928386d91fcb4ead63e09ad0013d284d67dffbd0f55
6
+ metadata.gz: 7af65ee9f9aea26c3e6fc132fbe29ad3fbf2a3c8e931e209ee72dd7cb2a57cab670875a3c14fad3907ea316138a58d272729bfd6239d68be9cacf360b4e94fa9
7
+ data.tar.gz: 7a220eec077f9845b682c3a081e971d2a488a7d09a7bacfb6f514023fff92a3340000420d5bca17b648dfd424522c1f32a31f505eb81c2eb3331c2718fddbf87
data/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ Notable changes since [v0.1.0](https://github.com/itadventurer/crud_components/releases/tag/v0.1.0).
4
+ This project follows [semantic versioning](https://semver.org).
5
+
6
+ ## Unreleased
7
+
8
+ ## v0.2.1 — 2026-08-01
9
+
10
+ ### Changed
11
+
12
+ - Verified against **Rails 8.1** — the CI matrix now covers it alongside 7.1, 7.2 and 8.0. The gem's runtime API and its declared dependencies are unchanged; the rest is tooling (GitHub Actions bumped, demo image on Ruby 4.0, CI installs libvips so the playground's Active Storage previews load).
13
+
14
+ ### Fixed
15
+
16
+ - An association column whose target labels itself with a *method* rather than a column (e.g. `label :display_title`) no longer raises `DefinitionError` ("… is neither a column nor an association of …") when its filter or `?q=` search runs. The label-based text match — and the `belongs_to` sort — are skipped whenever there is no column behind the label, as they already were for a block label. A `belongs_to` still filters by value; spell the columns out (`filter publisher: :name`) to match such a target as text.
17
+
18
+ ## v0.2.0 — 2026-07-14
19
+
20
+ ### Added
21
+
22
+ - Typed filter controls for dynamic columns: a `filter:` block with keyword params (`geq:`/`leq:`, `eq:`, `contains:`) filters as the column's `as:` type — a number/date range, a yes/no or a dropdown instead of a text box (override with `filter_as:`/`filter_choices:`). ([#20](https://github.com/itadventurer/crud_components/issues/20))
23
+ - `crud_filter` accepts `extra_columns:` and an opt-in `sort:` picker for headerless layouts. ([#22](https://github.com/itadventurer/crud_components/issues/22))
24
+ - A `belongs_to` column sorts by its target's label (via a join), matching the existing filter-by-label. ([#27](https://github.com/itadventurer/crud_components/pull/27))
25
+ - `crud_collection` takes `search_bar:` (default true) to drop the toolbar's `?q=` search box for one collection. ([#29](https://github.com/itadventurer/crud_components/pull/29))
26
+ - `Query` exposes the params it understands: `#permitted_keys` (the strong-params list for the filters it reads), `#filter_params` (the present subset of this request, for filter-preserving links) and `#active_filters` (active values by logical name, for chips) — so a host no longer hand-maintains a permit list that mirrors the columns. ([#31](https://github.com/itadventurer/crud_components/issues/31))
27
+ - Active Storage attachment columns filter by **presence**: a 3-state _any / present / absent_ dropdown (`EXISTS` / `NOT EXISTS`), covering `has_one_attached` and `has_many_attached`. ([#32](https://github.com/itadventurer/crud_components/issues/32))
28
+ - A `has_many`/habtm column filters by its children's **label** — the names shown in the cell — matching `belongs_to`'s filter-by-label; skipped when the target labels itself with a block. ([#36](https://github.com/itadventurer/crud_components/pull/36))
29
+
30
+ ### Changed
31
+
32
+ - "Search what you see": searching an association (the `?q=` of a model, the belongs_to text filter, or a bare association name in a spec) now matches the target's **label** — the name shown in its cell — instead of the target's full `search_in`. A free-text filter can no longer reach a target's hidden columns (passwords, tokens), so a `belongs_to` to a model without its own `crud_structure` is safe by default. The undeclared `search_in` default is likewise derived from the displayed fields (own string/text columns plus associations' labels). Declare `search_in` to override. ([#28](https://github.com/itadventurer/crud_components/issues/28))
33
+
34
+ ### Fixed
35
+
36
+ - Dynamic columns keep their inline filter and sort link when a prebuilt `Query` is passed. ([#21](https://github.com/itadventurer/crud_components/issues/21))
37
+ - A proc `sort` facet overrides a prior order (e.g. a search rank) instead of appending to it. ([#23](https://github.com/itadventurer/crud_components/issues/23))
38
+ - The inline filter row's apply/reset button now renders on any filterable table, even one with row actions and the column picker both off (previously the button was missing). ([#35](https://github.com/itadventurer/crud_components/pull/35))
39
+ - `LikeSpec` dedupes joined matches with an id subquery instead of `SELECT DISTINCT`, fixing `PG::UndefinedFunction` on Postgres when a model carrying a `json` column has one of its associations filtered. ([#37](https://github.com/itadventurer/crud_components/pull/37))
data/README.md CHANGED
@@ -146,15 +146,15 @@ together in one block:
146
146
  ```ruby
147
147
  attribute :author_names do
148
148
  render { |book| book.authors.map(&:name).to_sentence }
149
- filter :authors # delegate to Author's search_in; or spell it out: filter authors: :name
149
+ filter :authors # match Author's label; or spell it out: filter authors: :name
150
150
  sort { |scope, dir| scope.left_joins(:authors).order('authors.name' => dir) }
151
151
  end
152
152
  ```
153
153
 
154
154
  The **search spec** — the mini-language `filter` and `search_in` share — builds joins +
155
155
  parameterized, wildcard-escaped ILIKE from column/association names, nothing to sanitize.
156
- An association name alone *delegates* to that model's `search_in`.
157
- [Fields → the search spec](docs/fields.md#the-search-spec)
156
+ An association name alone matches that model's **label** (the name you see).
157
+ [Filtering → the search spec](docs/filtering.md#the-search-spec)
158
158
 
159
159
  ### Columns the model doesn't know about
160
160
 
@@ -212,12 +212,12 @@ the param. It's also a standalone helper (`crud_column_picker`) you can drop abo
212
212
  ```ruby
213
213
  label :title # default: name → title → first string column, else "Book #42"
214
214
  identify_by :slug # Use slug in the URLs
215
- search_in :title, :subtitle, :publisher # :publisher delegates to Publisher's search_in
215
+ search_in :title, :subtitle, :publisher # :publisher matches Publisher's label
216
216
  ```
217
217
 
218
218
  `label` + `identify_by` + `search_in` are a model's **identity** — and they define how
219
- *other* models render, link and filter it through their associations. Declare Order's
220
- identity once and every `belongs_to :order` column gets it for free. →
219
+ *other* models render, link and filter it through their associations. Declare Publisher's
220
+ identity once and every `belongs_to :publisher` column gets it for free. →
221
221
  [Fields → identity](docs/fields.md#identity-label-identify_by-search_in)
222
222
 
223
223
  ### Buttons / Actions
@@ -322,7 +322,7 @@ The whole DSL:
322
322
  | `attribute` / `attributes` | improve one/several fields (model-global) |
323
323
  | `render` / `filter` / `sort` | facets inside an `attribute` block — override exactly one derived behavior |
324
324
  | `label`, `identify_by` | identity: display name; the column URL params resolve |
325
- | `search_in` | the model's text identity (`?q=`, and what delegation expands to) |
325
+ | `search_in` | the model's text identity (`?q=`); default is "search what you see" |
326
326
  | `action` | buttons, per row or per collection |
327
327
  | `fieldset` | a named *selection* of fields and actions |
328
328
 
@@ -357,7 +357,7 @@ Keyed by what a field *is* — with zero config, every row applies without decla
357
357
  | enum | i18n'd badge (click to filter; `—` if null) | select of enum keys (+ "not set" if nullable) | validated against the enum; "not set" → IS NULL | yes |
358
358
  | json column | pretty `<pre>` (rouge if present) | — | — | no |
359
359
  | Active Storage attachment | image / preview / icon by content type | — | — | no |
360
- | `belongs_to` | nil-safe link via target's `label` | select valued by target's `identify_by` (text over `search_in` above `select_limit`) | `where(assoc: Target.where(identify_by => v))` | v2 |
360
+ | `belongs_to` | nil-safe link via target's `label` | select valued by target's `identify_by` (text over target's `label` above `select_limit`) | `where(assoc: Target.where(identify_by => v))` | v2 |
361
361
  | `has_many` / habtm | "a, b +n more" links | opt-in via `filter` facet | — | no |
362
362
  | public model method | by value type | — | — | — |
363
363
  | `render` block / `as:` | as declared | — *(unless `filter` facet)* | — *(unless `sort` facet)* | facet-gated |
@@ -430,7 +430,7 @@ gear, `picked_columns:` seeds it (`:auto` reads `?cols=`; an Array is verbatim,
430
430
  ```ruby
431
431
  label(method = nil, &block)
432
432
  identify_by(column) # default :id
433
- search_in(*spec) | search_in { |scope, q| … } # default: own string/text columns
433
+ search_in(*spec) | search_in { |scope, q| … } # default: "search what you see" (displayed fields + labels)
434
434
  attribute(name, as: nil, form_as: nil, if: nil, editable: nil, **renderer_options, &block)
435
435
  attributes(*names, **shared_options)
436
436
  # bare block (arity 1) = render markup
@@ -456,7 +456,10 @@ a block; an `as:` renderer with no matching partial or a missing gem.
456
456
  ```ruby
457
457
  CrudComponents::Query.new(model, params, fieldset: :default, ability: nil, param_prefix: nil)
458
458
  # #apply(scope) → relation; #active?
459
- CrudComponents.permitted_attributes(model, action: :update, ability: nil) # strong-params list
459
+ # #permitted_keys strong-params list for the filters it reads
460
+ # #filter_params → present subset, for filter-preserving links
461
+ # #active_filters → active values by logical name, for chips
462
+ CrudComponents.permitted_attributes(model, action: :update, ability: nil) # strong-params list (forms)
460
463
  CrudComponents.configure { |config| … } # css/icon maps, select_limit, defaults
461
464
  ```
462
465
 
@@ -465,7 +468,8 @@ CrudComponents.configure { |config| … } # css/icon maps, select_limit, def
465
468
 
466
469
  | Doc | What |
467
470
  | ------------------------------------------ | -------------------------------------------------------------------------------- |
468
- | [docs/fields.md](docs/fields.md) | Fields, renderers, facets, the search spec, identity, per-flavor behavior |
471
+ | [docs/fields.md](docs/fields.md) | Fields, renderers, facets, dynamic & path columns, identity, per-flavor behavior |
472
+ | [docs/filtering.md](docs/filtering.md) | The `filter` facet, the search spec, typed filter controls, the standalone form |
469
473
  | [docs/views.md](docs/views.md) | The helpers, fieldsets, actions & route resolution, the manual query, pagination |
470
474
  | [docs/forms.md](docs/forms.md) | `crud_form`, the permit list, `editable:`, form controls, attachments |
471
475
  | [docs/security.md](docs/security.md) | Permissions (`if:`/`editable:`), the whitelist, and the injection-safe URL model |
data/RELEASING.md CHANGED
@@ -21,24 +21,29 @@ publisher above. After that, every release is hands-off.
21
21
 
22
22
  ## Cutting a release
23
23
 
24
- 1. **Bump the version** in `lib/crud_components/version.rb`. Open it as a normal
25
- PR; merge to `main`.
24
+ 1. **Bump the version and close the changelog.** Set the new version in
25
+ `lib/crud_components/version.rb`, and in `CHANGELOG.md` rename the
26
+ `## Unreleased` heading to `## vX.Y.Z — YYYY-MM-DD`, adding a fresh empty
27
+ `## Unreleased` above it. Open as a normal PR; merge to `main`.
26
28
 
27
29
  2. **Publish the release** for the matching tag — the workflow expects `vX.Y.Z`
28
- to equal the version in `version.rb`:
30
+ to equal the version in `version.rb` — using that version's changelog section
31
+ as the notes:
29
32
 
30
33
  ```bash
31
- gh release create v0.1.0 --title v0.1.0 --generate-notes
34
+ notes=$(awk '/^## v[0-9]/{ if (seen) exit; seen=1; next } seen' CHANGELOG.md)
35
+ gh release create v0.1.0 --title v0.1.0 --notes "$notes"
32
36
  ```
33
37
 
34
- (or use the GitHub UI → *Draft a new release* → pick the tag
35
- *Generate release notes*). Publishing fires the workflow, which runs the
36
- tests, builds the gem, attaches a build-provenance attestation, and pushes it.
38
+ (or use the GitHub UI → *Draft a new release* → pick the tag, and paste that
39
+ section.) Publishing fires the workflow, which runs the tests, builds the gem,
40
+ attaches a build-provenance attestation, and pushes it.
37
41
 
38
42
  That's it — `gem-push.yml` does the build + push; you never touch credentials.
39
43
 
40
44
  ### Versioning
41
45
 
42
46
  [SemVer](https://semver.org): new backward-compatible features → minor (`0.x`),
43
- bug-fixes → patch. Release notes are generated from the merged PRs
44
- (`--generate-notes`); there is no hand-maintained changelog.
47
+ bug-fixes → patch. Release notes are the version's section in `CHANGELOG.md` —
48
+ keep its `## Unreleased` list current as you merge user-facing PRs, and step 1
49
+ turns it into the release.
@@ -23,6 +23,26 @@
23
23
  <%= filter.render_filter_control(field, filter.query) %>
24
24
  </div>
25
25
  <% end %>
26
+ <%# Sort picker for headerless surfaces (cards/lists) — a table carries its own
27
+ header sort links, so this is opt-in via `sort: true`. %>
28
+ <% if filter.sort_control? %>
29
+ <% sort_field, sort_dir = filter.sort_state %>
30
+ <div class="col">
31
+ <span class="<%= css.form_label %>"><%= t('crud_components.filter.sort_by', default: 'Sort by') %></span>
32
+ <div class="<%= css.input_group %>">
33
+ <%= select_tag filter.param_name('sort'),
34
+ options_for_select([[t('crud_components.filter.unsorted', default: '—'), '']] +
35
+ filter.sort_field_choices, sort_field),
36
+ class: css.select_input, id: nil,
37
+ aria: { label: t('crud_components.filter.sort_by', default: 'Sort by') } %>
38
+ <%= select_tag filter.param_name('dir'),
39
+ options_for_select([[t('crud_components.filter.asc', default: 'Ascending'), 'asc'],
40
+ [t('crud_components.filter.desc', default: 'Descending'), 'desc']], sort_dir),
41
+ class: css.select_input, id: nil,
42
+ aria: { label: t('crud_components.filter.direction', default: 'Direction') } %>
43
+ </div>
44
+ </div>
45
+ <% end %>
26
46
  </div>
27
47
  <div class="mt-3 d-flex gap-2">
28
48
  <button type="submit" class="<%= css.button_primary %>">
@@ -0,0 +1,5 @@
1
+ <%# Locals: field, query, form_id, compact, autosubmit, param_name, css %>
2
+ <%= tag.input type: 'date', name: param_name,
3
+ value: query.value(field.name.to_s),
4
+ class: compact ? css.input_sm : css.input, form: form_id,
5
+ aria: { label: field.human_name } %>
@@ -0,0 +1,6 @@
1
+ <%# Locals: field, query, form_id, compact, autosubmit, param_name, css %>
2
+ <%= tag.input type: 'number', step: 'any', name: param_name,
3
+ value: query.value(field.name.to_s),
4
+ class: compact ? css.input_sm : css.input, form: form_id,
5
+ placeholder: (field.human_name if compact),
6
+ aria: { label: field.human_name } %>
@@ -0,0 +1,13 @@
1
+ <%# Locals: field, query, form_id, compact, autosubmit, param_name, css
2
+ Present / absent control for an attachment column — the query turns the value
3
+ into an EXISTS / NOT EXISTS (see Fields::AttachmentField). %>
4
+ <% choices = [
5
+ [t('crud_components.filter.any', default: '–'), ''],
6
+ [t('crud_components.filter.present', default: 'Present'), CrudComponents::PRESENT_FILTER_VALUE],
7
+ [t('crud_components.filter.absent', default: 'Absent'), CrudComponents::ABSENT_FILTER_VALUE]
8
+ ] %>
9
+ <%= select_tag param_name,
10
+ options_for_select(choices, query.value(field.name.to_s)),
11
+ class: compact ? css.select_input_sm : css.select_input, form: form_id, id: nil,
12
+ data: (autosubmit ? { action: 'change->crud-filter#submit' } : {}),
13
+ aria: { label: field.human_name } %>
@@ -24,12 +24,19 @@ de:
24
24
  filter:
25
25
  any: "–"
26
26
  not_set: "Nicht gesetzt"
27
+ present: "Vorhanden"
28
+ absent: "Nicht vorhanden"
27
29
  "yes": "Ja"
28
30
  "no": "Nein"
29
31
  search: "Suche"
30
32
  apply: "Anwenden"
31
33
  submit: "Filtern"
32
34
  reset: "Zurücksetzen"
35
+ sort_by: "Sortieren nach"
36
+ unsorted: "–"
37
+ direction: "Richtung"
38
+ asc: "Aufsteigend"
39
+ desc: "Absteigend"
33
40
  pager:
34
41
  label: "Seitennummerierung"
35
42
  summary: "Seite %{page} von %{total} · %{count} gesamt"
@@ -25,12 +25,19 @@ en:
25
25
  filter:
26
26
  any: "–"
27
27
  not_set: "Not set"
28
+ present: "Present"
29
+ absent: "Absent"
28
30
  "yes": "Yes"
29
31
  "no": "No"
30
32
  search: "Search"
31
33
  apply: "Apply"
32
34
  submit: "Filter"
33
35
  reset: "Reset"
36
+ sort_by: "Sort by"
37
+ unsorted: "–"
38
+ direction: "Direction"
39
+ asc: "Ascending"
40
+ desc: "Descending"
34
41
  pager:
35
42
  label: "Pagination"
36
43
  summary: "Page %{page} of %{total} · %{count} total"
@@ -20,6 +20,7 @@ Gem::Specification.new do |spec|
20
20
 
21
21
  spec.metadata['homepage_uri'] = spec.homepage
22
22
  spec.metadata['source_code_uri'] = spec.homepage
23
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
23
24
  spec.metadata['rubygems_mfa_required'] = 'true'
24
25
 
25
26
  # Prefer git, but fall back to a glob so the gem (and the demo image) build
data/docs/fields.md CHANGED
@@ -29,7 +29,7 @@ attribute :token, filter: false # opt a derived field out
29
29
  `attributes` (plural) applies shared options to several fields at once:
30
30
 
31
31
  ```ruby
32
- attributes :participants, :owner, if: :manage
32
+ attributes :purchase_price, :shop_margin, if: :manage
33
33
  ```
34
34
 
35
35
  The field universe is always *all* derived columns/associations plus declared computed
@@ -152,7 +152,10 @@ meaning until a facet tells the gem how to express it.
152
152
 
153
153
  > **Query-block contract.** `filter`/`sort`/`search_in` blocks receive `(scope, value)`
154
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).
155
+ > time; the scope arrives extended with `where_like`.
156
+
157
+ For the `filter` facet's spec mini-language, typed controls and the standalone form, see
158
+ [Filtering & search](filtering.md).
156
159
 
157
160
  ## Dynamic columns
158
161
 
@@ -206,6 +209,10 @@ CrudComponents::DynamicColumn.new(:priority, as: :number,
206
209
  ) { |record, loaded| loaded[record.id]&.value }
207
210
  ```
208
211
 
212
+ A dynamic column filters the way it renders: give its `filter:` block keyword params
213
+ (`geq:`/`leq:`, `eq:`, `contains:`) and it gets the control its `as:` type wants — a number/date
214
+ range, a yes/no select, a dropdown; see [Filtering → typed filter controls](filtering.md#typed-filter-controls).
215
+
209
216
  `if:` follows the same rules as a declared field's: a denied column is absent from the
210
217
  table, the filter row, sorting and `?cols=` — everywhere. See the column picker in
211
218
  [views.md](views.md#column-picker) for letting users choose which of these they see, and
@@ -261,9 +268,9 @@ These are not specific to `DynamicColumn` — a declared `attribute :status, hea
261
268
  takes the same options. Everything works in the non-grouped and grouped (`group_by:`) layouts,
262
269
  and plays with the column picker (a hidden column simply renders no header). The
263
270
  `/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.
271
+ books × properties **matrix** live entirely in `crud_collection` — one `DynamicColumn` per
272
+ property, its controls in its own header — instead of a hand-built controls strip above
273
+ the table.
267
274
 
268
275
  ## Path columns
269
276
 
@@ -289,7 +296,7 @@ fieldset :index, %i[title publisher.name authors.email]
289
296
  then a link to its show page — so a path column doubles as a jump-to-the-object.
290
297
  - A **list** path (has_many / habtm) renders the values joined — `authors.email` shows
291
298
  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))
299
+ **filterable** (a contains-match through the join, via the [search spec](filtering.md#the-search-spec))
293
300
  but not sortable by default (no single value to order by; add a `sort` facet if you have a
294
301
  meaningful aggregate).
295
302
 
@@ -312,51 +319,12 @@ limits report a clear `DefinitionError`.
312
319
  `attribute(:"authors.email", if: :manage)` to gate it, or give it a block to override how it
313
320
  renders, filters or sorts.
314
321
 
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
322
  ## Identity: `label`, `identify_by`, `search_in`, `icon`
355
323
 
356
324
  ```ruby
357
325
  label :title # method or block; default: name → title → first string column → "Book #42"
358
326
  identify_by :slug # default: :id
359
- search_in :title, :subtitle, :publisher # default: own string/text columns
327
+ search_in :title, :subtitle, :publisher # default: the index's string/text fields + associations' labels
360
328
  icon 'book' # default: guessed from the model name (config.model_icons), else none
361
329
  ```
362
330
 
@@ -364,13 +332,17 @@ icon 'book' # default: guessed from the model name (config.model_i
364
332
  Block form: `label { |book| "#{book.title} (#{book.published_on&.year})" }`. With no
365
333
  string column at all it falls back to `"Book #42"` (`model_name.human` + ` #` + id).
366
334
  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]`
335
+ eager-loaded wherever this model is shown — `label :display_title, preload: %i[publisher]`
368
336
  ([Performance](performance.md#eager-loading-render-dependencies)).
369
337
  - **`identify_by`** — the column URL params use to identify a record of this model. With
370
338
  `identify_by :slug`, a filter URL reads `?publisher=tor-books` and resolves via
371
339
  `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.
340
+ - **`search_in`** — the model's text identity: what `?q=` searches. Undeclared, it's
341
+ "search what you see" the index's own string/text columns plus its associations'
342
+ labels — so secret columns you don't display are never searched. Declare it to override
343
+ (a custom column list, or a block for full-text/`tsvector`). The belongs_to text-filter
344
+ fallback and an association named in a spec (`filter :publisher`) match the target's
345
+ **label** only, never its `search_in`.
374
346
  - **`icon`** — a Bootstrap-icon name (no `bi-` prefix — paired with `config.css.icon_prefix`,
375
347
  swap the whole library there) that badges the model wherever it appears: column-picker
376
348
  groups, association links, path-column cells. Undeclared, it's guessed from the model name
@@ -429,8 +401,8 @@ with `preload: %i[publisher]` — [Performance](performance.md#eager-loading-ren
429
401
  | 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
402
  | json | `<pre>` (rouge if present) | — | — | not form-editable in v1 |
431
403
  | 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 |
404
+ | `belongs_to` | nil-safe link via target `label` | select (≤ `select_limit`) / text over target `label` | v2 | resolves by `identify_by` |
405
+ | `has_many` / habtm | "a, b +n more" links | text over the children's `label` (columnless label: facet) | no | "+n more" links to nested/filtered index |
434
406
  | public method | by value type | — | — | needs a facet to filter/sort |
435
407
  | `render` block | block output | — | — | facets add filter/sort |
436
408
 
@@ -438,5 +410,5 @@ Click-to-filter: in a collection, an enum badge and a boolean icon link to set t
438
410
  column's filter (respecting the fieldset whitelist and `param_prefix`). The inline
439
411
  filter row uses compact controls; the standalone `crud_filter` form uses full-size ones.
440
412
 
441
- See also: [Views & fieldsets](views.md) · [Forms](forms.md) · [Security](security.md) ·
442
- [Extending](extending.md).
413
+ See also: [Filtering & search](filtering.md) · [Views & fieldsets](views.md) · [Forms](forms.md) ·
414
+ [Security](security.md) · [Extending](extending.md).
data/docs/filtering.md ADDED
@@ -0,0 +1,167 @@
1
+ # Filtering & search
2
+
3
+ How a collection narrows its rows: the per-field `filter` facet, the search spec shared by
4
+ `filter` and `search_in`, typed filter controls for dynamic columns, and the standalone
5
+ `crud_filter` form. For how params reach SQL as a whole (the query tri-state) see
6
+ [views.md](views.md#the-query-tri-state); for the security rule ("only filter what you can
7
+ see") see [security.md](security.md).
8
+
9
+ > See it live: the [**filters demo**](https://crud-components.zelenin.de/books) (inline
10
+ > filter row + sidebar) and [**custom fields**](https://crud-components.zelenin.de/custom_fields)
11
+ > (typed controls on user-defined columns).
12
+
13
+ ## The `filter` facet
14
+
15
+ Every column is filterable by default through the control its type implies — a string by
16
+ substring, a number/date by range, an enum/boolean by select, an association by its target's
17
+ **label** (the name shown in the cell): a `belongs_to` as a select/text, a `has_many`/habtm as
18
+ text matching any child. A *computed* column, or an association whose label has no column
19
+ behind it — a block, or a method name like `label :display_title` — opts in with the `filter`
20
+ facet (a [search spec](#the-search-spec) or a block):
21
+
22
+ ```ruby
23
+ attribute :author_names do
24
+ render { |book| book.authors.map(&:name).to_sentence }
25
+ filter authors: :name # spec: contains-match through the join
26
+ end
27
+
28
+ attribute :token, filter: false # opt a derived field out of filtering
29
+ ```
30
+
31
+ A `has_many`/habtm column filters by its children's label with no extra config — typing in its
32
+ filter box keeps owners that have a matching child. Here `/publishers` (which lists each
33
+ publisher's **books**) is narrowed to publishers holding a book whose title contains "winter":
34
+
35
+ ![The /publishers filter row: a text box under the Books column holding "winter", and the rows below narrowed to publishers that have a matching book](screenshots/has-many-filter.png)
36
+
37
+ A `filter`/`search_in` block receives `(scope, value)` and returns a relation; the scope
38
+ arrives extended with `where_like` (see [the escape hatch](#the-escape-hatch)).
39
+
40
+ ## Typed filter controls
41
+
42
+ A [dynamic column](fields.md#dynamic-columns) **filters the way it renders**. Give its
43
+ `filter:` block keyword params and it gets the control its `as:` type wants — a date column a
44
+ date range, a number column a number range, a boolean a yes/no select:
45
+
46
+ ```ruby
47
+ CrudComponents::DynamicColumn.new(:published_on, as: :date,
48
+ filter: ->(scope, geq:, leq:) { … }) # → a date-range control
49
+ ```
50
+
51
+ The block declares which of `geq:` / `leq:` / `eq:` / `contains:` it handles and is called
52
+ with **only those** — each value already cast to the type (`:number` → `BigDecimal`, `:date`
53
+ → `Date`, `:boolean` → `true`/`false`), or `nil` when the param is blank or doesn't parse, so
54
+ junk never reaches SQL. The keywords also choose the control: a number/date block that asks for
55
+ a bound (`geq:`/`leq:`) renders a **range**, one that asks only for `eq:` a **single field**. So
56
+ one block drives both the SQL and the UI.
57
+
58
+ ![The /custom_fields filter row: a text box for the string property, a number range for the numeric one, a yes/no/any select for the boolean and a date range for the date](screenshots/typed-filters.png)
59
+
60
+ A positional `filter: ->(scope, value)` block (no keywords) stays a plain text filter — the
61
+ simplest case, unchanged. See [`/custom_fields`](https://crud-components.zelenin.de/custom_fields)
62
+ for one filter per flavor (`test/dummy/app/models/property_definition.rb`).
63
+
64
+ ### When the filter type isn't the render type
65
+
66
+ `filter_as:` overrides the filter type independently of `as:` (the same way `form_as:` overrides
67
+ the form input) — for a custom renderer, or when you want a different control. A `:select` adds
68
+ its options with `filter_choices:`:
69
+
70
+ ```ruby
71
+ CrudComponents::DynamicColumn.new(:rating, as: :stars, # renders as stars…
72
+ filter_as: :number, filter: ->(scope, geq:, leq:) { … }) # …but filters as a number range
73
+
74
+ CrudComponents::DynamicColumn.new(:binding, as: :string,
75
+ filter_as: :select, filter_choices: [%w[Hard hardcover], %w[Soft paperback]],
76
+ filter: ->(scope, eq:) { … }) # a dropdown
77
+ ```
78
+
79
+ `filter_choices:` takes `[[label, value], …]` (or a callable returning them).
80
+
81
+ ## The search spec
82
+
83
+ One declarative mini-language for "case-insensitive contains across these columns,
84
+ joining as needed" — shared by `filter` (passed positionally) and `search_in`:
85
+
86
+ ```ruby
87
+ filter :title # own column
88
+ filter :title, :subtitle # several own columns, OR-combined
89
+ filter authors: %i[name email] # join, explicit columns
90
+ filter user: { address: %i[street town] } # nested joins, explicit columns
91
+ filter :publisher # join, match Publisher's label (what you see)
92
+ filter :title, { authors: :name } # mixed
93
+ ```
94
+
95
+ The **label form** — an association name *without* columns — matches the target's
96
+ **label**: the name shown in that association's cell ("search what you see"). It is the
97
+ idiomatic style and never reaches the target's other columns, so a secret column on the
98
+ target can't be probed through an association. When the target's label has no column behind
99
+ it — a custom block, or a method name like `label :display_title` — spell the columns out
100
+ (`filter authors: :name`).
101
+
102
+ The gem turns a spec into `left_joins` plus parameterized, wildcard-escaped `ILIKE`
103
+ (via `sanitize_sql_like` with an explicit `\` escape char, so `%`, `_` and `\` are all
104
+ literal). A spec contains only column/association names you wrote — **no SQL strings**,
105
+ nothing to sanitize. A joined match is `DISTINCT`; an own-column spec is not (no join to
106
+ dedupe).
107
+
108
+ ### The escape hatch
109
+
110
+ A block is the escape hatch for genuinely custom logic; the scope it receives carries
111
+ the same machinery, so you keep the safe pit of success without `sanitize_sql_like`:
112
+
113
+ ```ruby
114
+ filter do |scope, value|
115
+ scope.where(active: true).where_like({ authors: :name }, value)
116
+ end
117
+ ```
118
+
119
+ `where_like(spec, value)` is available on every scope handed to a filter/search block.
120
+ Raw SQL in a block is possible — and then explicitly your responsibility.
121
+
122
+ ## The standalone filter form
123
+
124
+ `crud_collection` renders an inline filter row; `crud_filter` renders the same filters as a
125
+ **standalone form** (a sidebar or modal) you place yourself. It takes the same `extra_columns:`
126
+ as `crud_collection`, so a card grid gets its [dynamic-column](fields.md#dynamic-columns)
127
+ filters without hand-building a `Query`:
128
+
129
+ ```erb
130
+ <%= crud_collection @books, layout: :cards, extra_columns: @columns %>
131
+ <%= crud_filter Book, extra_columns: @columns, sort: true %>
132
+ ```
133
+
134
+ A table carries sort links in its column headers, but a **headerless** surface (cards, a list)
135
+ has none — so pass **`sort: true`** to render a field + direction picker built from the query's
136
+ sortable fields. It submits `?sort=&dir=` with the rest of the form, needs no JavaScript, and is
137
+ opt-in (a table doesn't need it).
138
+
139
+ ![The cards view's filter sidebar: a filter per field, then a "Sort by" field-and-direction picker at the bottom](screenshots/filter-sidebar.png)
140
+
141
+ See it live on the [cards layout](https://crud-components.zelenin.de/books?layout=cards).
142
+
143
+ ## The params a query reads
144
+
145
+ `crud_collection` and `crud_filter` build and read their own `Query`, so you rarely touch
146
+ params directly. When you do drive a [manual query](views.md#the-manual-query-pagination-and-big-tables)
147
+ from a controller, the query already knows exactly which params it reads — a param only
148
+ applies if it names a filterable field you can see (plus the reserved `q`/`sort`/`dir`), so
149
+ junk never reaches SQL and it's safe without a permit list. But a controller usually wants
150
+ that param surface anyway: to strong-params it, and to rebuild **filter-preserving links** (a
151
+ pager, a breadcrumb, a "clear search" target). Rather than hand-maintain a list that mirrors
152
+ the columns and drifts when they change, ask the query:
153
+
154
+ ```ruby
155
+ @query.permitted_keys # prefixed param names it reads: each filter field's value and
156
+ # `_geq`/`_leq` bounds, plus q/sort/dir. (page/per are yours.)
157
+ @query.filter_params # the present subset of *this* request, by real param name —
158
+ # e.g. { "title" => "hobbit", "price_geq" => "10", "sort" => "name" }
159
+ @query.active_filters # active filter/search values by logical name, for chips —
160
+ # e.g. { "title" => "hobbit", "q" => "dragons" }
161
+ ```
162
+
163
+ `filter_params` is the one to thread into link helpers (`books_path(**@query.filter_params)`)
164
+ instead of keeping a hand-kept params shim; it already respects `param_prefix:`. All three are
165
+ fieldset- and permission-bound: a key a user can't see is never listed.
166
+
167
+ See also: [Fields & rendering](fields.md) · [Views & fieldsets](views.md) · [Security](security.md).
data/docs/performance.md CHANGED
@@ -13,7 +13,7 @@ config can't guess.
13
13
  [Fast cell rendering](#fast-cell-rendering).
14
14
  - **belongs_to filters degrade gracefully.** A belongs_to filter renders a `<select>` of
15
15
  the target's records up to `config.select_limit` (default 250); beyond that it switches
16
- to a text input over the target's `search_in`, so a 50k-row association never builds a
16
+ to a text input over the target's `label`, so a 50k-row association never builds a
17
17
  giant `<select>`. (A typeahead/autocomplete is a later version.)
18
18
  - **Long text truncates** in collections — the full value renders on the record page.
19
19