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/views.md
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# Views: rendering, fieldsets, actions
|
|
2
|
+
|
|
3
|
+
> See it running: **[live demo](https://crud-components.zelenin.de)** — every feature has a page with the DSL behind it.
|
|
4
|
+
|
|
5
|
+
The render-side reference: the four helpers, how a collection's query is built, fieldsets
|
|
6
|
+
(which fields/actions appear where), actions and route resolution, and rendering several
|
|
7
|
+
collections on one page. For pagination and the manual `Query` object see the bottom of
|
|
8
|
+
this doc.
|
|
9
|
+
|
|
10
|
+
## The helpers
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
crud_collection(records, fieldset: nil, layout: :table, query: :auto, param_prefix: nil,
|
|
16
|
+
actions: true, group_by: nil, extra_columns: nil, picker: false, picked_columns: :auto)
|
|
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)
|
|
19
|
+
crud_form(record, fieldset: nil, action: nil, url: nil, method: nil, layout: :form) # see forms.md
|
|
20
|
+
crud_actions(record_or_model, fieldset: nil) # a record → row actions; a model class → collection actions
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`crud_collection` takes a relation — pass `Book.all`, `@books`, or
|
|
24
|
+
an authorized scope such as `Book.accessible_by(current_ability)`. (`crud_filter` takes the model class — it's
|
|
25
|
+
a filter form, not a set of records.)
|
|
26
|
+
|
|
27
|
+
```erb
|
|
28
|
+
<%= crud_collection @books %> <%# table; layout + filters + query derived %>
|
|
29
|
+
<%= crud_record @book %> <%# definition list, same cell renderers %>
|
|
30
|
+
<%= crud_filter Book %> <%# standalone labelled filter form %>
|
|
31
|
+
<%= crud_actions @book %> <%# just the row actions, for manual placement %>
|
|
32
|
+
<%= crud_actions Book %> <%# the collection actions (model class), for manual placement %>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## The query tri-state
|
|
36
|
+
|
|
37
|
+
`crud_collection`'s `query:` argument controls how a collection gets its records:
|
|
38
|
+
|
|
39
|
+
| `query:` | Mode | Behavior |
|
|
40
|
+
| ----------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| *not given* | **auto** | the helper reads the request params (and `current_ability` if defined), builds a `Query`, and applies it to the scope you pass. The only controller code is assigning that scope (e.g. `@books = Book.all`) |
|
|
42
|
+
| a `Query` | **manual** | the records are taken as *already filtered*; the query supplies control state (sort links, filter values) and the helper inherits its fieldset. This is how you paginate — see below |
|
|
43
|
+
| `false` | **static** | no filter row, no sort links, params ignored. What an embedded secondary table usually wants |
|
|
44
|
+
|
|
45
|
+
> **One auto collection per page.** Auto mode reads the shared, flat request params, so
|
|
46
|
+
> two auto collections would both answer to `?sort=…` / `?q=`. Use `param_prefix:` or
|
|
47
|
+
> `query: :static` for the second — see [Several collections on one page](#several-collections-on-one-page).
|
|
48
|
+
|
|
49
|
+
## Fieldsets
|
|
50
|
+
|
|
51
|
+
A **fieldset** is a named selection of fields and actions.
|
|
52
|
+
Definitions are model-global; fieldsets say what appears where. It is deliberately *not*
|
|
53
|
+
called a "view": table vs. list is a *layout* (`as:`), picked at the render site; a
|
|
54
|
+
fieldset says *which fields*, and the same fieldset can feed a table today and cards
|
|
55
|
+
tomorrow.
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
fieldset :default, %i[cover title genre price publisher]
|
|
59
|
+
fieldset :catalog, %i[cover title authors price published_on active],
|
|
60
|
+
actions: %i[preview edit destroy]
|
|
61
|
+
fieldset :compact, %i[title price]
|
|
62
|
+
fieldset :index, %i[cover title price], filters: %i[genre published_on]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```erb
|
|
66
|
+
<%= crud_collection @books, fieldset: :catalog %>
|
|
67
|
+
<%= crud_record @book, fieldset: :compact %>
|
|
68
|
+
<%= crud_filter Book, fieldset: :catalog %>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Resolution, in full:
|
|
72
|
+
|
|
73
|
+
- Every model has an implicit `fieldset :default` = **all** fields + the default actions.
|
|
74
|
+
Declaring `fieldset :default, …` overrides it. `fieldset :default, []` is the off
|
|
75
|
+
switch (no columns).
|
|
76
|
+
- `crud_collection` uses `:index` if declared, else `:default`. `crud_record` uses
|
|
77
|
+
`:show` if declared, else `:default`. `:index`/`:show` are conventions, not magic —
|
|
78
|
+
any name is a fieldset.
|
|
79
|
+
- An explicitly requested fieldset must exist: `fieldset: :catalogue` raises, listing the
|
|
80
|
+
fieldsets that do (typo protection). A fieldset referencing an unknown field or action
|
|
81
|
+
raises at boot.
|
|
82
|
+
|
|
83
|
+
### Filterability follows the fieldset
|
|
84
|
+
|
|
85
|
+
**You can only filter and sort what you can see.** A curated table ignores params for
|
|
86
|
+
fields it doesn't show — otherwise hidden data could be probed through the URL (filter by
|
|
87
|
+
an invisible `purchase_price` and bisect to its value by watching which rows survive; see
|
|
88
|
+
[security](security.md)). When a surface should offer *more* filters than columns, say so
|
|
89
|
+
explicitly with `filters:`:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
fieldset :index, %i[cover title price], filters: %i[genre published_on]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`filters:` extends the fieldset's filterable set (sorting stays strictly visible fields);
|
|
96
|
+
`crud_filter` renders all of them.
|
|
97
|
+
|
|
98
|
+
### Layout is a separate axis
|
|
99
|
+
|
|
100
|
+
```erb
|
|
101
|
+
<%= crud_collection @books, fieldset: :catalog, layout: :table %> <%# default %>
|
|
102
|
+
<%= crud_collection @books, fieldset: :catalog, layout: :cards %> <%# a custom layout %>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The gem ships with `:table`. If you want to use a different layout, you can add it by creating a partial in your app at `app/views/crud_components/layouts/_<layout_name>.html.erb`. See [Extending → layouts](extending.md#add-a-layout). Selection (fieldset) and
|
|
106
|
+
arrangement (`layout:`) are orthogonal: the same fieldset feeds any layout. (`crud_record`,
|
|
107
|
+
`crud_filter` and `crud_form` take `layout:` too — the partial each renders, defaulting to
|
|
108
|
+
`:record`/`:filter`/`:form`.)
|
|
109
|
+
|
|
110
|
+
## Column picker
|
|
111
|
+
|
|
112
|
+
A fieldset is the app's default set of columns. A column picker lets each user choose
|
|
113
|
+
which of those columns they want to see, and in what order. It's **two knobs**: `picker:`
|
|
114
|
+
turns the gear on, `picked_columns:` decides what it's seeded with.
|
|
115
|
+
|
|
116
|
+
```erb
|
|
117
|
+
<%= crud_collection @books, fieldset: :index, picker: true %>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`picker: true` renders a **gear** in the header row's actions cell. With the default
|
|
121
|
+
`picked_columns: :auto` the gem reads the `?cols=` selection the gear submits — ephemeral,
|
|
122
|
+
nothing stored. It opens a checklist of every column the user may see —
|
|
123
|
+
declared columns, [dynamic columns](fields.md#dynamic-columns) and
|
|
124
|
+
[path columns](fields.md#path-columns) (`authors.email` & co.) alike — each a checkbox,
|
|
125
|
+
draggable to reorder.
|
|
126
|
+
|
|
127
|
+
**Columns are grouped by their source model**, Pipedrive-style: this model's own columns
|
|
128
|
+
first (under a "Book" heading), then each associated model — `publisher`, `publisher.name`
|
|
129
|
+
and `publisher.founded_on` cluster under **Publisher** (with its [icon](fields.md#identity-label-identify_by-search_in-icon)),
|
|
130
|
+
`authors.name`/`authors.email` under **Author** — and every row also tags its model on the
|
|
131
|
+
right, so a long list stays scannable.
|
|
132
|
+
|
|
133
|
+
The picker is **just another query param**. Its form submits `?cols[]=` to the same URL —
|
|
134
|
+
exactly like the sort links and filter row — so it composes with filters, search, sort and
|
|
135
|
+
`param_prefix:`, and the selection rides in the URL with nothing stored server-side.
|
|
136
|
+
|
|
137
|
+
**No JavaScript required.** The gear is a native `<details>`/`<summary>` disclosure, so it
|
|
138
|
+
opens and closes without JS; ticking columns is plain HTML, and Apply/Reset are a plain GET.
|
|
139
|
+
The optional `crud-columns` Stimulus controller adds drag-to-reorder and collapses the
|
|
140
|
+
`?cols[]=a&cols[]=b` array into a tidier `?cols=a,b` (the server reads both forms).
|
|
141
|
+
|
|
142
|
+
### A persisted selection
|
|
143
|
+
|
|
144
|
+
For an ephemeral picker you're done — leave `picked_columns: :auto` and the gem reads the
|
|
145
|
+
param. To make a choice **stick across visits**, your controller resolves it (from the
|
|
146
|
+
param, from storage) and passes the result as an **Array**. The gem then shows exactly that
|
|
147
|
+
and **never re-reads `?cols=`** — one place owns the selection, no split-brain.
|
|
148
|
+
|
|
149
|
+
```erb
|
|
150
|
+
<%# nil → :auto until the first pick is stored, then an Array %>
|
|
151
|
+
<%= crud_collection @books, picker: true, picked_columns: current_user.book_columns %>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The two knobs are independent: **`picker:`** only decides whether the gear is rendered here;
|
|
155
|
+
**`picked_columns:`** is the selection and applies on its own. So an `Array` narrows even with
|
|
156
|
+
no gear here (the gear may be a standalone picker elsewhere), and `:auto` only reads `?cols=`
|
|
157
|
+
when there *is* a gear here — otherwise a stray param is ignored.
|
|
158
|
+
|
|
159
|
+
| `picker:` | `picked_columns:` | Gear | Shows |
|
|
160
|
+
| --- | --- | --- | --- |
|
|
161
|
+
| `false` (default) | `:auto` (default) | no | the fieldset's columns (a stray `?cols=` is ignored) |
|
|
162
|
+
| `false` | `%i[…]` (Array) | no | exactly this selection (gear lives elsewhere; param not read) |
|
|
163
|
+
| `true` | `:auto` (default) | yes | `?cols=` if present, else all columns |
|
|
164
|
+
| `true` | `%i[…]` (Array) | yes | exactly this selection (the param is **not** read) |
|
|
165
|
+
|
|
166
|
+
The chosen names are always **intersected with the permitted set**: a forged or stale
|
|
167
|
+
`?cols=` (or a `picked_columns:` Array naming a column the user lost access to) can only
|
|
168
|
+
hide or reorder columns, never reveal one the `if:` gate forbids. See [security](security.md).
|
|
169
|
+
|
|
170
|
+
### Reuse anywhere with `crud_column_picker`
|
|
171
|
+
|
|
172
|
+

|
|
173
|
+
|
|
174
|
+
The gear is also a standalone helper, so you can place it outside a table — e.g. above a
|
|
175
|
+
`crud_record` detail view. A detail view has no inline gear of its own (no `picker:` knob);
|
|
176
|
+
resolve the selection in the controller and pass it as `picked_columns:`:
|
|
177
|
+
|
|
178
|
+
```erb
|
|
179
|
+
<%# controller: @visible = CrudComponents.selected_columns(params) %>
|
|
180
|
+
<%= crud_column_picker @book, fieldset: :show %> <%# the gear, submits ?cols= to this page %>
|
|
181
|
+
<%= crud_record @book, picked_columns: @visible %> <%# narrows/orders the dl to match (nil → :auto → all) %>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`crud_column_picker` takes a relation, a model class or a record. Match its `param_prefix:`
|
|
185
|
+
to the consuming `crud_collection`/`crud_record` so they read the same param.
|
|
186
|
+
|
|
187
|
+
**Persistence is yours, and optional.** The gem reads the param; it doesn't store it. Use
|
|
188
|
+
`CrudComponents.selected_columns(params)` to pull the ordered selection out of a request
|
|
189
|
+
(it honors `param_prefix:` and accepts both the `cols[]` and `cols=a,b` forms), save it
|
|
190
|
+
wherever you keep per-user state, and pass it back via `picked_columns:`. The block form
|
|
191
|
+
runs only when the picker was actually submitted:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
def index
|
|
195
|
+
CrudComponents.selected_columns(params) { |cols| current_user.update!(book_columns: cols) }
|
|
196
|
+
@books = Book.all
|
|
197
|
+
end
|
|
198
|
+
# view: crud_collection @books, picker: true, picked_columns: current_user.book_columns
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The `/columns` page in `test/dummy` is a runnable example.
|
|
202
|
+
|
|
203
|
+
## Actions
|
|
204
|
+
|
|
205
|
+
Four actions exist by default: **`:new`** (collection), **`:show`**, **`:edit`** and
|
|
206
|
+
**`:destroy`** (per row; destroy is a DELETE with a confirm dialog).
|
|
207
|
+
|
|
208
|
+
`:show` is special: it renders only when the record isn't already reachable through a
|
|
209
|
+
label link in the same row — never two ways to the same page, always at least one.
|
|
210
|
+
|
|
211
|
+
Defaults are **self-disabling**: a derived action renders only if it is permitted
|
|
212
|
+
(`can?(:edit, record)` when an ability is around) *and* its conventional route resolves.
|
|
213
|
+
A model without RESTful routes simply gets no buttons.
|
|
214
|
+
|
|
215
|
+
### Route resolution
|
|
216
|
+
|
|
217
|
+
Resolution tries the most specific conventional route first and falls back outward:
|
|
218
|
+
|
|
219
|
+
- A collection built from an association — `crud_collection @publisher.books` — prefers
|
|
220
|
+
nested routes: `edit_publisher_book_path(publisher, book)`, then `edit_book_path(book)`,
|
|
221
|
+
then the button is omitted.
|
|
222
|
+
- Cells linking to associated records resolve the same way: a review in a book's row
|
|
223
|
+
tries `book_review_path(book, review)`, then `review_path(review)`, then plain text.
|
|
224
|
+
- The label cell links through the same `show` → `:edit` chain; if the label field isn't
|
|
225
|
+
in the fieldset, there is no implicit link — which is exactly when the derived `:show`
|
|
226
|
+
button appears instead.
|
|
227
|
+
- A has_many "+n more" link points at the nested index (`publisher_books_path(owner)`)
|
|
228
|
+
if it resolves, else the target's filtered index (`books_path(publisher: owner)`), else
|
|
229
|
+
plain text.
|
|
230
|
+
|
|
231
|
+
### Declaring actions
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
action :preview, icon: 'eye' do |book|
|
|
235
|
+
book_preview_path(book)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
action :import, on: :collection, icon: 'upload' do
|
|
239
|
+
import_books_path
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Button text** comes from i18n: `t("crud_components.actions.#{name}")`, humanized fallback.
|
|
244
|
+
The gem ships English and German defaults for the four built-in actions (`new`/`show`/`edit`/`destroy`);
|
|
245
|
+
override any of them, or add your own custom actions, in your app's `config/locales`.
|
|
246
|
+
|
|
247
|
+
**Icons** are [Bootstrap Icons](https://icons.getbootstrap.com) by default, rendered as
|
|
248
|
+
`#{css.icon_prefix}#{icon}` (`icon_prefix` defaults to `'bi bi-'`). Use any library by
|
|
249
|
+
setting it in the [class map](extending.md#styling), e.g. `config.css.icon_prefix = 'fa fa-'`
|
|
250
|
+
for Font Awesome — the icon *names* differ per library, so adjust those too.
|
|
251
|
+
|
|
252
|
+
The block is the path, run in the [view context](fields.md#custom-markup) with the record
|
|
253
|
+
(for row actions). Keywords:
|
|
254
|
+
|
|
255
|
+
| Keyword | Meaning | Default |
|
|
256
|
+
| ---------- | ----------------------- | ----------------------------------------------- |
|
|
257
|
+
| `icon:` | icon name | derived for `new/show/edit/destroy` |
|
|
258
|
+
| `title:` | button text | i18n lookup, humanized fallback |
|
|
259
|
+
| `class:` | CSS classes | from the [class map](extending.md#styling) |
|
|
260
|
+
| `confirm:` | `true` or a message | `true` for `:destroy`, else off |
|
|
261
|
+
| `method:` | HTTP method | `:delete` for `:destroy`, else GET |
|
|
262
|
+
| `on:` | `:row` or `:collection` | `:row` (`:new` is `:collection`) |
|
|
263
|
+
| `if:` | permission callable | `can?(name, record)` when an ability is present |
|
|
264
|
+
|
|
265
|
+
A fieldset's `actions:` is authoritative *per kind*: `actions: %i[preview edit destroy]`
|
|
266
|
+
curates the row buttons without losing the derived `:new`; `actions: []` hides
|
|
267
|
+
everything.
|
|
268
|
+
|
|
269
|
+
### Placement
|
|
270
|
+
|
|
271
|
+
Collection actions render in the collection header; row actions in the rightmost column;
|
|
272
|
+
`crud_record` shows the row actions above the definition list. Pass `actions: false` to
|
|
273
|
+
any helper and place them yourself:
|
|
274
|
+
|
|
275
|
+
```erb
|
|
276
|
+
<%= crud_actions @book %> <%# the row actions of one record %>
|
|
277
|
+
<%= crud_actions Book %> <%# the collection actions (a model class) %>
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
For a fully custom actions cell, a fieldset can name a partial instead of a list — it
|
|
281
|
+
receives `record`:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
fieldset :index, %i[cover title price], actions: 'books/actions'
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Grouping
|
|
288
|
+
|
|
289
|
+
`group_by:` is a render-time *arrangement*, like the layout (`as:`) — not part of
|
|
290
|
+
the fieldset (which is *what* shows) or the model. Pass it on the render call:
|
|
291
|
+
|
|
292
|
+
```erb
|
|
293
|
+
<%= crud_collection @books, group_by: :publisher %> <%# belongs_to, enum or a column %>
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The gem orders the relation by the group key first (your column sort applies *within*
|
|
297
|
+
each group), splits the rows into groups, and renders a header row per group — a chevron,
|
|
298
|
+
the group label (a `belongs_to`'s `label`, an enum's i18n value, or the column value) and a
|
|
299
|
+
count. A nil value forms a trailing "—" group.
|
|
300
|
+
|
|
301
|
+
Collapse state is a plain GET param, `?open=tor-books,ace`, so a half-expanded view is
|
|
302
|
+
copy-pasteable and works without JavaScript (each chevron is a link that toggles its key).
|
|
303
|
+
By default every group opens when the total row count is below
|
|
304
|
+
`config.group_collapse_threshold` (50); above it only the first opens. Once `?open=` is set
|
|
305
|
+
it is authoritative (and may open several).
|
|
306
|
+
|
|
307
|
+
Grouping applies to the rendered set, so with pagination it groups per page — typically you
|
|
308
|
+
group *instead of* paging. The key must be a column, `belongs_to` or enum (something with a
|
|
309
|
+
SQL column to order by); anything else raises at render.
|
|
310
|
+
|
|
311
|
+
## Selection (bulk actions)
|
|
312
|
+
|
|
313
|
+
Declare a bulk action with `on: :selection` and the verb it uses:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
action :delete_selected, on: :selection, method: :delete, confirm: true do
|
|
317
|
+
delete_selected_books_path
|
|
318
|
+
end
|
|
319
|
+
action :export_selected, on: :selection do export_selected_books_path end # GET
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The table then grows a checkbox column and toolbar buttons. Ticked rows submit their
|
|
323
|
+
`identify_by` values as `selected[]=<slug>` to the action's path with the declared method
|
|
324
|
+
(POST/DELETE get a confirm and a global CSRF token; GET just navigates). It is one external
|
|
325
|
+
`<form>` — the checkboxes bind via the HTML `form` attribute (the same trick as the inline
|
|
326
|
+
filter row), and each button targets its action via `formaction`/`formmethod`. **No
|
|
327
|
+
JavaScript required**; the optional `crud-select` controller adds "select all" (visible
|
|
328
|
+
rows), per-group selection and a live count.
|
|
329
|
+
|
|
330
|
+
Your controller resolves the selection with one helper — the gem owns no controllers, so
|
|
331
|
+
you choose the scope and the verb:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
def delete_selected
|
|
335
|
+
books = Book.accessible_by(current_ability) # the same scope you'd render
|
|
336
|
+
CrudComponents.selected(books, params).destroy_all # narrows within it
|
|
337
|
+
redirect_to books_path
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`CrudComponents.selected(scope, params)` turns `selected[]` into
|
|
342
|
+
`scope.where(identify_by => …)`. **Pass the authorized scope you render**, not the bare
|
|
343
|
+
model — selection narrows *within* it, so a tampered slug can never reach a row outside it.
|
|
344
|
+
(A model class also works, e.g. `CrudComponents.selected(Book, params)`, when you don't
|
|
345
|
+
scope and mean the whole table.) There is **no "select all" flag** — selection always
|
|
346
|
+
enumerates the chosen rows, so a stray param can never act on the whole table; "select all"
|
|
347
|
+
is purely the JS convenience of ticking the visible boxes.
|
|
348
|
+
|
|
349
|
+
## The manual query, pagination, and big tables
|
|
350
|
+
|
|
351
|
+

|
|
352
|
+
|
|
353
|
+
By default `crud_collection` renders **everything that matches**. For large tables, take
|
|
354
|
+
the query into your own hands — the explicit form of what the helper does automatically:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
# controller
|
|
358
|
+
@query = CrudComponents::Query.new(Book, params, fieldset: :catalog, ability: current_ability)
|
|
359
|
+
@books = @query.apply(Book.accessible_by(current_ability)).page(params[:page]) # kaminari
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
```erb
|
|
363
|
+
<%= crud_collection @books, query: @query %> <%# records already filtered; footer pager renders itself %>
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Everything stays an ActiveRecord relation, so any paginator and any pre-existing scope
|
|
367
|
+
compose. The manual query is also how you get the filtered relation for counts, CSV
|
|
368
|
+
exports, or charts.
|
|
369
|
+
|
|
370
|
+
**The footer pager renders itself** when the relation you pass is already paginated —
|
|
371
|
+
i.e. you called `.page` and it decorates the relation (kaminari, will_paginate). The gem
|
|
372
|
+
never paginates on its own (no surprise row limits); it only *notices* that you did and
|
|
373
|
+
draws a Bootstrap pager whose links preserve the active filters, search, sort and any
|
|
374
|
+
other collection's params (so it respects `param_prefix:`). Restyle it by overriding
|
|
375
|
+
`crud_components/_pager.html.erb` or the `css.pagination` class.
|
|
376
|
+
|
|
377
|
+
> **pagy** keeps its state in a separate `@pagy` object rather than on the relation, so
|
|
378
|
+
> there's nothing for the gem to detect — render `<%= pagy_nav(@pagy) %>` yourself after
|
|
379
|
+
> `crud_collection`.
|
|
380
|
+
|
|
381
|
+
`page`/`per` are reserved params (with `q`/`sort`/`dir`), so the pager's `?page=` never
|
|
382
|
+
collides with a filter. With `param_prefix: :books`, the page param is `books_page` —
|
|
383
|
+
read it in the controller (`.page(params[:books_page])`).
|
|
384
|
+
|
|
385
|
+
### Several collections on one page
|
|
386
|
+
|
|
387
|
+

|
|
388
|
+
|
|
389
|
+
- **`query: :static`** — a static collection (no filter row, no sort links, params ignored).
|
|
390
|
+
Usually right for a secondary table ("books by this publisher", embedded on the
|
|
391
|
+
publisher page).
|
|
392
|
+
- **`param_prefix:`** — a flat param namespace of its own:
|
|
393
|
+
`crud_collection @books, param_prefix: :books` reads `?books_title=…`, `?books_q=…`,
|
|
394
|
+
`?books_sort=…` and ignores everything unprefixed. URLs stay flat and shareable.
|
|
395
|
+
|
|
396
|
+
## Turbo Streams
|
|
397
|
+
|
|
398
|
+
Rows carry `dom_id`s and render independently, so the markup is morph- and
|
|
399
|
+
stream-friendly out of the box. Add Rails' own `broadcasts_refreshes` to the model and a
|
|
400
|
+
`turbo_stream_from` subscription to the page, and a collection updates live — only the
|
|
401
|
+
changed rows morph, the rest stay put. The gem ships no streaming machinery; it just
|
|
402
|
+
guarantees the markup a broadcast (or Turbo morph refresh) needs. The dummy app's "Live"
|
|
403
|
+
page demonstrates it.
|
|
404
|
+
|
|
405
|
+
See also: [Fields & rendering](fields.md) · [Forms](forms.md) · [Security](security.md).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# A button, per row or per collection. Derived defaults (:new, :show, :edit,
|
|
3
|
+
# :destroy) are self-disabling: they render only when permitted and their
|
|
4
|
+
# conventional route resolves (see RouteResolver).
|
|
5
|
+
class Action
|
|
6
|
+
# Behavioral defaults for the derived actions. Icons live in
|
|
7
|
+
# config.action_icons (see #icon); titles come from i18n (see #title).
|
|
8
|
+
DERIVED = {
|
|
9
|
+
new: { on: :collection },
|
|
10
|
+
show: { on: :row },
|
|
11
|
+
edit: { on: :row },
|
|
12
|
+
destroy: { on: :row, method: :delete, confirm: true, danger: true }
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
KNOWN_OPTIONS = %i[on icon title class confirm method if].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :name, :on, :http_method, :path_block
|
|
18
|
+
|
|
19
|
+
def initialize(name, derived: false, **options, &path_block)
|
|
20
|
+
unknown = options.keys - KNOWN_OPTIONS
|
|
21
|
+
if unknown.any?
|
|
22
|
+
raise DefinitionError, "action :#{name}: unknown option(s) #{unknown.map(&:inspect).join(', ')} — " \
|
|
23
|
+
"known: #{KNOWN_OPTIONS.map(&:inspect).join(', ')}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
defaults = DERIVED[name.to_sym] || {}
|
|
27
|
+
@name = name.to_sym
|
|
28
|
+
@derived = derived
|
|
29
|
+
@on = options[:on] || defaults[:on] || :row
|
|
30
|
+
@icon_option = options[:icon]
|
|
31
|
+
@icon_given = options.key?(:icon)
|
|
32
|
+
@title_option = options[:title]
|
|
33
|
+
@css_class = options[:class]
|
|
34
|
+
@confirm = options.key?(:confirm) ? options[:confirm] : defaults[:confirm]
|
|
35
|
+
@http_method = options[:method] || defaults[:method] || :get
|
|
36
|
+
@condition = options[:if]
|
|
37
|
+
@path_block = path_block
|
|
38
|
+
@danger = defaults[:danger] || false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Icon name (no library prefix — pair with css.icon_prefix). An explicit
|
|
42
|
+
# `icon:` on the action wins (including `icon: nil` for none); otherwise it
|
|
43
|
+
# comes from config.action_icons, which is empty for non-derived actions.
|
|
44
|
+
def icon(config = CrudComponents.config)
|
|
45
|
+
return @icon_option if @icon_given
|
|
46
|
+
|
|
47
|
+
config.action_icons[@name]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def derived? = @derived
|
|
51
|
+
def danger? = @danger
|
|
52
|
+
def collection? = @on == :collection
|
|
53
|
+
def selection? = @on == :selection
|
|
54
|
+
def row? = @on == :row
|
|
55
|
+
|
|
56
|
+
def confirm_message
|
|
57
|
+
return nil unless @confirm
|
|
58
|
+
|
|
59
|
+
@confirm == true ? I18n.t('crud_components.confirm', default: 'Are you sure?') : @confirm
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def title
|
|
63
|
+
@title_option || I18n.t("crud_components.actions.#{name}", default: name.to_s.humanize)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def css_class(config = CrudComponents.config)
|
|
67
|
+
@css_class || (danger? ? config.css.button_danger : config.css.button)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# `context` is the view (when CanCanCan's `can?` is around) or anything
|
|
71
|
+
# can?-shaped. Without an explicit `if:` and without `can?`, the action
|
|
72
|
+
# is shown — permissions are opt-in, not a dependency.
|
|
73
|
+
def permitted?(context, record_or_model)
|
|
74
|
+
if @condition
|
|
75
|
+
model = record_or_model.is_a?(Class) ? record_or_model : record_or_model.class
|
|
76
|
+
record = record_or_model.is_a?(Class) ? nil : record_or_model
|
|
77
|
+
Permission.permitted?(@condition, model, context, record)
|
|
78
|
+
elsif context.respond_to?(:can?)
|
|
79
|
+
context.can?(name, record_or_model)
|
|
80
|
+
else
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|