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/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
+ ![A collection table derived from the schema — cover thumbnails, genre badge, currency, publisher links and a boolean icon, with header search, an inline filter row, sortable columns, row actions and a standalone filter sidebar](screenshots/table.png)
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
+ ![A record detail view (a definition list) with the column-picker gear open above it, narrowing which fields the dl shows](screenshots/record-picker.png)
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
+ ![A paginated table: a footer pager seated in the table's tfoot — "Page 3 of 15 · 120 total" on the left and a windowed page control on the right](screenshots/pagination.png)
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
+ ![Two independent collections (Books and Reviews) side by side on one page, each with its own search, filters and sort, isolated by param_prefix](screenshots/dashboard.png)
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