plutonium 0.64.0 → 0.65.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 +4 -1
- data/.claude/skills/plutonium-app/SKILL.md +14 -0
- data/.claude/skills/plutonium-dashboard/SKILL.md +161 -0
- data/.claude/skills/plutonium-resource/SKILL.md +94 -14
- data/.claude/skills/plutonium-ui/SKILL.md +22 -1
- data/CHANGELOG.md +59 -0
- data/CLAUDE.md +282 -210
- 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/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 +175 -0
- data/docs/blog/introducing-plutonium-i18n.md +91 -0
- data/docs/blog/introducing-plutonium.md +4 -5
- data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
- data/docs/guides/dashboards.md +300 -0
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +2 -2
- 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 +2 -2
- data/docs/reference/app/portals.md +2 -0
- data/docs/reference/configuration.md +84 -27
- 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/i18n.md +239 -0
- data/docs/reference/index.md +10 -1
- data/docs/reference/kanban/dsl.md +1 -1
- data/docs/reference/kanban/positioning.md +2 -2
- data/docs/reference/resource/actions.md +1 -1
- data/docs/reference/resource/definition.md +96 -18
- data/docs/reference/resource/export.md +5 -1
- data/docs/reference/resource/index.md +2 -0
- data/docs/reference/{positioning.md → resource/positioning.md} +2 -1
- data/docs/reference/resource/query.md +2 -0
- data/docs/reference/ui/assets.md +8 -0
- data/docs/reference/ui/displays.md +5 -8
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +2 -2
- 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/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/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/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 +91 -0
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '008433e7fc21f308028d5d047ddfa2c01c26efe13258f8af39ca99b7b72f0e0d'
|
|
4
|
+
data.tar.gz: d3c4ecbec9fd85ee133c4e0bd4b28fcf4fcc66ee632e0b5ea84d277fa00316a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cc40c769d7dc3b30e2f5a2cd88aeb07d41c52bb37f1e28fb6946c88705d1dfb04adf19c19cdbc2889d4356f722a4d5705b4f0b3e1142ba3b58dbe9c9cbb8f30e
|
|
7
|
+
data.tar.gz: 0a772b07eee9784de0012559814134515a3fb228f9b9bf51a75b94f4161a5b05d913434a24cbb7991fdc0722f415a69b87f725990603ed7557af5b1afdd0205b
|
|
@@ -53,7 +53,7 @@ Not "defaults someone picked for you" — **computed from existing declarations*
|
|
|
53
53
|
### 4. Climb the escape-hatch ladder only as far as the problem requires
|
|
54
54
|
|
|
55
55
|
1. **Change an option** — `input :content, as: :markdown`
|
|
56
|
-
2. **Render inline** — `display :priority
|
|
56
|
+
2. **Render inline** — `display :priority do |f| … end` (a block, `instance_exec`ed in Phlex, emits markup directly and gives you `f.object`)
|
|
57
57
|
3. **Write a component** — a *field* component (subclasses the Phlexi base) plugs into `as:`; anything with its own constructor goes through a block (`display :card do |field| … end`)
|
|
58
58
|
4. **Implement a hook** — controller hooks instead of reopening `create`/`update`; page `render_before_*` / `render_after_*` instead of `view_template`
|
|
59
59
|
5. **Replace the page** — `view_template` on the nested class, or an ERB view at the controller path (ERB wins when both exist)
|
|
@@ -85,6 +85,7 @@ This is the global "look before you leap"; each targeted skill carries its own A
|
|
|
85
85
|
| **[[plutonium-async-interactions]]** | Async interactions — `async`, the Run STI model, failure policies (`halt`/`continue`/`transactional`), authorization re-derivation at perform time, registering AsyncRun as a resource (progress page + running banner), scheduling `ReapJob` |
|
|
86
86
|
| **[[plutonium-ui]]** | Page classes, forms, displays, tables, custom Phlex components, layouts, modals & tabs, Tailwind config, Stimulus, design tokens, `.pu-*` classes, Phlexi themes |
|
|
87
87
|
| **[[plutonium-kanban]]** | `kanban do…end` DSL in a Definition — columns, `card_fields`, `position_on`, `realtime`, column actions, `kanban_move?` policy, quick-add, static vs dynamic boards |
|
|
88
|
+
| **[[plutonium-dashboard]]** | Dashboards — `Plutonium::Dashboard::Base`, `metric` / `chart` / `card`, `register_dashboard`, lazy turbo-frame cards, `refresh`, `condition:`, `authorize?`, `pu:dashboard` |
|
|
88
89
|
| **[[plutonium-auth]]** | Rodauth install, account types (basic / admin / SaaS), profile resource, security section |
|
|
89
90
|
| **[[plutonium-tenancy]]** | Entity scoping (`associated_with`, `default_relation_scope`, three model shapes), nested resources, invites |
|
|
90
91
|
| **[[plutonium-testing]]** | `pu:test:install`, `pu:test:scaffold`, `ResourceCrud`/`ResourcePolicy`/`ResourceDefinition`/`ResourceModel`/`NestedResource`/`PortalAccess`/`ResourceInteraction`, `AuthHelpers` |
|
|
@@ -122,6 +123,7 @@ Add when relevant:
|
|
|
122
123
|
| Configure parent/child nested routes, custom parent resolution | **[[plutonium-tenancy]]** |
|
|
123
124
|
| Set up user invitations or entity membership | **[[plutonium-tenancy]]** |
|
|
124
125
|
| Build or customize a kanban board view — `kanban do…end`, columns, `card_fields`, `position_on`, `realtime`, column actions, `kanban_move?` policy | **[[plutonium-kanban]]** |
|
|
126
|
+
| Build a dashboard, KPI overview or chart page — `pu:dashboard`, `metric` / `chart` / `card`, `register_dashboard`, refresh, per-card conditions | **[[plutonium-dashboard]]** |
|
|
125
127
|
| Build a custom page (override `ShowPage`/`IndexPage`/`NewPage`/`EditPage`), custom form, custom display, custom table, custom Phlex component | **[[plutonium-ui]]** |
|
|
126
128
|
| Configure Tailwind, register Stimulus controllers, edit design tokens, theme forms/displays/tables, write a custom layout | **[[plutonium-ui]]** |
|
|
127
129
|
| Install Rodauth, set up accounts, configure login flow, add the profile resource | **[[plutonium-auth]]** |
|
|
@@ -169,6 +171,7 @@ Every Plutonium generator is discoverable via `rails g pu:<tab>`. Always pass `-
|
|
|
169
171
|
| `pu:eject:shell` | Eject topbar/sidebar partials | `plutonium-ui` |
|
|
170
172
|
| `pu:test:install` | Install `Plutonium::Testing` scaffolding | `plutonium-testing` |
|
|
171
173
|
| `pu:test:scaffold NAME --portals=...` | Scaffold integration tests | `plutonium-testing` |
|
|
174
|
+
| `pu:dashboard NAME --dest=PORTAL [--at=/]` | Dashboard class + `register_dashboard` route | `plutonium-dashboard` |
|
|
172
175
|
| `pu:skills:sync` | Sync Plutonium Claude skills into the project | (this skill) |
|
|
173
176
|
|
|
174
177
|
## Unattended execution
|
|
@@ -263,6 +263,16 @@ end
|
|
|
263
263
|
|
|
264
264
|
This is loaded from `config/application.rb`. Migrations from all packages are picked up by `rails db:migrate` automatically.
|
|
265
265
|
|
|
266
|
+
## Locale files
|
|
267
|
+
|
|
268
|
+
Packages and portals are Rails engines, so `packages/<name>/config/locales/*.yml` loads automatically, ahead of the app's own `config/locales` (app wins, then packages, then the gem's defaults). Both generators scaffold `config/locales/en.yml`.
|
|
269
|
+
|
|
270
|
+
- Feature package: model/attribute names (`activerecord.models`, `activerecord.attributes`) and field text (`plutonium.fields.<model>.<attr>.{placeholder,hint,description}`).
|
|
271
|
+
- Portal: portal-specific wording under `plutonium.portals.<portal_namespace>.…` (same tree as the global keys: `fields`, `actions`, `scopes`, `filters`, `kanban_columns`, `values`). Wins over the global key only inside that portal.
|
|
272
|
+
- Any of Plutonium's own strings (`plutonium.*` in the gem's `config/locales/en/*.yml`) can be overridden from either place. Pagination text comes from Pagy's dictionaries instead.
|
|
273
|
+
|
|
274
|
+
Switch locale with a normal Rails `around_action` in the portal's controller concern; Plutonium reads `I18n.locale` and never sets it. See `docs/reference/i18n.md`.
|
|
275
|
+
|
|
266
276
|
## When to use which
|
|
267
277
|
|
|
268
278
|
**Feature packages** — domain logic that:
|
|
@@ -475,6 +485,10 @@ Use the `--singular` flag on `pu:res:conn`:
|
|
|
475
485
|
rails g pu:res:conn Profile --dest=customer_portal --singular
|
|
476
486
|
```
|
|
477
487
|
|
|
488
|
+
## Dashboards
|
|
489
|
+
|
|
490
|
+
`register_dashboard SomeDashboard, at: "overview"` mounts a `Plutonium::Dashboard::Base` subclass (metric / chart / free-form cards, each in its own lazy turbo frame) on the portal at `/dashboards/overview`; `at: "/"` (generator: `--at=/`) makes it the portal root in place of `root to: "dashboard#index"`; delete the generated `DashboardController` and view afterwards. Generate one with `rails g pu:dashboard Overview --dest=<portal>`. See [[plutonium-dashboard]].
|
|
491
|
+
|
|
478
492
|
## Custom member / collection routes
|
|
479
493
|
|
|
480
494
|
```ruby
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plutonium-dashboard
|
|
3
|
+
description: Use BEFORE building a dashboard, KPI page, metrics overview or chart page in a Plutonium app — Plutonium::Dashboard::Base, the metric / chart / card DSL, register_dashboard, lazy turbo-frame cards, refresh, conditions, authorize?, and the pu:dashboard generator. The single source for "how do I add a dashboard with metric cards and charts".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plutonium Dashboards
|
|
7
|
+
|
|
8
|
+
A dashboard is a class of cards (`metric`, `chart`, `card`) mounted in a portal with one routes line. Every card loads in its own lazy turbo frame, so the page paints at once and each card's queries run in a separate request. Charts render with Chart.js through Chartkick, styled by Plutonium's design tokens.
|
|
9
|
+
|
|
10
|
+
For resources and their definitions see [[plutonium-resource]]; for portals and routes see [[plutonium-app]]; for custom Phlex markup inside a card see [[plutonium-ui]].
|
|
11
|
+
|
|
12
|
+
## 🚨 Critical (read first)
|
|
13
|
+
|
|
14
|
+
- **Experimental.** The DSL and behavior may change in a future release, the same status as [[plutonium-wizard]], [[plutonium-kanban]] and [[plutonium-async-interactions]]. Fine to build on; expect to follow the changelog.
|
|
15
|
+
- **Generate, don't hand-write:** `rails g pu:dashboard Sales --dest=admin_portal` writes the class AND the `register_dashboard` line.
|
|
16
|
+
- **Replacing the portal's default page:** `rails g pu:dashboard Home --dest=admin_portal --at=/` mounts it at the portal root and replaces the generated `root to: "dashboard#index"` line (two roots clash). It does NOT delete the generated `DashboardController` + `dashboard/index.html.erb`; delete them, nothing routes there any more. The sidebar Home link then opens the dashboard, and it is left out of the Dashboards group.
|
|
17
|
+
- **Dashboards live in `app/dashboards/`** (`packages/<portal>/app/dashboards/<portal>/` in a package). Class name ends in `Dashboard`; the label drops the suffix.
|
|
18
|
+
- **Card blocks run on the dashboard instance**, not the controller: use `current_user`, `current_scoped_entity`, `authorized_resource_scope(Model)`, `resource_url_for`, `helpers`, and the dashboard's own private methods. Scope queries with `authorized_resource_scope`, never `Model.all`, on a multi-tenant portal.
|
|
19
|
+
- **Root-qualify packaged models inside a portal dashboard.** Within `module AdminPortal`, `Blogging::Post` resolves to the portal's own `AdminPortal::Blogging` controller namespace and raises `NameError`; write `::Blogging::Post`.
|
|
20
|
+
- **`metric` and `chart` blocks return data; `card` blocks render Phlex markup.** A `card` block is `instance_exec`ed in a Phlex component: `div`, `ul`, `render` work, and unknown methods forward to the dashboard.
|
|
21
|
+
- **`condition:` gates the card endpoint too** (404 when false), unlike a field `condition:`. Page-level access is `authorize?` on the dashboard (403).
|
|
22
|
+
- **`refresh:` needs `lazy: true`** (the default). It reloads the frame; an inline card has no frame. `refresh: false` opts a lazy card out of the dashboard's `refresh`.
|
|
23
|
+
- **Chart options other than `type:` / `height:` pass straight to Chartkick.** Unknown metric options raise.
|
|
24
|
+
- **Ejected sidebars don't list dashboards automatically.** Portals generated before this feature have their own `_resource_sidebar.html.erb`; add the `registered_dashboards` block (below), which groups them under a "Dashboards" parent after the Home link (`plutonium.resource.nav.home`).
|
|
25
|
+
|
|
26
|
+
## Minimal dashboard
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# packages/admin_portal/app/dashboards/admin_portal/sales_dashboard.rb
|
|
30
|
+
module AdminPortal
|
|
31
|
+
class SalesDashboard < Plutonium::Dashboard::Base
|
|
32
|
+
presents label: "Sales", description: "Orders and revenue", icon: Phlex::TablerIcons::ChartBar
|
|
33
|
+
refresh 60 # seconds, every lazy card (default off)
|
|
34
|
+
|
|
35
|
+
metric(:orders, icon: Phlex::TablerIcons::ShoppingCart, href: -> { resource_url_for(Order, parent: nil) }) do
|
|
36
|
+
{value: orders.where(created_at: 30.days.ago..).count,
|
|
37
|
+
previous: orders.where(created_at: 60.days.ago...30.days.ago).count,
|
|
38
|
+
change_label: "vs. previous 30 days"}
|
|
39
|
+
end
|
|
40
|
+
metric(:revenue, format: :currency) { orders.sum(:total) }
|
|
41
|
+
metric(:refund_rate, format: :percentage, precision: 1, positive: :down) { {value: 2.1, previous: 1.8} }
|
|
42
|
+
|
|
43
|
+
chart(:revenue_by_day, type: :area, span: 8) { orders.group_by_day(:created_at, last: 30).sum(:total) }
|
|
44
|
+
chart(:by_channel, type: :donut, span: 4) { orders.group(:channel).count }
|
|
45
|
+
|
|
46
|
+
card(:latest, span: :full) do
|
|
47
|
+
ul { latest_orders.each { |o| li { o.number } } }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def authorize? = current_user.admin? # optional; default true
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def orders = authorized_resource_scope(Order)
|
|
55
|
+
def latest_orders = orders.order(created_at: :desc).limit(5)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# packages/admin_portal/config/routes.rb
|
|
60
|
+
AdminPortal::Engine.routes.draw do
|
|
61
|
+
register_dashboard AdminPortal::SalesDashboard, at: "sales" # GET /admin/dashboards/sales, /admin/dashboards/sales/cards/:card
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Card options (all kinds)
|
|
66
|
+
|
|
67
|
+
| Option | Meaning |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `label:` / `description:` | Text; default from `plutonium.dashboards.<key>.cards.<card>.label`, then the key titleized |
|
|
70
|
+
| `icon:` | `Phlex::TablerIcons::*` class |
|
|
71
|
+
| `span:` | `1`..`12` of a 12-column grid, or `:full` (= 12). Default: metric `3`, chart `6`, card `6`. There is no `columns` macro. Tablet: span >= 6 takes the row, else one of two columns |
|
|
72
|
+
| `lazy:` | `true` (own turbo frame, default) / `false` (inline) |
|
|
73
|
+
| `refresh:` | seconds, overrides the dashboard's; `false` opts this card out of the dashboard's `refresh` |
|
|
74
|
+
| `condition:` | proc (on the instance) or symbol (dashboard method); false hides + 404s |
|
|
75
|
+
| `href:` | path or proc; links the title |
|
|
76
|
+
|
|
77
|
+
## metric
|
|
78
|
+
|
|
79
|
+
Return a value, or a hash: `value` (required), `previous` (computes % change), `change` (numeric percentage points or a verbatim string), `trend` (`:up`/`:down`/`:flat`, inferred), `change_label`.
|
|
80
|
+
|
|
81
|
+
Options: `format:` (`:number` default, `:currency`, `:percentage`, `:human`, or `->(v) { ... }`), `precision:`, `unit:` (currency symbol), `prefix:`, `suffix:`, `positive:` (`:up` default, `:down` when falling is good), `change_label:`.
|
|
82
|
+
|
|
83
|
+
## chart
|
|
84
|
+
|
|
85
|
+
Return Chartkick data: `{label => value}`, `[[label, value], ...]`, or `[{name:, data:}, ...]`. Date/Time keys draw a time axis. `groupdate` (`group_by_day` etc.) is the natural companion; it is not a dependency.
|
|
86
|
+
|
|
87
|
+
`type:` `:line` (default), `:area`, `:column`, `:bar`, `:pie`, `:donut`, `:scatter`. `height:` default `"240px"`. Everything else (`colors:`, `stacked:`, `min:`, `max:`, `suffix:`, `xtitle:`, `legend:`, `library:` ...) goes to Chartkick. Colours default to `--pu-chart-1..8`; charts redraw on colour-mode change.
|
|
88
|
+
|
|
89
|
+
## card
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
card(:onboarding, span: 4) do
|
|
93
|
+
Plutonium::Wizard.in_progress_for(view_context).each do |entry|
|
|
94
|
+
a(href: entry.resume_url, class: "block py-1") { entry.label }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Registration
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
register_dashboard HomeDashboard, at: "/" # root: replaces `root to: "dashboard#index"`
|
|
103
|
+
register_dashboard SalesDashboard, at: "sales" # /dashboards/sales, sales_dashboard_path
|
|
104
|
+
register_dashboard Reports::WeeklyDashboard, at: "reports/weekly", as: "weekly"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- Synthesizes `<Portal>::DashboardsController < <Portal>::PlutoniumController` (auth, tenancy, layout inherited). Define that class yourself, including `Plutonium::Dashboard::Controller`, to customize.
|
|
108
|
+
- Entity-scoped portals: URLs carry the scope segment; use `dashboard_path_for(Klass)` rather than a hand-built helper.
|
|
109
|
+
- Every non-root mount is drawn under `dashboards/` (`at: "sales"` → `/admin/dashboards/sales`), so it cannot collide with a resource route. Helper names carry no prefix. `prefix: nil` mounts at the bare path (`/admin/sales`, keeping it clear of resource routes is then on you); `prefix: "reports"` swaps the segment.
|
|
110
|
+
- Lazy vs inline: see the table below.
|
|
111
|
+
- Errors in a card: raised in development/test. In production the card is replaced by a notice, and the error is logged and reported via `Rails.error` (`source: "plutonium.dashboard"`, `context: {dashboard:, card:}`).
|
|
112
|
+
|
|
113
|
+
## Lazy vs inline (`lazy: false`)
|
|
114
|
+
|
|
115
|
+
| | `lazy: true` (default) | `lazy: false` (inline) |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| 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 |
|
|
118
|
+
| What the page response holds | A `<turbo-frame loading="lazy">` with a skeleton | The finished card; no frame, no skeleton, no second request |
|
|
119
|
+
| 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 |
|
|
120
|
+
| `refresh` | Reloads on the card's or the dashboard's interval | Never refreshes. A number raises `ArgumentError`; the dashboard's `refresh` skips it |
|
|
121
|
+
| `condition:` false | Left out of the page, block never runs, endpoint is 404 | The same |
|
|
122
|
+
| Raises in production | Notice in place of the body, logged and reported | The same; the rest of the page still renders |
|
|
123
|
+
| 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 |
|
|
124
|
+
|
|
125
|
+
Default to lazy. Use `lazy: false` only for a cheap value (cached number, indexed count) near the top of the page; one slow inline card delays the whole page.
|
|
126
|
+
|
|
127
|
+
## Sidebar (ejected partial)
|
|
128
|
+
|
|
129
|
+
```erb
|
|
130
|
+
dashboards = registered_dashboards.reject { |dashboard| dashboard_path_for(dashboard) == root_path }
|
|
131
|
+
if dashboards.any?
|
|
132
|
+
m.item t("plutonium.resource.nav.dashboards"), icon: Phlex::TablerIcons::LayoutDashboard do |n|
|
|
133
|
+
dashboards.each do |dashboard|
|
|
134
|
+
n.item dashboard.label, url: dashboard_path_for(dashboard), icon: dashboard.icon
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Translations
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
en:
|
|
144
|
+
plutonium:
|
|
145
|
+
dashboards:
|
|
146
|
+
admin_portal/sales: # Class.i18n_key
|
|
147
|
+
label: "Sales"
|
|
148
|
+
cards:
|
|
149
|
+
orders: { label: "Orders", description: "Last 30 days" }
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Portal override: `plutonium.portals.<portal>.dashboards...`.
|
|
153
|
+
|
|
154
|
+
## Testing
|
|
155
|
+
|
|
156
|
+
Integration-test the page (`get "/admin/dashboards/sales"`, expect one `<turbo-frame ... loading="lazy">` per card) and each card endpoint with the frame header: `get "/admin/dashboards/sales/cards/orders", headers: {"Turbo-Frame" => "pu-dashboard-card-orders"}`. Hidden cards are `:not_found`; a denied `authorize?` is `:forbidden`.
|
|
157
|
+
|
|
158
|
+
## Full docs
|
|
159
|
+
|
|
160
|
+
- Guide: `/guides/dashboards`
|
|
161
|
+
- Reference: `/reference/dashboard/dsl`, `/reference/dashboard/registration`
|
|
@@ -495,7 +495,7 @@ end
|
|
|
495
495
|
|--------|-----------|----------|
|
|
496
496
|
| `field` | Forms + Show + Table | Universal type override |
|
|
497
497
|
| `input` | Forms only | Form-specific options |
|
|
498
|
-
| `display` | Show page
|
|
498
|
+
| `display` | Show page, and the table unless a `column` renders | Display-specific options |
|
|
499
499
|
| `column` | Table only | Table-specific options |
|
|
500
500
|
|
|
501
501
|
```ruby
|
|
@@ -507,6 +507,17 @@ class PostDefinition < ResourceDefinition
|
|
|
507
507
|
end
|
|
508
508
|
```
|
|
509
509
|
|
|
510
|
+
⚠️ **A component option (`colors:`, `unit:`, `true_label:`, `formatter:`, …) must be declared on the surface that renders it (`input`, `display`, or `column`), not on `field`.** A component reads its attributes from the surface declaration. Options on `field` go to the Phlexi field builder instead, which consumes the field-level keys (`:label`, `:description`/`:hint`, `:placeholder`) and ignores the rest. So `field :status, as: :badge, colors: {...}` renders a badge everywhere but silently ignores `colors:` and falls back to auto-coloring. `field` is still the right place for the shared `as:` type and for label/description/placeholder.
|
|
511
|
+
|
|
512
|
+
Where to put a component option:
|
|
513
|
+
|
|
514
|
+
- **`display` covers show AND index.** With no `column` declared, the table column inherits the display's `as:` and attributes, so `display :status, as: :badge, colors: {...}` alone colors the badge on both the show page and the table.
|
|
515
|
+
- **A `column` that renders, renders alone.** A `column` with an `as:`, a component attribute, or a block replaces the display for the table: its own `as:` and attributes only, even when the type matches. A `column` that only sets `align:`, `label:` or `condition:` keeps the display's rendering, so `column :status, align: :center` under `display :status, as: :badge, colors: {...}` is still a colored badge.
|
|
516
|
+
- **`align:` and `label:` can live on `field`.** They configure the column header, not the cell, so `field :amount, align: :end` works and never leaks into a component.
|
|
517
|
+
- Use `input` for the form.
|
|
518
|
+
|
|
519
|
+
The field-level help keys (`:label`, `:description`, `:hint`, `:placeholder`) are always stripped before a component sees them, so they never leak as HTML attributes wherever you put them. Other unknown keys are not stripped: a stray option on a surface whose component does not consume it still renders as a raw HTML attribute.
|
|
520
|
+
|
|
510
521
|
## Separation of Concerns
|
|
511
522
|
|
|
512
523
|
| Layer | Purpose | Example |
|
|
@@ -592,6 +603,13 @@ input :team_members do |f|
|
|
|
592
603
|
end
|
|
593
604
|
```
|
|
594
605
|
|
|
606
|
+
⚠️ **Don't bind a form field to a bare association name for a multi-select** (say `input :tags` / permit `:tags`). This is by design: param extraction excludes association writers (`submitted_resource_params` keeps a key only when `reflect_on_association(k).nil?`) because `tags=` expects model instances, not param strings, so a bare `tags` param is dropped rather than mis-assigned. Bind to a non-association writer instead:
|
|
607
|
+
|
|
608
|
+
- `input :tag_ids` uses Rails' collection-ids setter. Permit `:tag_ids`; values are plain ids. Simplest for a scoped checkbox list.
|
|
609
|
+
- `input :tags, as: :secure_association` is the native picker. It sets input_param `tag_sgids`, scopes via `choices:` (or the target policy), and validates submitted SGIDs by decoded model-id.
|
|
610
|
+
|
|
611
|
+
⚠️ Do **not** hand-roll `collection_checkboxes_tag(choices: [[label, record.to_sgid.to_s]])`. SGIDs are non-deterministic (`to_sgid` embeds a timestamp), the component re-derives `choices` at submit and intersects the posted value against the freshly-minted SGIDs, so it never matches and the value is dropped. Use stable ids in custom checkboxes, or `secure_association` (which validates by model-id).
|
|
612
|
+
|
|
595
613
|
## Conditional Rendering
|
|
596
614
|
|
|
597
615
|
```ruby
|
|
@@ -602,6 +620,8 @@ field :debug_info, condition: -> { Rails.env.development? }
|
|
|
602
620
|
|
|
603
621
|
Use `condition` for UI state; use the policy for authorization.
|
|
604
622
|
|
|
623
|
+
A surface's own `condition:` wins; otherwise the surface falls back to the `field` condition. So a `field` condition runs on the form, the show page AND the table. **The table has no `object`**, so a `field` condition must not reference the record: `-> { object.published? }` works on the form and show page and raises on the index. Keep a `field` condition to context that exists everywhere (`Rails.env`, a feature flag, `current_user`) and put record checks on `input`/`display`/`column`.
|
|
624
|
+
|
|
605
625
|
## Options That Vary Per Render
|
|
606
626
|
|
|
607
627
|
Any option may be a **proc**, resolved on every render rather than frozen at class load. Holds across the whole form DSL — `field`, `input`, `section`/`ungrouped`, `structured_input`, nested inputs. Arity says **whether you want the form**:
|
|
@@ -626,6 +646,38 @@ step :billing, condition: -> { data.plan.tier == "pro" } # the wizard —
|
|
|
626
646
|
|
|
627
647
|
It cannot take a `form` argument the way an option does: for a `column`/`display`, a step, or an action there is no form.
|
|
628
648
|
|
|
649
|
+
## Internationalization (labels, placeholders, hints)
|
|
650
|
+
|
|
651
|
+
**Never hard-code display text a locale file can supply.** Labels come from `activerecord.attributes.<model>.<attr>` already. Placeholders, hints and descriptions are looked up by convention when the definition leaves them blank, so the translatable default is to declare nothing:
|
|
652
|
+
|
|
653
|
+
```yaml
|
|
654
|
+
en:
|
|
655
|
+
plutonium:
|
|
656
|
+
fields:
|
|
657
|
+
blogging/post:
|
|
658
|
+
title: { placeholder: "A short, descriptive title", hint: "Shown in search" }
|
|
659
|
+
body: { description: "Rendered as Markdown" }
|
|
660
|
+
actions: { blogging/post: { publish: "Publish now" } }
|
|
661
|
+
scopes: { blogging/post: { drafts: "Drafts" } }
|
|
662
|
+
filters: { blogging/post: { author: "Written by" } }
|
|
663
|
+
kanban_columns: { blogging/post: { in_review: "In review" } }
|
|
664
|
+
wizard_steps: { onboarding_wizard: { billing: "Billing details" } }
|
|
665
|
+
values: { blogging/post: { status: { archived: "Archived" } } } # or activerecord.attributes.<model>.status/archived
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
Resolution for every slot: explicit option → `plutonium.portals.<portal>.…` → `plutonium.…` → (placeholder only) `helpers.placeholder.<model>.<attr>` → nothing. For actions, an interaction's explicit `presents label:` counts as declared (beats the convention); its class-name default does not. The model segment is `model_name.i18n_key` (`blogging/post`), walked up STI ancestors.
|
|
669
|
+
|
|
670
|
+
When an explicit value must be translated, use the definition's class-level `t`, which is lazy (resolved per render, in the request locale):
|
|
671
|
+
|
|
672
|
+
```ruby
|
|
673
|
+
input :email, placeholder: t("forms.shared.email_placeholder")
|
|
674
|
+
action :publish, label: t("plutonium.actions.blogging/post.publish")
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
🚨 Never `I18n.t(...)` in a class body: it freezes the string in whatever locale was active at load. Use `t(...)` or a `-> { I18n.t(...) }` proc.
|
|
678
|
+
|
|
679
|
+
Full reference: `docs/reference/i18n.md`.
|
|
680
|
+
|
|
629
681
|
## Dynamic Forms (`pre_submit`)
|
|
630
682
|
|
|
631
683
|
A `pre_submit: true` field triggers a server re-render on change, re-evaluating `condition:` procs. Use for cascading or context-dependent forms.
|
|
@@ -684,22 +736,30 @@ input :birth_date do |f|
|
|
|
684
736
|
end
|
|
685
737
|
```
|
|
686
738
|
|
|
687
|
-
|
|
739
|
+
**Custom display: use the block form.** `display :x do |f| … end` is `instance_exec`ed in the display component's Phlex context (`render instance_exec(f, &block)`), so you emit markup directly, it runs **once** (no per-item loop), and `f.object` is the real record:
|
|
688
740
|
|
|
689
741
|
```ruby
|
|
690
|
-
#
|
|
691
|
-
display :
|
|
742
|
+
# Inline markup. span/div are Phlex tag methods here.
|
|
743
|
+
display :priority do |f|
|
|
744
|
+
variant = {"high" => "danger", "medium" => "warning"}.fetch(f.value.to_s, "info")
|
|
745
|
+
span(class: "pu-badge pu-badge-#{variant}") { f.value.to_s.humanize }
|
|
746
|
+
end
|
|
692
747
|
|
|
693
|
-
#
|
|
694
|
-
display :
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
when 'medium' then span(class: "badge badge-warning") { "Medium" }
|
|
698
|
-
else span(class: "badge badge-info") { "Low" }
|
|
748
|
+
# Reaches the record, renders once even for a has_many
|
|
749
|
+
display :tags do |f|
|
|
750
|
+
div(class: "flex flex-wrap gap-1.5") do
|
|
751
|
+
f.object.tags.each { |t| span(class: "pu-badge") { t.name } }
|
|
699
752
|
end
|
|
700
|
-
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
# Reusable markup: hand the block a component
|
|
756
|
+
display :card do |f|
|
|
757
|
+
PostCardComponent.new(post: f.object)
|
|
758
|
+
end
|
|
701
759
|
```
|
|
702
760
|
|
|
761
|
+
> ⚠️ **`as: :phlexi_render, with:` is deprecated. Use the block form above.** It emits a deprecation warning and will be removed. It was strictly worse than the block form: the `with:` proc is `.call`ed with `self` set to the definition (so `span`/`div` in its body raise, and it must instead *return* a component or proc), its `value` arrives stringified (`DisplaysValue#normalize_value` runs `to_s`, so it never reaches the record), and for a `has_many`/`multiple?` field it fires once per element. The deprecation covers only the `as: :phlexi_render` display option. The internal `phlexi_render` helper is unaffected.
|
|
762
|
+
|
|
703
763
|
See [[plutonium-ui]] for writing custom Phlex components.
|
|
704
764
|
|
|
705
765
|
**Custom component classes** (Phlex components — see [[plutonium-ui]]). `as:` takes a **field component**, constructed as `YourComponent.new(field, **attributes)` — subclass `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base` (displays) and read the value off `field`:
|
|
@@ -732,6 +792,26 @@ column :full_name do |record|
|
|
|
732
792
|
end
|
|
733
793
|
```
|
|
734
794
|
|
|
795
|
+
**A `column` block runs in a Phlex context** (like a `display` block), so it can emit `span`/`div` directly, or return a String or a component. The block receives the record.
|
|
796
|
+
|
|
797
|
+
```ruby
|
|
798
|
+
column :status do |record| # emit markup directly
|
|
799
|
+
span(class: "pu-badge") { record.status.humanize }
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
column :full_name do |record| # or return a String, rendered as text
|
|
803
|
+
"#{record.first_name} #{record.last_name}"
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
column :card do |record| # or return a component
|
|
807
|
+
StatusBadgeComponent.new(value: record.status)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
column :status, as: :badge, colors: {...} # or just declare the type, no block
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
Inside the block `self` is the table page (the same as a `display` block, where `self` is the show page): `current_user`, `helpers` and `resource_definition` work, methods defined on the definition class do not, so reach through the record you are passed. A block that emits markup must end with a tag call (or `nil`), because Phlex renders the return value too; a number or other scalar return renders as text. Most of the time you need no column block at all, because `display :x, as: …` already flows to the table column (type and attributes). Declare it once on `display` and reach for `column` only to override per-table.
|
|
814
|
+
|
|
735
815
|
## Nested Inputs
|
|
736
816
|
|
|
737
817
|
Inline forms for associated records. Requires `accepts_nested_attributes_for` on the model.
|
|
@@ -926,14 +1006,14 @@ end
|
|
|
926
1006
|
- **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:` (positive Integer, literal only), `condition:`. Every option except `columns:` may be a **proc**, resolved at render under the same arity rule as any other option — take a `form` argument to read the render context.
|
|
927
1007
|
- ⚠️ **Breaking in 0.63**: section options used to take a zero-arg proc run *against* the form. They now follow the shared rule, and a `form_layout` block is evaluated against the layout builder, so a bare `object` is a `NameError`. Migrate `collapsed: -> { object.persisted? }` → `collapsed: ->(form) { form.object.persisted? }`. `condition:` is unchanged (still form-evaluated, still reads `object` with no argument).
|
|
928
1008
|
- **Absent fields are skipped.** A key the section lists that isn't in the permitted set (policy, per-action, scoping, nesting, or a typo) is silently dropped — never an error. The same layout serves a richly-permitted `edit` and a minimal `new`.
|
|
929
|
-
- **🚨 Zero-field sections drop entirely** — no heading, no grid. So `+ New` (fewer permitted attributes) won't sprout empty headings.
|
|
1009
|
+
- **🚨 Zero-field sections drop entirely** — no heading, no grid. So `+ New` (fewer permitted attributes) won't sprout empty headings. A section whose fields are **all** hidden by their own `condition:` on this render drops too (the hidden fields are still recorded on the form). A section's own `condition:` hides it as a unit regardless of its fields.
|
|
930
1010
|
- **Works on interactions too** (`Plutonium::Interaction::Base`) — groups `attribute` declarations. There `object` is the interaction instance; for record actions the record is `object.resource`.
|
|
931
1011
|
|
|
932
1012
|
Full DSL reference: [Resource › Definition › Form layout](/reference/resource/definition#form-layout).
|
|
933
1013
|
|
|
934
1014
|
## Display Layout (`display_layout`)
|
|
935
1015
|
|
|
936
|
-
The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field sections dropped) — applied to the show page's fields instead of the form's.
|
|
1016
|
+
The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field and all-condition-hidden sections dropped) — applied to the show page's fields instead of the form's.
|
|
937
1017
|
|
|
938
1018
|
```ruby
|
|
939
1019
|
class PostDefinition < ResourceDefinition
|
|
@@ -1138,7 +1218,7 @@ action :reposition, hidden: true # route + policy predicate, no button
|
|
|
1138
1218
|
- **`scope:` is the model author's job.** A globally positioned model rendered under a parent still reorders correctly per parent — but a rebalance renumbers every row in the table, not just that parent's.
|
|
1139
1219
|
- Native HTML5 drag doesn't fire on **touch** devices (same limitation as kanban). Keyboard works: focus the grip, <kbd>↑</kbd>/<kbd>↓</kbd>.
|
|
1140
1220
|
|
|
1141
|
-
Full reference: `docs/reference/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.
|
|
1221
|
+
Full reference: `docs/reference/resource/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.
|
|
1142
1222
|
|
|
1143
1223
|
---
|
|
1144
1224
|
|
|
@@ -500,7 +500,7 @@ The URL template is built off `current_page_path` (not `request.path`) so a post
|
|
|
500
500
|
|
|
501
501
|
**Accessibility.** Focus the grip and use <kbd>↑</kbd>/<kbd>↓</kbd> — deliberately linear even on a wrapped grid, since one position attribute stores a 1-D order. Focus is restored onto the same record's grip after a stream replaces the collection. ⚠️ Native HTML5 drag does **not** fire on touch devices (inherited from kanban); there is no automatic fallback.
|
|
502
502
|
|
|
503
|
-
Components: `lib/plutonium/ui/table/components/drag_handle.rb`, `lib/plutonium/ui/component/positionable.rb`, `src/js/controllers/positioned_controller.js`. Reference: `docs/reference/positioning.md`.
|
|
503
|
+
Components: `lib/plutonium/ui/table/components/drag_handle.rb`, `lib/plutonium/ui/component/positionable.rb`, `src/js/controllers/positioned_controller.js`. Reference: `docs/reference/resource/positioning.md`.
|
|
504
504
|
|
|
505
505
|
---
|
|
506
506
|
|
|
@@ -548,6 +548,21 @@ Avatar(src: avatar_url) # bare image, no subject/fallback
|
|
|
548
548
|
|
|
549
549
|
🚨 Ejected shells: `Avatar` only shows a Navii avatar when `NavUser` is passed `record:`. The gem's `_resource_header.html.erb` passes `record: (current_user if current_user.respond_to?(:id))`; portals that **ejected** the header before this must re-eject (`rails g pu:eject:shell --dest=<portal>`) or add the `record:` line, otherwise they keep the icon fallback. Pass a record only — a String `current_user` (e.g. a guest) would otherwise be seeded as a literal identity.
|
|
550
550
|
|
|
551
|
+
## Translating component text
|
|
552
|
+
|
|
553
|
+
Every `Plutonium::UI::Component::Base` subclass (pages included) has a protected `t(key, **opts)` that reads Rails I18n with a full key; components that subclass Phlexi classes call `Plutonium::Translation.t`. Put new user-facing text in a locale file, never a literal:
|
|
554
|
+
|
|
555
|
+
```ruby
|
|
556
|
+
def view_template
|
|
557
|
+
button { t("my_app.cards.expand") }
|
|
558
|
+
span { t("my_app.cards.more", count: hidden_count) } # one:/other: in YAML
|
|
559
|
+
end
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
Rules: one key per sentence with `%{name}` placeholders (never concatenate fragments around a value), `count:` for plurals, no `.downcase`/`.pluralize` on translated nouns. The gem's own keys live under `plutonium.*` in its `config/locales/en/*.yml` and any can be overridden from the app.
|
|
563
|
+
|
|
564
|
+
Stimulus controllers bundled with Plutonium read `plutonium.js.*` from a `<meta name="pu-i18n">` JSON blob the layout renders; host JS can call `window.Plutonium.t("plutonium.js.turbo_confirm.confirm")`. Library locales (Slim Select, flatpickr, Uppy, intl-tel-input) pass through `plutonium.js.libraries.*`. See `docs/reference/i18n.md`.
|
|
565
|
+
|
|
551
566
|
## Custom Phlex components
|
|
552
567
|
|
|
553
568
|
```ruby
|
|
@@ -759,6 +774,8 @@ rails generate pu:core:assets
|
|
|
759
774
|
|
|
760
775
|
This installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers, and points the Plutonium config at your asset files.
|
|
761
776
|
|
|
777
|
+
Packages install with the app's package manager, detected from the lockfile (`bun.lock`/`bun.lockb` → bun, `pnpm-lock.yaml` → pnpm, `package-lock.json` → npm, `yarn.lock` → yarn), else the first of bun, yarn, pnpm, npm on PATH. A yarn app stays yarn even with bun installed. Do not run `yarn add` by hand in a bun app: that leaves two lockfiles. Yarn 2+ needs `nodeLinker: node-modules` in `.yarnrc.yml` (the generator writes it); Tailwind's PostCSS plugin does not load under Plug'n'Play.
|
|
778
|
+
|
|
762
779
|
## Tailwind config (generated)
|
|
763
780
|
|
|
764
781
|
```javascript
|
|
@@ -1120,6 +1137,10 @@ end
|
|
|
1120
1137
|
- **Tokens are CSS variables, not Tailwind keys** — `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
|
|
1121
1138
|
- **`render_actions` is mandatory in custom `form_template`** — otherwise no submit button.
|
|
1122
1139
|
- **Dropdowns (`resource-drop-down`) teleport their menu to `<body>` while open.** popper's `fixed` strategy alone is still clipped by a transformed + `overflow:hidden` ancestor (e.g. grid cards, app shells), so the controller reparents the open menu to `<body>` and restores it on close. Don't rely on the menu being a DOM child of its trigger while open.
|
|
1140
|
+
- **`DisplaysValue` components stringify the value and loop per item.** `render_value` receives `normalize_value(value)`, which is `value.to_s`, and for a `field.multiple?` (has_many) field it is called once per element (`field.value.each`). So a custom component that inherits `DisplaysValue` only ever sees the stringified value, per item, never the record. When you need `f.object` or whole-collection rendering, use the block-form display (`display :x do |f| … end`), which is `instance_exec`ed in Phlex once and can emit markup directly.
|
|
1141
|
+
- **Blocks run in a Phlex context on every surface.** A `display`, `input`, or `column` block emits `span`/`div` directly, or returns a String or a component. All three are `instance_exec`ed by the resource page rendering them (`self` is the page, not the definition): `display`/`input` blocks receive the field `f`, a `column` block receives the record. Phlex renders the return value too, so a block that emits markup must end with a tag call or `nil`. Most columns need no block at all, because `display :x, as: …` already flows to the table column.
|
|
1142
|
+
- **A component reads its attributes from the surface declaration, not from `field`.** Options on `field` go to the Phlexi field builder, which consumes the field-level keys (`:label`, `:description` for displays, `:hint` for forms, `:placeholder`) and ignores the rest. Component options (`colors:`, `unit:`, …) belong on `input`/`display`/`column`. The four field-level help keys are stripped on every surface (the union), so a form key on a display or vice versa is dropped rather than leaked. Any *other* unknown key on a surface whose component does not consume it still renders as a raw HTML attribute, so keep each option on a surface whose component uses it. The table inherits the display's `as:` and attributes unless a `column` declares its own rendering (an `as:`, a component attribute, or a block); a `column` that only sets the header keys (`align:`, `label:`, `condition:`) keeps the display's rendering. `align:` and `label:` may also be declared on `field`.
|
|
1143
|
+
- **`as: :phlexi_render, with:` is deprecated** (see [[plutonium-resource]] › Custom Rendering). Use a block-form display. This covers only the display option; the internal `phlexi_render` helper (`Component::Behaviour`) stays.
|
|
1123
1144
|
|
|
1124
1145
|
---
|
|
1125
1146
|
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.65.0] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- Stop the nav overflowing, and let crushed tables scroll ([#111](https://github.com/radioactive-labs/plutonium-core/issues/111))
|
|
10
|
+
- Cover auto-detected attachments in attachment_input_keys ([#114](https://github.com/radioactive-labs/plutonium-core/issues/114))
|
|
11
|
+
- Keep the current view when clearing filters from the slideover ([#115](https://github.com/radioactive-labs/plutonium-core/issues/115))
|
|
12
|
+
- Inherit width in subclasses and wrap long stepper labels ([#116](https://github.com/radioactive-labs/plutonium-core/issues/116))
|
|
13
|
+
- Apply association filter's scope: option to dropdown and typeahead ([#151](https://github.com/radioactive-labs/plutonium-core/issues/151))
|
|
14
|
+
- Guard #draw against nil engine for unconventional engine names ([#138](https://github.com/radioactive-labs/plutonium-core/issues/138))
|
|
15
|
+
- Raise ArgumentError correctly in authorized_resource_scope ([#140](https://github.com/radioactive-labs/plutonium-core/issues/140))
|
|
16
|
+
- Rescue FloatDomainError in has_cents setters ([#147](https://github.com/radioactive-labs/plutonium-core/issues/147))
|
|
17
|
+
- Honour field condition: on forms by reading before stripping ([#149](https://github.com/radioactive-labs/plutonium-core/issues/149))
|
|
18
|
+
- Preserve sort and scope when applying filters ([#145](https://github.com/radioactive-labs/plutonium-core/issues/145))
|
|
19
|
+
- Neutralize full-width formula characters in CSV export ([#144](https://github.com/radioactive-labs/plutonium-core/issues/144))
|
|
20
|
+
- Strip leading dot from extension fallback in attachment thumbnails ([#143](https://github.com/radioactive-labs/plutonium-core/issues/143))
|
|
21
|
+
- Reject SGIDs whose model class has been removed ([#142](https://github.com/radioactive-labs/plutonium-core/issues/142))
|
|
22
|
+
- Require Stimulus entrypoint alongside Tailwind ([#141](https://github.com/radioactive-labs/plutonium-core/issues/141))
|
|
23
|
+
- Raise ::ActionPolicy::Unauthorized on denied wizard entry ([#148](https://github.com/radioactive-labs/plutonium-core/issues/148))
|
|
24
|
+
- Raise NotImplementedError instead of returning non-existent EntityMembership ([#150](https://github.com/radioactive-labs/plutonium-core/issues/150))
|
|
25
|
+
- Apply association filter's scope: option to filter pill labels ([#155](https://github.com/radioactive-labs/plutonium-core/issues/155))
|
|
26
|
+
- Prevent duplicate memberships via locked state re-check ([#139](https://github.com/radioactive-labs/plutonium-core/issues/139))
|
|
27
|
+
- Keep .env.test.local git-ignored in dotenv generator ([#146](https://github.com/radioactive-labs/plutonium-core/issues/146))
|
|
28
|
+
- JSON-encode Array/Hash direct-upload values for Stimulus ([#121](https://github.com/radioactive-labs/plutonium-core/issues/121))
|
|
29
|
+
- Float slim-select dropdown above modal instead of clipping it ([#157](https://github.com/radioactive-labs/plutonium-core/issues/157))
|
|
30
|
+
- Validate async file inputs by declaration, not value shape ([#169](https://github.com/radioactive-labs/plutonium-core/issues/169))
|
|
31
|
+
- Use sqlserver JDBC subadapter for SQL Server on JRuby ([#168](https://github.com/radioactive-labs/plutonium-core/issues/168))
|
|
32
|
+
- Install assets with the app's package manager, not yarn 1 ([#170](https://github.com/radioactive-labs/plutonium-core/issues/170))
|
|
33
|
+
- Npm install in docker-compose, document jsbundling detection accurately ([#172](https://github.com/radioactive-labs/plutonium-core/issues/172))
|
|
34
|
+
- Drop sections whose fields are all condition-hidden, and leave hidden inputs out of wizard review ([#173](https://github.com/radioactive-labs/plutonium-core/issues/173))
|
|
35
|
+
|
|
36
|
+
### Documentation
|
|
37
|
+
|
|
38
|
+
- Publish the introduction post ([#108](https://github.com/radioactive-labs/plutonium-core/issues/108))
|
|
39
|
+
- Per-post og cards, mobile search fix, complete configuration reference ([#109](https://github.com/radioactive-labs/plutonium-core/issues/109))
|
|
40
|
+
- Add the four unreachable pages to the landing rail ([#110](https://github.com/radioactive-labs/plutonium-core/issues/110))
|
|
41
|
+
- Note that width inherits to subclasses ([#120](https://github.com/radioactive-labs/plutonium-core/issues/120))
|
|
42
|
+
- Note reserved filter names in query reference ([#156](https://github.com/radioactive-labs/plutonium-core/issues/156))
|
|
43
|
+
|
|
44
|
+
### Features
|
|
45
|
+
|
|
46
|
+
- Reject filters named after reserved query controls ([#153](https://github.com/radioactive-labs/plutonium-core/issues/153))
|
|
47
|
+
- [**breaking**] Render column blocks in a Phlex context and layer header-only columns on the display ([#161](https://github.com/radioactive-labs/plutonium-core/issues/161))
|
|
48
|
+
- Make every Plutonium string and derived label translatable ([#162](https://github.com/radioactive-labs/plutonium-core/issues/162))
|
|
49
|
+
- Declarative dashboards with metric/chart/card DSL and pu:dashboard generator ([#163](https://github.com/radioactive-labs/plutonium-core/issues/163))
|
|
50
|
+
|
|
51
|
+
### Miscellaneous Tasks
|
|
52
|
+
|
|
53
|
+
- Remove dead rabl config and template-handler block ([#117](https://github.com/radioactive-labs/plutonium-core/issues/117))
|
|
54
|
+
- Remove dead commented-out actions ([#118](https://github.com/radioactive-labs/plutonium-core/issues/118))
|
|
55
|
+
- Remove dead PlutoniumGenerators::Installer module and Config concern ([#119](https://github.com/radioactive-labs/plutonium-core/issues/119))
|
|
56
|
+
- Remove commented-out create_presenter/create_query_object scaffold methods and orphaned templates ([#165](https://github.com/radioactive-labs/plutonium-core/issues/165))
|
|
57
|
+
- Remove unused Serializer concern and commented-out appname helpers ([#164](https://github.com/radioactive-labs/plutonium-core/issues/164))
|
|
58
|
+
- Remove dead pu:field:input generator ([#160](https://github.com/radioactive-labs/plutonium-core/issues/160))
|
|
59
|
+
- Remove dead turbo-rails monkey patches initializer ([#159](https://github.com/radioactive-labs/plutonium-core/issues/159))
|
|
60
|
+
- Remove dead FieldImporter::Spec#validate method ([#158](https://github.com/radioactive-labs/plutonium-core/issues/158))
|
|
61
|
+
- Remove unused invoke_options helper ([#166](https://github.com/radioactive-labs/plutonium-core/issues/166))
|
|
62
|
+
- Update claude.md
|
|
63
|
+
|
|
5
64
|
## [0.64.0] - 2026-08-22
|
|
6
65
|
|
|
7
66
|
### Miscellaneous Tasks
|