plutonium 0.62.2 → 0.64.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/.standard.yml +1 -1
- data/CHANGELOG.md +73 -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 +323 -141
- data/gemfiles/rails_8.0.gemfile.lock +126 -116
- data/gemfiles/rails_8.1.gemfile.lock +127 -117
- 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 +20 -20
- 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 +101 -22
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -3,22 +3,40 @@ require "active_model"
|
|
|
3
3
|
module Plutonium
|
|
4
4
|
module Interaction
|
|
5
5
|
# Base class for all interactions.
|
|
6
|
-
# Provides core functionality for validations, execution, and result handling.
|
|
7
6
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
7
|
+
# An interaction is a *presentation object*: it declares the inputs for an
|
|
8
|
+
# operation, renders as a button and a form, is gated by a policy, and
|
|
9
|
+
# returns an {Plutonium::Interaction::Outcome} the controller turns into a
|
|
10
|
+
# flash message and a redirect. It provides validations (of input *shape*),
|
|
11
|
+
# execution, and result handling.
|
|
12
|
+
#
|
|
13
|
+
# It cannot be constructed without a +view_context:+ — which is precisely
|
|
14
|
+
# the boundary it marks. Logic may *start* in +#execute+; a one-off with a
|
|
15
|
+
# single caller is fine there, and pre-extracting is YAGNI. But the moment a
|
|
16
|
+
# second caller appears — a background job, an API controller, a rake task,
|
|
17
|
+
# the console — move the behaviour onto the model, Rails-style. Otherwise
|
|
18
|
+
# that caller must either duplicate it or manufacture a +view_context+ it
|
|
19
|
+
# has no business owning.
|
|
20
|
+
#
|
|
21
|
+
# @example The model owns what "confirming" means; the interaction presents it
|
|
22
|
+
# class ConfirmUserInteraction < Plutonium::Interaction::Base
|
|
23
|
+
# attribute :resource
|
|
24
|
+
# attribute :confirmed_at, :datetime, default: -> { Time.current }
|
|
25
|
+
#
|
|
26
|
+
# validates :confirmed_at, presence: true
|
|
12
27
|
#
|
|
13
28
|
# private
|
|
14
29
|
#
|
|
15
30
|
# def execute
|
|
16
|
-
#
|
|
17
|
-
#
|
|
31
|
+
# resource.confirm!(at: confirmed_at) # User#confirm! — reachable from a job too
|
|
32
|
+
# succeed(resource).with_message("User confirmed.")
|
|
33
|
+
# rescue ActiveRecord::RecordInvalid => e
|
|
34
|
+
# failed(e.record.errors)
|
|
18
35
|
# end
|
|
19
36
|
# end
|
|
20
37
|
#
|
|
21
38
|
# @note Subclasses must implement the #execute method.
|
|
39
|
+
# @see https://radioactive-labs.github.io/plutonium-core/reference/behavior/interactions
|
|
22
40
|
class Base
|
|
23
41
|
include ActiveModel::Model
|
|
24
42
|
include ActiveModel::Attributes
|
|
@@ -27,6 +45,15 @@ module Plutonium
|
|
|
27
45
|
include Plutonium::Definition::Presentable
|
|
28
46
|
include Plutonium::Definition::StructuredInputs
|
|
29
47
|
include Plutonium::Definition::FormLayout
|
|
48
|
+
include Plutonium::Definition::InheritableConfigAttr
|
|
49
|
+
# An interaction renders through the same Form::Resource a resource does,
|
|
50
|
+
# so it needs the same width knobs — `page_width` / `form_width`, falling
|
|
51
|
+
# back to `Plutonium.configuration.default_page_width`. Without this an
|
|
52
|
+
# interactive action rendered as a standalone page has no width at all.
|
|
53
|
+
include Plutonium::Definition::PageWidths
|
|
54
|
+
# `async SomeRun` — the interaction persists a run and enqueues it
|
|
55
|
+
# instead of doing the work inline. Inert until an interaction declares it.
|
|
56
|
+
include Plutonium::Interaction::Concerns::Dispatchable
|
|
30
57
|
|
|
31
58
|
# On interactions, declaring a structured input also declares the backing
|
|
32
59
|
# ActiveModel attribute so the value survives `attributes=` and appears in
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Interaction
|
|
5
|
+
module Concerns
|
|
6
|
+
# Turns an interaction into a dispatcher: instead of doing the work
|
|
7
|
+
# inline, it persists a {Plutonium::Interaction::Async::Run}, enqueues it, and
|
|
8
|
+
# sends the user to it.
|
|
9
|
+
#
|
|
10
|
+
# The interaction keeps everything else it already was — it declares
|
|
11
|
+
# inputs, validates them, renders a form, and is gated by the same policy
|
|
12
|
+
# as before. Only +#execute+ changes.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# class Blogging::ArchivePosts < Plutonium::Resource::Interaction
|
|
16
|
+
# attribute :resources
|
|
17
|
+
# attribute :reason, :string
|
|
18
|
+
#
|
|
19
|
+
# async do
|
|
20
|
+
# on_failure :continue
|
|
21
|
+
# def perform_on(post) = post.archive!(reason: options["reason"])
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# == The outcome stays synchronous
|
|
26
|
+
#
|
|
27
|
+
# +call+ still returns an Outcome immediately, because dispatching is what
|
|
28
|
+
# succeeded. Whether the WORK succeeds is the run's business, reported on
|
|
29
|
+
# the run's own page — which is why Outcome needs no third "pending" state.
|
|
30
|
+
#
|
|
31
|
+
# == Nothing is passed in at the call site
|
|
32
|
+
#
|
|
33
|
+
# Every value the run needs is already reachable: the targets are the
|
|
34
|
+
# +resource+/+resources+ the controller resolved through the policy scope,
|
|
35
|
+
# and the authorization triple comes from the controller the interaction
|
|
36
|
+
# is rendering in. So no controller and no host app has to hand a
|
|
37
|
+
# dispatching interaction anything an inline one does not already get.
|
|
38
|
+
#
|
|
39
|
+
# == What dispatch records, and why it is exactly this
|
|
40
|
+
#
|
|
41
|
+
# The job has no controller, so {Plutonium::Interaction::Async::Context}
|
|
42
|
+
# rebuilds the authorization context from the row. Three of the columns
|
|
43
|
+
# exist purely so that rebuild cannot silently widen:
|
|
44
|
+
#
|
|
45
|
+
# * +authorization_namespace+ — the portal's module NAME. Without it,
|
|
46
|
+
# perform-time lookup falls back to the base policy and every narrowing
|
|
47
|
+
# the portal applied is lost.
|
|
48
|
+
# * +policy_class_name+ — the policy dispatch ACTUALLY RESOLVED, never an
|
|
49
|
+
# inferred "#{Model}Policy". A namespaced portal and an STI fallback both
|
|
50
|
+
# make the inferred name wrong, and Context refuses to run when the
|
|
51
|
+
# recorded name and today's lookup disagree.
|
|
52
|
+
# * +policy_action+ — the predicate, so perform re-checks PERMISSION and
|
|
53
|
+
# not merely visibility.
|
|
54
|
+
module Dispatchable
|
|
55
|
+
extend ActiveSupport::Concern
|
|
56
|
+
|
|
57
|
+
# Raised when a dispatching interaction runs while async interactions are
|
|
58
|
+
# switched off. Named rather than a bare RuntimeError so a host can tell
|
|
59
|
+
# it apart from anything the interaction itself raised — and so it reads
|
|
60
|
+
# the same way as the interaction layer's other typed failures
|
|
61
|
+
# (Async::Context::UnresolvableError, Async::Executor::TargetRefusedError).
|
|
62
|
+
class NotEnabledError < StandardError; end
|
|
63
|
+
|
|
64
|
+
# The tenant is half of what the policy scope filters on, and this is
|
|
65
|
+
# where it comes from.
|
|
66
|
+
include Plutonium::Interaction::Concerns::Scoping
|
|
67
|
+
|
|
68
|
+
included do
|
|
69
|
+
# A class_attribute rather than a class ivar so a subclass of a
|
|
70
|
+
# dispatching interaction inherits the declaration.
|
|
71
|
+
class_attribute :run_class, instance_accessor: false
|
|
72
|
+
|
|
73
|
+
# Guarded on run_class: an interaction that executes inline hands its
|
|
74
|
+
# uploads straight to a model, whose own attachment validations run as
|
|
75
|
+
# normal. Only a dispatched one stages, and only a staged token can be
|
|
76
|
+
# validated ahead of the work.
|
|
77
|
+
validate :validate_dispatch_attachments, if: -> { self.class.run_class }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Prepended, rather than defined via +define_method+, so a `def
|
|
81
|
+
# execute` written BELOW `async` can't silently shadow it —
|
|
82
|
+
# a prepended module sits ahead of the class in the ancestor chain
|
|
83
|
+
# regardless of source order. (A subclass's own #execute still wins,
|
|
84
|
+
# same as any override — this only closes same-class ordering.)
|
|
85
|
+
module ExecuteOverride
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def execute = dispatch
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class_methods do
|
|
92
|
+
# Declares that this interaction executes OUT OF BAND, in a job,
|
|
93
|
+
# instead of inline in the request.
|
|
94
|
+
#
|
|
95
|
+
# An interaction has exactly two ways to do its work, and this is the
|
|
96
|
+
# fork between them:
|
|
97
|
+
#
|
|
98
|
+
# def execute # inline, in the request
|
|
99
|
+
# async do ... # out of band, in a job
|
|
100
|
+
#
|
|
101
|
+
# == The block is the run's class body
|
|
102
|
+
#
|
|
103
|
+
# Not the body of #execute — the work happens later, in a process with
|
|
104
|
+
# no controller and no view_context, so it cannot be a closure over
|
|
105
|
+
# anything here. What the block declares is a
|
|
106
|
+
# {Plutonium::Interaction::Async::Run} subclass, with exactly the API a
|
|
107
|
+
# standalone run has: +on_failure+, and +perform_on(record)+ or
|
|
108
|
+
# +perform+.
|
|
109
|
+
#
|
|
110
|
+
# async do
|
|
111
|
+
# on_failure :continue
|
|
112
|
+
# def perform_on(post) = post.archive!(reason: options["reason"])
|
|
113
|
+
# end
|
|
114
|
+
#
|
|
115
|
+
# +def+ opens a fresh scope, so those bodies cannot accidentally close
|
|
116
|
+
# over the interaction's locals — which is what makes the block safe to
|
|
117
|
+
# write here rather than misleading.
|
|
118
|
+
#
|
|
119
|
+
# The generated class is NAMED, as +self::Run+, not left anonymous.
|
|
120
|
+
# That is load-bearing: the class name is persisted in the run's +type+
|
|
121
|
+
# column and constantized in another process, so an anonymous class
|
|
122
|
+
# would produce a row nothing can read back.
|
|
123
|
+
#
|
|
124
|
+
# == Passing a class instead
|
|
125
|
+
#
|
|
126
|
+
# A run shared by several interactions that do the same kind of work is
|
|
127
|
+
# declared once and named here:
|
|
128
|
+
#
|
|
129
|
+
# async Blogging::ArchivePostsRun
|
|
130
|
+
#
|
|
131
|
+
# @param run_class [Class<Plutonium::Interaction::Async::Run>, nil]
|
|
132
|
+
# @yield the run's class body, when no class is given
|
|
133
|
+
# @raise [ArgumentError] if given both a class and a block, or neither,
|
|
134
|
+
# or if this class already defines +#execute+ or its own +Run+
|
|
135
|
+
# @return [void]
|
|
136
|
+
def async(run_class = nil, &block)
|
|
137
|
+
if run_class && block
|
|
138
|
+
raise ArgumentError,
|
|
139
|
+
"#{self} declares async with both a run class and a block. The block IS " \
|
|
140
|
+
"a run class — pass one or the other."
|
|
141
|
+
end
|
|
142
|
+
unless run_class || block
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"#{self} declares async with nothing to run. Pass a run class, or a block " \
|
|
145
|
+
"declaring one."
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Catches the OTHER ordering: #execute already defined before
|
|
149
|
+
# async runs is two conflicting declarations, not an
|
|
150
|
+
# ordering accident, and the prepend below does not resolve it.
|
|
151
|
+
if !@execute_defined_by_dispatch &&
|
|
152
|
+
(method_defined?(:execute, false) || private_method_defined?(:execute, false))
|
|
153
|
+
raise ArgumentError,
|
|
154
|
+
"#{self} declares async but also defines its own #execute, which async " \
|
|
155
|
+
"would overwrite. An interaction either executes inline or runs async — " \
|
|
156
|
+
"remove one of the two."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
run_class ||= build_run_class(&block)
|
|
160
|
+
self.run_class = run_class
|
|
161
|
+
|
|
162
|
+
prepend(ExecuteOverride) unless @execute_defined_by_dispatch
|
|
163
|
+
@execute_defined_by_dispatch = true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# Defines +self::Run+ from the block.
|
|
169
|
+
#
|
|
170
|
+
# +const_set+ rather than an anonymous class: see #async. The
|
|
171
|
+
# collision check refuses to clobber a Run the author declared
|
|
172
|
+
# themselves, because silently replacing it would lose their work with
|
|
173
|
+
# no diagnostic — and because the two ways of declaring a run are meant
|
|
174
|
+
# to be alternatives, not layers.
|
|
175
|
+
def build_run_class(&block)
|
|
176
|
+
if const_defined?(:Run, false)
|
|
177
|
+
raise ArgumentError,
|
|
178
|
+
"#{self} already defines #{self}::Run, which async's block would replace. " \
|
|
179
|
+
"Either drop the block and pass the class, or remove the class."
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
const_set(:Run, Class.new(Plutonium::Interaction::Async::Run)).tap do |klass|
|
|
183
|
+
klass.class_eval(&block)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
# Persists the run and returns the outcome that sends the user to it.
|
|
191
|
+
#
|
|
192
|
+
# The run enqueues ITS OWN job, via an +after_commit+ on
|
|
193
|
+
# {Plutonium::Interaction::Async::Run} — not from here, which would race a
|
|
194
|
+
# fast/inline job adapter against the very commit the row needs to
|
|
195
|
+
# be visible.
|
|
196
|
+
#
|
|
197
|
+
# @return [Plutonium::Interaction::Outcome::Success]
|
|
198
|
+
def dispatch
|
|
199
|
+
# The flag gates the MIGRATION, not just the behaviour: while it is off
|
|
200
|
+
# the runs migration path is never registered (see Plutonium::Railtie),
|
|
201
|
+
# so plutonium_async_runs does not exist. Without this the create!
|
|
202
|
+
# below surfaces as a raw "no such table" from inside ActiveRecord,
|
|
203
|
+
# which says nothing about the switch that has to be flipped.
|
|
204
|
+
unless Plutonium.configuration.async_interactions.enabled
|
|
205
|
+
raise NotEnabledError,
|
|
206
|
+
"#{self.class} dispatches to #{self.class.run_class}, but async interactions are not " \
|
|
207
|
+
"enabled. Set `config.async_interactions.enabled = true` in your Plutonium " \
|
|
208
|
+
"initializer, then run the migration."
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
run = self.class.run_class.create!(
|
|
212
|
+
initiator: current_user,
|
|
213
|
+
scoped_entity: current_scoped_entity,
|
|
214
|
+
# The third policy input. Recorded here rather than re-derived at
|
|
215
|
+
# perform time, where there is no controller to ask — same reason
|
|
216
|
+
# the tenant and the namespace are on the row.
|
|
217
|
+
parent: current_parent,
|
|
218
|
+
parent_association: current_nested_association&.to_s,
|
|
219
|
+
options: dispatch_options,
|
|
220
|
+
**dispatch_target_attributes
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
succeed(run).with_redirect_response(dispatch_redirect_target(run))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Everything the interaction validated, minus the records themselves,
|
|
227
|
+
# in a shape that survives the trip to a job.
|
|
228
|
+
#
|
|
229
|
+
# The records are the TARGETS: they are stored as ids and re-resolved
|
|
230
|
+
# through the policy scope when the run performs. A serialized copy in
|
|
231
|
+
# options would be both stale and unauthorized.
|
|
232
|
+
#
|
|
233
|
+
# Two things have to happen to the rest, because options is a JSON column
|
|
234
|
+
# and JSON has neither files nor types:
|
|
235
|
+
#
|
|
236
|
+
# * An UPLOADED FILE is staged to its backend's cache and carried as the
|
|
237
|
+
# token — see {#stage_dispatch_attachments}. Left alone it serializes to
|
|
238
|
+
# a hash naming a tempfile the request deletes on its way out, so the
|
|
239
|
+
# run would receive the right filename and no bytes, with nothing
|
|
240
|
+
# raised anywhere.
|
|
241
|
+
# * TYPED VALUES go through ActiveJob::Arguments, which is Rails' own
|
|
242
|
+
# answer to this exact problem. Without it a Date arrives as "2026-08-19"
|
|
243
|
+
# and a BigDecimal as "12.34", so `options["amount"] * 2` quietly
|
|
244
|
+
# produces "12.3412.34". Primitives pass through verbatim, so the common
|
|
245
|
+
# case stays readable in the column and only what needs an envelope gets
|
|
246
|
+
# one — and a host can register its own serializers.
|
|
247
|
+
#
|
|
248
|
+
# @return [Hash]
|
|
249
|
+
def dispatch_options
|
|
250
|
+
# Staged during validation; this is a no-op for a caller that skipped it.
|
|
251
|
+
stage_dispatch_attachments!
|
|
252
|
+
::ActiveJob::Arguments.serialize([attributes.except("resource", "resources")]).first
|
|
253
|
+
rescue ::ActiveJob::SerializationError => e
|
|
254
|
+
# Raised HERE, where the author can see which attribute they declared,
|
|
255
|
+
# rather than surviving into a row whose work fails deep in a job.
|
|
256
|
+
raise ArgumentError,
|
|
257
|
+
"#{self.class} cannot carry one of its attributes to a run: #{e.message}. " \
|
|
258
|
+
"A run's options are JSON, so every attribute has to be JSON-safe, an " \
|
|
259
|
+
"uploaded file, or a type ActiveJob knows how to serialize."
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Replaces uploaded files with the token their backend mints for them.
|
|
263
|
+
#
|
|
264
|
+
# The token IS what gets stored, verbatim — not wrapped in an envelope —
|
|
265
|
+
# so the column stays readable and the run's own +attachment+ reader is
|
|
266
|
+
# the one place that knows how to turn it back into a file. That split is
|
|
267
|
+
# deliberate: staging happens here, in the request, where the file is;
|
|
268
|
+
# reviving happens wherever the value is read, which may be a job.
|
|
269
|
+
#
|
|
270
|
+
# @return [Hash]
|
|
271
|
+
# Stages every uploaded file and WRITES THE TOKEN BACK onto the attribute.
|
|
272
|
+
#
|
|
273
|
+
# Writing back is what makes the form survive a re-render. An attribute
|
|
274
|
+
# still holding an ActionDispatch::Http::UploadedFile blows up the moment
|
|
275
|
+
# the form redraws — the file input renders previously-attached files and
|
|
276
|
+
# calls +url+ on the value, which an uploaded file does not answer. So any
|
|
277
|
+
# validation failure on ANY attribute took the whole page down with a
|
|
278
|
+
# NoMethodError. A staged token is a plain String, which is exactly what
|
|
279
|
+
# the input already knows how to redraw, and what a wizard holds for the
|
|
280
|
+
# same reason.
|
|
281
|
+
#
|
|
282
|
+
# Idempotent: a token is already a String, so a re-submit stages nothing
|
|
283
|
+
# a second time.
|
|
284
|
+
def stage_dispatch_attachments!
|
|
285
|
+
return if @dispatch_attachments_staged
|
|
286
|
+
|
|
287
|
+
attributes.except("resource", "resources").each do |name, value|
|
|
288
|
+
next unless dispatch_attachment?(value)
|
|
289
|
+
|
|
290
|
+
token = Plutonium::Attachments.stage_upload(value, **dispatch_attachment_options(name))
|
|
291
|
+
public_send(:"#{name}=", token)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
@dispatch_attachments_staged = true
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# A file field's +backend:+ and +uploader:+, read off the interaction's own
|
|
298
|
+
# +input+ declaration — the same options, in the same place, that a wizard
|
|
299
|
+
# step reads them from:
|
|
300
|
+
#
|
|
301
|
+
# attribute :import_file
|
|
302
|
+
# input :import_file, as: :uppy, uploader: Catalog::ImportUploader
|
|
303
|
+
#
|
|
304
|
+
# An interaction that declares no input for the attribute gets the
|
|
305
|
+
# configured backend and base Shrine, which is what it would have got
|
|
306
|
+
# before either option existed.
|
|
307
|
+
def dispatch_attachment_options(name)
|
|
308
|
+
options = self.class.defined_inputs.dig(name.to_sym, :options) || {}
|
|
309
|
+
|
|
310
|
+
{backend: options[:backend] || dispatch_attachment_backend, uploader: options[:uploader]}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# An uploaded file, or a collection of them. Detected by behaviour rather
|
|
314
|
+
# than by class so it covers ActionDispatch::Http::UploadedFile, Rack's
|
|
315
|
+
# multipart shape, and a bare IO an author assigned themselves.
|
|
316
|
+
def dispatch_attachment?(value)
|
|
317
|
+
return value.any? { |v| dispatch_attachment?(v) } if value.is_a?(Array)
|
|
318
|
+
|
|
319
|
+
value.respond_to?(:read) && value.respond_to?(:original_filename)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Runs override the shared default, so an app can point runs at one
|
|
323
|
+
# backend without moving its wizards.
|
|
324
|
+
def dispatch_attachment_backend
|
|
325
|
+
Plutonium.configuration.async_interactions.attachment_backend ||
|
|
326
|
+
Plutonium::Attachments.default_backend
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Runs each file attribute's uploader validations, so a file that breaks
|
|
330
|
+
# them fails the FORM.
|
|
331
|
+
#
|
|
332
|
+
# Without this the interaction validates clean, dispatches, and the run
|
|
333
|
+
# fails in a job — the author's `validate_max_size` reported as a run
|
|
334
|
+
# failure the submitter never sees, on a page they have already left.
|
|
335
|
+
# Wizards reject at the step for the same reason; this is the interaction's
|
|
336
|
+
# equivalent of that.
|
|
337
|
+
#
|
|
338
|
+
# No-op for ActiveStorage fields, and for uploaders declaring no rules.
|
|
339
|
+
def validate_dispatch_attachments
|
|
340
|
+
staged = attributes.except("resource", "resources").select { |_, v| dispatch_attachment?(v) }.keys
|
|
341
|
+
stage_dispatch_attachments!
|
|
342
|
+
|
|
343
|
+
staged.each do |name|
|
|
344
|
+
messages = Plutonium::Attachments.validation_errors(
|
|
345
|
+
public_send(name), **dispatch_attachment_options(name)
|
|
346
|
+
)
|
|
347
|
+
messages.each { |message| errors.add(name, message) }
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Whether this interaction has a SUBJECT at all, decided by what it
|
|
352
|
+
# declared rather than by what the declaration currently holds.
|
|
353
|
+
#
|
|
354
|
+
# Emptiness cannot stand in for this. A bulk action whose ids all
|
|
355
|
+
# resolved to nothing produces an empty +resources+, and reading that as
|
|
356
|
+
# "no targets" would dispatch a run indistinguishable from genuinely
|
|
357
|
+
# opaque work — no target_type, no policy assertion, nothing for
|
|
358
|
+
# Async::Context to check. Asking what the class DECLARED separates the
|
|
359
|
+
# two cases exactly.
|
|
360
|
+
#
|
|
361
|
+
# @return [Boolean]
|
|
362
|
+
def dispatch_targeted?
|
|
363
|
+
attributes.key?("resources") || attributes.key?("resource")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# The records the run will act on, taken from whichever subject the
|
|
367
|
+
# controller bound — +resources+ for a bulk action, +resource+ for a
|
|
368
|
+
# record action. Both arrive already narrowed by the policy scope (see
|
|
369
|
+
# Plutonium::Resource::Controllers::InteractiveActions#interactive_bulk).
|
|
370
|
+
#
|
|
371
|
+
# @return [Array<ActiveRecord::Base>]
|
|
372
|
+
def dispatch_targets
|
|
373
|
+
@dispatch_targets ||= begin
|
|
374
|
+
records = Array(attributes["resources"] || attributes["resource"])
|
|
375
|
+
# Refused rather than downgraded to an opaque run. The likeliest
|
|
376
|
+
# cause is a bulk submission whose ids have all since been deleted or
|
|
377
|
+
# left the tenant, and "act on nothing" is not a sensible reading of
|
|
378
|
+
# it — the run would carry no policy assertion and then fail deep in
|
|
379
|
+
# a job with a message about a missing target_type.
|
|
380
|
+
if records.empty?
|
|
381
|
+
raise "#{self.class} declares a subject but resolved no records to act on. " \
|
|
382
|
+
"A targeted run over an empty set cannot record the policy it was " \
|
|
383
|
+
"dispatched under, and must not be silently downgraded to opaque work."
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
records
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# The target half of the row — empty for opaque work.
|
|
391
|
+
#
|
|
392
|
+
# A run with no targets carries no target_type, and therefore neither a
|
|
393
|
+
# policy to pin nor a predicate to ask. Recording either anyway would be
|
|
394
|
+
# writing an assertion nothing ever checks.
|
|
395
|
+
#
|
|
396
|
+
# @return [Hash]
|
|
397
|
+
def dispatch_target_attributes
|
|
398
|
+
return dispatch_untargeted_attributes unless dispatch_targeted?
|
|
399
|
+
|
|
400
|
+
records = dispatch_targets
|
|
401
|
+
namespace = dispatch_authorization_namespace
|
|
402
|
+
target_class = dispatch_target_class
|
|
403
|
+
|
|
404
|
+
{
|
|
405
|
+
target_type: target_class.name,
|
|
406
|
+
target_ids: records.map(&:id),
|
|
407
|
+
progress_total: records.size,
|
|
408
|
+
authorization_namespace: namespace&.name,
|
|
409
|
+
policy_class_name: ::ActionPolicy.lookup(target_class, namespace: namespace).name,
|
|
410
|
+
policy_action: dispatch_policy_action.to_s
|
|
411
|
+
}
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# What an OPAQUE run still records: which resource it concerns.
|
|
415
|
+
#
|
|
416
|
+
# It has no targets, so it carries no ids, no total and no policy to pin
|
|
417
|
+
# — there is nothing to re-resolve at perform time, which is the only
|
|
418
|
+
# thing those exist for.
|
|
419
|
+
#
|
|
420
|
+
# But target_type is not only about targets. Its stated job is the index
|
|
421
|
+
# feature, "runs for this resource" — Run.for_target is
|
|
422
|
+
# +where(target_type:)+, and it is what puts a run in the running banner.
|
|
423
|
+
# Left nil, an opaque run dispatched from a resource's index vanished the
|
|
424
|
+
# moment the user was sent back to that index: nothing on the page
|
|
425
|
+
# mentioned it, and the only way to find it was the Async Runs list.
|
|
426
|
+
#
|
|
427
|
+
# nil stays possible, and still means "no resource": an interaction
|
|
428
|
+
# dispatched outside a resource controller has none to name.
|
|
429
|
+
#
|
|
430
|
+
# @return [Hash]
|
|
431
|
+
def dispatch_untargeted_attributes
|
|
432
|
+
resource_class = dispatch_target_class
|
|
433
|
+
return {} unless resource_class
|
|
434
|
+
|
|
435
|
+
{target_type: resource_class.name}
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# The RESOURCE class, not the class of the records.
|
|
439
|
+
#
|
|
440
|
+
# A bulk selection of STI rows resolves its policy per record when it
|
|
441
|
+
# performs (Async::Context#policy_for), but the SCOPE the run resolves
|
|
442
|
+
# targets through has to be the one dispatch authorized against. Reading
|
|
443
|
+
# it off the first record would narrow a mixed Post/Article selection to
|
|
444
|
+
# whichever type happened to come first, and silently report the rest as
|
|
445
|
+
# missing.
|
|
446
|
+
#
|
|
447
|
+
# @return [Class]
|
|
448
|
+
def dispatch_target_class
|
|
449
|
+
view_context.controller.helpers.resource_class
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# ActionPolicy's own public accessor for the controller's module nesting
|
|
453
|
+
# (ActionPolicy::Behaviours::Namespaced). nil is legitimate and means
|
|
454
|
+
# "top level".
|
|
455
|
+
#
|
|
456
|
+
# @return [Module, nil]
|
|
457
|
+
def dispatch_authorization_namespace
|
|
458
|
+
view_context.controller.authorization_namespace
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# The predicate to re-check per record, derived from the action's name
|
|
462
|
+
# exactly as
|
|
463
|
+
# Plutonium::Resource::Controllers::InteractiveActions#authorize_interactive_bulk_action!
|
|
464
|
+
# derives it — the two must agree, or perform would re-check a different
|
|
465
|
+
# question than dispatch asked.
|
|
466
|
+
#
|
|
467
|
+
# @return [Symbol]
|
|
468
|
+
def dispatch_policy_action
|
|
469
|
+
action = view_context.controller.helpers.current_interactive_action
|
|
470
|
+
# Refused here rather than at perform time: Async::Context will reject a
|
|
471
|
+
# targeted run with no predicate, but by then the diagnosis is a row in
|
|
472
|
+
# a failed job, hours from the code that wrote it.
|
|
473
|
+
unless action
|
|
474
|
+
raise "#{self.class} dispatches #{self.class.run_class} over #{dispatch_targets.size} target(s), " \
|
|
475
|
+
"but no interactive action is in flight, so there is no policy predicate to record. " \
|
|
476
|
+
"A targeted run without one cannot re-check permission when it performs."
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
:"#{action.name}?"
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Where dispatch sends the user: back where they came from, else the
|
|
483
|
+
# run's own page.
|
|
484
|
+
#
|
|
485
|
+
# +return_to+ wins because dispatching is not a destination. The user
|
|
486
|
+
# asked to archive the rows they had selected; landing them on a progress
|
|
487
|
+
# page means the list they were working is now two clicks away, and every
|
|
488
|
+
# index already surfaces its in-progress runs in a banner — so going back
|
|
489
|
+
# loses them nothing. A dispatch with no +return_to+ (a direct link, an
|
|
490
|
+
# API-ish caller) still needs somewhere to go, and the run's page is the
|
|
491
|
+
# only page that is about what just happened.
|
|
492
|
+
#
|
|
493
|
+
# Through +url_from+, which is what makes the parameter safe to honour:
|
|
494
|
+
# it returns nil for anything not same-origin, so a forged +return_to+
|
|
495
|
+
# cannot turn a bulk action into an open redirect.
|
|
496
|
+
#
|
|
497
|
+
# The fallback is resolved with +resource_url_for+, which is how every
|
|
498
|
+
# resource URL in this framework is built, and NOT by handing the record
|
|
499
|
+
# to Response::Redirect for a plain +url_for+. Under +:path+ entity
|
|
500
|
+
# scoping the route helper is entity-prefixed and takes the tenant as its
|
|
501
|
+
# first argument — +org_runs_path(org, run)+, not +runs_path(run)+ — and
|
|
502
|
+
# +url_for(record)+ derives the helper from the record's own route_key, so
|
|
503
|
+
# it cannot know about the prefix. Passing the bare record would therefore
|
|
504
|
+
# raise for the common entity-scoped case.
|
|
505
|
+
#
|
|
506
|
+
# Override to send them somewhere else.
|
|
507
|
+
#
|
|
508
|
+
# @return [String]
|
|
509
|
+
def dispatch_redirect_target(run)
|
|
510
|
+
controller = view_context.controller
|
|
511
|
+
|
|
512
|
+
controller.url_from(controller.params[:return_to]) ||
|
|
513
|
+
controller.helpers.resource_url_for(run)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
end
|