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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Custom Actions
|
|
2
2
|
|
|
3
|
-
Add buttons beyond CRUD
|
|
3
|
+
Add buttons beyond CRUD: Publish, Archive, Import, Send invitation, Bulk-update, etc.
|
|
4
4
|
|
|
5
5
|
## Goal
|
|
6
6
|
|
|
@@ -10,17 +10,17 @@ A button appears in the right place (show page / table row / index header / bulk
|
|
|
10
10
|
|
|
11
11
|
| Flavor | Use for |
|
|
12
12
|
|---|---|
|
|
13
|
-
| **Simple action
|
|
14
|
-
| **Interactive action
|
|
13
|
+
| **Simple action**: navigate to a URL | Linking to external docs, jumping to a custom page that does its own thing |
|
|
14
|
+
| **Interactive action**: run an interaction class | Anything that *does* something (the common case) |
|
|
15
15
|
|
|
16
|
-
Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects
|
|
16
|
+
Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects, all for free.
|
|
17
17
|
|
|
18
|
-
## Quick recipe
|
|
18
|
+
## Quick recipe: interactive action
|
|
19
19
|
|
|
20
20
|
### 1. Write the interaction
|
|
21
21
|
|
|
22
22
|
```ruby
|
|
23
|
-
# app/models/post.rb
|
|
23
|
+
# app/models/post.rb: what publishing actually means
|
|
24
24
|
class Post < ApplicationRecord
|
|
25
25
|
def publish!(on: Time.current)
|
|
26
26
|
update!(published: true, published_at: on)
|
|
@@ -29,7 +29,7 @@ end
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
```ruby
|
|
32
|
-
# app/interactions/publish_post_interaction.rb
|
|
32
|
+
# app/interactions/publish_post_interaction.rb: the button in front of it
|
|
33
33
|
class PublishPostInteraction < ResourceInteraction
|
|
34
34
|
presents label: "Publish",
|
|
35
35
|
icon: Phlex::TablerIcons::Send,
|
|
@@ -51,7 +51,7 @@ Plutonium doesn't rescue it automatically. Always rescue when using `create!` /
|
|
|
51
51
|
:::
|
|
52
52
|
|
|
53
53
|
::: tip Why `publish!` is on the model
|
|
54
|
-
An interaction can only be built with a `view_context
|
|
54
|
+
An interaction can only be built with a `view_context`; it's a presentation object. A two-line `update!` inline in `execute` is fine while the button is the only caller; the moment a scheduled-publishing job wants the same behaviour it has to duplicate it or fake a view context. Full rule: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
|
|
55
55
|
:::
|
|
56
56
|
|
|
57
57
|
### 2. Register it in the definition
|
|
@@ -62,7 +62,7 @@ class PostDefinition < ResourceDefinition
|
|
|
62
62
|
end
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
Action visibility (record / bulk / resource) is **inferred** from the interaction's attributes
|
|
65
|
+
Action visibility (record / bulk / resource) is **inferred** from the interaction's attributes; no need to declare `record_action: true`. See [Inferred visibility](#inferred-visibility) below.
|
|
66
66
|
|
|
67
67
|
### 3. Add a policy method
|
|
68
68
|
|
|
@@ -88,7 +88,7 @@ For `interaction:`-based actions, visibility flags are inferred from the interac
|
|
|
88
88
|
| `attribute :resources` (plural) | `bulk_action: true` → bulk toolbar |
|
|
89
89
|
| neither | `resource_action: true` → index page header |
|
|
90
90
|
|
|
91
|
-
User-supplied flags can only **opt OUT** of inferred ones. Don't try to "broaden"
|
|
91
|
+
User-supplied flags can only **opt OUT** of inferred ones. Don't try to "broaden"; the interaction's attribute shape is semantic:
|
|
92
92
|
|
|
93
93
|
```ruby
|
|
94
94
|
# Hide from per-row menu, keep on show page
|
|
@@ -127,7 +127,7 @@ class Company::InviteUserInteraction < ResourceInteraction
|
|
|
127
127
|
end
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
`Company#invite!` creates the row *and* sends the mail. Both are things a seat-provisioning job needs to do without a browser anywhere in sight
|
|
130
|
+
`Company#invite!` creates the row *and* sends the mail. Both are things a seat-provisioning job needs to do without a browser anywhere in sight, see the [full worked example](/reference/behavior/interactions#complete-example).
|
|
131
131
|
|
|
132
132
|
## Bulk actions
|
|
133
133
|
|
|
@@ -146,7 +146,7 @@ class BulkArchiveInteraction < ResourceInteraction
|
|
|
146
146
|
end
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
Policy
|
|
149
|
+
Policy: checked **per record** (fails the whole request if any record is unauthorized):
|
|
150
150
|
|
|
151
151
|
```ruby
|
|
152
152
|
def bulk_archive?
|
|
@@ -181,13 +181,13 @@ class BulkArchiveInteraction < ResourceInteraction
|
|
|
181
181
|
end
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
Nothing else about the action changes
|
|
184
|
+
Nothing else about the action changes: the definition, the policy method and the form are the same. Only the work moves.
|
|
185
185
|
|
|
186
186
|
Three things worth knowing:
|
|
187
187
|
|
|
188
188
|
- **The block is the run's class body, not `execute`.** The work runs later, in a job with no controller, so it cannot close over anything in the interaction. Its inputs arrive through `options`.
|
|
189
189
|
- **The user is sent back where they were**, and the index they land on shows a banner for the run with a link to its progress page.
|
|
190
|
-
- **Permissions are re-checked per record, at perform time
|
|
190
|
+
- **Permissions are re-checked per record, at perform time**: not replayed from dispatch. A permission revoked while the run is working stops applying to the rest of it.
|
|
191
191
|
|
|
192
192
|
Full detail, including file attributes, failure policies and resuming a crashed run: [Async Interactions](/reference/behavior/async-interactions).
|
|
193
193
|
|
|
@@ -212,8 +212,8 @@ end
|
|
|
212
212
|
|
|
213
213
|
## Immediate vs form
|
|
214
214
|
|
|
215
|
-
- **Immediate
|
|
216
|
-
- **Form
|
|
215
|
+
- **Immediate**: interaction has only `:resource` / `:resources` (no extra inputs). Browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom message"` or `confirmation: false` on the action.
|
|
216
|
+
- **Form**: interaction has additional `attribute` / `input`. Renders modal form first; no auto-confirmation (the form is the confirmation).
|
|
217
217
|
|
|
218
218
|
## Action options
|
|
219
219
|
|
|
@@ -231,10 +231,10 @@ action :name,
|
|
|
231
231
|
|
|
232
232
|
# Behavior
|
|
233
233
|
confirmation: "Are you sure?",
|
|
234
|
-
modal: :slideover, # :slideover / :centered
|
|
235
|
-
size: :lg, # :sm / :md / :lg / :xl / :auto / :full
|
|
234
|
+
modal: :slideover, # :slideover / :centered, overrides definition's modal mode
|
|
235
|
+
size: :lg, # :sm / :md / :lg / :xl / :auto / :full, overrides definition's modal size
|
|
236
236
|
|
|
237
|
-
# HTML attributes
|
|
237
|
+
# HTML attributes: author wins over the framework's on every key
|
|
238
238
|
link: {target: "_blank", rel: "noopener"}, # every <a> rendering (toolbar GET link, dropdown items, bulk links, card show link)
|
|
239
239
|
button: {data: {analytics: "archive"}} # the button_to <form> wrapper (non-GET toolbar rendering)
|
|
240
240
|
```
|
|
@@ -283,7 +283,7 @@ Every resource gets `:archive` automatically.
|
|
|
283
283
|
|
|
284
284
|
## Where the logic goes
|
|
285
285
|
|
|
286
|
-
Interactions are presentation objects
|
|
286
|
+
Interactions are presentation objects: they need a `view_context` to exist at all. So the reflex to reach for when an operation grows:
|
|
287
287
|
|
|
288
288
|
```ruby
|
|
289
289
|
# 🚫 Three interactions, three view contexts, none of it callable from a job
|
|
@@ -300,22 +300,22 @@ def execute
|
|
|
300
300
|
end
|
|
301
301
|
```
|
|
302
302
|
|
|
303
|
-
Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a rake task also does
|
|
303
|
+
Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a rake task also does, and neither has a view context to hand.
|
|
304
304
|
|
|
305
|
-
The rule isn't "never put logic in an interaction". A single-caller operation can stay inline in `execute`; don't pre-extract. **The second caller is the trigger
|
|
305
|
+
The rule isn't "never put logic in an interaction". A single-caller operation can stay inline in `execute`; don't pre-extract. **The second caller is the trigger**, and the destination is the model, Rails-style, not a new service layer. Chaining three interactions is usually the tell that you already crossed it. Full explanation: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
|
|
306
306
|
|
|
307
307
|
## Common issues
|
|
308
308
|
|
|
309
|
-
- **Action button missing
|
|
310
|
-
- **`ActiveRecord::RecordInvalid` crashes the action
|
|
311
|
-
- **Bulk action fails on some records
|
|
312
|
-
- **Confirmation prompt shows when you don't want one
|
|
313
|
-
- **The action times out on a large selection
|
|
309
|
+
- **Action button missing**: check the policy method (`def my_action?`). Undefined returns `false`.
|
|
310
|
+
- **`ActiveRecord::RecordInvalid` crashes the action**: not rescued automatically. Wrap with `rescue`, return `failed(e.record.errors)`.
|
|
311
|
+
- **Bulk action fails on some records**: that's by design. Bulk policy is checked per-record; if any fails, the whole request is rejected. Either fix authorization or pre-filter the selection.
|
|
312
|
+
- **Confirmation prompt shows when you don't want one**: pass `confirmation: false` on the action.
|
|
313
|
+
- **The action times out on a large selection**: the work is running inside the request. Move it to a background run with `async`, above.
|
|
314
314
|
|
|
315
315
|
## Related
|
|
316
316
|
|
|
317
|
-
- [Reference › Resource › Actions](/reference/resource/actions)
|
|
318
|
-
- [Reference › Behavior › Interactions](/reference/behavior/interactions)
|
|
319
|
-
- [Reference › Behavior › Async Interactions](/reference/behavior/async-interactions)
|
|
320
|
-
- [Reference › Behavior › Policies](/reference/behavior/policies)
|
|
321
|
-
- [Authorization](./authorization)
|
|
317
|
+
- [Reference › Resource › Actions](/reference/resource/actions): full action options and bulk patterns
|
|
318
|
+
- [Reference › Behavior › Interactions](/reference/behavior/interactions): interaction class anatomy
|
|
319
|
+
- [Reference › Behavior › Async Interactions](/reference/behavior/async-interactions): `async`, progress pages, resuming a crashed run
|
|
320
|
+
- [Reference › Behavior › Policies](/reference/behavior/policies): `def <action>?` methods
|
|
321
|
+
- [Authorization](./authorization): policy patterns
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Customizing the UI
|
|
2
2
|
|
|
3
|
-
Plutonium's UI is built on Phlex, Tailwind 4, and Stimulus. Almost everything you see
|
|
3
|
+
Plutonium's UI is built on Phlex, Tailwind 4, and Stimulus. Almost everything you see (pages, forms, displays, tables, components, layouts, even the design tokens) is open for override. This guide is the map. Each section shows the smallest useful example for one kind of customization, then points to the reference for the full surface.
|
|
4
4
|
|
|
5
5
|
When you're not sure where to start, read this top to bottom. When you know what you need, jump to the right section and follow the link to reference.
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ class PostDefinition < ResourceDefinition
|
|
|
17
17
|
end
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Each nested class inherits from Plutonium's defaults and lets you override only the methods you care about. Don't reimplement the whole layer
|
|
20
|
+
Each nested class inherits from Plutonium's defaults and lets you override only the methods you care about. Don't reimplement the whole layer; use the render hooks below.
|
|
21
21
|
|
|
22
22
|
→ See [Reference › UI › Pages](/reference/ui/pages) for the full hook list.
|
|
23
23
|
|
|
@@ -29,15 +29,17 @@ Most page customization is "I want to add something before/after this section."
|
|
|
29
29
|
class PostDefinition < ResourceDefinition
|
|
30
30
|
class ShowPage < ShowPage
|
|
31
31
|
def render_before_content
|
|
32
|
-
div(class: "pu-
|
|
33
|
-
|
|
32
|
+
div(class: "pu-alert pu-alert-info", role: "status") do
|
|
33
|
+
div(class: "pu-alert-message") { t("blog.posts.show.comment_count", count: object.comments.count) }
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
`pu-alert pu-alert-<success|warning|danger|info>` is the same banner the flash messages use, so it already has its dark-mode colors. The text comes from a locale key; see [i18n](/reference/i18n#your-own-components-and-pages).
|
|
41
|
+
|
|
42
|
+
Hooks exist around the header, breadcrumbs, page header, toolbar, content, and footer: pick the one closest to where you want the thing to appear.
|
|
41
43
|
|
|
42
44
|
→ See [Reference › UI › Pages](/reference/ui/pages) › Page hooks.
|
|
43
45
|
|
|
@@ -56,7 +58,7 @@ class Form < Form
|
|
|
56
58
|
render_resource_field :published_at
|
|
57
59
|
render_resource_field :category
|
|
58
60
|
end
|
|
59
|
-
render_actions # REQUIRED
|
|
61
|
+
render_actions # REQUIRED: without this, no submit button
|
|
60
62
|
end
|
|
61
63
|
|
|
62
64
|
private
|
|
@@ -126,14 +128,14 @@ class PostCardComponent < Plutonium::UI::Component::Base
|
|
|
126
128
|
div(class: "pu-card pu-card-body") do
|
|
127
129
|
h3(class: "font-bold text-[var(--pu-text)]") { @post.title }
|
|
128
130
|
p(class: "text-[var(--pu-text-muted)] mt-2") { @post.excerpt }
|
|
129
|
-
a(href: resource_url_for(@post), class: "pu-btn pu-btn-sm pu-btn-ghost") { "
|
|
131
|
+
a(href: resource_url_for(@post), class: "pu-btn pu-btn-sm pu-btn-ghost") { t("blog.posts.card.read_more") }
|
|
130
132
|
end
|
|
131
133
|
end
|
|
132
134
|
end
|
|
133
135
|
```
|
|
134
136
|
|
|
135
137
|
Use it directly in a page, or wire it as a field in the definition. A component
|
|
136
|
-
with its own constructor takes the block form
|
|
138
|
+
with its own constructor takes the block form: you build it, so you decide what
|
|
137
139
|
it receives:
|
|
138
140
|
|
|
139
141
|
```ruby
|
|
@@ -143,13 +145,13 @@ end
|
|
|
143
145
|
```
|
|
144
146
|
|
|
145
147
|
`as: SomeComponent` is for a *field* component, which Plutonium constructs with
|
|
146
|
-
the field builder (`Phlexi::Form::Components::Base` / `Phlexi::Display::Components::Base`)
|
|
148
|
+
the field builder (`Phlexi::Form::Components::Base` / `Phlexi::Display::Components::Base`); see the reference below.
|
|
147
149
|
|
|
148
150
|
→ See [Reference › UI › Components](/reference/ui/components).
|
|
149
151
|
|
|
150
152
|
## Phlexi themes (recolor without rewriting)
|
|
151
153
|
|
|
152
|
-
If all you want is to recolor or restyle the form/display/table, write a `Theme` class instead of overriding the template. Always `super.merge(...)
|
|
154
|
+
If all you want is to recolor or restyle the form/display/table, write a `Theme` class instead of overriding the template. Always `super.merge(...)`; never replace wholesale:
|
|
153
155
|
|
|
154
156
|
```ruby
|
|
155
157
|
class Form < Form
|
|
@@ -173,7 +175,7 @@ By default `:new`, `:edit`, and every interactive action render in a slideover p
|
|
|
173
175
|
|
|
174
176
|
```ruby
|
|
175
177
|
class PostDefinition < ResourceDefinition
|
|
176
|
-
modal :slideover # default
|
|
178
|
+
modal :slideover # default: slide-in from the right
|
|
177
179
|
# modal :centered # centered dialog
|
|
178
180
|
# modal :centered, size: :lg # centered, wider container
|
|
179
181
|
# modal false # full standalone page
|
|
@@ -184,7 +186,7 @@ end
|
|
|
184
186
|
|
|
185
187
|
## Layouts and the shell
|
|
186
188
|
|
|
187
|
-
The layout is the chrome around every resource page
|
|
189
|
+
The layout is the chrome around every resource page: topbar, sidebar, flash region, scripts. Two ways to customize it:
|
|
188
190
|
|
|
189
191
|
```bash
|
|
190
192
|
# Per-portal: eject the shell partials and edit them directly
|
|
@@ -199,24 +201,24 @@ For programmatic overrides, subclass `Plutonium::UI::Layout::ResourceLayout` and
|
|
|
199
201
|
|
|
200
202
|
## Tailwind, Stimulus, and assets
|
|
201
203
|
|
|
202
|
-
Plutonium ships with a Tailwind config, design tokens, and a set of Stimulus controllers.
|
|
204
|
+
Plutonium ships with a Tailwind config, design tokens, and a set of Stimulus controllers. Out of the box the app serves the gem's prebuilt `plutonium.css` and `plutonium.min.js`, so app-side Tailwind classes, a new brand palette, token overrides and your own Stimulus controllers need your own bundles first. Run the generator rather than wiring the pipeline by hand:
|
|
203
205
|
|
|
204
206
|
```bash
|
|
205
207
|
rails generate pu:core:assets
|
|
206
208
|
```
|
|
207
209
|
|
|
208
|
-
This installs the npm packages, creates a `tailwind.config.js` that extends Plutonium's defaults via `plutoniumTailwindConfig.merge`, imports Plutonium's CSS,
|
|
210
|
+
This installs the npm packages, creates a `tailwind.config.js` that extends Plutonium's defaults via `plutoniumTailwindConfig.merge`, imports Plutonium's CSS, registers its Stimulus controllers, and points `config.assets.stylesheet` / `config.assets.script` at your `application` bundles. It needs `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` to exist (an app created with `-j esbuild -c tailwind` plus Stimulus). After that, you can:
|
|
209
211
|
|
|
210
|
-
- Extend the palette under `theme.extend.colors` (always inside `plutoniumTailwindConfig.merge
|
|
211
|
-
- Use `.pu-btn`, `.pu-card`, `.pu-input`, `.pu-table`, etc. instead of hand-rolling Tailwind chains.
|
|
212
|
+
- Extend the palette under `theme.extend.colors` (always inside `plutoniumTailwindConfig.merge`: a plain spread drops Plutonium's defaults), then rebuild the CSS.
|
|
213
|
+
- Use `.pu-btn`, `.pu-card`, `.pu-input`, `.pu-table`, `.pu-alert`, `.pu-badge`, etc. instead of hand-rolling Tailwind chains.
|
|
212
214
|
- Reference design tokens directly: `bg-[var(--pu-surface)]`, `text-[var(--pu-text-muted)]`, `border-[var(--pu-border)]`. These auto-switch with dark mode.
|
|
213
|
-
- Register your own Stimulus controllers alongside Plutonium's
|
|
215
|
+
- Register your own Stimulus controllers alongside Plutonium's. Your `application.js` now replaces the gem's bundle, so `registerControllers(application)` (added by the generator) must stay, or the entire interactive layer is dead.
|
|
214
216
|
|
|
215
217
|
→ See [Reference › UI › Assets](/reference/ui/assets) for the full toolchain, the `.pu-*` class catalog, and design-token reference.
|
|
216
218
|
|
|
217
219
|
## ERB views (escape hatch)
|
|
218
220
|
|
|
219
|
-
When the Phlex page class is the wrong tool
|
|
221
|
+
When the Phlex page class is the wrong tool (you want to keep an existing ERB layout, you're integrating with a designer's HTML, or you just want to surround the generated page with custom markup), drop an ERB view at the controller path:
|
|
220
222
|
|
|
221
223
|
```
|
|
222
224
|
app/views/posts/show.html.erb
|
|
@@ -237,7 +239,7 @@ Keep that line and wrap it to add chrome without giving up the generated page:
|
|
|
237
239
|
<%= render partial: "related" %>
|
|
238
240
|
```
|
|
239
241
|
|
|
240
|
-
Or replace the line entirely for full control. ERB views always win over the Phlex page class when both exist for the same action
|
|
242
|
+
Or replace the line entirely for full control. ERB views always win over the Phlex page class when both exist for the same action, so reach for this only when Phlex hooks + overrides genuinely can't do the job.
|
|
241
243
|
|
|
242
244
|
## When to reach for what
|
|
243
245
|
|
|
@@ -249,18 +251,19 @@ Or replace the line entirely for full control. ERB views always win over the Phl
|
|
|
249
251
|
| Reuse a UI block across pages | Custom Phlex component |
|
|
250
252
|
| Recolor without changing structure | Phlexi `Theme` class |
|
|
251
253
|
| Swap the topbar or sidebar | `pu:eject:shell` or custom layout class |
|
|
252
|
-
| Change brand color
|
|
254
|
+
| Change brand color | Tailwind `primary` palette via `plutoniumTailwindConfig.merge`, then rebuild. See [Theming](/guides/theming) |
|
|
255
|
+
| Change surfaces, borders or radius | Design tokens (mirrored in `.dark`). See [Theming](/guides/theming) |
|
|
253
256
|
| Add a custom JS interaction | Stimulus controller registered alongside Plutonium's |
|
|
254
257
|
|
|
255
258
|
## Gotchas
|
|
256
259
|
|
|
257
|
-
- **Don't override `view_template` in pages** when a render hook fits
|
|
258
|
-
- **`render_actions` is mandatory** when you write a custom `form_template
|
|
259
|
-
- **
|
|
260
|
+
- **Don't override `view_template` in pages** when a render hook fits, or you lose breadcrumbs, header, and DynaFrame (turbo-frame) behavior.
|
|
261
|
+
- **`render_actions` is mandatory** when you write a custom `form_template`, otherwise the form has no submit button.
|
|
262
|
+
- **Once the app serves its own JS bundle, `registerControllers(application)`** must be in `app/javascript/controllers/index.js` (`pu:core:assets` adds it). Without it, every Plutonium-shipped Stimulus controller is dead (color mode, slim-select, flatpickr, easymde, form pre-submit).
|
|
260
263
|
- **Use `plutoniumTailwindConfig.merge`** when extending the Tailwind theme. A plain object spread drops Plutonium's defaults.
|
|
261
|
-
- **
|
|
264
|
+
- **Style with `.pu-*` classes first, `var(--pu-*)` tokens second, raw palette pairs last.** The classes and tokens switch with dark mode; a hand-written `gray-X/dark:gray-Y` pair needs its own dark twin and won't follow a rebrand.
|
|
262
265
|
|
|
263
266
|
## Related
|
|
264
267
|
|
|
265
|
-
- [Theming](/guides/theming)
|
|
266
|
-
- [Reference › UI](/reference/ui/)
|
|
268
|
+
- [Theming](/guides/theming): design tokens, brand colors.
|
|
269
|
+
- [Reference › UI](/reference/ui/): the full surface area for every override above.
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# Dashboards
|
|
2
|
+
|
|
3
|
+
::: warning Experimental
|
|
4
|
+
Dashboards are experimental: the DSL and behavior may change in a future release.
|
|
5
|
+
:::
|
|
6
|
+
|
|
7
|
+
A dashboard is a page of cards: headline numbers, charts and free-form panels, declared in one Ruby class and mounted with a single routes line. By default every card loads in its own lazy turbo frame, so the page paints immediately and each card's queries run in a separate request as it scrolls into view.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## What you get
|
|
12
|
+
|
|
13
|
+
- A `Plutonium::Dashboard::Base` subclass with a class-level DSL: `metric`, `chart` and `card`.
|
|
14
|
+
- `register_dashboard` in a portal's routes draws the page, the per-card endpoint and a synthesized controller that inherits the portal's auth, tenant scoping and layout.
|
|
15
|
+
- Metric cards format numbers (delimited, currency, percentage, human) and show a change indicator against a previous value.
|
|
16
|
+
- Chart cards render with [Chart.js](https://www.chartjs.org/) through [Chartkick](https://chartkick.com/), using Plutonium's design tokens in light and dark mode. The chart bundle loads on demand, so pages without a chart never download it.
|
|
17
|
+
- Cards sit on a 12-column grid with a sensible default width per kind, and can refresh themselves on an interval, link somewhere, and hide behind a condition.
|
|
18
|
+
- Registered dashboards appear in the portal sidebar.
|
|
19
|
+
|
|
20
|
+
## Worked example
|
|
21
|
+
|
|
22
|
+
### 1. Generate the dashboard
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
rails g pu:dashboard Sales --dest=admin_portal
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This writes `packages/admin_portal/app/dashboards/admin_portal/sales_dashboard.rb` and adds `register_dashboard AdminPortal::SalesDashboard, at: "sales"` to the portal's routes. Pass `--at=/` to make the dashboard the portal's root page instead; see [Replacing the portal's default page](#replacing-the-portal-s-default-page).
|
|
29
|
+
|
|
30
|
+
### 2. Declare the cards
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
module AdminPortal
|
|
34
|
+
class SalesDashboard < Plutonium::Dashboard::Base
|
|
35
|
+
presents label: "Sales", description: "Orders and revenue at a glance",
|
|
36
|
+
icon: Phlex::TablerIcons::ChartBar
|
|
37
|
+
|
|
38
|
+
refresh 60
|
|
39
|
+
|
|
40
|
+
metric(:orders, icon: Phlex::TablerIcons::ShoppingCart,
|
|
41
|
+
href: -> { resource_url_for(Order, parent: nil) }) do
|
|
42
|
+
{value: orders.where(created_at: 30.days.ago..).count,
|
|
43
|
+
previous: orders.where(created_at: 60.days.ago...30.days.ago).count,
|
|
44
|
+
change_label: "vs. previous 30 days"}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
metric(:revenue, format: :currency) { orders.sum(:total) }
|
|
48
|
+
|
|
49
|
+
metric(:refund_rate, format: :percentage, precision: 1, positive: :down) do
|
|
50
|
+
{value: refund_rate, previous: previous_refund_rate}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
metric(:customers, format: :human) { Customer.count }
|
|
54
|
+
|
|
55
|
+
chart(:revenue_by_day, type: :area, span: 8) do
|
|
56
|
+
orders.group_by_day(:created_at, last: 30).sum(:total)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
chart(:by_channel, type: :donut, span: 4) do
|
|
60
|
+
orders.group(:channel).count
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
card(:latest, span: :full) do
|
|
64
|
+
ul(class: "divide-y divide-[var(--pu-border-muted)]") do
|
|
65
|
+
latest_orders.each do |order|
|
|
66
|
+
li(class: "py-2 flex justify-between") do
|
|
67
|
+
span { order.number }
|
|
68
|
+
span(class: "text-[var(--pu-text-muted)]") { helpers.number_to_currency(order.total) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def orders = authorized_resource_scope(Order)
|
|
77
|
+
def latest_orders = orders.order(created_at: :desc).limit(5)
|
|
78
|
+
def refund_rate = 100.0 * orders.refunded.count / [orders.count, 1].max
|
|
79
|
+
def previous_refund_rate = 1.8
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`group_by_day` comes from the [groupdate](https://github.com/ankane/groupdate) gem, which pairs well with chart cards but is not required.
|
|
85
|
+
|
|
86
|
+
### 3. Visit it
|
|
87
|
+
|
|
88
|
+

|
|
89
|
+
|
|
90
|
+
The page is at `/admin/dashboards/sales`. Each card is a `<turbo-frame src="/admin/dashboards/sales/cards/<key>" loading="lazy">` holding a skeleton until its response lands. Every mount sits under `dashboards/`, so `at: "sales"` never collides with a `Sale` resource registered on the same portal. Pass `prefix: nil` to mount at the bare path (`/admin/sales`), or a string to use another segment (`prefix: "reports"`).
|
|
91
|
+
|
|
92
|
+
## Replacing the portal's default page
|
|
93
|
+
|
|
94
|
+
Every generated portal opens on `root to: "dashboard#index"`, a `DashboardController` and a view that lists the registered resources. To make a dashboard the portal's root page instead, mount it at `/`:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
rails g pu:dashboard Home --dest=admin_portal --at=/
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The generator writes `AdminPortal::HomeDashboard` and replaces the `root to:` line with `register_dashboard AdminPortal::HomeDashboard, at: "/"`. The page is served at the portal root (`/admin`), its cards at `/admin/dashboards/home/cards/<key>`, and the sidebar's Home link keeps pointing at it. A root-mounted dashboard is the Home link, so it is left out of the Dashboards group.
|
|
101
|
+
|
|
102
|
+
The generator does not delete the old `DashboardController` or its `dashboard/index.html.erb`; nothing routes to them any more, so remove them yourself:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
rm packages/admin_portal/app/controllers/admin_portal/dashboard_controller.rb
|
|
106
|
+
rm packages/admin_portal/app/views/admin_portal/dashboard/index.html.erb
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The same registration by hand, for an existing portal:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
AdminPortal::Engine.routes.draw do
|
|
113
|
+
register_dashboard AdminPortal::HomeDashboard, at: "/" # in place of root to: "dashboard#index"
|
|
114
|
+
# ...
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Only one root per portal: keep either the `root to:` line or the `at: "/"` registration, never both.
|
|
119
|
+
|
|
120
|
+
## Cards
|
|
121
|
+
|
|
122
|
+
All three kinds share the same options:
|
|
123
|
+
|
|
124
|
+
| Option | Meaning |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `label:` | Card title. Defaults to a locale convention, then the key titleized. |
|
|
127
|
+
| `description:` | Caption under the title. |
|
|
128
|
+
| `icon:` | A `Phlex::TablerIcons::*` class shown beside the title. |
|
|
129
|
+
| `span:` | Columns of the 12-column grid to span: `1` to `12`, or `:full`. Defaults to `3` for a metric and `6` for a chart or a custom card. See [Layout](#layout). |
|
|
130
|
+
| `lazy:` | `true` (default) loads the card in its own turbo frame; `false` renders it inline with the page. See [Lazy and inline cards](#lazy-and-inline-cards). |
|
|
131
|
+
| `refresh:` | Seconds between automatic reloads of the card's frame. Needs `lazy: true`. `false` opts the card out of the dashboard's `refresh`. |
|
|
132
|
+
| `condition:` | A proc or a symbol naming a dashboard method. When false the card is left out of the page and its endpoint responds 404. |
|
|
133
|
+
| `href:` | A path, or a proc returning one, that links the title. |
|
|
134
|
+
|
|
135
|
+
Blocks run on the dashboard instance, which exposes `current_user`, `current_scoped_entity`, `params`, `authorized_resource_scope`, `resource_url_for`, `allowed_to?`, `helpers` (the view context) and the dashboard's own private methods. A one-argument block receives the dashboard instead.
|
|
136
|
+
|
|
137
|
+
### Metric
|
|
138
|
+
|
|
139
|
+
The block returns a value, or a hash with a change:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
metric(:signups) { User.count } # just the number
|
|
143
|
+
metric(:mrr, format: :currency) { {value: 12_400, previous: 11_000} } # +12.7%
|
|
144
|
+
metric(:latency, suffix: " ms") { {value: 182, change: "-14 ms", trend: :down} }
|
|
145
|
+
metric(:churn, format: :percentage, positive: :down) { {value: 1.2, previous: 1.0} }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
| Key | Meaning |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `value` | The number (or string) to show. `nil` renders an em dash. |
|
|
151
|
+
| `previous` | Computes the percentage change from this value. |
|
|
152
|
+
| `change` | A number in percentage points (`2.5` renders `+2.5%`) or a string shown verbatim. |
|
|
153
|
+
| `trend` | `:up`, `:down` or `:flat`. Inferred from the sign of `change` when omitted. |
|
|
154
|
+
| `change_label` | Caption after the change, such as "vs. last month". Also a card option. |
|
|
155
|
+
|
|
156
|
+
Metric options: `format:` (`:number`, `:currency`, `:percentage`, `:human`, or a proc receiving the value), `precision:`, `unit:` (currency symbol), `prefix:`, `suffix:`, `positive:` (`:up` by default; `:down` when a falling number is good) and `change_label:`.
|
|
157
|
+
|
|
158
|
+
### Chart
|
|
159
|
+
|
|
160
|
+
The block returns [Chartkick data](https://chartkick.com/#data): a `{label => value}` hash, an array of pairs, or an array of `{name:, data:}` series. Date and time keys become a time axis.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
chart(:signups, type: :line) { User.group_by_day(:created_at, last: 30).count }
|
|
164
|
+
chart(:plans, type: :pie) { Subscription.group(:plan).count }
|
|
165
|
+
chart(:traffic, type: :column, stacked: true) do
|
|
166
|
+
[{name: "Web", data: web_by_week}, {name: "Mobile", data: mobile_by_week}]
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`type:` is one of `:line`, `:area`, `:column`, `:bar`, `:pie`, `:donut` or `:scatter`; `height:` sets the drawing area (default `"240px"`). Every other option (`colors:`, `stacked:`, `min:`, `max:`, `prefix:`, `suffix:`, `xtitle:`, `ytitle:`, `legend:`, `curve:`, `points:`, `library:`, ...) is passed straight through to Chartkick. Series colours default to the `--pu-chart-1` to `--pu-chart-8` design tokens.
|
|
171
|
+
|
|
172
|
+
### Custom
|
|
173
|
+
|
|
174
|
+
The block renders Phlex markup inside the card body. It is evaluated in the card component, so `div`, `ul`, `render` and the rest are available, and any method it calls that the component lacks is forwarded to the dashboard.
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
card(:onboarding, description: "Where new tenants are") do
|
|
178
|
+
Plutonium::Wizard.in_progress_for(view_context).each do |entry|
|
|
179
|
+
a(href: entry.resume_url, class: "block py-1") { entry.label }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Layout
|
|
185
|
+
|
|
186
|
+
The grid is 12 columns wide on large screens, so halves, thirds and quarters all divide evenly and can share a dashboard. `span:` says how many of the 12 a card takes. Left out, a metric takes `3` (four to a row) and a chart or a custom card takes `6` (two to a row).
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
metric(:orders) { orders.count } # 3 of 12: four to a row
|
|
190
|
+
metric(:revenue, span: 6, format: :currency) { revenue } # a headline number, half the row
|
|
191
|
+
chart(:revenue_by_day, type: :area, span: 8) { by_day } # two thirds
|
|
192
|
+
chart(:by_channel, type: :donut, span: 4) { by_channel } # the remaining third
|
|
193
|
+
card(:latest, span: :full) { render_latest } # the whole row (same as 12)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Cards fill rows in declaration order and wrap when the next card does not fit. Tablets get two columns: a card with a span of `6` or more takes the full row, anything narrower takes one column. Phones get a single column.
|
|
197
|
+
|
|
198
|
+
`width` sets the page width with the same tokens as resource pages (`:sm` to `:full`) and defaults to `:full`.
|
|
199
|
+
|
|
200
|
+
## Lazy and inline cards
|
|
201
|
+
|
|
202
|
+
Every card is lazy unless you say otherwise. `lazy: false` makes it inline: rendered with the page instead of fetched after it.
|
|
203
|
+
|
|
204
|
+
| | `lazy: true` (default) | `lazy: false` (inline) |
|
|
205
|
+
|---|---|---|
|
|
206
|
+
| Where the block runs | In its own request to `<mount>/cards/<key>`, when the frame scrolls into view | In the page request, before the page is sent |
|
|
207
|
+
| What the page response holds | A `<turbo-frame loading="lazy">` with a skeleton | The finished card; no frame, no skeleton, no second request |
|
|
208
|
+
| Cost | One extra request per card; the page itself never waits | The page waits for the block, so every inline card adds its query time to the page |
|
|
209
|
+
| `refresh` | Reloads on the card's or the dashboard's interval | Never refreshes. A number raises `ArgumentError`; the dashboard's `refresh` skips it |
|
|
210
|
+
| `condition:` false | Left out of the page, block never runs, endpoint is 404 | The same |
|
|
211
|
+
| Raises in production | Notice in place of the body, logged and reported | The same; the rest of the page still renders |
|
|
212
|
+
| Raises in development and test | That card's frame request fails; the page and the other cards are fine | The whole page raises, because the card is part of the page |
|
|
213
|
+
|
|
214
|
+
Make a card inline when its value is cheap (a cached number, an indexed count) and sits at the top of the page, where a skeleton flash and an extra request cost more than the query. Leave everything else lazy: one slow inline card delays the whole page, which is the problem lazy cards exist to solve.
|
|
215
|
+
|
|
216
|
+
## Refreshing
|
|
217
|
+
|
|
218
|
+
`refresh 60` on the dashboard reloads every lazy card once a minute; `refresh: 10` on a card overrides it, and `refresh: false` keeps a card out of it (an expensive chart that does not need to be live). Reloads pause while the tab is hidden and catch up when it becomes visible. A chart re-renders in place when its data changes.
|
|
219
|
+
|
|
220
|
+
## Authorization
|
|
221
|
+
|
|
222
|
+
A portal dashboard sits behind the portal's authentication like every other page. To restrict it further, override `authorize?`; a false answer is a 403 on the page and on every card endpoint:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
def authorize? = current_user.admin?
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Use `condition:` to hide an individual card. The condition gates the card's endpoint as well, so a hidden card cannot be fetched by URL. An inline card behaves the same way.
|
|
229
|
+
|
|
230
|
+
## Errors
|
|
231
|
+
|
|
232
|
+
When `config.consider_all_requests_local` is off (production), a card whose block raises renders a short notice in its place, and the rest of the dashboard is unaffected. The failure is written to the Rails log and reported through `Rails.error` with `source: "plutonium.dashboard"` and a `context` naming the dashboard class and the card key, so an error tracker subscribed to `Rails.error` (Sentry, Honeybadger, AppSignal) receives it. In development and test the error is raised so it is visible.
|
|
233
|
+
|
|
234
|
+

|
|
235
|
+
|
|
236
|
+
An inline card renders under the same guard, so in production it shows the same notice. In development and test its error is the page's error: the whole dashboard raises, where a lazy card fails only its own frame. [Lazy and inline cards](#lazy-and-inline-cards) has the full comparison.
|
|
237
|
+
|
|
238
|
+
## Multi-tenancy
|
|
239
|
+
|
|
240
|
+
On an entity-scoped portal the page and card URLs carry the tenant segment, and `current_scoped_entity` is available in every block:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
class TeamDashboard < Plutonium::Dashboard::Base
|
|
244
|
+
metric(:members) { current_scoped_entity.memberships.count }
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Sidebar
|
|
249
|
+
|
|
250
|
+
Dashboards registered with `register_dashboard` are grouped in the portal sidebar under a **Dashboards** item, after the Home link, with one child link per dashboard. A dashboard mounted at the root is what the Home link opens, so it is left out of the group; with nothing else registered the group is not rendered. 
|
|
251
|
+
|
|
252
|
+
Portals generated before this feature carry an ejected `_resource_sidebar.html.erb`; add the block from the gem's partial to list dashboards there:
|
|
253
|
+
|
|
254
|
+
```erb
|
|
255
|
+
dashboards = registered_dashboards.reject { |dashboard| dashboard_path_for(dashboard) == root_path }
|
|
256
|
+
if dashboards.any?
|
|
257
|
+
m.item t("plutonium.resource.nav.dashboards"), icon: Phlex::TablerIcons::LayoutDashboard do |n|
|
|
258
|
+
dashboards.each do |dashboard|
|
|
259
|
+
n.item dashboard.label, url: dashboard_path_for(dashboard), icon: dashboard.icon
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The labels are the `plutonium.resource.nav.home` and `plutonium.resource.nav.dashboards` locale keys.
|
|
266
|
+
|
|
267
|
+
## Translations
|
|
268
|
+
|
|
269
|
+
Titles resolve by convention, layered per portal like every other derived label:
|
|
270
|
+
|
|
271
|
+
```yaml
|
|
272
|
+
en:
|
|
273
|
+
plutonium:
|
|
274
|
+
dashboards:
|
|
275
|
+
admin_portal/sales:
|
|
276
|
+
label: "Ventes"
|
|
277
|
+
description: "Commandes et chiffre d'affaires"
|
|
278
|
+
cards:
|
|
279
|
+
orders:
|
|
280
|
+
label: "Commandes"
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
The dashboard key is the class name underscored without the `Dashboard` suffix. See the [i18n reference](/reference/i18n).
|
|
284
|
+
|
|
285
|
+
## Customizing the page
|
|
286
|
+
|
|
287
|
+
The page is `Plutonium::UI::Page::Dashboard`, a `Page::Base` with the usual `render_before_*` and `render_after_*` hooks. To take it over, define `<Portal>::DashboardsController` yourself, include `Plutonium::Dashboard::Controller`, and override `show`:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
module AdminPortal
|
|
291
|
+
class DashboardsController < PlutoniumController
|
|
292
|
+
include Plutonium::Dashboard::Controller
|
|
293
|
+
|
|
294
|
+
def show
|
|
295
|
+
authorize_dashboard!
|
|
296
|
+
render CustomDashboardPage.new(dashboard: current_dashboard)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
```
|