plutonium 0.64.0 → 0.66.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/.claude/skills/plutonium/SKILL.md +44 -41
- data/.claude/skills/plutonium-app/SKILL.md +115 -59
- data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
- data/.claude/skills/plutonium-auth/SKILL.md +119 -53
- data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
- data/.claude/skills/plutonium-dashboard/SKILL.md +168 -0
- data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
- data/.claude/skills/plutonium-resource/SKILL.md +236 -145
- data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
- data/.claude/skills/plutonium-testing/SKILL.md +130 -33
- data/.claude/skills/plutonium-ui/SKILL.md +171 -95
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +80 -0
- data/CLAUDE.md +282 -210
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium-charts.js +20604 -0
- data/app/assets/plutonium-charts.js.map +7 -0
- data/app/assets/plutonium-charts.min.js +41 -0
- data/app/assets/plutonium-charts.min.js.map +7 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +794 -629
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +48 -48
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_flash_alerts.html.erb +2 -2
- data/app/views/plutonium/_resource_header.html.erb +2 -2
- data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
- data/app/views/plutonium/_toast.html.erb +2 -2
- data/app/views/rodauth/_login_form.html.erb +1 -1
- data/app/views/rodauth/_password_visibility.html.erb +1 -1
- data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
- data/app/views/rodauth/change_login.html.erb +2 -2
- data/app/views/rodauth/create_account.html.erb +4 -4
- data/app/views/rodauth/reset_password_request.html.erb +1 -1
- data/app/views/rodauth/verify_account_resend.html.erb +1 -1
- data/app/views/rodauth/webauthn_remove.html.erb +1 -1
- data/config/brakeman.ignore +4 -4
- data/config/initializers/rabl.rb +0 -40
- data/config/locales/en/api_client.yml +22 -0
- data/config/locales/en/async.yml +34 -0
- data/config/locales/en/dashboard.yml +25 -0
- data/config/locales/en/invites.yml +37 -0
- data/config/locales/en/js.yml +40 -0
- data/config/locales/en/kanban.yml +28 -0
- data/config/locales/en/plutonium.yml +26 -0
- data/config/locales/en/positioning.yml +14 -0
- data/config/locales/en/profile.yml +29 -0
- data/config/locales/en/resource.yml +60 -0
- data/config/locales/en/ui.yml +93 -0
- data/config/locales/en/wizard.yml +46 -0
- data/docs/.vitepress/config.ts +34 -1
- data/docs/.vitepress/sync-skills.mjs +6 -3
- data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
- data/docs/.vitepress/theme/custom.css +34 -377
- data/docs/blog/introducing-plutonium-dashboards.md +174 -0
- data/docs/blog/introducing-plutonium-i18n.md +90 -0
- data/docs/blog/introducing-plutonium.md +4 -5
- data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
- data/docs/getting-started/installation.md +5 -5
- data/docs/getting-started/tutorial/02-first-resource.md +3 -3
- data/docs/getting-started/tutorial/03-authentication.md +7 -7
- data/docs/getting-started/tutorial/04-authorization.md +21 -4
- data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
- data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
- data/docs/getting-started/tutorial/index.md +1 -1
- data/docs/guides/adding-resources.md +10 -7
- data/docs/guides/authentication.md +25 -25
- data/docs/guides/authorization.md +24 -24
- data/docs/guides/creating-packages.md +17 -17
- data/docs/guides/custom-actions.md +32 -32
- data/docs/guides/customizing-ui.md +29 -26
- data/docs/guides/dashboards.md +300 -0
- data/docs/guides/index.md +4 -3
- data/docs/guides/kanban.md +56 -56
- data/docs/guides/multi-tenancy.md +35 -22
- data/docs/guides/nested-resources.md +21 -21
- data/docs/guides/performance.md +3 -3
- data/docs/guides/search-filtering.md +13 -13
- data/docs/guides/testing.md +16 -12
- data/docs/guides/theming.md +32 -17
- data/docs/guides/troubleshooting.md +2 -2
- data/docs/guides/user-invites.md +17 -17
- data/docs/guides/user-profile.md +51 -24
- data/docs/guides/wizards.md +55 -55
- data/docs/public/images/blog/dashboards-card-error.png +0 -0
- data/docs/public/images/blog/dashboards-loading.png +0 -0
- data/docs/public/images/blog/dashboards-overview.png +0 -0
- data/docs/public/images/blog/dashboards-sidebar.png +0 -0
- data/docs/public/images/blog/i18n-es-admin.png +0 -0
- data/docs/public/images/guides/dashboard-card-error.png +0 -0
- data/docs/public/images/guides/dashboard-loading.png +0 -0
- data/docs/public/images/guides/dashboard-overview.png +0 -0
- data/docs/public/images/guides/dashboard-sidebar.png +0 -0
- data/docs/reference/app/generators.md +24 -24
- data/docs/reference/app/index.md +15 -18
- data/docs/reference/app/packages.md +8 -8
- data/docs/reference/app/portals.md +77 -29
- data/docs/reference/auth/accounts.md +15 -15
- data/docs/reference/auth/index.md +12 -12
- data/docs/reference/auth/profile.md +67 -29
- data/docs/reference/behavior/async-interactions.md +24 -24
- data/docs/reference/behavior/controllers.md +28 -28
- data/docs/reference/behavior/index.md +5 -5
- data/docs/reference/behavior/interactions.md +44 -44
- data/docs/reference/behavior/policies.md +48 -28
- data/docs/reference/configuration.md +88 -31
- data/docs/reference/dashboard/dsl.md +123 -0
- data/docs/reference/dashboard/index.md +35 -0
- data/docs/reference/dashboard/registration.md +76 -0
- data/docs/reference/generators/lite.md +7 -7
- data/docs/reference/i18n.md +262 -0
- data/docs/reference/index.md +11 -2
- data/docs/reference/kanban/authorization.md +9 -9
- data/docs/reference/kanban/dsl.md +33 -33
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +18 -16
- data/docs/reference/resource/actions.md +51 -51
- data/docs/reference/resource/definition.md +166 -88
- data/docs/reference/resource/export.md +11 -7
- data/docs/reference/resource/index.md +16 -14
- data/docs/reference/resource/model.md +24 -24
- data/docs/reference/{positioning.md → resource/positioning.md} +78 -75
- data/docs/reference/resource/query.md +14 -12
- data/docs/reference/tenancy/entity-scoping.md +65 -35
- data/docs/reference/tenancy/index.md +11 -11
- data/docs/reference/tenancy/invites.md +20 -20
- data/docs/reference/tenancy/nested-resources.md +13 -13
- data/docs/reference/testing/index.md +116 -22
- data/docs/reference/ui/assets.md +65 -25
- data/docs/reference/ui/components.md +20 -20
- data/docs/reference/ui/displays.md +19 -22
- data/docs/reference/ui/forms.md +35 -35
- data/docs/reference/ui/index.md +17 -15
- data/docs/reference/ui/layouts.md +21 -21
- data/docs/reference/ui/pages.md +22 -22
- data/docs/reference/ui/tables.md +7 -7
- data/docs/reference/wizard/anchoring-resume.md +33 -32
- data/docs/reference/wizard/dsl.md +45 -45
- data/docs/reference/wizard/index.md +6 -6
- data/docs/reference/wizard/one-time.md +18 -18
- data/docs/reference/wizard/registration-launch.md +32 -32
- data/docs/reference/wizard/storage-config.md +23 -23
- data/esbuild.config.js +5 -2
- data/gemfiles/rails_7.gemfile.lock +2 -2
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/active_model/validations/array_validator.rb +1 -1
- data/lib/active_model/validations/attached_validator.rb +1 -1
- data/lib/active_model/validations/url_validator.rb +2 -2
- data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
- data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +3 -2
- data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
- data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
- data/lib/generators/pu/docker/install/install_generator.rb +58 -0
- data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
- data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
- data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
- data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
- data/lib/generators/pu/invites/invitable_generator.rb +18 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
- data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
- data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
- data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
- data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
- data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
- data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
- data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
- data/lib/plutonium/action/base.rb +27 -3
- data/lib/plutonium/action/interactive.rb +17 -4
- data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
- data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
- data/lib/plutonium/auth/sequel_adapter.rb +1 -1
- data/lib/plutonium/configuration.rb +5 -0
- data/lib/plutonium/core/controller.rb +25 -1
- data/lib/plutonium/core/controllers/authorizable.rb +1 -1
- data/lib/plutonium/dashboard/base.rb +114 -0
- data/lib/plutonium/dashboard/card.rb +200 -0
- data/lib/plutonium/dashboard/controller.rb +101 -0
- data/lib/plutonium/dashboard/dsl.rb +104 -0
- data/lib/plutonium/dashboard/register.rb +40 -0
- data/lib/plutonium/dashboard/route_resolution.rb +30 -0
- data/lib/plutonium/dashboard.rb +42 -0
- data/lib/plutonium/definition/actions.rb +6 -1
- data/lib/plutonium/definition/base.rb +41 -3
- data/lib/plutonium/definition/index_views.rb +1 -1
- data/lib/plutonium/definition/positioning.rb +1 -1
- data/lib/plutonium/definition/presentable.rb +2 -2
- data/lib/plutonium/helpers/assets_helper.rb +15 -2
- data/lib/plutonium/helpers/display_helper.rb +10 -2
- data/lib/plutonium/interaction/async/context.rb +6 -8
- data/lib/plutonium/interaction/async/executor.rb +6 -7
- data/lib/plutonium/interaction/async/run.rb +2 -2
- data/lib/plutonium/interaction/async/run_definition.rb +1 -1
- data/lib/plutonium/interaction/base.rb +2 -0
- data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
- data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
- data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
- data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
- data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
- data/lib/plutonium/invites/controller.rb +11 -11
- data/lib/plutonium/kanban/column.rb +13 -3
- data/lib/plutonium/kanban/dsl.rb +7 -4
- data/lib/plutonium/models/has_cents.rb +1 -1
- data/lib/plutonium/profile/security_section.rb +10 -18
- data/lib/plutonium/query/filter.rb +10 -1
- data/lib/plutonium/query/filters/association.rb +27 -2
- data/lib/plutonium/query/filters/boolean.rb +4 -4
- data/lib/plutonium/query/filters/date.rb +4 -17
- data/lib/plutonium/query/filters/date_range.rb +2 -2
- data/lib/plutonium/query/filters/select.rb +10 -1
- data/lib/plutonium/query/filters/text.rb +9 -12
- data/lib/plutonium/railtie.rb +17 -0
- data/lib/plutonium/resource/controller.rb +10 -5
- data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
- data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
- data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
- data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
- data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
- data/lib/plutonium/resource/query_object.rb +30 -3
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/routing/dashboard_registration.rb +137 -0
- data/lib/plutonium/routing/resource_registration.rb +5 -0
- data/lib/plutonium/routing/route_set_extensions.rb +2 -1
- data/lib/plutonium/translation.rb +227 -0
- data/lib/plutonium/ui/actions_dropdown.rb +2 -2
- data/lib/plutonium/ui/breadcrumbs.rb +3 -3
- data/lib/plutonium/ui/color_mode_selector.rb +1 -1
- data/lib/plutonium/ui/component/behaviour.rb +46 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/dashboard/board.rb +80 -0
- data/lib/plutonium/ui/dashboard/card.rb +127 -0
- data/lib/plutonium/ui/dashboard/chart.rb +43 -0
- data/lib/plutonium/ui/dashboard/custom.rb +32 -0
- data/lib/plutonium/ui/dashboard/frame.rb +25 -0
- data/lib/plutonium/ui/dashboard/metric.rb +137 -0
- data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
- data/lib/plutonium/ui/display/components/attachment.rb +1 -1
- data/lib/plutonium/ui/display/components/badge.rb +7 -1
- data/lib/plutonium/ui/display/components/boolean.rb +2 -2
- data/lib/plutonium/ui/display/resource.rb +23 -16
- data/lib/plutonium/ui/export_button.rb +3 -3
- data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
- data/lib/plutonium/ui/form/components/password.rb +2 -2
- data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
- data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
- data/lib/plutonium/ui/form/components/uppy.rb +11 -3
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
- data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/ui/form/query.rb +8 -8
- data/lib/plutonium/ui/form/resource.rb +54 -26
- data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
- data/lib/plutonium/ui/grid/card.rb +3 -3
- data/lib/plutonium/ui/grid/resource.rb +3 -3
- data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
- data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
- data/lib/plutonium/ui/kanban/column.rb +6 -6
- data/lib/plutonium/ui/layout/base.rb +19 -1
- data/lib/plutonium/ui/layout/header.rb +1 -1
- data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
- data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
- data/lib/plutonium/ui/layout/sidebar.rb +1 -1
- data/lib/plutonium/ui/layout/topbar.rb +2 -2
- data/lib/plutonium/ui/modal/base.rb +4 -4
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
- data/lib/plutonium/ui/nav_user.rb +2 -2
- data/lib/plutonium/ui/page/base.rb +10 -2
- data/lib/plutonium/ui/page/dashboard.rb +54 -0
- data/lib/plutonium/ui/page/edit.rb +3 -3
- data/lib/plutonium/ui/page/interactive_action.rb +2 -2
- data/lib/plutonium/ui/page/new.rb +3 -3
- data/lib/plutonium/ui/page/show.rb +2 -2
- data/lib/plutonium/ui/page/wizard.rb +22 -16
- data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
- data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
- data/lib/plutonium/ui/skeleton_table.rb +1 -1
- data/lib/plutonium/ui/table/base.rb +2 -2
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
- data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
- data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
- data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
- data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
- data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
- data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
- data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
- data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
- data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
- data/lib/plutonium/ui/table/resource.rb +53 -17
- data/lib/plutonium/ui/wizard/review.rb +7 -7
- data/lib/plutonium/ui/wizard/stepper.rb +1 -1
- data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/base.rb +2 -0
- data/lib/plutonium/wizard/driving.rb +8 -2
- data/lib/plutonium/wizard/dsl.rb +4 -2
- data/lib/plutonium/wizard/field_importer.rb +1 -8
- data/lib/plutonium/wizard/review_step.rb +8 -1
- data/lib/plutonium/wizard/step.rb +14 -3
- data/package.json +4 -2
- data/plutonium.gemspec +4 -1
- data/postcss-gem-import.cjs +34 -0
- data/postcss-gem-import.js +3 -28
- data/src/css/components.css +101 -10
- data/src/css/slim_select.css +0 -38
- data/src/css/tokens.css +24 -0
- data/src/js/controllers/attachment_input_controller.js +15 -6
- data/src/js/controllers/chart_controller.js +162 -0
- data/src/js/controllers/clipboard_controller.js +3 -2
- data/src/js/controllers/dirty_form_guard_controller.js +4 -3
- data/src/js/controllers/easymde_controller.js +3 -0
- data/src/js/controllers/filter_panel_controller.js +15 -6
- data/src/js/controllers/flatpickr_controller.js +8 -0
- data/src/js/controllers/frame_refresh_controller.js +59 -0
- data/src/js/controllers/intl_tel_input_controller.js +5 -0
- data/src/js/controllers/register_controllers.js +4 -0
- data/src/js/controllers/slim_select_controller.js +79 -176
- data/src/js/core.js +9 -1
- data/src/js/i18n.js +69 -0
- data/src/js/plutonium-charts.js +15 -0
- data/src/js/turbo/turbo_confirm.js +5 -3
- metadata +64 -16
- data/.yarnrc.yml +0 -8
- data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
- data/lib/generators/pu/field/input/input_generator.rb +0 -32
- data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
- data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
- data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
- data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
- data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
- data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
- data/public/plutonium-assets/application.js +0 -31419
- data/public/plutonium-assets/plutonium.ico +0 -0
- data/yarn.lock +0 -6858
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Introducing Plutonium dashboards: metric cards and charts for your Rails admin"
|
|
3
|
+
titleTemplate: "Plutonium Blog"
|
|
4
|
+
date: 2026-10-02
|
|
5
|
+
description: Plutonium now ships dashboards. Declare metric, chart and free-form cards in one Ruby class, mount it with one routes line, and every card loads in its own turbo frame inside your portal's auth and tenancy.
|
|
6
|
+
author: Stefan Froelich
|
|
7
|
+
tags: [announcement, dashboards, charts]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Introducing Plutonium dashboards: metric cards and charts for your Rails admin
|
|
11
|
+
|
|
12
|
+
<BlogMeta />
|
|
13
|
+
|
|
14
|
+
Every portal Plutonium generates opens on a Dashboard page that lists your resources with a record count each. Anything past that, the numbers and charts an admin actually opens the app to see, was yours to build: a controller action, some instance variables, a view. Plutonium 0.65 ships it. A dashboard is a Ruby class of cards, mounted with one line in your routes.
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
## What's in the box
|
|
19
|
+
|
|
20
|
+
- A class-level DSL with three kinds of card: `metric` for headline numbers, `chart` for charts, `card` for anything else.
|
|
21
|
+
- `register_dashboard`, which draws the routes and a controller that inherits your portal's authentication, tenant scoping and layout.
|
|
22
|
+
- Lazy loading: by default every card is fetched in its own turbo frame, behind a skeleton.
|
|
23
|
+
- A 12-column grid with a default width per kind of card, plus per-card refresh intervals, conditions, links and icons.
|
|
24
|
+
- A `pu:dashboard` generator, and a Dashboards group in the portal sidebar.
|
|
25
|
+
|
|
26
|
+
## Generate one
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
rails g pu:dashboard Sales --dest=admin_portal
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This writes `packages/admin_portal/app/dashboards/admin_portal/sales_dashboard.rb` and adds the registration to the portal's routes:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
AdminPortal::Engine.routes.draw do
|
|
36
|
+
register_dashboard AdminPortal::SalesDashboard, at: "sales"
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The page is served at `/admin/dashboards/sales`. Pass `--at=/` instead and the dashboard becomes the portal's root page, replacing the generated `root to: "dashboard#index"` (the old `DashboardController` and its view can then go).
|
|
41
|
+
|
|
42
|
+
## Declare the cards
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
module AdminPortal
|
|
46
|
+
class SalesDashboard < Plutonium::Dashboard::Base
|
|
47
|
+
presents label: "Sales", icon: Phlex::TablerIcons::ChartBar
|
|
48
|
+
|
|
49
|
+
refresh 60
|
|
50
|
+
|
|
51
|
+
metric(:orders, icon: Phlex::TablerIcons::ShoppingCart, href: -> { resource_url_for(Order, parent: nil) }) do
|
|
52
|
+
{value: orders.where(created_at: 30.days.ago..).count,
|
|
53
|
+
previous: orders.where(created_at: 60.days.ago...30.days.ago).count,
|
|
54
|
+
change_label: "vs. previous 30 days"}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
metric(:revenue, format: :currency) { orders.sum(:total) }
|
|
58
|
+
|
|
59
|
+
metric(:refund_rate, format: :percentage, precision: 1, positive: :down) do
|
|
60
|
+
{value: 2.1, previous: 1.8}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
chart(:orders_per_day, type: :area, span: 8) do
|
|
64
|
+
orders.pluck(:created_at).map(&:to_date).tally.sort.to_h
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
chart(:by_status, type: :donut, span: 4) { orders.group(:status).count }
|
|
68
|
+
|
|
69
|
+
card(:latest, span: :full) do
|
|
70
|
+
ul { latest_orders.each { |order| li { order.number } } }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def orders = authorized_resource_scope(Order)
|
|
76
|
+
|
|
77
|
+
def latest_orders = orders.order(created_at: :desc).limit(5)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Metrics.** The block returns a number, or a hash. Give it a `previous` value and the card computes the percentage change and shows it with a trend arrow. `format:` is `:number`, `:currency`, `:percentage`, `:human` (1.23 Million) or a proc. `positive: :down` says a falling number is the good direction, so the refund rate above, up from 1.8 to 2.1, is styled as bad news.
|
|
83
|
+
|
|
84
|
+
**Charts.** The block returns [Chartkick data](https://chartkick.com/#data): a hash, an array of pairs, or an array of named series. `type:` is `:line`, `:area`, `:column`, `:bar`, `:pie`, `:donut` or `:scatter`, and every other option passes straight through to Chartkick.
|
|
85
|
+
|
|
86
|
+
**Custom cards.** The block renders Phlex markup inside the card, for a recent-activity list, a call to action, or whatever the first two do not cover.
|
|
87
|
+
|
|
88
|
+
**Layout.** The grid is 12 columns wide, so halves, thirds and quarters can share a dashboard. `span:` says how many a card takes. Left out, a metric takes 3 (four to a row) and a chart or custom card takes 6. Above, the area chart takes two thirds of its row and the donut the remaining third. Tablets drop to two columns and phones to one.
|
|
89
|
+
|
|
90
|
+
Blocks run on the dashboard instance, so private methods are how cards share a query. `authorized_resource_scope(Order)` is the same policy scope your resource pages use: the dashboard counts what this user may see, in this tenant, without restating either rule.
|
|
91
|
+
|
|
92
|
+
## Every card loads on its own
|
|
93
|
+
|
|
94
|
+
The page response contains no query results. Each card renders as a lazy turbo frame holding a skeleton:
|
|
95
|
+
|
|
96
|
+
```html
|
|
97
|
+
<turbo-frame id="pu-dashboard-card-orders" src="/admin/dashboards/sales/cards/orders" loading="lazy">
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The browser fetches each frame as it scrolls into view, and the card's block runs in that request. The page paints at once, the cheap cards fill in first, and one expensive aggregate delays itself and nothing else.
|
|
101
|
+
|
|
102
|
+

|
|
103
|
+
|
|
104
|
+
The Conversion card in that screenshot is declared `lazy: false`, which makes it inline: its block runs in the page request and the finished card is part of the page response, with no frame, no skeleton and no second request. The price is that the page waits for it. Use it for a number cheap enough that a round trip costs more than the query, and leave everything else lazy. An inline card never refreshes, since there is no frame to reload, and in development and test an exception in its block fails the whole page rather than one frame.
|
|
105
|
+
|
|
106
|
+
`refresh 60` on the dashboard reloads every lazy card once a minute. `refresh: 10` on a card overrides it, and `refresh: false` keeps an expensive card out of it. Reloads pause while the tab is hidden and catch up when it becomes visible again, so a dashboard left open overnight is not running your aggregates all night.
|
|
107
|
+
|
|
108
|
+
## Who sees what
|
|
109
|
+
|
|
110
|
+
A portal dashboard sits behind the portal's login like every other page. To restrict it further, override `authorize?`; a false answer is a 403 from the page and from every card URL.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
def authorize? = current_user.admin?
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`condition:` hides a single card. Because a card is an endpoint, the condition guards the endpoint too: a hidden card responds 404, so it cannot be fetched by guessing its key.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
metric(:margin, format: :percentage, condition: -> { current_user.admin? }) { margin }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
On an entity-scoped portal the page and card URLs carry the tenant segment (`/org/1/dashboards/team`) and `current_scoped_entity` is available in every block:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class TeamDashboard < Plutonium::Dashboard::Base
|
|
126
|
+
metric(:members) { current_scoped_entity.memberships.count }
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## When a card breaks
|
|
131
|
+
|
|
132
|
+
In development and test a card that raises, raises, and you fix the query. In production the card shows a short notice in place of its body and the rest of the dashboard is untouched.
|
|
133
|
+
|
|
134
|
+

|
|
135
|
+
|
|
136
|
+
The failure is written to the Rails log with the dashboard class, the card key and the top of the backtrace, and it is reported through `Rails.error`:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
Rails.error.report(
|
|
140
|
+
error,
|
|
141
|
+
handled: true,
|
|
142
|
+
source: "plutonium.dashboard",
|
|
143
|
+
context: {dashboard: "AdminPortal::SalesDashboard", card: "revenue"}
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Both happen because `Rails.error.report` notifies subscribers and writes nothing to the log. An app with no error tracker still gets a log line; an app with Sentry, Honeybadger or AppSignal subscribed gets a handled error it can group by card and alert on.
|
|
148
|
+
|
|
149
|
+
## Charts without the bundle tax
|
|
150
|
+
|
|
151
|
+
Charts render with [Chart.js](https://www.chartjs.org/) through Chartkick, which is a lot of JavaScript for an app where most pages are tables and forms. It ships as a separate bundle. A chart card carries the bundle's URL in a data attribute and the `chart` Stimulus controller injects the script the first time a chart connects, so pages without a chart never download it.
|
|
152
|
+
|
|
153
|
+
Series colours come from the `--pu-chart-1` to `--pu-chart-8` design tokens and are re-read when the colour mode flips. Charts follow your theme in light and dark mode with no per-chart configuration.
|
|
154
|
+
|
|
155
|
+
## In the sidebar
|
|
156
|
+
|
|
157
|
+
Registered dashboards are grouped under a Dashboards item in the portal sidebar, with a link per dashboard. A dashboard mounted at the root is what the Home link opens, so it stays out of the group.
|
|
158
|
+
|
|
159
|
+

|
|
160
|
+
|
|
161
|
+
If your portal has an ejected `_resource_sidebar.html.erb` from an earlier release, the [guide](/guides/dashboards#sidebar) has the block to add.
|
|
162
|
+
|
|
163
|
+
## A note on URLs
|
|
164
|
+
|
|
165
|
+
Every dashboard is drawn under a `dashboards/` segment. A portal's URL space already belongs to `register_resource`, and a dashboard at a bare `/admin/sales` is one `Sale` model away from a route collision that Rails resolves silently, by order. If you want the bare path anyway, `prefix: nil` turns the segment off for that mount.
|
|
166
|
+
|
|
167
|
+
## Experimental, for now
|
|
168
|
+
|
|
169
|
+
Dashboards ship marked experimental, like kanban, wizards and async interactions: fine to build on, but the DSL may still move before 1.0. If the API fights you, tell me. That feedback still changes things.
|
|
170
|
+
|
|
171
|
+
## Where to go next
|
|
172
|
+
|
|
173
|
+
- [Dashboards guide](/guides/dashboards): the worked example, every card option, layout and translations.
|
|
174
|
+
- [Dashboard reference](/reference/dashboard/): the DSL, `register_dashboard` and the routes it draws, and the generator.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Introducing Plutonium i18n: every string translatable, the Rails way"
|
|
3
|
+
titleTemplate: "Plutonium Blog"
|
|
4
|
+
date: 2026-10-02
|
|
5
|
+
description: The text a framework renders has always been the framework's, not yours. Plutonium now routes every string it draws through a locale file, so translating the UI is a matter of YAML and nothing else.
|
|
6
|
+
author: Stefan Froelich
|
|
7
|
+
tags: [announcement, i18n, rails]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Introducing Plutonium i18n: every string translatable, the Rails way
|
|
11
|
+
|
|
12
|
+
<BlogMeta />
|
|
13
|
+
|
|
14
|
+
You have always been able to translate your own app. Rails has shipped `I18n.t`, per-model attribute names and localized dates since long before you needed them. What you could not translate was the framework on top. The button that says "Create", the "Search..." in the filter box, the flash that a record was saved, the empty-state line, the sentence under the paginator: an admin framework ships those in English, baked into views you adopted the framework precisely so you would never open. Translating them meant forking them.
|
|
15
|
+
|
|
16
|
+
Plutonium now routes every string it renders through a locale file, and resolves every label it derives from a key through the Rails i18n conventions you already know. Translating the UI is a matter of YAML. Nothing in a definition or a policy changes.
|
|
17
|
+
|
|
18
|
+
## The strings come from YAML
|
|
19
|
+
|
|
20
|
+
Plutonium's own text lives under `config/locales/en/*.yml`, in a `plutonium` namespace, loaded at the lowest precedence. Rails' normal load order does the rest: your app's `config/locales` beats a portal's, a portal's beats the gem's. To reword a fixed string, or translate it, copy its key into your own file:
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
# config/locales/en.yml
|
|
24
|
+
en:
|
|
25
|
+
plutonium:
|
|
26
|
+
boolean:
|
|
27
|
+
"true": "On"
|
|
28
|
+
"false": "Off"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
That one move covers rewording the default "Yes" and "No" in English and translating them to another language. There is no second mechanism to learn for the second case.
|
|
32
|
+
|
|
33
|
+
## The labels you never wrote translate too
|
|
34
|
+
|
|
35
|
+
The strings above are the easy half. The interesting half is the text a definition never spells out. An action, a scope, a filter, a kanban column, a field's placeholder: each one defaults to a humanized version of its key, and each has a convention key that a locale file can supply.
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
en:
|
|
39
|
+
plutonium:
|
|
40
|
+
actions:
|
|
41
|
+
blogging/post:
|
|
42
|
+
publish: "Publish now"
|
|
43
|
+
scopes:
|
|
44
|
+
blogging/post:
|
|
45
|
+
drafts: "Drafts"
|
|
46
|
+
fields:
|
|
47
|
+
blogging/post:
|
|
48
|
+
title:
|
|
49
|
+
placeholder: "A short, descriptive title"
|
|
50
|
+
hint: "Shown in search results"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A field you never annotated is already translatable. You did not declare a placeholder to get one, and you do not declare a key to translate it: Plutonium looks it up by the model's `i18n_key`, the same way `human_attribute_name` walks ancestors, so a key on `blogging/post` also serves its STI subclasses. Model and attribute names flow from the standard Rails `activerecord` keys, which is why column headers, page titles and flashes come along the moment you provide them. Nothing is declared twice, and the exception, an explicit label you do want to spell out, gets a named place: the definition's own `t`, resolved per request in the right locale.
|
|
54
|
+
|
|
55
|
+
## Switching locale is a Rails around_action
|
|
56
|
+
|
|
57
|
+
Plutonium reads `I18n.locale` on every render and never sets it, so you switch the way you would in any Rails app. The dummy app's admin portal wires an `EN | ES` toggle into the top bar and remembers the choice per session:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
included do
|
|
61
|
+
around_action :switch_locale
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def switch_locale(&)
|
|
65
|
+
session[:locale] = params[:locale] if params[:locale].present?
|
|
66
|
+
locale = session[:locale].to_s.to_sym
|
|
67
|
+
locale = I18n.default_locale unless I18n.available_locales.include?(locale)
|
|
68
|
+
I18n.with_locale(locale, &)
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Pick ES and the portal answers in Spanish: the heading, the search box, the filters and scopes, the column headers, the row and page actions, the status badges, the pager and the CRUD flashes. The heading translates because the definition sets it with the same lazy `t`, `index_page_title t("blog.index.title")`, that inputs and labels use. Two things stay English by design: the record data, because it is yours and not the framework's, and any label a definition hardcodes as a plain string, like the `column :user, label: "Author"` you can see in the shot sitting next to the translated `Autor`.
|
|
73
|
+
|
|
74
|
+

|
|
75
|
+
|
|
76
|
+
## Even the parts that aren't Rails views
|
|
77
|
+
|
|
78
|
+
Two surfaces never touch Rails' server-side `t`, and both come along.
|
|
79
|
+
|
|
80
|
+
The Stimulus controllers bundled with the gem read their strings from a JSON blob the layout renders once per page in the current locale. Override a key in YAML and the bundled JavaScript's text changes with it, no rebuild. And pagination, which has its own dictionary, is translated by [Pagy](https://ddnexus.github.io/pagy/resources/i18n) in about thirty-five languages out of the box. Plutonium syncs Pagy's locale to `I18n.locale` on every render, so the same `around_action` that switched the chrome switched the pager, in the screenshot above, for free.
|
|
81
|
+
|
|
82
|
+
## Ship a half-translated locale
|
|
83
|
+
|
|
84
|
+
You do not have to finish a language before you use it. The dummy demo translates the blog-posts screen and leaves the rest of the app, the dates, and Rodauth's own flashes to fall back to English through `config.i18n.fallbacks`. That matters more than it sounds: the dummy's test environment turns on `raise_on_missing_translations`, as many apps do, so a page under a half-done locale would otherwise blow up on the first key you had not reached yet. With fallbacks on, a found-via-fallback string is not missing. Translate the screens your users live on first, ship it, and fill in the rest as you go.
|
|
85
|
+
|
|
86
|
+
## Adding a language
|
|
87
|
+
|
|
88
|
+
A full second locale is the usual Rails checklist: a [rails-i18n](https://github.com/svenfuchs/rails-i18n) locale for dates and numbers, a Pagy dictionary, `rodauth-i18n` if you use Rodauth, the `plutonium` namespace translated from the gem's English files, and your own models and attributes. Plutonium itself ships only English, and hands you the door to the rest.
|
|
89
|
+
|
|
90
|
+
The [internationalization reference](/reference/i18n) has the full key catalogue and the exact resolution order for every slot.
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Introducing Plutonium: Rails conventions, past CRUD"
|
|
3
3
|
titleTemplate: "Plutonium Blog"
|
|
4
|
-
date: 2026-08-
|
|
4
|
+
date: 2026-08-22
|
|
5
5
|
description: Rails made a bargain. Follow the conventions and the framework carries you. Plutonium makes the same bargain about the layer Rails deliberately left alone.
|
|
6
6
|
author: Stefan Froelich
|
|
7
7
|
tags: [announcement, rails, architecture]
|
|
8
|
-
draft: true
|
|
9
8
|
---
|
|
10
9
|
|
|
11
10
|
# Introducing Plutonium: Rails conventions, past CRUD
|
|
@@ -146,11 +145,11 @@ input :content, as: :markdown
|
|
|
146
145
|
display :status, as: :badge, colors: {published: :accent, draft: :neutral}
|
|
147
146
|
```
|
|
148
147
|
|
|
149
|
-
**Render it inline.** A display takes a block
|
|
148
|
+
**Render it inline.** A display takes a block that runs inside a Phlex context, so tag methods and Tailwind classes are available without defining a class:
|
|
150
149
|
|
|
151
150
|
```ruby
|
|
152
|
-
display :priority
|
|
153
|
-
span(class: "pu-badge") { value.humanize }
|
|
151
|
+
display :priority do |field|
|
|
152
|
+
span(class: "pu-badge") { field.value.to_s.humanize }
|
|
154
153
|
end
|
|
155
154
|
```
|
|
156
155
|
|
|
@@ -119,7 +119,7 @@ See [Wizards](/guides/wizards) for the full DSL, including per-step writes, resu
|
|
|
119
119
|
|
|
120
120
|
## The thing underneath the board
|
|
121
121
|
|
|
122
|
-
Kanban did not invent its ordering. It uses the same fractional positioning that powers [drag-to-reorder](/reference/positioning) on ordinary index tables, card grids and nested association tables. `positioned_on` on the model says how positions are stored, `position_on` in the definition says the list is orderable, and a drop writes exactly one decimal, the midpoint between its two neighbours. No renumbering sweep across the table.
|
|
122
|
+
Kanban did not invent its ordering. It uses the same fractional positioning that powers [drag-to-reorder](/reference/resource/positioning) on ordinary index tables, card grids and nested association tables. `positioned_on` on the model says how positions are stored, `position_on` in the definition says the list is orderable, and a drop writes exactly one decimal, the midpoint between its two neighbours. No renumbering sweep across the table.
|
|
123
123
|
|
|
124
124
|
A board and a sortable table are the same feature wearing different clothes, which is why they share a vocabulary. Positioning itself is stable, not experimental.
|
|
125
125
|
|
|
@@ -9,7 +9,7 @@ rails new myapp -a propshaft -j esbuild -c tailwind \
|
|
|
9
9
|
-m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
This sets up Rails with Propshaft, esbuild, TailwindCSS, and Plutonium
|
|
12
|
+
This sets up Rails with Propshaft, esbuild, TailwindCSS, and Plutonium, plus Rodauth auth, asset pipeline, and initial migrations.
|
|
13
13
|
|
|
14
14
|
After the template completes:
|
|
15
15
|
|
|
@@ -62,7 +62,7 @@ For account options and customization, see [Reference › Auth](/reference/auth/
|
|
|
62
62
|
rails generate pu:core:assets
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
Installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers.
|
|
65
|
+
Installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers (`registerControllers(application)`, which your own JS bundle must keep), and points `config.assets.stylesheet` / `script` at your `application` bundles. Until then the app serves the gem's prebuilt CSS and JS, so it is required for custom CSS, brand colors or your own Stimulus controllers. It needs `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` (an app created with `-j esbuild -c tailwind` plus Stimulus). See [Reference › UI › Assets](/reference/ui/assets) and [Guides › Theming](/guides/theming).
|
|
66
66
|
|
|
67
67
|
## Verify
|
|
68
68
|
|
|
@@ -99,6 +99,6 @@ bin/dev
|
|
|
99
99
|
|
|
100
100
|
## Next steps
|
|
101
101
|
|
|
102
|
-
- [Tutorial](./tutorial/)
|
|
103
|
-
- [Adding resources](/guides/adding-resources)
|
|
104
|
-
- [Creating packages](/guides/creating-packages)
|
|
102
|
+
- [Tutorial](./tutorial/): build a complete blog application step-by-step
|
|
103
|
+
- [Adding resources](/guides/adding-resources): create your first resource
|
|
104
|
+
- [Creating packages](/guides/creating-packages): organize code into feature and portal packages
|
|
@@ -118,7 +118,7 @@ rails db:prepare
|
|
|
118
118
|
|
|
119
119
|
## Creating a Portal
|
|
120
120
|
|
|
121
|
-
Resources need a portal to be accessible via the web. Let's create a public admin portal so we can explore the UI right away
|
|
121
|
+
Resources need a portal to be accessible via the web. Let's create a public admin portal so we can explore the UI right away; we'll add authentication in [Chapter 3](./03-authentication).
|
|
122
122
|
|
|
123
123
|
```bash
|
|
124
124
|
rails generate pu:pkg:portal admin --public
|
|
@@ -149,9 +149,9 @@ Visit `http://localhost:3000/admin/blogging/posts`. You should see an empty post
|
|
|
149
149
|
|
|
150
150
|

|
|
151
151
|
|
|
152
|
-
Click "New"
|
|
152
|
+
Click "New": the form is automatically generated from your model's attributes. By default Plutonium opens it as a slideover (right) so you keep the index visible; visiting `/admin/blogging/posts/new` directly renders the same form as a standalone page (left):
|
|
153
153
|
|
|
154
|
-
| Default
|
|
154
|
+
| Default: slideover from index | Standalone page (direct URL) |
|
|
155
155
|
|:--:|:--:|
|
|
156
156
|
|  |  |
|
|
157
157
|
|
|
@@ -14,7 +14,7 @@ Plutonium integrates Rodauth seamlessly with its portal system.
|
|
|
14
14
|
|
|
15
15
|
## Installing Rodauth
|
|
16
16
|
|
|
17
|
-
Run the Plutonium Rodauth installer once per app
|
|
17
|
+
Run the Plutonium Rodauth installer once per app. It creates the Rodauth app, plugin, and initializer:
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
rails generate pu:rodauth:install
|
|
@@ -24,7 +24,7 @@ rails generate pu:rodauth:install
|
|
|
24
24
|
|
|
25
25
|
## Creating an Account Type
|
|
26
26
|
|
|
27
|
-
Plutonium supports multiple account types. For admins, use the dedicated `pu:rodauth:admin` generator
|
|
27
|
+
Plutonium supports multiple account types. For admins, use the dedicated `pu:rodauth:admin` generator. It's a preset on top of `pu:rodauth:account` that enables 2FA, lockout, audit logging, and disables public signup:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
rails generate pu:rodauth:admin admin
|
|
@@ -71,8 +71,8 @@ rails generate pu:pkg:portal admin --auth=admin --force
|
|
|
71
71
|
|
|
72
72
|
This updates two files:
|
|
73
73
|
|
|
74
|
-
- `packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb
|
|
75
|
-
- `packages/admin_portal/config/routes.rb
|
|
74
|
+
- `packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb`: swaps `include Plutonium::Auth::Public` for `include Plutonium::Auth::Rodauth(:admin)`, giving you `current_user`, `logout_url`, and `profile_url` helpers throughout the portal.
|
|
75
|
+
- `packages/admin_portal/config/routes.rb`: wraps the engine mount in a routes-level constraint:
|
|
76
76
|
|
|
77
77
|
```ruby
|
|
78
78
|
constraints Rodauth::Rails.authenticate(:admin) do
|
|
@@ -80,9 +80,9 @@ This updates two files:
|
|
|
80
80
|
end
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
The routes constraint is what actually gates access
|
|
83
|
+
The routes constraint is what actually gates access: unauthenticated requests to `/admin/*` are redirected to `/admins/login` before they hit any controller or policy.
|
|
84
84
|
|
|
85
|
-
(If you prefer not to regenerate, you can apply both edits by hand
|
|
85
|
+
(If you prefer not to regenerate, you can apply both edits by hand; they're shown above.)
|
|
86
86
|
|
|
87
87
|
## Testing Authentication
|
|
88
88
|
|
|
@@ -164,7 +164,7 @@ Now we can add the author relationship to our Post model. Generate a migration:
|
|
|
164
164
|
rails generate migration AddUserToBloggingPosts user:belongs_to
|
|
165
165
|
```
|
|
166
166
|
|
|
167
|
-
Update the migration to add the foreign key:
|
|
167
|
+
Update the migration to add the foreign key (keep the `Migration[x.y]` version Rails generated for your app):
|
|
168
168
|
|
|
169
169
|
```ruby
|
|
170
170
|
class AddUserToBloggingPosts < ActiveRecord::Migration[8.0]
|
|
@@ -28,8 +28,13 @@ Let's implement basic CRUD permissions:
|
|
|
28
28
|
|
|
29
29
|
```ruby
|
|
30
30
|
class Blogging::PostPolicy < Blogging::ResourcePolicy
|
|
31
|
-
# Anyone can
|
|
31
|
+
# Anyone can open the list; relation_scope (below) decides which posts it holds
|
|
32
32
|
def read?
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A single post: published, or your own
|
|
37
|
+
def show?
|
|
33
38
|
record.published? || owner?
|
|
34
39
|
end
|
|
35
40
|
|
|
@@ -56,6 +61,8 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
|
|
|
56
61
|
end
|
|
57
62
|
```
|
|
58
63
|
|
|
64
|
+
`read?` also backs `index?`, which is checked against the `Blogging::Post` class rather than a post, so `record.published?` there would raise `NoMethodError` on the list page. Record-state checks go in `show?`, `update?` and `destroy?`, which always receive the post.
|
|
65
|
+
|
|
59
66
|
## Understanding the Policy Context
|
|
60
67
|
|
|
61
68
|
Inside a policy, you have access to:
|
|
@@ -63,13 +70,13 @@ Inside a policy, you have access to:
|
|
|
63
70
|
| Accessor | Description |
|
|
64
71
|
|----------|-------------|
|
|
65
72
|
| `user` | The current authenticated user |
|
|
66
|
-
| `record` | The resource being authorized |
|
|
73
|
+
| `record` | The resource being authorized (the resource class on collection routes such as index and new) |
|
|
67
74
|
| `entity_scope` | Parent record for scoping (e.g., Organization in multi-tenant apps) |
|
|
68
75
|
|
|
69
76
|
```ruby
|
|
70
77
|
def some_permission?
|
|
71
78
|
user # => Current user (from authentication)
|
|
72
|
-
record # => The Post instance being checked
|
|
79
|
+
record # => The Post instance being checked (Blogging::Post on index/new)
|
|
73
80
|
entity_scope # => Parent record for multi-tenancy (or nil)
|
|
74
81
|
end
|
|
75
82
|
```
|
|
@@ -101,6 +108,12 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
|
|
|
101
108
|
[:title] # Limited view for unpublished posts
|
|
102
109
|
end
|
|
103
110
|
end
|
|
111
|
+
|
|
112
|
+
# The table is built from the class, not a post, so this must not touch `record`
|
|
113
|
+
# (without it, index falls back to permitted_attributes_for_read and raises)
|
|
114
|
+
def permitted_attributes_for_index
|
|
115
|
+
[:title, :published, :created_at, :user]
|
|
116
|
+
end
|
|
104
117
|
end
|
|
105
118
|
```
|
|
106
119
|
|
|
@@ -108,7 +121,7 @@ end
|
|
|
108
121
|
|
|
109
122
|
Control which records appear in listings:
|
|
110
123
|
|
|
111
|
-
`relation_scope` is a macro
|
|
124
|
+
`relation_scope` is a macro: it takes a block. Writing it as a plain instance
|
|
112
125
|
method (`def relation_scope(relation)`) overrides nothing and your scoping is
|
|
113
126
|
silently ignored, so Plutonium raises if you try.
|
|
114
127
|
|
|
@@ -148,6 +161,10 @@ class AdminPortal::Blogging::PostPolicy < ::Blogging::PostPolicy
|
|
|
148
161
|
true
|
|
149
162
|
end
|
|
150
163
|
|
|
164
|
+
def show?
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
151
168
|
def update?
|
|
152
169
|
true
|
|
153
170
|
end
|
|
@@ -79,11 +79,11 @@ end
|
|
|
79
79
|
|
|
80
80
|
## Testing the Action
|
|
81
81
|
|
|
82
|
-
Open any unpublished post and click **Actions** in the top-right
|
|
82
|
+
Open any unpublished post and click **Actions** in the top-right; the "Publish Post" item appears with its Tabler icon:
|
|
83
83
|
|
|
84
84
|

|
|
85
85
|
|
|
86
|
-
It also shows on each table row's `⋮` menu
|
|
86
|
+
It also shows on each table row's `⋮` menu, same action, available wherever the record is rendered:
|
|
87
87
|
|
|
88
88
|

|
|
89
89
|
|
|
@@ -13,8 +13,11 @@ Nested resources are resources that belong to a parent resource. In our blog:
|
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
rails generate pu:res:scaffold Comment body:text user:belongs_to Blogging/Post:belongs_to --dest=blogging
|
|
16
|
+
rails db:prepare
|
|
16
17
|
```
|
|
17
18
|
|
|
19
|
+
Run the migration before connecting the resource to a portal: `pu:res:conn` reads the table's columns to seed the policy's attribute lists.
|
|
20
|
+
|
|
18
21
|
## Setting Up the Association
|
|
19
22
|
|
|
20
23
|
Update the Post model to add the `has_many` association:
|
|
@@ -69,11 +72,11 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
|
|
|
69
72
|
end
|
|
70
73
|
```
|
|
71
74
|
|
|
72
|
-
The post show page now has tabs
|
|
75
|
+
The post show page now has tabs, **Details** and **Comments**, driven by the associations you permit:
|
|
73
76
|
|
|
74
77
|

|
|
75
78
|
|
|
76
|
-
Clicking **Comments** opens the nested index for that post
|
|
79
|
+
Clicking **Comments** opens the nested index for that post: a complete sub-resource view with its own paginated table, "New" button, and row actions:
|
|
77
80
|
|
|
78
81
|

|
|
79
82
|
|
|
@@ -168,11 +168,11 @@ Now you have two portals:
|
|
|
168
168
|
| Admin | `/admin` | Admin | All posts |
|
|
169
169
|
| Author | `/author` | User | Own posts only |
|
|
170
170
|
|
|
171
|
-
Log in at `/users/login` with the user account and you land on the Author Portal dashboard
|
|
171
|
+
Log in at `/users/login` with the user account and you land on the Author Portal dashboard, the same chrome as the Admin Portal but mounted at `/author`, gated by `Rodauth::Rails.authenticate(:user)`:
|
|
172
172
|
|
|
173
173
|

|
|
174
174
|
|
|
175
|
-
The posts list lives at `/author/blogging/posts
|
|
175
|
+
The posts list lives at `/author/blogging/posts`: same `Blogging::Post` resource, different portal context (and once you add the scoping policy below, scoped to the logged-in author):
|
|
176
176
|
|
|
177
177
|

|
|
178
178
|
|