plutonium 0.62.2 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/CHANGELOG.md +67 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- 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/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +322 -140
- data/gemfiles/rails_8.0.gemfile.lock +125 -115
- data/gemfiles/rails_8.1.gemfile.lock +126 -116
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +29 -11
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +103 -16
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -170,20 +170,48 @@ module Plutonium
|
|
|
170
170
|
respond_to_wizard_result(runner, result)
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
173
|
+
# DELETE (/:token) — abandon a run. Same entry gauntlet as every other wizard
|
|
174
|
+
# action (auth → ownership → policy) before anything is destroyed.
|
|
175
|
+
def wizard_cancel
|
|
176
|
+
require_wizard_authentication!
|
|
177
|
+
runner = build_wizard_runner
|
|
178
|
+
deny_wizard_resume_for_other_user!(runner)
|
|
179
|
+
authorize_wizard_entry!(runner)
|
|
178
180
|
|
|
179
|
-
|
|
181
|
+
# No stored row at this key — nothing to abandon, so bail BEFORE `cancel`,
|
|
182
|
+
# which is destructive: `run_cleanup` fires every declared `on_rollback`,
|
|
183
|
+
# then `clear` deletes the row.
|
|
184
|
+
#
|
|
185
|
+
# A DELETE carrying a stale or forged token mints an EMPTY runner, and
|
|
186
|
+
# `rollback_step` only no-ops when the step ALSO declares no `on_rollback` —
|
|
187
|
+
# so a side-effect-only compensator (refund the charge, call the external
|
|
188
|
+
# API) runs for a run that never existed. The token is the only thing
|
|
189
|
+
# identifying a run and a caller can simply make one up, which puts this in
|
|
190
|
+
# reach of anyone allowed to LAUNCH the wizard.
|
|
191
|
+
#
|
|
192
|
+
# PRG to the launch path: the chooser over any runs the user still has, or
|
|
193
|
+
# a fresh run. A finished one-time run lands here too (it resumes no row)
|
|
194
|
+
# and gets its completed page.
|
|
195
|
+
unless runner.resumed?
|
|
196
|
+
return redirect_to wizard_launch_url, status: :see_other, allow_other_host: false
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
runner.cancel
|
|
200
|
+
clear_wizard_session_token
|
|
201
|
+
clear_wizard_return_to
|
|
202
|
+
|
|
203
|
+
# PRG to the bare launch path: with this run gone, that re-renders the
|
|
204
|
+
# chooser over the user's remaining runs, or mints a fresh one if it was
|
|
205
|
+
# their last.
|
|
206
|
+
redirect_to wizard_launch_url, status: :see_other, allow_other_host: false
|
|
180
207
|
end
|
|
181
208
|
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
209
|
+
# Advance the POSTed step, finalizing when it turns out to end the flow. The
|
|
210
|
+
# advance-or-finalize decision lives in the runner because it can only be made
|
|
211
|
+
# AFTER the submission is staged — a step's `condition:` may be gated on an
|
|
212
|
+
# answer from the very step being submitted. See {Runner#submit}.
|
|
213
|
+
def advance_or_finalize(runner)
|
|
214
|
+
runner.submit(params[:step], wizard_params(runner), goto: params[:_goto].presence)
|
|
187
215
|
end
|
|
188
216
|
|
|
189
217
|
def respond_to_wizard_result(runner, result)
|
|
@@ -214,8 +242,26 @@ module Plutonium
|
|
|
214
242
|
# `:return_to` (the page the user was bounced FROM into a one-time wizard)
|
|
215
243
|
# still wins, so they resume where they were headed.
|
|
216
244
|
clear_wizard_return_to
|
|
245
|
+
flash_wizard_messages(result.messages)
|
|
217
246
|
target = session.delete(:return_to).presence || wizard_completion_url(result.value)
|
|
218
|
-
|
|
247
|
+
|
|
248
|
+
respond_to do |format|
|
|
249
|
+
format.turbo_stream do
|
|
250
|
+
render turbo_stream: helpers.turbo_stream_redirect(target)
|
|
251
|
+
end
|
|
252
|
+
format.html do
|
|
253
|
+
redirect_to target, status: :see_other, allow_other_host: false
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Carry the outcome's `with_message` entries ([message, type] pairs) into the
|
|
259
|
+
# flash so the page we land on shows them. Several messages of one type are
|
|
260
|
+
# joined — writing them in a loop would leave only the last one standing.
|
|
261
|
+
def flash_wizard_messages(messages)
|
|
262
|
+
messages.group_by(&:last).each do |type, group|
|
|
263
|
+
flash[type] = group.map(&:first).join(" ")
|
|
264
|
+
end
|
|
219
265
|
end
|
|
220
266
|
|
|
221
267
|
# Drop a guest run's token from the Rails session (on completion). A no-op
|
|
@@ -274,6 +320,12 @@ module Plutonium
|
|
|
274
320
|
# the step form; otherwise re-render the whole page. Mirrors interactive
|
|
275
321
|
# actions, where conditional inputs depend on sibling values.
|
|
276
322
|
def render_wizard_pre_submit(runner)
|
|
323
|
+
# Seed the re-render from the JUST-SUBMITTED values, so an input conditional
|
|
324
|
+
# on the changed field appears/disappears and the user's other typed values
|
|
325
|
+
# survive the swap. In-memory only — `stage_inputs` never persists, so an
|
|
326
|
+
# abandoned refresh leaves nothing durable.
|
|
327
|
+
runner.stage_inputs(runner.current_step.key, wizard_pre_submit_inputs(runner))
|
|
328
|
+
|
|
277
329
|
form = wizard_step_form(runner)
|
|
278
330
|
respond_to do |format|
|
|
279
331
|
format.turbo_stream do
|
|
@@ -488,12 +540,38 @@ module Plutonium
|
|
|
488
540
|
def wizard_params(runner)
|
|
489
541
|
return {} if params[:wizard].blank?
|
|
490
542
|
|
|
543
|
+
cleaned = wizard_extracted_inputs(runner)
|
|
544
|
+
stage_wizard_uploads!(runner.current_step, cleaned)
|
|
545
|
+
cleaned.stringify_keys
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Like {#wizard_params} but WITHOUT staging uploads — shared with the
|
|
549
|
+
# `pre_submit` re-render, which must not stage them, or every `change` event
|
|
550
|
+
# would push the selected file to the backend cache.
|
|
551
|
+
def wizard_extracted_inputs(runner)
|
|
552
|
+
return {} if params[:wizard].blank?
|
|
553
|
+
|
|
491
554
|
step = runner.current_step
|
|
492
555
|
form = wizard_step_form(runner)
|
|
493
556
|
extracted = form.extract_input(params, view_context:)[:wizard] || {}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
557
|
+
clean_structured_inputs(Plutonium::Wizard::StepAdapter.new(step), extracted.dup)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# The submitted values a `pre_submit` re-render may seed itself from.
|
|
561
|
+
#
|
|
562
|
+
# Attachment fields are EXCLUDED. Their extracted value is a raw upload — or,
|
|
563
|
+
# far more often, nothing at all, since a file input doesn't re-post on an
|
|
564
|
+
# unrelated field's `change`. Merging either over the token already staged in
|
|
565
|
+
# `data` would blank the file out of the re-rendered form, losing an upload the
|
|
566
|
+
# user had already made. Uploads only ever stage on a real submit
|
|
567
|
+
# ({#stage_wizard_uploads!}); a pre_submit leaves them exactly as they were.
|
|
568
|
+
def wizard_pre_submit_inputs(runner)
|
|
569
|
+
step = runner.current_step
|
|
570
|
+
inputs = wizard_extracted_inputs(runner).stringify_keys
|
|
571
|
+
step.inputs.each do |name, config|
|
|
572
|
+
inputs.delete(name.to_s) if Plutonium::Wizard::Attachments.field?(config)
|
|
573
|
+
end
|
|
574
|
+
inputs
|
|
497
575
|
end
|
|
498
576
|
|
|
499
577
|
# Replace each attachment field's value with a staged TOKEN, minting one from
|
|
@@ -528,6 +606,7 @@ module Plutonium
|
|
|
528
606
|
step = runner.current_step
|
|
529
607
|
Plutonium::UI::Form::Wizard.new(
|
|
530
608
|
step:,
|
|
609
|
+
wizard: runner.wizard,
|
|
531
610
|
data: Plutonium::Wizard::AttachmentData.wrap(runner.wizard.data[step.key], step),
|
|
532
611
|
action: wizard_step_url(step&.key),
|
|
533
612
|
fields: step.attribute_schema.keys.map(&:to_sym) + step.structured_inputs.keys.map(&:to_sym)
|
|
@@ -634,6 +713,11 @@ module Plutonium
|
|
|
634
713
|
def wizard_step_url(step_key)
|
|
635
714
|
raise NotImplementedError
|
|
636
715
|
end
|
|
716
|
+
|
|
717
|
+
# @return [String] the GET URL for launching this wizard.
|
|
718
|
+
def wizard_launch_url
|
|
719
|
+
raise NotImplementedError
|
|
720
|
+
end
|
|
637
721
|
end
|
|
638
722
|
end
|
|
639
723
|
end
|
data/lib/plutonium/wizard/dsl.rb
CHANGED
|
@@ -152,6 +152,29 @@ module Plutonium
|
|
|
152
152
|
@cleanup_after = (ttl == :never) ? nil : ttl
|
|
153
153
|
end
|
|
154
154
|
|
|
155
|
+
# --- presentation ---
|
|
156
|
+
|
|
157
|
+
# Width of THIS wizard's step pages, one of
|
|
158
|
+
# {Plutonium::UI::PageWidth::VALID_SIZES}. Unset inherits
|
|
159
|
+
# `config.wizards.width`, and stops there — the wizard chain does NOT
|
|
160
|
+
# reach `config.default_page_width`. A wizard is a focused,
|
|
161
|
+
# self-contained flow rather than a resource detail page, so an app
|
|
162
|
+
# that widens its resource pages should not silently drag its wizards
|
|
163
|
+
# along with them.
|
|
164
|
+
#
|
|
165
|
+
# Mirrors what a resource definition gets from `page_width`: a wizard
|
|
166
|
+
# with a wide review table or a two-column step can widen itself
|
|
167
|
+
# without the app having to move every wizard.
|
|
168
|
+
#
|
|
169
|
+
# class CheckoutWizard < Plutonium::Wizard::Base
|
|
170
|
+
# width :lg
|
|
171
|
+
# end
|
|
172
|
+
def width(value = UNSET)
|
|
173
|
+
return @width || Plutonium.configuration.wizards.width if value.equal?(UNSET)
|
|
174
|
+
|
|
175
|
+
@width = Plutonium::UI::PageWidth.validate!(value)
|
|
176
|
+
end
|
|
177
|
+
|
|
155
178
|
# --- concurrency (§4.2) ---
|
|
156
179
|
|
|
157
180
|
# Declare the run's CONCURRENCY KEY — the value(s) a run is keyed by
|
|
@@ -36,6 +36,9 @@ module Plutonium
|
|
|
36
36
|
:current_step_label,
|
|
37
37
|
:updated_at,
|
|
38
38
|
:resume_url,
|
|
39
|
+
# The DELETE target that abandons this run, resolved from the same mount as
|
|
40
|
+
# `resume_url`. nil when the mount exposes no cancel route.
|
|
41
|
+
:cancel_url,
|
|
39
42
|
:resume_unresolved_reason,
|
|
40
43
|
:session
|
|
41
44
|
)
|
|
@@ -100,6 +103,7 @@ module Plutonium
|
|
|
100
103
|
current_step_label: step&.label,
|
|
101
104
|
updated_at: row.updated_at,
|
|
102
105
|
resume_url: resolved[:url],
|
|
106
|
+
cancel_url: resolved[:cancel_url],
|
|
103
107
|
resume_unresolved_reason: resolved[:reason],
|
|
104
108
|
session: row
|
|
105
109
|
)
|
|
@@ -111,71 +115,114 @@ module Plutonium
|
|
|
111
115
|
wizard_class.steps.find { |s| s.key.to_s == key.to_s }
|
|
112
116
|
end
|
|
113
117
|
|
|
114
|
-
# Resolves a single row to its resume
|
|
118
|
+
# Resolves a single row to its resume + cancel URLs in the current portal.
|
|
119
|
+
#
|
|
120
|
+
# Both URLs come from the SAME resolved mount, through named route helpers —
|
|
121
|
+
# the cancel target is never derived by string-surgery on the resume URL,
|
|
122
|
+
# which would drop query params and break for a run whose `current_step` is
|
|
123
|
+
# still nil (there the resume URL is the bare launch path, so lopping off its
|
|
124
|
+
# last segment yields a non-route).
|
|
115
125
|
class ResumeUrl
|
|
126
|
+
# How this row's wizard is reachable in the current portal.
|
|
127
|
+
#
|
|
128
|
+
# - +:register_wizard+ — a standalone `register_wizard` mount. `subject` is
|
|
129
|
+
# the GET route's name, resolved within `route_set`.
|
|
130
|
+
# - +:member+ — a resource-mounted ANCHORED wizard. `subject` is the anchor
|
|
131
|
+
# record.
|
|
132
|
+
# - +:collection+ — a resource-mounted non-anchored wizard. `subject` is the
|
|
133
|
+
# resource class whose definition registers it.
|
|
134
|
+
Mount = Struct.new(:kind, :subject, :wizard_name, :route_set)
|
|
135
|
+
|
|
116
136
|
def initialize(row, wizard_class, view_context)
|
|
117
137
|
@row = row
|
|
118
138
|
@wizard_class = wizard_class
|
|
119
139
|
@view_context = view_context
|
|
120
140
|
end
|
|
121
141
|
|
|
122
|
-
# @return [Hash] {url:, reason:} —
|
|
142
|
+
# @return [Hash] {url:, cancel_url:, reason:} — `reason` is non-nil exactly
|
|
143
|
+
# when no resume URL could be built. The two URLs resolve INDEPENDENTLY:
|
|
144
|
+
# a run with no `current_step` has no stepped resume URL, but is still
|
|
145
|
+
# perfectly cancellable, and leaving the user no way to clear it would
|
|
146
|
+
# strand the row in their chooser forever.
|
|
123
147
|
def resolve
|
|
124
|
-
|
|
125
|
-
return {url: named, reason: nil}
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
if (member = resource_member_url)
|
|
129
|
-
return {url: member, reason: nil}
|
|
130
|
-
end
|
|
148
|
+
return {url: nil, cancel_url: nil, reason: unresolved_reason} if mount.nil?
|
|
131
149
|
|
|
132
|
-
|
|
150
|
+
url = build_step_url(mount)
|
|
151
|
+
{url: url, cancel_url: build_cancel_url(mount), reason: url.nil? ? unresolved_reason : nil}
|
|
133
152
|
end
|
|
134
153
|
|
|
135
154
|
private
|
|
136
155
|
|
|
137
|
-
#
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
156
|
+
# The first mount that claims this row, in precedence order: a standalone
|
|
157
|
+
# `register_wizard` route, then the row's anchor, then a resource definition
|
|
158
|
+
# that registers this wizard as a collection (non-anchored) mount.
|
|
159
|
+
def mount
|
|
160
|
+
return @mount if defined?(@mount)
|
|
142
161
|
|
|
143
|
-
|
|
144
|
-
end
|
|
145
|
-
nil
|
|
162
|
+
@mount = register_wizard_mount || resource_member_mount || resource_collection_mount
|
|
146
163
|
end
|
|
147
164
|
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
{
|
|
165
|
+
# A `register_wizard` route is named and carries `defaults[:wizard_class]`.
|
|
166
|
+
def register_wizard_mount
|
|
167
|
+
return @register_wizard_mount if defined?(@register_wizard_mount)
|
|
168
|
+
|
|
169
|
+
@register_wizard_mount = route_sets.filter_map { |route_set|
|
|
170
|
+
name = Plutonium::Wizard::RouteResolution.route_name(route_set, @wizard_class, action: "show")
|
|
171
|
+
Mount.new(:register_wizard, name, nil, route_set) if name
|
|
172
|
+
}.first
|
|
153
173
|
end
|
|
154
174
|
|
|
155
|
-
# A resource-mounted ANCHORED wizard
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
# the current portal's `current_engine`, threads the entity segment when the
|
|
159
|
-
# portal is path-scoped, and singularizes the member helper). We pass the
|
|
160
|
-
# row's anchor as the record, the registering definition's wizard name, and
|
|
161
|
-
# the resumed step; a tokened (non-keyed) run also carries its run token.
|
|
162
|
-
def resource_member_url
|
|
175
|
+
# A resource-mounted ANCHORED wizard resolves against the row's anchor and
|
|
176
|
+
# the registering definition's wizard name.
|
|
177
|
+
def resource_member_mount
|
|
163
178
|
anchor = @row.anchor
|
|
164
179
|
return nil if anchor.nil?
|
|
165
180
|
|
|
166
|
-
wizard_name =
|
|
181
|
+
wizard_name = registered_wizard_name_for(anchor.class)
|
|
167
182
|
return nil if wizard_name.nil?
|
|
168
183
|
|
|
169
|
-
|
|
184
|
+
Mount.new(:member, anchor, wizard_name, nil)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A resource-mounted non-anchored (collection) wizard carries no resource
|
|
188
|
+
# identity on the row, so find it the other way round: scan this portal's
|
|
189
|
+
# registered resources for the definition that registers this wizard class.
|
|
190
|
+
#
|
|
191
|
+
# Rescued as a whole — `current_engine` is nil (or a plain Rails app with no
|
|
192
|
+
# resource register) outside a portal, and `resolve`'s contract is to report
|
|
193
|
+
# a reason rather than raise.
|
|
194
|
+
def resource_collection_mount
|
|
195
|
+
return nil unless @row.anchor.nil?
|
|
196
|
+
|
|
197
|
+
engine = @view_context.current_engine
|
|
198
|
+
return nil if engine.nil?
|
|
199
|
+
|
|
200
|
+
candidates = engine.resource_register.resources.filter_map { |resource_class|
|
|
201
|
+
name = registered_wizard_name_for(resource_class)
|
|
202
|
+
[resource_class, name] if name
|
|
203
|
+
}.sort_by { |resource_class, _name| resource_class.name }
|
|
204
|
+
return nil if candidates.empty?
|
|
205
|
+
|
|
206
|
+
if candidates.size > 1
|
|
207
|
+
Rails.logger.warn do
|
|
208
|
+
"[Plutonium::Wizard] #{@wizard_class.name} is registered on more than one resource " \
|
|
209
|
+
"definition in #{engine.name} (#{candidates.map { |klass, _| klass.name }.join(", ")}); " \
|
|
210
|
+
"resolving its resume URL against #{candidates.first.first.name}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
resource_class, wizard_name = candidates.first
|
|
215
|
+
Mount.new(:collection, resource_class, wizard_name, nil)
|
|
170
216
|
rescue => e
|
|
171
|
-
Rails.logger.warn { "[Plutonium::Wizard]
|
|
217
|
+
Rails.logger.warn { "[Plutonium::Wizard] collection mount lookup failed for #{@wizard_class.name}: #{e.message}" }
|
|
172
218
|
nil
|
|
173
219
|
end
|
|
174
220
|
|
|
175
221
|
# Reverse-lookup the `wizard`-macro name registered for this wizard class on
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
222
|
+
# a resource's definition. nil when the definition can't be loaded or doesn't
|
|
223
|
+
# register this wizard.
|
|
224
|
+
def registered_wizard_name_for(resource_class)
|
|
225
|
+
definition = "#{resource_class.name}Definition".safe_constantize
|
|
179
226
|
return nil unless definition.respond_to?(:registered_wizards)
|
|
180
227
|
|
|
181
228
|
definition.registered_wizards.find do |_name, reg|
|
|
@@ -183,8 +230,45 @@ module Plutonium
|
|
|
183
230
|
end&.first
|
|
184
231
|
end
|
|
185
232
|
|
|
186
|
-
|
|
187
|
-
|
|
233
|
+
# The GET URL that resumes the run at its current step.
|
|
234
|
+
#
|
|
235
|
+
# For a resource mount this is the SAME `resource_url_for(subject, wizard:,
|
|
236
|
+
# step:)` machinery the launch button uses (§5.1) — portal- and scope-correct
|
|
237
|
+
# by construction (it resolves on the current portal's `current_engine`,
|
|
238
|
+
# threads the entity segment when the portal is path-scoped, and singularizes
|
|
239
|
+
# the member helper).
|
|
240
|
+
def build_step_url(mount)
|
|
241
|
+
if mount.kind == :register_wizard
|
|
242
|
+
build_url(mount.route_set, mount.subject, register_wizard_params)
|
|
243
|
+
else
|
|
244
|
+
resource_wizard_url(mount, step: @row.current_step)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# The DELETE target that abandons the run, from the same mount's named cancel
|
|
249
|
+
# route. nil when the mount predates the cancel route (or generation fails) —
|
|
250
|
+
# the caller then renders no cancel affordance.
|
|
251
|
+
def build_cancel_url(mount)
|
|
252
|
+
if mount.kind == :register_wizard
|
|
253
|
+
name = Plutonium::Wizard::RouteResolution.route_name(mount.route_set, @wizard_class, action: "cancel")
|
|
254
|
+
name && build_url(mount.route_set, name, scope_param.merge(token_param))
|
|
255
|
+
else
|
|
256
|
+
resource_wizard_url(mount, wizard_action: :cancel)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def resource_wizard_url(mount, **extra)
|
|
261
|
+
@view_context.resource_url_for(mount.subject, wizard: mount.wizard_name, **token_param, **extra)
|
|
262
|
+
rescue => e
|
|
263
|
+
Rails.logger.warn { "[Plutonium::Wizard] url build failed for #{@wizard_class.name}: #{e.message}" }
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Params for a `register_wizard` named helper: the current step, the tenant
|
|
268
|
+
# scope path segment (when the run is scoped), and the URL token for a
|
|
269
|
+
# tokened (no concurrency_key) run.
|
|
270
|
+
def register_wizard_params
|
|
271
|
+
{step: @row.current_step}.merge(scope_param).merge(token_param)
|
|
188
272
|
end
|
|
189
273
|
|
|
190
274
|
# The scope path segment for an entity-scoped portal, keyed by the portal
|
|
@@ -220,24 +304,18 @@ module Plutonium
|
|
|
220
304
|
end
|
|
221
305
|
|
|
222
306
|
def unresolved_reason
|
|
223
|
-
if @row.anchor &&
|
|
307
|
+
if @row.anchor && registered_wizard_name_for(@row.anchor.class).nil?
|
|
224
308
|
"no `wizard` macro registration found for #{@wizard_class.name} " \
|
|
225
309
|
"on #{@row.anchor.class.name}Definition"
|
|
226
|
-
elsif @row.anchor.nil? &&
|
|
227
|
-
"non-anchored
|
|
228
|
-
"
|
|
310
|
+
elsif @row.anchor.nil? && register_wizard_mount.nil?
|
|
311
|
+
"non-anchored wizard — no `register_wizard` route in this portal, and no " \
|
|
312
|
+
"resource definition here registers #{@wizard_class.name}"
|
|
229
313
|
else
|
|
230
314
|
"no route found for #{@wizard_class.name} (not registered via " \
|
|
231
315
|
"register_wizard or a `wizard` macro mount this resolver can reach)"
|
|
232
316
|
end
|
|
233
317
|
end
|
|
234
318
|
|
|
235
|
-
# Heuristic for the reason text only: the wizard isn't a register_wizard
|
|
236
|
-
# mount (no named route) and has no anchor on the row.
|
|
237
|
-
def resource_mounted_candidate?
|
|
238
|
-
register_wizard_url.nil?
|
|
239
|
-
end
|
|
240
|
-
|
|
241
319
|
# The CURRENT portal's route set, plus the main app's (for `public:` mounts).
|
|
242
320
|
# Scoped to this portal so a `register_wizard` wizard mounted in several
|
|
243
321
|
# portals resolves here, not in whichever engine happens to be scanned first.
|
|
@@ -18,7 +18,7 @@ module Plutonium
|
|
|
18
18
|
# - +completed+ — finalize ran `execute` to completion.
|
|
19
19
|
# - +redirect_step+ — finalize found a completeness gap; the step to bounce to.
|
|
20
20
|
# - +value+ — the successful `execute` outcome's value.
|
|
21
|
-
Result = Struct.new(:ok, :errors, :completed, :redirect_step, :value) do
|
|
21
|
+
Result = Struct.new(:ok, :errors, :completed, :redirect_step, :value, :messages) do
|
|
22
22
|
def ok? = !!ok
|
|
23
23
|
|
|
24
24
|
def completed? = !!completed
|
|
@@ -147,6 +147,43 @@ module Plutonium
|
|
|
147
147
|
end
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
# Submit the posted step: advance through it, then finalize if it turns out
|
|
151
|
+
# to be the end of the flow (§6.3). This is the driving layer's single entry
|
|
152
|
+
# point for a forward POST.
|
|
153
|
+
#
|
|
154
|
+
# "Is this the last step?" CANNOT be answered before the submission is
|
|
155
|
+
# staged. Every `condition:` is evaluated against `data`, so a step gated on
|
|
156
|
+
# an answer from the step being submitted is still hidden at POST time.
|
|
157
|
+
# Deciding first broke two shapes:
|
|
158
|
+
#
|
|
159
|
+
# - a wizard whose later steps are all revealed BY its first step saw a
|
|
160
|
+
# visible path of just `[first]`, called it terminal, and finalized — which
|
|
161
|
+
# found `first` unsubmitted and bounced back to it, forever. The revealed
|
|
162
|
+
# steps were unreachable and no state was ever written.
|
|
163
|
+
# - a wizard with no `review` step finalized its last step WITHOUT staging
|
|
164
|
+
# it, silently dropping that step's params before `execute`.
|
|
165
|
+
#
|
|
166
|
+
# So the order is stage, then look: after `advance` the cursor holds the next
|
|
167
|
+
# visible step, recomputed against the freshly staged data, and a nil cursor
|
|
168
|
+
# means nothing follows — finish. This also matches what the user sees: the
|
|
169
|
+
# nav strip renders Finish only on the review step (§7), never on a field
|
|
170
|
+
# step that merely happens to be last right now.
|
|
171
|
+
#
|
|
172
|
+
# The terminal `review` step short-circuits: it declares no fields, so there
|
|
173
|
+
# is nothing to stage and Finish runs `execute` directly.
|
|
174
|
+
def submit(step_key, params, goto: nil)
|
|
175
|
+
step = step_for(step_key)
|
|
176
|
+
return finalize if step&.review?
|
|
177
|
+
|
|
178
|
+
result = advance(step_key, params, goto:)
|
|
179
|
+
return result unless result.ok?
|
|
180
|
+
# A cursor target survives → the flow continues (including the `goto:`
|
|
181
|
+
# "Save & review" shortcut). Nil → `advance` found no next visible step.
|
|
182
|
+
return result if @state.current_step.present?
|
|
183
|
+
|
|
184
|
+
finalize
|
|
185
|
+
end
|
|
186
|
+
|
|
150
187
|
# Validate + stage a step, run its `on_submit` (in a transaction), then move
|
|
151
188
|
# the cursor to the next visible step. On validation/`on_submit` failure the
|
|
152
189
|
# cursor does not move and the errors are returned.
|
|
@@ -243,6 +280,13 @@ module Plutonium
|
|
|
243
280
|
true
|
|
244
281
|
end
|
|
245
282
|
|
|
283
|
+
# Stage a step's params for a render-only refresh (the pre_submit re-render).
|
|
284
|
+
# Unlike `advance` it never persists, so the step isn't marked submitted and
|
|
285
|
+
# an abandoned refresh leaves nothing durable.
|
|
286
|
+
def stage_inputs(step_key, params)
|
|
287
|
+
stage(step_key, params)
|
|
288
|
+
end
|
|
289
|
+
|
|
246
290
|
# Move the cursor to the previous visible step. No validation; never discards
|
|
247
291
|
# staged data (§6 — back is navigation, not submission).
|
|
248
292
|
def back
|
|
@@ -301,7 +345,7 @@ module Plutonium
|
|
|
301
345
|
else
|
|
302
346
|
@store.clear(@instance_key)
|
|
303
347
|
end
|
|
304
|
-
Result.new(ok: true, completed: true, value: outcome.value)
|
|
348
|
+
Result.new(ok: true, completed: true, value: outcome.value, messages: outcome.messages)
|
|
305
349
|
else
|
|
306
350
|
revert_completing!
|
|
307
351
|
Result.new(ok: false, errors: wizard_errors)
|
|
@@ -26,7 +26,7 @@ module Plutonium
|
|
|
26
26
|
# `uploader:` as an attribute, and `backend:` would otherwise leak as a stray
|
|
27
27
|
# attribute. Driving reads them off `step.inputs` directly, so removing them
|
|
28
28
|
# here is invisible to staging.
|
|
29
|
-
STAGING_ONLY_INPUT_OPTIONS =
|
|
29
|
+
STAGING_ONLY_INPUT_OPTIONS = Plutonium::Attachments::STAGING_ONLY_INPUT_OPTIONS
|
|
30
30
|
|
|
31
31
|
def initialize(step)
|
|
32
32
|
@step = step
|
|
@@ -19,6 +19,22 @@ module Plutonium
|
|
|
19
19
|
# and an unconstantizable wizard class is skipped while the row is still reaped.
|
|
20
20
|
# +completed+ rows are never touched (the +sweepable+ scope excludes them).
|
|
21
21
|
class SweepJob < ActiveJob::Base
|
|
22
|
+
# One sweep at a time, when the host's queue offers a semaphore.
|
|
23
|
+
#
|
|
24
|
+
# A sweep that outlives its own interval would otherwise overlap the next
|
|
25
|
+
# tick and walk the same rows. That is not merely wasteful here: two
|
|
26
|
+
# sweepers can pick up the same session and both build a Runner for it, so
|
|
27
|
+
# a step's on_rollback compensator — refund the charge, call the external
|
|
28
|
+
# API — runs twice for one abandoned run.
|
|
29
|
+
#
|
|
30
|
+
# A constant key: every sweep is the same sweep, so they queue behind each
|
|
31
|
+
# other globally rather than per row. Only +key+ and +to+ are passed;
|
|
32
|
+
# +on_conflict+ arrived in Solid Queue 1.2 and passing it would turn an
|
|
33
|
+
# older host's boot into an ArgumentError.
|
|
34
|
+
if respond_to?(:limits_concurrency)
|
|
35
|
+
limits_concurrency to: 1, key: ->(*) { "sweep" }
|
|
36
|
+
end
|
|
37
|
+
|
|
22
38
|
def perform(now: Time.current)
|
|
23
39
|
store = Store::ActiveRecord.new
|
|
24
40
|
|
data/lib/plutonium.rb
CHANGED
|
@@ -26,6 +26,15 @@ module Plutonium
|
|
|
26
26
|
# Custom error class for Plutonium-specific exceptions
|
|
27
27
|
class Error < StandardError; end
|
|
28
28
|
|
|
29
|
+
# Raised when a resource registered `singular: true` has an authorized scope
|
|
30
|
+
# that resolves to more than one record.
|
|
31
|
+
#
|
|
32
|
+
# A singular route carries no :id — the scope IS the identification — so the
|
|
33
|
+
# promise `singular: true` makes is "exactly one record here". A scope that
|
|
34
|
+
# returns several silently nests (or shows) an arbitrary row, so it fails
|
|
35
|
+
# instead. Always a registration/policy mismatch, never user input.
|
|
36
|
+
class SingularScopeError < Error; end
|
|
37
|
+
|
|
29
38
|
# Turbo frame id used by the modal/slideover renderer. The layout wraps
|
|
30
39
|
# itself in this frame, in_modal? checks against it, and Action and
|
|
31
40
|
# Definition default to targeting it. Kept as a single constant so the
|
|
@@ -40,6 +49,18 @@ module Plutonium
|
|
|
40
49
|
|
|
41
50
|
MODAL_FRAMES = [REMOTE_MODAL_FRAME, REMOTE_MODAL_SECONDARY_FRAME].freeze
|
|
42
51
|
|
|
52
|
+
# DOM id of the page's toast region (Layout::Base#render_flash). An action
|
|
53
|
+
# that answers a turbo_stream never renders the layout, so it never gets to
|
|
54
|
+
# consume `flash` — it appends a rendered toast to this region instead.
|
|
55
|
+
FLASH_REGION = "pu-flash"
|
|
56
|
+
|
|
57
|
+
# Query param a kanban card appends to its show URL when the board opens the
|
|
58
|
+
# card in the shared remote-modal frame. Since a kanban modal and a regular
|
|
59
|
+
# show modal both target REMOTE_MODAL_FRAME, `in_modal?` can't tell them apart
|
|
60
|
+
# — this flag lets `in_kanban_modal?` distinguish the two so the show page can
|
|
61
|
+
# render a compact detail (no metadata rail) for kanban cards only.
|
|
62
|
+
KANBAN_MODAL_PARAM = "kanban_modal"
|
|
63
|
+
|
|
43
64
|
# Set up Zeitwerk loader for the Plutonium gem
|
|
44
65
|
# @return [Zeitwerk::Loader] configured Zeitwerk loader instance
|
|
45
66
|
Loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false).tap do |loader|
|