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

|
|
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).
|
data/docs/performance.md
ADDED
|
@@ -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).
|