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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +13 -9
- data/RELEASING.md +14 -9
- data/app/views/crud_components/_filter.html.erb +20 -0
- data/app/views/crud_components/filters/_date.html.erb +5 -0
- data/app/views/crud_components/filters/_number.html.erb +6 -0
- data/app/views/crud_components/filters/_presence.html.erb +13 -0
- data/config/locales/crud_components.de.yml +7 -0
- data/config/locales/crud_components.en.yml +7 -0
- data/crud_components.gemspec +1 -0
- data/docs/fields.md +20 -48
- data/docs/filtering.md +166 -0
- data/docs/performance.md +1 -1
- data/docs/security.md +9 -7
- data/docs/views.md +8 -2
- data/lib/crud_components/dynamic_column.rb +11 -3
- data/lib/crud_components/fields/attachment_field.rb +23 -1
- data/lib/crud_components/fields/base.rb +71 -11
- data/lib/crud_components/fields/belongs_to_field.rb +38 -10
- data/lib/crud_components/fields/boolean_field.rb +2 -2
- data/lib/crud_components/fields/date_field.rb +2 -2
- data/lib/crud_components/fields/enum_field.rb +4 -4
- data/lib/crud_components/fields/has_many_field.rb +22 -2
- data/lib/crud_components/fields/numeric_field.rb +2 -2
- data/lib/crud_components/fields/path_field.rb +6 -6
- data/lib/crud_components/fields/string_field.rb +3 -3
- data/lib/crud_components/helpers.rb +19 -5
- data/lib/crud_components/like_spec.rb +32 -33
- data/lib/crud_components/presenters/collection.rb +17 -8
- data/lib/crud_components/presenters/filter.rb +27 -3
- data/lib/crud_components/query.rb +45 -6
- data/lib/crud_components/structure.rb +5 -2
- data/lib/crud_components/typed_filter.rb +113 -0
- data/lib/crud_components/version.rb +1 -1
- data/lib/crud_components.rb +8 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f9f34326b3293f67ca0c584810204c8cafaa134c9a198133c5e0250a6022363
|
|
4
|
+
data.tar.gz: a0e849ee97e6e9f2ce92bf8e3a5fcde8575d82346d1765bc27084ac6ee3a9c1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 #
|
|
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
|
|
157
|
-
[
|
|
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
|
|
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
|
|
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 `
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
44
|
-
|
|
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,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"
|
data/crud_components.gemspec
CHANGED
|
@@ -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
|
|
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:
|
|
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,
|
|
373
|
-
|
|
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 `
|
|
433
|
-
| `has_many` / habtm | "a, b +n more" links |
|
|
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: [
|
|
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
|
+

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

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

|
|
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 `
|
|
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**,
|
|
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
|
-
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|