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
|
@@ -32,7 +32,11 @@ module Plutonium
|
|
|
32
32
|
header_cell_link: "flex items-center gap-1 cursor-pointer hover:text-[var(--pu-text)]",
|
|
33
33
|
|
|
34
34
|
# Body
|
|
35
|
-
|
|
35
|
+
# `group/row` is what lets the drag grip reveal itself on row hover.
|
|
36
|
+
# NAMED, not the bare `group`: header cells already use an unnamed
|
|
37
|
+
# group for their sort indicator, and a future nested group inside a
|
|
38
|
+
# cell would silently capture `group-hover:` from anything below it.
|
|
39
|
+
body_row: "pu-table-body-row group/row",
|
|
36
40
|
body_cell: "pu-table-body-cell whitespace-pre max-w-[450px] overflow-hidden text-ellipsis",
|
|
37
41
|
|
|
38
42
|
# Sorting
|
|
@@ -49,7 +53,60 @@ module Plutonium
|
|
|
49
53
|
column_menu_panel: "hidden absolute right-0 top-full mt-1 z-50 min-w-[180px] bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-md shadow-lg p-1",
|
|
50
54
|
column_menu_item: "flex items-center gap-2 px-2 py-1.5 text-sm text-[var(--pu-text)] rounded hover:bg-[var(--pu-surface-alt)] w-full",
|
|
51
55
|
column_menu_item_disabled: "flex items-center gap-2 px-2 py-1.5 text-sm text-[var(--pu-text-subtle)] opacity-60 cursor-not-allowed",
|
|
52
|
-
column_menu_separator: "my-1 border-t border-[var(--pu-border)]"
|
|
56
|
+
column_menu_separator: "my-1 border-t border-[var(--pu-border)]",
|
|
57
|
+
|
|
58
|
+
# Drag-to-reorder grip (see Components::DragHandle)
|
|
59
|
+
#
|
|
60
|
+
# `-ml-5` cancels `w-5` exactly, so the grip sits in the cell's
|
|
61
|
+
# existing left padding and costs the content no horizontal space.
|
|
62
|
+
#
|
|
63
|
+
# Hidden until the row is hovered — but ALWAYS visible on focus. A
|
|
64
|
+
# keyboard user cannot hover, and an invisible tab stop that moves
|
|
65
|
+
# rows when you press an arrow key is worse than no affordance at all.
|
|
66
|
+
drag_handle_cell: "flex items-center",
|
|
67
|
+
drag_handle: "-ml-5 mr-1.5 w-5 h-5 shrink-0 inline-flex items-center justify-center rounded " \
|
|
68
|
+
"text-[var(--pu-text-subtle)] hover:text-[var(--pu-text)] " \
|
|
69
|
+
"opacity-0 group-hover/row:opacity-100 focus:opacity-100 " \
|
|
70
|
+
"transition-opacity cursor-grab active:cursor-grabbing " \
|
|
71
|
+
"focus:outline-none focus:ring-2 focus:ring-[var(--pu-input-focus-ring)]",
|
|
72
|
+
# Dimmer than the live grip, and a pointer rather than a grab cursor:
|
|
73
|
+
# it looks like the same control, switched off, and clicking it is
|
|
74
|
+
# what switches it back on.
|
|
75
|
+
drag_handle_disabled: "-ml-5 mr-1.5 w-5 h-5 shrink-0 inline-flex items-center justify-center rounded " \
|
|
76
|
+
"text-[var(--pu-text-subtle)] " \
|
|
77
|
+
"opacity-0 group-hover/row:opacity-40 hover:opacity-100 focus:opacity-100 " \
|
|
78
|
+
"transition-opacity cursor-pointer " \
|
|
79
|
+
"focus:outline-none focus:ring-2 focus:ring-[var(--pu-input-focus-ring)]",
|
|
80
|
+
|
|
81
|
+
# The card-grid placement of the same grip (Grid::Card).
|
|
82
|
+
#
|
|
83
|
+
# Same principle as the row grip — live in padding the card already
|
|
84
|
+
# had, cost the content nothing — but expressed as a full-height rail
|
|
85
|
+
# rather than a negative margin, because a card's content is a
|
|
86
|
+
# multi-line flex column rather than one cell. `w-4` is exactly the
|
|
87
|
+
# `p-4` left padding of both card layouts, so the rail fills the
|
|
88
|
+
# gutter and stops precisely where the text begins. A floating corner
|
|
89
|
+
# button was tried first and sat on top of the card title.
|
|
90
|
+
#
|
|
91
|
+
# It still carries a translucent surface + blur: in the :media layout
|
|
92
|
+
# the cover image runs edge to edge, so the gutter is only empty in
|
|
93
|
+
# the compact layout.
|
|
94
|
+
#
|
|
95
|
+
# `group/card`, not `group/row`: the card names its own hover group
|
|
96
|
+
# so nesting a card inside a table (or vice versa) can never have one
|
|
97
|
+
# reveal the other's grip.
|
|
98
|
+
drag_handle_card: "absolute left-0 top-0 bottom-0 z-20 w-4 inline-flex items-center justify-center " \
|
|
99
|
+
"bg-[var(--pu-surface-alt)]/70 backdrop-blur-sm " \
|
|
100
|
+
"text-[var(--pu-text-subtle)] hover:text-[var(--pu-text)] " \
|
|
101
|
+
"opacity-0 group-hover/card:opacity-100 focus:opacity-100 " \
|
|
102
|
+
"transition-opacity cursor-grab active:cursor-grabbing " \
|
|
103
|
+
"focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[var(--pu-input-focus-ring)]",
|
|
104
|
+
drag_handle_card_disabled: "absolute left-0 top-0 bottom-0 z-20 w-4 inline-flex items-center justify-center " \
|
|
105
|
+
"bg-[var(--pu-surface-alt)]/70 backdrop-blur-sm " \
|
|
106
|
+
"text-[var(--pu-text-subtle)] " \
|
|
107
|
+
"opacity-0 group-hover/card:opacity-40 hover:opacity-100 focus:opacity-100 " \
|
|
108
|
+
"transition-opacity cursor-pointer " \
|
|
109
|
+
"focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[var(--pu-input-focus-ring)]"
|
|
53
110
|
})
|
|
54
111
|
end
|
|
55
112
|
end
|
|
@@ -114,11 +114,13 @@ module Plutonium
|
|
|
114
114
|
div(class: "px-5 py-4") do
|
|
115
115
|
# Decorate so attachment fields resolve to displayable attachments —
|
|
116
116
|
# the SummaryDisplay then renders them through the normal attachment
|
|
117
|
-
# display component, not the raw token string.
|
|
117
|
+
# display component, not the raw token string. (Choice fields need no
|
|
118
|
+
# decorator: the summary resolves their labels per field from the
|
|
119
|
+
# `inputs` it is already handed.)
|
|
118
120
|
if fields.any?
|
|
119
121
|
render SummaryDisplay.new(
|
|
120
122
|
Plutonium::Wizard::AttachmentData.wrap(step_data(step), step),
|
|
121
|
-
fields:, inputs: step.inputs
|
|
123
|
+
fields:, inputs: step.inputs, wizard: @runner.wizard
|
|
122
124
|
)
|
|
123
125
|
end
|
|
124
126
|
render_structured(step) if structured.any?
|
|
@@ -14,13 +14,20 @@ module Plutonium
|
|
|
14
14
|
# @param inputs [Hash] the step's input config ({name => {options:}}), so a
|
|
15
15
|
# field's declared `as:` informs the display component (e.g. a `:text`
|
|
16
16
|
# input renders via the markdown/text display tag).
|
|
17
|
-
def initialize(object, fields:, inputs: {}, **options)
|
|
17
|
+
def initialize(object, fields:, inputs: {}, wizard: nil, **options)
|
|
18
18
|
options[:key] = :wizard
|
|
19
19
|
@summary_fields = fields
|
|
20
20
|
@summary_inputs = inputs
|
|
21
|
+
@wizard = wizard
|
|
21
22
|
super(object, **options)
|
|
22
23
|
end
|
|
23
24
|
|
|
25
|
+
# The wizard driving this run. Present for the same reason the step form
|
|
26
|
+
# exposes it: an `->(form) { form.wizard… }` option asks the form for it,
|
|
27
|
+
# and on the review page this component stands in for the form.
|
|
28
|
+
# @return [Plutonium::Wizard::Base, nil]
|
|
29
|
+
attr_reader :wizard
|
|
30
|
+
|
|
24
31
|
def display_template
|
|
25
32
|
dl(class: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3") do
|
|
26
33
|
@summary_fields.each { |name| render_summary_field(name) }
|
|
@@ -37,7 +44,7 @@ module Plutonium
|
|
|
37
44
|
# has_cents reflection, so inference would render it as a bare number.
|
|
38
45
|
# Force the currency display and thread the declared `unit:` so the
|
|
39
46
|
# recap reads "$1,234.56", matching the input's prefix.
|
|
40
|
-
if input_options[:as]
|
|
47
|
+
if Plutonium::Definition::InputAliases.resolve(input_options[:as]) == :currency
|
|
41
48
|
return render field(name, **field_options).wrapped { |f|
|
|
42
49
|
render f.send(:create_component, Plutonium::UI::Display::Components::Currency, :currency, unit: input_options[:unit])
|
|
43
50
|
}
|
|
@@ -58,17 +65,41 @@ module Plutonium
|
|
|
58
65
|
end
|
|
59
66
|
|
|
60
67
|
# Map the raw value(s) to their choice label(s) using the SAME mapper the
|
|
61
|
-
# form's select uses, so summary labels always match the form. Falls back
|
|
62
|
-
#
|
|
63
|
-
#
|
|
68
|
+
# form's select uses, so summary labels always match the form. Falls back to
|
|
69
|
+
# the raw value for anything not in `choices:`; nil for an empty field (so
|
|
70
|
+
# the display renders its placeholder).
|
|
71
|
+
#
|
|
72
|
+
# The mapper is built ONCE per field, not once per value: `choices:` is
|
|
73
|
+
# routinely a proc (`-> { Member.all.map { |m| [m.name, m.id] } }`), and a
|
|
74
|
+
# per-value mapper would re-run that query for every element of a
|
|
75
|
+
# multi-select.
|
|
76
|
+
#
|
|
64
77
|
def resolve_choice_label(name, input_options)
|
|
78
|
+
values = Array(Phlexi::Field::Support::Value.from(object, name))
|
|
79
|
+
return if values.empty?
|
|
80
|
+
|
|
65
81
|
mapper = Phlexi::Form::SimpleChoicesMapper.new(
|
|
66
|
-
input_options[:choices],
|
|
82
|
+
resolved_choices(input_options[:choices]),
|
|
67
83
|
label_method: input_options[:label_method],
|
|
68
84
|
value_method: input_options[:value_method]
|
|
69
85
|
)
|
|
70
|
-
|
|
71
|
-
|
|
86
|
+
values.map { |value| mapper[value] || value }.join(", ").presence
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Resolve a proc'd `choices:` by the SAME arity rule the form applies to
|
|
90
|
+
# every input option (see Form::Resource#call_option_proc): zero-arity means
|
|
91
|
+
# what it reads like where it was written; `->(form) { … }` is asking for
|
|
92
|
+
# the form, and on the review page this component stands in for it — it
|
|
93
|
+
# answers `object` (the step's staged data) and `wizard`.
|
|
94
|
+
#
|
|
95
|
+
# Without this the summary hands the raw proc to the mapper, which bare-
|
|
96
|
+
# `call`s it — so `choices: ->(form) { form.wizard.anchor.available_tiers }`,
|
|
97
|
+
# a declaration Form::Wizard documents and the step form renders happily,
|
|
98
|
+
# would raise ArgumentError the moment the user reached review.
|
|
99
|
+
def resolved_choices(choices)
|
|
100
|
+
return choices unless choices.is_a?(Proc)
|
|
101
|
+
|
|
102
|
+
choices.arity.zero? ? choices.call : choices.call(self)
|
|
72
103
|
end
|
|
73
104
|
|
|
74
105
|
# Pick the display component the same way a resource display does — infer it
|
|
@@ -78,12 +109,9 @@ module Plutonium
|
|
|
78
109
|
# data object is decorated upstream to resolve the token to an attachment).
|
|
79
110
|
def summary_tag_block(name)
|
|
80
111
|
->(f) {
|
|
81
|
-
|
|
82
|
-
if
|
|
83
|
-
|
|
84
|
-
else
|
|
85
|
-
f.send(:"#{tag}_tag")
|
|
86
|
-
end
|
|
112
|
+
# nil lets the builder infer the tag from the value's type.
|
|
113
|
+
tag = :attachment if Plutonium::Wizard::Attachments.field?(@summary_inputs[name])
|
|
114
|
+
f.component_for(tag)
|
|
87
115
|
}
|
|
88
116
|
end
|
|
89
117
|
end
|
data/lib/plutonium/version.rb
CHANGED
|
@@ -2,224 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
module Plutonium
|
|
4
4
|
module Wizard
|
|
5
|
-
#
|
|
6
|
-
# `Uppy` input and `Display::Components::Attachment` render — **model-free and
|
|
7
|
-
# backend-agnostic**, for a bare `attribute :photo, :string` + `input :photo,
|
|
8
|
-
# as: :uppy, direct_upload: true` field (no `using:` model needed).
|
|
5
|
+
# A wizard's view of {Plutonium::Attachments}.
|
|
9
6
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# an AS signed_id doesn't — so we revive each through its own backend with no
|
|
15
|
-
# model and no per-field configuration.
|
|
7
|
+
# The staging and revival machinery moved out when async interactions needed
|
|
8
|
+
# the same thing: both hold an attachment as a bare string while the work is
|
|
9
|
+
# in flight, and neither has a model to hang it off until the work lands.
|
|
10
|
+
# None of that was ever wizard-specific — only the two things left here are.
|
|
16
11
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# (`filename`/`content_type` vs `original_filename`/`mime_type`), so a resolved
|
|
21
|
-
# token is wrapped in {Resolved}, a uniform view exposing exactly what the
|
|
22
|
-
# display + preview components call. See the wizard-attachments design spec.
|
|
12
|
+
# This stays the wizard's entry point rather than having wizard code reach
|
|
13
|
+
# for the shared module directly, so the +backend:+ default keeps being the
|
|
14
|
+
# wizard's own setting without every call site remembering to pass it.
|
|
23
15
|
module Attachments
|
|
24
|
-
|
|
16
|
+
# Kept as a constant here because it is part of this module's published
|
|
17
|
+
# surface: a host rendering a staged attachment may name
|
|
18
|
+
# +Wizard::Attachments::Resolved+.
|
|
19
|
+
Resolved = Plutonium::Attachments::Resolved
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
# {Resolved} view(s).
|
|
28
|
-
#
|
|
29
|
-
# @param value [String, Array, nil] the staged token(s).
|
|
30
|
-
# @return [Array<Resolved>] resolved attachments; blank, tampered, or
|
|
31
|
-
# unrecognized tokens are dropped (never raised), so a bad token can't 500
|
|
32
|
-
# the form or the review.
|
|
33
|
-
def resolve(value)
|
|
34
|
-
Array(value).filter_map { |token| resolve_token(token) }
|
|
35
|
-
end
|
|
21
|
+
module_function
|
|
36
22
|
|
|
37
23
|
# Whether a step input renders as an attachment (its `as:` is a file alias),
|
|
38
24
|
# so its staged token should be resolved for display. Keys off the form's
|
|
39
25
|
# canonical file-input alias set, so the two never drift.
|
|
26
|
+
#
|
|
27
|
+
# Wizard-only: it reads a STEP INPUT's options, which is a shape nothing
|
|
28
|
+
# outside the wizard DSL has.
|
|
40
29
|
def field?(input_options)
|
|
41
30
|
as = input_options&.dig(:options, :as) || input_options&.dig(:as)
|
|
42
|
-
Plutonium::
|
|
31
|
+
Plutonium::Definition::InputAliases.file_input?(as)
|
|
43
32
|
end
|
|
44
33
|
|
|
45
|
-
#
|
|
46
|
-
#
|
|
34
|
+
# The server-side staging backend for wizard attachments: the wizard's own
|
|
35
|
+
# setting, else the global default.
|
|
47
36
|
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# - an uploaded file (IO-like) → uploaded to the backend's cache, returning its
|
|
52
|
-
# token (an AS signed_id, or Shrine cached-file JSON);
|
|
53
|
-
# - blank / no selection → nil (the caller drops the key so the previously
|
|
54
|
-
# staged token survives a Back/re-submit);
|
|
55
|
-
# - an Array (multiple) → each element mapped, blanks dropped.
|
|
37
|
+
# Layered rather than replacing: an app that sets only
|
|
38
|
+
# +config.attachment_backend+ gets it here too, and one that sets
|
|
39
|
+
# +config.wizards.attachment_backend+ still overrides it for wizards alone.
|
|
56
40
|
#
|
|
57
|
-
# @
|
|
58
|
-
# @param uploader [Class, String, nil] a Shrine uploader to cache through
|
|
59
|
-
# (`:shrine` backend only) — its cache-stage plugins (mime/dimension
|
|
60
|
-
# extraction, `generate_location`, validations) run instead of base Shrine's.
|
|
61
|
-
# The minted token stays uploader-agnostic, so display + `execute` promotion
|
|
62
|
-
# are unaffected. Ignored shape for ActiveStorage (raises if given).
|
|
63
|
-
def stage_upload(value, backend: nil, uploader: nil)
|
|
64
|
-
if value.is_a?(Array)
|
|
65
|
-
value.filter_map { |v| stage_upload(v, backend:, uploader:) }.presence
|
|
66
|
-
elsif value.is_a?(String)
|
|
67
|
-
value.presence
|
|
68
|
-
elsif value.respond_to?(:read)
|
|
69
|
-
upload_to_cache(value, backend || attachment_backend, uploader:)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# The default server-side staging backend: the configured one, else
|
|
74
|
-
# auto-detected (active_shrine loaded → Shrine, else ActiveStorage).
|
|
41
|
+
# @return [Symbol]
|
|
75
42
|
def attachment_backend
|
|
76
43
|
Plutonium.configuration.wizards.attachment_backend ||
|
|
77
|
-
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Run the EFFECTIVE Shrine uploader's attacher validations against a staged
|
|
81
|
-
# token (or array of them), returning the validation messages — so a file that
|
|
82
|
-
# violates the uploader's `validate_*` rules is rejected at the STEP (stage
|
|
83
|
-
# phase), not deferred to `execute`'s model assignment.
|
|
84
|
-
#
|
|
85
|
-
# The effective uploader is the field's `uploader:` if given, else base
|
|
86
|
-
# `Shrine` — both of which may carry `Attacher.validate` rules. Returns `[]`
|
|
87
|
-
# when the field isn't Shrine-backed (ActiveStorage has no attacher here), when
|
|
88
|
-
# nothing is staged, or when the effective uploader declares no validations.
|
|
89
|
-
#
|
|
90
|
-
# @param value [String, Array, nil] the staged token(s).
|
|
91
|
-
# @param backend [Symbol, nil] per-field override; nil → the configured default.
|
|
92
|
-
# @param uploader [Class, String, nil] the field's `uploader:` option.
|
|
93
|
-
# @return [Array<String>] validation messages (empty ⇒ valid).
|
|
94
|
-
def validation_errors(value, backend: nil, uploader: nil)
|
|
95
|
-
return [] unless (backend || attachment_backend).to_sym == :shrine
|
|
96
|
-
|
|
97
|
-
klass = shrine_uploader(uploader)
|
|
98
|
-
# Shrine's `validation` plugin is OPTIONAL — without it (or `validation_helpers`)
|
|
99
|
-
# the Attacher has no `#errors` and nothing to enforce. Detect it up front so a
|
|
100
|
-
# plugin-less app is a clean no-op, not a per-step rescued NoMethodError.
|
|
101
|
-
return [] unless klass::Attacher.method_defined?(:errors)
|
|
102
|
-
|
|
103
|
-
Array(value).flat_map { |token| token_validation_errors(klass, token) }
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# Validate one cached token through an uploader's attacher. A broad rescue
|
|
107
|
-
# (like {resolve_token}) — a tampered/expired token shouldn't 500 the step; it
|
|
108
|
-
# surfaces at `execute` instead, where it's caught as a RecordInvalid.
|
|
109
|
-
def token_validation_errors(uploader_class, token)
|
|
110
|
-
return [] if token.blank?
|
|
111
|
-
|
|
112
|
-
attacher = uploader_class::Attacher.new
|
|
113
|
-
attacher.assign(token)
|
|
114
|
-
Array(attacher.errors)
|
|
115
|
-
rescue => e
|
|
116
|
-
Rails.logger.warn { "[Plutonium::Wizard] attachment validation skipped: #{e.class}: #{e.message}" }
|
|
117
|
-
[]
|
|
118
|
-
end
|
|
119
|
-
private_class_method :token_validation_errors
|
|
120
|
-
|
|
121
|
-
# Upload a file to the backend's CACHE and return its re-postable token. The
|
|
122
|
-
# file lives in cache until `execute` assigns the token to a real attachment
|
|
123
|
-
# (which promotes it); an abandoned upload is reaped by the backend's own
|
|
124
|
-
# unattached-cache cleanup.
|
|
125
|
-
def upload_to_cache(file, backend, uploader: nil)
|
|
126
|
-
case backend.to_sym
|
|
127
|
-
when :shrine
|
|
128
|
-
shrine_uploader(uploader).upload(file, :cache).to_json
|
|
129
|
-
when :active_storage
|
|
130
|
-
raise ArgumentError, "input `uploader:` is only supported for the :shrine backend" if uploader
|
|
131
|
-
ActiveStorage::Blob.create_and_upload!(
|
|
132
|
-
io: file, filename: file.original_filename, content_type: file.content_type
|
|
133
|
-
).signed_id
|
|
134
|
-
else
|
|
135
|
-
raise ArgumentError, "unknown wizard attachment backend: #{backend.inspect}"
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
private_class_method :upload_to_cache
|
|
139
|
-
|
|
140
|
-
# Resolve an `uploader:` option to the Shrine uploader class to cache through.
|
|
141
|
-
# nil → base `Shrine`; a class is used as-is; a String/Symbol is constantized.
|
|
142
|
-
# Anything that isn't a Shrine subclass is a configuration error (fail loud).
|
|
143
|
-
def shrine_uploader(uploader)
|
|
144
|
-
return Shrine if uploader.nil?
|
|
145
|
-
|
|
146
|
-
klass = uploader.is_a?(Class) ? uploader : uploader.to_s.safe_constantize
|
|
147
|
-
unless klass.is_a?(Class) && klass <= Shrine
|
|
148
|
-
raise ArgumentError, "input `uploader:` must be a Shrine uploader class, got #{uploader.inspect}"
|
|
149
|
-
end
|
|
150
|
-
klass
|
|
151
|
-
end
|
|
152
|
-
private_class_method :shrine_uploader
|
|
153
|
-
|
|
154
|
-
# Revive one token through whichever backend owns it, wrapped in {Resolved}. A
|
|
155
|
-
# broad rescue is warranted here (unlike elsewhere): the token is arbitrary,
|
|
156
|
-
# user-supplied input reconstituted at a render boundary, and the two backends
|
|
157
|
-
# raise different error classes for a tampered/expired token — none of which
|
|
158
|
-
# should take down the page.
|
|
159
|
-
def resolve_token(token)
|
|
160
|
-
return if token.blank?
|
|
161
|
-
|
|
162
|
-
source = shrine_uploaded_file(token) || active_storage_blob(token)
|
|
163
|
-
source && Resolved.new(source, token)
|
|
164
|
-
rescue => e
|
|
165
|
-
Rails.logger.warn { "[Plutonium::Wizard] could not resolve attachment token: #{e.class}: #{e.message}" }
|
|
166
|
-
nil
|
|
44
|
+
Plutonium::Attachments.default_backend
|
|
167
45
|
end
|
|
168
|
-
private_class_method :resolve_token
|
|
169
|
-
|
|
170
|
-
# A Shrine cached-file token is JSON (`{"id":…,"storage":"cache",…}`); an AS
|
|
171
|
-
# signed_id isn't. Parse-success → Shrine materializes it from the globally
|
|
172
|
-
# registered storages (no model, no per-field uploader needed for a `.url`).
|
|
173
|
-
def shrine_uploaded_file(token)
|
|
174
|
-
return unless defined?(Shrine)
|
|
175
46
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
rescue JSON::ParserError, TypeError
|
|
179
|
-
nil
|
|
180
|
-
end
|
|
181
|
-
return unless data.is_a?(Hash)
|
|
182
|
-
|
|
183
|
-
Shrine.uploaded_file(data)
|
|
184
|
-
end
|
|
185
|
-
private_class_method :shrine_uploaded_file
|
|
47
|
+
# @see Plutonium::Attachments.resolve
|
|
48
|
+
def resolve(value) = Plutonium::Attachments.resolve(value)
|
|
186
49
|
|
|
187
|
-
|
|
188
|
-
|
|
50
|
+
# @see Plutonium::Attachments.stage_upload
|
|
51
|
+
def stage_upload(value, backend: nil, uploader: nil)
|
|
52
|
+
Plutonium::Attachments.stage_upload(value, backend: backend || attachment_backend, uploader:)
|
|
189
53
|
end
|
|
190
|
-
private_class_method :active_storage_blob
|
|
191
|
-
|
|
192
|
-
# A uniform view over a resolved attachment so the review display + the uppy
|
|
193
|
-
# preview don't care whether the source is an ActiveStorage `Blob`
|
|
194
|
-
# (`filename`/`content_type`/`representable?`) or a Shrine `UploadedFile`
|
|
195
|
-
# (`original_filename`/`mime_type`, none of the AS-only methods). Exposes
|
|
196
|
-
# exactly what those components call.
|
|
197
|
-
class Resolved
|
|
198
|
-
# @param source the backend object (AS Blob or Shrine UploadedFile).
|
|
199
|
-
# @param token [String] the ORIGINAL staged token — what the hidden preview
|
|
200
|
-
# field re-posts to preserve the upload across a Back/re-submit, and what
|
|
201
|
-
# `execute` assigns to the model attachment.
|
|
202
|
-
def initialize(source, token)
|
|
203
|
-
@source = source
|
|
204
|
-
@token = token
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# `url` is lazy (called at render, inside a request, where AS url options
|
|
208
|
-
# exist) — never eager at resolve time.
|
|
209
|
-
def url(*args) = @source.url(*args)
|
|
210
|
-
|
|
211
|
-
# The re-postable token, surfaced under the name the uppy input reads.
|
|
212
|
-
def signed_id = @token
|
|
213
|
-
|
|
214
|
-
def filename = (@source.try(:filename) || @source.try(:original_filename)).to_s
|
|
215
54
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def extension = @source.try(:extension).presence || File.extname(filename).delete(".").presence
|
|
221
|
-
|
|
222
|
-
def present? = true
|
|
55
|
+
# @see Plutonium::Attachments.validation_errors
|
|
56
|
+
def validation_errors(value, backend: nil, uploader: nil)
|
|
57
|
+
Plutonium::Attachments.validation_errors(value, backend: backend || attachment_backend, uploader:)
|
|
223
58
|
end
|
|
224
59
|
end
|
|
225
60
|
end
|
|
@@ -205,7 +205,12 @@ module Plutonium
|
|
|
205
205
|
def failed(errors = nil, attribute = :base)
|
|
206
206
|
case errors
|
|
207
207
|
when Hash
|
|
208
|
-
|
|
208
|
+
# A per-attribute value may be a single message or a list of them
|
|
209
|
+
# (`{name: ["is required", "is too short"]}`). `Array.wrap`, not `Array()`
|
|
210
|
+
# — the latter would splat a Hash value into [key, value] pairs.
|
|
211
|
+
errors.each do |attr, error|
|
|
212
|
+
Array.wrap(error).each { |err| self.errors.add(attr, err) }
|
|
213
|
+
end
|
|
209
214
|
else
|
|
210
215
|
Array(errors).each { |error| self.errors.add(attribute, error) }
|
|
211
216
|
end
|
|
@@ -29,8 +29,20 @@ module Plutonium
|
|
|
29
29
|
# arrive as a token and ignore this.
|
|
30
30
|
attr_accessor :attachment_backend
|
|
31
31
|
|
|
32
|
+
# @return [Symbol] width for wizard step pages, one of
|
|
33
|
+
# {Plutonium::UI::PageWidth::VALID_SIZES}.
|
|
34
|
+
#
|
|
35
|
+
# Deliberately INDEPENDENT of `config.default_page_width`. A wizard is
|
|
36
|
+
# its own kind of page — a focused, self-contained flow, not a resource
|
|
37
|
+
# detail page — so an app that widens (or unconstrains) its resource
|
|
38
|
+
# pages should not silently drag its wizards along. The default here
|
|
39
|
+
# happens to match the resource default, but nothing ties them: change
|
|
40
|
+
# one and the other stays put.
|
|
41
|
+
attr_accessor :width
|
|
42
|
+
|
|
32
43
|
# Initialize a new wizard Configuration instance with default values.
|
|
33
44
|
def initialize
|
|
45
|
+
@width = :md
|
|
34
46
|
@enabled = false
|
|
35
47
|
@cleanup_after = 14.days
|
|
36
48
|
@database = :primary
|
|
@@ -63,6 +63,11 @@ module Plutonium
|
|
|
63
63
|
wizard_update
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# DELETE .../:token
|
|
67
|
+
def cancel
|
|
68
|
+
wizard_cancel
|
|
69
|
+
end
|
|
70
|
+
|
|
66
71
|
private
|
|
67
72
|
|
|
68
73
|
# Identity for a standalone wizard host. Defers to the host's own auth
|
|
@@ -157,6 +162,15 @@ module Plutonium
|
|
|
157
162
|
:"#{name}_path"
|
|
158
163
|
end
|
|
159
164
|
end
|
|
165
|
+
|
|
166
|
+
def wizard_launch_url
|
|
167
|
+
url_options = {}
|
|
168
|
+
if scoped_to_entity?
|
|
169
|
+
url_options[scoped_entity_param_key] = params[scoped_entity_param_key]
|
|
170
|
+
end
|
|
171
|
+
helper_name = wizard_step_url_helper.to_s.sub(/_path\z/, "_launch_path").to_sym
|
|
172
|
+
current_engine.routes.url_helpers.public_send(helper_name, **url_options)
|
|
173
|
+
end
|
|
160
174
|
end
|
|
161
175
|
end
|
|
162
176
|
end
|