crud_components 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +23 -0
  4. data/LICENSE +21 -0
  5. data/README.md +511 -0
  6. data/RELEASING.md +44 -0
  7. data/Rakefile +12 -0
  8. data/app/assets/stylesheets/crud_components.css +35 -0
  9. data/app/views/crud_components/_action_button.html.erb +11 -0
  10. data/app/views/crud_components/_actions.html.erb +12 -0
  11. data/app/views/crud_components/_column_header.html.erb +24 -0
  12. data/app/views/crud_components/_column_picker.html.erb +66 -0
  13. data/app/views/crud_components/_filter.html.erb +34 -0
  14. data/app/views/crud_components/_form.html.erb +30 -0
  15. data/app/views/crud_components/_pager.html.erb +41 -0
  16. data/app/views/crud_components/_record.html.erb +15 -0
  17. data/app/views/crud_components/_row.html.erb +26 -0
  18. data/app/views/crud_components/_selection_action.html.erb +14 -0
  19. data/app/views/crud_components/_sort_link.html.erb +17 -0
  20. data/app/views/crud_components/_toolbar.html.erb +50 -0
  21. data/app/views/crud_components/fields/_asciidoc.html.erb +8 -0
  22. data/app/views/crud_components/fields/_association.html.erb +13 -0
  23. data/app/views/crud_components/fields/_association_list.html.erb +24 -0
  24. data/app/views/crud_components/fields/_attachment.html.erb +16 -0
  25. data/app/views/crud_components/fields/_attachment_thumb.html.erb +17 -0
  26. data/app/views/crud_components/fields/_boolean.html.erb +13 -0
  27. data/app/views/crud_components/fields/_date.html.erb +6 -0
  28. data/app/views/crud_components/fields/_datetime.html.erb +6 -0
  29. data/app/views/crud_components/fields/_email.html.erb +7 -0
  30. data/app/views/crud_components/fields/_enum.html.erb +14 -0
  31. data/app/views/crud_components/fields/_json.html.erb +10 -0
  32. data/app/views/crud_components/fields/_markdown.html.erb +9 -0
  33. data/app/views/crud_components/fields/_number.html.erb +8 -0
  34. data/app/views/crud_components/fields/_string.html.erb +8 -0
  35. data/app/views/crud_components/fields/_text.html.erb +9 -0
  36. data/app/views/crud_components/fields/_url.html.erb +11 -0
  37. data/app/views/crud_components/filters/_boolean.html.erb +12 -0
  38. data/app/views/crud_components/filters/_date_range.html.erb +11 -0
  39. data/app/views/crud_components/filters/_number_range.html.erb +13 -0
  40. data/app/views/crud_components/filters/_select.html.erb +8 -0
  41. data/app/views/crud_components/filters/_text.html.erb +5 -0
  42. data/app/views/crud_components/form_fields/_belongs_to.html.erb +3 -0
  43. data/app/views/crud_components/form_fields/_boolean.html.erb +12 -0
  44. data/app/views/crud_components/form_fields/_date.html.erb +2 -0
  45. data/app/views/crud_components/form_fields/_datetime.html.erb +2 -0
  46. data/app/views/crud_components/form_fields/_enum.html.erb +8 -0
  47. data/app/views/crud_components/form_fields/_file.html.erb +47 -0
  48. data/app/views/crud_components/form_fields/_habtm.html.erb +5 -0
  49. data/app/views/crud_components/form_fields/_number.html.erb +2 -0
  50. data/app/views/crud_components/form_fields/_string.html.erb +3 -0
  51. data/app/views/crud_components/form_fields/_text.html.erb +2 -0
  52. data/app/views/crud_components/layouts/_table.html.erb +143 -0
  53. data/config/locales/crud_components.de.yml +39 -0
  54. data/config/locales/crud_components.en.yml +40 -0
  55. data/crud_components.gemspec +48 -0
  56. data/docs/extending.md +308 -0
  57. data/docs/fields.md +442 -0
  58. data/docs/forms.md +253 -0
  59. data/docs/performance.md +90 -0
  60. data/docs/security.md +139 -0
  61. data/docs/views.md +405 -0
  62. data/lib/crud_components/action.rb +85 -0
  63. data/lib/crud_components/builder.rb +246 -0
  64. data/lib/crud_components/config.rb +128 -0
  65. data/lib/crud_components/dynamic_column.rb +68 -0
  66. data/lib/crud_components/engine.rb +25 -0
  67. data/lib/crud_components/errors.rb +9 -0
  68. data/lib/crud_components/fields/attachment_field.rb +22 -0
  69. data/lib/crud_components/fields/base.rb +260 -0
  70. data/lib/crud_components/fields/belongs_to_field.rb +91 -0
  71. data/lib/crud_components/fields/boolean_field.rb +31 -0
  72. data/lib/crud_components/fields/computed_field.rb +34 -0
  73. data/lib/crud_components/fields/date_field.rb +51 -0
  74. data/lib/crud_components/fields/dynamic_field.rb +44 -0
  75. data/lib/crud_components/fields/enum_field.rb +40 -0
  76. data/lib/crud_components/fields/has_many_field.rb +50 -0
  77. data/lib/crud_components/fields/json_field.rb +10 -0
  78. data/lib/crud_components/fields/numeric_field.rb +31 -0
  79. data/lib/crud_components/fields/path_field.rb +327 -0
  80. data/lib/crud_components/fields/string_field.rb +41 -0
  81. data/lib/crud_components/fields/text_field.rb +9 -0
  82. data/lib/crud_components/fieldset.rb +38 -0
  83. data/lib/crud_components/helpers.rb +259 -0
  84. data/lib/crud_components/like_spec.rb +113 -0
  85. data/lib/crud_components/markup.rb +36 -0
  86. data/lib/crud_components/model.rb +33 -0
  87. data/lib/crud_components/permission_context.rb +62 -0
  88. data/lib/crud_components/presenters/actions.rb +51 -0
  89. data/lib/crud_components/presenters/base.rb +95 -0
  90. data/lib/crud_components/presenters/cell_context.rb +28 -0
  91. data/lib/crud_components/presenters/cells.rb +160 -0
  92. data/lib/crud_components/presenters/collection.rb +498 -0
  93. data/lib/crud_components/presenters/column_selection.rb +91 -0
  94. data/lib/crud_components/presenters/filter.rb +38 -0
  95. data/lib/crud_components/presenters/form.rb +57 -0
  96. data/lib/crud_components/presenters/record.rb +57 -0
  97. data/lib/crud_components/query.rb +110 -0
  98. data/lib/crud_components/route_resolver.rb +123 -0
  99. data/lib/crud_components/structure.rb +343 -0
  100. data/lib/crud_components/version.rb +3 -0
  101. data/lib/crud_components/where_like.rb +13 -0
  102. data/lib/crud_components.rb +160 -0
  103. data/lib/generators/crud_components/install/install_generator.rb +43 -0
  104. data/lib/generators/crud_components/install/templates/crud_columns_controller.js +76 -0
  105. data/lib/generators/crud_components/install/templates/crud_filter_controller.js +32 -0
  106. data/lib/generators/crud_components/install/templates/crud_multiselect_controller.js +70 -0
  107. data/lib/generators/crud_components/install/templates/crud_select_controller.js +35 -0
  108. data/lib/generators/crud_components/install/templates/initializer.rb +56 -0
  109. data/lib/generators/crud_components/views/views_generator.rb +14 -0
  110. metadata +209 -0
data/docs/forms.md ADDED
@@ -0,0 +1,253 @@
1
+ # Forms
2
+
3
+ `crud_form` derives a create/edit form from the same field metadata everything else uses.
4
+ The gem renders the form; **your controller saves it** — there is no gem-owned controller
5
+ and no gem-owned routes. The two are kept from drifting by a shared permit list.
6
+
7
+ ```erb
8
+ <%= crud_form @book %> <%# edit if persisted, new if not %>
9
+ ```
10
+
11
+ ![A derived edit form rendered through simple_form: required marks and valid-state inputs, read-only fields shown as compact labels, and the habtm association as a removable-chip picker with an "add" dropdown](screenshots/form.png)
12
+
13
+ ## The permit list — why fields can't silently fail to save
14
+
15
+ The form and your strong-params both derive from the same metadata, so a field can't be
16
+ in one and missing from the other. Use the list the gem derived the form from:
17
+
18
+ ```ruby
19
+ def book_params
20
+ params.require(:book)
21
+ .permit(*CrudComponents.permitted_attributes(Book, action: action_name.to_sym, ability: current_ability))
22
+ end
23
+ ```
24
+
25
+ The classic "I added a field and it silently doesn't save" bug is structurally
26
+ impossible: the permit list *is* the form's field set, projected to param keys. For
27
+ models that don't `include CrudComponents::Model`, use
28
+ `CrudComponents.permitted_attributes(Model, action:, ability:)` — identical result.
29
+
30
+ What the list contains, per editable field:
31
+
32
+ | Field | Permit key |
33
+ | --------------------------------------------- | --------------------------------- |
34
+ | column (string/number/date/boolean/enum/text) | `:name` |
35
+ | `belongs_to` | `:publisher_id` (the foreign key) |
36
+ | habtm / has_many (ids) | `{ author_ids: [] }` |
37
+ | single attachment | `:cover` |
38
+ | `has_many_attached` | `{ images: [] }` |
39
+
40
+ A `belongs_to` always permits the real foreign key (`:publisher_id`), never the slug —
41
+ even when the target uses `identify_by :slug`. A form POST is a request body, not a
42
+ shareable URL, so the slug buys nothing here (unlike a filter, which puts it in the URL);
43
+ see the mapping-table row below.
44
+
45
+ Excluded automatically: `id`, `created_at`, `updated_at`, computed fields (no form
46
+ control), JSON columns (read-only in v1), `has_many` that isn't habtm, and any field that
47
+ is non-editable or not permitted for the current user (below).
48
+
49
+ ## Two permission dimensions
50
+
51
+ Editing introduces a question viewing doesn't: you may *see* a field but not be allowed
52
+ to *change* it. So `editable:` sits alongside `if:`:
53
+
54
+ ```ruby
55
+ attribute :slug, editable: false # shown read-only in the form
56
+ attribute :state, editable: :publish # editable only if can?(:publish, Book)
57
+ attribute :purchase_price, if: :manage # invisible to non-managers, everywhere
58
+ ```
59
+
60
+ - **`if:`** controls **visibility** — a field you can't see isn't in the form, the permit
61
+ list, the query, or any other surface.
62
+ - **`editable:`** controls **writability** — a visible-but-not-editable field renders as
63
+ compact read-only text and is left out of the permit list. Same callable contract as
64
+ `if:` (symbol → `can?`, zero-arity lambda, record lambda / `it`); see
65
+ [Security → permissions](security.md#permissions).
66
+
67
+ Because both are enforced on the permit list *and* the form, the two can never disagree:
68
+ a user who can't edit a field can neither see an input for it nor smuggle it through
69
+ params.
70
+
71
+ ## Which fields, and where it submits
72
+
73
+ Form field selection falls back **action → `:form` → `:default`**:
74
+
75
+ - `fieldset :form, %i[…]` — fields for all forms.
76
+ - `fieldset :edit, %i[…]` / `fieldset :new, %i[…]` — override one form (`:update` maps to
77
+ `:edit`, `:create` to `:new`).
78
+ - otherwise the `:default` set is used.
79
+
80
+ New vs. edit (POST vs. PATCH) and the URL are inferred from the record (`persisted?`).
81
+ Override with `url:` / `method:` when routes aren't conventional:
82
+
83
+ ```erb
84
+ <%= crud_form @book, url: publisher_book_path(@publisher, @book), method: :patch %>
85
+ ```
86
+
87
+ ## Rendering: simple_form
88
+
89
+ Forms render through [simple_form](https://github.com/heartcombo/simple_form) (a runtime
90
+ dependency). The gem decides *which* fields appear, their flavor, the permit list and the
91
+ read-only/permission logic; simple_form does the markup — labels, inputs, wrappers,
92
+ required marks, and **per-field error display** — through your app's wrapper config
93
+ (Bootstrap by default; the community ships Tailwind/Bulma/Foundation). So the gem's forms
94
+ inherit your design system automatically, and there's no hand-rolled `field_with_errors`
95
+ to fight.
96
+
97
+ The flavor → simple_form mapping (one `form_fields/_<type>` partial each):
98
+
99
+ | Field | simple_form call |
100
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
101
+ | string / number / date / datetime | `f.input :name` (type inferred from the column) |
102
+ | text | `f.input :name, as: :text` |
103
+ | boolean | `f.input :name, as: :boolean` (a checkbox; a *nullable* column renders a 3-state Yes / No / not-set select instead) |
104
+ | enum | `f.input :name, collection: …` (your i18n'd keys; a *nullable* column adds a blank "not set" option) |
105
+ | `belongs_to` | `f.association :publisher, collection: …` — submits the real **id** (forms are POST bodies, not shareable URLs, unlike filters which use `identify_by`) |
106
+ | habtm | `f.association :authors, as: :select, multiple` + a `crud-multiselect` chip-picker hook (see below) |
107
+ | single / many attachment | a file input + current preview + a "keep" checkbox per file (signed_id) — see [Attachments](#attachments) |
108
+ | read-only (not editable) | rendered by the gem as a compact `label: value`, not submitted |
109
+
110
+ Errors: simple_form shows per-field errors inline; the gem adds a summary for base
111
+ errors (`errors[:base]`) and any error on a column the form doesn't show, so "fix N
112
+ errors" is never a dead end (see [validation errors](#validation-errors) below).
113
+
114
+ To customise an input, override the per-type partial or point a single field at your own
115
+ (see [Customising an input](#customising-an-input) below) — or use simple_form's own
116
+ wrapper/component config, which the gem inherits.
117
+
118
+ ## Associations and attachments
119
+
120
+ - **belongs_to** → a select valued by record id; permit `:publisher_id`.
121
+ - **habtm** → a `<select multiple>` baseline (works no-JS, scales) that carries
122
+ `data-controller="crud-multiselect"`; permit `{ author_ids: [] }`. The optional
123
+ `crud-multiselect` Stimulus controller (shipped by `crud_components:install`) replaces the
124
+ select in place with a **chips-list + "add" dropdown** — the select stays the hidden source
125
+ of truth, so the form submits identically with or without JS. This handles up to a few
126
+ hundred options client-side.
127
+ - **Thousands of options?** That needs an autocomplete querying *your* endpoint (the gem
128
+ owns no controllers). Override the habtm input — drop a `form_fields/_habtm.html.erb`
129
+ into your app, or set a different `as:`/`input_html` for that field — and point your
130
+ library (tom-select, select2, a Stimulus+fetch) at your route. The param shape
131
+ (`author_ids[]`) stays the same, so any library drops in.
132
+
133
+ ### Attachments
134
+
135
+ Attachment inputs show the **current** file(s) — drawn by content type (image inline, a
136
+ previewable file like a PDF as a preview, anything else a **filetype icon + filename**) —
137
+ and support **keep / add / remove** through the standard permit list with **no controller
138
+ code**. The mechanism rests on one Active Storage fact: *an empty file input submits
139
+ nothing*, so leaving it empty keeps the current file(s).
140
+
141
+ - **single attachment** (`has_one_attached`) → permit `:cover`. Leave the file input empty
142
+ to keep; choose a file to **replace**; tick **Remove** to delete (it submits a blank,
143
+ which purges).
144
+ - **has_many_attached** → permit `{ images: [] }`. Each existing file has a **Keep**
145
+ checkbox carrying its `signed_id` (untick to remove); the `multiple` file input adds. A
146
+ hidden blank keeps the array present so unticking everything actually clears it. On save
147
+ the set becomes exactly the kept ids + new uploads — see Rails'
148
+ [Replacing vs Adding Attachments](https://guides.rubyonrails.org/active_storage_overview.html#replacing-vs-adding-attachments).
149
+ Fully derived — see `Author` (`has_many_attached :images`) in the dummy app, *zero* config.
150
+
151
+ A plain `@record.update(permitted)` keeps / adds / removes correctly; you write no
152
+ attachment-specific controller code.
153
+
154
+ The non-image fallback icon is chosen by file extension via `config.file_icons` (a map of
155
+ extension → icon name, e.g. `'pdf' => 'filetype-pdf'`, `'zip' => 'file-earmark-zip'`),
156
+ falling back to `config.file_fallback_icon`. The icon *library* is the `icon_prefix` entry
157
+ in the [class map](extending.md#styling). Add or remap an extension in the config, or
158
+ override `crud_components/fields/_attachment_thumb.html.erb` to change the markup.
159
+
160
+ ## Customising an input
161
+
162
+ Each editable input is rendered through a per-type partial,
163
+ `crud_components/form_fields/_<type>.html.erb`, where `<type>` is the field's form control
164
+ (`string`, `number`, `date`, `datetime`, `text`, `boolean`, `enum`, `belongs_to`, `habtm`,
165
+ `file`). simple_form still does the markup *inside* each partial. Two override levers,
166
+ plus the escape hatch:
167
+
168
+ - **A whole type** — drop a same-named partial into your app
169
+ (`app/views/crud_components/form_fields/_enum.html.erb`); Rails view-path precedence picks
170
+ yours. Every enum input now uses it.
171
+ - **One field** — `attribute :blurb, form_as: :rich_text` points just that field at
172
+ `form_fields/_rich_text.html.erb`. `form_as:` is the form-side parallel of `as:` (which
173
+ picks the read-only/display renderer) and defaults to the field's type. The partial
174
+ receives the simple_form builder `f`, the `field`, and `form`.
175
+ - **Everything** — override `crud_components/_form.html.erb` to take over form rendering
176
+ entirely.
177
+
178
+ (There is deliberately no `form` facet — `render`/`filter`/`sort` facets are unchanged;
179
+ forms customise through partials instead.)
180
+
181
+ ## Validation errors
182
+
183
+ Validations live on your model; the gem re-renders the form correctly after a failed
184
+ save. Your controller renders the form again with the invalid record and a 422:
185
+
186
+ ```ruby
187
+ def update
188
+ @book = find_book
189
+ if @book.update(book_params)
190
+ redirect_to @book
191
+ else
192
+ render :edit, status: :unprocessable_entity # crud_form @book re-renders with errors
193
+ end
194
+ end
195
+ ```
196
+
197
+ On that re-render:
198
+
199
+ - **Entered values are kept** — `simple_form_for` reads the in-memory record, which holds
200
+ the submitted (invalid) attributes, so nothing the user typed is lost.
201
+ - **Per-field errors** render inline (simple_form, via your wrapper config — Bootstrap's
202
+ `.invalid-feedback` by default).
203
+ - **Base / non-field errors** — `errors[:base]`, or errors on a column the form doesn't
204
+ show — render in a summary at the top, so a counted error always has somewhere to be
205
+ fixed.
206
+
207
+ ## Scope (v1)
208
+
209
+ Single record; flat columns plus belongs_to and habtm. No nested forms /
210
+ `accepts_nested_attributes` and no JSON-column editing in v1.
211
+
212
+ ## A complete example
213
+
214
+ ```ruby
215
+ # app/models/book.rb
216
+ crud_structure do
217
+ attribute :slug, editable: false
218
+ attribute :active, editable: :manage
219
+ attribute :purchase_price, if: :manage
220
+ fieldset :form, %i[title subtitle slug blurb price purchase_price pages
221
+ published_on genre active publisher authors cover]
222
+ end
223
+ ```
224
+
225
+ ```ruby
226
+ # app/controllers/books_controller.rb
227
+ def new = (@book = Book.new)
228
+ def edit = (@book = Book.find_by!(slug: params[:id]))
229
+ def create
230
+ @book = Book.new(book_params)
231
+ @book.save ? redirect_to(@book) : render(:new, status: :unprocessable_entity)
232
+ end
233
+ def update
234
+ @book.update(book_params) ? redirect_to(@book) : render(:edit, status: :unprocessable_entity)
235
+ end
236
+
237
+ private
238
+
239
+ def book_params
240
+ params.require(:book).permit(*CrudComponents.permitted_attributes(Book, action: action_name.to_sym, ability: current_ability))
241
+ end
242
+ ```
243
+
244
+ ```erb
245
+ <%# app/views/books/edit.html.erb %>
246
+ <%= crud_form @book %>
247
+ ```
248
+
249
+ `slug` shows read-only; `active` is an input only for managers (read-only otherwise);
250
+ `purchase_price` is absent entirely for non-managers — in the form *and* the permit list.
251
+
252
+ See also: [Fields & rendering](fields.md) · [Views](views.md) · [Security](security.md) ·
253
+ [Extending](extending.md).
@@ -0,0 +1,90 @@
1
+ # Performance
2
+
3
+ The gem keeps derived surfaces cheap by default and hands you the controls for the cases
4
+ config can't guess.
5
+
6
+ ## On by default
7
+
8
+ - **No N+1 from derived columns.** Associations of visible fields (`belongs_to` *and*
9
+ `has_many`/habtm, plus Active Storage attachments) are eager-loaded for the rendered set.
10
+ When a `label` or `render` block reaches *further* into associations, declare those deps
11
+ once — see [Eager-loading render dependencies](#eager-loading-render-dependencies).
12
+ - **Fast cells** — built-in cell types are rendered inline, not a partial per cell — see
13
+ [Fast cell rendering](#fast-cell-rendering).
14
+ - **belongs_to filters degrade gracefully.** A belongs_to filter renders a `<select>` of
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
17
+ giant `<select>`. (A typeahead/autocomplete is a later version.)
18
+ - **Long text truncates** in collections — the full value renders on the record page.
19
+
20
+ ## Eager-loading render dependencies
21
+
22
+ *Advanced.* Visible association and attachment columns are eager-loaded automatically.
23
+ The cases the gem **can't** infer are a custom `label` or `render` block that reaches
24
+ *further* into associations (the classic source of a per-row query). Declare those once
25
+ and they compose into the collection's `includes` — declare where the dependency lives,
26
+ and it's correct everywhere that thing is shown:
27
+
28
+ ```ruby
29
+ class Review < ApplicationRecord
30
+ include CrudComponents::Model
31
+ crud_structure do
32
+ # this label reaches :book — eager-loaded whenever a Review is shown as another
33
+ # model's association column (e.g. a Book's reviews list):
34
+ label(preload: %i[book]) { |review| "#{review.reviewer_name} on #{review.book.title}" }
35
+
36
+ # the `book` column, re-titled for this context, reaches :publisher → nested:
37
+ attribute :book, label: ->(book) { "#{book.title} (#{book.publisher.name})" }, preload: %i[publisher]
38
+ end
39
+ end
40
+
41
+ class Book < ApplicationRecord
42
+ include CrudComponents::Model
43
+ crud_structure do
44
+ # a render block reaching associations on *this* model → top-level:
45
+ attribute :author_names, preload: %i[authors] do
46
+ render { |book| book.authors.map(&:name).to_sentence }
47
+ end
48
+ # …or model-level and standalone (additive with `label …, preload:`):
49
+ preload :publisher
50
+ end
51
+ end
52
+ ```
53
+
54
+ How it composes: an **association column** nests the target's declared preloads — a Book's
55
+ `reviews` column becomes `includes(reviews: %i[book])` because `Review` declared
56
+ `label … preload: %i[book]`; the re-titled `book` column above becomes
57
+ `includes(book: %i[publisher])`. A `preload:` on a **non-association** column loads
58
+ top-level. There's nothing to wire at the call site — the gem adds these to whatever scope
59
+ you render, so `crud_collection @books` is already N+1-free.
60
+
61
+ ## Pagination (you bring it)
62
+
63
+ The gem **never paginates on its own** — no surprise row limits, no records silently
64
+ dropped. Rendering a 50k-row table unbounded is the documented footgun; bound it yourself:
65
+
66
+ - Pass a paginated relation and the gem renders a footer pager automatically when it
67
+ detects one (kaminari / will_paginate, which decorate the relation):
68
+
69
+ ```ruby
70
+ @query = CrudComponents::Query.new(Book, params, ability: current_ability)
71
+ @books = @query.apply(Book.accessible_by(current_ability)).page(params[:page]) # kaminari
72
+ ```
73
+ ```erb
74
+ <%= crud_collection @books, query: @query %>
75
+ ```
76
+
77
+ - Or pass any bounded scope. See [Views → the manual query](views.md#the-manual-query-pagination-and-big-tables)
78
+ for the full story (including pagy, whose state lives off the relation, so you render its
79
+ nav yourself).
80
+
81
+ ## Big associations in forms
82
+
83
+ The derived habtm/has_many input is a `<select multiple>` (optionally enhanced by the
84
+ `crud-multiselect` controller). It loads every option client-side — fine up to a few
85
+ hundred. For thousands of options, don't render them all: override that one field's form
86
+ input with `form_as:` and a [custom partial](forms.md#customising-an-input) that talks to
87
+ your own autocomplete endpoint. Same for a huge belongs_to select — point it at a
88
+ server-backed picker.
89
+
90
+ See also: [Security](security.md) · [Views](views.md) · [Forms](forms.md).
data/docs/security.md ADDED
@@ -0,0 +1,139 @@
1
+ # Security
2
+
3
+ The gem has two security jobs:
4
+
5
+ 1. **Show only what the user is allowed to see** — and never let them filter or sort by it
6
+ either. Visibility is permission-aware, end to end.
7
+ 2. **Turn untrusted URL params into SQL with no injection** — every value, name and spec
8
+ that reaches the query is whitelisted, validated and parameterized.
9
+
10
+ Both are encoded as tests in
11
+ [`test/query_security_test.rb`](../test/query_security_test.rb).
12
+
13
+ ## Permissions: `if:` and `editable:`
14
+
15
+ Two dimensions, declared on an attribute:
16
+
17
+ ```ruby
18
+ attribute :purchase_price, if: :manage # visible only to managers — hidden everywhere otherwise
19
+ attribute :state, editable: :publish # everyone sees it; only :publish may change it in a form
20
+ attribute :slug, editable: false # shown read-only in the form, never submitted
21
+ ```
22
+
23
+ - **`if:`** governs **visibility** — and it is total. A field whose `if:` fails is absent
24
+ from the table, the record view, the form *and the query layer*: you cannot filter,
25
+ sort or `?q=`-search by a column you may not see. (See [the whitelist](#the-whitelist) and
26
+ [`?q=` and permissions](#q-search-and-permissions).)
27
+ - **`editable:`** governs **writability in forms** only — a field can be visible but not
28
+ changeable. `false` (or an unmet permission) renders it read-only and drops it from the
29
+ [permit list](forms.md#the-permit-list); it stays visible for context.
30
+
31
+ ### Callable forms
32
+
33
+ Both `if:` and `editable:` accept the same three forms:
34
+
35
+ ```ruby
36
+ if: :manage # Symbol — sugar for can?(:manage, record)
37
+ if: -> { can?(:publish, Book) } # zero-arity lambda — ability only
38
+ if: ->(book) { book.draft? } # one-arity lambda — receives the record
39
+ if: ->(book) { can?(:edit, book) && book.draft? } # …and can? is in scope too — depend on both
40
+ ```
41
+
42
+ - **Symbol** → `can?(symbol, record)` — the record being decided about (so it matches the
43
+ derived action check, `can?(:edit, @book)`), or the model class for a column-level
44
+ decision, where there is no record.
45
+ - **Zero-arity lambda** runs in a context where `can?` works (the view when rendering, a
46
+ thin ability wrapper when querying); it receives no record.
47
+ - **One-arity lambda** receives the record **and** runs where `can?` works — so a condition
48
+ can depend on the ability, the record, or both. Where there is no record — a column-level
49
+ or strong-params check that can't depend on a single row — the lambda is **not run**; it
50
+ defers to a safe default: visibility (`if:`) shows the column, editability (`editable:`)
51
+ withholds the field (a class-level permit list can't grant per-record write access).
52
+
53
+ ### The `can?` dependency (there isn't one)
54
+
55
+ `can?` is **feature-detected**, not required. The gem depends on no authorization library;
56
+ it works with [CanCanCan](https://github.com/CanCanCommunity/cancancan) or anything exposing
57
+ a `can?(action, subject)` method.
58
+
59
+ - Pass the ability where you build the query, or let auto mode pick up `current_ability`:
60
+
61
+ ```ruby
62
+ CrudComponents::Query.new(Book, params, ability: current_ability)
63
+ ```
64
+
65
+ - **No `can?` provider and no ability?** A `Symbol` condition simply evaluates to *not
66
+ permitted* — the field is hidden. It does **not** raise. Safe by default: absent an
67
+ authority to say "yes", the answer is "no". (Lambdas that don't call `can?` are
68
+ unaffected.)
69
+
70
+ ## The whitelist
71
+
72
+ > **A URL param is applied only if it names a filterable field of the fieldset in play
73
+ > that the current user may see (or a reserved param). Everything else never reaches SQL.**
74
+
75
+ Two consequences worth stating plainly:
76
+
77
+ - **You can only filter and sort what you can see.** The set of filterable/sortable fields
78
+ is the visible fieldset (plus its declared `filters:`), minus anything an `if:` hides.
79
+ - **Hidden data can't be probed.** Because a permission-gated column never reaches the
80
+ query, you can't bisect an invisible `purchase_price` by watching which rows survive a
81
+ crafted `purchase_price_geq`.
82
+
83
+ ## The injection-safe URL model
84
+
85
+ The URL *is* the state — plain GET forms and links, `data-turbo-action="advance"`,
86
+ shareable. Flat params:
87
+
88
+ | Param | Meaning |
89
+ | ---------------------------------------- | --------------------------------------------------- |
90
+ | `?title=ruby` | filter a field (text / enum / boolean / belongs_to) |
91
+ | `?price=12` / `?published_on=2026-01-01` | exact match (number / single day) |
92
+ | `?price_geq=10&price_leq=20` | ranges (numeric, date; dates whole-day-inclusive) |
93
+ | `?q=tolkien` | global search through `search_in` |
94
+ | `?sort=title&dir=desc` | sorting; composes with active filters |
95
+ | `?cols[]=title&cols[]=price` | column-picker selection (ordered, permitted subset) |
96
+
97
+ `q`, `sort`, `dir`, `page`, `per`, `cols` are **reserved** — they're the gem's own control params.
98
+ A field named after one would silently shadow it, so the gem raises at boot instead; rename
99
+ the field (or scope the whole collection with `param_prefix: :books`, which prefixes every
100
+ param). With `param_prefix:`, unprefixed params are ignored.
101
+
102
+ The guarantees, each backed by a test:
103
+
104
+ - **Unknown / non-scalar params are inert.** Only whitelisted fields are read; `?title[]=…`
105
+ and `?title[x]=…` are ignored.
106
+ - **No injection through `sort`/`dir`.** `sort` resolves against sortable fields only —
107
+ `?sort=title;DROP TABLE books` yields *no* `ORDER BY`, not an escaped one. `dir` is
108
+ validated to `asc`/`desc`.
109
+ - **`?cols=` can only narrow, never widen.** The column-picker selection is intersected
110
+ with the permitted column set, so a forged or stale `cols` (or a `picked_columns:` default
111
+ naming a now-gated column) can hide or reorder columns but never surface one the `if:`
112
+ gate forbids.
113
+ - **Escaped LIKE.** Wildcards (`%`, `_`) and the backslash escape itself are escaped
114
+ (`sanitize_sql_like` with an explicit `\`), so `%` matches a literal percent.
115
+ - **Validated casts.** Enum values are checked against the enum; booleans against an
116
+ explicit set (`t/f/1/0/yes/no/on/off`); numeric/date casts reject the unparsable *and*
117
+ the non-finite (`NaN`, `Infinity`). Anything invalid leaves the scope unchanged.
118
+ - **belongs_to by `identify_by`.** belongs_to params resolve through the target's
119
+ `identify_by` column as a parameterized subquery — never a raw id (unless `identify_by`
120
+ is `:id` (default)).
121
+ - **Specs are author-written.** A search spec contains only column/association names you
122
+ wrote; the gem builds joins + parameterized ILIKE from it. The one place SQL is
123
+ hand-written is the escape-hatch `filter { |scope, value| … }` block — and `where_like`
124
+ exists so you rarely need to. Raw SQL in a block is your responsibility.
125
+
126
+ ## `?q=` search and permissions
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:
130
+
131
+ - A **declared, permission-gated** column (`attribute :notes, if: :manage`) is dropped from
132
+ 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.
138
+
139
+ See also: [Performance](performance.md) · [Views](views.md) · [Fields](fields.md) · [Forms](forms.md).