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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +41 -4
- data/app/assets/stylesheets/crud_components_admin.css +13 -0
- data/app/controllers/crud_components/admin/application_controller.rb +100 -0
- data/app/controllers/crud_components/admin/dashboard_controller.rb +10 -0
- data/app/controllers/crud_components/admin/resources_controller.rb +184 -0
- data/app/views/crud_components/admin/_sidebar.html.erb +20 -0
- data/app/views/crud_components/admin/dashboard/show.html.erb +22 -0
- data/app/views/crud_components/admin/resources/_dependent_records.html.erb +16 -0
- data/app/views/crud_components/admin/resources/delete.html.erb +98 -0
- data/app/views/crud_components/admin/resources/edit.html.erb +14 -0
- data/app/views/crud_components/admin/resources/index.html.erb +26 -0
- data/app/views/crud_components/admin/resources/new.html.erb +14 -0
- data/app/views/crud_components/admin/resources/show.html.erb +16 -0
- data/app/views/layouts/crud_components/admin.html.erb +41 -0
- data/config/locales/crud_components.de.yml +31 -0
- data/config/locales/crud_components.en.yml +31 -0
- data/docs/admin.md +321 -0
- data/docs/fields.md +7 -7
- data/docs/filtering.md +6 -5
- data/docs/views.md +14 -0
- data/lib/crud_components/admin/configuration.rb +122 -0
- data/lib/crud_components/admin/dependents.rb +138 -0
- data/lib/crud_components/admin/engine.rb +22 -0
- data/lib/crud_components/admin/entry.rb +123 -0
- data/lib/crud_components/admin/gate.rb +40 -0
- data/lib/crud_components/admin/registry.rb +176 -0
- data/lib/crud_components/admin/routes.rb +37 -0
- data/lib/crud_components/admin/view_helpers.rb +156 -0
- data/lib/crud_components/admin.rb +92 -0
- data/lib/crud_components/builder.rb +59 -4
- data/lib/crud_components/fields/belongs_to_field.rb +4 -5
- data/lib/crud_components/fields/has_many_field.rb +2 -2
- data/lib/crud_components/helpers.rb +36 -5
- data/lib/crud_components/like_spec.rb +3 -3
- data/lib/crud_components/presenters/collection.rb +16 -4
- data/lib/crud_components/presenters/record.rb +14 -2
- data/lib/crud_components/route_resolver.rb +37 -7
- data/lib/crud_components/structure.rb +15 -1
- data/lib/crud_components/version.rb +1 -1
- data/lib/crud_components.rb +2 -0
- 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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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 :
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 :
|
|
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` (
|
|
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
|
|
19
|
-
|
|
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
|
|
99
|
-
|
|
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
|