plutonium 0.60.5 → 0.62.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 +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +576 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +1173 -194
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +45 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +557 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +82 -0
- data/docs/reference/kanban/dsl.md +334 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +26 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +77 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +337 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +17 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +16 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +70 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +122 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/models/has_cents.rb +30 -2
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +24 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +14 -0
- data/lib/plutonium/routing/mapper_extensions.rb +45 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/components/currency.rb +41 -9
- data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +25 -5
- data/lib/plutonium/ui/form/components/currency.rb +64 -0
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +26 -5
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
- data/lib/plutonium/ui/form/resource.rb +13 -1
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +56 -18
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +411 -0
- data/lib/plutonium/ui/kanban/resource.rb +319 -0
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +2 -1
- data/lib/plutonium/ui/page/interactive_action.rb +12 -2
- data/lib/plutonium/ui/page/kanban_move.rb +20 -0
- data/lib/plutonium/ui/page/show.rb +29 -1
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +7 -5
- data/src/css/components.css +137 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +717 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +8 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +945 -286
- metadata +113 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Resource
|
|
5
|
+
module Controllers
|
|
6
|
+
# Resource-mounted wizard launch surface (§5.1 / Fix A). Anchored (and
|
|
7
|
+
# non-anchored) wizards registered via the `wizard` definition macro are
|
|
8
|
+
# auto-mounted as member/collection routes on the resource's OWN controller —
|
|
9
|
+
# the same way interactive record/resource actions are (see
|
|
10
|
+
# {InteractiveActions}). This is what makes the anchor IDOR-safe:
|
|
11
|
+
#
|
|
12
|
+
# - **member** actions (`wizard_record_action` / `commit_wizard_record_action`)
|
|
13
|
+
# resolve the anchor through the resource controller's scoped, policy-gated
|
|
14
|
+
# `resource_record!` — never an unscoped `find_by(id:)`. A record outside the
|
|
15
|
+
# portal's authorized scope 404s, exactly like a record action.
|
|
16
|
+
# - **collection** actions (`wizard_resource_action` /
|
|
17
|
+
# `commit_wizard_resource_action`) have no anchor (create flows).
|
|
18
|
+
#
|
|
19
|
+
# The runner-driving flow itself lives in {Plutonium::Wizard::Driving} (shared
|
|
20
|
+
# with the standalone {Plutonium::Wizard::Controller}); this concern only
|
|
21
|
+
# supplies the surface hooks (wizard class from the definition's registry, the
|
|
22
|
+
# anchor from `resource_record!`, the per-step URL) and the action authorization
|
|
23
|
+
# (the resource action policy predicate, mirroring interactive actions).
|
|
24
|
+
module WizardActions
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
include Plutonium::Wizard::Driving
|
|
27
|
+
|
|
28
|
+
included do
|
|
29
|
+
before_action :validate_wizard_action!, only: %i[
|
|
30
|
+
launch_wizard_record_action launch_wizard_resource_action
|
|
31
|
+
wizard_record_action commit_wizard_record_action
|
|
32
|
+
wizard_resource_action commit_wizard_resource_action
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
before_action :authorize_wizard_record_action!, only: %i[
|
|
36
|
+
launch_wizard_record_action
|
|
37
|
+
wizard_record_action commit_wizard_record_action
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
before_action :authorize_wizard_resource_action!, only: %i[
|
|
41
|
+
launch_wizard_resource_action
|
|
42
|
+
wizard_resource_action commit_wizard_resource_action
|
|
43
|
+
]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# GET /resources/:id/wizards/:wizard_name — resolve the run, redirect to step.
|
|
47
|
+
def launch_wizard_record_action
|
|
48
|
+
wizard_launch
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# GET /resources/wizards/:wizard_name — resolve the run, redirect to step.
|
|
52
|
+
def launch_wizard_resource_action
|
|
53
|
+
skip_verify_current_authorized_scope!
|
|
54
|
+
wizard_launch
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# GET /resources/:id/wizards/:wizard_name/(:token)/:step
|
|
58
|
+
def wizard_record_action
|
|
59
|
+
wizard_show
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# POST /resources/:id/wizards/:wizard_name/(:token)/:step
|
|
63
|
+
def commit_wizard_record_action
|
|
64
|
+
wizard_update
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# GET /resources/wizards/:wizard_name/(:token)/:step
|
|
68
|
+
def wizard_resource_action
|
|
69
|
+
skip_verify_current_authorized_scope!
|
|
70
|
+
wizard_show
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# POST /resources/wizards/:wizard_name/(:token)/:step
|
|
74
|
+
def commit_wizard_resource_action
|
|
75
|
+
skip_verify_current_authorized_scope!
|
|
76
|
+
wizard_update
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# --- surface hooks (override Driving) ---
|
|
82
|
+
|
|
83
|
+
# The wizard class for this request, resolved from the resource definition's
|
|
84
|
+
# registry by the `:wizard_name` route segment.
|
|
85
|
+
def current_wizard_class
|
|
86
|
+
@current_wizard_class ||= current_wizard_registration.fetch(:wizard_class)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The anchor for member (record) actions is the scoped, policy-gated record
|
|
90
|
+
# — the IDOR-safe path. Member routes carry `:id`; collection (create) routes
|
|
91
|
+
# don't, and have no anchor.
|
|
92
|
+
def resolved_wizard_anchor
|
|
93
|
+
return nil if params[:id].blank?
|
|
94
|
+
|
|
95
|
+
resource_record!
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Build the GET URL for a given step, preserving the `:id` (member),
|
|
99
|
+
# `:wizard_name`, and `:token` segments. Built through `resource_url_for`
|
|
100
|
+
# with the `wizard:` kwarg (mirroring how interactions build their URLs via
|
|
101
|
+
# `resource_url_for(..., interaction:)`) — never string-surgery on
|
|
102
|
+
# `request.path`, so the URL is always a same-host, route-validated path.
|
|
103
|
+
def wizard_step_url(step_key)
|
|
104
|
+
resource_url_for(
|
|
105
|
+
wizard_url_subject,
|
|
106
|
+
wizard: current_wizard_name,
|
|
107
|
+
step: step_key,
|
|
108
|
+
**wizard_token_param
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The URL anchor: the scoped record for member (record) actions, the
|
|
113
|
+
# resource class for collection (resource) actions.
|
|
114
|
+
def wizard_url_subject
|
|
115
|
+
params[:id].present? ? resource_record! : resource_class
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def current_wizard_name
|
|
119
|
+
params[:wizard_name]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Carry the `:token` segment for an authenticated repeatable (tokened) run,
|
|
123
|
+
# so a fresh GET resumes rather than forks (§4.5). Guest/keyed runs add no
|
|
124
|
+
# URL token (see Driving#wizard_url_token).
|
|
125
|
+
def wizard_token_param
|
|
126
|
+
token = wizard_url_token
|
|
127
|
+
token.present? ? {token: token} : {}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# --- registry / authorization ---
|
|
131
|
+
|
|
132
|
+
def registered_wizards
|
|
133
|
+
@registered_wizards ||= current_definition.class.registered_wizards
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def current_wizard_registration
|
|
137
|
+
registered_wizards.fetch(params[:wizard_name]&.to_sym)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def wizard_page_description
|
|
141
|
+
current_definition.defined_actions[params[:wizard_name]&.to_sym]&.description
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_wizard_action!
|
|
145
|
+
key = params[:wizard_name]&.to_sym
|
|
146
|
+
unless registered_wizards.key?(key)
|
|
147
|
+
raise ::AbstractController::ActionNotFound, "Unknown wizard '#{key}'"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Mirror interactive record-action authorization: gate via the resource
|
|
152
|
+
# action policy predicate named after the wizard key (e.g. `configure?`).
|
|
153
|
+
def authorize_wizard_record_action!
|
|
154
|
+
authorize_current! resource_record!, to: :"#{params[:wizard_name]}?"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Mirror interactive resource-action authorization: gate via the resource
|
|
158
|
+
# class action policy predicate (e.g. `onboard?`).
|
|
159
|
+
def authorize_wizard_resource_action!
|
|
160
|
+
authorize_current! resource_class, to: :"#{params[:wizard_name]}?"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -10,6 +10,12 @@ module Plutonium
|
|
|
10
10
|
authorize :entity_scope, allow_nil: true
|
|
11
11
|
authorize :parent, optional: true
|
|
12
12
|
authorize :parent_association, optional: true
|
|
13
|
+
# Supplied only during a kanban drag-move (kanban_move? authorization): the
|
|
14
|
+
# source and destination Plutonium::Kanban::Column. Optional — nil for every
|
|
15
|
+
# other authorization. Lets a policy gate a specific transition, e.g.
|
|
16
|
+
# def kanban_move? = kanban_to&.key == :closed_won ? user.admin? : super
|
|
17
|
+
authorize :kanban_from, optional: true
|
|
18
|
+
authorize :kanban_to, optional: true
|
|
13
19
|
|
|
14
20
|
relation_scope do |relation|
|
|
15
21
|
default_relation_scope(relation)
|
|
@@ -172,6 +178,14 @@ module Plutonium
|
|
|
172
178
|
update?
|
|
173
179
|
end
|
|
174
180
|
|
|
181
|
+
# Authorizes a kanban board move. Delegates to update? by default — override
|
|
182
|
+
# to allow board drags without granting full edit-form access.
|
|
183
|
+
#
|
|
184
|
+
# @return [Boolean] Delegates to update?.
|
|
185
|
+
def kanban_move?
|
|
186
|
+
update?
|
|
187
|
+
end
|
|
188
|
+
|
|
175
189
|
# Checks if record search is permitted.
|
|
176
190
|
#
|
|
177
191
|
# @return [Boolean] Delegates to index?.
|
|
@@ -43,6 +43,8 @@ module Plutonium
|
|
|
43
43
|
define_collection_interactive_actions
|
|
44
44
|
define_collection_typeahead_actions
|
|
45
45
|
define_collection_export_actions
|
|
46
|
+
define_member_wizard_actions
|
|
47
|
+
define_collection_wizard_actions
|
|
46
48
|
end
|
|
47
49
|
end
|
|
48
50
|
|
|
@@ -149,6 +151,8 @@ module Plutonium
|
|
|
149
151
|
as: :interactive_record_action
|
|
150
152
|
post "record_actions/:interactive_action", action: :commit_interactive_record_action,
|
|
151
153
|
as: :commit_interactive_record_action
|
|
154
|
+
get "kanban_move_form", action: :kanban_move_form, as: :kanban_move_form
|
|
155
|
+
post "kanban_move", action: :kanban_move, as: :kanban_move
|
|
152
156
|
end
|
|
153
157
|
end
|
|
154
158
|
|
|
@@ -183,6 +187,47 @@ module Plutonium
|
|
|
183
187
|
end
|
|
184
188
|
end
|
|
185
189
|
|
|
190
|
+
# Defines member-level wizard launch actions (§5.1 / Fix A). Auto-mounted on
|
|
191
|
+
# every Plutonium resource alongside record_actions — the action 404s unless
|
|
192
|
+
# `:wizard_name` is a wizard registered (anchored → record) on the resource's
|
|
193
|
+
# definition, mirroring how `:interactive_action` gates record_actions. The
|
|
194
|
+
# anchor is the scoped, policy-gated `resource_record!` (IDOR-safe).
|
|
195
|
+
#
|
|
196
|
+
# @return [void]
|
|
197
|
+
def define_member_wizard_actions
|
|
198
|
+
return unless Plutonium.configuration.wizards.enabled
|
|
199
|
+
|
|
200
|
+
member do
|
|
201
|
+
# Bare launch (no :step): resolve/mint the run and redirect to its step.
|
|
202
|
+
get "wizards/:wizard_name", action: :launch_wizard_record_action,
|
|
203
|
+
as: :launch_wizard_record_action
|
|
204
|
+
get "wizards/:wizard_name(/:token)/:step", action: :wizard_record_action,
|
|
205
|
+
as: :wizard_record_action
|
|
206
|
+
post "wizards/:wizard_name(/:token)/:step", action: :commit_wizard_record_action,
|
|
207
|
+
as: :commit_wizard_record_action
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Defines collection-level wizard launch actions (§5.1 / Fix A) for
|
|
212
|
+
# non-anchored (create) wizards. Auto-mounted alongside resource_actions;
|
|
213
|
+
# the action 404s unless `:wizard_name` is a collection wizard registered on
|
|
214
|
+
# the resource's definition.
|
|
215
|
+
#
|
|
216
|
+
# @return [void]
|
|
217
|
+
def define_collection_wizard_actions
|
|
218
|
+
return unless Plutonium.configuration.wizards.enabled
|
|
219
|
+
|
|
220
|
+
collection do
|
|
221
|
+
# Bare launch (no :step): resolve/mint the run and redirect to its step.
|
|
222
|
+
get "wizards/:wizard_name", action: :launch_wizard_resource_action,
|
|
223
|
+
as: :launch_wizard_resource_action
|
|
224
|
+
get "wizards/:wizard_name(/:token)/:step", action: :wizard_resource_action,
|
|
225
|
+
as: :wizard_resource_action
|
|
226
|
+
post "wizards/:wizard_name(/:token)/:step", action: :commit_wizard_resource_action,
|
|
227
|
+
as: :commit_wizard_resource_action
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
186
231
|
# Defines the collection-level CSV export action. Auto-mounted on
|
|
187
232
|
# every Plutonium resource alongside typeahead and bulk actions.
|
|
188
233
|
# The action itself is gated by the `export_csv?` policy (default
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Routing
|
|
5
|
+
# Adds `register_wizard` to the routing mapper, mirroring `register_resource`
|
|
6
|
+
# (see {MapperExtensions}). A wizard is **portal-hosted** (§5.2): the routes are
|
|
7
|
+
# drawn inside the portal engine's `routes.draw` block, so they inherit the
|
|
8
|
+
# portal's scope/auth/layout, and they dispatch to a portal-namespaced wizard
|
|
9
|
+
# controller that includes {Plutonium::Wizard::Controller} + the portal's own
|
|
10
|
+
# controller concern.
|
|
11
|
+
#
|
|
12
|
+
# @example inside a portal engine's routes
|
|
13
|
+
# AdminPortal::Engine.routes.draw do
|
|
14
|
+
# register_wizard OnboardingWizard, at: "onboarding"
|
|
15
|
+
# register_resource ::User
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Draws (portal-relative):
|
|
19
|
+
# GET /onboarding(/:token)/:step → <Portal>::WizardsController#show
|
|
20
|
+
# POST /onboarding(/:token)/:step → <Portal>::WizardsController#update
|
|
21
|
+
#
|
|
22
|
+
# with `onboarding_wizard_path` / `_url` helpers.
|
|
23
|
+
module WizardRegistration
|
|
24
|
+
WIZARD_CONTROLLER_NAME = "wizards"
|
|
25
|
+
|
|
26
|
+
# Tracks public wizard mounts already appended to the main app route set, so
|
|
27
|
+
# re-draws (boot, reload, multiple portals) don't stack duplicate named
|
|
28
|
+
# routes. Keyed by the wizard CLASS NAME (not the helper name) so two distinct
|
|
29
|
+
# anonymous wizards never collapse into one entry — a helper-name collision
|
|
30
|
+
# between different wizards is a hard error (see {#register_public_wizard}),
|
|
31
|
+
# not a silent drop. Maps `wizard_class.name => helper_name`.
|
|
32
|
+
class << self
|
|
33
|
+
attr_accessor :appended_public_wizards
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param wizard_class [Class] a Plutonium::Wizard::Base subclass
|
|
37
|
+
# @param at [String] the portal-relative base path for the wizard's steps
|
|
38
|
+
# @param as [String, Symbol, nil] override the route helper name prefix
|
|
39
|
+
# @param public [Boolean, nil] mount on a PUBLIC (unauthenticated) route
|
|
40
|
+
# outside the portal's auth constraint, for an `anonymous` (guest) wizard.
|
|
41
|
+
# Defaults to the wizard's own `anonymous?` flag. A non-`anonymous` wizard
|
|
42
|
+
# may not be mounted public; an `anonymous` wizard may not be mounted
|
|
43
|
+
# authenticated (its whole point is pre-login access). See §4.5.
|
|
44
|
+
# @param layout [Symbol, String, nil] the Rails layout this mount renders in —
|
|
45
|
+
# a layout NAME, exactly like the controller `layout` macro: `:basic` (the
|
|
46
|
+
# bare `BasicLayout`, e.g. an onboarding screen), `:resource` (the standard
|
|
47
|
+
# shell), or any app layout. Only meaningful for `register_wizard` mounts;
|
|
48
|
+
# resource-defined (`wizard` macro) wizards are always embedded. Defaults by
|
|
49
|
+
# context (portal → the resource shell, main-app → `:basic`); turbo-frame
|
|
50
|
+
# requests are always layout-less regardless.
|
|
51
|
+
def register_wizard(wizard_class, at:, as: nil, public: nil, layout: nil)
|
|
52
|
+
# The wizard subsystem is opt-in (`config.wizards.enabled`). When disabled,
|
|
53
|
+
# draw no routes — its tables/migrations are skipped too, so a mounted route
|
|
54
|
+
# couldn't work anyway. Warn rather than fail silently, so a
|
|
55
|
+
# registered-but-disabled wizard is discoverable instead of a mystery 404.
|
|
56
|
+
unless Plutonium.configuration.wizards.enabled
|
|
57
|
+
Rails.logger.warn { "[Plutonium::Wizard] not registering routes for #{wizard_class} — config.wizards.enabled is false" }
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A CONTEXT anchor (`anchored via: :method`) is portal-level: the anchor is
|
|
62
|
+
# resolved by calling a controller method, needs no URL `:id`, and is
|
|
63
|
+
# IDOR-safe (trusted context) — so it CAN mount here. Only a TYPE anchor
|
|
64
|
+
# (`with:`-only, resolved from the URL `:id`) is rejected, because it needs
|
|
65
|
+
# the resource controller's scoped, policy-gated `resource_record!`.
|
|
66
|
+
if wizard_class.anchored? && !wizard_class.anchored_via?
|
|
67
|
+
raise ArgumentError,
|
|
68
|
+
"register_wizard #{wizard_class.name} — `with:`-anchored wizards are not " \
|
|
69
|
+
"mounted portal-level. Register them on the anchored resource's definition " \
|
|
70
|
+
"with the `wizard` macro, which auto-mounts a record action whose anchor is " \
|
|
71
|
+
"resolved through the resource controller's scoped, policy-gated " \
|
|
72
|
+
"`resource_record!`. (A `via:`-anchored wizard mounts here fine.)"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Default the mount kind to the wizard's `anonymous?` flag, and reject
|
|
76
|
+
# contradictions (§4.5): an `anonymous` wizard NEEDS a public route
|
|
77
|
+
# (pre-login); a non-`anonymous` wizard MUST stay behind portal auth.
|
|
78
|
+
is_public = public.nil? ? wizard_class.anonymous? : !!public
|
|
79
|
+
if is_public && !wizard_class.anonymous?
|
|
80
|
+
raise ArgumentError,
|
|
81
|
+
"register_wizard #{wizard_class.name}, public: true — only an `anonymous` " \
|
|
82
|
+
"wizard may be mounted public. Add the `anonymous` macro to the wizard, or " \
|
|
83
|
+
"drop `public:`."
|
|
84
|
+
end
|
|
85
|
+
if !is_public && wizard_class.anonymous?
|
|
86
|
+
raise ArgumentError,
|
|
87
|
+
"register_wizard #{wizard_class.name} — an `anonymous` wizard must be mounted " \
|
|
88
|
+
"public (it runs pre-login). Pass `public: true` (it is the default for " \
|
|
89
|
+
"`anonymous` wizards)."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
return register_public_wizard(wizard_class, at:, as:, layout:) if is_public
|
|
93
|
+
|
|
94
|
+
ensure_wizard_controller!(wizard_class)
|
|
95
|
+
|
|
96
|
+
# The helper name defaults to the mount path (`at:`), so
|
|
97
|
+
# `register_wizard W, at: "onboarding"` yields `onboarding_wizard_path`.
|
|
98
|
+
# `as:` overrides it; the wizard's own route name is the final fallback.
|
|
99
|
+
helper_name = (as || at.presence || wizard_route_name(wizard_class)).to_s.tr("/", "_")
|
|
100
|
+
defaults = wizard_route_defaults(wizard_class, layout)
|
|
101
|
+
|
|
102
|
+
scope path: at do
|
|
103
|
+
# Canonical launch: GET the bare mount → resolve/mint the run and PRG to
|
|
104
|
+
# its first (or resumed) step, with the token already in the URL. This is
|
|
105
|
+
# the shareable entry point; `wizard_step_url` builds the stepped URLs.
|
|
106
|
+
get "/", to: "#{WIZARD_CONTROLLER_NAME}#launch",
|
|
107
|
+
as: :"#{helper_name}_wizard_launch", defaults: defaults
|
|
108
|
+
get "(/:token)/:step", to: "#{WIZARD_CONTROLLER_NAME}#show",
|
|
109
|
+
as: :"#{helper_name}_wizard", defaults: defaults
|
|
110
|
+
post "(/:token)/:step", to: "#{WIZARD_CONTROLLER_NAME}#update",
|
|
111
|
+
defaults: defaults
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Route defaults carried on every wizard route: the wizard class (resolves the
|
|
118
|
+
# wizard at request time) and, when explicitly set, the `layout`
|
|
119
|
+
# (a layout name). An unset layout is omitted — the driving layer then
|
|
120
|
+
# defaults it by context (portal → the resource shell, main-app → `:basic`). The
|
|
121
|
+
# layout rides the route (not a controller-class setting) because one
|
|
122
|
+
# synthesized controller serves many mounts, so the per-mount value can't live
|
|
123
|
+
# on the controller.
|
|
124
|
+
def wizard_route_defaults(wizard_class, layout)
|
|
125
|
+
defaults = {wizard_class: wizard_class.name}
|
|
126
|
+
defaults[:wizard_layout] = layout.to_s if layout
|
|
127
|
+
defaults
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Mount an `anonymous` wizard on a PUBLIC (unauthenticated) route (§4.5).
|
|
131
|
+
#
|
|
132
|
+
# Portal engines are mounted INSIDE the host's auth constraint
|
|
133
|
+
# (`constraints Rodauth::Rails.authenticate(:user) { mount ... }`), so a
|
|
134
|
+
# route drawn in the engine is unreachable pre-login. A guest wizard must
|
|
135
|
+
# therefore be drawn on the MAIN application's route set, OUTSIDE that
|
|
136
|
+
# constraint. We append to `Rails.application.routes` so the public route is
|
|
137
|
+
# added after (and independent of) the engine mount.
|
|
138
|
+
#
|
|
139
|
+
# The route dispatches to a synthesized top-level `WizardsController` that
|
|
140
|
+
# includes the full Plutonium controller stack + `Plutonium::Auth::Public`
|
|
141
|
+
# (so `current_user` is the guest sentinel) + {Plutonium::Wizard::Controller}.
|
|
142
|
+
def register_public_wizard(wizard_class, at:, as:, layout: nil)
|
|
143
|
+
ensure_public_wizard_controller!
|
|
144
|
+
|
|
145
|
+
helper_name = (as || at.presence || wizard_route_name(wizard_class)).to_s.tr("/", "_")
|
|
146
|
+
defaults = wizard_route_defaults(wizard_class, layout)
|
|
147
|
+
mount_path = at.to_s.sub(%r{\A/}, "")
|
|
148
|
+
|
|
149
|
+
# `Rails.application.routes.append` blocks are RETAINED and re-run on every
|
|
150
|
+
# route reload — so append a given wizard's block at most once. Key by the
|
|
151
|
+
# wizard CLASS (not the helper name) so re-drawing the SAME wizard is a no-op
|
|
152
|
+
# while two DIFFERENT wizards are never silently collapsed.
|
|
153
|
+
registered = (Plutonium::Routing::WizardRegistration.appended_public_wizards ||= {})
|
|
154
|
+
|
|
155
|
+
# Two distinct public wizards sharing a helper name (same `at:`/`as:`) would
|
|
156
|
+
# draw the same route name → Rails "route name already in use", or worse, a
|
|
157
|
+
# silent drop. Fail loudly with a fix instead.
|
|
158
|
+
clash = registered.find { |klass_name, helper| helper == helper_name && klass_name != wizard_class.name }
|
|
159
|
+
if clash
|
|
160
|
+
raise ArgumentError,
|
|
161
|
+
"register_wizard #{wizard_class.name}, at: #{at.inspect} — the route helper " \
|
|
162
|
+
"`#{helper_name}_wizard` is already used by #{clash.first}. Give one of them a " \
|
|
163
|
+
"distinct `at:` or `as:`."
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
return unless registered[wizard_class.name].nil?
|
|
167
|
+
registered[wizard_class.name] = helper_name
|
|
168
|
+
|
|
169
|
+
Rails.application.routes.append do
|
|
170
|
+
scope path: mount_path do
|
|
171
|
+
get "/", to: "public_wizards#launch",
|
|
172
|
+
as: :"#{helper_name}_wizard_launch", defaults: defaults
|
|
173
|
+
get "(/:token)/:step", to: "public_wizards#show",
|
|
174
|
+
as: :"#{helper_name}_wizard", defaults: defaults
|
|
175
|
+
post "(/:token)/:step", to: "public_wizards#update",
|
|
176
|
+
defaults: defaults
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Synthesize the top-level public `PublicWizardsController` once. Unlike the
|
|
182
|
+
# portal controller (built on the portal's authenticated `PlutoniumController`),
|
|
183
|
+
# the public one is built directly on the Plutonium controller stack with
|
|
184
|
+
# `Plutonium::Auth::Public`, so it has the rendering/scoping infra a wizard
|
|
185
|
+
# needs WITHOUT requiring a login.
|
|
186
|
+
#
|
|
187
|
+
# It is a DISTINCT const from the authenticated main-app `::WizardsController`
|
|
188
|
+
# (see {#ensure_wizard_controller!}): the two must not share a controller, or a
|
|
189
|
+
# public (guest) and an authenticated main-app wizard in the same app would
|
|
190
|
+
# collapse onto whichever was synthesized first — an authenticated main-app
|
|
191
|
+
# wizard would then run through `Auth::Public` and reject every logged-in user.
|
|
192
|
+
def ensure_public_wizard_controller!
|
|
193
|
+
return if Object.const_defined?(:PublicWizardsController, false)
|
|
194
|
+
|
|
195
|
+
# Build on a BARE base, decoupled from the app's `::PlutoniumController`
|
|
196
|
+
# (which portals inherit and may carry auth). `Plutonium::Wizard::Controller`
|
|
197
|
+
# brings the full rendering stack — including `Core::Controller`'s gem
|
|
198
|
+
# view-path, which resolves the shared partials (`plutonium/_flash`, etc.) —
|
|
199
|
+
# so no PlutoniumController inheritance is needed for that.
|
|
200
|
+
base = "ApplicationController".safe_constantize || ActionController::Base
|
|
201
|
+
klass = Class.new(base) do
|
|
202
|
+
# `Auth::Public` provides the guest `current_user`; an `anonymous` wizard
|
|
203
|
+
# ignores it for identity (session-token keyed) but the host still needs a
|
|
204
|
+
# `current_user` defined.
|
|
205
|
+
include Plutonium::Auth::Public
|
|
206
|
+
include Plutonium::Wizard::Controller
|
|
207
|
+
end
|
|
208
|
+
Object.const_set(:PublicWizardsController, klass)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def wizard_route_name(wizard_class)
|
|
212
|
+
wizard_class.name.demodulize.underscore.sub(/_wizard\z/, "")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Resolve (creating if needed) the portal-namespaced wizard controller the
|
|
216
|
+
# routes dispatch to. The portal engine isolates its namespace, so a route
|
|
217
|
+
# `controller: "wizards"` resolves to `<PortalModule>::WizardsController`.
|
|
218
|
+
# There is no hand-written file for it (unlike resource controllers, which
|
|
219
|
+
# are scaffolded), so we synthesize it here — the same idea as
|
|
220
|
+
# {Plutonium::Portal::DynamicControllers}, but triggered explicitly at route
|
|
221
|
+
# draw time rather than via `const_missing`.
|
|
222
|
+
def ensure_wizard_controller!(wizard_class)
|
|
223
|
+
engine = wizard_route_engine
|
|
224
|
+
return if engine.nil?
|
|
225
|
+
|
|
226
|
+
portal_module = wizard_portal_module(engine)
|
|
227
|
+
if portal_module.nil?
|
|
228
|
+
# Main-app / non-namespaced mount. Synthesize a BARE top-level
|
|
229
|
+
# WizardsController (ApplicationController + the wizard module) — it is NOT
|
|
230
|
+
# rooted in the app's `::PlutoniumController` (portals inherit that, so auth
|
|
231
|
+
# there would leak). A bare synthesized controller has NO auth, so a
|
|
232
|
+
# public/`anonymous` wizard works as-is; an AUTHENTICATED main-app wizard
|
|
233
|
+
# requires the app to define its own `::WizardsController` (with its auth
|
|
234
|
+
# concern), which the const-check below picks up instead of synthesizing.
|
|
235
|
+
#
|
|
236
|
+
# This rhymes with `register_resource` ("the app owns the controller") but
|
|
237
|
+
# isn't identical: `register_resource` never synthesizes a fallback, so a
|
|
238
|
+
# missing controller is a loud routing/constant error; here the fallback is
|
|
239
|
+
# auth-less, so a missing override for an authenticated wizard fails QUIETER
|
|
240
|
+
# — every user is bounced to login rather than erroring. Covered by the
|
|
241
|
+
# skill docs + main_app_wizard_test so it's an explicit, tested edge.
|
|
242
|
+
define_wizard_controller(Object, "WizardsController", "ApplicationController", nil)
|
|
243
|
+
return
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
define_wizard_controller(
|
|
247
|
+
portal_module,
|
|
248
|
+
"WizardsController",
|
|
249
|
+
"#{portal_module.name}::PlutoniumController",
|
|
250
|
+
"#{portal_module.name}::Concerns::Controller"
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Synthesize a wizard controller unless one is already defined (the app's
|
|
255
|
+
# override wins — define `<Portal>::WizardsController` / `::WizardsController`
|
|
256
|
+
# to take over). `Plutonium::Wizard::Controller` brings the full rendering
|
|
257
|
+
# stack, so the parent only needs to supply auth/scope (a portal's
|
|
258
|
+
# PlutoniumController) or nothing (a bare main-app base).
|
|
259
|
+
def define_wizard_controller(namespace, const_name, parent_name, concern_name)
|
|
260
|
+
return if namespace.const_defined?(const_name, false)
|
|
261
|
+
|
|
262
|
+
parent = parent_name.safe_constantize || ActionController::Base
|
|
263
|
+
klass = Class.new(parent) do
|
|
264
|
+
include Plutonium::Wizard::Controller
|
|
265
|
+
end
|
|
266
|
+
namespace.const_set(const_name, klass)
|
|
267
|
+
|
|
268
|
+
if concern_name && (concern = concern_name.safe_constantize)
|
|
269
|
+
klass.include concern
|
|
270
|
+
end
|
|
271
|
+
klass
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The Plutonium engine owning this route set (mirrors RouteSetExtensions#engine).
|
|
275
|
+
def wizard_route_engine
|
|
276
|
+
rs = respond_to?(:route_set) ? route_set : @set
|
|
277
|
+
rs.respond_to?(:engine) ? rs.engine : nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The portal module (e.g. AdminPortal) for a `SomePortal::Engine`, or nil for
|
|
281
|
+
# the main application.
|
|
282
|
+
def wizard_portal_module(engine)
|
|
283
|
+
return nil if engine == Rails.application.class
|
|
284
|
+
|
|
285
|
+
engine.module_parent
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -6,16 +6,52 @@ module Plutonium
|
|
|
6
6
|
module UI
|
|
7
7
|
module Display
|
|
8
8
|
module Components
|
|
9
|
-
# Renders a numeric value as currency (delimited, 2 decimals).
|
|
10
|
-
# by
|
|
11
|
-
#
|
|
9
|
+
# Renders a numeric value as currency (delimited, 2 decimals). The symbol
|
|
10
|
+
# is resolved by {resolve_unit}: an explicit `unit:` (a literal "£", a
|
|
11
|
+
# Symbol read off the record for per-row currencies, or `false` for no
|
|
12
|
+
# symbol) → the record's `has_cents` unit → `default_currency_unit` /
|
|
13
|
+
# the i18n `number.currency.format.unit` ($ in en).
|
|
12
14
|
#
|
|
13
|
-
# display :price, as: :currency
|
|
15
|
+
# display :price, as: :currency # → configured/i18n default unit
|
|
14
16
|
# display :price, as: :currency, unit: "£"
|
|
15
17
|
# display :price, as: :currency, unit: :currency_symbol
|
|
18
|
+
# display :price, as: :currency, unit: false # no symbol
|
|
16
19
|
class Currency < Phlexi::Display::Components::Base
|
|
17
20
|
include Phlexi::Display::Components::Concerns::DisplaysValue
|
|
18
21
|
|
|
22
|
+
# Resolves the currency unit string for a value, shared by this
|
|
23
|
+
# component and the grid/kanban {Grid::Card} so both format currency
|
|
24
|
+
# identically. Precedence, where `nil` means "not set, keep looking"
|
|
25
|
+
# and `false` means "explicitly no symbol, stop":
|
|
26
|
+
# explicit unit → record's has_cents unit → configured/i18n default.
|
|
27
|
+
#
|
|
28
|
+
# @param explicit [String, Symbol, false, nil] a per-display `unit:`.
|
|
29
|
+
# @param record [Object] the record being rendered.
|
|
30
|
+
# @param key [Symbol] the attribute name.
|
|
31
|
+
# @return [String] the unit to pass to number_to_currency ("" for none).
|
|
32
|
+
def self.resolve_unit(explicit, record, key)
|
|
33
|
+
unit = explicit
|
|
34
|
+
unit = record.has_cents_unit_for(key) if unit.nil? && record.respond_to?(:has_cents_unit_for)
|
|
35
|
+
unit = default_unit if unit.nil?
|
|
36
|
+
|
|
37
|
+
case unit
|
|
38
|
+
when nil, false then ""
|
|
39
|
+
when Symbol then record.public_send(unit).to_s
|
|
40
|
+
else unit.to_s
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The unit used when nothing more specific is configured. Returns the
|
|
45
|
+
# `default_currency_unit` config verbatim when set (including `false`
|
|
46
|
+
# to disable the symbol); otherwise the i18n `number.currency.format.unit`
|
|
47
|
+
# *if the locale defines it*, else no symbol. We don't hardcode a "$".
|
|
48
|
+
def self.default_unit
|
|
49
|
+
config = Plutonium.configuration.default_currency_unit
|
|
50
|
+
return config unless config.nil?
|
|
51
|
+
|
|
52
|
+
I18n.t("number.currency.format.unit", default: "")
|
|
53
|
+
end
|
|
54
|
+
|
|
19
55
|
def render_value(value)
|
|
20
56
|
p(**attributes) { format_currency(value) }
|
|
21
57
|
end
|
|
@@ -35,11 +71,7 @@ module Plutonium
|
|
|
35
71
|
end
|
|
36
72
|
|
|
37
73
|
def resolved_unit
|
|
38
|
-
|
|
39
|
-
when nil then ""
|
|
40
|
-
when Symbol then field.object.public_send(@unit)
|
|
41
|
-
else @unit
|
|
42
|
-
end
|
|
74
|
+
self.class.resolve_unit(@unit, field.object, field.key)
|
|
43
75
|
end
|
|
44
76
|
|
|
45
77
|
def normalize_value(value) = value
|
|
@@ -5,6 +5,8 @@ module Plutonium
|
|
|
5
5
|
module Display
|
|
6
6
|
module Options
|
|
7
7
|
module InferredTypes
|
|
8
|
+
include Plutonium::UI::Options::HasCentsField
|
|
9
|
+
|
|
8
10
|
private
|
|
9
11
|
|
|
10
12
|
def infer_field_component
|
|
@@ -23,11 +25,6 @@ module Plutonium
|
|
|
23
25
|
super
|
|
24
26
|
end
|
|
25
27
|
end
|
|
26
|
-
|
|
27
|
-
def has_cents_field?
|
|
28
|
-
klass = object.class
|
|
29
|
-
klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(key)
|
|
30
|
-
end
|
|
31
28
|
end
|
|
32
29
|
end
|
|
33
30
|
end
|
|
@@ -74,18 +74,23 @@ module Plutonium
|
|
|
74
74
|
def render_tablist_with_details
|
|
75
75
|
tablist = BuildTabList()
|
|
76
76
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
77
|
+
# Only render the Details tab when the user is permitted to see at
|
|
78
|
+
# least one field. With no permitted fields the tab would be empty,
|
|
79
|
+
# so we drop it and let the first association tab lead instead.
|
|
80
|
+
if resource_fields.present?
|
|
81
|
+
# Build an inner display component for the Details tab.
|
|
82
|
+
# It must be a standalone Phlex component so that TabList can call
|
|
83
|
+
# `render(details_display)` from within its own context. Phlex propagates
|
|
84
|
+
# @_state through render calls, so the inner component writes to the same
|
|
85
|
+
# buffer as the outer Resource display even though self changes.
|
|
86
|
+
details_display = build_details_display
|
|
87
|
+
|
|
88
|
+
tablist.with_tab(
|
|
89
|
+
identifier: "details",
|
|
90
|
+
title: -> { plain "Details" }
|
|
91
|
+
) do
|
|
92
|
+
render details_display
|
|
93
|
+
end
|
|
89
94
|
end
|
|
90
95
|
|
|
91
96
|
resource_associations.each do |name|
|