crud_components 0.1.0 → 0.2.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +13 -9
  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 +20 -48
  13. data/docs/filtering.md +166 -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/dynamic_column.rb +11 -3
  18. data/lib/crud_components/fields/attachment_field.rb +23 -1
  19. data/lib/crud_components/fields/base.rb +71 -11
  20. data/lib/crud_components/fields/belongs_to_field.rb +38 -10
  21. data/lib/crud_components/fields/boolean_field.rb +2 -2
  22. data/lib/crud_components/fields/date_field.rb +2 -2
  23. data/lib/crud_components/fields/enum_field.rb +4 -4
  24. data/lib/crud_components/fields/has_many_field.rb +22 -2
  25. data/lib/crud_components/fields/numeric_field.rb +2 -2
  26. data/lib/crud_components/fields/path_field.rb +6 -6
  27. data/lib/crud_components/fields/string_field.rb +3 -3
  28. data/lib/crud_components/helpers.rb +19 -5
  29. data/lib/crud_components/like_spec.rb +32 -33
  30. data/lib/crud_components/presenters/collection.rb +17 -8
  31. data/lib/crud_components/presenters/filter.rb +27 -3
  32. data/lib/crud_components/query.rb +45 -6
  33. data/lib/crud_components/structure.rb +5 -2
  34. data/lib/crud_components/typed_filter.rb +113 -0
  35. data/lib/crud_components/version.rb +1 -1
  36. data/lib/crud_components.rb +8 -0
  37. 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: 5f9f34326b3293f67ca0c584810204c8cafaa134c9a198133c5e0250a6022363
4
+ data.tar.gz: a0e849ee97e6e9f2ce92bf8e3a5fcde8575d82346d1765bc27084ac6ee3a9c1b
5
5
  SHA512:
6
- metadata.gz: 1ad9372ab031db1343c6afccf5f2f8525e593e00e23da8ee3b9a868b2e11a0dbbb5971dea96d348d2689517de69ea0da1aa9f9e589fa64dc825767f66717c72d
7
- data.tar.gz: 4b5f31a8f4e4b77a3fcef9ca7a27ce31ee4e254c071c5710b1b9816e1d59d5412db40551ffba641b3e50a928386d91fcb4ead63e09ad0013d284d67dffbd0f55
6
+ metadata.gz: ec31fe79476eb57060a317bf48167cae62496ba135e7bc5bf3a2f8da26b5e665e075678fa78bd092c0e2d3ffd630f4e098f378e7f32ecab2b862baffcefb4bbb
7
+ data.tar.gz: f3f9d619c8246a57a1a42dae88211cc0155b0f0ae155efcb93f2a79e5be9ad338370c52f4d204d9dccb4b08767fdde13186af2ce13403ec645599d889911d31d
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
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.0 — 2026-07-14
9
+
10
+ ### Added
11
+
12
+ - 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))
13
+ - `crud_filter` accepts `extra_columns:` and an opt-in `sort:` picker for headerless layouts. ([#22](https://github.com/itadventurer/crud_components/issues/22))
14
+ - 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))
15
+ - `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))
16
+ - `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))
17
+ - 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))
18
+ - 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))
19
+
20
+ ### Changed
21
+
22
+ - "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))
23
+
24
+ ### Fixed
25
+
26
+ - Dynamic columns keep their inline filter and sort link when a prebuilt `Query` is passed. ([#21](https://github.com/itadventurer/crud_components/issues/21))
27
+ - 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))
28
+ - 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))
29
+ - `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,7 +212,7 @@ 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
@@ -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
@@ -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
@@ -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
 
@@ -369,8 +337,12 @@ icon 'book' # default: guessed from the model name (config.model_i
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` (block 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,166 @@
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 is a block (no
19
+ single column to match), opts in with the `filter` facet (a [search spec](#the-search-spec) or
20
+ 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 is a custom block
99
+ (no single column to match), spell the columns out (`filter authors: :name`).
100
+
101
+ The gem turns a spec into `left_joins` plus parameterized, wildcard-escaped `ILIKE`
102
+ (via `sanitize_sql_like` with an explicit `\` escape char, so `%`, `_` and `\` are all
103
+ literal). A spec contains only column/association names you wrote — **no SQL strings**,
104
+ nothing to sanitize. A joined match is `DISTINCT`; an own-column spec is not (no join to
105
+ dedupe).
106
+
107
+ ### The escape hatch
108
+
109
+ A block is the escape hatch for genuinely custom logic; the scope it receives carries
110
+ the same machinery, so you keep the safe pit of success without `sanitize_sql_like`:
111
+
112
+ ```ruby
113
+ filter do |scope, value|
114
+ scope.where(active: true).where_like({ authors: :name }, value)
115
+ end
116
+ ```
117
+
118
+ `where_like(spec, value)` is available on every scope handed to a filter/search block.
119
+ Raw SQL in a block is possible — and then explicitly your responsibility.
120
+
121
+ ## The standalone filter form
122
+
123
+ `crud_collection` renders an inline filter row; `crud_filter` renders the same filters as a
124
+ **standalone form** (a sidebar or modal) you place yourself. It takes the same `extra_columns:`
125
+ as `crud_collection`, so a card grid gets its [dynamic-column](fields.md#dynamic-columns)
126
+ filters without hand-building a `Query`:
127
+
128
+ ```erb
129
+ <%= crud_collection @books, layout: :cards, extra_columns: @columns %>
130
+ <%= crud_filter Book, extra_columns: @columns, sort: true %>
131
+ ```
132
+
133
+ A table carries sort links in its column headers, but a **headerless** surface (cards, a list)
134
+ has none — so pass **`sort: true`** to render a field + direction picker built from the query's
135
+ sortable fields. It submits `?sort=&dir=` with the rest of the form, needs no JavaScript, and is
136
+ opt-in (a table doesn't need it).
137
+
138
+ ![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)
139
+
140
+ See it live on the [cards layout](https://crud-components.zelenin.de/books?layout=cards).
141
+
142
+ ## The params a query reads
143
+
144
+ `crud_collection` and `crud_filter` build and read their own `Query`, so you rarely touch
145
+ params directly. When you do drive a [manual query](views.md#the-manual-query-pagination-and-big-tables)
146
+ from a controller, the query already knows exactly which params it reads — a param only
147
+ applies if it names a filterable field you can see (plus the reserved `q`/`sort`/`dir`), so
148
+ junk never reaches SQL and it's safe without a permit list. But a controller usually wants
149
+ that param surface anyway: to strong-params it, and to rebuild **filter-preserving links** (a
150
+ pager, a breadcrumb, a "clear search" target). Rather than hand-maintain a list that mirrors
151
+ the columns and drifts when they change, ask the query:
152
+
153
+ ```ruby
154
+ @query.permitted_keys # prefixed param names it reads: each filter field's value and
155
+ # `_geq`/`_leq` bounds, plus q/sort/dir. (page/per are yours.)
156
+ @query.filter_params # the present subset of *this* request, by real param name —
157
+ # e.g. { "title" => "hobbit", "price_geq" => "10", "sort" => "name" }
158
+ @query.active_filters # active filter/search values by logical name, for chips —
159
+ # e.g. { "title" => "hobbit", "q" => "dragons" }
160
+ ```
161
+
162
+ `filter_params` is the one to thread into link helpers (`books_path(**@query.filter_params)`)
163
+ instead of keeping a hand-kept params shim; it already respects `param_prefix:`. All three are
164
+ fieldset- and permission-bound: a key a user can't see is never listed.
165
+
166
+ 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
 
data/docs/security.md CHANGED
@@ -125,15 +125,17 @@ The guarantees, each backed by a test:
125
125
 
126
126
  ## `?q=` search and permissions
127
127
 
128
- `search_in` is the model's **text identity**, used model-globally (it powers `?q=`, the
129
- belongs_to text fallback, and delegated specs). Two things follow:
128
+ `search_in` is the model's **text identity**, powering `?q=`. Three things follow:
130
129
 
131
130
  - A **declared, permission-gated** column (`attribute :notes, if: :manage`) is dropped from
132
131
  the search spec for a user who can't see it — `?q=` upholds "hidden everywhere".
133
- - An **undeclared** column in the zero-config default spec (all string/text columns) is
134
- searched model-globally by design. If a model has a sensitive string column you don't
135
- want reachable via `?q=`, declare `search_in` explicitly with only the columns you want.
136
- The default is broad so zero-config search "just finds things"; narrowing it is the
137
- author's call.
132
+ - The **zero-config default is "search what you see"**: the index's own string/text columns
133
+ plus its associations' labels. A column you never display including a model's secret
134
+ columns (`encrypted_password`, `*_token`, `api_key`) is never searched. Declare
135
+ `search_in` to override (a narrower column list, or a block for full-text).
136
+ - An **association** reached by `?q=`, the belongs_to text fallback, or a spec naming it
137
+ (`filter :publisher`) matches the target's **label** only — never the target's other
138
+ columns. So filtering by a `belongs_to :user` can't probe `users.encrypted_password`,
139
+ even when `User` has no `crud_structure` of its own.
138
140
 
139
141
  See also: [Performance](performance.md) · [Views](views.md) · [Fields](fields.md) · [Forms](forms.md).
data/docs/views.md CHANGED
@@ -15,7 +15,7 @@ this doc.
15
15
  crud_collection(records, fieldset: nil, layout: :table, query: :auto, param_prefix: nil,
16
16
  actions: true, group_by: nil, extra_columns: nil, picker: false, picked_columns: :auto)
17
17
  crud_record(record, fieldset: nil, actions: true, layout: :record, picked_columns: :auto)
18
- crud_filter(model, fieldset: nil, query: nil, param_prefix: nil, layout: :filter)
18
+ crud_filter(model, fieldset: nil, query: nil, param_prefix: nil, extra_columns: nil, sort: false, layout: :filter)
19
19
  crud_form(record, fieldset: nil, action: nil, url: nil, method: nil, layout: :form) # see forms.md
20
20
  crud_actions(record_or_model, fieldset: nil) # a record → row actions; a model class → collection actions
21
21
  ```
@@ -95,6 +95,10 @@ fieldset :index, %i[cover title price], filters: %i[genre published_on]
95
95
  `filters:` extends the fieldset's filterable set (sorting stays strictly visible fields);
96
96
  `crud_filter` renders all of them.
97
97
 
98
+ The standalone `crud_filter` form takes `extra_columns:` (for dynamic-column filters) and
99
+ `sort: true` (a sort picker for headerless surfaces) — see
100
+ [Filtering → the standalone form](filtering.md#the-standalone-filter-form).
101
+
98
102
  ### Layout is a separate axis
99
103
 
100
104
  ```erb
@@ -365,7 +369,9 @@ the query into your own hands — the explicit form of what the helper does auto
365
369
 
366
370
  Everything stays an ActiveRecord relation, so any paginator and any pre-existing scope
367
371
  compose. The manual query is also how you get the filtered relation for counts, CSV
368
- exports, or charts.
372
+ exports, or charts. To permit the query's params yourself or rebuild filter-preserving
373
+ links by hand, the query exposes the params it reads — see
374
+ [the params a query reads](filtering.md#the-params-a-query-reads).
369
375
 
370
376
  **The footer pager renders itself** when the relation you pass is already paginated —
371
377
  i.e. you called `.page` and it decorates the relation (kaminari, will_paginate). The gem