crud_components 0.2.1 → 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 +17 -0
- data/README.md +39 -2
- 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 +1 -1
- 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 +56 -1
- data/lib/crud_components/helpers.rb +35 -4
- 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 +8 -1
- data/lib/crud_components/version.rb +1 -1
- data/lib/crud_components.rb +2 -0
- metadata +24 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6507798a0f053a225223897f37877c6fd10f3ba553edb7c446cb49554033d828
|
|
4
|
+
data.tar.gz: 66da668cd27252924532484e8a3b81eb6ee0ad94d5616edc08ee7e9cf7dffe1e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb019084e1d48b34133874cbffe5c19c9a80b7f2d7b13fc7d07d832015a52c6a2a9d1d457597a43bed9d2c5c50dd67b43b53fb763bee642f60d4b4208217e2b1
|
|
7
|
+
data.tar.gz: '08a9b30a374459df1ab0d034c5e260b5e9c5a4e2ff2f2639b38d9d2cd002250b4683f5550fb660597ff377e527b2c5df9febe0c34f316daf48e9e61a397199db'
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ This project follows [semantic versioning](https://semver.org).
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## v0.3.0 — 2026-08-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **An optional, mountable admin UI.** `mount CrudComponents::Admin::Engine => '/admin'` gives every model an index, a record view and working forms, derived from the same `crud_structure` your own pages use — no scaffold per model and no second rendering path. The engine draws real conventional `resources` routes per model, which is why every existing link (derived actions, association links, `+n more`, `crud_form`'s inferred URL) resolves inside it unchanged. Models are discovered automatically; framework tables, HABTM join models and STI subclasses are skipped. Who may in is one line in the ability you already have — `can :access, :crud_admin` — and it decides exactly that one thing: past the door your ordinary rules keep deciding, model by model and action by action, so an operator with no rule for a model gets an admin without it. Where there is no CanCanCan, `auth_with { … }` takes a gate of your own and `auth_with :none` serves without one on purpose. Beyond the gate `accessible_by`, `if:`/`editable:` and the derived permit list apply exactly as they do elsewhere. Includes a sidebar whose group headings go through i18n (`crud_components.admin.groups.*`, ordered by the declared name so the order holds in every locale) and counts, nested indexes per to-many association, bulk delete, and German/English strings. ([#45](https://github.com/itadventurer/crud_components/issues/45)–[#51](https://github.com/itadventurer/crud_components/issues/51), [#69](https://github.com/itadventurer/crud_components/issues/69), [#73](https://github.com/itadventurer/crud_components/issues/73))
|
|
13
|
+
- `admin` in `crud_structure` — the per-model half of that: `admin false` keeps a model out entirely, `admin actions: %i[index show]` makes it read-only (the write routes are never drawn, so a hand-crafted `POST` 404s), plus `group:`, `label:`, `fieldset:` and `scope:`. Inert when the admin engine isn't loaded.
|
|
14
|
+
- `app_path { |book| … }` in `crud_structure`, and the `crud_app_path(record)` helper: the host application's own page for a record, for when it is not the conventional route. Backs the admin's "Show in app" button.
|
|
15
|
+
- `crud_admin_path(record, action = :show)` — the way back in, from an ordinary page to the mounted admin. Finds the mount point itself; nil when the admin isn't mounted or that action isn't enabled for the model.
|
|
16
|
+
- **The admin's delete goes through a confirmation page** that counts what the delete would take with it: associations declaring `dependent: :destroy`/`:delete_all` (with a marker when those cascade further), Active Storage attachments, what `:nullify` would orphan, and what `:restrict_with_*` blocks — the Delete button stays disabled while anything blocks. Each group **names** its records rather than only counting them: the first ten, each a link to its own page (an attachment names its file and links to it), then a link to the index holding the rest. **Delete selected** reaches the same page for the whole selection — one record is a selection of one, so it is literally the same template and the same code path — with the ticked records named and their dependents counted across all of them, instead of deleting behind a browser dialog. A group the ability would not let you delete one by one is marked as such — a cascade takes it either way. Delete buttons follow the ability too: what is refused is not offered. A browser dialog cannot say any of that.
|
|
17
|
+
- `crud_collection` and `crud_record` take `except_actions:` — action names one render drops, whatever the model declares. The admin uses it to replace the derived `:destroy` button with a link to that page.
|
|
18
|
+
- `crud_collection` and `crud_record` take `extra_actions:` — row (or selection) actions appended for one render, for a button that belongs to the surface rather than to the model. Same permission check and route resolution as declared actions.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- A declared action whose `path` block names a route helper that does not exist in the current route set now omits the button instead of raising — the rule derived actions already followed. Only for names ending in `_path`/`_url`, so a typo in an app's own block still raises loudly.
|
|
23
|
+
- The bundled playground (`test/dummy`) boots on hosts without the native libvips — the demo image is one, and so is any checkout that installed the gems but not the library. Active Storage's variant processor is switched off when libvips is missing, so attachments show as icon + filename instead of aborting the boot with `LoadError: Could not open library 'libvips.so.42'`. Nothing in the gem itself changes.
|
|
24
|
+
|
|
8
25
|
## v0.2.1 — 2026-08-01
|
|
9
26
|
|
|
10
27
|
### Changed
|
data/README.md
CHANGED
|
@@ -102,6 +102,38 @@ A single record and a standalone filter form work the same way:
|
|
|
102
102
|
The rest of this README is a short "now I want to…" tour. Every step is optional; nothing below is
|
|
103
103
|
required for any view above to work. Each section links to its in-depth reference.
|
|
104
104
|
|
|
105
|
+
## …and a whole admin, if you want one
|
|
106
|
+
|
|
107
|
+
The same one-line idea, one level up: mount the optional admin engine and **every** model
|
|
108
|
+
has a backend — index, record view, forms, delete.
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# config/routes.rb
|
|
112
|
+
mount CrudComponents::Admin::Engine => '/admin'
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+

|
|
116
|
+
|
|
117
|
+
It renders from the same `crud_structure` your own pages use — no scaffold per model, no
|
|
118
|
+
second rendering path — and who may in is **one line in the ability you already have**
|
|
119
|
+
(past the door your ordinary rules keep deciding, model by model):
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class Ability
|
|
123
|
+
include CanCan::Ability
|
|
124
|
+
|
|
125
|
+
def initialize(user)
|
|
126
|
+
can :access, :crud_admin if user&.admin? # may open the admin
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
No CanCanCan? `config.auth_with { … }` takes a gate of your own.
|
|
132
|
+
|
|
133
|
+
`admin false` keeps a model out, `admin actions: %i[index show]` makes it read-only (the
|
|
134
|
+
write routes are never drawn), and every record links back to the page a visitor would see.
|
|
135
|
+
→ [The admin UI](docs/admin.md)
|
|
136
|
+
|
|
105
137
|
## The tour
|
|
106
138
|
|
|
107
139
|
### Choose the columns
|
|
@@ -411,11 +443,15 @@ map plus a few partials, never a fork. → [Extending → styling](docs/extendin
|
|
|
411
443
|
|
|
412
444
|
```ruby
|
|
413
445
|
crud_collection(records, fieldset: nil, layout: :table, query: :auto, param_prefix: nil,
|
|
414
|
-
actions: true, group_by: nil, extra_columns: nil, picker: false, picked_columns: :auto
|
|
415
|
-
|
|
446
|
+
actions: true, group_by: nil, extra_columns: nil, picker: false, picked_columns: :auto,
|
|
447
|
+
extra_actions: nil)
|
|
448
|
+
crud_record(record, fieldset: nil, actions: true, layout: :record, picked_columns: :auto,
|
|
449
|
+
extra_actions: nil)
|
|
416
450
|
crud_filter(model, fieldset: nil, query: nil, param_prefix: nil, layout: :filter)
|
|
417
451
|
crud_form(record, fieldset: nil, action: nil, url: nil, method: nil, layout: :form)
|
|
418
452
|
crud_actions(record_or_model, fieldset: nil)
|
|
453
|
+
crud_app_path(record) # the host app's own page for a record, or nil
|
|
454
|
+
crud_admin_path(record, :show) # the mounted admin's page for it, or nil
|
|
419
455
|
```
|
|
420
456
|
|
|
421
457
|
`crud_collection` takes a **relation** (`@books`, `Book.all`, or an authorized scope),
|
|
@@ -475,6 +511,7 @@ CrudComponents.configure { |config| … } # css/icon maps, select_limit, def
|
|
|
475
511
|
| [docs/security.md](docs/security.md) | Permissions (`if:`/`editable:`), the whitelist, and the injection-safe URL model |
|
|
476
512
|
| [docs/extending.md](docs/extending.md) | Partials/renderers/layouts, progressive enhancement, styling, i18n |
|
|
477
513
|
| [docs/performance.md](docs/performance.md) | Eager-loading, the belongs_to select→text threshold, pagination |
|
|
514
|
+
| [docs/admin.md](docs/admin.md) | The optional mountable admin UI: discovery, authorization, Show in App |
|
|
478
515
|
|
|
479
516
|
|
|
480
517
|
## Dependencies
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* crud_components admin — the few rules the bundled layout needs beyond
|
|
2
|
+
* Bootstrap. Loaded only inside the mounted admin.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/* A wide table scrolls inside the content column instead of pushing the
|
|
6
|
+
* sidebar out of the row. */
|
|
7
|
+
.crud-admin-main { min-width: 0; }
|
|
8
|
+
.crud-admin-main .card-body { overflow-x: auto; }
|
|
9
|
+
|
|
10
|
+
.crud-admin-sidebar { --crud-admin-sidebar-top: 1rem; }
|
|
11
|
+
.crud-admin-sidebar > .crud-admin-sidebar-inner { position: sticky; top: var(--crud-admin-sidebar-top); }
|
|
12
|
+
|
|
13
|
+
.crud-admin-nav .nav-link { border-radius: .25rem; }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Admin
|
|
3
|
+
# Base for the engine's controllers. Inherits from the host's controller
|
|
4
|
+
# (see `config.parent_controller`), so `current_user`, the session and any
|
|
5
|
+
# `rescue_from` are the app's own.
|
|
6
|
+
class ApplicationController < CrudComponents::Admin.config.parent_controller_class
|
|
7
|
+
# Active Storage's route helpers live in the application's route set, not
|
|
8
|
+
# in an isolated engine's — attachment cells call them by bare name.
|
|
9
|
+
ACTIVE_STORAGE_HELPERS = %i[
|
|
10
|
+
rails_blob_path rails_blob_url rails_representation_path rails_representation_url
|
|
11
|
+
rails_storage_proxy_path rails_storage_proxy_url
|
|
12
|
+
rails_storage_redirect_path rails_storage_redirect_url
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
layout -> { CrudComponents::Admin.config.layout }
|
|
16
|
+
helper CrudComponents::Admin::ViewHelpers
|
|
17
|
+
|
|
18
|
+
helper do
|
|
19
|
+
ACTIVE_STORAGE_HELPERS.each do |helper_name|
|
|
20
|
+
define_method(helper_name) do |*args, **options|
|
|
21
|
+
main_app.public_send(helper_name, *args, **options)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
before_action :run_admin_gate!
|
|
27
|
+
|
|
28
|
+
rescue_from CrudComponents::Admin::ForbiddenError do |error|
|
|
29
|
+
render plain: error.message, status: :forbidden
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
helper_method :admin_config, :admin_registry, :admin_entries, :admin_entry_groups
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def admin_config = CrudComponents::Admin.config
|
|
37
|
+
|
|
38
|
+
def admin_registry = CrudComponents::Admin.registry
|
|
39
|
+
|
|
40
|
+
# The models the navigation offers: routed, allowed, and with a table
|
|
41
|
+
# behind them (a half-migrated database leaves one out rather than
|
|
42
|
+
# offering a link that only errors).
|
|
43
|
+
def admin_entries
|
|
44
|
+
@admin_entries ||= admin_registry.entries.select do |entry|
|
|
45
|
+
CrudComponents::Admin.path_for(entry.model, :index) && readable?(entry) && table?(entry)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def table?(entry)
|
|
50
|
+
entry.model.table_exists?
|
|
51
|
+
rescue ActiveRecord::ActiveRecordError
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def admin_entry_groups = admin_registry.groups(admin_entries)
|
|
56
|
+
|
|
57
|
+
def readable?(entry)
|
|
58
|
+
ability = admin_ability
|
|
59
|
+
ability.nil? || ability.can?(:index, entry.model)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The entry's relation, narrowed by the ability when one can scope.
|
|
63
|
+
def admin_scope(entry)
|
|
64
|
+
scope = entry.scope
|
|
65
|
+
ability = cancan_ability
|
|
66
|
+
return scope unless ability && scope.respond_to?(:accessible_by)
|
|
67
|
+
|
|
68
|
+
scope.accessible_by(ability)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def run_admin_gate!
|
|
72
|
+
gate = Gate.new(admin_config, admin_ability)
|
|
73
|
+
|
|
74
|
+
case gate.verdict
|
|
75
|
+
when :block then instance_exec(&admin_config.auth_block)
|
|
76
|
+
when :unauthorized then raise UnauthorizedError, gate.unauthorized_message
|
|
77
|
+
when :forbidden
|
|
78
|
+
# Through the host's `authorize!` first, so its own `rescue_from` decides.
|
|
79
|
+
authorize!(Gate::ACTION, admin_config.auth_subject) if respond_to?(:authorize!, true)
|
|
80
|
+
raise ForbiddenError, gate.forbidden_message
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Whatever answers `can?` here: a CanCanCan ability, else the controller
|
|
85
|
+
# itself when the host defined `can?` on it, else nothing.
|
|
86
|
+
def admin_ability
|
|
87
|
+
return @admin_ability if defined?(@admin_ability)
|
|
88
|
+
|
|
89
|
+
@admin_ability = if respond_to?(:current_ability, true) then current_ability
|
|
90
|
+
elsif respond_to?(:can?, true) then self
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Only a real CanCanCan ability can scope a relation.
|
|
95
|
+
def cancan_ability
|
|
96
|
+
current_ability if respond_to?(:current_ability, true)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Admin
|
|
3
|
+
# The mount root: what there is to administer.
|
|
4
|
+
class DashboardController < ApplicationController
|
|
5
|
+
def show
|
|
6
|
+
@counts = admin_entries.to_h { |entry| [entry.name, admin_scope(entry).count] } if admin_config.counts
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Admin
|
|
3
|
+
# Every registered model's CRUD, in one controller. The model comes from the
|
|
4
|
+
# route's `crud_model` default, so a request can only ever name a model the
|
|
5
|
+
# registry drew a route for.
|
|
6
|
+
class ResourcesController < ApplicationController
|
|
7
|
+
before_action :set_entry
|
|
8
|
+
before_action :set_record, only: %i[show edit update destroy delete]
|
|
9
|
+
before_action :build_record, only: %i[new create]
|
|
10
|
+
before_action :authorize_action!
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
@owner = owner
|
|
14
|
+
@query = CrudComponents::Query.new(@model, params, fieldset: @entry.fieldset, ability: admin_ability)
|
|
15
|
+
@records = paginate(@query.apply(base_scope))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show; end
|
|
19
|
+
|
|
20
|
+
def new; end
|
|
21
|
+
|
|
22
|
+
def edit; end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
@record.assign_attributes(record_params(:create))
|
|
26
|
+
if @record.save
|
|
27
|
+
redirect_to after_save_path(@record), notice: saved_notice(:created)
|
|
28
|
+
else
|
|
29
|
+
render :new, status: :unprocessable_entity
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def update
|
|
34
|
+
if @record.update(record_params(:update))
|
|
35
|
+
redirect_to after_save_path(@record), notice: saved_notice(:updated)
|
|
36
|
+
else
|
|
37
|
+
render :edit, status: :unprocessable_entity
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The confirmation step, for one record or for the ticked rows: which they
|
|
42
|
+
# are, and what goes with them.
|
|
43
|
+
def delete
|
|
44
|
+
@records = [@record]
|
|
45
|
+
@dependents = Dependents::Selection.new(@records)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def delete_selected
|
|
49
|
+
@records = selected_records
|
|
50
|
+
@dependents = Dependents::Selection.new(@records)
|
|
51
|
+
render :delete
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def destroy
|
|
55
|
+
@record.destroy!
|
|
56
|
+
redirect_to index_path, notice: saved_notice(:destroyed)
|
|
57
|
+
rescue ActiveRecord::InvalidForeignKey, ActiveRecord::DeleteRestrictionError => e
|
|
58
|
+
redirect_to after_save_path(@record), alert: e.message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def destroy_selected
|
|
62
|
+
records = selected_records
|
|
63
|
+
records.each(&:destroy!)
|
|
64
|
+
redirect_to index_path,
|
|
65
|
+
notice: t('crud_components.admin.notices.destroyed_selected', count: records.size,
|
|
66
|
+
default: '%{count} deleted.')
|
|
67
|
+
rescue ActiveRecord::InvalidForeignKey, ActiveRecord::DeleteRestrictionError => e
|
|
68
|
+
redirect_to index_path, alert: e.message
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# The ticked rows, each checked against the ability on its own.
|
|
74
|
+
def selected_records
|
|
75
|
+
CrudComponents.selected(base_scope, params).select { |record| allowed?(:destroy, record) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def set_entry
|
|
79
|
+
@entry = admin_registry[params[:crud_model]]
|
|
80
|
+
raise ActiveRecord::RecordNotFound, "no admin entry for #{params[:crud_model].inspect}" unless @entry
|
|
81
|
+
|
|
82
|
+
@model = @entry.model
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A nested index (`/admin/publishers/tor-books/books`) renders the owner's
|
|
86
|
+
# association; everything else renders the model's own scope.
|
|
87
|
+
def base_scope
|
|
88
|
+
return admin_scope(@entry) unless owner
|
|
89
|
+
|
|
90
|
+
owner.public_send(params[:crud_association])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def owner_entry
|
|
94
|
+
return @owner_entry if defined?(@owner_entry)
|
|
95
|
+
|
|
96
|
+
@owner_entry = params[:crud_owner] && admin_registry[params[:crud_owner]]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def owner
|
|
100
|
+
return @owner if defined?(@owner)
|
|
101
|
+
|
|
102
|
+
entry = owner_entry
|
|
103
|
+
return @owner = nil unless entry
|
|
104
|
+
|
|
105
|
+
record = find_record(admin_scope(entry), params["#{entry.singular_route_key}_id"], entry)
|
|
106
|
+
raise ForbiddenError, "not allowed to show this #{entry.model.model_name.human}" unless allowed?(:show, record)
|
|
107
|
+
|
|
108
|
+
@owner = record
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def set_record
|
|
112
|
+
@record = find_record(base_scope, params[:id])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_record
|
|
116
|
+
@record = @model.new
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A URL segment matches `identify_by` first, then the primary key — so a
|
|
120
|
+
# model whose `to_param` is a slug and one that uses ids both resolve.
|
|
121
|
+
def find_record(scope, param, entry = @entry)
|
|
122
|
+
key = entry.param_key
|
|
123
|
+
found = key.to_s == 'id' ? nil : scope.find_by(key => param)
|
|
124
|
+
found ||= scope.find_by(entry.model.primary_key => param)
|
|
125
|
+
raise ActiveRecord::RecordNotFound, "#{entry.model}: no record for #{param.inspect}" unless found
|
|
126
|
+
|
|
127
|
+
found
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The engine's own actions, mapped to the RESTful one an ability knows.
|
|
131
|
+
ACTION_PERMISSIONS = { destroy_selected: :destroy, delete_selected: :destroy, delete: :destroy }.freeze
|
|
132
|
+
|
|
133
|
+
# Otherwise the action the request performs is the one authorized.
|
|
134
|
+
# CanCanCan aliases :new to :create and :edit to :update, so a rule
|
|
135
|
+
# written either way still applies; an ability that tells them apart is
|
|
136
|
+
# asked the narrower question of the two.
|
|
137
|
+
def authorize_action!
|
|
138
|
+
subject = @record || @model
|
|
139
|
+
permission = ACTION_PERMISSIONS.fetch(action_name.to_sym, action_name.to_sym)
|
|
140
|
+
return if allowed?(permission, subject)
|
|
141
|
+
|
|
142
|
+
# Through the host's `authorize!` first, so its own `rescue_from` decides.
|
|
143
|
+
authorize!(permission, subject) if respond_to?(:authorize!, true)
|
|
144
|
+
|
|
145
|
+
raise ForbiddenError, "not allowed to #{permission} this #{@model.model_name.human}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def allowed?(permission, subject)
|
|
149
|
+
ability = admin_ability
|
|
150
|
+
ability.nil? || ability.can?(permission, subject)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def record_params(action)
|
|
154
|
+
permitted = CrudComponents.permitted_attributes(@model, action: action, ability: admin_ability)
|
|
155
|
+
return {} if permitted.empty?
|
|
156
|
+
|
|
157
|
+
params.require(@model.model_name.param_key).permit(*permitted)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def paginate(scope)
|
|
161
|
+
return scope unless scope.respond_to?(:page)
|
|
162
|
+
|
|
163
|
+
scope = scope.page(params[:page])
|
|
164
|
+
scope.respond_to?(:per) ? scope.per(admin_config.per_page) : scope
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# One controller serves every model, so `url_for(action:)` cannot tell the
|
|
168
|
+
# resources apart — go through the entry's own named helpers.
|
|
169
|
+
def index_path = public_send("#{@entry.route_key}_path")
|
|
170
|
+
|
|
171
|
+
def after_save_path(record)
|
|
172
|
+
return public_send("#{@entry.singular_route_key}_path", record) if @entry.allows?(:show)
|
|
173
|
+
return public_send("edit_#{@entry.singular_route_key}_path", record) if @entry.allows?(:edit)
|
|
174
|
+
|
|
175
|
+
index_path
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def saved_notice(key)
|
|
179
|
+
t("crud_components.admin.notices.#{key}", model: @model.model_name.human,
|
|
180
|
+
default: "%{model} #{key}.")
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%# The model navigation. Rendered by the bundled layout; render it yourself
|
|
2
|
+
(`render 'crud_components/admin/sidebar'`) when the admin runs in a layout
|
|
3
|
+
of your own. %>
|
|
4
|
+
<nav class="crud-admin-nav" aria-label="<%= t('crud_components.admin.models', default: 'Models') %>">
|
|
5
|
+
<% admin_entry_groups.each do |heading, entries| %>
|
|
6
|
+
<% if heading %>
|
|
7
|
+
<div class="text-uppercase text-muted small fw-semibold px-2 mt-3 mb-1"><%= heading %></div>
|
|
8
|
+
<% end %>
|
|
9
|
+
<ul class="nav nav-pills flex-column mb-1">
|
|
10
|
+
<% entries.each do |entry| %>
|
|
11
|
+
<li class="nav-item">
|
|
12
|
+
<%= link_to admin_index_path(entry),
|
|
13
|
+
class: "nav-link py-1 px-2 #{'active' if admin_current_entry?(entry)}" do %>
|
|
14
|
+
<%= admin_icon(entry.icon, css_class: 'me-1') %><%= entry.label %>
|
|
15
|
+
<% end %>
|
|
16
|
+
</li>
|
|
17
|
+
<% end %>
|
|
18
|
+
</ul>
|
|
19
|
+
<% end %>
|
|
20
|
+
</nav>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<h1 class="h4 mb-3"><%= admin_title %></h1>
|
|
2
|
+
|
|
3
|
+
<% admin_entry_groups.each do |group, entries| %>
|
|
4
|
+
<% if group %>
|
|
5
|
+
<h2 class="h6 text-uppercase text-muted mt-4"><%= group %></h2>
|
|
6
|
+
<% end %>
|
|
7
|
+
|
|
8
|
+
<div class="row row-cols-1 row-cols-sm-2 row-cols-lg-4 g-3">
|
|
9
|
+
<% entries.each do |entry| %>
|
|
10
|
+
<div class="col">
|
|
11
|
+
<%= link_to admin_index_path(entry), class: 'card h-100 text-decoration-none text-reset' do %>
|
|
12
|
+
<div class="card-body d-flex justify-content-between align-items-center gap-2">
|
|
13
|
+
<span><%= admin_icon(entry.icon, css_class: 'me-1') %><%= entry.label %></span>
|
|
14
|
+
<% if (count = @counts&.dig(entry.name)) %>
|
|
15
|
+
<span class="badge text-bg-light"><%= number_with_delimiter(count) %></span>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# Locals: owner, item — the records this item stands for, named: the first
|
|
2
|
+
few as links, then the index holding the rest. %>
|
|
3
|
+
<%- if item.records.present? -%>
|
|
4
|
+
<ul class="list-unstyled small ms-3 mb-0">
|
|
5
|
+
<%- item.records.each do |dependent| -%>
|
|
6
|
+
<li><%= admin_dependent_link(dependent, owner: owner) %></li>
|
|
7
|
+
<%- end -%>
|
|
8
|
+
<%- if item.unnamed.positive? -%>
|
|
9
|
+
<% more = t('crud_components.more', count: item.unnamed, default: '+%{count} more') %>
|
|
10
|
+
<% index_path = admin_dependents_path(owner, item) %>
|
|
11
|
+
<li>
|
|
12
|
+
<%= index_path ? link_to(more, index_path, class: 'text-muted') : tag.span(more, class: 'text-muted') %>
|
|
13
|
+
</li>
|
|
14
|
+
<%- end -%>
|
|
15
|
+
</ul>
|
|
16
|
+
<%- end -%>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<%# One record or a ticked selection: the same page, differing only in how many
|
|
2
|
+
records it is about. @record is set for the member route, nil for the
|
|
3
|
+
collection one; @records holds them either way. %>
|
|
4
|
+
<% one = @records.one? && @record %>
|
|
5
|
+
<% selection = admin_selection_item(@model, @records) %>
|
|
6
|
+
<% title = t('crud_components.admin.delete.title', count: @records.size, model: selection.human_name,
|
|
7
|
+
default: 'Delete these %{count} %{model}?') %>
|
|
8
|
+
<% content_for :title, title %>
|
|
9
|
+
|
|
10
|
+
<nav aria-label="breadcrumb">
|
|
11
|
+
<ol class="breadcrumb">
|
|
12
|
+
<li class="breadcrumb-item"><%= link_to @entry.label, admin_index_path(@entry) %></li>
|
|
13
|
+
<%- if one -%>
|
|
14
|
+
<li class="breadcrumb-item"><%= link_to crud_label(@record), url_for(@record) %></li>
|
|
15
|
+
<%- end -%>
|
|
16
|
+
<li class="breadcrumb-item active" aria-current="page"><%= t('crud_components.actions.destroy', default: 'Delete') %></li>
|
|
17
|
+
</ol>
|
|
18
|
+
</nav>
|
|
19
|
+
|
|
20
|
+
<div class="card border-danger">
|
|
21
|
+
<div class="card-body">
|
|
22
|
+
<h1 class="h5 mb-3"><%= title %></h1>
|
|
23
|
+
|
|
24
|
+
<%- if @records.empty? -%>
|
|
25
|
+
<p class="text-muted"><%= t('crud_components.admin.delete.none_selected', default: 'Nothing was selected.') %></p>
|
|
26
|
+
<%- else -%>
|
|
27
|
+
<%= render 'crud_components/admin/resources/dependent_records', owner: nil, item: selection %>
|
|
28
|
+
|
|
29
|
+
<% blockers = @dependents.blockers %>
|
|
30
|
+
<%- if blockers.any? -%>
|
|
31
|
+
<div class="alert alert-warning mt-3">
|
|
32
|
+
<p class="mb-1"><%= t('crud_components.admin.delete.blocked', count: @records.size,
|
|
33
|
+
default: 'This cannot be deleted while it still has:') %></p>
|
|
34
|
+
<ul class="mb-0">
|
|
35
|
+
<%- blockers.each do |item| -%>
|
|
36
|
+
<li>
|
|
37
|
+
<%= item.count %> <%= item.human_name %>
|
|
38
|
+
<%= render 'crud_components/admin/resources/dependent_records', owner: @record, item: item %>
|
|
39
|
+
</li>
|
|
40
|
+
<%- end -%>
|
|
41
|
+
</ul>
|
|
42
|
+
</div>
|
|
43
|
+
<%- end -%>
|
|
44
|
+
|
|
45
|
+
<% goes = @dependents.items.select(&:destroys?) %>
|
|
46
|
+
<% nullified = @dependents.items.select(&:nullifies?) %>
|
|
47
|
+
|
|
48
|
+
<%- if goes.any? -%>
|
|
49
|
+
<p class="mb-1 mt-3"><%= t('crud_components.admin.delete.also_deleted', default: 'These go with it:') %></p>
|
|
50
|
+
<ul>
|
|
51
|
+
<%- goes.each do |item| -%>
|
|
52
|
+
<li>
|
|
53
|
+
<strong><%= number_with_delimiter(item.count) %></strong> <%= item.human_name %>
|
|
54
|
+
<%- if item.cascades -%>
|
|
55
|
+
<span class="text-muted"><%= t('crud_components.admin.delete.cascades',
|
|
56
|
+
default: '(and whatever depends on those)') %></span>
|
|
57
|
+
<%- end -%>
|
|
58
|
+
<%- unless admin_may_destroy?(item.model) -%>
|
|
59
|
+
<span class="text-warning-emphasis"><%= t('crud_components.admin.delete.beyond_you',
|
|
60
|
+
default: '(not deletable on their own)') %></span>
|
|
61
|
+
<%- end -%>
|
|
62
|
+
<%= render 'crud_components/admin/resources/dependent_records', owner: @record, item: item %>
|
|
63
|
+
</li>
|
|
64
|
+
<%- end -%>
|
|
65
|
+
</ul>
|
|
66
|
+
<%- end -%>
|
|
67
|
+
|
|
68
|
+
<%- if nullified.any? -%>
|
|
69
|
+
<p class="mb-1 mt-3"><%= t('crud_components.admin.delete.kept', default: 'These stay, but lose the reference:') %></p>
|
|
70
|
+
<ul>
|
|
71
|
+
<%- nullified.each do |item| -%>
|
|
72
|
+
<li>
|
|
73
|
+
<strong><%= number_with_delimiter(item.count) %></strong> <%= item.human_name %>
|
|
74
|
+
<%= render 'crud_components/admin/resources/dependent_records', owner: @record, item: item %>
|
|
75
|
+
</li>
|
|
76
|
+
<%- end -%>
|
|
77
|
+
</ul>
|
|
78
|
+
<%- end -%>
|
|
79
|
+
|
|
80
|
+
<%- if goes.empty? && nullified.empty? && blockers.empty? -%>
|
|
81
|
+
<p class="text-muted mt-3"><%= t('crud_components.admin.delete.nothing_else', count: @records.size,
|
|
82
|
+
default: 'Nothing else depends on it.') %></p>
|
|
83
|
+
<%- end -%>
|
|
84
|
+
<%- end -%>
|
|
85
|
+
|
|
86
|
+
<div class="d-flex gap-2 mt-4">
|
|
87
|
+
<%- if @records.any? -%>
|
|
88
|
+
<%= button_to t('crud_components.actions.destroy', default: 'Delete'),
|
|
89
|
+
one ? url_for(@record) : public_send("destroy_selected_#{@entry.route_key}_path"),
|
|
90
|
+
method: :delete, class: 'btn btn-danger', disabled: blockers.any?,
|
|
91
|
+
params: one ? {} : { selected: admin_selected_values(@records) } %>
|
|
92
|
+
<%- end -%>
|
|
93
|
+
<%= link_to t('crud_components.admin.delete.cancel', default: 'Cancel'),
|
|
94
|
+
one ? url_for(@record) : admin_index_path(@entry),
|
|
95
|
+
class: 'btn btn-outline-secondary' %>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
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_form @record %>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<% content_for :title, @entry.label %>
|
|
2
|
+
|
|
3
|
+
<% if @owner %>
|
|
4
|
+
<nav aria-label="breadcrumb">
|
|
5
|
+
<ol class="breadcrumb">
|
|
6
|
+
<li class="breadcrumb-item"><%= link_to @owner_entry.label, admin_index_path(@owner_entry) %></li>
|
|
7
|
+
<li class="breadcrumb-item"><%= link_to crud_label(@owner), url_for(@owner) %></li>
|
|
8
|
+
<li class="breadcrumb-item active" aria-current="page"><%= @entry.label %></li>
|
|
9
|
+
</ol>
|
|
10
|
+
</nav>
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<h1 class="h4 mb-3">
|
|
14
|
+
<%= admin_icon(@entry.icon) %> <%= @entry.label %>
|
|
15
|
+
<% if @owner %><small class="text-muted fs-6">— <%= crud_label(@owner) %></small><% end %>
|
|
16
|
+
</h1>
|
|
17
|
+
|
|
18
|
+
<div class="card">
|
|
19
|
+
<div class="card-body">
|
|
20
|
+
<%= crud_collection @records, query: @query, fieldset: @entry.fieldset, picker: true,
|
|
21
|
+
extra_actions: [admin_show_in_app_action,
|
|
22
|
+
admin_delete_action(@entry),
|
|
23
|
+
admin_destroy_selected_action(@entry)].compact,
|
|
24
|
+
except_actions: %i[destroy] %>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<% content_for :title, t('crud_components.admin.new', model: @model.model_name.human, default: 'New %{model}') %>
|
|
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"><%= content_for(:title) %></li>
|
|
7
|
+
</ol>
|
|
8
|
+
</nav>
|
|
9
|
+
|
|
10
|
+
<div class="card">
|
|
11
|
+
<div class="card-body">
|
|
12
|
+
<%= crud_form @record %>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
@@ -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>
|