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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +39 -2
  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 +1 -1
  21. data/docs/views.md +14 -0
  22. data/lib/crud_components/admin/configuration.rb +122 -0
  23. data/lib/crud_components/admin/dependents.rb +138 -0
  24. data/lib/crud_components/admin/engine.rb +22 -0
  25. data/lib/crud_components/admin/entry.rb +123 -0
  26. data/lib/crud_components/admin/gate.rb +40 -0
  27. data/lib/crud_components/admin/registry.rb +176 -0
  28. data/lib/crud_components/admin/routes.rb +37 -0
  29. data/lib/crud_components/admin/view_helpers.rb +156 -0
  30. data/lib/crud_components/admin.rb +92 -0
  31. data/lib/crud_components/builder.rb +56 -1
  32. data/lib/crud_components/helpers.rb +35 -4
  33. data/lib/crud_components/presenters/collection.rb +16 -4
  34. data/lib/crud_components/presenters/record.rb +14 -2
  35. data/lib/crud_components/route_resolver.rb +37 -7
  36. data/lib/crud_components/structure.rb +8 -1
  37. data/lib/crud_components/version.rb +1 -1
  38. data/lib/crud_components.rb +2 -0
  39. metadata +24 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3039812d78a4d8677f871959497a18eb9f2236627a84a0d046b72a520c0ba34c
4
- data.tar.gz: 70b36787f0e28e2f51af0cfdf3361dc860fcdfb048d90365ddf4ebf128759372
3
+ metadata.gz: 6507798a0f053a225223897f37877c6fd10f3ba553edb7c446cb49554033d828
4
+ data.tar.gz: 66da668cd27252924532484e8a3b81eb6ee0ad94d5616edc08ee7e9cf7dffe1e
5
5
  SHA512:
6
- metadata.gz: 7af65ee9f9aea26c3e6fc132fbe29ad3fbf2a3c8e931e209ee72dd7cb2a57cab670875a3c14fad3907ea316138a58d272729bfd6239d68be9cacf360b4e94fa9
7
- data.tar.gz: 7a220eec077f9845b682c3a081e971d2a488a7d09a7bacfb6f514023fff92a3340000420d5bca17b648dfd424522c1f32a31f505eb81c2eb3331c2718fddbf87
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
+ ![The admin dashboard: a sidebar listing every model, grouped and iconed, and a card per model with its record count](docs/screenshots/admin-dashboard.png)
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
- crud_record(record, fieldset: nil, actions: true, layout: :record, picked_columns: :auto)
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>