crud_components 0.2.0 → 0.3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +41 -4
  4. data/app/assets/stylesheets/crud_components_admin.css +13 -0
  5. data/app/controllers/crud_components/admin/application_controller.rb +100 -0
  6. data/app/controllers/crud_components/admin/dashboard_controller.rb +10 -0
  7. data/app/controllers/crud_components/admin/resources_controller.rb +184 -0
  8. data/app/views/crud_components/admin/_sidebar.html.erb +20 -0
  9. data/app/views/crud_components/admin/dashboard/show.html.erb +22 -0
  10. data/app/views/crud_components/admin/resources/_dependent_records.html.erb +16 -0
  11. data/app/views/crud_components/admin/resources/delete.html.erb +98 -0
  12. data/app/views/crud_components/admin/resources/edit.html.erb +14 -0
  13. data/app/views/crud_components/admin/resources/index.html.erb +26 -0
  14. data/app/views/crud_components/admin/resources/new.html.erb +14 -0
  15. data/app/views/crud_components/admin/resources/show.html.erb +16 -0
  16. data/app/views/layouts/crud_components/admin.html.erb +41 -0
  17. data/config/locales/crud_components.de.yml +31 -0
  18. data/config/locales/crud_components.en.yml +31 -0
  19. data/docs/admin.md +321 -0
  20. data/docs/fields.md +7 -7
  21. data/docs/filtering.md +6 -5
  22. data/docs/views.md +14 -0
  23. data/lib/crud_components/admin/configuration.rb +122 -0
  24. data/lib/crud_components/admin/dependents.rb +138 -0
  25. data/lib/crud_components/admin/engine.rb +22 -0
  26. data/lib/crud_components/admin/entry.rb +123 -0
  27. data/lib/crud_components/admin/gate.rb +40 -0
  28. data/lib/crud_components/admin/registry.rb +176 -0
  29. data/lib/crud_components/admin/routes.rb +37 -0
  30. data/lib/crud_components/admin/view_helpers.rb +156 -0
  31. data/lib/crud_components/admin.rb +92 -0
  32. data/lib/crud_components/builder.rb +59 -4
  33. data/lib/crud_components/fields/belongs_to_field.rb +4 -5
  34. data/lib/crud_components/fields/has_many_field.rb +2 -2
  35. data/lib/crud_components/helpers.rb +36 -5
  36. data/lib/crud_components/like_spec.rb +3 -3
  37. data/lib/crud_components/presenters/collection.rb +16 -4
  38. data/lib/crud_components/presenters/record.rb +14 -2
  39. data/lib/crud_components/route_resolver.rb +37 -7
  40. data/lib/crud_components/structure.rb +15 -1
  41. data/lib/crud_components/version.rb +1 -1
  42. data/lib/crud_components.rb +2 -0
  43. metadata +24 -1
@@ -0,0 +1,16 @@
1
+ <% content_for :title, crud_label(@record) %>
2
+
3
+ <nav aria-label="breadcrumb">
4
+ <ol class="breadcrumb">
5
+ <li class="breadcrumb-item"><%= link_to @entry.label, admin_index_path(@entry) %></li>
6
+ <li class="breadcrumb-item active" aria-current="page"><%= crud_label(@record) %></li>
7
+ </ol>
8
+ </nav>
9
+
10
+ <div class="card">
11
+ <div class="card-body">
12
+ <%= crud_record @record, fieldset: @entry.fieldset,
13
+ extra_actions: [admin_show_in_app_action, admin_delete_action(@entry)].compact,
14
+ except_actions: %i[destroy] %>
15
+ </div>
16
+ </div>
@@ -0,0 +1,41 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= [content_for(:title), admin_title].compact.join(' · ') %></title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%# Bootstrap 5 + Bootstrap Icons, what this layout's markup expects.
9
+ Override this file to load a build of your own instead. %>
10
+ <%= stylesheet_link_tag 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css' %>
11
+ <%= stylesheet_link_tag 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css' %>
12
+ <%= crud_components_styles %>
13
+ <%= admin_styles %>
14
+ </head>
15
+ <body class="bg-body-tertiary">
16
+ <nav class="navbar navbar-dark bg-dark">
17
+ <div class="container-fluid">
18
+ <%= link_to admin_title, root_path, class: 'navbar-brand mb-0 h1 fs-6' %>
19
+ </div>
20
+ </nav>
21
+
22
+ <div class="container-fluid">
23
+ <div class="row">
24
+ <aside class="crud-admin-sidebar col-12 col-md-3 col-xl-2 border-end bg-white py-3">
25
+ <div class="crud-admin-sidebar-inner">
26
+ <%= render 'crud_components/admin/sidebar' %>
27
+ </div>
28
+ </aside>
29
+
30
+ <main class="crud-admin-main col py-3">
31
+ <% %i[notice alert].each do |level| %>
32
+ <% next unless flash[level] %>
33
+ <div class="alert alert-<%= level == :notice ? 'success' : 'danger' %>"><%= flash[level] %></div>
34
+ <% end %>
35
+
36
+ <%= yield %>
37
+ </main>
38
+ </div>
39
+ </div>
40
+ </body>
41
+ </html>
@@ -44,3 +44,34 @@ de:
44
44
  next: "›"
45
45
  previous_label: "Zurück"
46
46
  next_label: "Weiter"
47
+ admin:
48
+ models: "Modelle"
49
+ show_in_app: "In der App ansehen"
50
+ destroy_selected: "Auswahl löschen"
51
+ # Überschriften der Sidebar-Gruppen, Schlüssel ist die deklarierte
52
+ # Gruppe parametrisiert (`admin group: 'Custom properties'` → custom_properties):
53
+ # groups:
54
+ # custom_properties: "Eigene Felder"
55
+ delete:
56
+ title:
57
+ one: "%{model} wirklich löschen?"
58
+ other: "Diese %{count} %{model} wirklich löschen?"
59
+ blocked:
60
+ one: "Solange daran noch etwas hängt, geht das nicht:"
61
+ other: "Solange daran noch etwas hängt, geht das nicht:"
62
+ none_selected: "Es war nichts ausgewählt."
63
+ also_deleted: "Das geht mit:"
64
+ cascades: "(und was daran wiederum hängt)"
65
+ beyond_you: "(einzeln nicht löschbar)"
66
+ kept: "Das bleibt, verliert aber die Zuordnung:"
67
+ nothing_else:
68
+ one: "Es hängt nichts weiter daran."
69
+ other: "Es hängt nichts weiter daran."
70
+ cancel: "Abbrechen"
71
+ notices:
72
+ created: "%{model} angelegt."
73
+ updated: "%{model} gespeichert."
74
+ destroyed: "%{model} gelöscht."
75
+ destroyed_selected:
76
+ one: "1 Datensatz gelöscht."
77
+ other: "%{count} Datensätze gelöscht."
@@ -45,3 +45,34 @@ en:
45
45
  next: "›"
46
46
  previous_label: "Previous"
47
47
  next_label: "Next"
48
+ admin:
49
+ models: "Models"
50
+ show_in_app: "Show in app"
51
+ destroy_selected: "Delete selected"
52
+ # Sidebar group headings, keyed by the declared group parameterized
53
+ # (`admin group: 'Custom properties'` → custom_properties):
54
+ # groups:
55
+ # custom_properties: "Extra fields"
56
+ delete:
57
+ title:
58
+ one: "Delete this %{model}?"
59
+ other: "Delete these %{count} %{model}?"
60
+ blocked:
61
+ one: "This cannot be deleted while it still has:"
62
+ other: "These cannot be deleted while they still have:"
63
+ none_selected: "Nothing was selected."
64
+ also_deleted: "These go with it:"
65
+ cascades: "(and whatever depends on those)"
66
+ beyond_you: "(not deletable on their own)"
67
+ kept: "These stay, but lose the reference:"
68
+ nothing_else:
69
+ one: "Nothing else depends on it."
70
+ other: "Nothing else depends on them."
71
+ cancel: "Cancel"
72
+ notices:
73
+ created: "%{model} created."
74
+ updated: "%{model} updated."
75
+ destroyed: "%{model} deleted."
76
+ destroyed_selected:
77
+ one: "1 record deleted."
78
+ other: "%{count} records deleted."
data/docs/admin.md ADDED
@@ -0,0 +1,321 @@
1
+ # The admin UI
2
+
3
+ A mountable backend over every model in your app: an index with the gem's usual filtering,
4
+ sorting and search, a record view, working forms, and a delete that tells you what it takes
5
+ with it.
6
+
7
+ ![The admin dashboard: a sidebar listing every model, grouped and iconed, and a card per model with its record count](screenshots/admin-dashboard.png)
8
+
9
+ ## Setup
10
+
11
+ **1. Mount it.**
12
+
13
+ ```ruby
14
+ # config/routes.rb
15
+ mount CrudComponents::Admin::Engine => '/admin'
16
+ ```
17
+
18
+ **2. Say who may in** — in the ability, where the rest of your permissions live:
19
+
20
+ ```ruby
21
+ class Ability
22
+ include CanCan::Ability
23
+
24
+ def initialize(user)
25
+ can :access, :crud_admin if user&.admin?
26
+ end
27
+ end
28
+ ```
29
+
30
+ That is the whole gate. No initializer, no second place to look.
31
+
32
+ **3. Visit `/admin`.** Every model with a table is there.
33
+
34
+ ## The gate
35
+
36
+ `can :access, :crud_admin` decides one thing: whether this visitor may open the admin at
37
+ all. `:crud_admin` is not a model — it is a plain symbol standing for the backend itself.
38
+
39
+ **Past the door, nothing changes.** Your ability keeps deciding, model by model and action
40
+ by action, exactly as it does on your own pages: a model you may not `:index` is not in the
41
+ sidebar and its URL is refused, indexes go through `accessible_by`, each write is authorized
42
+ as the action it performs, and `if:`/`editable:` still hide and freeze columns. The gate
43
+ grants entry, not permission.
44
+
45
+ ```ruby
46
+ class Ability
47
+ include CanCan::Ability
48
+
49
+ def initialize(user)
50
+ return unless user
51
+
52
+ can :access, :crud_admin if user.staff? # may open the admin
53
+ can :manage, Book # …and inside it, may do everything with books
54
+ can %i[index show], Author # …and only look at authors
55
+ # no rule for Review → no Review in the sidebar, /admin/reviews refused
56
+ end
57
+ end
58
+ ```
59
+
60
+ So an operator who may open the admin but has no rule for a model sees an admin without it.
61
+ That is the point: one ability, one answer, wherever it is asked.
62
+
63
+ **Denied** requests go through your `authorize!`, so an app that rescues
64
+ `CanCan::AccessDenied` (a redirect to the login page, a flash) keeps doing that; without such
65
+ a handler the admin renders 403.
66
+
67
+ ### Without CanCanCan
68
+
69
+ `auth_with` takes a gate of your own. The block runs as a `before_action` in the admin's
70
+ controller, so `current_user`, `redirect_to`, `head :forbidden` and your `rescue_from`s all
71
+ work as usual:
72
+
73
+ ```ruby
74
+ # config/initializers/crud_components.rb
75
+ CrudComponents::Admin.configure do |config|
76
+ config.auth_with { redirect_to main_app.root_path unless current_user&.admin? }
77
+ end
78
+ ```
79
+
80
+ Anything else that answers `can?(action, subject)` works as the default gate does — the gem
81
+ depends on no authorization library. With neither (nothing answers `can?`, no block) every
82
+ request raises `CrudComponents::Admin::UnauthorizedError`, naming both ways out.
83
+
84
+ `config.auth_with :none` serves the admin with no gate at all — a public demo, a local
85
+ playground. `config.auth_with :cancan, subject: :backend` asks about a symbol of your own,
86
+ for an app that already has one (`can :access, :backend`).
87
+
88
+ If your app already gates routes — a Devise `authenticate` block, a constraint — put the
89
+ mount inside it and keep the ability as the second lock.
90
+
91
+ ## What each model gets
92
+
93
+ | Page | What it does |
94
+ | --- | --- |
95
+ | `/admin/books` | index: filter row, sortable headers, `?q=` search, column picker, pagination |
96
+ | `/admin/books/the-hobbit` | the record as a definition list, with edit / delete / *Show in app* |
97
+ | `/admin/books/new`, `…/edit` | forms, from the same fieldsets and permit list as `crud_form` |
98
+ | `/admin/books/the-hobbit/delete` | [what the delete takes with it](#deleting), then the delete |
99
+ | `/admin/publishers/tor-books/books` | one nested index per to-many association |
100
+
101
+ Everything a model declares in `crud_structure` — labels, icons, renderers, `if:`,
102
+ `editable:`, custom actions — applies here. A model that declares nothing gets the derived
103
+ default.
104
+
105
+ Indexes paginate when a pagination gem is loaded (kaminari, will_paginate); without one
106
+ they render every row.
107
+
108
+ ## Choosing the models
109
+
110
+ By default: every model in `app/models` with a table. Framework tables (Active Storage,
111
+ Action Text, the job and cache backends), other gems' bookkeeping models, HABTM join models
112
+ and STI subclasses stay out.
113
+
114
+ **Drop or pick models in the initializer:**
115
+
116
+ ```ruby
117
+ config.except = %w[Review] # everything but these
118
+ config.only = %w[Book Publisher Author] # exactly these, in this order
119
+ ```
120
+
121
+ **Or decide it on the model**, in the `crud_structure` it already has:
122
+
123
+ ```ruby
124
+ class Review < ApplicationRecord
125
+ include CrudComponents::Model
126
+ crud_structure { admin false } # not in the admin at all
127
+ end
128
+ ```
129
+
130
+ ## Configuring a model
131
+
132
+ ```ruby
133
+ crud_structure do
134
+ admin group: 'Catalog', actions: %i[index show], label: 'Back catalogue'
135
+ end
136
+ ```
137
+
138
+ | Option | What it does |
139
+ | --- | --- |
140
+ | `false` | keeps the model out entirely |
141
+ | `actions:` | which of `%i[index show new create edit update destroy]` exist. What you leave out has **no route** — a hand-crafted `POST` 404s |
142
+ | `group:` | the sidebar heading (defaults to the model's namespace, if any) — [translatable](#translating-the-group-headings) |
143
+ | `label:` | the sidebar label (defaults to the model's human name — translate `activerecord.models.*` and it follows) |
144
+ | `fieldset:` | which fieldset the admin renders (default: `:admin` if you declare one, else every field) |
145
+ | `scope:` | narrows the base relation, e.g. `scope: -> { where(archived: false) }` |
146
+
147
+ **Read-only** is `actions: %i[index show]`.
148
+
149
+ ### Translating the group headings
150
+
151
+ The declared name is the default, not the last word. Each heading is looked up under its own
152
+ key — the declared name parameterized, so `group: 'Custom properties'` reads
153
+ `crud_components.admin.groups.custom_properties`:
154
+
155
+ ```yaml
156
+ de:
157
+ crud_components:
158
+ admin:
159
+ groups:
160
+ custom_properties: "Eigene Felder"
161
+ ```
162
+
163
+ `config.groups` orders by the **declared** name, so the order holds in every locale:
164
+ `config.groups = ['Custom properties']` still puts that group first when its heading reads
165
+ "Eigene Felder". Groups it does not name follow, alphabetically by heading.
166
+
167
+ **A different column set for the backend** than for your app: declare an `:admin` fieldset.
168
+ Without one the admin shows every field, which is usually what you want from a backend —
169
+ the column-picker gear narrows a wide table per view.
170
+
171
+ ```ruby
172
+ fieldset :index, %i[cover title genre price] # what the shop shows
173
+ fieldset :admin, %i[title genre price stock slug active] # what an operator needs
174
+ ```
175
+
176
+ ## Who may do what
177
+
178
+ Beyond the gate, your existing permissions apply unchanged:
179
+
180
+ - **CanCanCan** (or anything answering `can?`): indexes go through `accessible_by`, and
181
+ every action is authorized as the action it performs — `create` as `:create`, `destroy`
182
+ as `:destroy`. A model you may not `:index` is not even listed in the sidebar.
183
+ - **`if:` and `editable:`** hide and freeze columns exactly as they do elsewhere, in the
184
+ query layer too — see [Security](security.md).
185
+ - **Buttons follow the ability**: no `:destroy` on a record, no delete button on its row; none
186
+ on the model, no **Delete selected** in the toolbar. What is refused is not offered.
187
+ - **Forms** permit exactly `CrudComponents.permitted_attributes`, the same list the form
188
+ renders from.
189
+
190
+ ### Hiding a column
191
+
192
+ The admin shows every column of a model, including the dull and the sensitive ones. To keep
193
+ one out, say so:
194
+
195
+ ```ruby
196
+ attribute :api_key, if: false # nowhere, ever
197
+ attribute :internal_note, if: :manage # only for those who may :manage
198
+ fieldset :admin, %i[name email created_at] # not in the admin's list
199
+ ```
200
+
201
+ Nothing is guessed from a column name — a `token` column that is safe to read stays
202
+ readable, and one that is not is your call to make.
203
+
204
+ ## Deleting
205
+
206
+ The trash button opens a confirmation page rather than firing a `DELETE`:
207
+
208
+ ![The delete page for a publisher: what goes with it, what merely loses its reference — each named, linked, and with a link to the rest — and a red Delete button](screenshots/admin-delete.png)
209
+
210
+ It lists, from what the model declares:
211
+
212
+ - **what goes with it** — `dependent: :destroy` / `:destroy_async` / `:delete_all`, with
213
+ counts, plus the record's attachments. Associations whose own targets cascade further are
214
+ marked as such; the count is the first level;
215
+ - **what stays but loses the reference** — `dependent: :nullify`;
216
+ - **what blocks it** — `:restrict_with_error` / `:restrict_with_exception` with rows still
217
+ attached. The Delete button stays disabled while any of those hold.
218
+
219
+ Each group **names its records**, not just their number: the first ten, each linking to its
220
+ own admin page, and past that a link to the index holding the rest — the nested index under
221
+ the record (`/admin/publishers/tor-books/books`), else that model's index filtered by it. An
222
+ attachment names its file and links to it, opened in a new tab. A count tells you how much
223
+ goes; the names tell you what.
224
+
225
+ A cascade can reach further than you: `dependent: :destroy` takes records the ability would
226
+ not let you delete one by one. That is not blocked — the database does it either way — but the
227
+ group is **marked** on the page, so the delete is a decision rather than a surprise.
228
+
229
+ Ticking rows in the index and using **Delete selected** reaches **the same page**, for the
230
+ whole selection (`/admin/books/delete` rather than `/admin/books/hobbit/delete`): one record
231
+ is a selection of one. The ticked records are named, and what goes with them is counted
232
+ across all of them at once. Each record is still checked against the ability on its own —
233
+ what the ability withholds is neither listed nor deleted.
234
+
235
+ ![The delete page for three ticked books: the three named, then the ten reviews and the attachments that go with them](screenshots/admin-delete-selected.png)
236
+
237
+ ## Between the admin and your app
238
+
239
+ **Show in app** sits on every record and every row, and links to the page a visitor would
240
+ see — when there is one. It resolves the conventional route against your application. When
241
+ your URL is not conventional, say so:
242
+
243
+ ```ruby
244
+ crud_structure do
245
+ app_path { |book| main_app.publisher_book_path(book.publisher, book) }
246
+ end
247
+ ```
248
+
249
+ Return `nil` for a record to leave the button off. The helper is public, so an ordinary page
250
+ can use it too: `crud_app_path(record)`.
251
+
252
+ **The other direction** — from an app page into the admin:
253
+
254
+ ```erb
255
+ <% if (url = crud_admin_path(@book, :edit)) %>
256
+ <%= link_to 'Edit in admin', url %>
257
+ <% end %>
258
+ ```
259
+
260
+ `crud_admin_path(record, action = :show)` — also `:index`, `:new` — finds the mount point
261
+ itself and returns `nil` when the admin isn't mounted, the model isn't registered, or that
262
+ action isn't enabled for it.
263
+
264
+ ## Making it fit your app
265
+
266
+ **Its own shell** (the default) is a plain Bootstrap 5 page with the model sidebar. It loads
267
+ Bootstrap and Bootstrap Icons from a CDN; to load a build of your own instead, override the
268
+ layout — `app/views/layouts/crud_components/admin.html.erb` in your app wins over the
269
+ bundled one, the same override rule as every other view here.
270
+
271
+ **Your layout** instead:
272
+
273
+ ```ruby
274
+ config.layout = 'application'
275
+ ```
276
+
277
+ Two things to know when you do: render the sidebar yourself if you want it
278
+ (`render 'crud_components/admin/sidebar'`), and **route helpers in that layout must go
279
+ through `main_app`** — the admin renders inside an engine, so a bare `root_path` there
280
+ resolves against the engine's routes and raises. `main_app.root_path` is safe everywhere.
281
+
282
+ **Individual pages and partials**: everything the admin renders is a partial under
283
+ `app/views/crud_components/admin/`, and a file at the same path in your app wins — the same
284
+ override rule as the rest of the gem ([Extending](extending.md)).
285
+
286
+ ## Configuration reference
287
+
288
+ ```ruby
289
+ CrudComponents::Admin.configure do |config|
290
+ config.auth_with :cancan # the default: `can :access, :crud_admin` in the ability
291
+ config.auth_with :cancan, subject: :backend # …asking about a symbol of your own
292
+ config.auth_with { head :forbidden unless current_user&.admin? } # a gate of your own
293
+ config.auth_with :none # no gate at all — a demo, a local playground
294
+
295
+ config.title = 'Bookstore admin' # brand line
296
+ config.layout = 'crud_components/admin' # the bundled shell, or one of yours
297
+
298
+ config.only = nil # Array of model names, or nil for all
299
+ config.except = [] # Array of model names (or the classes)
300
+ config.groups = ['Catalog', 'People'] # group order, by declared name; the rest follow alphabetically
301
+ config.excluded_namespaces << 'Legacy' # more model-name prefixes to skip
302
+
303
+ config.counts = true # record counts on the dashboard
304
+ config.per_page = 50 # rows per index page
305
+ config.parent_controller = '::ApplicationController' # what the admin's controllers inherit
306
+ end
307
+ ```
308
+
309
+ `parent_controller` is how the admin reaches your `current_user`, your session and your
310
+ `rescue_from`s.
311
+
312
+ ## Worth knowing
313
+
314
+ - **A new model needs a restart.** Routes are generated per model at boot.
315
+ - **Wide tables scroll.** A model with 40 columns gets 40 columns; declare an `:admin`
316
+ fieldset or use the column-picker gear.
317
+ - **A host helper wins over a route helper of the same name.** If your app defines
318
+ `ApplicationHelper#map_path`, the admin's label cells use it and link to *your* page;
319
+ `Edit` and the rest still point into the admin.
320
+ - **A missing table** (a half-migrated database) leaves that model out of the sidebar
321
+ rather than taking the admin down.
data/docs/fields.md CHANGED
@@ -29,7 +29,7 @@ attribute :token, filter: false # opt a derived field out
29
29
  `attributes` (plural) applies shared options to several fields at once:
30
30
 
31
31
  ```ruby
32
- attributes :participants, :owner, if: :manage
32
+ attributes :purchase_price, :shop_margin, if: :manage
33
33
  ```
34
34
 
35
35
  The field universe is always *all* derived columns/associations plus declared computed
@@ -73,7 +73,7 @@ Built-in renderers:
73
73
  * `:enum` — i18n'd badge; nil shows `—`.
74
74
  * `:association` — nil-safe link via the target's `label`.
75
75
  * `:association_list` — "a, b +n more" links.
76
- * `:attachment` — supports `has_one_attached` / `has_many_attached`: each file is drawn by content type — an image inline, a previewable file (e.g. PDF) as a preview, anything else as an icon + filename download link. Sized by surface; a has_many set renders as a row.
76
+ * `:attachment` — supports `has_one_attached` / `has_many_attached`: each file is drawn by content type — an image inline, a previewable file (e.g. PDF) as a preview, anything else as an icon + filename download link. Previews need a previewer binary (poppler/ffmpeg) plus [image_processing](https://github.com/janko/image_processing) and its variant backend — where any is missing, the file falls back to the icon. Sized by surface; a has_many set renders as a row.
77
77
  * `:json` — pretty-printed `<pre>`, syntax-highlighted when [rouge](https://github.com/rouge-ruby/rouge) is present (optional — no rouge, no colors, no error).
78
78
  * `:markdown` — needs one of [commonmarker](https://github.com/gjtorikian/commonmarker), [redcarpet](https://github.com/vmg/redcarpet) or [kramdown](https://github.com/gettalong/kramdown) in your bundle; **raises at boot** if none is present.
79
79
  * `:asciidoc` — needs [asciidoctor](https://github.com/asciidoctor/asciidoctor); **raises at boot** if absent.
@@ -268,9 +268,9 @@ These are not specific to `DynamicColumn` — a declared `attribute :status, hea
268
268
  takes the same options. Everything works in the non-grouped and grouped (`group_by:`) layouts,
269
269
  and plays with the column picker (a hidden column simply renders no header). The
270
270
  `/column_headers` page in `test/dummy` is a full worked example. This is what lets a
271
- participants × mails / × resources **matrix** live entirely in `crud_collection` — one
272
- `DynamicColumn` per mail/resource, its controls in its own header — instead of a hand-built
273
- controls strip above the table.
271
+ books × properties **matrix** live entirely in `crud_collection` — one `DynamicColumn` per
272
+ property, its controls in its own header — instead of a hand-built controls strip above
273
+ the table.
274
274
 
275
275
  ## Path columns
276
276
 
@@ -332,7 +332,7 @@ icon 'book' # default: guessed from the model name (config.model_i
332
332
  Block form: `label { |book| "#{book.title} (#{book.published_on&.year})" }`. With no
333
333
  string column at all it falls back to `"Book #42"` (`model_name.human` + ` #` + id).
334
334
  When the label reaches into associations, declare them with `preload:` so they're
335
- eager-loaded wherever this model is shown — `label :full_title, preload: %i[publisher]`
335
+ eager-loaded wherever this model is shown — `label :display_title, preload: %i[publisher]`
336
336
  ([Performance](performance.md#eager-loading-render-dependencies)).
337
337
  - **`identify_by`** — the column URL params use to identify a record of this model. With
338
338
  `identify_by :slug`, a filter URL reads `?publisher=tor-books` and resolves via
@@ -402,7 +402,7 @@ with `preload: %i[publisher]` — [Performance](performance.md#eager-loading-ren
402
402
  | json | `<pre>` (rouge if present) | — | — | not form-editable in v1 |
403
403
  | Active Storage attachment | image / preview / icon by content type | — | — | form shows current; keep/add/remove via signed_ids |
404
404
  | `belongs_to` | nil-safe link via target `label` | select (≤ `select_limit`) / text over target `label` | v2 | resolves by `identify_by` |
405
- | `has_many` / habtm | "a, b +n more" links | text over the children's `label` (block label: facet) | no | "+n more" links to nested/filtered index |
405
+ | `has_many` / habtm | "a, b +n more" links | text over the children's `label` (columnless label: facet) | no | "+n more" links to nested/filtered index |
406
406
  | public method | by value type | — | — | needs a facet to filter/sort |
407
407
  | `render` block | block output | — | — | facets add filter/sort |
408
408
 
data/docs/filtering.md CHANGED
@@ -15,9 +15,9 @@ see") see [security.md](security.md).
15
15
  Every column is filterable by default through the control its type implies — a string by
16
16
  substring, a number/date by range, an enum/boolean by select, an association by its target's
17
17
  **label** (the name shown in the cell): a `belongs_to` as a select/text, a `has_many`/habtm as
18
- text matching any child. A *computed* column, or an association whose label is a block (no
19
- single column to match), opts in with the `filter` facet (a [search spec](#the-search-spec) or
20
- a block):
18
+ text matching any child. A *computed* column, or an association whose label has no column
19
+ behind it a block, or a method name like `label :display_title` opts in with the `filter`
20
+ facet (a [search spec](#the-search-spec) or a block):
21
21
 
22
22
  ```ruby
23
23
  attribute :author_names do
@@ -95,8 +95,9 @@ filter :title, { authors: :name } # mixed
95
95
  The **label form** — an association name *without* columns — matches the target's
96
96
  **label**: the name shown in that association's cell ("search what you see"). It is the
97
97
  idiomatic style and never reaches the target's other columns, so a secret column on the
98
- target can't be probed through an association. When the target's label is a custom block
99
- (no single column to match), spell the columns out (`filter authors: :name`).
98
+ target can't be probed through an association. When the target's label has no column behind
99
+ it a custom block, or a method name like `label :display_title` — spell the columns out
100
+ (`filter authors: :name`).
100
101
 
101
102
  The gem turns a spec into `left_joins` plus parameterized, wildcard-escaped `ILIKE`
102
103
  (via `sanitize_sql_like` with an explicit `\` escape char, so `%`, `_` and `\` are all
data/docs/views.md CHANGED
@@ -232,6 +232,20 @@ Resolution tries the most specific conventional route first and falls back outwa
232
232
  if it resolves, else the target's filtered index (`books_path(publisher: owner)`), else
233
233
  plain text.
234
234
 
235
+ ### Per-surface actions
236
+
237
+ A button that belongs to *this page* rather than to the model goes in `extra_actions:`,
238
+ appended to the row actions of that one render:
239
+
240
+ ```erb
241
+ <%= crud_collection @books, extra_actions: [my_action] %>
242
+ <%= crud_record @book, extra_actions: [my_action] %>
243
+ ```
244
+
245
+ They are ordinary `CrudComponents::Action` objects and go through the same permission
246
+ check and route resolution as declared ones — one that does not resolve is omitted. The
247
+ [admin UI](admin.md#how-the-button-gets-there) uses this for its "Show in app" button.
248
+
235
249
  ### Declaring actions
236
250
 
237
251
  ```ruby
@@ -0,0 +1,122 @@
1
+ module CrudComponents
2
+ module Admin
3
+ # What the mounted admin needs to know that the models don't say themselves.
4
+ #
5
+ # CrudComponents::Admin.configure do |config|
6
+ # config.auth_with { head :forbidden unless current_user&.admin? }
7
+ # config.title = 'Bookstore admin'
8
+ # end
9
+ class Configuration
10
+ # Model name prefixes that are framework bookkeeping, not application data.
11
+ DEFAULT_EXCLUDED_NAMESPACES = %w[
12
+ ActiveRecord ActiveStorage ActionText ActionMailbox
13
+ SolidQueue SolidCache SolidCable
14
+ Delayed GoodJob Que Noticed PgSearch FriendlyId
15
+ ].freeze
16
+
17
+ # Sidebar brand line; defaults to the application's name.
18
+ attr_accessor :title
19
+
20
+ # The layout the engine renders in. 'crud_components/admin' is the bundled
21
+ # Bootstrap shell; name one of your own (e.g. 'application') instead.
22
+ attr_accessor :layout
23
+
24
+ # nil = every discovered model. An Array of model names (String, Symbol or
25
+ # class) means exactly those, in that order, discovery filters bypassed.
26
+ attr_accessor :only
27
+
28
+ # Model names to drop from an otherwise automatic registry.
29
+ attr_accessor :except
30
+
31
+ # Sidebar group order. Groups not listed follow, alphabetically.
32
+ attr_accessor :groups
33
+
34
+ # Model name prefixes to skip during discovery.
35
+ attr_accessor :excluded_namespaces
36
+
37
+ # Whether the dashboard runs a COUNT(*) per model.
38
+ attr_accessor :counts
39
+
40
+ # The controller the engine's own controllers inherit from — how the admin
41
+ # reaches your `current_user`, your session and your `rescue_from`s.
42
+ attr_accessor :parent_controller
43
+
44
+ # Rows per index page, when a pagination gem is present.
45
+ attr_accessor :per_page
46
+
47
+ # How the admin decides who gets in: :cancan, :none, or :block when
48
+ # `auth_with` was given one. See {#auth_with}.
49
+ attr_reader :auth_mode
50
+
51
+ # The `before_action` body that decides who gets in, for `auth_with { … }`.
52
+ attr_reader :auth_block
53
+
54
+ # What the :cancan gate asks about: `can?(:access, auth_subject)`.
55
+ attr_accessor :auth_subject
56
+
57
+ def initialize
58
+ @title = nil
59
+ @layout = 'crud_components/admin'
60
+ @only = nil
61
+ @except = []
62
+ @groups = []
63
+ @excluded_namespaces = DEFAULT_EXCLUDED_NAMESPACES.dup
64
+ @counts = true
65
+ @parent_controller = '::ApplicationController'
66
+ @per_page = 50
67
+ @auth_mode = :cancan
68
+ @auth_subject = :crud_admin
69
+ @auth_block = nil
70
+ end
71
+
72
+ # The resolved parent controller class, falling back to ActionController::Base
73
+ # when the named one does not exist.
74
+ def parent_controller_class
75
+ @parent_controller.to_s.safe_constantize || ActionController::Base
76
+ end
77
+
78
+ # Who gets in. Three forms:
79
+ #
80
+ # config.auth_with :cancan # the default: `can :access, :crud_admin`
81
+ # config.auth_with :cancan, subject: :backend
82
+ # config.auth_with :none # no gate at all — a demo, a playground
83
+ # config.auth_with { redirect_to main_app.root_path unless current_user&.admin? }
84
+ #
85
+ # A block runs as a `before_action` in the engine's controller, in that
86
+ # controller's own context — `current_user`, `redirect_to`, `head` and
87
+ # your `rescue_from`s all work as usual.
88
+ def auth_with(mode = nil, subject: nil, &block)
89
+ raise ArgumentError, 'auth_with takes a mode or a block, not both' if mode && block
90
+
91
+ @auth_subject = subject if subject
92
+ @auth_mode = block ? :block : normalized_mode(mode)
93
+ @auth_block = block
94
+ end
95
+
96
+ def cancan_gate? = @auth_mode == :cancan
97
+
98
+ def open_gate? = @auth_mode == :none
99
+
100
+ def resolved_title
101
+ @title || default_title
102
+ end
103
+
104
+ private
105
+
106
+ MODES = %i[cancan cancancan ability none].freeze
107
+
108
+ def normalized_mode(mode)
109
+ raise ArgumentError, "auth_with: unknown mode #{mode.inspect}, one of #{MODES.inspect}" unless
110
+ MODES.include?(mode)
111
+
112
+ mode == :none ? :none : :cancan
113
+ end
114
+
115
+ def default_title
116
+ app = defined?(Rails) && Rails.respond_to?(:application) && Rails.application
117
+ name = app&.class&.module_parent_name
118
+ name ? "#{name.underscore.humanize} admin" : 'Admin'
119
+ end
120
+ end
121
+ end
122
+ end