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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Definition
|
|
5
|
+
# Declarative display sectioning — the `display_layout` counterpart to
|
|
6
|
+
# {FormLayout}, applied to the show page's field grid instead of the
|
|
7
|
+
# form. Same shape (`section`/`ungrouped`, first-section-wins ownership,
|
|
8
|
+
# `condition:`) and the same resolution semantics, so a section/field
|
|
9
|
+
# author only has to learn the DSL once.
|
|
10
|
+
#
|
|
11
|
+
# Reuses {FormLayout}'s `Section`/`ResolvedSection` structs and its
|
|
12
|
+
# ownership-resolution algorithm ({FormLayout.resolve_sections}) rather
|
|
13
|
+
# than duplicating them — the algorithm has nothing form-specific about
|
|
14
|
+
# it, only the declaring macro and the render target differ.
|
|
15
|
+
#
|
|
16
|
+
# Deliberately does NOT support `columns:`. The display side already has
|
|
17
|
+
# a per-field width pathway — `display :x, wrapper: {class: "col-span-2"}`
|
|
18
|
+
# — and every section renders into the same responsive grid, so a second,
|
|
19
|
+
# section-level column mechanism would be a redundant way to say the same
|
|
20
|
+
# thing. Grouping is what this DSL is for; widths stay on the field. A
|
|
21
|
+
# `columns:` option raises rather than being silently dropped.
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# display_layout do
|
|
25
|
+
# section :identity, :name, :email, label: "Identification"
|
|
26
|
+
# section :address, :street, :city, collapsible: true,
|
|
27
|
+
# condition: -> { object.requires_address? }
|
|
28
|
+
# ungrouped label: "Other"
|
|
29
|
+
# end
|
|
30
|
+
module DisplayLayout
|
|
31
|
+
extend ActiveSupport::Concern
|
|
32
|
+
|
|
33
|
+
# Collects section/ungrouped calls from a display_layout block, in
|
|
34
|
+
# order. Builds the same Section struct FormLayout uses — the two
|
|
35
|
+
# layouts share a resolver, so they share a value type.
|
|
36
|
+
class Builder
|
|
37
|
+
attr_reader :sections
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@sections = []
|
|
41
|
+
@ungrouped_seen = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def section(key, *fields, **options)
|
|
45
|
+
if key == FormLayout::UNGROUPED_KEY
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"`section :#{FormLayout::UNGROUPED_KEY}` is reserved — use the `ungrouped` macro"
|
|
48
|
+
end
|
|
49
|
+
reject_columns!(options)
|
|
50
|
+
@sections << FormLayout::Section.new(key:, fields: fields.freeze, options: options.freeze)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ungrouped(**options)
|
|
54
|
+
raise ArgumentError, "`ungrouped` may only be declared once" if @ungrouped_seen
|
|
55
|
+
@ungrouped_seen = true
|
|
56
|
+
reject_columns!(options)
|
|
57
|
+
@sections << FormLayout::Section.new(
|
|
58
|
+
key: FormLayout::UNGROUPED_KEY, fields: [].freeze, options: options.freeze
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# `columns:` is a form_layout option only. Raising (rather than
|
|
65
|
+
# ignoring it) means an author who copies a form_layout block across
|
|
66
|
+
# finds out immediately, instead of wondering why the column count
|
|
67
|
+
# had no effect.
|
|
68
|
+
def reject_columns!(options)
|
|
69
|
+
return unless options.key?(:columns)
|
|
70
|
+
raise ArgumentError,
|
|
71
|
+
"display_layout does not support `columns:` — set a width on the field instead, " \
|
|
72
|
+
"e.g. `display :x, wrapper: {class: \"col-span-2\"}`"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class_methods do
|
|
77
|
+
# Declare the display layout. Re-declaring replaces it as a unit.
|
|
78
|
+
def display_layout(&block)
|
|
79
|
+
raise ArgumentError, "`display_layout` requires a block" unless block
|
|
80
|
+
builder = Builder.new
|
|
81
|
+
builder.instance_exec(&block)
|
|
82
|
+
@defined_display_layout = builder.sections.freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Ordered Array<FormLayout::Section>, or nil when no layout was declared.
|
|
86
|
+
def defined_display_layout
|
|
87
|
+
@defined_display_layout
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def inherited(subclass)
|
|
91
|
+
super
|
|
92
|
+
subclass.instance_variable_set(:@defined_display_layout, defined_display_layout&.dup)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Instance access — the display render path holds a definition
|
|
97
|
+
# instance (mirrors the defineable_prop convention).
|
|
98
|
+
def defined_display_layout
|
|
99
|
+
self.class.defined_display_layout
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Resolve the policy-filtered field list into ordered ResolvedSections.
|
|
103
|
+
# Returns nil when no layout is declared (caller falls back to one grid).
|
|
104
|
+
def resolve_display_sections(resource_fields)
|
|
105
|
+
layout = defined_display_layout
|
|
106
|
+
return nil unless layout
|
|
107
|
+
|
|
108
|
+
FormLayout.resolve_sections(layout, resource_fields)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -135,17 +135,18 @@ module Plutonium
|
|
|
135
135
|
|
|
136
136
|
# Register each column's enter_interaction as an interactive record
|
|
137
137
|
# action too, so its form and params extraction exist and route the
|
|
138
|
-
# standard way. It is flagged `
|
|
139
|
-
#
|
|
140
|
-
# dropping a card. The key is column-scoped
|
|
141
|
-
# :<column>_enter_interaction), so it is
|
|
142
|
-
# never collides with another column's
|
|
143
|
-
# policy method of its own — the drop is
|
|
138
|
+
# standard way. It is flagged `hidden: true` so it is excluded from
|
|
139
|
+
# every render surface (toolbars, row dropdowns, cards, bulk bars) —
|
|
140
|
+
# it is reachable only by dropping a card. The key is column-scoped
|
|
141
|
+
# (enter_interaction_key → :<column>_enter_interaction), so it is
|
|
142
|
+
# unique by construction and never collides with another column's
|
|
143
|
+
# interaction. It carries NO policy method of its own — the drop is
|
|
144
|
+
# authorized by kanban_move?.
|
|
144
145
|
if col.enter_interaction?
|
|
145
146
|
action(
|
|
146
147
|
col.enter_interaction_key,
|
|
147
148
|
interaction: col.enter_interaction,
|
|
148
|
-
|
|
149
|
+
hidden: true
|
|
149
150
|
)
|
|
150
151
|
end
|
|
151
152
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Definition
|
|
5
|
+
# What an `as:` on an input / display declaration MEANS, independent of any
|
|
6
|
+
# view.
|
|
7
|
+
#
|
|
8
|
+
# `as:` is a union: either an alias naming a built-in tag (`:string`,
|
|
9
|
+
# `:uppy`, a String) OR a component Class rendered directly (see
|
|
10
|
+
# {Plutonium::UI::Component::ResolvesTags}). A Class has no `#to_sym`, so
|
|
11
|
+
# every "which alias is this?" question goes through {resolve} rather than
|
|
12
|
+
# coercing `as` itself — the controller, the wizard, the generators and the
|
|
13
|
+
# views all ask here.
|
|
14
|
+
module InputAliases
|
|
15
|
+
# The `as:` values that render through the Uppy file-upload component —
|
|
16
|
+
# the single source of truth, so the form builder's tag aliases and the
|
|
17
|
+
# attachment detection in {Plutonium::Wizard::Attachments.field?} and
|
|
18
|
+
# `Plutonium::Resource::Controller#attachment_input_keys` never drift.
|
|
19
|
+
FILE_INPUT_TYPES = %i[uppy file attachment].freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# @param as [Symbol, String, Class, nil] a declared `as:`.
|
|
23
|
+
# @return [Symbol, nil] the Symbol form of a symbol/string `as:`; nil for
|
|
24
|
+
# anything else, a component Class included, since it names no alias.
|
|
25
|
+
# The Symbol is NOT validated against the tags that actually exist —
|
|
26
|
+
# callers compare it against the aliases they care about.
|
|
27
|
+
def resolve(as)
|
|
28
|
+
as.to_sym if as.is_a?(Symbol) || as.is_a?(String)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Whether an `as:` renders as an attachment/file input.
|
|
32
|
+
def file_input?(as)
|
|
33
|
+
FILE_INPUT_TYPES.include?(resolve(as))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Definition
|
|
5
|
+
# Per-resource width for detail-style pages. `page_width` sets both the
|
|
6
|
+
# form and the show page; `form_width` / `display_width` override one
|
|
7
|
+
# surface without disturbing the other.
|
|
8
|
+
#
|
|
9
|
+
# Plutonium.configure { |c| c.default_page_width = :md } # global
|
|
10
|
+
#
|
|
11
|
+
# class PostDefinition < ResourceDefinition
|
|
12
|
+
# page_width :lg # this resource's form AND show page
|
|
13
|
+
# display_width :full # ...except the show page, which goes full
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Resolution, most specific first: the surface-specific setting, then
|
|
17
|
+
# `page_width`, then `Plutonium.configuration.default_page_width`. All
|
|
18
|
+
# three inherit to subclasses (so a portal-specific definition keeps its
|
|
19
|
+
# parent's width unless it says otherwise), which is why these are
|
|
20
|
+
# `inheritable_config_attr` rather than plain class attributes.
|
|
21
|
+
module PageWidths
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
# `inheritable_config_attr` appends the `_config` suffix itself and
|
|
25
|
+
# defines the bare name as a singleton method on the class. A
|
|
26
|
+
# `class_methods` module would sit BEHIND that in the ancestor chain and
|
|
27
|
+
# never be called, so the validating versions are defined here, after
|
|
28
|
+
# generation, to replace it — the same ordering `show_in` relies on.
|
|
29
|
+
included do
|
|
30
|
+
inheritable_config_attr :page_width, :form_width, :display_width
|
|
31
|
+
|
|
32
|
+
# Reader with no argument, validated writer with one, so an unknown
|
|
33
|
+
# token raises at declaration rather than silently rendering at some
|
|
34
|
+
# other width.
|
|
35
|
+
%i[page_width form_width display_width].each do |name|
|
|
36
|
+
define_singleton_method(name) do |value = :__not_set__|
|
|
37
|
+
return public_send(:"#{name}_config") if value == :__not_set__
|
|
38
|
+
|
|
39
|
+
public_send(:"#{name}_config=", Plutonium::UI::PageWidth.validate!(value))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The resolved token for each surface. Nil-coalescing rather than
|
|
45
|
+
# `||`-on-a-boolean, so an explicit `:full` (a legitimate choice) is
|
|
46
|
+
# honoured instead of being treated as "unset".
|
|
47
|
+
def resolved_form_width
|
|
48
|
+
self.class.form_width_config ||
|
|
49
|
+
self.class.page_width_config ||
|
|
50
|
+
Plutonium.configuration.default_page_width
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resolved_display_width
|
|
54
|
+
self.class.display_width_config ||
|
|
55
|
+
self.class.page_width_config ||
|
|
56
|
+
Plutonium.configuration.default_page_width
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The classes to put on the surface, or nil for `:full`.
|
|
60
|
+
def form_width_classes = Plutonium::UI::PageWidth.classes_for(resolved_form_width)
|
|
61
|
+
|
|
62
|
+
def display_width_classes = Plutonium::UI::PageWidth.classes_for(resolved_display_width)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "plutonium/positioning/config"
|
|
4
|
+
|
|
5
|
+
module Plutonium
|
|
6
|
+
module Definition
|
|
7
|
+
# Declares a resource drag-orderable. The MODEL owns how positions are
|
|
8
|
+
# stored (`positioned_on :sort_order, scope: :project_id`); this only says
|
|
9
|
+
# "this UI can be reordered", and never restates the column or the scope.
|
|
10
|
+
#
|
|
11
|
+
# position_on # Mode A, follows the model's column
|
|
12
|
+
# position_on :sort_order # Mode A, must MATCH the model's column
|
|
13
|
+
# position_on(:rank) { |move| … } # Mode B, another gem owns the write
|
|
14
|
+
# position_on false # Mode C, ordering off
|
|
15
|
+
#
|
|
16
|
+
# Mirrors kanban's `position_on` (lib/plutonium/kanban/dsl.rb) exactly, and
|
|
17
|
+
# builds the same Plutonium::Positioning::Config. Deliberately the same verb
|
|
18
|
+
# rather than a third one: the model says `positioned_on`, every UI layer
|
|
19
|
+
# says `position_on`, and a kanban board with no `position_on` of its own
|
|
20
|
+
# inherits the definition's (see Kanban::Board#position_config_for).
|
|
21
|
+
module Positioning
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
included do
|
|
25
|
+
class_attribute :defined_position_config, instance_accessor: false, default: nil
|
|
26
|
+
|
|
27
|
+
# `attribute` defaults to nil, not :position — in Mode A the model has
|
|
28
|
+
# already named its column, and assuming :position here would silently
|
|
29
|
+
# order by one column while reposition! wrote another. Mode B has no
|
|
30
|
+
# model contract to read, so it keeps :position as its own default.
|
|
31
|
+
def self.position_on(attribute = nil, &block)
|
|
32
|
+
config =
|
|
33
|
+
if attribute == false
|
|
34
|
+
Plutonium::Positioning::Config.disabled
|
|
35
|
+
elsif block
|
|
36
|
+
Plutonium::Positioning::Config.with_block(attribute || :position, block)
|
|
37
|
+
else
|
|
38
|
+
validate_model_is_positioned!(attribute)
|
|
39
|
+
Plutonium::Positioning::Config.attribute(attribute || model_class.positioning_column)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
self.defined_position_config = config
|
|
43
|
+
return config if config.disabled?
|
|
44
|
+
|
|
45
|
+
# Registering the sort is load-bearing: dragging is only permitted
|
|
46
|
+
# when the collection is ordered by this attribute, so without a
|
|
47
|
+
# permitted sort there is no way back out of the disabled state.
|
|
48
|
+
sort config.attribute
|
|
49
|
+
|
|
50
|
+
# Only claim default_sort while nobody has chosen one. A `default_sort`
|
|
51
|
+
# written ABOVE position_on must survive exactly as one written below
|
|
52
|
+
# it does — this method is otherwise the single order-dependent line in
|
|
53
|
+
# a class body that is order-independent everywhere else (see
|
|
54
|
+
# Board#position_config_for).
|
|
55
|
+
#
|
|
56
|
+
# `equal?`, not `==`: DEFAULT_SORT is a sentinel, and `default_sort
|
|
57
|
+
# :id, :desc` builds a value-equal but distinct array. Comparing by
|
|
58
|
+
# value would read that explicit choice as "untouched" and silently
|
|
59
|
+
# overwrite it.
|
|
60
|
+
#
|
|
61
|
+
# NOTE this respects an inherited choice too: a base definition with
|
|
62
|
+
# its own `default_sort` means every resource under it renders the grip
|
|
63
|
+
# in the disabled state until the user sorts by position. That is the
|
|
64
|
+
# intended precedence — the app asked for that ordering — and it is
|
|
65
|
+
# documented in docs/reference/positioning.md.
|
|
66
|
+
default_sort config.attribute, :asc if _default_sort.equal?(Sorting::DEFAULT_SORT)
|
|
67
|
+
|
|
68
|
+
# Hidden: it has a POST member route (routing/mapper_extensions.rb) and
|
|
69
|
+
# a `reposition?` policy predicate (resource/policy.rb), but is
|
|
70
|
+
# reachable only by dragging — never rendered as a button.
|
|
71
|
+
action :reposition, hidden: true
|
|
72
|
+
|
|
73
|
+
config
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Mode A calls record.reposition! AND reads `.rebalanced?` off its
|
|
77
|
+
# return, so it needs the real concern — not merely something that
|
|
78
|
+
# responds to reposition!. A model that hand-rolls reposition!
|
|
79
|
+
# (wrapping acts_as_list, say) would otherwise fail at drop time,
|
|
80
|
+
# mid-transaction, with `NoMethodError: undefined method 'rebalanced?'`.
|
|
81
|
+
#
|
|
82
|
+
# This check lives HERE rather than in Config#reposition! for two
|
|
83
|
+
# reasons: the house rule against defensive call-site guards, and
|
|
84
|
+
# config.rb's standalone-loadability invariant — naming
|
|
85
|
+
# Plutonium::Positioning::Model there would pull in the concern and
|
|
86
|
+
# break the guard test in test/plutonium/positioning/config_test.rb.
|
|
87
|
+
# The DSL is where the mode is chosen, so it is where the contract belongs.
|
|
88
|
+
def self.validate_model_is_positioned!(attribute)
|
|
89
|
+
declaration = attribute ? "position_on #{attribute.inspect}" : "position_on"
|
|
90
|
+
|
|
91
|
+
unless model_class.include?(Plutonium::Positioning::Model)
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"#{name || "definition"}: `#{declaration}` requires #{model_class} to " \
|
|
94
|
+
"`include Plutonium::Positioning::Model` and declare `positioned_on`. " \
|
|
95
|
+
"If another gem owns positioning for this model, use the block form " \
|
|
96
|
+
"instead: position_on(#{(attribute || :position).inspect}) { |move| … }"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Including the concern is not enough: without `positioned_on` there is
|
|
100
|
+
# no before_create hook, so every row is created with a nil position and
|
|
101
|
+
# the resulting order is arbitrary — the silent failure this class-load
|
|
102
|
+
# check exists to prevent. positioning_column has a default and so
|
|
103
|
+
# cannot answer this question; positioning_declared can.
|
|
104
|
+
unless model_class.positioning_declared
|
|
105
|
+
raise ArgumentError,
|
|
106
|
+
"#{name || "definition"}: `#{declaration}` requires #{model_class} to declare " \
|
|
107
|
+
"`positioned_on` — including Plutonium::Positioning::Model alone never " \
|
|
108
|
+
"assigns a position on create, so every row would sort arbitrarily."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
return if attribute.nil? || attribute.to_sym == model_class.positioning_column.to_sym
|
|
112
|
+
|
|
113
|
+
raise ArgumentError,
|
|
114
|
+
"#{name || "definition"}: `#{declaration}` contradicts #{model_class}'s " \
|
|
115
|
+
"`positioned_on #{model_class.positioning_column.inspect}` — the collection would " \
|
|
116
|
+
"be ordered by one column while reposition! wrote another, so dragging would " \
|
|
117
|
+
"appear to do nothing. Drop the argument to follow the model, or use the block " \
|
|
118
|
+
"form if another gem owns the write."
|
|
119
|
+
end
|
|
120
|
+
private_class_method :validate_model_is_positioned!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def defined_position_config = self.class.defined_position_config
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -3,6 +3,17 @@ module Plutonium
|
|
|
3
3
|
module Sorting
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
|
+
# The framework-wide default sort: newest first.
|
|
7
|
+
#
|
|
8
|
+
# A SENTINEL, compared with `equal?` — see Definition::Positioning, which
|
|
9
|
+
# only claims default_sort while nobody has chosen one. `==` cannot answer
|
|
10
|
+
# that question: `default_sort :id, :desc` builds a fresh `[field,
|
|
11
|
+
# direction]` array that is value-equal to this one, so an app that
|
|
12
|
+
# explicitly asked for newest-first would have its choice silently
|
|
13
|
+
# overwritten. Identity distinguishes them, which is why the installation
|
|
14
|
+
# below assigns this exact object rather than going through `default_sort`.
|
|
15
|
+
DEFAULT_SORT = [:id, :desc].freeze
|
|
16
|
+
|
|
6
17
|
included do
|
|
7
18
|
defineable_props :sort
|
|
8
19
|
|
|
@@ -21,8 +32,12 @@ module Plutonium
|
|
|
21
32
|
_default_sort
|
|
22
33
|
end
|
|
23
34
|
|
|
24
|
-
# Set a sensible default: newest items first
|
|
25
|
-
default_sort
|
|
35
|
+
# Set a sensible default: newest items first. Assigned directly rather
|
|
36
|
+
# than through `default_sort` so `_default_sort` holds the SENTINEL
|
|
37
|
+
# object itself — that identity is what tells a later `position_on` the
|
|
38
|
+
# sort is still untouched. Equivalent otherwise: `default_sort :id,
|
|
39
|
+
# :desc` would store a value-equal `[:id, :desc]`.
|
|
40
|
+
self._default_sort = DEFAULT_SORT
|
|
26
41
|
end
|
|
27
42
|
|
|
28
43
|
def default_sort
|
|
@@ -16,6 +16,13 @@ module Plutonium
|
|
|
16
16
|
# modal frame specifically.
|
|
17
17
|
def in_secondary_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_SECONDARY_FRAME
|
|
18
18
|
|
|
19
|
+
# True when this show request was opened from a kanban card in the modal
|
|
20
|
+
# frame — the card tags its show URL with KANBAN_MODAL_PARAM. Gated on
|
|
21
|
+
# in_modal? so a stray param on a full-page show never hides metadata.
|
|
22
|
+
# Lets the show page drop the metadata rail for kanban cards specifically,
|
|
23
|
+
# leaving regular show modals (and full-page shows) untouched.
|
|
24
|
+
def in_kanban_modal? = in_modal? && params[Plutonium::KANBAN_MODAL_PARAM].present?
|
|
25
|
+
|
|
19
26
|
# Returns a turbo-frame-scoped element id. Two identically-named forms
|
|
20
27
|
# can be on the page simultaneously (e.g. a primary modal opens a
|
|
21
28
|
# secondary modal, each rendering an `id="resource-form"`). When the
|
|
@@ -14,24 +14,29 @@
|
|
|
14
14
|
|
|
15
15
|
## Introduction
|
|
16
16
|
|
|
17
|
-
Interactions
|
|
18
|
-
It builds upon the traditional MVC pattern by introducing additional layers that encapsulate business logic and improve separation of concerns.
|
|
17
|
+
Interactions organize code around the actions a user can take. Each one is the entry point from a page into a single, well-defined operation: it declares the inputs, renders as a button and a form, is gated by a policy, and hands back an outcome the controller turns into a message and a redirect.
|
|
19
18
|
|
|
20
19
|
### Key Benefits
|
|
21
20
|
|
|
22
|
-
-
|
|
23
|
-
-
|
|
21
|
+
- A uniform surface for custom operations: button, form, authorization, messaging, redirect
|
|
22
|
+
- Clear separation of the *request* from the controller
|
|
24
23
|
- Consistent handling of success and failure cases
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
- Improved code organization and discoverability of business logic
|
|
24
|
+
- Input validation that renders as form errors
|
|
25
|
+
- Improved discoverability — every custom operation is a named class in `app/interactions/`
|
|
28
26
|
|
|
29
27
|
## Key Concepts
|
|
30
28
|
|
|
31
29
|
### Interactions
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
An interaction is a **presentation object**. It owns the button, the form, the input-shape validation, and the user-facing outcome.
|
|
32
|
+
|
|
33
|
+
It is *not* where a domain operation should end up living. An interaction cannot be constructed without a `view_context:`, so anything reachable only through one is reachable only from a page. Logic may **start** in `execute` — a one-off with a single caller is fine there, and pre-extracting is YAGNI. The trigger to extract is the **second caller**: a background job, an API controller, a rake task, the console, or another interaction. At that point the behaviour moves onto the **model** (fat models, per Rails convention — not a new service layer), and the interaction shrinks to a call to a well-named method.
|
|
34
|
+
|
|
35
|
+
`view_context` is the tell: if a caller would need one purely to reach some behaviour, that behaviour is on the wrong side of the boundary.
|
|
36
|
+
|
|
37
|
+
Validations split along the same line. **Interaction validations check input shape** — is it present, does it parse, is it the right type — and exist to render form errors. **Model validations are business invariants** and must hold regardless of who is calling.
|
|
38
|
+
|
|
39
|
+
Full explanation: [Reference › Behavior › Interactions](https://radioactive-labs.github.io/plutonium-core/reference/behavior/interactions).
|
|
35
40
|
|
|
36
41
|
### Outcomes
|
|
37
42
|
|
|
@@ -99,7 +104,7 @@ We ship with these out of the box:
|
|
|
99
104
|
.with_response(Response::Redirect.new(user_path(user)))
|
|
100
105
|
.with_message("User was successfully created.")
|
|
101
106
|
else
|
|
102
|
-
|
|
107
|
+
failed(user.errors)
|
|
103
108
|
end
|
|
104
109
|
end
|
|
105
110
|
end
|
|
@@ -159,26 +164,55 @@ else
|
|
|
159
164
|
end
|
|
160
165
|
```
|
|
161
166
|
|
|
162
|
-
|
|
167
|
+
### Composing outcomes — `and_then`
|
|
168
|
+
|
|
169
|
+
`and_then` composes `Outcome`s. On a `Success` it yields **the value** (not the outcome) and returns whatever the block returns; on a `Failure` it short-circuits, returning the failure untouched. It's useful for expressing a guard as an outcome inside one `execute`:
|
|
163
170
|
|
|
164
171
|
```ruby
|
|
165
172
|
def execute
|
|
166
|
-
|
|
167
|
-
.
|
|
168
|
-
|
|
169
|
-
|
|
173
|
+
unlocked_resource.and_then do |post|
|
|
174
|
+
post.publish!
|
|
175
|
+
succeed(post).with_message("Published")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def unlocked_resource
|
|
182
|
+
resource.locked? ? failed("This post is locked") : succeed(resource)
|
|
170
183
|
end
|
|
171
184
|
```
|
|
172
185
|
|
|
186
|
+
> **Don't use `and_then` to sequence business operations.** A chain of three interactions is a chain of three things that each demand a `view_context` — none of which a job or an API controller can supply. That's one model method wearing three costumes; see [Best Practices](#best-practices) below.
|
|
187
|
+
|
|
173
188
|
## Best Practices
|
|
174
189
|
|
|
175
190
|
1. Keep interactions focused on a single responsibility
|
|
176
191
|
2. Use meaningful names for interactions that describe the action being performed
|
|
177
|
-
3.
|
|
178
|
-
4.
|
|
192
|
+
3. **Put the operation on the model as soon as a second caller needs it.** Logic may start in `execute`; a job, API controller, rake task, or console session wanting the same behaviour is the trigger to extract. Name the model method in domain language (`publish!`, `archive!`, `register!`), not persistence terms (`update_published_at`)
|
|
193
|
+
4. **Interaction validations check input shape; model validations enforce invariants.** The interaction's exist to render form errors — they don't run for any other caller
|
|
179
194
|
5. Use `with_response` to explicitly set the desired response type
|
|
180
195
|
6. Keep the interaction's core logic separate from response handling
|
|
181
196
|
|
|
197
|
+
### The chaining anti-pattern
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
# 🚫 Each link demands a view_context that has nothing to do with the work
|
|
201
|
+
CreateUserInteraction.call(view_context:, **user_params)
|
|
202
|
+
.and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
|
|
203
|
+
.and_then { |user| LogActivity.call(view_context:, user:) }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a seeds script also has to do — and neither has a view context. Give the model the operation instead:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# ✅
|
|
210
|
+
def execute
|
|
211
|
+
user = User.register!(**attributes) # welcome email + audit row live in here
|
|
212
|
+
succeed(user).with_message("Welcome aboard!")
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
182
216
|
## Testing
|
|
183
217
|
|
|
184
218
|
Interactions are easy to test in isolation. Here's an example using RSpec:
|
|
@@ -314,7 +348,7 @@ module Users
|
|
|
314
348
|
if user.save
|
|
315
349
|
success(user).with_message("User created successfully")
|
|
316
350
|
else
|
|
317
|
-
|
|
351
|
+
failed(user.errors)
|
|
318
352
|
end
|
|
319
353
|
end
|
|
320
354
|
end
|
|
@@ -345,25 +379,28 @@ module Orders
|
|
|
345
379
|
|
|
346
380
|
def find_user(user_id)
|
|
347
381
|
user = User.find_by(id: user_id)
|
|
348
|
-
user ? success(user) :
|
|
382
|
+
user ? success(user) : failed(["User not found"])
|
|
349
383
|
end
|
|
350
384
|
|
|
351
385
|
def find_products(user, product_ids)
|
|
352
386
|
products = Product.where(id: product_ids.split(','))
|
|
353
|
-
products.empty? ?
|
|
387
|
+
products.empty? ? failed(["No valid products found"]) : success([user, products])
|
|
354
388
|
end
|
|
355
389
|
|
|
356
390
|
def create_order(user, products)
|
|
357
|
-
|
|
358
|
-
|
|
391
|
+
# Order.place! owns the domain work — inventory, totals, confirmation mail.
|
|
392
|
+
# The interaction only resolves the inputs and shapes the outcome.
|
|
393
|
+
success(Order.place!(user: user, products: products))
|
|
394
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
395
|
+
failed(e.record.errors)
|
|
359
396
|
end
|
|
360
397
|
end
|
|
361
398
|
end
|
|
362
399
|
```
|
|
363
400
|
|
|
364
|
-
|
|
401
|
+
Every link here is a **private method of this one interaction** resolving an input, not a separate interaction — so nothing in the chain needs a `view_context` of its own, and `Order.place!` stays callable from a job or an API. That's the shape `and_then` is for: composing outcomes inside a single `execute`, not sequencing business operations across interactions.
|
|
365
402
|
|
|
366
|
-
|
|
403
|
+
Note also how `with_response` and `with_message` set the response and attach a message to the outcome.
|
|
367
404
|
|
|
368
405
|
<!--
|
|
369
406
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Interaction
|
|
5
|
+
module Async
|
|
6
|
+
# Configuration for async interactions. Mirrors
|
|
7
|
+
# Plutonium::Wizard::Configuration: `enabled` gates both the subsystem and
|
|
8
|
+
# its migrations (see Plutonium::Migrations).
|
|
9
|
+
class Configuration
|
|
10
|
+
# @return [Boolean] whether runs (and their migrations) are enabled
|
|
11
|
+
attr_accessor :enabled
|
|
12
|
+
|
|
13
|
+
# @return [Symbol] ActiveJob queue for run jobs
|
|
14
|
+
attr_accessor :queue
|
|
15
|
+
|
|
16
|
+
# @return [ActiveSupport::Duration] how long a run may sit with no
|
|
17
|
+
# progress write before ReapJob considers it stalled
|
|
18
|
+
attr_accessor :stall_after
|
|
19
|
+
|
|
20
|
+
# @return [Symbol, nil] the storage backend used to stage an attachment
|
|
21
|
+
# attribute into the run's options — `:active_storage` or `:shrine`.
|
|
22
|
+
# `nil` falls back to `config.attachment_backend`, then to auto-detection.
|
|
23
|
+
#
|
|
24
|
+
# A file cannot ride the options column: it is JSON, and the request's
|
|
25
|
+
# tempfile is gone by the time the job runs. So an attachment attribute is
|
|
26
|
+
# uploaded to the backend's cache at dispatch and carried as its token.
|
|
27
|
+
# This is the knob for which backend does that, for runs alone.
|
|
28
|
+
attr_accessor :attachment_backend
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@enabled = false
|
|
32
|
+
@queue = :default
|
|
33
|
+
@stall_after = 1.hour
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|